Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
971d477795 | ||
|
|
f12042f194 | ||
|
|
e7cf336665 | ||
|
|
8ca20de82e | ||
|
|
1fa654cb6a | ||
|
|
c993d9dd0d | ||
|
|
33d2b3ce60 | ||
|
|
c7ce35bd43 | ||
|
|
ad1d71a462 | ||
|
|
03e38d1ca3 | ||
|
|
bded929812 | ||
|
|
3612458e86 | ||
|
|
8d9fad1749 | ||
|
|
d25ed492c9 | ||
|
|
1f9abefc35 | ||
|
|
b634f41a1c | ||
|
|
0f85f588fb | ||
|
|
e5fc99d66c | ||
|
|
8b74803290 | ||
|
|
540639d2c1 | ||
|
|
562871b1ce | ||
|
|
cca3f8bfcd | ||
|
|
89c08be712 | ||
|
|
b4ecf86c13 | ||
|
|
b14fe78306 | ||
|
|
3f0c1038c3 | ||
|
|
1fbefce6df | ||
|
|
63cb68451a | ||
|
|
17cfebbe26 | ||
|
|
379fb930fc | ||
|
|
1bebdeac0f | ||
|
|
f458591132 | ||
|
|
6155539254 | ||
|
|
76e0f1f3b6 | ||
|
|
ba6ce2cdb6 | ||
|
|
8212049f57 | ||
|
|
5814f47659 | ||
|
|
94f5e892c3 | ||
|
|
a3b6467047 | ||
|
|
66db6497ec | ||
|
|
81be17f09f | ||
|
|
4237fb5e79 | ||
|
|
4302138b4f | ||
|
|
3b9b74dae5 | ||
|
|
4021c1f496 | ||
|
|
5f8de584bc | ||
|
|
38de1b3310 | ||
|
|
abfbccc906 | ||
|
|
9d4e74e094 | ||
|
|
db355b759c | ||
|
|
31d77f01ac | ||
|
|
1b0ed281b2 | ||
|
|
9c6580f5c0 | ||
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 | ||
|
|
969570e38b | ||
|
|
b73d646db5 | ||
|
|
8c37ff412c | ||
|
|
06bf359535 | ||
|
|
a4f3415f0f | ||
|
|
c9c9ebe6d4 | ||
|
|
100993445b | ||
|
|
a4f80e7ec1 | ||
|
|
4ad34d3a0a | ||
|
|
c9bae926a5 | ||
|
|
cb3f7e8720 | ||
|
|
00682e6420 | ||
|
|
95cdc3daea | ||
|
|
1d05f2c27a | ||
|
|
b3f16d07a6 | ||
|
|
14d2b37e99 | ||
|
|
f5b255ee68 | ||
|
|
6e8d90fb5f | ||
|
|
66c4b0d375 | ||
|
|
0f74ebfbbe | ||
|
|
ee11863ada | ||
|
|
86052d9552 | ||
|
|
047ef98987 | ||
|
|
c681472e15 | ||
|
|
7c0ba14a00 | ||
|
|
eacd74e1db | ||
|
|
34b68001d1 |
@@ -0,0 +1,31 @@
|
||||
# Blocking incident: Framework LND startup and false zero balance
|
||||
|
||||
Before starting or resuming work in this repository, read
|
||||
`docs/incident-framework-lnd-startup.md` and check its status.
|
||||
|
||||
The user explicitly required this incident to take priority over later work.
|
||||
While its status is OPEN:
|
||||
|
||||
- Surface this blocker at session start, including when the user asks for unrelated work.
|
||||
- Prioritize investigation and repair on the actual Framework node. If access is
|
||||
unavailable, ask for it and continue useful offline investigation; defer unrelated
|
||||
work unless the user explicitly overrides this priority.
|
||||
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
|
||||
does not resolve the incident. Do not mark it fixed until the Framework's startup,
|
||||
Receive flow, and balance behavior are verified on the node, including a controlled
|
||||
reboot with access and recovery arrangements in place.
|
||||
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
|
||||
Never run wallet wipe/recreation as an automatic investigation or recovery step.
|
||||
- Record evidence, changes, validation, and remaining work in the incident document.
|
||||
|
||||
This priority comes from the user's explicit instruction on 2026-09-15. It remains
|
||||
in effect across sessions until the documented acceptance criteria are met or the
|
||||
user explicitly changes it.
|
||||
|
||||
## Unit tests on a live node
|
||||
|
||||
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
|
||||
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
||||
tests still reached real service commands. The runner isolates wallet data,
|
||||
service buses, container storage, networking, and process IDs. Compilation with
|
||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||
@@ -2,6 +2,63 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.8.21-alpha (2026-09-30)
|
||||
|
||||
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
|
||||
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||
|
||||
## v1.8.20-alpha (2026-09-29)
|
||||
|
||||
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
|
||||
- Improved saving paid files into Files and reopening purchases without paying again.
|
||||
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
|
||||
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
|
||||
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
|
||||
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
|
||||
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
|
||||
|
||||
## v1.8.19-alpha (2026-09-28)
|
||||
|
||||
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||
- Embedded AIUI now stays transparent so the dashboard background appears once.
|
||||
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
|
||||
|
||||
## v1.8.18-alpha (2026-09-18)
|
||||
|
||||
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
|
||||
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
|
||||
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
|
||||
|
||||
## v1.8.17-alpha (2026-09-15)
|
||||
|
||||
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
||||
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
|
||||
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
|
||||
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
|
||||
|
||||
## v1.8.16-alpha (2026-09-15)
|
||||
|
||||
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
|
||||
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
|
||||
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
|
||||
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
|
||||
|
||||
## v1.8.15-alpha (2026-09-13)
|
||||
|
||||
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
|
||||
- Added regression coverage for Cuprate install and installed-state grouping.
|
||||
- Release validation was rerun on the corrected tree before OTA and ISO publication.
|
||||
|
||||
## v1.8.14-alpha (2026-09-13)
|
||||
|
||||
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
|
||||
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
|
||||
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
|
||||
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
|
||||
|
||||
## v1.8.13-alpha (2026-09-12)
|
||||
|
||||
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
: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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.1
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
@@ -45,7 +45,9 @@ app:
|
||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /
|
||||
# Probe the backend through nginx: a static page can be healthy while
|
||||
# every API/WebSocket request is stuck on a dead backend address.
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -54,7 +54,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
@@ -60,7 +60,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
app:
|
||||
id: cuprate-ui
|
||||
name: Cuprate UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
|
||||
inside a container, serves a static status dashboard, and proxies
|
||||
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
|
||||
published host port for the container's 18089). No credentials are
|
||||
injected — the restricted RPC is Monero's own safe-for-public subset — so
|
||||
the nginx.conf is baked into the image and there is no rendered-config
|
||||
bind-mount like bitcoin-ui's.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/cuprate-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/cuprate-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: cuprate
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 18091 directly on the host IP.
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate would have no
|
||||
# idea the port existed: neither protected nor listed as unprotected.
|
||||
ports:
|
||||
- host: 18091
|
||||
container: 18091
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:18091
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
category: money
|
||||
tier: optional
|
||||
author: Archipelago
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
@@ -36,12 +36,26 @@ app:
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
||||
# `pruning` crate exists in the workspace but nothing in this config
|
||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
||||
# headroom rather than assuming pruning is available.
|
||||
- storage: 300Gi
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
|
||||
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
|
||||
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
|
||||
# crate in its workspace is Monero's p2p *protocol* pruning, not a
|
||||
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
|
||||
# when disk is scarce (see the DISK_GB branch in
|
||||
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
|
||||
# syncs until the filesystem fills and takes Archipelago down. The
|
||||
# disk-scarce equivalent is enforced in Rust instead: install, start,
|
||||
# restart and update refuse, and boot reconcile skips, on any node under
|
||||
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
|
||||
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
|
||||
# replace that gate with the bitcoin-style entrypoint branch.
|
||||
#
|
||||
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
|
||||
# (store size display, install pre-checks, docs) must show the number the
|
||||
# Rust gate actually enforces, or a user provisioned to the displayed
|
||||
# value gets refused at a different, unexplained one. Single source of
|
||||
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
|
||||
- storage: 450Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
@@ -51,7 +65,9 @@ app:
|
||||
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||
# headroom above the 8GiB cache for the process itself.
|
||||
memory_limit: 10Gi
|
||||
disk_limit: 300Gi
|
||||
# Matches the storage dependency above (= the enforced disk floor),
|
||||
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
|
||||
disk_limit: 450Gi
|
||||
|
||||
security:
|
||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: mempool
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
+2
-2
@@ -67,13 +67,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.8.21-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.8.21-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -162,11 +162,33 @@ 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. This request did not redeem an ecash payment."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header("Content-Range", format!("bytes */{total}"))
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap()),
|
||||
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"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
if suffix == "/archy-status" {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(
|
||||
rpc.handle_lnd_readiness().await.to_string(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
|
||||
@@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||
/// recovered amount, or explicitly say when a refund could not be confirmed.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
@@ -32,14 +32,118 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
Ok(sats) => {
|
||||
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||
format!("Refunded {sats} sats to your wallet.")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: refund not confirmed: {e}");
|
||||
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only pass through the peer's bounded, printable explanation; refund status
|
||||
/// is always determined locally and must never come from the peer's wording.
|
||||
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||
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_owned));
|
||||
match reason {
|
||||
Some(reason) if !reason.trim().is_empty() => {
|
||||
let clean: String = reason
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(240)
|
||||
.collect();
|
||||
format!("Seller response ({status}): {clean}")
|
||||
}
|
||||
_ => format!("Peer returned an error ({status})."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep first purchases and cached repeats compatible with both existing clients.
|
||||
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
|
||||
use base64::Engine;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
serde_json::json!({
|
||||
"data": data, "data_base64": data,
|
||||
"size": bytes.len(), "size_bytes": bytes.len(),
|
||||
"mime_type": mime, "paid_sats": paid_sats, "owned": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated
|
||||
/// API rather than writing host paths with the backend's unrelated UID. Its
|
||||
/// override=false upload atomically refuses existing names, including races.
|
||||
async fn file_purchase_in_files(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let folder = if mime.starts_with("image/") || mime.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let mut folder_url = reqwest::Url::parse(base_url)?;
|
||||
folder_url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid Files URL"))?
|
||||
.extend(["api", "resources", folder, ""]);
|
||||
let response = client
|
||||
.get(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let response = client
|
||||
.post(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() != reqwest::StatusCode::CONFLICT {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
} else {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
let base = std::path::Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("download");
|
||||
let (stem, extension) = match base.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
|
||||
_ => (base, String::new()),
|
||||
};
|
||||
for attempt in 1..=100 {
|
||||
let name = if attempt == 1 {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{stem} ({attempt}){extension}")
|
||||
};
|
||||
let mut url = folder_url.clone();
|
||||
url.path_segments_mut().unwrap().pop_if_empty().push(&name);
|
||||
url.query_pairs_mut().append_pair("override", "false");
|
||||
let response = client
|
||||
.post(url)
|
||||
.header("X-Auth", token)
|
||||
.body(bytes.to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::CONFLICT {
|
||||
continue;
|
||||
}
|
||||
response.error_for_status()?;
|
||||
return Ok(format!("{folder}/{name}"));
|
||||
}
|
||||
anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache")
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -463,17 +567,10 @@ impl RpcHandler {
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||
result["already_owned"] = serde_json::json!(true);
|
||||
result["filename"] = serde_json::json!(o.filename);
|
||||
return Ok(result);
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
@@ -545,16 +642,14 @@ impl RpcHandler {
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
// A bearer token must not be replayed after an ambiguous delivery.
|
||||
// A transport error can mean the seller received it without replying.
|
||||
let (response, transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
@@ -564,9 +659,10 @@ impl RpcHandler {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
"error": format!("The purchase could not be completed. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -583,25 +679,17 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
// per backend — for ecash both sides must share a redemption network
|
||||
// (a Cashu mint, or a Fedimint federation).
|
||||
// A 402 can mean mint validation, network failure, underpayment,
|
||||
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
};
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
)
|
||||
"error": format!("The seller could not verify the payment. {refund}")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -609,9 +697,9 @@ impl RpcHandler {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||
"error": format!("{} {refund}", seller_error_message(status, &body))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -625,10 +713,17 @@ impl RpcHandler {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
let bytes = match response.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
tracing::warn!("paid download: response body failed: {error}");
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||
@@ -658,63 +753,41 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed = async {
|
||||
let auth = self.handle_filebrowser_token().await?;
|
||||
let token = auth
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("FileBrowser omitted its authentication token")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
file_purchase_in_files(
|
||||
&client,
|
||||
"http://127.0.0.1:8083",
|
||||
token,
|
||||
&filename,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
.await;
|
||||
match filed {
|
||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||
Err(error) => tracing::warn!(
|
||||
"paid download: optional Files copy failed; purchase cache retained: {error}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"paid_sats": price_sats,
|
||||
"ecash_backend": used_backend,
|
||||
"mime_type": mime_type,
|
||||
"owned": true,
|
||||
}))
|
||||
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
|
||||
result["ecash_backend"] = serde_json::json!(used_backend);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||
@@ -1387,3 +1460,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::*;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Response, Server,
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
struct FilesApi {
|
||||
url: String,
|
||||
seen: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for FilesApi {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
fn files_api(statuses: Vec<u16>) -> FilesApi {
|
||||
let statuses = Arc::new(Mutex::new(VecDeque::from(statuses)));
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let history = seen.clone();
|
||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||
let address = server.local_addr();
|
||||
let service = make_service_fn(move |_| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = history.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = seen.clone();
|
||||
async move {
|
||||
assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session");
|
||||
let method = request.method().to_string();
|
||||
let uri = request.uri().to_string();
|
||||
let body = hyper::body::to_bytes(request.into_body())
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
seen.lock().unwrap().push((method, uri, body));
|
||||
let status = statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected extra Files request");
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
FilesApi {
|
||||
url: format!("http://{address}"),
|
||||
seen,
|
||||
task: tokio::spawn(async move {
|
||||
server.serve(service).await.unwrap();
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
use base64::Engine;
|
||||
for paid in [0, 1] {
|
||||
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
|
||||
assert_eq!(response["data"], response["data_base64"]);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(response["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
[0, 255, 123]
|
||||
);
|
||||
assert_eq!(response["size"], 3);
|
||||
assert_eq!(response["size_bytes"], 3);
|
||||
assert_eq!(response["paid_sats"], paid);
|
||||
assert_eq!(response["owned"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_uses_authenticated_api_and_preserves_existing_names() {
|
||||
let api = files_api(vec![200, 409, 200]);
|
||||
let client = reqwest::Client::new();
|
||||
let path = file_purchase_in_files(
|
||||
&client,
|
||||
&api.url,
|
||||
"test-session",
|
||||
"../my #file?.txt",
|
||||
"text/plain",
|
||||
b"paid bytes",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, "Documents/my #file? (2).txt");
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[0].0, "GET");
|
||||
assert_eq!(seen[0].1, "/api/resources/Documents/");
|
||||
assert_eq!(seen.len(), 3);
|
||||
for (_, uri, body) in &seen[1..] {
|
||||
assert!(uri.contains("override=false"));
|
||||
assert!(uri.contains("%23file%3F"));
|
||||
assert!(!uri.contains("../"));
|
||||
assert_eq!(body, b"paid bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_creates_missing_media_folder() {
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/ogg", "Music"),
|
||||
] {
|
||||
let api = files_api(vec![404, 200, 200]);
|
||||
let path = file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file",
|
||||
mime,
|
||||
b"bytes",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, format!("{folder}/file"));
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[1].0, "POST");
|
||||
assert!(seen[1].1.ends_with('/'));
|
||||
assert!(seen[1].2.is_empty());
|
||||
assert_eq!(seen[2].2, b"bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
|
||||
for statuses in [
|
||||
vec![401],
|
||||
vec![503],
|
||||
vec![404, 500],
|
||||
vec![200, 507],
|
||||
vec![200, 403],
|
||||
] {
|
||||
let expected = statuses.len();
|
||||
let api = files_api(statuses);
|
||||
assert!(file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file.txt",
|
||||
"text/plain",
|
||||
b"bytes"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(api.seen.lock().unwrap().len(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
|
||||
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
|
||||
assert!(message.starts_with("Seller response (503"));
|
||||
assert!(message.ends_with("Cannot read file"));
|
||||
assert!(!message.contains('\n') && !message.contains('\0'));
|
||||
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
|
||||
assert!(seller_error_message(status, &body).chars().count() < 300);
|
||||
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
|
||||
assert_eq!(
|
||||
seller_error_message(status, body),
|
||||
"Peer returned an error (503 Service Unavailable)."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
|
||||
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
macaroon_hex: &str,
|
||||
) -> Result<T> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Grpc-Metadata-macaroon", macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND is unavailable; balance could not be checked")?
|
||||
.error_for_status()
|
||||
.context("LND is not ready; balance could not be checked")?
|
||||
.json()
|
||||
.await
|
||||
.context("LND returned invalid wallet data")
|
||||
}
|
||||
|
||||
fn checked_balances(
|
||||
wallet: LndBalanceResponse,
|
||||
channels: LndChannelBalanceResponse,
|
||||
) -> Result<(i64, i64, i64)> {
|
||||
fn sats(value: Option<String>) -> Result<i64> {
|
||||
let value = value.context("LND omitted a balance; balance is unavailable")?;
|
||||
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
|
||||
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
|
||||
Ok(amount)
|
||||
}
|
||||
Ok((
|
||||
sats(wallet.total_balance)?,
|
||||
sats(channels.local_balance.and_then(|a| a.sat))?,
|
||||
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn bitcoin_wait_state(
|
||||
installed: bool,
|
||||
running: bool,
|
||||
fresh: bool,
|
||||
ibd: Option<bool>,
|
||||
) -> (&'static str, &'static str) {
|
||||
if !installed {
|
||||
("waiting_install", "Waiting for Bitcoin to be installed")
|
||||
} else if !running {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if !fresh || ibd.is_none() {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if ibd == Some(true) {
|
||||
("waiting_sync", "Waiting for Bitcoin to sync")
|
||||
} else {
|
||||
("bitcoin_ready", "Bitcoin is ready")
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if !data.server_info.status_info.containers_scanned {
|
||||
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
|
||||
}
|
||||
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
|
||||
.iter()
|
||||
.filter_map(|id| data.package_data.get(*id))
|
||||
.collect();
|
||||
let installed = !nodes.is_empty();
|
||||
let running = nodes
|
||||
.iter()
|
||||
.any(|p| p.state == crate::data_model::PackageState::Running);
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let ibd = bitcoin
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool());
|
||||
let (state, message) =
|
||||
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
|
||||
serde_json::json!({"state": state, "message": message})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
@@ -85,45 +164,26 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||
checked_balances(wallet_balance, channel_balance)?;
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
@@ -135,18 +195,9 @@ impl RpcHandler {
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
balance_sats,
|
||||
channel_balance_sats,
|
||||
pending_open_balance,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
@@ -268,6 +319,76 @@ impl RpcHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unavailable_balances_are_not_zero() {
|
||||
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
|
||||
assert!(checked_balances(
|
||||
serde_json::from_str(body).unwrap(),
|
||||
serde_json::from_str(body).unwrap(),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
for value in ["bad", "-1", "9223372036854775808"] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(value.into()),
|
||||
};
|
||||
let channels = serde_json::from_str(
|
||||
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(checked_balances(wallet, channels).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_zero_and_nonzero_balances_survive() {
|
||||
for expected in [0, 42] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(expected.to_string()),
|
||||
};
|
||||
let channels = serde_json::from_value(serde_json::json!({
|
||||
"local_balance":{"sat":expected.to_string()},
|
||||
"pending_open_local_balance":{"sat":"0"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
checked_balances(wallet, channels).unwrap(),
|
||||
(expected, expected, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_wallet_http_response_is_not_successful_getinfo() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0; 2048];
|
||||
stream.read(&mut buf).await.unwrap();
|
||||
let body =
|
||||
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
).as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(get_lnd_json::<LndGetInfoResponse>(
|
||||
&client,
|
||||
&format!("http://{addr}/v1/getinfo"),
|
||||
"test"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
@@ -341,3 +462,44 @@ mod tests {
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dependency_readiness_tests {
|
||||
use super::bitcoin_wait_state;
|
||||
#[test]
|
||||
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(false, false, false, None).0,
|
||||
"waiting_install"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, false, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(true)).0,
|
||||
"waiting_sync"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
// Previously synced cached information must not hide a current outage.
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, Some(false)).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
|
||||
status.ok
|
||||
&& !status.stale
|
||||
&& status.age_ms < 30_000
|
||||
&& status
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
== Some(false)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
let mut last_height: Option<u64> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
// Initial Bitcoin sync, warmup, and outages are dependencies to
|
||||
// wait for, never evidence that LND is wedged. Do not accumulate
|
||||
// restart pressure during a days-long initial block download.
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|
||||
|| crate::app_ops::lifecycle_op_in_flight("lnd")
|
||||
{
|
||||
bad_minutes = 0;
|
||||
last_height = None;
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
let height = info.get("block_height").and_then(|v| v.as_u64());
|
||||
let progressing = height
|
||||
.zip(last_height)
|
||||
.is_some_and(|(now, before)| now > before);
|
||||
last_height = height;
|
||||
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -239,3 +272,31 @@ impl RpcHandler {
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watchdog_dependency_tests {
|
||||
use super::bitcoin_ready_for_lnd_watchdog;
|
||||
use crate::bitcoin_status::BitcoinNodeStatus;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
|
||||
let mut status = BitcoinNodeStatus::default();
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":true}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":false}));
|
||||
assert!(bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = true;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = false;
|
||||
status.ok = false;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.age_ms = 30_000;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.age_ms = 0;
|
||||
status.blockchain_info = Some(json!({}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ impl RpcHandler {
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle (prevents double-click
|
||||
// queuing two installs on the same package).
|
||||
@@ -294,6 +295,12 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
// Update is stop → pull → remove → recreate, i.e. a fresh start by
|
||||
// another name: on a disk that shrank since install it would resume
|
||||
// cuprate's unprunable sync unchecked. Same gate as install and
|
||||
// start, run BEFORE the Updating flip so a refusal leaves the app
|
||||
// cleanly in its previous state.
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
@@ -323,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
|
||||
@@ -338,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;
|
||||
}
|
||||
|
||||
@@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 {
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
|
||||
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
|
||||
/// shared with the boot reconciler so install/start and boot can never
|
||||
/// disagree about where cuprate may run.
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
|
||||
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
|
||||
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
|
||||
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
|
||||
/// to run cuprate at all rather than let it sync until the filesystem fills —
|
||||
/// which took Archipelago itself down on nodes with too little disk.
|
||||
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
|
||||
format!(
|
||||
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
|
||||
A Monero node cannot run pruned — upstream cuprate has no pruning \
|
||||
support — so the chain (~250 GB and growing) would fill the disk and \
|
||||
take Archipelago down with it. Attach a larger disk (or move \
|
||||
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
|
||||
pruned on smaller disks; Monero currently cannot.",
|
||||
CUPRATE_MIN_DISK_GB, disk_gb
|
||||
)
|
||||
}
|
||||
|
||||
/// Pure decision half of the cuprate disk gate — testable without df.
|
||||
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<String> {
|
||||
(disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb))
|
||||
}
|
||||
|
||||
/// Install/start-time pre-check: refuse cuprate on disks too small to hold
|
||||
/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s
|
||||
/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX
|
||||
/// when df fails, so an unreadable disk never blocks an install).
|
||||
pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> {
|
||||
if package_id != "cuprate" {
|
||||
return Ok(());
|
||||
}
|
||||
let disk_gb = detect_disk_gb().await;
|
||||
if let Some(message) = cuprate_disk_gate(disk_gb) {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
@@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin,
|
||||
bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
|
||||
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
|
||||
};
|
||||
use archipelago_container::Dependency;
|
||||
|
||||
@@ -1017,6 +1061,37 @@ mod tests {
|
||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
|
||||
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
|
||||
assert!(cuprate_disk_gate(0).is_some());
|
||||
assert!(cuprate_disk_gate(250).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
|
||||
assert!(cuprate_disk_gate(1000).is_none());
|
||||
// df failure reads as u64::MAX — an unreadable disk must not block.
|
||||
assert!(cuprate_disk_gate(u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
|
||||
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
|
||||
assert!(msg.contains("cannot run pruned"), "{msg}");
|
||||
assert!(msg.contains("larger disk"), "{msg}");
|
||||
assert!(msg.contains("250 GB"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cuprate_disk_gate_only_applies_to_cuprate() {
|
||||
// Every other package passes regardless of disk — including the
|
||||
// bitcoin apps, which self-prune via their manifest entrypoint.
|
||||
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
super::check_cuprate_disk_compatibility(package_id)
|
||||
.await
|
||||
.expect("non-cuprate installs must not be gated here");
|
||||
}
|
||||
}
|
||||
|
||||
mod dep_wait {
|
||||
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
@@ -3,10 +3,10 @@ use super::config::{
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
||||
DEP_WAIT_MAX_ATTEMPTS,
|
||||
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
|
||||
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
|
||||
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
|
||||
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -326,6 +326,10 @@ impl RpcHandler {
|
||||
// an older version pins it so install_fresh resolves that image and the
|
||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
if let Some(value) = params.get("prune") {
|
||||
let prune = value.as_bool().context("prune must be a boolean")?;
|
||||
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
|
||||
}
|
||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||
persist_install_version_selection(package_id, version).await;
|
||||
}
|
||||
@@ -374,6 +378,7 @@ impl RpcHandler {
|
||||
// failing instantly.
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
check_cuprate_disk_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
|
||||
@@ -60,6 +60,12 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// A cuprate node that starts on a too-small disk fills it and takes
|
||||
// Archipelago down with it (no upstream pruning — see
|
||||
// dependencies::check_cuprate_disk_compatibility). Fail the start
|
||||
// before clearing user-stopped or flipping state, so the app stays
|
||||
// cleanly stopped and the error carries the actionable message.
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
@@ -251,6 +257,11 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// Restart is stop + recreate, so on a disk that shrank below the cuprate
|
||||
// minimum after install it resumes the doomed unprunable sync just like
|
||||
// start would — same gate, same "fail before clearing user-stopped /
|
||||
// flipping state" contract (see handle_package_start).
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let single_orchestrator_app =
|
||||
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
||||
|
||||
@@ -153,8 +153,18 @@ impl RpcHandler {
|
||||
let default = app_catalog::catalog_default_version(app_id);
|
||||
let cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
Some(
|
||||
crate::settings::bitcoin_storage::load(&self.config.data_dir)
|
||||
.await?
|
||||
.prune,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"bitcoinPrune": bitcoin_prune,
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
|
||||
@@ -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,7 +663,10 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
// 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"
|
||||
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
"mysql-mempool" => push("archy-mempool-db"),
|
||||
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
|
||||
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
|
||||
"fedimintd" => push("fedimint"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
|
||||
verify_update_targets,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
|
||||
let installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let stale = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&stale, &installed).is_err());
|
||||
let current = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &installed).unwrap());
|
||||
let legacy = vec![(
|
||||
"mempool-web".into(),
|
||||
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &legacy).unwrap());
|
||||
let newer = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&newer, &installed).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_update_checks_backend_even_when_frontend_matches() {
|
||||
let installed = vec![
|
||||
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
|
||||
];
|
||||
let mut targets = vec![
|
||||
(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/team/web:3.3.1-archy1".into(),
|
||||
),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
|
||||
];
|
||||
assert!(update_targets_need_change(&targets, &installed).unwrap());
|
||||
targets[0].1 = "r.test/team/web:3.3.1".into();
|
||||
assert!(update_targets_need_change(&targets, &installed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_completion_requires_the_target_version_to_be_installed() {
|
||||
let targets = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let mut installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(verify_update_targets(&targets, &installed).is_err());
|
||||
assert!(verify_update_targets(&targets, &[]).is_err());
|
||||
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
|
||||
assert!(verify_update_targets(&targets, &installed).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
for app in [
|
||||
"immich",
|
||||
"penpot",
|
||||
"indeedhub",
|
||||
"mempool",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
|
||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim()
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
let state = if lower.contains("loading block index") {
|
||||
Some("loading its block index. This can take a while after installation or restart")
|
||||
} else if lower.contains("replaying blocks") {
|
||||
Some("checking saved blocks before startup completes")
|
||||
} else if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
@@ -340,3 +344,21 @@ mod tests {
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_message_tests {
|
||||
#[test]
|
||||
fn loading_block_index_is_explained_without_rpc_error_dump() {
|
||||
for cached in [false, true] {
|
||||
let message = super::friendly_transient_error(
|
||||
cached,
|
||||
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
|
||||
);
|
||||
assert!(message.contains("loading its block index"));
|
||||
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
|
||||
assert!(!message.contains(raw));
|
||||
}
|
||||
assert_eq!(message.contains("last known state"), cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
||||
|
||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node may be installed, started,
|
||||
/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk
|
||||
/// pruning (verified against upstream `cuprated/src/config.rs` — the
|
||||
/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin
|
||||
/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB
|
||||
/// Monero chain simply does not fit and running it would fill the filesystem
|
||||
/// and take Archipelago down. 450 = chain + growth/headroom: allows
|
||||
/// 500 GB-class disks, refuses the 250 GB VPS class.
|
||||
///
|
||||
/// SINGLE SOURCE OF TRUTH — the RPC gates
|
||||
/// (`api::rpc::package::dependencies`) and the boot reconciler
|
||||
/// (`container::prod_orchestrator`) both read this; a drift between them
|
||||
/// would silently reopen the disk-fill failure the gate exists to close.
|
||||
/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned.
|
||||
pub const CUPRATE_MIN_DISK_GB: u64 = 450;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
|
||||
//! | cuprate | archy-cuprate-ui | Monero node status UI |
|
||||
//!
|
||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||
@@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
"lnd" => LND_UI,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||
"fedimint" | "fedimintd" => FEDIMINT_UI,
|
||||
"cuprate" => CUPRATE_UI,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
/// Every companion this build knows how to provision. Kept beside
|
||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||
/// will not recognise it as one of ours and will leave it running forever.
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI];
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] =
|
||||
&[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI];
|
||||
|
||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-bitcoin-ui",
|
||||
@@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-cuprate-ui",
|
||||
image_base: "cuprate-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/cuprate-ui",
|
||||
"/home/archipelago/archy/docker/cuprate-ui",
|
||||
"/home/archipelago/Projects/archy/docker/cuprate-ui",
|
||||
],
|
||||
// No pre-start hook and no bind mounts: unlike bitcoin-ui there is no
|
||||
// secret to inject. Cuprate's restricted RPC (the only thing this UI
|
||||
// proxies) is unauthenticated by design — Monero's safe-for-public
|
||||
// subset — so the nginx.conf is baked into the image.
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||
Box::pin(async {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
@@ -292,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let mut local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
@@ -301,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// `:local` is a deliberate manual override — never auto-rebuild it.
|
||||
// Older installers and self-update create :local themselves. It
|
||||
// must receive source updates too; treating it as a permanent
|
||||
// manual override silently kept the old LND UI after an OTA.
|
||||
if image_exists(&local_image_compat).await {
|
||||
return Ok(local_image_compat);
|
||||
local_image = local_image_compat.clone();
|
||||
}
|
||||
// Reuse the auto-built `:latest` only when the build context has NOT
|
||||
// Reuse either local tag only when the build context has NOT
|
||||
// changed since it was built. Without this staleness check an
|
||||
// already-present image is reused forever, so edits to the baked-in
|
||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||
@@ -828,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
if !matches_known_shape {
|
||||
return Ok(true);
|
||||
}
|
||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||
if let Some(image) = managed_local_image(spec, &on_disk) {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Conservative on any timeout/error inside: reuse the cache.
|
||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||
return Ok(context_is_newer_than_image(dir, &image).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option<String> {
|
||||
["latest", "local"]
|
||||
.iter()
|
||||
.map(|tag| format!("localhost/{}:{tag}", spec.image_base))
|
||||
.find(|image| build_unit(spec, image).render() == unit)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
|
||||
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
|
||||
for tag in ["local", "latest"] {
|
||||
let image = format!("localhost/{}:{tag}", spec.image_base);
|
||||
let unit = build_unit(spec, &image).render();
|
||||
assert_eq!(managed_local_image(spec, &unit), Some(image));
|
||||
}
|
||||
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
assert_eq!(
|
||||
managed_local_image(spec, &build_unit(spec, ®istry).render()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
||||
@@ -869,6 +915,7 @@ mod tests {
|
||||
"mempool-electrs",
|
||||
"fedimint",
|
||||
"fedimintd",
|
||||
"cuprate",
|
||||
];
|
||||
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
|
||||
.iter()
|
||||
@@ -893,6 +940,7 @@ mod tests {
|
||||
names(&orphan_companions(&[])),
|
||||
vec![
|
||||
"archy-bitcoin-ui",
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
@@ -906,7 +954,10 @@ mod tests {
|
||||
// electrumx installed, fedimint and lnd not — yet all four companions
|
||||
// were running because the reconciler was fed the manifest list.
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
|
||||
assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]);
|
||||
assert_eq!(
|
||||
names(&orphans),
|
||||
vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -926,12 +977,18 @@ mod tests {
|
||||
#[test]
|
||||
fn apps_without_companions_orphan_everything_and_panic_nothing() {
|
||||
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
|
||||
assert_eq!(orphans.len(), 4);
|
||||
assert_eq!(orphans.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_backend_installed_leaves_no_orphans() {
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"]));
|
||||
let orphans = orphan_companions(&ids(&[
|
||||
"bitcoin-knots",
|
||||
"lnd",
|
||||
"electrumx",
|
||||
"fedimint",
|
||||
"cuprate",
|
||||
]));
|
||||
assert!(
|
||||
names(&orphans).is_empty(),
|
||||
"unexpected orphans: {:?}",
|
||||
@@ -970,7 +1027,12 @@ mod tests {
|
||||
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
|
||||
assert_eq!(
|
||||
names(&due),
|
||||
vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
vec![
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1024,6 +1086,7 @@ mod tests {
|
||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||
assert_eq!(companions_for("fedimint").len(), 1);
|
||||
assert_eq!(companions_for("fedimintd").len(), 1);
|
||||
assert_eq!(companions_for("cuprate").len(), 1);
|
||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||
|
||||
// Match VAR="value" or VAR=value
|
||||
if let Some((key, val)) = parse_assignment(line) {
|
||||
// Read a self-default assignment without evaluating shell code.
|
||||
let default_prefix = format!("${{{key}:-");
|
||||
let val = val
|
||||
.strip_prefix(&default_prefix)
|
||||
.and_then(|v| v.strip_suffix('}'))
|
||||
.unwrap_or(val);
|
||||
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||
if key == "ARCHY_REGISTRY" {
|
||||
registry = expanded.clone();
|
||||
@@ -146,6 +152,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
|
||||
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
|
||||
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
|
||||
"cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"),
|
||||
|
||||
// Mempool stack (primary = web)
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||
@@ -204,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;
|
||||
}
|
||||
}
|
||||
|
||||
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`),
|
||||
@@ -422,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#"
|
||||
@@ -444,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"));
|
||||
|
||||
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
|
||||
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
|
||||
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
|
||||
async fn bitcoin_rpc_ready() -> bool {
|
||||
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let client = match reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let response = client.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(user, Some(password))
|
||||
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
|
||||
.send().await;
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.is_ok_and(|value| bitcoin_readiness_response(&value)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
|
||||
value.get("error").is_none_or(|e| e.is_null())
|
||||
&& value
|
||||
.pointer("/result/blocks")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some()
|
||||
&& value
|
||||
.pointer("/result/initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
// GetInfo can wait for Bitcoin sync even though the wallet is already
|
||||
// unlocked. State RPC stays available during that normal startup phase.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
|
||||
return Ok(());
|
||||
}
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||
const LND_ADMIN_MACAROON: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -366,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||
// a wrong password still exits on the first pass via `all_rejected`.
|
||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
@@ -390,14 +341,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet WITHOUT the destructive fallback.
|
||||
///
|
||||
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
|
||||
/// password can open — correct for a boot path that must self-heal, and exactly
|
||||
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
|
||||
/// still wants. Rotation calls this instead, so there is no code path from
|
||||
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
|
||||
/// as an error the caller reports, never as a wipe.
|
||||
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
match unlock_existing_wallet().await? {
|
||||
true => Ok(()),
|
||||
@@ -408,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_is_unlocked(state: Option<&str>) -> bool {
|
||||
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
@@ -538,7 +487,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,3 +1152,44 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_readiness_tests {
|
||||
use super::bitcoin_readiness_response;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
|
||||
for response in [
|
||||
json!({}),
|
||||
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
|
||||
json!({"result":{"blocks":null}}),
|
||||
] {
|
||||
assert!(!bitcoin_readiness_response(&response));
|
||||
}
|
||||
// Initial sync is supported by LND. Loading the database is not.
|
||||
for ibd in [true, false] {
|
||||
assert!(bitcoin_readiness_response(
|
||||
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod syncing_wallet_state_tests {
|
||||
#[test]
|
||||
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
|
||||
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
|
||||
assert!(super::wallet_is_unlocked(Some(state)));
|
||||
}
|
||||
for state in [
|
||||
None,
|
||||
Some("LOCKED"),
|
||||
Some("NON_EXISTING"),
|
||||
Some("WAITING_TO_START"),
|
||||
Some("unknown"),
|
||||
] {
|
||||
assert!(!super::wallet_is_unlocked(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
|
||||
///
|
||||
/// Keep in sync with the running fixture on .116. Centralized as a constant
|
||||
/// so the rule is visible in one place and unit-testable.
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one
|
||||
// value shared with the install/start/restart/update RPC gates so boot
|
||||
// reconcile can never resume below the line they refuse at.
|
||||
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool {
|
||||
app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB
|
||||
}
|
||||
|
||||
/// Apps expected to exist from first boot on every node — the ONLY apps the
|
||||
/// boot reconciler may install from nothing. Every other app needs
|
||||
@@ -789,6 +798,10 @@ fn host_port_bindings_drifted(
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
// Unit tests inject a runtime; they must not restart the host Podman API.
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if podman_socket_accepts_connections(socket_path).await {
|
||||
return Ok(());
|
||||
@@ -1161,15 +1174,21 @@ impl ReconcileReport {
|
||||
fn cascade_pairs_for_report<'r>(
|
||||
report: &'r ReconcileReport,
|
||||
user_stopped: &std::collections::HashSet<String>,
|
||||
changed_backends: &HashSet<String>,
|
||||
) -> Vec<(&'r str, &'static str)> {
|
||||
let mut pairs = Vec::new();
|
||||
for (backend, action) in &report.actions {
|
||||
if !matches!(
|
||||
action,
|
||||
ReconcileAction::Installed | ReconcileAction::Started
|
||||
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// A successful systemctl start can be a no-op after a transient
|
||||
// Podman inspect failure. Require a witnessed lifecycle change.
|
||||
if !changed_backends.contains(backend) {
|
||||
continue;
|
||||
}
|
||||
for dep in crate::app_ops::address_caching_dependents(backend) {
|
||||
let dep_untouched = report
|
||||
.actions
|
||||
@@ -1183,6 +1202,25 @@ fn cascade_pairs_for_report<'r>(
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Only positive runtime evidence permits disrupting an address-caching wallet.
|
||||
/// A known absent/stopped backend becoming running, a new container ID, or a
|
||||
/// changed start timestamp qualifies. A failed observation never does.
|
||||
fn backend_instance_changed(before: Option<&ContainerStatus>, after: &ContainerStatus) -> bool {
|
||||
if after.state != ContainerState::Running || after.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(before) = before else {
|
||||
return true;
|
||||
};
|
||||
if before.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if before.id != after.id || before.state != ContainerState::Running {
|
||||
return true;
|
||||
}
|
||||
matches!((&before.started_at, &after.started_at), (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() && a != b)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AdoptionReport {
|
||||
pub adopted: Vec<String>,
|
||||
@@ -1855,7 +1893,7 @@ impl ProdContainerOrchestrator {
|
||||
// Durable installation record, consulted alongside the perishable
|
||||
// `was_running` snapshot for desired-state recovery below.
|
||||
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
||||
let (manifests, container_name_by_app_id): (
|
||||
let (mut manifests, container_name_by_app_id): (
|
||||
Vec<LoadedManifest>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = {
|
||||
@@ -1886,15 +1924,50 @@ impl ProdContainerOrchestrator {
|
||||
.collect();
|
||||
(filtered, names)
|
||||
};
|
||||
// Wallet readiness must not wait behind unrelated image pulls/builds.
|
||||
// A running LND container can still be locked after boot; its post-start
|
||||
// hook must run promptly. Reconcile Bitcoin first, then LND, before the
|
||||
// rest of the catalog. Each app still honors stopped/uninstalled markers.
|
||||
manifests.sort_by_key(|lm| match lm.manifest.app.id.as_str() {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 0,
|
||||
"lnd" => 1,
|
||||
_ => 2,
|
||||
});
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
||||
let listed_containers = self.runtime.list_containers().await.ok();
|
||||
let present_containers: HashSet<String> = listed_containers
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.name.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
// Keep unknown distinct from confirmed absence. Runtime queries can
|
||||
// fail under load while systemd still has a healthy running backend.
|
||||
let mut backend_before: HashMap<String, Option<ContainerStatus>> = HashMap::new();
|
||||
for lm in &manifests {
|
||||
let id = &lm.manifest.app.id;
|
||||
if crate::app_ops::address_caching_dependents(id).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
match self.runtime.get_container_status(&name).await {
|
||||
Ok(status) => {
|
||||
backend_before.insert(id.clone(), Some(status));
|
||||
}
|
||||
Err(_) if listed_containers.is_some() && !present_containers.contains(&name) => {
|
||||
backend_before.insert(id.clone(), None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(backend = %id, error = %err,
|
||||
"cannot observe backend before reconcile; will not infer a dependency restart from an action report");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
|| crate::settings::bitcoin_storage::load(&self.data_dir)
|
||||
.await
|
||||
.map(|settings| settings.prune)
|
||||
.unwrap_or(true);
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||
@@ -1934,7 +2007,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& requires_archival_bitcoin(&app_id)
|
||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
&& bitcoin_pruned
|
||||
{
|
||||
report.record(
|
||||
&app_id,
|
||||
@@ -1944,6 +2017,23 @@ impl ProdContainerOrchestrator {
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
// Same shape as the archival-bitcoin skip above: recorded BEFORE
|
||||
// ensure_running_with_mode, so the "absent" desired-state recovery
|
||||
// below can never fire on this reason and undo it.
|
||||
if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
disk_gb,
|
||||
"cuprate needs a larger disk (no pruning support) — skipping start"
|
||||
);
|
||||
report.record(
|
||||
&app_id,
|
||||
ReconcileAction::Left("cuprate-insufficient-disk".into()),
|
||||
);
|
||||
crate::crash_recovery::pending_boot_start_done(&app_id);
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
match self.ensure_running_with_mode(&lm, mode).await {
|
||||
// Desired-state recovery: the app has no container and was left
|
||||
// "absent" by boot reconcile, BUT it was running at the last
|
||||
@@ -2052,7 +2142,20 @@ impl ProdContainerOrchestrator {
|
||||
// state recovery, repair recreate, boot InstallMissing) moves the
|
||||
// address behind a running dependent's back — §C "restart lnd after
|
||||
// ANY bitcoin recreate".
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) {
|
||||
let mut changed_backends = HashSet::new();
|
||||
for (backend, before) in &backend_before {
|
||||
let Some(name) = container_name_by_app_id.get(backend) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(after) = self.runtime.get_container_status(name).await {
|
||||
if backend_instance_changed(before.as_ref(), &after) {
|
||||
changed_backends.insert(backend.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// A user stop during a slow reconcile pass still takes precedence.
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped, &changed_backends) {
|
||||
// Same rule as the RPC cascade: hold the dependent's op lock
|
||||
// across the restart; skip when a worker is mid-sequence.
|
||||
let lock = crate::app_ops::op_lock(dep);
|
||||
@@ -3191,6 +3294,9 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(network) = manifest.app.container.network.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3685,6 +3791,17 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||
let storage = crate::settings::bitcoin_storage::load(&self.data_dir).await?;
|
||||
env.retain(|entry| !entry.starts_with("BITCOIN_PRUNE="));
|
||||
if storage.prune {
|
||||
anyhow::ensure!(
|
||||
manifest.app.container.custom_args.iter().any(|arg| arg.contains("BITCOIN_PRUNE")),
|
||||
"This Bitcoin app definition cannot honor the pruning choice. Refresh the app catalog and try again."
|
||||
);
|
||||
env.push("BITCOIN_PRUNE=1".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
@@ -4641,6 +4758,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
|
||||
@@ -5050,6 +5188,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".
|
||||
@@ -5174,7 +5313,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,
|
||||
@@ -5365,6 +5510,27 @@ app:
|
||||
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
|
||||
let m = pull_manifest("lnd-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-lnd-ui");
|
||||
let m = pull_manifest("cuprate-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-cuprate-ui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() {
|
||||
// 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate
|
||||
// has no pruning — boot reconcile must leave it down.
|
||||
assert!(requires_cuprate_disk("cuprate", 250));
|
||||
assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1));
|
||||
assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB));
|
||||
assert!(!requires_cuprate_disk("cuprate", 1000));
|
||||
// df failure in detect_disk_gb reads as 0 → fail closed at boot: a
|
||||
// doomed sync is worse than a node that stays down until it can
|
||||
// measure (same direction as the archival-bitcoin skip).
|
||||
assert!(requires_cuprate_disk("cuprate", 0));
|
||||
// Nothing else is gated here: bitcoin apps self-prune, everything
|
||||
// else is irrelevant to the Monero chain.
|
||||
for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
assert!(!requires_cuprate_disk(app_id, 0), "{app_id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5989,6 +6155,48 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bitcoin_storage_choice_is_applied_and_old_catalog_cannot_silently_ignore_it() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
orch.set_data_dir(dir.path().to_path_buf());
|
||||
for id in ["bitcoin-core", "bitcoin-knots"] {
|
||||
let mut old = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
// No preference: existing containers need no new environment flag.
|
||||
crate::settings::bitcoin_storage::save(dir.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
orch.resolve_dynamic_env(&mut old).await.unwrap();
|
||||
assert!(!old
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s.starts_with("BITCOIN_PRUNE=")));
|
||||
crate::settings::bitcoin_storage::save(dir.path(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(orch
|
||||
.resolve_dynamic_env(&mut old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot honor"));
|
||||
let mut current = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
current
|
||||
.app
|
||||
.container
|
||||
.custom_args
|
||||
.push("if [ ${BITCOIN_PRUNE:-0} = 1 ]; then :; fi".into());
|
||||
orch.resolve_dynamic_env(&mut current).await.unwrap();
|
||||
assert!(current
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s == "BITCOIN_PRUNE=1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_resolves_derived_and_secret_env_before_create() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -6260,6 +6468,67 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_cascade_requires_observed_instance_change() {
|
||||
let running = ContainerStatus {
|
||||
id: "container-1".into(),
|
||||
name: "bitcoin-core".into(),
|
||||
state: ContainerState::Running,
|
||||
started_at: Some("start-1".into()),
|
||||
health: None,
|
||||
exit_code: None,
|
||||
image: "bitcoin:1".into(),
|
||||
created: "created-1".into(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
};
|
||||
assert!(!backend_instance_changed(Some(&running), &running));
|
||||
assert!(backend_instance_changed(None, &running));
|
||||
let mut before = running.clone();
|
||||
before.state = ContainerState::Exited;
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.id = "old-container".into();
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.started_at = Some("earlier-start".into());
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before.started_at = None;
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
before.id.clear();
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
let mut after = running.clone();
|
||||
after.state = ContainerState::Exited;
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
after = running.clone();
|
||||
after.id.clear();
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_ignores_false_started_report_but_detects_real_exec_drift() {
|
||||
let none = HashSet::new();
|
||||
let mut report = ReconcileReport {
|
||||
actions: vec![
|
||||
("bitcoin-core".into(), ReconcileAction::Started),
|
||||
("lnd".into(), ReconcileAction::NoOp),
|
||||
],
|
||||
failures: vec![],
|
||||
};
|
||||
// systemctl start of an already active unit does not move its address.
|
||||
assert!(cascade_pairs_for_report(&report, &none, &none).is_empty());
|
||||
// A unit exec rewrite can restart Bitcoin while the outer reconcile
|
||||
// action remains NoOp. Runtime evidence still requires LND to reconnect.
|
||||
let changed = ["bitcoin-core".into()].into();
|
||||
report.actions[0].1 = ReconcileAction::NoOp;
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&report, &none, &changed),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
report.actions[0].1 = ReconcileAction::Left("lifecycle-op-in-flight".into());
|
||||
assert!(cascade_pairs_for_report(&report, &none, &changed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
||||
use std::collections::HashSet;
|
||||
@@ -6271,6 +6540,7 @@ app:
|
||||
failures: vec![],
|
||||
};
|
||||
let none = HashSet::new();
|
||||
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
|
||||
|
||||
// Backend recreated while lnd sat running (NoOp) → cascade.
|
||||
let r = report(vec![
|
||||
@@ -6278,7 +6548,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
vec![("bitcoin-knots", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6288,7 +6558,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6297,7 +6567,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::NoOp),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &none).is_empty());
|
||||
|
||||
// Dependent itself (re)started this pass → it already resolved the
|
||||
// fresh address; no cascade.
|
||||
@@ -6305,7 +6575,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::Started),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
|
||||
// User-stopped dependent is never bounced.
|
||||
let r = report(vec![
|
||||
@@ -6313,14 +6583,50 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
||||
assert!(cascade_pairs_for_report(&r, &stopped).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty());
|
||||
|
||||
// Non-backend recreates don't cascade anything.
|
||||
let r = report(vec![
|
||||
("grafana", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_wallet_start_precedes_unrelated_failed_image_pull() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("bitcoin-knots", ContainerState::Exited);
|
||||
rt.set_state("lnd", ContainerState::Exited);
|
||||
*rt.fail_pull.lock().unwrap() = Some("registry unreachable".into());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
orch.set_disk_gb_for_test(2000);
|
||||
for id in ["unrelated", "lnd", "bitcoin-knots"] {
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest(id, &format!("docker.io/example/{id}:1")),
|
||||
PathBuf::from(format!("/tmp/{id}")),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let report = orch.reconcile_all().await;
|
||||
assert!(report.failures.iter().any(|(id, _)| id == "unrelated"));
|
||||
let calls = rt.calls();
|
||||
let bitcoin = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:bitcoin-knots")
|
||||
.unwrap();
|
||||
let lnd = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:lnd")
|
||||
.unwrap();
|
||||
let pull = calls
|
||||
.iter()
|
||||
.position(|c| c.starts_with("pull_image:"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
bitcoin < lnd && lnd < pull,
|
||||
"wallet startup was delayed by unrelated recovery: {calls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -6724,6 +7030,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());
|
||||
|
||||
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
|
||||
pub no_new_privileges: bool,
|
||||
pub cpu_quota: Option<u32>,
|
||||
pub restart_policy: RestartPolicy,
|
||||
pub stop_grace_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl QuadletUnit {
|
||||
@@ -216,6 +217,10 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "[Container]");
|
||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||
let _ = writeln!(s, "Image={}", self.image);
|
||||
let grace = self
|
||||
.stop_grace_secs
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||
let _ = writeln!(s, "StopTimeout={grace}");
|
||||
// Pull=never: companions are pre-pulled or built. A missing image
|
||||
// must surface as a unit start failure, not a silent retry storm.
|
||||
let _ = writeln!(s, "Pull=never");
|
||||
@@ -350,6 +355,15 @@ impl QuadletUnit {
|
||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||
// not a systemd start gate.
|
||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||
// existing container may still carry Podman's old 10-second default;
|
||||
// StopTimeout alone only protects containers created after migration.
|
||||
let _ = writeln!(s, "ExecStop=");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||
);
|
||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||
// from saturating the journal. Companions: Always. Backends:
|
||||
// OnFailure (clean stops stay stopped).
|
||||
@@ -525,6 +539,9 @@ impl QuadletUnit {
|
||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||
restart_policy: RestartPolicy::Always,
|
||||
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||
manifest, name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,6 +693,13 @@ pub async fn unit_exists(name: &str) -> bool {
|
||||
|
||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||
pub async fn unit_dir() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
static TEST_UNITS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
return Ok(TEST_UNITS
|
||||
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||
.clone());
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||
@@ -785,7 +809,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
||||
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||
match systemctl_user_status(&["stop", service], timeout).await {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||
@@ -806,10 +834,29 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// The command waiter must outlive both the container grace and systemd's
|
||||
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||
.max(QUADLET_STOP_TIMEOUT)
|
||||
}
|
||||
|
||||
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||
directive_values(unit_body, "StopTimeout=")
|
||||
.last()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||
}
|
||||
|
||||
async fn systemctl_user_status(
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
return Ok(std::process::ExitStatus::from_raw(0));
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -856,6 +903,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
||||
}
|
||||
|
||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::bail!("Unit tests have no real user service manager");
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -923,6 +974,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
||||
/// that systemd no longer knows about.
|
||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
let svc = format!("{unit_name}.service");
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||
let timeout = stop_wait_timeout(unit_name, &body);
|
||||
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||
// rootless podman a generated unit can wedge in "deactivating" while
|
||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||
@@ -930,13 +985,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||
// blocks reinstall). If the graceful stop times out, escalate to
|
||||
// SIGKILL + reset-failed so teardown always proceeds.
|
||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
||||
if systemctl_user_status(&["stop", &svc], timeout)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = kill_and_reset_service(&svc).await;
|
||||
}
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
@@ -949,9 +1003,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||
// exists to avoid.
|
||||
let _ = tokio::time::timeout(
|
||||
QUADLET_STOP_TIMEOUT,
|
||||
timeout,
|
||||
Command::new("podman")
|
||||
.args(["rm", "-f", unit_name])
|
||||
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||
.status(),
|
||||
)
|
||||
.await;
|
||||
@@ -960,6 +1014,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
|
||||
/// Is the quadlet-generated service currently active?
|
||||
pub async fn is_active(service: &str) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "is-active", "--quiet", service])
|
||||
.status()
|
||||
@@ -973,6 +1030,118 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||
for (name, grace) in [
|
||||
("bitcoin-core", 600),
|
||||
("bitcoin-knots", 600),
|
||||
("lnd", 330),
|
||||
("electrumx", 300),
|
||||
("other", 30),
|
||||
] {
|
||||
let unit = QuadletUnit {
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let body = unit.render();
|
||||
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||
assert_eq!(
|
||||
stop_wait_timeout(name, &body),
|
||||
Duration::from_secs(grace + 30)
|
||||
);
|
||||
// Legacy units have no StopTimeout directive yet.
|
||||
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||
let manifest: AppManifest = serde_yaml::from_str(
|
||||
r#"
|
||||
app:
|
||||
id: custom-db
|
||||
name: Custom database
|
||||
version: 1.0.0
|
||||
stop_grace_secs: 900
|
||||
container:
|
||||
image: example/db:1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||
assert_eq!(
|
||||
stop_wait_timeout("custom-db", &unit.render()),
|
||||
Duration::from_secs(930)
|
||||
);
|
||||
assert_eq!(
|
||||
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||
Duration::from_secs(360)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||
let unit = sample_unit();
|
||||
let new = unit.render();
|
||||
let old = new
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.starts_with("StopTimeout=")
|
||||
&& !line.starts_with("TimeoutStopSec=")
|
||||
&& !line.starts_with("ExecStop=")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(!exec_changed(&old, &new));
|
||||
assert!(!publish_ports_changed(&old, &new));
|
||||
assert!(!network_aliases_changed(&old, &new));
|
||||
assert!(!health_cmd_changed(&old, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||
if !generator.exists() {
|
||||
eprintln!(
|
||||
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dir = tempdir().unwrap();
|
||||
let unit = QuadletUnit {
|
||||
name: "grace-test".into(),
|
||||
image: "localhost/test:latest".into(),
|
||||
stop_grace_secs: Some(600),
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||
let output = std::process::Command::new(generator)
|
||||
.args(["--user", "--dryrun"])
|
||||
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||
+ &String::from_utf8_lossy(&output.stderr);
|
||||
let stop = generated
|
||||
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||
.unwrap();
|
||||
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||
assert!(
|
||||
stop < remove,
|
||||
"Legacy container must stop gracefully before removal"
|
||||
);
|
||||
assert!(generated.contains("--stop-timeout 600"));
|
||||
assert!(generated.contains("TimeoutStopSec=615"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_emits_secret_env_by_reference_never_value() {
|
||||
let u = QuadletUnit {
|
||||
|
||||
@@ -238,6 +238,11 @@ 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,
|
||||
/// Requested byte range cannot be served; no payment was taken.
|
||||
RangeNotSatisfiable(u64),
|
||||
}
|
||||
|
||||
/// Serve a content item by ID with access control and optional range request.
|
||||
@@ -252,6 +257,39 @@ pub async fn serve_content(
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
) -> Result<ServeResult> {
|
||||
serve_content_with(
|
||||
data_dir,
|
||||
id,
|
||||
payment_token,
|
||||
invoice_hash,
|
||||
peer_did,
|
||||
range,
|
||||
owner_session,
|
||||
|path, range, mime| prepare_content(data_dir, path, range, mime),
|
||||
|token, amount| async move { verify_payment_token(data_dir, &token, amount).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Inject only the read and payment boundaries, so tests can prove ordering
|
||||
// without mint access, file-permission assumptions or privileged commands.
|
||||
async fn serve_content_with<R, RF, V, VF>(
|
||||
data_dir: &Path,
|
||||
id: &str,
|
||||
payment_token: Option<&str>,
|
||||
invoice_hash: Option<&str>,
|
||||
peer_did: Option<&str>,
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
read: R,
|
||||
verify: V,
|
||||
) -> Result<ServeResult>
|
||||
where
|
||||
R: FnOnce(PathBuf, Option<ByteRange>, String) -> RF,
|
||||
RF: std::future::Future<Output = Result<ServeResult>>,
|
||||
V: FnOnce(String, u64) -> VF,
|
||||
VF: std::future::Future<Output = bool>,
|
||||
{
|
||||
let catalog = load_catalog(data_dir).await?;
|
||||
let item = match catalog.items.iter().find(|i| i.id == id) {
|
||||
Some(i) => i,
|
||||
@@ -296,6 +334,47 @@ 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);
|
||||
}
|
||||
|
||||
// Refuse unauthorized viewers before opening or reading any bytes.
|
||||
if !owner_session && matches!(item.access, AccessControl::PeersOnly) && !is_known_peer {
|
||||
return Ok(ServeResult::Forbidden);
|
||||
}
|
||||
if !owner_session {
|
||||
if let AccessControl::Paid { price_sats, .. } = &item.access {
|
||||
if payment_token.is_none() && invoice_hash.is_none() {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish all file I/O before consuming bearer payment. Merely opening then
|
||||
// reopening after charging still lost payments on read errors or deletion.
|
||||
let prepared = match read(file_path, range, item.mime_type.clone()).await {
|
||||
Ok(result @ (ServeResult::Ok(..) | ServeResult::Partial { .. })) => result,
|
||||
Ok(other) => return Ok(other),
|
||||
Err(error) => {
|
||||
warn!(content_id = %id, "Cannot prepare shared content: {error:#}");
|
||||
return Ok(ServeResult::Unavailable);
|
||||
}
|
||||
};
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
@@ -307,9 +386,13 @@ pub async fn serve_content(
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
let method = if token.trim().starts_with("cashu") {
|
||||
"ecash"
|
||||
} else {
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify(token.to_owned(), *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -336,73 +419,127 @@ 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);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
let total_size = metadata.len();
|
||||
|
||||
// Handle range request for streaming
|
||||
if let Some(range) = range {
|
||||
let start = range.start.min(total_size.saturating_sub(1));
|
||||
let end = range
|
||||
.end
|
||||
.map(|e| e.min(total_size - 1))
|
||||
.unwrap_or(total_size - 1);
|
||||
|
||||
if start > end || start >= total_size {
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
let len = (end - start + 1) as usize;
|
||||
async fn prepare_content(
|
||||
data_dir: &Path,
|
||||
path: PathBuf,
|
||||
range: Option<ByteRange>,
|
||||
mime: String,
|
||||
) -> Result<ServeResult> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = tokio::fs::File::open(&file_path)
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(&path)
|
||||
.await
|
||||
.context("Failed to open content file")?;
|
||||
file.seek(std::io::SeekFrom::Start(start))
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
|
||||
return slice_prepared_content(bytes, range, mime);
|
||||
}
|
||||
Err(error) => return Err(error).context("Opening shared content"),
|
||||
};
|
||||
let metadata = file.metadata().await?;
|
||||
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
|
||||
let total = metadata.len();
|
||||
if let Some(range) = range {
|
||||
let Some((start, end)) = checked_range(&range, total) else {
|
||||
return Ok(ServeResult::RangeNotSatisfiable(total));
|
||||
};
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let len = usize::try_from(end - start + 1).context("Content range is too large")?;
|
||||
let mut bytes = vec![0; len];
|
||||
file.read_exact(&mut bytes)
|
||||
.await
|
||||
.context("Failed to seek")?;
|
||||
let mut buf = vec![0u8; len];
|
||||
file.read_exact(&mut buf)
|
||||
.await
|
||||
.context("Failed to read range")?;
|
||||
|
||||
debug!(
|
||||
"Serving content '{}' range {}-{}/{} ({} bytes)",
|
||||
id, start, end, total_size, len
|
||||
);
|
||||
.context("Reading shared content range")?;
|
||||
return Ok(ServeResult::Partial {
|
||||
bytes: buf,
|
||||
mime_type: item.mime_type.clone(),
|
||||
bytes,
|
||||
mime_type: mime,
|
||||
start,
|
||||
end,
|
||||
total: total_size,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file_path)
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.await
|
||||
.context("Failed to read content file")?;
|
||||
.context("Reading shared content")?;
|
||||
Ok(ServeResult::Ok(bytes, mime))
|
||||
}
|
||||
|
||||
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
|
||||
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
|
||||
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
|
||||
let last = total.checked_sub(1)?;
|
||||
let end = range.end.unwrap_or(last).min(last);
|
||||
(range.start <= end && range.start < total).then_some((range.start, end))
|
||||
}
|
||||
|
||||
fn slice_prepared_content(
|
||||
bytes: Vec<u8>,
|
||||
range: Option<ByteRange>,
|
||||
mime: String,
|
||||
) -> Result<ServeResult> {
|
||||
let total = bytes.len() as u64;
|
||||
match range {
|
||||
None => Ok(ServeResult::Ok(bytes, mime)),
|
||||
Some(range) => match checked_range(&range, total) {
|
||||
Some((start, end)) => Ok(ServeResult::Partial {
|
||||
bytes: bytes[start as usize..=end as usize].to_vec(),
|
||||
mime_type: mime,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
}),
|
||||
None => Ok(ServeResult::RangeNotSatisfiable(total)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read only an explicitly shared, regular file within FileBrowser storage.
|
||||
/// Do not change its mode or grant world-readable access to paid/private data.
|
||||
async fn filebrowser_read_path(data_dir: &Path, path: &Path) -> Result<PathBuf> {
|
||||
let root = fs::canonicalize(data_dir.join("filebrowser")).await?;
|
||||
let target = fs::canonicalize(path).await?;
|
||||
anyhow::ensure!(
|
||||
target.starts_with(&root) && target != root,
|
||||
"Shared file is outside Files storage"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
fs::metadata(&target).await?.is_file(),
|
||||
"Shared content is not a regular file"
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec<u8>> {
|
||||
let path = filebrowser_read_path(data_dir, path).await?;
|
||||
// Tests exercise the boundary explicitly; they never launch the host Podman.
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = path;
|
||||
anyhow::bail!("Files namespace read disabled in unit tests")
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(900),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["unshare", "cat", "--"])
|
||||
.arg(path)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.context("Files namespace read timed out")??;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace read failed: {}",
|
||||
output.status
|
||||
);
|
||||
Ok(output.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of attempting to serve a preview.
|
||||
@@ -573,7 +710,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
}
|
||||
|
||||
/// Verify a payment token covers the required amount.
|
||||
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
||||
/// Accepts real Cashu tokens and Fedimint notes.
|
||||
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
||||
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
||||
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||
@@ -725,3 +862,301 @@ mod prune_missing_content_tests {
|
||||
assert_eq!(reloaded.items[0].id, "present-item");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paid_read_order_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
async fn fixture(bytes: &[u8]) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join("content/files"))
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(dir.path().join("content/files/test.bin"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
save_catalog(
|
||||
dir.path(),
|
||||
&ContentCatalog {
|
||||
items: vec![ContentItem {
|
||||
id: "paid".into(),
|
||||
filename: "test.bin".into(),
|
||||
mime_type: "application/octet-stream".into(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
description: String::new(),
|
||||
access: AccessControl::Paid {
|
||||
price_sats: 10,
|
||||
accepted: vec!["ecash".into()],
|
||||
},
|
||||
availability: Availability::AllPeers,
|
||||
added_at: "2026-09-30".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_read_failures_precede_redemption_even_as_root() {
|
||||
for kind in [
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
std::io::ErrorKind::NotFound,
|
||||
std::io::ErrorKind::Other,
|
||||
] {
|
||||
let dir = fixture(b"abc").await;
|
||||
let charged = AtomicUsize::new(0);
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async move { Err(std::io::Error::from(kind).into()) },
|
||||
|_, _| async {
|
||||
charged.fetch_add(1, Ordering::SeqCst);
|
||||
true
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Unavailable));
|
||||
assert_eq!(charged.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletion_during_payment_cannot_lose_prepared_bytes() {
|
||||
let dir = fixture(b"original").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, amount| {
|
||||
assert_eq!(amount, 10);
|
||||
async {
|
||||
fs::remove_file(dir.path().join("content/files/test.bin"))
|
||||
.await
|
||||
.unwrap();
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"original"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_out_of_bounds_and_reversed_ranges_never_charge() {
|
||||
for (bytes, start, end) in [
|
||||
(b"".as_slice(), 0, None),
|
||||
(b"abc".as_slice(), 3, None),
|
||||
(b"abc".as_slice(), 2, Some(1)),
|
||||
] {
|
||||
let dir = fixture(bytes).await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
Some(ByteRange { start, end }),
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("invalid range reached payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ServeResult::RangeNotSatisfiable(n) if n == bytes.len() as u64)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_range_survives_file_change_while_payment_is_verified() {
|
||||
let dir = fixture(b"abcdef").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
Some(ByteRange {
|
||||
start: 2,
|
||||
end: Some(999),
|
||||
}),
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async {
|
||||
fs::write(dir.path().join("content/files/test.bin"), b"x")
|
||||
.await
|
||||
.unwrap();
|
||||
true
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ServeResult::Partial { bytes, start: 2, end: 5, total: 6, .. } if bytes == b"cdef")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payment_denial_never_returns_prepared_content() {
|
||||
let dir = fixture(b"secret").await;
|
||||
let charged = AtomicUsize::new(0);
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async {
|
||||
charged.fetch_add(1, Ordering::SeqCst);
|
||||
false
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
assert_eq!(charged.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_payment_and_peer_restrictions_precede_file_reads() {
|
||||
let dir = fixture(b"secret").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async { panic!("unauthorized file read") },
|
||||
|_, _| async { panic!("unexpected payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
let mut catalog = load_catalog(dir.path()).await.unwrap();
|
||||
catalog.items[0].access = AccessControl::PeersOnly;
|
||||
save_catalog(dir.path(), &catalog).await.unwrap();
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async { panic!("unauthorized file read") },
|
||||
|_, _| async { panic!("unexpected payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_reads_paid_content_without_redemption() {
|
||||
let dir = fixture(b"own file").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("owner charged") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"own file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_in_place_of_file_does_not_charge() {
|
||||
let dir = fixture(b"abc").await;
|
||||
let path = dir.path().join("content/files/test.bin");
|
||||
fs::remove_file(&path).await.unwrap();
|
||||
fs::create_dir(&path).await.unwrap();
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("directory charged") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Unavailable));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_namespace_read_is_scoped_to_regular_files_and_keeps_mode() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
let dir = fixture(b"outside").await;
|
||||
let root = dir.path().join("filebrowser");
|
||||
fs::create_dir(&root).await.unwrap();
|
||||
let inside = root.join("song");
|
||||
fs::write(&inside, b"song").await.unwrap();
|
||||
fs::set_permissions(&inside, std::fs::Permissions::from_mode(0o640))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
filebrowser_read_path(dir.path(), &inside).await.unwrap(),
|
||||
inside
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&inside).await.unwrap().permissions().mode() & 0o777,
|
||||
0o640
|
||||
);
|
||||
let outside = dir.path().join("content/files/test.bin");
|
||||
symlink(&outside, root.join("escape")).unwrap();
|
||||
for path in [outside, root.join("escape"), root.clone()] {
|
||||
assert!(filebrowser_read_path(dir.path(), &path).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_namespace_bytes_use_the_same_range_rules() {
|
||||
assert!(matches!(
|
||||
slice_prepared_content(
|
||||
vec![],
|
||||
Some(ByteRange {
|
||||
start: 0,
|
||||
end: None
|
||||
}),
|
||||
"x".into()
|
||||
)
|
||||
.unwrap(),
|
||||
ServeResult::RangeNotSatisfiable(0)
|
||||
));
|
||||
assert!(
|
||||
matches!(slice_prepared_content(b"abc".to_vec(), Some(ByteRange { start: 1, end: None }), "x".into()).unwrap(), ServeResult::Partial { bytes, start: 1, end: 2, total: 3, .. } if bytes == b"bc")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
|
||||
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
18081, 18083, 18091, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -113,7 +132,21 @@ pub fn client() -> reqwest::Client {
|
||||
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
||||
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||
client_with_delivery_policy(timeout, false)
|
||||
}
|
||||
|
||||
fn delivery_redirect_policy(single: bool) -> reqwest::redirect::Policy {
|
||||
if single {
|
||||
reqwest::redirect::Policy::none()
|
||||
} else {
|
||||
reqwest::redirect::Policy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(delivery_redirect_policy(single))
|
||||
.timeout(timeout)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.user_agent("archipelago-fips/1")
|
||||
@@ -130,10 +163,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 +391,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 +407,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 {
|
||||
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
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));
|
||||
@@ -481,7 +541,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));
|
||||
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||
let mut rb = c.post(&url).json(body);
|
||||
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)),
|
||||
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
|
||||
"FIPS POST failed after possible delivery; not replaying: {e}"
|
||||
)),
|
||||
Err(_) if single => Err(anyhow::anyhow!(
|
||||
"FIPS POST exceeded its budget after possible delivery; not replaying"
|
||||
)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||
let mut rb = c.get(&url);
|
||||
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!(
|
||||
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
|
||||
.context("Invalid Tor SOCKS proxy URL")?;
|
||||
reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.redirect(delivery_redirect_policy(self.single_delivery))
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.context("Build Tor HTTP client")
|
||||
@@ -759,4 +843,181 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delivery_redirect_tests {
|
||||
use super::*;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Response, Server,
|
||||
};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_bearer_request_does_not_follow_redirects_but_normal_get_does() {
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||
let address = server.local_addr();
|
||||
let service = make_service_fn(move |_| {
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
let response = if request.uri().path() == "/first" {
|
||||
Response::builder()
|
||||
.status(302)
|
||||
.header("Location", "/replay")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
} else {
|
||||
Response::new(Body::from("replayed"))
|
||||
};
|
||||
Ok::<_, Infallible>(response)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
let task = tokio::spawn(server.serve(service));
|
||||
let url = format!("http://{address}/first");
|
||||
let response = client_with_delivery_policy(Duration::from_secs(2), true)
|
||||
.get(&url)
|
||||
.header("X-Payment-Token", "dummy-test-token")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||
let response = client_with_delivery_policy(Duration::from_secs(2), false)
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 3);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_request_is_not_resent_when_peer_disconnects_after_reading_it() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = [0; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
drop(stream);
|
||||
}
|
||||
});
|
||||
let c = client_with_delivery_policy(Duration::from_secs(2), true);
|
||||
let error = send_with_retry_if(c.get(format!("http://{address}/")), |e| {
|
||||
fips_retryable(true, e)
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.is_connect());
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
|
||||
| "indeedhub-api" => StartupTier::DependentService,
|
||||
|
||||
// Tier 4: Frontend/UI
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend"
|
||||
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
|
||||
| "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
|
||||
// Tier 3: Application layer (everything else)
|
||||
_ => StartupTier::Application,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Install-time pruning preference, shared by Bitcoin Core and Knots.
|
||||
//! Missing preference preserves the existing disk-based automatic selection.
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct BitcoinStorage {
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> Result<BitcoinStorage> {
|
||||
match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, prune: bool) -> Result<()> {
|
||||
let dir = data_dir.join("settings");
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join("bitcoin-storage.json");
|
||||
let temporary = dir.join("bitcoin-storage.json.tmp");
|
||||
tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?;
|
||||
tokio::fs::rename(temporary, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), true).await.unwrap();
|
||||
assert!(load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), false).await.unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_setting_is_not_silently_changed_to_archival() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), true).await.unwrap();
|
||||
tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load(dir.path()).await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,5 @@
|
||||
pub mod ai_permissions;
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
pub mod bitcoin_storage;
|
||||
|
||||
@@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
/// service unit that inherits systemd's default protections (i.e. none
|
||||
/// of ours), escaping the namespace.
|
||||
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -1505,6 +1520,21 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
||||
/// (e.g. `stat`) where the answer is in the output, not the exit status.
|
||||
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -2159,20 +2189,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 +2223,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 +2241,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.
|
||||
// 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,
|
||||
|
||||
@@ -775,7 +775,9 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||
all_target.extend(&change_denoms);
|
||||
|
||||
let swap_result = client.swap(&selected_proofs, &all_target).await?;
|
||||
let swap_result = client
|
||||
.swap_at_least(&selected_proofs, &all_target, amount_sats)
|
||||
.await?;
|
||||
|
||||
// Mark original proofs as spent
|
||||
wallet.mark_spent(&indices);
|
||||
@@ -1192,7 +1194,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// Verify all mints in the token are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
||||
}
|
||||
}
|
||||
@@ -1205,6 +1211,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 {
|
||||
@@ -1216,7 +1223,8 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
}
|
||||
@@ -1224,10 +1232,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 +1248,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> {
|
||||
@@ -1288,22 +1304,10 @@ pub async fn verify_and_receive_payment(
|
||||
token_str: &str,
|
||||
required_sats: u64,
|
||||
) -> Result<u64> {
|
||||
// Handle legacy tokens
|
||||
let token_str = token_str.trim();
|
||||
// Synthetic legacy balances are not cryptographic proof of payment.
|
||||
if token_str.starts_with("cashuSend_") {
|
||||
let amount = token_str
|
||||
.split('_')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
if amount < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
amount,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
let received = receive_legacy_token(data_dir, token_str).await?;
|
||||
return Ok(received);
|
||||
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||
}
|
||||
|
||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||||
@@ -1326,52 +1330,45 @@ pub async fn verify_and_receive_payment(
|
||||
|
||||
// Parse and validate the token (cashuA or cashuB)
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
let total = token.total_amount();
|
||||
|
||||
if token.unit.as_deref().unwrap_or("sat") != "sat" {
|
||||
anyhow::bail!("Payment must be denominated in sats");
|
||||
}
|
||||
// A sale must redeem atomically at one mint. Otherwise a later mint
|
||||
// failure can consume earlier inputs without delivering the purchase.
|
||||
let entry = match token.token.as_slice() {
|
||||
[entry] => entry,
|
||||
_ => anyhow::bail!("Use a single-mint token for this payment"),
|
||||
};
|
||||
let total = entry
|
||||
.proofs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.ok_or_else(|| anyhow::anyhow!("Payment amount overflow"))?;
|
||||
if total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
total,
|
||||
required_sats
|
||||
);
|
||||
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||
}
|
||||
|
||||
// Verify mints are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
||||
}
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint is not in the seller's accepted mints list");
|
||||
}
|
||||
|
||||
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
|
||||
for entry in &token.token {
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(entry_total);
|
||||
|
||||
match client.swap(&entry.proofs, &target_amounts).await {
|
||||
Ok(result) => {
|
||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Payment verification failed: only {} of {} sats verified",
|
||||
received_total,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
let result = client
|
||||
.swap_at_least(
|
||||
&entry.proofs,
|
||||
&amount_to_denominations(total),
|
||||
required_sats,
|
||||
)
|
||||
.await?;
|
||||
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
// Load after the network call, so an unrelated wallet update during the
|
||||
// swap is not overwritten with a pre-swap snapshot.
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
@@ -1632,6 +1629,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;
|
||||
|
||||
@@ -2443,3 +2452,7 @@ mod tests {
|
||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "payment_tests.rs"]
|
||||
mod payment_tests;
|
||||
|
||||
@@ -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(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;
|
||||
}
|
||||
// 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()
|
||||
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(since))
|
||||
.limit(200);
|
||||
|
||||
let result = match client
|
||||
.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) => {
|
||||
let mut out: Vec<(String, u64, String, String)> = events
|
||||
.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())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
client.shutdown().await;
|
||||
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 for claim DMs failed: {e}");
|
||||
Vec::new()
|
||||
warn!("Minibits: relay fetch failed; preserving scan cursor: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
client.shutdown().await;
|
||||
result
|
||||
let count = events.len();
|
||||
let oldest = events.iter().map(|e| e.1).min();
|
||||
for event in events {
|
||||
if ids.insert(event.3.clone()) {
|
||||
out.push(event);
|
||||
}
|
||||
}
|
||||
if count < scan.limit {
|
||||
resume = None;
|
||||
break;
|
||||
}
|
||||
if let Some(oldest) = oldest {
|
||||
if scan.until == Some(oldest) {
|
||||
scan.limit = scan.limit.saturating_add(CLAIM_PAGE_LIMIT);
|
||||
} else {
|
||||
scan.until = Some(oldest);
|
||||
scan.limit = CLAIM_PAGE_LIMIT;
|
||||
}
|
||||
}
|
||||
resume = Some(scan);
|
||||
}
|
||||
out.sort_by(|a, b| (a.1, &a.3).cmp(&(b.1, &b.3)));
|
||||
RelayBatch { dms: out, resume }
|
||||
}
|
||||
|
||||
fn queue_relay_dm(
|
||||
@@ -907,8 +1007,15 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
// NIP-04 DM on relays, not via `/claim` above. `since` is our own
|
||||
// watermark (Nostr events never expire off a relay, so without it we'd
|
||||
// re-fetch and re-attempt every claim ever sent on every poll).
|
||||
let dms = fetch_relay_dms(identity.keys.public_key(), server_pk, state.last_dm_seen_at).await;
|
||||
for (content, created_at, author, event_id) in dms {
|
||||
let batch = fetch_relay_dms(
|
||||
identity.keys.public_key(),
|
||||
server_pk,
|
||||
state.last_dm_seen_at,
|
||||
state.relay_scan,
|
||||
)
|
||||
.await;
|
||||
state.relay_scan = batch.resume;
|
||||
for (content, created_at, author, event_id) in batch.dms {
|
||||
if author != state.server_nostr_pubkey {
|
||||
warn!("Minibits: ignoring claim DM from unexpected pubkey {author}");
|
||||
continue;
|
||||
@@ -964,6 +1071,14 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
sats += got;
|
||||
info!("Minibits: redeemed a claimed payment ({got} sats)");
|
||||
}
|
||||
Err(e) if is_already_redeemed(&e) => {
|
||||
// Terminal: the value was already swept (a relay-watermark
|
||||
// replay, or a claim redeemed by an earlier run before a
|
||||
// crash lost track of it). Retrying can never succeed, so
|
||||
// drop it instead of leaving `failed_count` stuck non-zero
|
||||
// forever — see archy-x250-pa3, 2026-09-15.
|
||||
info!("Minibits mint reports this claim was already redeemed; removing it from the retry queue");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
|
||||
still_pending.push(claim.clone());
|
||||
@@ -994,6 +1109,123 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn simulated_relay_page(events: &[RelayDm], scan: RelayScan) -> Vec<RelayDm> {
|
||||
let mut page: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.1 >= scan.since && scan.until.is_none_or(|until| e.1 <= until))
|
||||
.cloned()
|
||||
.collect();
|
||||
page.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.3.cmp(&b.3)));
|
||||
page.truncate(scan.limit);
|
||||
page
|
||||
}
|
||||
|
||||
fn relay_fixture(count: usize, same_second: bool) -> Vec<RelayDm> {
|
||||
(1..=count)
|
||||
.map(|n| {
|
||||
(
|
||||
format!("claim-{n}"),
|
||||
if same_second { 100 } else { n as u64 },
|
||||
"service".into(),
|
||||
format!("id-{n:06}"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_paging_fetches_older_claims_in_newest_first_backlog() {
|
||||
let events = relay_fixture(450, false);
|
||||
let batch = collect_relay_pages(0, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(batch.dms.len(), 450);
|
||||
assert!(batch.resume.is_none());
|
||||
assert_eq!(batch.dms.first().unwrap().1, 1);
|
||||
assert_eq!(batch.dms.last().unwrap().1, 450);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_paging_preserves_payments_at_the_same_timestamp() {
|
||||
let events = relay_fixture(250, true);
|
||||
let batch = collect_relay_pages(100, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(batch.dms.len(), 250);
|
||||
assert!(batch.resume.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_page_cap_resumes_older_claims_after_watermark_advances() {
|
||||
let events = relay_fixture(1300, false);
|
||||
let mut state = MinibitsState::default();
|
||||
let first = collect_relay_pages(0, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert!(first.resume.is_some());
|
||||
state.relay_scan = first.resume;
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for (content, time, author, id) in first.dms {
|
||||
ids.insert(id.clone());
|
||||
queue_relay_dm(&mut state, content, time, id, author);
|
||||
}
|
||||
assert_eq!(state.last_dm_seen_at, 1300);
|
||||
let state: MinibitsState =
|
||||
serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
|
||||
let second = collect_relay_pages(state.last_dm_seen_at, state.relay_scan, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert!(second.resume.is_none());
|
||||
ids.extend(second.dms.into_iter().map(|e| e.3));
|
||||
assert_eq!(ids.len(), 1300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_fetch_failure_keeps_the_unfinished_page_cursor() {
|
||||
let events = relay_fixture(450, false);
|
||||
let mut requests = 0;
|
||||
let first = collect_relay_pages(0, None, |scan| {
|
||||
requests += 1;
|
||||
std::future::ready(if requests == 1 {
|
||||
Ok(simulated_relay_page(&events, scan))
|
||||
} else {
|
||||
Err(anyhow!("relay timeout"))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert_eq!(first.dms.len(), 200);
|
||||
assert_eq!(first.resume.unwrap().until, Some(251));
|
||||
let second = collect_relay_pages(450, first.resume, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
let ids: std::collections::HashSet<_> = first
|
||||
.dms
|
||||
.into_iter()
|
||||
.chain(second.dms)
|
||||
.map(|e| e.3)
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 450);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_typed_spent_claims_are_terminal_even_with_wrapped_errors() {
|
||||
let spent = anyhow::Error::new(super::super::mint_client::AlreadyRedeemed)
|
||||
.context("receive token")
|
||||
.context("claim failed");
|
||||
assert!(is_already_redeemed(&spent));
|
||||
assert!(!is_already_redeemed(&anyhow!(
|
||||
super::super::mint_client::ALREADY_REDEEMED_MSG
|
||||
)));
|
||||
assert!(!is_already_redeemed(&anyhow!(
|
||||
"mint temporarily unreachable"
|
||||
)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,10 +71,28 @@ pub struct MintResult {
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
///
|
||||
/// Text of the NUT error-code-11001 translation, exposed so callers that
|
||||
/// received an `anyhow::Error` from a receive/redeem path (e.g. a replayed
|
||||
/// Minibits claim) can recognize an already-spent token as terminal rather
|
||||
/// than retrying it forever.
|
||||
pub const ALREADY_REDEEMED_MSG: &str =
|
||||
"This ecash has already been redeemed — it can't be claimed twice.";
|
||||
|
||||
/// Typed terminal condition: never infer spent proofs from a mint's free text.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct AlreadyRedeemed;
|
||||
impl std::fmt::Display for AlreadyRedeemed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(ALREADY_REDEEMED_MSG)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for AlreadyRedeemed {}
|
||||
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11001 => ALREADY_REDEEMED_MSG,
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
@@ -124,8 +142,29 @@ 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))
|
||||
}
|
||||
|
||||
fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec<u64> {
|
||||
let mut outputs = Vec::new();
|
||||
for &amount in requested {
|
||||
if available >= amount {
|
||||
outputs.push(amount);
|
||||
available -= amount;
|
||||
} else {
|
||||
outputs.extend(amount_to_denominations(available));
|
||||
break;
|
||||
}
|
||||
}
|
||||
outputs
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
@@ -487,6 +526,21 @@ impl MintClient {
|
||||
/// Swap proofs for new proofs of different denominations.
|
||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
self.swap_at_least(inputs, target_amounts, 0).await
|
||||
}
|
||||
|
||||
/// Refuse a payment whose mint fees would leave the seller underpaid,
|
||||
/// before consuming any input proofs.
|
||||
pub async fn swap_at_least(
|
||||
&self,
|
||||
inputs: &[Proof],
|
||||
target_amounts: &[u64],
|
||||
minimum: u64,
|
||||
) -> Result<SwapResult> {
|
||||
// V4 tokens carry short keyset IDs. Every swap path (including paid
|
||||
// files and streams) must expand these, not only wallet imports.
|
||||
let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
|
||||
let inputs = resolved.as_slice();
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||
@@ -494,16 +548,35 @@ impl MintClient {
|
||||
// should equal outputs less fee`). Applied here rather than at each
|
||||
// call site so send, receive and cross-mint swaps are all covered.
|
||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
||||
let fee = match self.get_keysets().await {
|
||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
||||
Err(e) => {
|
||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
||||
0
|
||||
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||
let inputs_total = inputs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.context("Input amount overflow")?;
|
||||
let keysets = self.get_keysets().await?;
|
||||
let mut fee_ppk = 0u64;
|
||||
for proof in inputs {
|
||||
let input_keyset = keysets
|
||||
.iter()
|
||||
.find(|k| k.id == proof.id)
|
||||
.context("The mint does not recognize an input keyset")?;
|
||||
anyhow::ensure!(
|
||||
input_keyset.unit == "sat",
|
||||
"Input keyset is not denominated in sats"
|
||||
);
|
||||
fee_ppk = fee_ppk
|
||||
.checked_add(input_keyset.input_fee_ppk)
|
||||
.context("Mint fee overflow")?;
|
||||
}
|
||||
};
|
||||
let fee = fee_ppk.div_ceil(1000);
|
||||
let spendable = inputs_total.saturating_sub(fee);
|
||||
let requested: u64 = target_amounts.iter().sum();
|
||||
if spendable < minimum {
|
||||
anyhow::bail!("Payment would leave {spendable} sats after mint fees; need {minimum} sats. No proofs were redeemed.");
|
||||
}
|
||||
let requested = target_amounts
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, amount| sum.checked_add(*amount))
|
||||
.context("Output amount overflow")?;
|
||||
let owned_targets: Vec<u64>;
|
||||
let target_amounts: &[u64] = if requested > spendable {
|
||||
if spendable == 0 {
|
||||
@@ -514,7 +587,10 @@ impl MintClient {
|
||||
debug!(
|
||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||
);
|
||||
owned_targets = amount_to_denominations(spendable);
|
||||
// Callers put payment outputs before change. Keep that prefix
|
||||
// intact while fees reduce change; re-splitting the entire sum
|
||||
// can omit a payment denomination after consuming the inputs.
|
||||
owned_targets = fee_adjusted_targets(target_amounts, spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
@@ -559,6 +635,9 @@ impl MintClient {
|
||||
|
||||
let mut new_proofs = Vec::new();
|
||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||
if sig.amount != *amount || sig.id != keyset.id {
|
||||
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset");
|
||||
}
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let mint_key = keyset.key_for_amount(*amount)?;
|
||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
@@ -705,43 +784,35 @@ impl MintClient {
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||
/// or ambiguous short IDs are rejected before redemption.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return proofs.to_vec();
|
||||
return Ok(proofs.to_vec());
|
||||
}
|
||||
|
||||
// The mint's own keyset list, in the reference implementation's shape
|
||||
// so its NUT-02 resolver can consume it directly.
|
||||
let known = match self.get_cdk_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
let known = self.get_cdk_keysets().await?;
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
||||
p.id = full;
|
||||
if is_truncated_v2_keyset_id(&p.id) {
|
||||
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||
}
|
||||
p
|
||||
Ok(p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -777,7 +848,7 @@ impl MintClient {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
for entry in &token.token {
|
||||
if entry.mint != self.url {
|
||||
if entry.mint.trim_end_matches('/') != self.url {
|
||||
debug!(
|
||||
"Skipping proofs from different mint {} (ours: {})",
|
||||
entry.mint, self.url
|
||||
@@ -788,8 +859,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);
|
||||
}
|
||||
|
||||
@@ -803,6 +873,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,428 @@
|
||||
//! Real HTTP/curve-signature regressions for paid Cashu redemption.
|
||||
use super::*;
|
||||
use crate::wallet::{bdhke, cashu::Proof};
|
||||
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Request, Response, Server,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const ACTIVE: &str = "0011223344556677";
|
||||
const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111";
|
||||
|
||||
struct Mint {
|
||||
url: String,
|
||||
requests: Arc<Mutex<Vec<Value>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
failure: Arc<std::sync::atomic::AtomicU16>,
|
||||
}
|
||||
impl Drop for Mint {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn signing_key() -> SecretKey {
|
||||
SecretKey::from_slice(&[7; 32]).unwrap()
|
||||
}
|
||||
fn signed_point(point: PublicKey) -> String {
|
||||
point
|
||||
.mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key()))
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
fn proof(id: &str, amount: u64) -> Proof {
|
||||
let secret = format!("test-{id}-{amount}");
|
||||
Proof {
|
||||
amount,
|
||||
id: id.into(),
|
||||
c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()),
|
||||
secret,
|
||||
}
|
||||
}
|
||||
impl Mint {
|
||||
async fn start(fee: u64, failure: Option<u16>) -> Self {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = requests.clone();
|
||||
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
|
||||
let rejection = failure.clone();
|
||||
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
|
||||
let service = make_service_fn(move |_| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
let mut status = 200;
|
||||
let body = match req.uri().path() {
|
||||
"/v1/keysets" => json!({"keysets":[
|
||||
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
||||
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
||||
]}),
|
||||
"/v1/keys" => {
|
||||
let public =
|
||||
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
||||
.to_string();
|
||||
let keys: serde_json::Map<String, Value> = (0..16)
|
||||
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
||||
.collect();
|
||||
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
|
||||
}
|
||||
"/v1/swap" => {
|
||||
let body: Value = serde_json::from_slice(
|
||||
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
seen.lock().unwrap().push(body.clone());
|
||||
let inputs = body["inputs"].as_array().unwrap();
|
||||
let outputs = body["outputs"].as_array().unwrap();
|
||||
let code = rejection.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if code != 0 {
|
||||
status = code;
|
||||
json!({"detail":"mock mint rejection"})
|
||||
} else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE)
|
||||
{
|
||||
status = 422;
|
||||
json!({"detail":[{"msg":"NUT02: ID length invalid"}]})
|
||||
} else if inputs.iter().any(|p| {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(p["secret"].as_str().unwrap())
|
||||
}) {
|
||||
status = 400;
|
||||
json!({"code":11001,"detail":"Token Already Spent"})
|
||||
} else {
|
||||
let total: u64 =
|
||||
inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
let out: u64 =
|
||||
outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
assert_eq!(
|
||||
out,
|
||||
total - (inputs.len() as u64 * fee).div_ceil(1000)
|
||||
);
|
||||
for p in inputs {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(p["secret"].as_str().unwrap().into());
|
||||
}
|
||||
json!({"signatures":outputs.iter().map(|o| json!({
|
||||
"amount":o["amount"],"id":ACTIVE,
|
||||
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
status = 404;
|
||||
json!({})
|
||||
}
|
||||
};
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
let server = Server::from_tcp(listener).unwrap().serve(service);
|
||||
let task = tokio::spawn(async move {
|
||||
server.await.unwrap();
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
requests,
|
||||
task,
|
||||
failure,
|
||||
}
|
||||
}
|
||||
async fn wallet(&self) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_accepted_mints(
|
||||
dir.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec![format!("{}/", self.url)],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let decoded = CashuToken::deserialize(&token).unwrap();
|
||||
assert_eq!(
|
||||
decoded.token[0].proofs[0].id.len(),
|
||||
16,
|
||||
"reproduce the short V4 ID"
|
||||
);
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
100
|
||||
);
|
||||
let wallet = load_wallet(dir.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 100);
|
||||
for p in wallet.proofs {
|
||||
assert_eq!(
|
||||
p.proof.c,
|
||||
signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap())
|
||||
);
|
||||
}
|
||||
assert!(mint.requests.lock().unwrap()[0]["inputs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|p| p["id"] == V2));
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v3_full_v2_and_v1_ids_work() {
|
||||
for id in [V2, ACTIVE] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
128
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() {
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 128)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("after mint fees"));
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 127)
|
||||
.await
|
||||
.unwrap(),
|
||||
127
|
||||
);
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_mint_response_does_not_credit_wallet() {
|
||||
for status in [200, 400, 422, 500, 503] {
|
||||
let mint = Mint::start(0, Some(status)).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]);
|
||||
let mut invalid = vec![
|
||||
"cashuSend_500_abc_1700000000".into(),
|
||||
"cashuBinvalid".into(),
|
||||
];
|
||||
let mut wrong_unit = token.clone();
|
||||
wrong_unit.unit = Some("usd".into());
|
||||
invalid.push(wrong_unit.serialize().unwrap());
|
||||
let mut multi = token.clone();
|
||||
multi.token.push(token.token[0].clone());
|
||||
invalid.push(multi.serialize().unwrap());
|
||||
let mut untrusted = token.clone();
|
||||
untrusted.token[0].mint = "http://127.0.0.1:1".into();
|
||||
invalid.push(untrusted.serialize().unwrap());
|
||||
for id in ["00ffffffffffffff", "01ffffffffffffff"] {
|
||||
invalid.push(
|
||||
CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for value in invalid {
|
||||
assert!(verify_and_receive_payment(dir.path(), &value, 100)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
assert!(
|
||||
verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() {
|
||||
let mint = Mint::start(0, Some(422)).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let seller = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let token = send_token(buyer.path(), 100).await.unwrap();
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
||||
assert!(verify_and_receive_payment(seller.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(receive_token(buyer.path(), &token).await.is_err());
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_mint_does_not_credit_seller() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
mint.task.abort();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_with_fees_preserves_payment_denominations_and_saves_change() {
|
||||
// 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1,
|
||||
// which is needed for a 65-sat payment, after consuming the inputs.
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
let first = proof(V2, 64);
|
||||
let mut second = first.clone();
|
||||
second.secret.push_str("-second");
|
||||
second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap());
|
||||
wallet.add_proofs(&mint.url, vec![first, second]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let encoded = send_token(buyer.path(), 65).await.unwrap();
|
||||
assert_eq!(
|
||||
CashuToken::deserialize(&encoded).unwrap().total_amount(),
|
||||
65
|
||||
);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() {
|
||||
use crate::content_server::{
|
||||
self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult,
|
||||
};
|
||||
for (exists, accepts_cashu, price) in [
|
||||
(true, true, 100),
|
||||
(true, false, 100),
|
||||
(false, true, 100),
|
||||
(true, true, 129),
|
||||
] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let seller = mint.wallet().await;
|
||||
let item = ContentItem {
|
||||
id: "paid-test".into(),
|
||||
filename: "test.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size_bytes: 5,
|
||||
description: String::new(),
|
||||
added_at: String::new(),
|
||||
availability: Availability::AllPeers,
|
||||
access: AccessControl::Paid {
|
||||
price_sats: price,
|
||||
accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()],
|
||||
},
|
||||
};
|
||||
content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] })
|
||||
.await
|
||||
.unwrap();
|
||||
if exists {
|
||||
tokio::fs::create_dir_all(seller.path().join("content/files"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let result = content_server::serve_content(
|
||||
seller.path(),
|
||||
"paid-test",
|
||||
Some(&token),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if exists && accepts_cashu && price <= 128 {
|
||||
match result {
|
||||
ServeResult::Ok(bytes, mime) => {
|
||||
assert_eq!(bytes, b"hello");
|
||||
assert_eq!(mime, "text/plain");
|
||||
}
|
||||
_ => panic!("paid content was not delivered"),
|
||||
}
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
||||
));
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<style>
|
||||
html { color-scheme: light dark; }
|
||||
body { width: 35em; margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>An error occurred.</h1>
|
||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||
Please try again later.</p>
|
||||
<p>If you are the system administrator of this resource then you should check
|
||||
the error log for details.</p>
|
||||
<p><em>Faithfully yours, nginx.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
COPY assets/ /usr/share/nginx/html/assets/
|
||||
# Unlike bitcoin-ui, the nginx.conf is baked into the image, not
|
||||
# bind-mounted: there is no secret to render in. Cuprate's restricted RPC
|
||||
# (the only upstream this UI proxies) is unauthenticated by design —
|
||||
# Monero's safe-for-public subset — so there is nothing to substitute at
|
||||
# start time and no rotation to follow.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
#
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user
|
||||
# namespaces. The rest of the nginx image is unchanged.
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 18091
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@@ -0,0 +1,407 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>Cuprate Node - Archipelago</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #000;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
background-image: url('assets/img/bg-network.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: .42;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
|
||||
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
|
||||
|
||||
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
|
||||
left, compact live status cards on the right. */
|
||||
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
|
||||
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
|
||||
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
|
||||
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
|
||||
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
|
||||
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
|
||||
.info-card-copy .sub { margin:0; font-size:.6875rem; }
|
||||
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
|
||||
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
|
||||
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
|
||||
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
|
||||
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
|
||||
[data-panel].tab-hidden { display:none; }
|
||||
@media (max-width:700px) {
|
||||
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
|
||||
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
|
||||
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
|
||||
.app-header-text { text-align:center; }
|
||||
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
|
||||
.app-header-actions > .info-card { width:100%; }
|
||||
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
|
||||
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
|
||||
}
|
||||
@media (min-width:701px) {
|
||||
.app-header { flex-wrap:nowrap; }
|
||||
.app-header-text { min-width:0; }
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
header h1 .accent { color: inherit; }
|
||||
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
padding: 0.35rem 0.8rem; border-radius: 999px;
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
|
||||
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
|
||||
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
|
||||
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
|
||||
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
|
||||
|
||||
.card-title {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
.stat:last-child { border-bottom: none; }
|
||||
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
|
||||
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
|
||||
.stat .value.warn { color: #f7931a; }
|
||||
.stat .value.err { color: #ef4444; }
|
||||
.stat .value.ok { color: #22c55e; }
|
||||
|
||||
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
|
||||
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
|
||||
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
|
||||
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.notice {
|
||||
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
|
||||
background: rgba(247, 147, 26, 0.08);
|
||||
border: 1px solid rgba(247, 147, 26, 0.35);
|
||||
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
|
||||
.notice b { color: white; }
|
||||
|
||||
.endpoint {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
|
||||
}
|
||||
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
|
||||
|
||||
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-layer"></div>
|
||||
<div class="wrap">
|
||||
<header class="glass-card" style="padding:1.5rem;">
|
||||
<div class="app-header">
|
||||
<div class="app-header-id">
|
||||
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
|
||||
<div class="app-header-text">
|
||||
<h1><span class="accent">Cuprate</span> Monero Node</h1>
|
||||
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="info-card">
|
||||
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
|
||||
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="offlineNotice" class="notice error" style="display:none;">
|
||||
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
|
||||
Check the service status and try again shortly.
|
||||
</div>
|
||||
|
||||
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
|
||||
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
|
||||
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
|
||||
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
|
||||
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
|
||||
</nav>
|
||||
|
||||
<div class="grid" style="margin-top:1rem;">
|
||||
<div class="glass-card" data-panel="node">
|
||||
<div class="card-title">Blockchain Sync</div>
|
||||
<div class="height-hero">
|
||||
<span class="big" id="height">—</span>
|
||||
<span class="of" id="targetOf">of —</span>
|
||||
</div>
|
||||
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
|
||||
<div class="progress-label" id="syncLabel">Waiting for node…</div>
|
||||
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
|
||||
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
|
||||
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="peers">
|
||||
<div class="card-title">Peers & Traffic</div>
|
||||
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
|
||||
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
|
||||
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
|
||||
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
|
||||
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
|
||||
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
|
||||
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="insights">
|
||||
<div class="card-title">Chain & Disk</div>
|
||||
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
|
||||
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
|
||||
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="connect">
|
||||
<div class="card-title">Connect a Wallet</div>
|
||||
<div class="endpoint">
|
||||
<span id="walletEndpoint">—</span>
|
||||
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Restricted RPC — Monero's own safe-for-public subset, what Feather,
|
||||
monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC
|
||||
stays container-loopback only and is never published.
|
||||
</div>
|
||||
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
|
||||
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
|
||||
Data served from this node's restricted RPC, refreshed every 15 seconds.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const RPC = 'cuprate-rpc/';
|
||||
const POLL_MS = 15000;
|
||||
|
||||
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const n = Number(bytes);
|
||||
if (!Number.isFinite(n) || n <= 0) return '—';
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let v = n, i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
||||
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatUptime(secs) {
|
||||
const s = Number(secs);
|
||||
if (!Number.isFinite(s) || s < 0) return '—';
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function setStat(id, value, cls) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
|
||||
el.className = 'value' + (cls ? ' ' + cls : '');
|
||||
}
|
||||
|
||||
function setStatus(kind, text) {
|
||||
document.getElementById('statusText').textContent = text;
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
dot.className = 'dot ' + kind;
|
||||
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
|
||||
}
|
||||
|
||||
async function callRpc(endpoint) {
|
||||
const response = await fetch(RPC + endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data && data.error) throw new Error(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
function render(info, heightFallback) {
|
||||
const height = info.height ?? heightFallback ?? 0;
|
||||
// Monero's get_info returns target_height == 0 when the node is
|
||||
// FULLY SYNCED — the field is the height being caught up to, not
|
||||
// the chain tip, so `??` cannot substitute for the 0 case (a
|
||||
// synced node would sit forever at "Syncing — 0.00%"). Treat
|
||||
// 0/absent as "target is our own height" — the same sentinel
|
||||
// core/archipelago/src/electrs_status.rs branches on.
|
||||
const rawTarget = info.target_height ?? info.target ?? 0;
|
||||
const target = rawTarget > 0 ? rawTarget : height;
|
||||
document.getElementById('height').textContent = tabular(height);
|
||||
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
|
||||
|
||||
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
|
||||
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
|
||||
|
||||
const synced = target > 0 && height >= target;
|
||||
if (synced) {
|
||||
document.getElementById('syncLabel').textContent = 'Fully synced';
|
||||
setStatus('online', 'Synced');
|
||||
} else {
|
||||
const behind = Math.max(0, target - height);
|
||||
document.getElementById('syncLabel').textContent =
|
||||
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
|
||||
setStatus('syncing', 'Syncing');
|
||||
}
|
||||
|
||||
const nettype = info.mainnet ? 'Mainnet'
|
||||
: info.testnet ? 'Testnet'
|
||||
: info.stagenet ? 'Stagenet'
|
||||
: (info.net || info.nettype || '—');
|
||||
setStat('nettype', nettype);
|
||||
document.getElementById('headerNetwork').textContent = nettype;
|
||||
document.getElementById('headerHeight').textContent = tabular(height);
|
||||
|
||||
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
|
||||
const started = info.start_time ?? info.startup_time;
|
||||
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
|
||||
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
|
||||
|
||||
setStat('outConns', tabular(info.outgoing_connections_count));
|
||||
setStat('inConns', tabular(info.incoming_connections_count));
|
||||
setStat('rpcConns', tabular(info.rpc_connections_count));
|
||||
setStat('whitePeers', tabular(info.white_peerlist_size));
|
||||
setStat('grayPeers', tabular(info.grey_peerlist_size));
|
||||
setStat('txPool', tabular(info.tx_pool_size));
|
||||
const alt = Number(info.alt_blocks_count || 0);
|
||||
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
|
||||
|
||||
setStat('difficulty', tabular(info.difficulty));
|
||||
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
|
||||
const free = info.free_space;
|
||||
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
|
||||
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
|
||||
|
||||
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
|
||||
document.getElementById('walletEndpoint').textContent =
|
||||
`${window.location.hostname}:18090`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let height = null;
|
||||
try {
|
||||
const h = await callRpc('get_height');
|
||||
height = h.height;
|
||||
} catch { /* get_info below carries the real error */ }
|
||||
try {
|
||||
const info = await callRpc('get_info');
|
||||
render(info, height);
|
||||
} catch {
|
||||
setStatus('offline', 'Node offline');
|
||||
if (height !== null) document.getElementById('height').textContent = tabular(height);
|
||||
}
|
||||
}
|
||||
|
||||
function copyEndpoint(btn) {
|
||||
const text = document.getElementById('walletEndpoint').textContent;
|
||||
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch { /* best effort */ }
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const tab = button.dataset.tab;
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
|
||||
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
|
||||
});
|
||||
});
|
||||
|
||||
// Height alone answers even while get_info is warming up; if both
|
||||
// fail the offline card explains the disk gate as a likely cause.
|
||||
refresh();
|
||||
setInterval(refresh, POLL_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
server {
|
||||
# Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui.
|
||||
# This container is host-networked, so nginx binds the HOST's address
|
||||
# directly; a bare `listen` would expose the page on LAN, Tailscale and
|
||||
# the mesh with the app gate nowhere in front of it. Binding loopback lets
|
||||
# the daemon claim the external addresses and authenticate them;
|
||||
# see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated).
|
||||
listen 127.0.0.1:18091;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Session gate for the RPC proxy below. Internal: reachable only by
|
||||
# nginx's own auth_request subrequest, never by a client.
|
||||
location = /_session_check {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:5678/auth/session-check;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
}
|
||||
|
||||
# Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open
|
||||
# — Monero's own safe-for-public subset, what remote-node wallets use).
|
||||
# It injects no credentials the caller lacks, but it is still session-
|
||||
# gated here so the whole companion behaves as one authenticated surface
|
||||
# (same defence-in-depth bitcoin-ui applies to its credential-injecting
|
||||
# proxy: loopback reaches it without the gate's challenge).
|
||||
location /cuprate-rpc/ {
|
||||
# Preflight carries no cookies by design — answer it before the gate,
|
||||
# otherwise the browser reports an opaque CORS failure instead of a 401.
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
auth_request /_session_check;
|
||||
proxy_pass http://127.0.0.1:18090/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Access-Control-Allow-Origin $scheme://$http_host always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Vary "Origin" always;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
}
|
||||
|
||||
# no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui:
|
||||
# a rebuilt companion image must actually be seen by the browser, while the
|
||||
# ETag still saves the transfer when nothing changed.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+60
-18
@@ -989,7 +989,7 @@
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
let unit = 'sats';
|
||||
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let peerSort = { col: 'peer', dir: 1 };
|
||||
let activityFilter = 'all';
|
||||
let logsLoaded = false;
|
||||
@@ -1142,9 +1142,19 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (state.refreshing) return;
|
||||
state.refreshing = true;
|
||||
const icon = document.getElementById('refreshIcon');
|
||||
if (icon) icon.classList.add('animate-spin-slow');
|
||||
try {
|
||||
state.readiness = await lndSafe('/archy-status', null);
|
||||
if (state.readiness && state.readiness.state.startsWith('waiting_')) {
|
||||
state.info = null;
|
||||
state.onchainStale = true;
|
||||
state.chanbalStale = true;
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
|
||||
lndSafe('/v1/getinfo', null),
|
||||
lndSafe('/v1/channels', { channels: [] }),
|
||||
@@ -1166,10 +1176,17 @@
|
||||
state.invoices = (invoices && invoices.invoices) || [];
|
||||
state.txns = (txns && txns.transactions) || [];
|
||||
|
||||
// Balances are separate so one failing endpoint can't blank the rest.
|
||||
state.onchain = await lndSafe('/v1/balance/blockchain', null);
|
||||
state.chanbal = await lndSafe('/v1/balance/channels', null);
|
||||
// Preserve known balances on outage; never decode an error as zero.
|
||||
const [onchain, chanbal] = await Promise.all([
|
||||
lndSafe('/v1/balance/blockchain', null),
|
||||
lndSafe('/v1/balance/channels', null),
|
||||
]);
|
||||
state.onchainStale = !validBalance(onchain && (onchain.confirmed_balance ?? onchain.total_balance));
|
||||
state.chanbalStale = !validBalance(chanbal && (chanbal.local_balance?.sat ?? chanbal.balance));
|
||||
if (!state.onchainStale) state.onchain = onchain;
|
||||
if (!state.chanbalStale) state.chanbal = chanbal;
|
||||
} finally {
|
||||
state.refreshing = false;
|
||||
if (icon) icon.classList.remove('animate-spin-slow');
|
||||
}
|
||||
renderAll();
|
||||
@@ -1192,11 +1209,17 @@
|
||||
const pill = document.getElementById('headerStatusPill');
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
|
||||
if (!g) {
|
||||
setText('headerStatusText', 'Unreachable');
|
||||
pill.className = 'pill bad';
|
||||
dot.className = 'status-dot-sm bg-red';
|
||||
document.getElementById('syncCard').style.display = 'none';
|
||||
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
|
||||
if (!g || waiting) {
|
||||
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
|
||||
pill.className = 'pill warn';
|
||||
dot.className = 'status-dot-sm bg-yellow';
|
||||
document.getElementById('syncCard').style.display = '';
|
||||
setText('syncSubtitle', waiting ? state.readiness.message + '. Lightning will become available automatically.' : 'Checking Lightning availability. Retrying automatically.');
|
||||
setText('syncBlockLabel', '');
|
||||
setText('syncPercent', '');
|
||||
document.getElementById('syncProgressBar').style.width = '0%';
|
||||
for (const id of ['syncChain', 'syncGraph', 'syncHeight', 'syncPeers']) setText(id, '—');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1237,6 +1260,11 @@
|
||||
}
|
||||
|
||||
// ── Balances ────────────────────────────────────────────────────
|
||||
function validBalance(value) {
|
||||
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
|
||||
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function renderBalances() {
|
||||
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
||||
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
||||
@@ -1253,22 +1281,23 @@
|
||||
const haveOnchain = !!state.onchain;
|
||||
const haveChan = !!cb;
|
||||
|
||||
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balLightning', haveChan ? lnLocal : null);
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND'
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
|
||||
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
|
||||
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||
|
||||
setText('liqLocal', fmtAmount(lnLocal));
|
||||
setText('liqRemote', fmtAmount(lnRemote));
|
||||
const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
|
||||
setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
|
||||
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
|
||||
const total = lnLocal + lnRemote;
|
||||
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
|
||||
document.getElementById('liqBarLocal').style.width = localPct + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
|
||||
setText('liqHint', total > 0
|
||||
document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
|
||||
setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
|
||||
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
|
||||
: 'Open a channel to start sending and receiving over Lightning.');
|
||||
}
|
||||
@@ -1284,6 +1313,15 @@
|
||||
|
||||
function renderSummary() {
|
||||
const g = state.info;
|
||||
if (!g) {
|
||||
for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—');
|
||||
for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND');
|
||||
for (const id of ['healthChain', 'healthGraph']) {
|
||||
const pill = document.getElementById(id);
|
||||
pill.textContent = '—'; pill.className = 'pill warn';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const chans = state.channels;
|
||||
const active = chans.filter(c => c.active).length;
|
||||
const inactive = chans.length - active;
|
||||
@@ -1321,6 +1359,10 @@
|
||||
function renderChannels() {
|
||||
const el = document.getElementById('channelList');
|
||||
if (!el) return;
|
||||
if (!state.info) {
|
||||
el.innerHTML = '<div class="empty-state">Waiting for LND. Existing channels will appear when it is ready.</div>';
|
||||
return;
|
||||
}
|
||||
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
|
||||
let list = state.channels.slice();
|
||||
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
|
||||
#
|
||||
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
|
||||
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
|
||||
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
|
||||
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
|
||||
# after any backend restart (podman reassigns the IP). See the script header.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
|
||||
# Keep the upstream startup logic; repair its rendered proxy configuration.
|
||||
# Publish this derived image under an Archipelago-specific tag, never the
|
||||
# upstream version tag that the registry mirror can overwrite.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
|
||||
FROM ${BASE}
|
||||
|
||||
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
|
||||
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
|
||||
# nginx -g "daemon off;") are inherited unchanged.
|
||||
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
|
||||
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
|
||||
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Mempool frontend DNS recovery
|
||||
|
||||
The stock v3.3.1 nginx configuration resolves `mempool-api` only when workers
|
||||
start. Recreating the backend can change its Podman address while the frontend
|
||||
continues to serve its static page, leaving all API/WebSocket requests offline.
|
||||
|
||||
Build and test the derived image before publishing:
|
||||
|
||||
```sh
|
||||
podman build --pull=never -t source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1 docker/mempool-frontend
|
||||
python3 scripts/test-mempool-dns-recovery.py
|
||||
```
|
||||
|
||||
The base is pinned by digest. The wrapper preserves upstream runtime options,
|
||||
then repairs all four local API/WebSocket routes after placeholder rendering.
|
||||
DNS is cached for five seconds using the container network resolver. Explicit
|
||||
rewrites preserve API prefixes and query arguments; backend absence does not
|
||||
prevent nginx startup. An unexpected upstream configuration fails startup
|
||||
instead of silently omitting the fix.
|
||||
|
||||
Use an Archipelago-specific image tag. Do not replace it with a stock upstream
|
||||
mirror when updating mempool. Every upstream update must rebuild this wrapper
|
||||
and pass the recovery test (backend absent, changed IP, HTTP and WebSocket
|
||||
mapping, repeated repair, and frontend restart).
|
||||
|
||||
Publish the tested image before publishing the signed app catalog. Both the
|
||||
mempool umbrella image mapping and the archy-mempool-web embedded manifest must
|
||||
point at the patched image. Keep scripts/image-versions.sh in sync. The frontend
|
||||
health check must reach `/api/v1/backend-info` through nginx, not only `/`.
|
||||
Regular → Executable
+4
-136
@@ -1,137 +1,5 @@
|
||||
#!/bin/sh
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
||||
|
||||
CONF=/etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
# ─── archipelago patch ────────────────────────────────────────────────────
|
||||
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
|
||||
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
|
||||
# start and caches it for the process lifetime. Podman reassigns the backend
|
||||
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
|
||||
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
|
||||
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
|
||||
#
|
||||
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
|
||||
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
|
||||
# location→URI rewriting, each block is rewritten to preserve its original
|
||||
# path mapping exactly:
|
||||
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
|
||||
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
|
||||
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
|
||||
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
|
||||
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
|
||||
# Resolver address: podman's aardvark-dns answers on the network gateway
|
||||
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
|
||||
# works on any podman network/subnet (and still falls back for Docker).
|
||||
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
|
||||
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
|
||||
|
||||
if ! grep -q 'set \$mp_backend' "$CONF"; then
|
||||
awk -v res_addr="$ARCHY_RESOLVER" '
|
||||
BEGIN { res = 0 }
|
||||
/^[[:space:]]*location / && res == 0 {
|
||||
print "\tresolver " res_addr " valid=10s ipv6=off;"
|
||||
res = 1
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
|
||||
fi
|
||||
# ─── end archipelago patch ────────────────────────────────────────────────
|
||||
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
cp /etc/nginx/nginx.conf /patch/nginx.conf
|
||||
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
|
||||
cat /patch/nginx.conf > /etc/nginx/nginx.conf
|
||||
|
||||
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
|
||||
export LIGHTNING=true
|
||||
fi
|
||||
|
||||
# Runtime overrides - read env vars defined in docker compose
|
||||
|
||||
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
|
||||
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
|
||||
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
|
||||
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
|
||||
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
|
||||
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
|
||||
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
|
||||
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
|
||||
__NGINX_PORT__=${NGINX_PORT:=8999}
|
||||
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
|
||||
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||
__BASE_MODULE__=${BASE_MODULE:=mempool}
|
||||
__ROOT_NETWORK__=${ROOT_NETWORK:=}
|
||||
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
|
||||
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
|
||||
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
|
||||
__LIGHTNING__=${LIGHTNING:=false}
|
||||
__AUDIT__=${AUDIT:=false}
|
||||
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__ACCELERATOR__=${ACCELERATOR:=false}
|
||||
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
|
||||
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
|
||||
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
|
||||
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
|
||||
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
|
||||
|
||||
# Export as environment variables to be used by envsubst
|
||||
export __MAINNET_ENABLED__
|
||||
export __TESTNET_ENABLED__
|
||||
export __TESTNET4_ENABLED__
|
||||
export __SIGNET_ENABLED__
|
||||
export __LIQUID_ENABLED__
|
||||
export __LIQUID_TESTNET_ENABLED__
|
||||
export __ITEMS_PER_PAGE__
|
||||
export __KEEP_BLOCKS_AMOUNT__
|
||||
export __NGINX_PROTOCOL__
|
||||
export __NGINX_HOSTNAME__
|
||||
export __NGINX_PORT__
|
||||
export __BLOCK_WEIGHT_UNITS__
|
||||
export __MEMPOOL_BLOCKS_AMOUNT__
|
||||
export __BASE_MODULE__
|
||||
export __ROOT_NETWORK__
|
||||
export __MEMPOOL_WEBSITE_URL__
|
||||
export __LIQUID_WEBSITE_URL__
|
||||
export __MINING_DASHBOARD__
|
||||
export __LIGHTNING__
|
||||
export __AUDIT__
|
||||
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __ACCELERATOR__
|
||||
export __ACCELERATOR_BUTTON__
|
||||
export __SERVICES_API__
|
||||
export __PUBLIC_ACCELERATIONS__
|
||||
export __HISTORICAL_PRICE__
|
||||
export __ADDITIONAL_CURRENCIES__
|
||||
|
||||
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
|
||||
echo ${folder}
|
||||
envsubst < ${folder}/config.template.js > ${folder}/config.js
|
||||
|
||||
exec "$@"
|
||||
set -eu
|
||||
# Preserve the pinned upstream entrypoint (including new runtime options).
|
||||
# Apply our DNS repair only after it has rendered the nginx configuration.
|
||||
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
# Resolve the backend again after container IP changes. Run after upstream
|
||||
# placeholder substitution, so the repair also works on an existing container.
|
||||
set -eu
|
||||
conf=${1:-/etc/nginx/conf.d/nginx-mempool.conf}
|
||||
resolv=${2:-/etc/resolv.conf}
|
||||
backend=${BACKEND_MAINNET_HTTP_HOST:-127.0.0.1}
|
||||
port=${BACKEND_MAINNET_HTTP_PORT:-8999}
|
||||
resolver=$(awk '/^nameserver/ { print $2; exit }' "$resolv")
|
||||
[ -n "$resolver" ] || { echo 'No DNS resolver configured' >&2; exit 1; }
|
||||
case "$resolver" in *:*) resolver="[$resolver]" ;; esac
|
||||
case "$backend" in *[!a-zA-Z0-9._-]*|'') echo 'Invalid backend hostname' >&2; exit 1 ;; esac
|
||||
case "$port" in *[!0-9]*|'') echo 'Invalid backend port' >&2; exit 1 ;; esac
|
||||
|
||||
tmp=$(mktemp "${conf}.archy.XXXXXX")
|
||||
trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
||||
awk -v backend="$backend" -v port="$port" -v resolver="$resolver" '
|
||||
BEGIN {
|
||||
base = "http://" backend ":" port
|
||||
print "# Archipelago: refresh backend DNS after container replacement."
|
||||
print "resolver " resolver " valid=5s ipv6=off; # archy-dns"
|
||||
print "resolver_timeout 3s; # archy-dns"
|
||||
}
|
||||
/# Archipelago: refresh backend DNS/ || /# archy-dns/ { next }
|
||||
/^[[:space:]]*location[[:space:]]/ { location = $2 }
|
||||
/^[[:space:]]*proxy_pass[[:space:]]/ && index($2, base) == 1 {
|
||||
target = $2
|
||||
sub(/;$/, "", target)
|
||||
path = substr(target, length(base) + 1)
|
||||
if (location != "/api/v1/ws" && location != "/ws" && location != "/api/v1" && location != "/api/") {
|
||||
print "Unexpected backend location: " location > "/dev/stderr"
|
||||
failed = 1; exit 1
|
||||
}
|
||||
if (path != "/" && path != "/api/v1" && path != "/api/v1/") {
|
||||
print "Unexpected backend URI mapping" > "/dev/stderr"
|
||||
failed = 1; exit 1
|
||||
}
|
||||
# Explicitly preserve prefix substitution and query arguments. A variable
|
||||
# proxy_pass without a URI forwards the rewritten URI and original args.
|
||||
print "\t\tset $mp_backend " backend ";"
|
||||
if (path != location)
|
||||
print "\t\trewrite ^" location "(.*)$ " path "$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:" port ";"
|
||||
count++
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/\$mp_backend:/ { count++ }
|
||||
{ print }
|
||||
END {
|
||||
if (failed || count != 4) {
|
||||
print "Expected four backend proxies; refusing an incomplete DNS repair" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$conf" > "$tmp"
|
||||
cat "$tmp" > "$conf"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
/patch/repair-nginx.sh
|
||||
nginx -t
|
||||
exec "$@"
|
||||
@@ -3,6 +3,47 @@
|
||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Framework incident — closed with operator acceptance
|
||||
|
||||
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
||||
missing Receive address / false zero balance.** Startup, native balances,
|
||||
Cashu address and source integration were verified; the operator accepted the
|
||||
remaining display check and authorized release. See the incident record for evidence.
|
||||
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||
and the repository `AGENTS.md` session-start instructions.
|
||||
|
||||
## Next release after 1.8.21 — reported 2026-09-30
|
||||
|
||||
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
|
||||
and appears underneath the pruning information.** Operator reports white
|
||||
styling with invisible text on the actual kiosk; the same flow works in remote
|
||||
Brave. Reproduce on the X250's kiosk engine and record its version, display
|
||||
scale and resolution. Inspect the native `<select>` in
|
||||
`neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
|
||||
scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
|
||||
not a confirmed cause. Fix contrast and popup visibility without changing
|
||||
version selection or pruning behavior. Validate Core and Knots, open/closed
|
||||
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
|
||||
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
|
||||
alone do not establish that the kiosk rendering is fixed. Track for the next
|
||||
release; the signed 1.8.21 artifacts remain unchanged.
|
||||
|
||||
## Current repair and release tasks — 2026-09-29
|
||||
|
||||
Release is blocked until these pass; see [execution record](repair-release-20260929.md).
|
||||
|
||||
- [ ] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
|
||||
mint errors, fees, and refund reporting before live validation.
|
||||
- [ ] Complete the remaining Framework incident verification and evidence.
|
||||
- [ ] Replace the unavailable tx1138.com explorer default with mempool.space;
|
||||
migrate the old default with fresh consent and preserve custom/local explorers.
|
||||
- [ ] Offer pruning in the Bitcoin installation version modal, using the same
|
||||
pruning settings as automatic pruning even on large disks.
|
||||
- [ ] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
|
||||
RPC readiness and show install/start/sync waiting states with automatic recovery.
|
||||
- [ ] Test the completed changes on this development box, then publish a new
|
||||
signed OTA and raw ISO release. Record any remaining verification gaps.
|
||||
|
||||
## Dev & build process (priority)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Incident — 2026-09-15: Minibits Cashu claim stuck retrying an already-redeemed token
|
||||
|
||||
## Report
|
||||
|
||||
User: "The cashu server is unable to get it's tokens from nostr on
|
||||
[affected node]" — clarified as the Cashu **client wallet**
|
||||
(Minibits `@minibits.cash` Lightning-address receive flow), not a mint
|
||||
server. UI showed: *"a payment arrived but couldn't be redeemed yet (1)"*.
|
||||
|
||||
## Root cause
|
||||
|
||||
`wallet::minibits::claim_and_redeem` (`core/archipelago/src/wallet/minibits.rs`)
|
||||
polls Nostr relays for NIP-04-encrypted Cashu tokens sent to the node's
|
||||
`@minibits.cash` address, decrypts them, and redeems them at the mint. A
|
||||
token that fails to redeem is kept in `MinibitsState.pending_claims` and
|
||||
retried on the next poll — by design, so a *transient* failure (mint briefly
|
||||
down, decrypt hiccup) never drops real money.
|
||||
|
||||
But one queued claim had already been redeemed (mint error **11001 "Token
|
||||
Already Spent"** — most likely double-delivered by the relay, or redeemed
|
||||
by an earlier run before a crash lost track of it). That's a *terminal*
|
||||
condition, not a transient one: the code didn't distinguish the two, so it
|
||||
retried the same dead claim every ~6 seconds forever:
|
||||
|
||||
```
|
||||
WARN archipelago::wallet::ecash: Failed to swap proofs from mint https://mint.minibits.cash/Bitcoin:
|
||||
This ecash has already been redeemed — it can't be claimed twice.: {"code":11001,"detail":"Token Already Spent"}
|
||||
WARN archipelago::wallet::minibits: Minibits claim decrypted but failed to redeem (...); will retry next poll
|
||||
```
|
||||
|
||||
Confirmed via `sudo journalctl -u archipelago.service` on the affected node,
|
||||
and via `/var/lib/archipelago/wallet/minibits.json`, which had exactly one
|
||||
`pending_claims` entry. Each poll also unconditionally queried all three
|
||||
`CLAIM_RELAY_URLS` (`relay.minibits.cash`, `relay.damus.io`, `nos.lol`)
|
||||
instead of the primary relay only, adding needless churn and leaking the
|
||||
wallet's Nostr pubkey to two relays it didn't need to touch — `relay.damus.io`
|
||||
was additionally failing NIP-42 auth / 503ing on every poll.
|
||||
|
||||
**No funds were at risk** — an already-redeemed token has zero remaining
|
||||
value. The only symptom was a permanently stuck "couldn't be redeemed yet"
|
||||
banner and wasted relay connections.
|
||||
|
||||
### Why this had already been "fixed" once and came back
|
||||
|
||||
This exact bug (terminal-11001 handling + relay-query reduction) was fixed
|
||||
on 2026-09-09 on branch `feat/minibits-lnurl-receive` (commits `4e410d7`,
|
||||
`489995c`) and pushed to `origin`. **That branch was never merged into
|
||||
`main`.** `main` carries its own, independently-diverged rewrite of
|
||||
`minibits.rs` that never got those two hardening fixes. The affected node
|
||||
OTA'd to `1.8.16-alpha` (built from `main`) earlier on 2026-09-15, so the bug
|
||||
resurfaced on the first replayed/double-delivered claim after that update.
|
||||
|
||||
## Fix
|
||||
|
||||
Two parts:
|
||||
|
||||
### 1. Immediate unstick (affected node, operational, no code change)
|
||||
|
||||
- Backed up `/var/lib/archipelago/wallet/minibits.json`.
|
||||
- Stopped `archipelago.service`, emptied `pending_claims` (`[]`) in the
|
||||
state file, restarted the service.
|
||||
- Verified via `journalctl` that polling resumed cleanly with no further
|
||||
"already been redeemed" warnings.
|
||||
|
||||
### 2. Code fix, ported into `main`
|
||||
|
||||
- **`core/archipelago/src/wallet/mint_client.rs`**: exposed the existing
|
||||
NUT error-code-11001 translation as a public constant,
|
||||
`ALREADY_REDEEMED_MSG`, and a typed `AlreadyRedeemed` condition identified
|
||||
only by the structured mint error code. Remote text cannot impersonate it.
|
||||
- **`core/archipelago/src/wallet/minibits.rs`**:
|
||||
- Added `is_already_redeemed(&anyhow::Error) -> bool`, checking the error
|
||||
chain for the typed `AlreadyRedeemed` condition. The ecash receive path
|
||||
preserves it only when all failed mint entries report already-spent proofs;
|
||||
mixed terminal/transient failures remain retryable.
|
||||
- In the claim redeem loop, a redeem failure matching
|
||||
`is_already_redeemed` is now dropped (logged at `info!`, not retried)
|
||||
instead of being pushed back onto `pending_claims`. Every other failure
|
||||
still retries next poll, unchanged.
|
||||
- `fetch_relay_dms` now connects to `RELAY_URL` (the Minibits relay)
|
||||
alone first via `try_connect_relay`, and only adds the two public
|
||||
fallback relays (`relay.damus.io`, `nos.lol`) if that primary relay is
|
||||
unreachable. Also paginates the DM fetch (200/page, capped at 5 pages)
|
||||
backward with an inclusive `until` boundary. The cursor persists across
|
||||
polls when capped or interrupted, independently of the forward watermark.
|
||||
A full same-second boundary is fetched with a larger limit rather than
|
||||
skipped, so multiple payments sharing a timestamp remain reachable.
|
||||
|
||||
Deliberately **not** ported from the unmerged branch: its `STATE_LOCK`
|
||||
skip-if-busy guard and per-claim attempt-count backstop. `main`'s existing
|
||||
`MINIBITS_STATE_LOCK` already fully serializes claim polls (blocks rather
|
||||
than skips — a different but equally valid way to close the same race), and
|
||||
an attempt-count backstop would have required reshaping the `PendingClaim`
|
||||
enum for marginal extra protection beyond what the 11001 fix already covers.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo build -p archipelago` — clean, no new warnings.
|
||||
- `cargo test -p archipelago --bin archipelago wallet::minibits` — existing
|
||||
suite still green (see PR/commit for the run).
|
||||
- Live on the affected node: claim poll loop confirmed quiet post-unstick
|
||||
(only `relay.minibits.cash` connects logged, no redeem-failure warnings).
|
||||
|
||||
## Lesson (recorded in memory)
|
||||
|
||||
A fix that lives only on an unmerged feature branch is not a fix that's
|
||||
actually deployed. Before trusting a memory or changelog claim that
|
||||
something "shipped," check which branch the running/released build was
|
||||
built from (`git log <branch>..main` / `main..<branch>`) rather than
|
||||
assuming a pushed branch was merged.
|
||||
|
||||
## Pre-merge review regressions
|
||||
|
||||
- A 450-event newest-first backlog is completely fetched.
|
||||
- 250 distinct payments sharing one timestamp are preserved.
|
||||
- A 1,300-event backlog resumes after the five-page cap and a state reload.
|
||||
- An interrupted relay fetch retains its unfinished cursor.
|
||||
- Only structured error 11001 is terminal, including when errors are wrapped;
|
||||
remote free text and mixed mint failures cannot discard a retryable claim.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Framework: LND startup, missing Receive address, false zero balance
|
||||
|
||||
**Status: CLOSED WITH OPERATOR ACCEPTANCE — startup, native balances, Cashu address and source integration verified; user accepted the remaining display check and authorized release on 2026-09-30.**
|
||||
|
||||
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
|
||||
The Framework's installed version and exact incident time have not been verified.
|
||||
|
||||
## Mandatory priority across sessions
|
||||
|
||||
The user explicitly requested that this be investigated and fixed on the node
|
||||
before resuming unrelated work in later sessions. `AGENTS.md` in the repository
|
||||
and `/home/archipelago/.codex/AGENTS.md` carry this session-start priority.
|
||||
Only live verification below, or an explicit user change of priority, clears it.
|
||||
|
||||
## Reported observations
|
||||
|
||||
- Framework stopped showing its Lightning address in Receive.
|
||||
- After a restart, LND did not initialize and the UI displayed a balance of zero.
|
||||
- Manually restarting LND restored operation.
|
||||
- Node access will be supplied later. No Framework connection, restart, wallet
|
||||
operation, or deployment was performed during this offline investigation.
|
||||
- Still clarify whether the restart was a full reboot or management-service
|
||||
restart, and which Receive item vanished: a Lightning invoice, an on-chain
|
||||
address, or the Cashu tab's `@minibits.cash` address.
|
||||
|
||||
A successful manual restart is a workaround, not a root cause or durable fix.
|
||||
The zero display does not establish that any funds were lost. Its relation to
|
||||
v1.8.17-alpha is unknown; do not infer a release regression from timing alone.
|
||||
|
||||
## Confirmed source findings
|
||||
|
||||
### 1. LND errors can be presented as successful zero balances
|
||||
|
||||
`core/archipelago/src/api/rpc/lnd/info.rs`, `handle_lnd_getinfo`:
|
||||
|
||||
- `/v1/getinfo` is decoded without checking HTTP success. Its response fields are
|
||||
optional, so an error object such as `{"code":14,"message":"wallet not ready"}`
|
||||
can deserialize with every expected field absent instead of rejecting the call.
|
||||
- Channel and blockchain balance requests suppress connection/JSON failures and
|
||||
substitute responses with absent balances. HTTP status is not checked here either.
|
||||
- Missing or unparsable balances become `0` through `unwrap_or(0)`.
|
||||
- `neode-ui/src/views/Home.vue`, `loadWeb5Status`, treats this RPC response as
|
||||
success, sets the wallet connected flag, overwrites prior balances, and can
|
||||
persist the false zero in the wallet snapshot. Its existing failure handling
|
||||
preserves prior balances only when the RPC actually rejects.
|
||||
|
||||
This is a confirmed code defect and a plausible explanation for the reported
|
||||
display. It is not proof of the Framework's failure sequence.
|
||||
|
||||
Required fix: reject unsuccessful/incomplete LND balance responses or model
|
||||
availability explicitly end to end. Never translate unavailable data into a
|
||||
verified zero. Preserve known balances with a clear unavailable/stale indication;
|
||||
show an unknown state when no valid balance is known. Genuine successful zeros
|
||||
must still render as zero. Cover outage, partial failure, cold load, and recovery.
|
||||
|
||||
### 2. Startup readiness and wallet unlock need live evidence
|
||||
|
||||
- `main.rs` runs crash/container boot recovery before starting the reconciler.
|
||||
- `crash_recovery.rs` can start existing containers directly.
|
||||
- `container/prod_orchestrator.rs` runs LND post-start hooks on explicit restart
|
||||
and on normal reconciliation of already-running containers. Therefore it is
|
||||
incorrect to conclude that running containers categorically skip unlock.
|
||||
- `container/lnd.rs::ensure_wallet_initialized` checks wallet existence and
|
||||
`/v1/getinfo`, then attempts unlock. Its unlock wait budget is approximately ten
|
||||
minutes; per-request timeouts can extend elapsed time. Historical comments
|
||||
describe slow database startup and restart loops, but that is not Framework evidence.
|
||||
- `health_monitor.rs` models LND's Bitcoin dependency. Container-running state
|
||||
alone is not proof of wallet readiness, Bitcoin connectivity, or invoice readiness.
|
||||
|
||||
Investigate boot ordering, Bitcoin readiness, listener/port mapping, wallet unlock,
|
||||
mount availability, stopped markers, restart counters, and actual reconcile logs.
|
||||
|
||||
### 3. Destructive automatic recovery exists; exclude it from diagnosis
|
||||
|
||||
`container/lnd.rs::ensure_wallet_initialized` calls
|
||||
`recreate_wallet_destructively` when all candidate passwords are rejected. That
|
||||
function can delete the LND chain and graph data directories. Its comment assumes
|
||||
alpha wallets hold no real funds; that assumption must not guide this investigation.
|
||||
|
||||
No evidence establishes that it ran on Framework. Preserve the original wallet
|
||||
and channels; rejected passwords must lead to a recoverable error, not automatic
|
||||
wallet deletion. Review and disable this destructive fallback before using a
|
||||
modified initialization path as a repair. The existing
|
||||
`unlock_existing_wallet_no_wipe` demonstrates the non-destructive error behavior.
|
||||
|
||||
### 4. The missing address must be identified precisely
|
||||
|
||||
`ReceiveBitcoinModal.vue` generates Lightning invoices using `lnd.createinvoice`
|
||||
after a readiness check, and Bitcoin addresses using `lnd.newaddress`. Its Cashu
|
||||
Lightning address uses `wallet.ecash-lnaddress` and the Minibits service separately.
|
||||
Do not assume the Minibits address disappears because LND is down. Trace the actual
|
||||
tab and response once the user clarifies and the node can be inspected.
|
||||
|
||||
## Next session: live investigation order
|
||||
|
||||
1. Request Framework access and verify node identity without publishing its hostname,
|
||||
address, credentials, or wallet identifiers. Do not substitute the development box.
|
||||
2. Record installed backend/image versions, boot and incident timestamps, and exact
|
||||
restart/action sequence. Capture current and previous-boot management/LND logs
|
||||
before another restart can obscure evidence. Keep raw logs private and redact
|
||||
secrets, invoices, wallet identifiers, and personally identifying data in summaries.
|
||||
3. Read container/service state, restart counters, mounts, stopped markers, listener
|
||||
mappings, Bitcoin readiness, LND wallet state, and authenticated API results.
|
||||
Never dump container environments, macaroons, passwords, seeds, or wallet databases.
|
||||
4. Compare HTTP status and data from LND getinfo/balance endpoints with the RPC and
|
||||
visible Receive/balance state. Distinguish unavailable data, locked wallet,
|
||||
syncing wallet, and genuine zero. Preserve last-known balance evidence privately.
|
||||
5. Establish whether the manual restart ran a missing/failed hook, waited out a
|
||||
dependency, refreshed networking/credentials, or masked another failure.
|
||||
6. Implement the evidenced startup repair and unavailable-balance handling with
|
||||
regressions. Preserve wallet/channel state and arrange recovery access before
|
||||
deploying or deliberately rebooting the node.
|
||||
|
||||
## Acceptance criteria — all required to close
|
||||
|
||||
- [x] Root cause of Framework startup failure supported by node evidence.
|
||||
- [x] Fix implemented and focused regression tests pass.
|
||||
- [ ] Failed, locked, delayed, and partial LND responses never masquerade as a
|
||||
fresh zero balance; genuine zero remains correct.
|
||||
- [x] Existing wallet identity and channel state preserved through the repair.
|
||||
- [x] Framework starts LND and reaches usable wallet readiness after a controlled
|
||||
full reboot, without manually restarting LND.
|
||||
- [ ] The originally affected Receive flow works after boot and after recovery;
|
||||
outages show an actionable state and recover without requiring a page reload.
|
||||
- [ ] Display confirmation pending; authenticated LND balances match pre-reboot values.
|
||||
- [x] LND logs show no restart loop, repeated unlock failure, or wallet-recreation path.
|
||||
- [ ] Evidence, tested versions, deployment, and limitations recorded here; user
|
||||
informed of live results. Only then set status RESOLVED and clear the blockers.
|
||||
|
||||
## Work completed so far
|
||||
|
||||
2026-09-15: source investigation and persistent session-start instructions only.
|
||||
No code fix, release, node deployment, or live reproduction for this incident yet.
|
||||
|
||||
## Live evidence captured 2026-09-15
|
||||
|
||||
Access was provided during the same session. Read-only inspection confirmed:
|
||||
|
||||
- Framework runs `1.8.17-alpha-dev`; the current full boot began at 18:40:09 UTC.
|
||||
- LND opened its databases in 6.7 seconds and requested its wallet password at
|
||||
18:40:20. It then rejected GetInfo/ChannelBalance/WalletBalance as wallet locked.
|
||||
- The management service's first sequential reconcile pass was occupied by
|
||||
unrelated image recovery, including a missing voice image from 18:40:24 and
|
||||
later a missing Core Lightning image. Manifests are iterated from a HashMap;
|
||||
wallet readiness has no initial priority. Boot recovery itself completed at
|
||||
18:40:18; the first full app-reconcile report appeared at 18:44:34.
|
||||
- The user's manual LND restart was recorded at 18:42:33. The replacement LND
|
||||
process started at 18:42:40, requested its password at 18:43:05, and unlocked
|
||||
at 18:43:07 through the explicit restart hook. This supports delayed unlock
|
||||
behind unrelated recovery, rather than a missing wallet or bad password.
|
||||
- At inspection, `/v1/state` reports SERVER_ACTIVE; getinfo reports chain and
|
||||
graph sync and two active channels. Both authenticated balance endpoints
|
||||
report nonzero balances. No wallet-recreation event was found in captured logs.
|
||||
- The Minibits RPC separately fails with “The ecash wallet has no seed yet”.
|
||||
`wallet/cashu_seed.json` and `wallet/minibits.json` are absent. The existing
|
||||
ecash wallet is present with proofs and an August modification timestamp.
|
||||
Do not overwrite it or generate an unrelated recovery identity. Still identify
|
||||
which Receive item the user meant before declaring this part repaired.
|
||||
|
||||
Private raw evidence: `/home/archipelago/.local/state/archy-incidents/framework-lnd-20260915/`.
|
||||
Files have mode 0600 and the directory 0700. Do not commit or publish raw logs.
|
||||
|
||||
Candidate changes on `investigate/framework-lnd-startup`:
|
||||
|
||||
- Run Bitcoin and LND reconciliation before unrelated image pulls/builds.
|
||||
- Reject failed/incomplete LND balance responses instead of manufacturing zeros.
|
||||
- Preserve known Home balances on invalid responses, visibly label unavailable
|
||||
balances, and clear the warning after a successful refresh.
|
||||
- Remove automatic destructive wallet recreation; failed unlock preserves data.
|
||||
- Add backend outage/zero/ordering regressions and UI failure/recovery coverage.
|
||||
|
||||
These changes are not yet deployed or verified through a Framework reboot.
|
||||
|
||||
### Candidate validation and staging
|
||||
|
||||
Source fix commit: `4237fb5e` on `investigate/framework-lnd-startup`.
|
||||
|
||||
- 44 focused backend tests passed (including LND errors, genuine zero, startup ordering).
|
||||
- 58 additional reconciliation/update tests passed.
|
||||
- 12 Home UI tests passed, including outage/partial response/cold-load/recovery cases.
|
||||
- Rust formatting, frontend type checking and production build passed.
|
||||
- Optimized backend build passed in 8m02s.
|
||||
- Both candidate artifacts were copied to Framework and SHA-256 matched locally.
|
||||
- Private on-node baseline and static channel backup are under
|
||||
`/var/lib/archipelago/support/framework-lnd-20260915/`, along with the previous
|
||||
backend, dashboard, and `rollback.sh`. This directory is root-only.
|
||||
- Candidate staged at `/tmp/archy-framework-candidate/`; not applied yet.
|
||||
- A timing confirmation for the maintenance restart/full reboot was requested
|
||||
because it interrupts all node services. Do not reboot while that is pending.
|
||||
- SSH works through the temporary control socket
|
||||
`/tmp/archy-framework-connection/control`. No SSH password was saved to disk.
|
||||
- The supplied SSH password did not authenticate to the dashboard. Do not guess
|
||||
additional passwords or alter dashboard authentication. Native LND diagnostics
|
||||
are authenticated using its existing local macaroon without printing it.
|
||||
|
||||
Status remains OPEN until deployment and live boot/Receive/balance verification.
|
||||
|
||||
### Authorized deployment and full reboot — 2026-09-15
|
||||
|
||||
The user answered “yes please” to applying the staged fix and rebooting. Timing
|
||||
approval is no longer pending. Applied the staged backend and dashboard after
|
||||
rechecking both checksums and rollback copies. There were no pending channel
|
||||
HTLCs at reboot. No wallet data, secrets, or recovery identities were replaced.
|
||||
|
||||
Live results:
|
||||
|
||||
- A different boot ID confirms a full reboot occurred.
|
||||
- Running backend on disk matches candidate SHA-256
|
||||
`5a354f76ebe619561eef0d318e4f41f177d04004682504d7434d632733f8e298`.
|
||||
- Management service started around 19:23:57 UTC; LND asked for its wallet
|
||||
password at 19:24:10 and logged automatic unlock at 19:24:18. No manual LND
|
||||
restart or interactive unlock was used after this reboot.
|
||||
- LND reports SERVER_ACTIVE and chain sync. Its identity and channel-point set
|
||||
are identical to the private pre-reboot baseline; both channels are active.
|
||||
- On-chain and Lightning balances exactly equal the pre-reboot values.
|
||||
- LND container and systemd restart counts are zero after recovery.
|
||||
- Public HTTP checks on the node returned 200 for the dashboard index and new
|
||||
Home bundle; their bytes match the installed candidate, including the new
|
||||
unavailable-balance notice.
|
||||
- Captured post-reboot management and LND journals in the private local evidence
|
||||
directory. Detailed before/after identity, channel, and balance records remain
|
||||
in the root-only support directory on Framework.
|
||||
|
||||
The user was asked to refresh the dashboard and confirm the originally missing
|
||||
Receive item and displayed balances. Keep OPEN until that reply is assessed;
|
||||
Minibits seed absence was a separate finding and must not be mistaken for an
|
||||
LND startup failure. Candidate is a direct node deployment, not a newly signed
|
||||
fleet release. The source branch must be integrated before a subsequent release
|
||||
can preserve this fix across the fleet.
|
||||
|
||||
### Cashu Receive follow-up
|
||||
|
||||
The user confirmed that the remaining error is specifically on the Ecash tab:
|
||||
“Lightning address unavailable — you can still paste a token below.”
|
||||
|
||||
Read-only checks confirm Framework has an encrypted node master seed, existing
|
||||
Cashu proofs, and neither `wallet/cashu_seed.json` nor `wallet/minibits.json`.
|
||||
The existing Minibits handler requires an ecash seed, but setup was available
|
||||
only through the Settings backup screen; Receive hid the actionable cause.
|
||||
|
||||
UI fix commit: `a3b64670`.
|
||||
|
||||
- Receive checks the non-secret seed status when registration fails.
|
||||
- Unseeded wallets get the existing password/TOTP/backup-passphrase-verified setup
|
||||
component directly in Receive, with import/restore controls excluded from this
|
||||
focused setup screen. Setup derives from the saved node seed when present.
|
||||
- The recovery words stay in the existing local reveal UI, are cleared on Done,
|
||||
and are never emitted to Receive. Receive retries registration after Done.
|
||||
- Seeded wallets with service outages get Retry, without offering a new identity.
|
||||
- Ten focused Receive/backup tests and the production UI build passed.
|
||||
- Deployed the dashboard change without restarting services; live HTTP index and
|
||||
setup bundle returned 200 and byte-matched the candidate.
|
||||
- Backed up original Cashu proofs to the root-only support directory as
|
||||
`ecash-before-address-setup.json`. No seed or proof mutation was performed by
|
||||
the assistant. Prior LND-fixed dashboard is also backed up there.
|
||||
|
||||
The user was asked to refresh Receive → Ecash → Set up address, authenticate in
|
||||
that node UI, and click Done. Dashboard password is required to decrypt the node
|
||||
seed; the SSH password did not authenticate to the dashboard. Do not request or
|
||||
print recovery words, bypass authentication, or create an unrelated random seed.
|
||||
After completion, verify saved seed/profile presence, registration success,
|
||||
address display, and unchanged original proofs before closing the incident.
|
||||
|
||||
### Cashu setup completed and verified — 2026-09-15
|
||||
|
||||
The user initially reported a forgotten passphrase, then said “did it now”. No
|
||||
independent-seed fallback was implemented or used. The user completed the existing
|
||||
password-verified setup themselves; the assistant did not receive recovery words.
|
||||
|
||||
Read-only node verification confirmed:
|
||||
|
||||
- `wallet/cashu_seed.json` exists, is nonempty, and records source `node-seed`.
|
||||
- `wallet/minibits.json` exists with a `@minibits.cash` address and no pending claims.
|
||||
- The original ecash wallet file is byte-for-byte unchanged from the protected
|
||||
pre-setup copy; every original proof is preserved.
|
||||
- The registered address's public LNURL-pay metadata returns HTTP 200, tag
|
||||
`payRequest`, an HTTPS callback, and a valid amount range. No invoice was paid
|
||||
and no funded payment test was performed.
|
||||
|
||||
LND automatic startup and native balances were already verified after the full
|
||||
reboot. Cashu setup and address registration are now also verified on Framework.
|
||||
Do not ask for the forgotten passphrase again or propose a replacement Cashu seed.
|
||||
|
||||
Remaining: integrate the tested source branch before the next fleet release;
|
||||
record final human confirmation of the rendered dashboard balance (native balances
|
||||
match exactly, and UI failure/recovery regressions pass). Keep this follow-up
|
||||
visible across sessions; do not rebuild/reboot/reinitialize a working wallet just
|
||||
to repeat already completed checks.
|
||||
|
||||
### Backup copy and layout — 2026-09-15
|
||||
|
||||
At the user's request, shortened the ecash backup explanations and stacked each
|
||||
card section's text and full-width action vertically. Kept the distinction
|
||||
between node-derived and separate phrases, and the warning that a newly created
|
||||
phrase covers future coins rather than existing legacy coins.
|
||||
|
||||
All 10 Receive/backup tests and the production UI build pass. Deployed the UI to
|
||||
Framework without a restart; served index and backup-component bundle match the
|
||||
build byte-for-byte. The prior UI is saved as `web-ui-before-backup-copy` in the
|
||||
protected incident directory. Source integration and final rendered dashboard
|
||||
balance confirmation remain pending as above.
|
||||
|
||||
### LNURL comment-length report — 2026-09-15
|
||||
|
||||
User reports a maximum-comment-length error in some sending wallets. Live
|
||||
Framework address metadata advertises integer `commentAllowed: 100`. The QR
|
||||
contains the address only; Archy's Receive UI does not add a comment. The
|
||||
Minibits-hosted callback returned invoices for omitted/empty comments, 100 ASCII
|
||||
characters, 101 ASCII characters, and 100 accented characters. These were unpaid
|
||||
invoice requests at the advertised minimum amount; no funds were sent.
|
||||
|
||||
The callback did not reproduce the error, including beyond its advertised limit.
|
||||
Sending-wallet validation against the advertised 100-character limit is therefore
|
||||
a hypothesis, not a confirmed root cause. Asked which wallets fail and whether
|
||||
an empty comment also fails. Need that result before selecting a code fix.
|
||||
The service controls the advertised limit; changing local Receive text or QR
|
||||
cannot raise it for other wallets.
|
||||
|
||||
### Primal Spark: automatic recipient note exceeds the address limit
|
||||
|
||||
User clarified that no comment was entered and the sender is Primal Spark.
|
||||
Checked Framework's management journal over the preceding 20 minutes: no
|
||||
comment-length errors, service active, and zero pending Minibits claims. Recent
|
||||
claim polling connected to and disconnected from the relay normally. Historical
|
||||
seed-authentication failures preceded the successful setup already documented.
|
||||
|
||||
The live address's Minibits `text/plain` description is **101 ASCII characters**,
|
||||
while `commentAllowed` is **100**. Description template (address redacted):
|
||||
`Pay to [ADDRESS] with Lightning. Receiver will receive ecash into Minibits Wallet.`
|
||||
|
||||
Primal Android source at `36939db97213e7f8eeefaa4adaf125d839fc662e`:
|
||||
- `WalletTextParserImpl.handleLnUrlText` assigns the parsed description to
|
||||
`DraftTx.noteRecipient`, including for Lightning-address input.
|
||||
- `TransactionEditor` initializes its editable recipient note from that value.
|
||||
- `SparkWalletServiceImpl` passes it untrimmed to `PrepareLnurlPayRequest.comment`.
|
||||
- Breez Spark source at `8bb38ec292a590907360c4e7f2a4134b8f09de9e`,
|
||||
`common/src/lnurl/pay.rs::validate_user_input`, rejects a comment exceeding the
|
||||
limit with the exact reported error before requesting the callback.
|
||||
|
||||
This identifies a concrete compatibility failure: the address description can
|
||||
become an automatic over-limit comment without the sender typing anything.
|
||||
The user confirmed that explicitly clearing the prefilled recipient note made
|
||||
the payment work, and supplied the same description observed in live metadata.
|
||||
This confirms the automatic-comment compatibility failure. The installed Primal
|
||||
platform/version was not captured. Node logs alone cannot show sender-side
|
||||
validation or requests to the external Minibits callback.
|
||||
|
||||
Durable upstream correction: Primal should keep receiver metadata separate from
|
||||
the sender's comment and enforce the limit on actual user comments. Minibits can
|
||||
also shorten its description or raise its advertised comment limit. Archy does
|
||||
not serve this external LNURL metadata; do not rename an existing wallet address,
|
||||
rotate its seed, or claim that a local dashboard edit fixes this sender behavior.
|
||||
|
||||
### Primal workaround confirmed by user
|
||||
|
||||
The user confirmed successful payment after removing the automatic description.
|
||||
The permanent sender-side correction is to leave the recipient comment empty by
|
||||
default and retain receiver metadata only as display text. In Primal Android,
|
||||
remove the assignment of the LNURL description to the draft recipient note in
|
||||
`WalletTextParserImpl.handleLnUrlText`; also validate explicitly entered comments
|
||||
against the endpoint's limit. No upstream change has been submitted or deployed.
|
||||
Existing Framework addresses and wallet identities remain unchanged.
|
||||
|
||||
### Can Archy shorten the current address description?
|
||||
|
||||
Inspected Minibits' public wallet client (`src/services/minibitsService.ts`,
|
||||
`updateWalletProfile`) and `WalletProfileRecord`. The supported profile update
|
||||
fields are name, lud16, and avatar; there is no exposed LNURL description or
|
||||
comment-limit setting. Its public web repository also contains no implementation
|
||||
of the LNURL metadata endpoint or description template.
|
||||
|
||||
For the existing `@minibits.cash` address, no supported client-side mechanism
|
||||
to shorten this text was found. Do not send guessed profile-update fields or
|
||||
rename the address to disguise the problem. A Minibits server change could use
|
||||
`Pay to [ADDRESS]`, well below the current limit. Controlling this metadata in
|
||||
Archy would instead require an Archy-hosted LNURL service/address and correct
|
||||
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
||||
is insufficient. No wallet/profile mutations were made during this investigation.
|
||||
|
||||
### Source integration confirmed — 2026-09-29
|
||||
|
||||
`git merge-base --is-ancestor 4237fb5e HEAD` succeeds on main at
|
||||
`540639d2`. The previously tested startup ordering, safe unlock, and unavailable
|
||||
balance fixes are integrated and included in the intervening releases. The
|
||||
earlier “source integration pending” notes above are historical, not current.
|
||||
The user reports no further Framework incidents. Requested final confirmation
|
||||
of rendered balances and Receive; do not mark closed without that response.
|
||||
|
||||
A separate startup failure was observed on the development box today when Core
|
||||
was installed against existing block data: Core made steady replay progress,
|
||||
while LND exited on its short “bitcoind start timeout”. Candidate work defers
|
||||
unlock until authenticated Bitcoin RPC answers, with dependency waiting states
|
||||
in the LND UI. This is not evidence of a new failure on Framework.
|
||||
|
||||
### Operator acceptance and release authorization — 2026-09-30
|
||||
|
||||
After being told that final rendered balance/Receive confirmation remained and
|
||||
SSH access was unavailable, the user replied: “that's fine I believe it'd fixed,
|
||||
please release”. This explicitly accepts proceeding past the remaining human
|
||||
display check. Close this incident with operator acceptance based on the earlier
|
||||
controlled reboot, preserved identity/channels/native balances, working Receive
|
||||
address/payment, source integration, and the user's report of no further issues.
|
||||
No new direct Framework inspection or on-screen verification is claimed today.
|
||||
Reopen investigation if the original startup, Receive, or false-zero symptom
|
||||
recurs; preserve the wallet and channels.
|
||||
@@ -0,0 +1,358 @@
|
||||
# Repair and release execution — 2026-09-29
|
||||
|
||||
**Status: IN PROGRESS. Do not publish an OTA or ISO until the release gates pass.**
|
||||
|
||||
User requires all tasks completed and tested on the development box before the
|
||||
next OTA and raw ISO. Passing unit tests alone does not establish live correctness.
|
||||
|
||||
## Confirmed evidence
|
||||
|
||||
- Dev-to-Shorty 100-sat Cashu file purchases failed twice. Both sellers' and
|
||||
buyers' accepted mints match. Shorty's mint swap returned HTTP 422; both
|
||||
attempted purchases were refunded 100 sats. The old message guessed a mint
|
||||
mismatch without evidence.
|
||||
- Wallet import repaired truncated V2 keyset IDs, while paid-content redemption
|
||||
bypassed that repair. Central swap repair and protocol-level regression tests now pass.
|
||||
- Core installation on dev reused existing chain data. At 17:42 UTC it was
|
||||
advancing through block replay with no Core container restarts. At 17:49 UTC
|
||||
it had connected to peers and started transaction-index synchronization.
|
||||
- LND exited repeatedly with `bitcoind start timeout` while Core loaded. After
|
||||
Core became available LND stayed running and reported waiting for backend sync.
|
||||
- Framework source fix 4237fb5e is already an ancestor of main. Existing live
|
||||
reboot/native balance evidence is in the incident document. Final display
|
||||
confirmation remains pending.
|
||||
|
||||
## Changes under validation
|
||||
|
||||
- Cashu V4/V2 ID expansion at every swap; fee-aware underpayment rejection;
|
||||
single-mint/sat-only/cryptographic paid tokens; no false mint-mismatch or
|
||||
unconditional refund claims. Missing content checked before redemption.
|
||||
- mempool.space default; migrate old tx1138 default with fresh consent, retain
|
||||
local explorer priority and custom preferences.
|
||||
- Core/Knots optional pruning on the version modal and app detail install path;
|
||||
persist choice across runtime restarts; use identical 50,000 MiB automatic
|
||||
pruning entrypoint behavior on large and small disks.
|
||||
- Plain Bitcoin block-index startup message; defer LND wallet initialization or
|
||||
unlock until Bitcoin RPC is usable; authenticated dependency status and LND UI
|
||||
waiting states; no partial total displayed as a complete balance.
|
||||
|
||||
## Validation and release gates
|
||||
|
||||
- [x] Final backend regression suite passes (including mock mint HTTP and real
|
||||
curve signatures, v1/full-v2/truncated-v2, fees, errors, duplicate redemption).
|
||||
- [x] Initial explorer and pruning modal tests pass: 15 tests.
|
||||
- [x] Both actual manifest entrypoints tested with isolated fake bitcoind across
|
||||
6 disk/choice combinations each. No existing chain pruned for this test.
|
||||
- [x] Initial LND UI install/start/sync/recovery and invalid-balance tests pass.
|
||||
- [x] Frontend production build and relevant existing wallet tests pass (34
|
||||
focused tests, including 12 Home failure/recovery checks). Final UI suite: 1,120 passed; production build passed. Full release harness and final frontend follow-up passed.
|
||||
- [x] Fault tests and final source review complete.
|
||||
- [x] Candidate deployed with rollback to dev and Shorty; hashes verified.
|
||||
- [x] Live paid-file purchase succeeds; failed purchase/refund behavior verified.
|
||||
- [x] Live waiting/UI verified on dev; recovery covered by deterministic tests.
|
||||
- [x] Framework operator acceptance and authorization to release recorded.
|
||||
- [x] Release version/changelog, catalog/image implications, signing prepared.
|
||||
- [ ] Signed OTA built, tested, published to git and ngit.
|
||||
- [ ] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||
|
||||
Tests must not wipe/recreate wallets, prune the operator's existing full chain,
|
||||
or claim that arbitrary failures can never happen. Record material gaps before
|
||||
release. Signing keys remain with the user; prepare concrete artifacts first.
|
||||
|
||||
### Further startup findings
|
||||
|
||||
Live dev `/v1/state` returned `RPC_ACTIVE` while `/v1/getinfo` timed out during
|
||||
Bitcoin initial sync. Candidate startup now recognizes the already-unlocked
|
||||
state instead of repeating unlock attempts for ten minutes. The health watchdog
|
||||
also now excludes Bitcoin initial sync, warmup, unavailable/stale status and
|
||||
LND height progress from its restart criteria. A later observed `podman restart`
|
||||
was externally initiated; its precise caller has not yet been established, so
|
||||
the watchdog defect is a source finding rather than a confirmed attribution.
|
||||
|
||||
Framework SSH rejected the previously provided login on 2026-09-29. No password
|
||||
was saved and no wallet changes were attempted. The human display-confirmation
|
||||
question remains pending. Do not repeat a Framework reboot to reconfirm old work.
|
||||
|
||||
LND UI waiting-state, stale-balance, partial-failure/recovery and prompt-render
|
||||
tests pass (4 Node tests). Waiting states avoid calls to LND endpoints that block
|
||||
until sync, and prevent overlapping refreshes.
|
||||
|
||||
### Final source validation
|
||||
|
||||
The final backend suite passed: 1,548 passed, zero failed, four existing ignored
|
||||
live/hardware tests. Includes saved pruning preference, rejecting an old catalog
|
||||
that cannot honor explicit pruning, and all nine paid-Cashu protocol tests.
|
||||
Unsigned candidate catalog passes strict drift and fleet registry trust checks.
|
||||
The release gate caught a missing What's New entry; generated it from the curated
|
||||
changelog and reran the frontend gate/build. No public release has been changed.
|
||||
|
||||
At 18:23 UTC dev Bitcoin exited with status 137 and restarted; current container
|
||||
is not marked OOM-killed and no kernel/oomd record identified the cause. Bitcoin
|
||||
is replaying blocks again (height 482071 at 18:31 UTC). Installed old LND continues
|
||||
to time out while Bitcoin RPC warms up. Candidate is not deployed yet; verify its
|
||||
readiness deferral live before declaring this fixed. Do not attribute the Bitcoin
|
||||
exit to a specific actor without evidence.
|
||||
|
||||
### Doctor restart cause established and repaired
|
||||
|
||||
Full system journal identifies container-doctor at 18:23:21 UTC issuing raw
|
||||
`podman restart bitcoin-core` for an allegedly missing 8333 listener. The same
|
||||
script restarted LND at 17:57:48 and 18:23:35 UTC. The port was actually listening.
|
||||
Reproduced the original `ss | awk | grep -q` pipeline returning `0 141 0`: grep
|
||||
exits after its match, awk gets SIGPIPE, and pipefail falsely reports no listener.
|
||||
The raw restart also enforces a short stop timeout and races Quadlet cleanup.
|
||||
|
||||
The repaired check consumes the entire socket snapshot, distinguishes inspection
|
||||
failure from a missing port, and leaves containers running when inspection fails.
|
||||
Necessary restarts use their managed systemd units and shutdown timeouts; unmanaged
|
||||
Bitcoin/LND fallback receives 600/330-second grace respectively. Regression uses
|
||||
20,000 socket rows plus mocked service/container commands and passes. Thirty
|
||||
read-only checks of the actual Bitcoin listener pass. Script deployed to dev and
|
||||
Shorty with root-only rollback copies. OTA runtime payload includes scripts/.
|
||||
This evidence supersedes the earlier unknown-caller/unknown-exit attribution.
|
||||
|
||||
### Initial candidate live validation — 18:48 UTC
|
||||
|
||||
Source 0f85f588, optimized backend SHA256
|
||||
84434c495c5f8472cf6bfcb6c65e762502c74718ad88271619373335c0054bb6,
|
||||
deployed to dev and Shorty with matching hashes and rollback copies. Both
|
||||
management services restarted; wallets/channels were not reset. Old embedded
|
||||
runtime assets restored the old doctor on backend startup; updated the live
|
||||
script AND embedded runtime copy on both nodes. Final OTA will contain the new
|
||||
script directly.
|
||||
|
||||
Authenticated dev readiness transitioned from waiting_start to waiting_sync.
|
||||
Real Chromium at 1440px and 390px showed Waiting for Bitcoin to sync, an unknown
|
||||
balance, and no blocked native LND calls. Screenshot review also caught invented
|
||||
zero capacity/channel counts during waiting: corrected them and the empty-channel
|
||||
recommendation; five UI regression tests now pass.
|
||||
|
||||
Real Minibits Cashu purchase from dev to Shorty succeeded for one sat and returned
|
||||
the expected 44 bytes. A rejected one-sat underpayment was refunded exactly, and
|
||||
two cached downloads charged zero. Temporary seller files/catalog entries removed.
|
||||
The first test runner expected data_base64 while the first-purchase API returns
|
||||
data; cached responses use data_base64. Existing purchase clients only consume
|
||||
data, so a follow-up normalizes both response variants to both fields.
|
||||
|
||||
The optional Files copy failed because FileBrowser owns host paths as mapped UID
|
||||
100000. Follow-up uses its authenticated API with override=false and collision
|
||||
suffixes. A live API probe succeeded, refused overwrite with HTTP409, preserved
|
||||
original bytes, and cleaned up. New protocol tests cover folder creation, escaped
|
||||
names, collisions, authentication failure, disk-full, and unavailable service.
|
||||
Full backend suite for these follow-ups is running; do not package the earlier
|
||||
backend as final.
|
||||
|
||||
### Follow-up validation and OTA delivery check
|
||||
|
||||
Paid-response and Files API regressions passed in the full backend run: 1,552
|
||||
passed, zero failed, four existing ignored tests. Live browser waiting checks
|
||||
passed again after removing invented zero capacity and channel counts.
|
||||
|
||||
OTA inspection found that companion image :local (created by old installers and
|
||||
used on dev) bypassed both source-staleness detection and rebuilding. The earlier
|
||||
assumption that build-context detection covered these nodes was incorrect.
|
||||
Follow-up applies the existing source-mtime/stamp checks to both :local and
|
||||
:latest, preserving the existing tag and rebuilding only stale source. Existing
|
||||
image-ID comparison then restarts the UI companion onto the new image. This does
|
||||
not restart LND itself. Regression covers every companion's two local tags; final
|
||||
backend suite is running. Verify the resulting live rebuilt image before release.
|
||||
|
||||
### Test isolation finding — release remains blocked
|
||||
|
||||
The next full run passed 1,552 tests but one existing boot-loop timing test failed.
|
||||
Its output and node logs exposed an independent test defect: MockRuntime tests
|
||||
still invoked real Quadlet service operations and Podman socket recovery. These
|
||||
caused further LND/companion restarts during unrestricted unit runs. They were not
|
||||
a recurrence of the repaired doctor port check. Stopped unrestricted testing;
|
||||
LND has remained running since 19:02:46 UTC during isolated test execution.
|
||||
|
||||
New isolated runner hides live wallets, service buses, container storage and host
|
||||
process IDs, supplies a private network and temporary writable fixture paths,
|
||||
and keeps host filesystems read-only. An independent boundary probe passed.
|
||||
Test-only service helpers use a temporary Quadlet directory and simulated service
|
||||
results; mocked runtimes skip real Podman socket/network provisioning. Host file
|
||||
helpers require the isolated-runner marker and execute inside the namespace
|
||||
instead of escaping through sudo/systemd-run. Release harness and AGENTS now
|
||||
require this runner. Initial isolation trials correctly blocked host operations
|
||||
and exposed fixture permission assumptions; final runner compiles and executes
|
||||
the full suite with those fixture paths isolated. No final pass claimed yet.
|
||||
|
||||
Main dashboard candidate and AIUI build at b634f41a are now deployed on dev; served
|
||||
index SHA matches the build. Live package.versions returns bitcoinPrune=false
|
||||
for Core and Knots, preserving current automatic mode. Existing full chain stays
|
||||
unpruned. Final backend (Files/cached response/legacy UI delivery follow-ups) is
|
||||
not yet deployed; earlier 0f85f588 backend remains live on both nodes.
|
||||
|
||||
Final isolated backend run: **1,553 passed, zero failed, four existing ignored**
|
||||
in 13 seconds after compilation. Boundary probe confirms no host service buses,
|
||||
live wallet data, host process IDs, or external network. Bitcoin/LND start times
|
||||
remained unchanged during isolated execution. Production helpers are unchanged;
|
||||
the namespace-specific command behavior is compiled only into unit tests.
|
||||
Release and ISO gates now use the isolated runner.
|
||||
|
||||
### Final backend deployment and App Store follow-up — 19:36 UTC
|
||||
|
||||
Full release harness passed: static/catalog checks, frontend type-check and
|
||||
1,117 frontend tests, cargo-check, and isolated backend suite (1,553 passed,
|
||||
four existing ignored). Final optimized backend built successfully; SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Deployed to dev. Legacy :local LND companion automatically rebuilt at 19:35 UTC
|
||||
and restarted onto image 702c0cd88fb5c8a561c76dabdb96c40648dd62d401c78f2e10d4318b06f02abe.
|
||||
Served UI bytes match candidate source. Native Bitcoin/LND start times unchanged.
|
||||
|
||||
Actual desktop pruning screenshot exposed horizontal overflow; moved the
|
||||
explanation below the app header. The App Store uses Marketplace.vue, a separate
|
||||
install path from Discover.vue. Its first Install button bypassed the version
|
||||
modal. The browser check therefore sent an unintended Knots install request at
|
||||
19:28 UTC. Core remained running, no Knots container was created, and the full
|
||||
chain was not pruned. Removed only the newly created Knots installed-app record
|
||||
and newly created version config; preserved root-only rollback copies.
|
||||
|
||||
Marketplace now uses the shared version/pruning modal. Added integration tests
|
||||
for both Core and Knots: no install request until confirmation, selected version
|
||||
and pruning forwarded, cancellation sends no install request. Four Marketplace
|
||||
tests pass (three new plus existing refresh check). Further browser checks block
|
||||
package.install requests at their network boundary. Final frontend rebuild and
|
||||
post-fix live checks remain pending. Final paid-file follow-up is still pending.
|
||||
|
||||
### Unsigned release candidate ready — 19:46 UTC
|
||||
|
||||
Final frontend source/build attribution: 3612458e. Production dashboard and AIUI
|
||||
builds passed. Final frontend suite: 1,120 tests across 139 files passed.
|
||||
Desktop 1280px and mobile 390px browser checks passed for the app detail pruning
|
||||
choice and App Store version modal; no horizontal overflow and no installation
|
||||
request. Screenshot review confirms readable controls and explanation. Browser
|
||||
installation requests are blocked during these selection-only checks.
|
||||
|
||||
Final backend SHA above matches both dev and Shorty. A fresh one-sat purchase
|
||||
passed on those exact binaries: correct file bytes, both response field aliases,
|
||||
exact one-sat refund on underpayment, zero-charge cached repeat, and exact Files
|
||||
copy. Temporary seller entries/files and Files test copy removed; transaction
|
||||
audit and owned cache retained. Total net transfer during the two live purchase
|
||||
rounds: two sats from dev to Shorty. Desktop/mobile LND waiting checks passed
|
||||
again on the automatically rebuilt companion. Native Bitcoin and LND stayed up.
|
||||
|
||||
Prepared, unsigned files:
|
||||
- releases/pending/v1.8.20-alpha/app-catalog.json
|
||||
- releases/pending/v1.8.20-alpha/manifest.json
|
||||
|
||||
Staged OTA backend: 64,716,656 bytes, SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Frontend archive: 97,152,297 bytes, SHA256
|
||||
658b78fce0dfa20a627c987dd153b24cbac15adbde905cc6518744c637e12802.
|
||||
Artifact sizes/hashes/release notes validate. Checked actual archive: flat
|
||||
layout, readable root permissions, exact doctor/LND UI source bytes, and fresh
|
||||
AIUI attribution. Catalog has zero metadata drift and passes fleet registry trust.
|
||||
|
||||
Remaining: user-local release-root signatures, Framework's final display
|
||||
confirmation, signed publication to git/ngit, then raw ISO build/boot test/signing
|
||||
and publication. No v1.8.20 public release or tag exists yet. Four pre-existing
|
||||
hardware/live tests remain ignored. Bitcoin sync-to-ready recovery is covered
|
||||
by deterministic tests; the live node remains in initial sync. Do not describe
|
||||
these checks as proof against every possible network/payment failure.
|
||||
|
||||
### Signing and release authorization — 2026-09-30
|
||||
|
||||
Both catalog and OTA signatures verify against the pinned release root. Staged
|
||||
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
||||
remaining Framework display check and explicitly authorized release. Publication
|
||||
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
||||
|
||||
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
|
||||
|
||||
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
|
||||
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
|
||||
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
|
||||
still used its ten-second default and killed Bitcoin. Core replayed its block
|
||||
index; LND later lost its connection to the previous Bitcoin container IP.
|
||||
|
||||
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
|
||||
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
|
||||
Installed explicit graceful-stop systemd overrides on dev and Shorty without
|
||||
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
|
||||
and command-wait budgets, including existing containers and uninstall fallback.
|
||||
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
|
||||
Full tests, disposable slow-stop verification, build and deployment remain pending.
|
||||
|
||||
Disposable live regression passed: started an Alpine container with its legacy
|
||||
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
|
||||
second graceful stop, verified the same container ID and old internal timeout
|
||||
remained running, then stopped it. Its twelve-second shutdown handler completed
|
||||
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
|
||||
Fixture had no network or wallet mounts and was removed afterward.
|
||||
|
||||
Core finished index loading and resumed unpruned initial sync. LND automatically
|
||||
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
|
||||
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
|
||||
automatically unlocked again and reached chain-sync waiting. No manual wallet
|
||||
unlock or restart was used for this recovery.
|
||||
|
||||
### False dependency restart exposed during monitoring — 09:08 UTC
|
||||
|
||||
The initial 1.8.21 candidate passed all 1,557 isolated backend tests and 1,120
|
||||
frontend tests. Monitoring nevertheless found another managed LND restart at
|
||||
09:08:32 while Bitcoin's container/start timestamp remained unchanged. Management
|
||||
logs explicitly attribute it to the backend-address cascade. This also makes
|
||||
the earlier 08:57 cascade suspect; it must not be described as a proven necessary
|
||||
restart. These service restarts preceded the isolated test executable, whose
|
||||
namespace boundaries remain intact.
|
||||
|
||||
The cascade trusted Started/Installed action reports. A failed runtime inspection
|
||||
followed by successful systemctl start of an already active unit can produce
|
||||
Started without changing Bitcoin. Dependency restarts now require observed
|
||||
container-ID, running-state, or start-time changes. Failed observations remain
|
||||
unknown, not absence; a known absent backend becoming running still qualifies.
|
||||
Actual exec-drift restarts are recognized even when their outer report is NoOp.
|
||||
Stopped/lifecycle-in-flight dependents remain excluded, and user stop markers
|
||||
are re-read after the potentially slow pass. Added runtime-observation and
|
||||
false-action/real-exec-drift regression cases; full isolated rerun pending.
|
||||
Stopped the first optimized build and preparing new artifacts from this correction.
|
||||
|
||||
### Final 1.8.21 artifacts and live verification — 2026-09-30
|
||||
|
||||
Source and frontend/AIUI attribution: c993d9dd. Full isolated backend suite:
|
||||
1,559 passed, zero failed, four existing hardware/live tests ignored. Frontend
|
||||
suite: 1,120 passed; final production type-check/build passed after the last
|
||||
release-note-only edit. Optimized backend built in 13m22s.
|
||||
|
||||
Staged unsigned 1.8.21 OTA manifest and artifacts:
|
||||
- Backend: 64,748,176 bytes; SHA256
|
||||
ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb.
|
||||
- Frontend archive: 97,152,546 bytes; SHA256
|
||||
6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620.
|
||||
|
||||
Artifact validator passed. Actual archive has flat paths, readable root index,
|
||||
and exact fresh AIUI, doctor and LND UI payload bytes. Exact files deployed to
|
||||
dev at 09:32 UTC and Shorty at 09:35 UTC; rollback binaries and dashboards under
|
||||
root-only /var/lib/archipelago/support/release-1821 on each node. Only management
|
||||
services restarted. Existing Bitcoin/Core-or-Knots and native LND container IDs
|
||||
and start times were preserved. Correct generated graceful-stop commands are
|
||||
present before forced removal on both nodes; temporary grace overrides removed.
|
||||
Dev systemd deadlines are 615 seconds for Bitcoin and 345 seconds for LND.
|
||||
|
||||
Desktop/mobile Lightning UI checks passed again: waiting for Bitcoin sync,
|
||||
unknown balance, no unavailable native RPC requests. Served dashboard and AIUI
|
||||
attribution bytes match the release. Native LND states: dev RPC_ACTIVE while
|
||||
Bitcoin syncs; Shorty SERVER_ACTIVE. Dev completed full reconciliation passes
|
||||
at 09:34:21 and 09:36:40 with Bitcoin/LND NoOp, and no dependency restart.
|
||||
Shorty's first full pass completed 09:36:55 with Knots/LND NoOp.
|
||||
|
||||
Final paid-file check on these exact binaries passed: fresh one-sat dev-to-Shorty
|
||||
purchase, exact one-sat refund on underpayment, identical response aliases,
|
||||
correct Files copy, and zero-charge cached repeat. Removed temporary seller
|
||||
entries/files and Files copy; retained purchase audit and owned cache. Total net
|
||||
transfer across all three live payment rounds in this repair session: three sats.
|
||||
|
||||
Remaining: finish Shorty observation and remove temporary diagnostic logging;
|
||||
user-local 1.8.21 OTA signature (existing catalog signature remains valid),
|
||||
publish git/ngit, build/boot-test/sign and publish the raw 1.8.21 ISO.
|
||||
No 1.8.21 release tag or public OTA yet. Do not publish the quarantined partial
|
||||
1.8.20 ISO. The existing 1.8.20 git/ngit release notes now explain the withheld ISO
|
||||
and pending hotfix; signed 1.8.20 assets remain immutable.
|
||||
|
||||
Shorty's second clean full pass completed at 09:38:06 UTC. Removed temporary
|
||||
diagnostic logging on both nodes and restarted only management again; native
|
||||
Bitcoin and LND IDs/start times remained unchanged, with generated stop settings
|
||||
still verified. No temporary graceful-stop overrides remain. Catalog signature
|
||||
verifies against the pinned release root; final 1.8.21 artifact validator passes.
|
||||
The candidate is ready for the user's local OTA signing ceremony.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.21-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.21-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.21-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface PackageVersionsResponse {
|
||||
pinnedVersion: string | null
|
||||
autoUpdate: boolean
|
||||
versions: CatalogVersionInfo[]
|
||||
bitcoinPrune?: boolean | null
|
||||
}
|
||||
|
||||
export interface AppGatePortStatus {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="mt-5 space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model" type="checkbox" class="accent-orange-400" />
|
||||
Prune Bitcoin to save disk space
|
||||
</label>
|
||||
<p class="text-xs text-white/50">
|
||||
Keeps about 50 GB of recent blocks, using the same settings as automatic
|
||||
pruning on smaller disks. All blocks are still downloaded and verified.
|
||||
Mempool and other apps that need the full blockchain won’t be available.
|
||||
Turning pruning off later requires downloading the blockchain again.
|
||||
</p>
|
||||
<p v-if="!model" class="text-xs text-white/50">
|
||||
Automatic pruning still applies on disks smaller than 1 TB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
@@ -3,6 +3,9 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
defineProps<{ setupOnly?: boolean }>()
|
||||
const emit = defineEmits<{ ready: [] }>()
|
||||
|
||||
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
|
||||
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
|
||||
// would be a third thing to learn.
|
||||
@@ -102,12 +105,14 @@ async function submitReveal() {
|
||||
}
|
||||
|
||||
function closeReveal() {
|
||||
const established = revealedWords.value.length > 0
|
||||
showRevealModal.value = false
|
||||
revealedWords.value = []
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealPassphrase.value = ''
|
||||
showRevealPassphrase.value = false
|
||||
if (established) emit('ready')
|
||||
}
|
||||
|
||||
async function copyRevealedWords() {
|
||||
@@ -221,61 +226,54 @@ async function restoreFromPhrase() {
|
||||
Your ecash has no backup yet
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ setupOnly ? 'Set up your Cashu Lightning address' : 'Ecash backup phrase' }}</h2>
|
||||
|
||||
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
|
||||
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
|
||||
phrase — so the words you already wrote down cover your ecash too. Reveal it here
|
||||
if you want to restore your ecash into another wallet (Minibits, Nutstash,
|
||||
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
|
||||
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm leading-relaxed text-white/60">
|
||||
Your node's recovery phrase also recovers this ecash phrase. Reveal its 24 words
|
||||
to restore in a compatible Cashu wallet without sharing your node's phrase.
|
||||
</p>
|
||||
<p v-else-if="status?.active" class="text-sm text-white/60">
|
||||
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to
|
||||
restore your ecash into another wallet (Minibits, Nutstash,
|
||||
<span class="font-mono">cdk-cli</span>).
|
||||
<p v-else-if="status?.active" class="text-sm leading-relaxed text-white/60">
|
||||
Save your 24-word ecash phrase to restore this wallet here or in another
|
||||
compatible Cashu wallet.
|
||||
</p>
|
||||
<p v-else class="text-sm text-white/60">
|
||||
Ecash is a bearer instrument: the coins live in a file on this node, and right now
|
||||
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
|
||||
that for every coin minted from then on.
|
||||
<p v-else class="text-sm leading-relaxed text-white/60">
|
||||
If this node's coin file is lost, your ecash is lost. Set up a phrase to recover
|
||||
future coins; existing coins aren't covered.
|
||||
<template v-if="status?.derivable_from_node_seed">
|
||||
It's derived from this node's recovery phrase, so there's nothing new to write down.
|
||||
Your node's recovery phrase will also recover this phrase.
|
||||
</template>
|
||||
<template v-else>
|
||||
This node has no encrypted seed backup to derive from, so the phrase will be its
|
||||
own — you'll need to write these words down and keep them.
|
||||
This node has no saved seed, so write down and keep the new phrase separately.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
|
||||
This wallet's phrase was <strong>not</strong> derived from the node's recovery
|
||||
phrase{{ status?.source === 'imported' ? ' — it was imported' : '' }}, so restoring
|
||||
the node will not bring the ecash back. Only these words will.
|
||||
{{ status?.source === 'imported' ? 'This imported phrase' : 'This phrase' }} is separate
|
||||
from your node's backup. <strong>Only these words recover this ecash wallet.</strong>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
||||
@click="openReveal"
|
||||
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
|
||||
>{{ status?.active ? 'Reveal' : (setupOnly ? 'Set up address' : 'Set up backup') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<p class="text-sm text-white/60 min-w-0">
|
||||
<div v-if="status?.active && !setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||
<span class="text-white/80 font-medium">Restore from this phrase.</span>
|
||||
Asks your mint which coins it has signed for these words and puts back any that
|
||||
are still unspent. Safe to run at any time — it never duplicates coins you already
|
||||
hold.
|
||||
Recover unspent coins from your mint. Safe to repeat; coins you already hold
|
||||
won't be duplicated.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
:disabled="restoring"
|
||||
@click="restoreFromPhrase"
|
||||
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
|
||||
@@ -284,16 +282,16 @@ async function restoreFromPhrase() {
|
||||
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<p class="text-sm text-white/60 min-w-0">
|
||||
<div v-if="!setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
|
||||
Point this wallet at a phrase you already have — from Minibits, Nutstash or
|
||||
<span class="font-mono">cdk-cli</span> — so its coins can be restored here.
|
||||
Import a phrase from Minibits, Nutstash or <span class="font-mono">cdk-cli</span>
|
||||
to restore its coins here.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
@click="openImport"
|
||||
>Import</button>
|
||||
</div>
|
||||
@@ -319,7 +317,7 @@ async function restoreFromPhrase() {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||
Paste the 24-word phrase from the other wallet. The coins already in this wallet
|
||||
stay spendable either way.
|
||||
</p>
|
||||
@@ -376,9 +374,8 @@ async function restoreFromPhrase() {
|
||||
</h3>
|
||||
|
||||
<template v-if="revealedWords.length === 0">
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Confirm your credentials to
|
||||
{{ status?.active ? 'display the 24-word ecash phrase' : 'derive and display your ecash backup phrase' }}.
|
||||
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||
Confirm your credentials to {{ status?.active ? 'reveal' : 'set up' }} your ecash phrase.
|
||||
</p>
|
||||
<form @submit.prevent="submitReveal" class="space-y-3">
|
||||
<div>
|
||||
@@ -407,12 +404,12 @@ async function restoreFromPhrase() {
|
||||
<SeedRevealPanel :words="revealedWords" />
|
||||
<p class="text-xs text-white/40 mt-3">
|
||||
<template v-if="revealedSource === 'node-seed'">
|
||||
Derived from this node's recovery phrase — restoring the node restores this
|
||||
ecash wallet too. These words also restore it into any NUT-13 wallet.
|
||||
Your node's recovery phrase recovers this ecash wallet too. Use these words
|
||||
separately in a compatible Cashu (NUT-13) wallet.
|
||||
</template>
|
||||
<template v-else>
|
||||
This phrase is independent of the node's recovery phrase. It is the
|
||||
<strong>only</strong> way to restore this ecash wallet — write it down.
|
||||
Write these words down. They are the <strong>only</strong> way to recover
|
||||
this ecash wallet; your node's phrase won't recover it.
|
||||
</template>
|
||||
</p>
|
||||
<div class="flex gap-2 pt-4">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
|
||||
/>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Defaults to tx1138.com. Any Mempool-compatible instance works — you can change this
|
||||
Defaults to mempool.space. Any Mempool-compatible instance works — you can change this
|
||||
any time in Settings → System.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
|
||||
</div>
|
||||
|
||||
<BitcoinPruningChoice v-if="isBitcoin && !loading" v-model="prune" />
|
||||
|
||||
<template #footer>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button
|
||||
@@ -58,9 +60,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
import BitcoinPruningChoice from './BitcoinPruningChoice.vue'
|
||||
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
@@ -73,13 +76,16 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
|
||||
confirm: [version: string]
|
||||
confirm: [version: string, prune?: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const versions = ref<CatalogVersionInfo[]>([])
|
||||
const selected = ref('')
|
||||
const prune = ref(false)
|
||||
const pruneKnown = ref(false)
|
||||
const isBitcoin = computed(() => ['bitcoin-core', 'bitcoin-knots'].includes(props.appId))
|
||||
|
||||
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
|
||||
function optionLabel(v: CatalogVersionInfo): string {
|
||||
@@ -92,12 +98,16 @@ function optionLabel(v: CatalogVersionInfo): string {
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
prune.value = false
|
||||
pruneKnown.value = false
|
||||
versions.value = []
|
||||
selected.value = ''
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(props.appId)
|
||||
// catalog_versions() returns the list default(=latest)-first, so versions[0]
|
||||
// is the latest — pre-select it.
|
||||
pruneKnown.value = typeof info.bitcoinPrune === 'boolean'
|
||||
prune.value = info.bitcoinPrune === true
|
||||
versions.value = info.versions || []
|
||||
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
|
||||
} catch (err) {
|
||||
@@ -111,7 +121,7 @@ async function load() {
|
||||
|
||||
function confirm() {
|
||||
if (!selected.value) return
|
||||
emit('confirm', selected.value)
|
||||
emit('confirm', selected.value, isBitcoin.value && (pruneKnown.value || prune.value) ? prune.value : undefined)
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -77,8 +77,13 @@
|
||||
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
||||
{{ t('receiveBitcoin.lnAddressLoading') }}
|
||||
</div>
|
||||
<div v-else-if="lnAddressNeedsSetup" class="mb-3">
|
||||
<p class="text-sm text-white/70 mb-3">Set up this wallet's recovery phrase once to enable its Lightning address.</p>
|
||||
<EcashSeedBackup setup-only @ready="loadLnAddress" />
|
||||
</div>
|
||||
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
||||
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
||||
<button type="button" class="glass-button rounded-lg px-3 py-2 ml-2" @click="loadLnAddress">Retry</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -132,6 +137,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import CopyButton from '@/components/CopyButton.vue'
|
||||
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
|
||||
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
@@ -214,6 +220,7 @@ const error = ref('')
|
||||
const lnAddress = ref('')
|
||||
const lnAddressLoading = ref(false)
|
||||
const lnAddressError = ref(false)
|
||||
const lnAddressNeedsSetup = ref(false)
|
||||
// A payment the backend fetched (and so already consumed at Minibits) but
|
||||
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
|
||||
// operator should see it rather than have it be a silent, unbounded wait.
|
||||
@@ -230,6 +237,7 @@ async function loadLnAddress() {
|
||||
if (lnAddress.value || lnAddressLoading.value) return
|
||||
lnAddressLoading.value = true
|
||||
lnAddressError.value = false
|
||||
lnAddressNeedsSetup.value = false
|
||||
try {
|
||||
const res = await rpcClient.call<{ address?: string }>({
|
||||
method: 'wallet.ecash-lnaddress',
|
||||
@@ -245,6 +253,16 @@ async function loadLnAddress() {
|
||||
}
|
||||
} catch {
|
||||
lnAddressError.value = true
|
||||
// A legacy wallet may hold valid proofs without having a recovery phrase.
|
||||
// Use the existing authenticated setup flow; never silently create a new
|
||||
// identity or send the user to an unexplained generic service error.
|
||||
try {
|
||||
const seedStatus = await rpcClient.call<{ active: boolean; can_activate: boolean }>({
|
||||
method: 'wallet.ecash-seed-status',
|
||||
timeout: 5000,
|
||||
})
|
||||
lnAddressNeedsSetup.value = seedStatus.active === false && seedStatus.can_activate === true
|
||||
} catch { /* Keep the retryable service error when status is unavailable. */ }
|
||||
} finally {
|
||||
lnAddressLoading.value = false
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
@change="saveExplorer"
|
||||
/>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Any Mempool-compatible instance works. Default: tx1138.com.
|
||||
Any Mempool-compatible instance works. Default: mempool.space.
|
||||
</p>
|
||||
|
||||
<div class="mt-3 p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/80 text-xs leading-relaxed">
|
||||
|
||||
@@ -22,6 +22,37 @@ describe('EcashSeedBackup reveal credentials (#127)', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('signals readiness only after authenticated setup is finished and clears the words', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-seed-status') {
|
||||
return { active: false, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||
}
|
||||
if (method === 'wallet.ecash-seed-reveal') {
|
||||
return { words: [...Array(23).fill('abandon'), 'art'], source: 'node-seed' } as never
|
||||
}
|
||||
throw new Error('unexpected request')
|
||||
})
|
||||
wrapper = mount(EcashSeedBackup, { props: { setupOnly: true }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
await wrapper.get('button').trigger('click')
|
||||
const cancel = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Cancel')!
|
||||
cancel.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||
await wrapper.get('button').trigger('click')
|
||||
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
|
||||
password.value = 'test-password'
|
||||
password.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Done')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toEqual([[]])
|
||||
expect(document.body.querySelector('[aria-labelledby="reveal-ecash-seed-title"]')).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('abandon')
|
||||
})
|
||||
|
||||
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import InstallVersionModal from '../InstallVersionModal.vue'
|
||||
const versions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../api/rpc-client', () => ({ rpcClient: { getPackageVersions: versions } }))
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false, messages: { en: { common: { install: 'Install', cancel: 'Cancel' } } } })
|
||||
function modal(id = 'bitcoin-core') {
|
||||
return mount(InstallVersionModal, {
|
||||
props: { show: true, appId: id, app: { id, title: id } },
|
||||
global: { plugins: [i18n], stubs: { BaseModal: { template: '<div><slot/><slot name="footer"/></div>' } } },
|
||||
})
|
||||
}
|
||||
describe('Bitcoin install storage choice', () => {
|
||||
it.each(['bitcoin-core', 'bitcoin-knots'])('sends chosen version and explicit pruning for %s', async id => {
|
||||
versions.mockResolvedValue({ bitcoinPrune: false, default: 'latest', versions: [{ version: 'latest' }, { version: '28.4' }] })
|
||||
const wrapper = modal(id)
|
||||
await flushPromises()
|
||||
await wrapper.get('select').setValue('28.4')
|
||||
await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toEqual([['28.4', true]])
|
||||
expect(wrapper.text()).toContain('automatic pruning')
|
||||
expect(wrapper.text()).toContain('Mempool')
|
||||
})
|
||||
it('keeps automatic disk selection by default and resets on reopening', async () => {
|
||||
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
|
||||
const wrapper = modal()
|
||||
await flushPromises()
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toEqual([['latest', false]])
|
||||
await wrapper.get('input').setValue(true)
|
||||
await wrapper.setProps({ show: false })
|
||||
await wrapper.setProps({ show: true })
|
||||
await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
it('still allows choosing pruning when version lookup fails', async () => {
|
||||
versions.mockRejectedValue(new Error('offline'))
|
||||
const wrapper = modal()
|
||||
await flushPromises()
|
||||
await wrapper.get('input').setValue(true)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
|
||||
})
|
||||
it('remembers the node pruning preference when reinstalling or switching Bitcoin variants', async () => {
|
||||
versions.mockResolvedValue({ bitcoinPrune: true, versions: [{ version: 'latest' }] })
|
||||
const wrapper = modal('bitcoin-knots')
|
||||
await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
|
||||
})
|
||||
it('does not turn off a saved pruning preference when its lookup fails', async () => {
|
||||
versions.mockRejectedValue(new Error('offline'))
|
||||
const wrapper = modal()
|
||||
await flushPromises()
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toEqual([['latest', undefined]])
|
||||
})
|
||||
it('does not offer Bitcoin settings for other apps', async () => {
|
||||
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
|
||||
const wrapper = modal('other')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||
import EcashSeedBackup from '../EcashSeedBackup.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -39,6 +40,51 @@ beforeEach(() => {
|
||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
||||
// path a future change could regress, so it's worth pinning down.
|
||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
|
||||
let active = false
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-lnaddress') {
|
||||
if (!active) throw new Error('The ecash wallet has no seed yet')
|
||||
return { address: 'someone@minibits.cash' } as never
|
||||
}
|
||||
if (method === 'wallet.ecash-seed-status') {
|
||||
return { active, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||
const tab = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('Set up your Cashu Lightning address')
|
||||
expect(document.body.textContent).not.toContain('receiveBitcoin.lnAddressUnavailable')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([r]) => r.method === 'wallet.ecash-seed-reveal')).toBe(false)
|
||||
active = true
|
||||
wrapper.findComponent(EcashSeedBackup).vm.$emit('ready')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('someone@minibits.cash')
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a seeded wallet on the retry path during a service outage', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-seed-status') return { active: true, can_activate: true } as never
|
||||
throw new Error('service unavailable')
|
||||
})
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(EcashSeedBackup).exists()).toBe(false)
|
||||
expect(document.body.textContent).toContain('receiveBitcoin.lnAddressUnavailable')
|
||||
const retry = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Retry')!
|
||||
expect(retry).toBeTruthy()
|
||||
retry.click()
|
||||
await flushPromises()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
|
||||
|
||||
|
||||
@@ -76,6 +76,24 @@ describe('useTxExplorer.openTx', () => {
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
})
|
||||
|
||||
it('does not open an external explorer while container discovery is pending', async () => {
|
||||
const external = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
let finish!: () => void
|
||||
ensureFetched.mockImplementationOnce(() => new Promise<void>(resolve => {
|
||||
finish = () => { fetched = true; resolve() }
|
||||
}))
|
||||
const { openTx, setExplorer } = useTxExplorer()
|
||||
setExplorer(DEFAULT_TX_EXPLORER, true)
|
||||
const opening = openTx(TX)
|
||||
expect(external).not.toHaveBeenCalled()
|
||||
expect(openSession).not.toHaveBeenCalled()
|
||||
finish()
|
||||
await opening
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
expect(external).not.toHaveBeenCalled()
|
||||
external.mockRestore()
|
||||
})
|
||||
|
||||
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
||||
containerState = 'not-installed'
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
@@ -84,3 +102,23 @@ describe('useTxExplorer.openTx', () => {
|
||||
expect(pendingTx.value).toBe(TX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('explorer default migration', () => {
|
||||
beforeEach(() => { localStorage.clear(); vi.resetModules() })
|
||||
it('uses mempool.space with consent for a new browser', async () => {
|
||||
const { useTxExplorer } = await import('../useTxExplorer')
|
||||
expect(useTxExplorer().prefs.value).toEqual({ url: 'https://mempool.space', acknowledged: false })
|
||||
})
|
||||
it.each(['https://tx1138.com', 'https://tx1138.com/', 'http://tx1138.com'])('migrates %s and resets consent', async url => {
|
||||
localStorage.setItem('archipelago.tx-explorer.v1', JSON.stringify({ url, acknowledged: true }))
|
||||
const { useTxExplorer } = await import('../useTxExplorer')
|
||||
expect(useTxExplorer().prefs.value).toEqual({ url: 'https://mempool.space', acknowledged: false })
|
||||
expect(JSON.parse(localStorage.getItem('archipelago.tx-explorer.v1')!)).toEqual(useTxExplorer().prefs.value)
|
||||
})
|
||||
it('preserves a custom explorer and its consent', async () => {
|
||||
const prefs = { url: 'https://my-explorer.example', acknowledged: true }
|
||||
localStorage.setItem('archipelago.tx-explorer.v1', JSON.stringify(prefs))
|
||||
const { useTxExplorer } = await import('../useTxExplorer')
|
||||
expect(useTxExplorer().prefs.value).toEqual(prefs)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,8 @@ import { ref } from 'vue'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
|
||||
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com'
|
||||
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide'
|
||||
export const DEFAULT_TX_EXPLORER = 'https://mempool.space'
|
||||
export const EXPLORER_PLACEHOLDER = DEFAULT_TX_EXPLORER
|
||||
|
||||
const KEY = 'archipelago.tx-explorer.v1'
|
||||
|
||||
@@ -30,7 +30,13 @@ interface TxExplorerPrefs {
|
||||
function loadPrefs(): TxExplorerPrefs {
|
||||
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
|
||||
try {
|
||||
return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
||||
const stored = { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
||||
// Changing operators requires fresh consent, even if the old one was trusted.
|
||||
if (typeof stored.url === 'string' && /^https?:\/\/tx1138\.com\/*$/i.test(stored.url.trim())) {
|
||||
localStorage.setItem(KEY, JSON.stringify(defaults))
|
||||
return defaults
|
||||
}
|
||||
return stored
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('useAppLauncherStore', () => {
|
||||
const store = useAppLauncherStore()
|
||||
store.openSession('archipelago-source')
|
||||
expect(openInApp).toHaveBeenCalledWith(
|
||||
'http://192.0.2.10/app/archipelago-source/',
|
||||
'http://192.0.2.10/app/archipelago-source/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy',
|
||||
)
|
||||
expect(store.panelAppId).toBeNull()
|
||||
expect(store.isOpen).toBe(false)
|
||||
|
||||
@@ -672,6 +672,11 @@ async function handleInstall(app: MarketplaceApp) {
|
||||
return
|
||||
}
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
if (['bitcoin-core', 'bitcoin-knots'].includes(app.id)) {
|
||||
installModalApp.value = app
|
||||
showInstallModal.value = true
|
||||
return
|
||||
}
|
||||
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
||||
// front via a full-screen modal (latest pre-selected) instead of silently
|
||||
// installing the default. Best-effort — if the lookup fails we install directly.
|
||||
@@ -686,19 +691,19 @@ async function handleInstall(app: MarketplaceApp) {
|
||||
startInstall(app)
|
||||
}
|
||||
|
||||
function startInstall(app: MarketplaceApp, versionOverride?: string) {
|
||||
function startInstall(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
if (app.source === 'local') {
|
||||
installApp(app, versionOverride)
|
||||
installApp(app, versionOverride, prune)
|
||||
} else {
|
||||
installCommunityApp(app, versionOverride)
|
||||
installCommunityApp(app, versionOverride, prune)
|
||||
}
|
||||
}
|
||||
|
||||
function onInstallModalConfirm(version: string) {
|
||||
function onInstallModalConfirm(version: string, prune?: boolean) {
|
||||
const app = installModalApp.value
|
||||
showInstallModal.value = false
|
||||
installModalApp.value = null
|
||||
if (app) startInstall(app, version)
|
||||
if (app) startInstall(app, version, prune)
|
||||
}
|
||||
|
||||
function viewAppDetails(app: MarketplaceApp) {
|
||||
@@ -774,25 +779,25 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
queueInstall(app)
|
||||
installToast(app)
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
queueInstall(app)
|
||||
installToast(app)
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
|
||||
if ((app as Record<string, unknown>).containerConfig) {
|
||||
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
class="order-2 lg:order-none"
|
||||
:animate="animateCards"
|
||||
:wallet-connected="walletConnected"
|
||||
:wallet-balance-unavailable="walletBalanceUnavailable"
|
||||
:wallet-onchain="walletOnchain"
|
||||
:wallet-lightning="walletLightning"
|
||||
:wallet-ecash="walletEcash"
|
||||
@@ -685,6 +686,7 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
|
||||
// readout instead; a rail only becomes a number when a call actually
|
||||
// succeeds, so a real 0 is still a real 0.
|
||||
const walletConnected = ref(false)
|
||||
const walletBalanceUnavailable = ref(false)
|
||||
const walletOnchain = ref<number | null>(null)
|
||||
const walletLightning = ref<number | null>(null)
|
||||
const walletEcash = ref<number | null>(null)
|
||||
@@ -775,13 +777,24 @@ async function loadWeb5Status() {
|
||||
// call, which is what makes the card feel like an app launch.
|
||||
const balances = Promise.allSettled([
|
||||
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
|
||||
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
|
||||
.then(res => {
|
||||
if (!Number.isSafeInteger(res.balance_sats) || res.balance_sats < 0 ||
|
||||
!Number.isSafeInteger(res.channel_balance_sats) || res.channel_balance_sats < 0) {
|
||||
throw new Error('LND balance is unavailable')
|
||||
}
|
||||
walletOnchain.value = res.balance_sats
|
||||
walletLightning.value = res.channel_balance_sats
|
||||
walletConnected.value = true
|
||||
walletBalanceUnavailable.value = false
|
||||
walletInfoFailures = 0
|
||||
})
|
||||
.catch(() => {
|
||||
// A single slow poll must NOT flip the card to "disconnected" and
|
||||
// hide balances the user already knows — busy nodes routinely blow
|
||||
// the 5s budget mid-payment or during IO storms (a test node user
|
||||
// report: balances vanished while a payment settled). Only call it
|
||||
// disconnected after three consecutive failures (~30s of silence).
|
||||
walletBalanceUnavailable.value = true
|
||||
walletInfoFailures += 1
|
||||
if (walletInfoFailures >= 3) walletConnected.value = false
|
||||
}),
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
:tier-label="getAppTier(app.id)"
|
||||
:install-blocked-reason="installBlockedReason(app.id)"
|
||||
@view="viewAppDetails"
|
||||
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
||||
@install="handleInstall(app)"
|
||||
@launch="launchInstalledApp"
|
||||
/>
|
||||
</div>
|
||||
@@ -153,7 +153,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Scrollable Apps Section -->
|
||||
|
||||
<InstallVersionModal
|
||||
:show="showInstallModal"
|
||||
:app-id="installModalApp?.id || ''"
|
||||
:app="installModalApp"
|
||||
@close="showInstallModal = false; installModalApp = null"
|
||||
@confirm="onInstallModalConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -175,11 +181,13 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
import {
|
||||
type MarketplaceApp,
|
||||
INSTALLED_ALIASES,
|
||||
MULTI_VERSION_APP_IDS,
|
||||
getAppTier,
|
||||
categorizeCommunityApp,
|
||||
getCuratedAppList,
|
||||
@@ -206,6 +214,8 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
const installingApps = server.installingApps
|
||||
const showInstallModal = ref(false)
|
||||
const installModalApp = ref<MarketplaceApp | null>(null)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
function installToast(app: MarketplaceApp) {
|
||||
@@ -518,7 +528,34 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp) {
|
||||
function handleInstall(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
if (MULTI_VERSION_APP_IDS.has(app.id)) {
|
||||
installModalApp.value = app
|
||||
showInstallModal.value = true
|
||||
return
|
||||
}
|
||||
startInstall(app)
|
||||
}
|
||||
|
||||
function startInstall(app: MarketplaceApp, version?: string, prune?: boolean) {
|
||||
if (app.source === 'local') void installApp(app, version, prune)
|
||||
else void installCommunityApp(app, version, prune)
|
||||
}
|
||||
|
||||
function onInstallModalConfirm(version: string, prune?: boolean) {
|
||||
const app = installModalApp.value
|
||||
showInstallModal.value = false
|
||||
installModalApp.value = null
|
||||
if (app) startInstall(app, version, prune)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
@@ -536,7 +573,7 @@ async function installApp(app: MarketplaceApp) {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: { id: app.id, url: installUrl, version: app.version },
|
||||
params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) },
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -545,7 +582,7 @@ async function installApp(app: MarketplaceApp) {
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp) {
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
@@ -558,7 +595,7 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
installToast(app)
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
|
||||
if (app.containerConfig) installParams.containerConfig = app.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<button
|
||||
v-if="!isInstalled"
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -90,6 +90,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BitcoinPruningChoice
|
||||
v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded"
|
||||
v-model="pruneOnInstall"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<!-- Top: Icon + Info -->
|
||||
@@ -137,6 +143,7 @@
|
||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<BitcoinPruningChoice v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded" v-model="pruneOnInstall" class="mb-4" />
|
||||
|
||||
<!-- Bottom: Action Buttons -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@@ -153,7 +160,7 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
@@ -374,6 +381,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BitcoinPruningChoice from '@/components/BitcoinPruningChoice.vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
@@ -408,6 +416,10 @@ const bitcoinPruned = ref(false)
|
||||
// Hidden when an app offers only one version — install stays one-click.
|
||||
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
||||
const selectedInstallVersion = ref('')
|
||||
const pruneOnInstall = ref(false)
|
||||
const pruneSettingKnown = ref(false)
|
||||
const prunePrefsLoaded = ref(false)
|
||||
const isBitcoinInstall = computed(() => ['bitcoin-core', 'bitcoin-knots'].includes(app.value?.id || ''))
|
||||
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
@@ -587,10 +599,13 @@ onMounted(() => {
|
||||
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
||||
// paints from cache with no new RPC.
|
||||
async function loadInstallVersions() {
|
||||
if (versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||
if (isBitcoinInstall.value || versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||
await versionsResource.refresh()
|
||||
}
|
||||
const info = versionsResource.data.value
|
||||
pruneSettingKnown.value = !versionsResource.error.value && typeof info?.bitcoinPrune === 'boolean'
|
||||
pruneOnInstall.value = pruneSettingKnown.value && info?.bitcoinPrune === true
|
||||
prunePrefsLoaded.value = true
|
||||
if (!info || !info.supportsVersions || info.versions.length < 2) {
|
||||
installVersions.value = []
|
||||
return
|
||||
@@ -701,6 +716,7 @@ async function installApp() {
|
||||
id: app.value.id,
|
||||
dockerImage: app.value.dockerImage,
|
||||
version: chosenVersion,
|
||||
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||
}
|
||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||
await rpcClient.call({
|
||||
@@ -717,6 +733,7 @@ async function installApp() {
|
||||
id: app.value.id,
|
||||
url: installUrl,
|
||||
version: chosenVersion,
|
||||
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Marketplace from '../Marketplace.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import MarketplaceAppCard from '../marketplace/MarketplaceAppCard.vue'
|
||||
|
||||
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
|
||||
// mounting a view directly with its heavier deps mocked at the module
|
||||
@@ -44,22 +47,37 @@ vi.mock('@/composables/useMarketplaceApp', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
|
||||
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock, action: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
|
||||
getPackageVersions: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../discover/curatedApps', () => ({
|
||||
fetchAppCatalog: vi.fn().mockResolvedValue({
|
||||
apps: ['bitcoin-core', 'bitcoin-knots'].map(id => ({
|
||||
id, title: id, version: '29.0', description: 'Bitcoin node',
|
||||
dockerImage: `registry.example/${id}:29.0`, source: 'community',
|
||||
})),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||
routerPushMock.mockClear()
|
||||
toastErrorMock.mockClear()
|
||||
toastInfoMock.mockClear()
|
||||
vi.mocked(rpcClient.call).mockReset()
|
||||
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
|
||||
supportsVersions: true, default: '29.0', bitcoinPrune: false,
|
||||
versions: [{ version: '29.0', default: true, deprecated: false, eol: null }],
|
||||
} as Awaited<ReturnType<typeof rpcClient.getPackageVersions>>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -89,4 +107,38 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['bitcoin-core', 'bitcoin-knots'])('requires the version modal before installing %s and forwards pruning', async (id) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
|
||||
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||
await flushPromises()
|
||||
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === id)!
|
||||
expect(card.exists()).toBe(true)
|
||||
card.vm.$emit('install', card.props('app'))
|
||||
await flushPromises()
|
||||
const installs = () => vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')
|
||||
expect(installs()).toHaveLength(0)
|
||||
const modal = wrapper.findComponent(InstallVersionModal)
|
||||
expect(modal.props('show')).toBe(true)
|
||||
await modal.get('input[type="checkbox"]').setValue(true)
|
||||
await modal.get('button.glass-button-warning').trigger('click')
|
||||
await flushPromises()
|
||||
expect(installs()).toHaveLength(1)
|
||||
expect(installs()[0]?.[0].params).toMatchObject({ id, version: '29.0', prune: true })
|
||||
expect(modal.props('show')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('cancels Bitcoin selection without sending an installation request', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
|
||||
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||
await flushPromises()
|
||||
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === 'bitcoin-knots')!
|
||||
card.vm.$emit('install', card.props('app'))
|
||||
await flushPromises()
|
||||
wrapper.findComponent(InstallVersionModal).vm.$emit('close')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,6 +238,47 @@ describe('Home tab cache (Task 2): system/update/storage groups + wallet freshne
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['failure', 'missing', 'partial'])('preserves known balances during %s and clears the warning on recovery', async (failure) => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
const home = wrapper.findComponent(Home)
|
||||
const refresh = () => (home.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
|
||||
rpcCallMock.mockImplementationOnce(async () => {
|
||||
if (failure === 'failure') throw new Error('wallet locked')
|
||||
return failure === 'partial' ? { balance_sats: 0 } : {}
|
||||
})
|
||||
await refresh()
|
||||
await settle()
|
||||
const card = wrapper.findComponent(HomeWalletCard)
|
||||
expect(card.props('walletOnchain')).toBe(5000)
|
||||
expect(card.props('walletLightning')).toBe(2500)
|
||||
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('last known')
|
||||
const snapshot = JSON.parse(localStorage.getItem('archy-wallet-snapshot-v1')!)
|
||||
expect(snapshot.onchain).toBe(5000)
|
||||
expect(snapshot.lightning).toBe(2500)
|
||||
rpcCallMock.mockImplementationOnce(async () => ({ balance_sats: 0, channel_balance_sats: 0, synced_to_chain: true }))
|
||||
await refresh()
|
||||
await settle()
|
||||
expect(card.props('walletOnchain')).toBe(0)
|
||||
expect(card.props('walletLightning')).toBe(0)
|
||||
expect(card.find('[data-testid="wallet-balance-unavailable"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows unknown rather than zero when the first LND request fails', async () => {
|
||||
rpcCallMock.mockImplementation(async (request) => {
|
||||
if (request.method === 'lnd.getinfo') throw new Error('wallet locked')
|
||||
return defaultRpcCallImpl(request)
|
||||
})
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
const card = wrapper.findComponent(HomeWalletCard)
|
||||
expect(card.props('walletOnchain')).toBeNull()
|
||||
expect(card.props('walletLightning')).toBeNull()
|
||||
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('unavailable')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
|
||||
@@ -149,8 +149,8 @@ describe('appSessionConfig', () => {
|
||||
// A runtime port the gate does NOT front keeps plain http (https would
|
||||
// fail to connect outright).
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
||||
// Cuprate's UI port is auth:none — plain HTTP stays plain.
|
||||
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
|
||||
// Cuprate's raw RPC is never a launch surface; use the companion UI.
|
||||
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('/app/cuprate-ui/')
|
||||
})
|
||||
|
||||
it('keeps the pre-catalog Source app on the dashboard origin', () => {
|
||||
|
||||
@@ -34,6 +34,9 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'cuprate': 18091,
|
||||
'cuprate-ui': 18091,
|
||||
'archy-cuprate-ui': 18091,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
'archy-electrs-ui': 50002,
|
||||
@@ -153,6 +156,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
return appOrigin(8334, id)
|
||||
}
|
||||
|
||||
// Cuprate UI is the same companion shape on :18091. The cuprate app itself
|
||||
// publishes only the restricted RPC (18090) — a raw JSON endpoint, not a
|
||||
// page — so cuprate launches must land on the companion, never on the
|
||||
// runtimeUrl a running cuprate reports.
|
||||
if (id === 'cuprate' || id === 'cuprate-ui' || id === 'archy-cuprate-ui') {
|
||||
if (import.meta.env.DEV) return '/app/cuprate-ui/'
|
||||
return appOrigin(18091, id)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
|
||||
@@ -9,6 +9,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"cuprate-ui": 18091,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
@@ -54,6 +55,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"btcpay-server": "BTCPay Server",
|
||||
"core-lightning": "Core Lightning (CLN)",
|
||||
"cuprate": "Cuprate",
|
||||
"cuprate-ui": "Cuprate UI",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
"fedimint": "Fedimint Guardian",
|
||||
|
||||
@@ -78,12 +78,11 @@ describe('appsConfig service filtering', () => {
|
||||
|
||||
it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
|
||||
['cuprate', makePkg('cuprate', 'Cuprate daemon', 'money')],
|
||||
['cuprate', makePkg('cuprate', 'Cuprate', 'money')],
|
||||
['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')],
|
||||
]
|
||||
;(entries[0][1].manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:18091' } }
|
||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate-ui'])
|
||||
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual(['cuprate'])
|
||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate'])
|
||||
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to packaged app icon when static icon token is not a path', () => {
|
||||
|
||||
@@ -20,6 +20,10 @@ export const isServiceContainer = sharedIsServiceContainer
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
// Cuprate's dashboard is bundled as a companion of the primary cuprate
|
||||
// package; showing the generated container as a second Services entry
|
||||
// defeats the one-app presentation.
|
||||
'archy-cuprate-ui',
|
||||
])
|
||||
|
||||
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
@@ -37,7 +41,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Cuprate's daemon is the backend dependency of the Cuprate UI app.
|
||||
'cuprate',
|
||||
// Cuprate is presented as one user-facing app. Its companion container
|
||||
// (archy-cuprate-ui) is the implementation detail; do not classify the
|
||||
// primary package as a Service or it disappears from My Apps and loses its
|
||||
// launch button during/after install.
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
|
||||
@@ -123,6 +123,8 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
|
||||
'archy-lnd-ui': 'lnd-ui',
|
||||
'bitcoin-knots': 'bitcoin-ui',
|
||||
'bitcoin-core': 'bitcoin-ui',
|
||||
'cuprate': 'cuprate-ui',
|
||||
'archy-cuprate-ui': 'cuprate-ui',
|
||||
'fedimintd': 'fedimint',
|
||||
'immich_server': 'immich',
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="walletBalanceUnavailable" data-testid="wallet-balance-unavailable" class="text-sm text-amber-200 mb-3" role="status">
|
||||
{{ walletOnchain != null || walletLightning != null
|
||||
? 'Bitcoin and Lightning balances could not be refreshed. Showing last known amounts.'
|
||||
: 'Bitcoin and Lightning balances are unavailable while the wallet starts or reconnects.' }}
|
||||
</p>
|
||||
|
||||
<!-- Incoming Transactions Panel -->
|
||||
<transition name="incoming-tx-slide">
|
||||
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||
@@ -221,6 +227,7 @@ export interface WalletTransaction {
|
||||
const props = defineProps<{
|
||||
animate: boolean
|
||||
walletConnected: boolean
|
||||
walletBalanceUnavailable?: boolean
|
||||
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
|
||||
// what lets the card show a pixel readout instead of claiming a figure.
|
||||
walletOnchain: number | null
|
||||
|
||||
@@ -362,6 +362,111 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.21-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.21-alpha</span>
|
||||
<span class="text-xs text-white/40">September 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.</p>
|
||||
<p>Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.</p>
|
||||
<p>Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.</p>
|
||||
<p>Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.20-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.20-alpha</span>
|
||||
<span class="text-xs text-white/40">September 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.</p>
|
||||
<p>Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.</p>
|
||||
<p>Improved saving paid files into Files and reopening purchases without paying again.</p>
|
||||
<p>Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.</p>
|
||||
<p>Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.</p>
|
||||
<p>LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.</p>
|
||||
<p>Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.</p>
|
||||
<p>Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.19-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.19-alpha</span>
|
||||
<span class="text-xs text-white/40">September 28, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.</p>
|
||||
<p>Embedded AIUI now stays transparent so the dashboard background appears once.</p>
|
||||
<p>AIUI background fixes are now included reliably in OTA updates and fresh installations.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.18-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.18-alpha</span>
|
||||
<span class="text-xs text-white/40">September 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.</p>
|
||||
<p>Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.</p>
|
||||
<p>Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.17-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.17-alpha</span>
|
||||
<span class="text-xs text-white/40">September 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.</p>
|
||||
<p>Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.</p>
|
||||
<p>Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.</p>
|
||||
<p>Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.16-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.16-alpha</span>
|
||||
<span class="text-xs text-white/40">September 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.</p>
|
||||
<p>Fixed repeated Mempool update offers: downstream -archyN patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.</p>
|
||||
<p>Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.</p>
|
||||
<p>Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.15-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.15-alpha</span>
|
||||
<span class="text-xs text-white/40">September 13, 2026</span>
|
||||
</div>
|
||||
<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 -->
|
||||
<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.14-alpha</span>
|
||||
<span class="text-xs text-white/40">September 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Cuprate gains a first-party companion dashboard.</strong> The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.</p>
|
||||
<p><strong>Bitcoin Core Tor enrollment uses the correct protocol identity.</strong> bitcoin-core is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.</p>
|
||||
<p><strong>GitWorkshop opens Archipelago’s canonical ngit repository by default.</strong> The launcher and registry promotion use the full maintainer/relay/archy coordinate, with regression coverage for Companion and browser-tab launches.</p>
|
||||
<p><strong>Release validation is stricter.</strong> The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.13-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -369,7 +474,9 @@ init()
|
||||
<span class="text-xs text-white/40">September 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>GitWorkshop installs reliably on fresh nodes.</strong> The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services. Fresh installs now use the production orchestrator to build the bundled GitWorkshop image rather than sending its local image reference through the legacy registry-pull path. Regression coverage now protects all curated app classifications.</p>
|
||||
<p><strong>GitWorkshop installs reliably on fresh nodes.</strong> The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.</p>
|
||||
<p><strong>Fresh GitWorkshop installs build the correct image.</strong> The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.</p>
|
||||
<p><strong>Curated app classification is regression-tested.</strong> Every user-facing app remains in My Apps during installation, while headless services stay in Services.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.12-alpha -->
|
||||
|
||||
@@ -173,6 +173,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/app/cuprate-ui': {
|
||||
target: process.env.BACKEND_URL || 'http://localhost:5959',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Demo mock app UIs (electrumx, lnd, fedimint) + generic notice page.
|
||||
'/app/electrumx': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/electrs': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
|
||||
+18
-17
@@ -1,29 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.",
|
||||
"**Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.",
|
||||
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services."
|
||||
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago",
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.13-alpha",
|
||||
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4",
|
||||
"size_bytes": 64585424
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||
"size_bytes": 64748176
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"new_version": "1.8.13-alpha",
|
||||
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538",
|
||||
"size_bytes": 97915796
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||
"size_bytes": 97152546
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-12",
|
||||
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f",
|
||||
"release_date": "2026-09-30",
|
||||
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.13-alpha"
|
||||
"version": "1.8.21-alpha"
|
||||
}
|
||||
|
||||
+3259
-3196
File diff suppressed because one or more lines are too long
+18
-17
@@ -1,29 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.",
|
||||
"**Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.",
|
||||
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services."
|
||||
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago",
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.13-alpha",
|
||||
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4",
|
||||
"size_bytes": 64585424
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||
"size_bytes": 64748176
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"new_version": "1.8.13-alpha",
|
||||
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538",
|
||||
"size_bytes": 97915796
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||
"size_bytes": 97152546
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-12",
|
||||
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f",
|
||||
"release_date": "2026-09-30",
|
||||
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.13-alpha"
|
||||
"version": "1.8.21-alpha"
|
||||
}
|
||||
|
||||
@@ -112,10 +112,9 @@ VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*
|
||||
if [ "$SKIP_GATES" = "0" ]; then
|
||||
stage "release-gate-harness" bash tests/release/run.sh
|
||||
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Full Rust suite — the release harness only runs a 6-module slice;
|
||||
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
|
||||
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
|
||||
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
|
||||
# The release harness runs the full backend suite inside namespaces.
|
||||
# Never execute unrestricted tests on a node with live wallets/services.
|
||||
|
||||
else
|
||||
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
|
||||
fi
|
||||
|
||||
@@ -19,6 +19,7 @@ INTERNAL_MANIFEST_IDS = {
|
||||
"archy-nbxplorer",
|
||||
"bitcoin-ui",
|
||||
"core-lightning",
|
||||
"cuprate-ui",
|
||||
"electrs-ui",
|
||||
"fips-ui",
|
||||
"lnd-ui",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user