Compare commits

...
Author SHA1 Message Date
archipelago 971d477795 Merge current main and harden paid-download delivery 2026-09-30 07:25:47 -04:00
archipelago f12042f194 docs: track X250 kiosk Bitcoin version selector regression 2026-09-30 07:01:57 -04:00
archipelago e7cf336665 chore: publish release v1.8.21-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-09-30 05:55:13 -04:00
archipelago 8ca20de82e release: prepare signed 1.8.21-alpha OTA 2026-09-30 05:51:16 -04:00
archipelago 1fa654cb6a docs: record 1.8.21 artifact and two-node release acceptance 2026-09-30 05:39:26 -04:00
archipelago c993d9dd0d fix(lnd): require observed Bitcoin lifecycle change before dependency restart 2026-09-30 05:16:17 -04:00
archipelago 33d2b3ce60 fix(containers): preserve graceful shutdown through Quadlet and prepare 1.8.21 2026-09-30 04:59:30 -04:00
archipelago c7ce35bd43 chore: publish release v1.8.20-alpha
Demo images / Build & push demo images (push) Failing after 1m31s
2026-09-30 04:32:43 -04:00
archipelago ad1d71a462 Prepare signed v1.8.20-alpha release and record operator acceptance 2026-09-30 04:27:02 -04:00
ssmithxandClaude Opus 5.5 03e38d1ca3 test: regression tests for the paid-download fixes
- mint_client: a stub mint shows swap() sends the full v2 keyset id when
  given a cashuB short id, and leaves complete v1/v2 ids unchanged.
- fips::dial: the single-delivery decisions are now small functions
  (fips_answer_is_final, fips_retryable). Tests cover them and, against a
  silent local peer, check that a single-delivery request isn't resent
  after a timeout while an ordinary one still is.
- content_server: an unreadable paid file returns Unavailable before the
  payment gate runs, and a readable one still returns 402. Also covers
  ensure_readable's grant/reopen behaviour. The podman grant is replaced
  by a refusal under cfg(test) so results don't depend on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:12:16 +00:00
archipelago bded929812 Record validated OTA candidate and remaining release gates 2026-09-29 15:47:22 -04:00
archipelago 3612458e86 Handle empty recorded calls in install regression assertion 2026-09-29 15:38:49 -04:00
archipelago 8d9fad1749 Require Bitcoin version and pruning selection from the App Store 2026-09-29 15:37:05 -04:00
archipelago d25ed492c9 Keep Bitcoin pruning explanation below desktop install controls 2026-09-29 15:27:00 -04:00
archipelago 1f9abefc35 Isolate backend tests from live node wallets and services 2026-09-29 15:15:51 -04:00
archipelago b634f41a1c Complete paid-file caching and deliver LND waiting UI to existing nodes 2026-09-29 14:59:08 -04:00
archipelago 0f85f588fb Fix Cashu file redemption and Bitcoin-dependent wallet readiness 2026-09-29 14:42:44 -04:00
ssmithxandClaude Opus 5.5 e5fc99d66c fix(content): never charge for a file the seller can't serve or replay a spent token
After the keyset-id fix, a Minibits paid download still failed and the
buyer lost the sats. What happened, 2026-09-29, amishparadise:

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:47:23 +00:00
archipelago 540639d2c1 chore: publish release v1.8.19-alpha 2026-09-28 13:21:51 -04:00
archipelago 562871b1ce chore: prepare signed release v1.8.19-alpha 2026-09-28 13:18:34 -04:00
archipelago cca3f8bfcd docs: include AIUI packaging fix in v1.8.19 release notes
Demo images / Build & push demo images (push) Failing after 44s
2026-09-28 13:13:31 -04:00
archipelago 89c08be712 fix(aiui): match host color scheme for iframe transparency
Demo images / Build & push demo images (push) Failing after 56s
2026-09-28 12:37:40 -04:00
archipelago b4ecf86c13 chore: stage v1.8.19-alpha version bump 2026-09-28 12:33:59 -04:00
archipelago b14fe78306 fix(aiui): expose host wallpaper and package fresh production builds 2026-09-28 12:32:18 -04:00
archipelago 3f0c1038c3 docs: add v1.8.19 release notes to settings 2026-09-28 12:23:57 -04:00
archipelago 1fbefce6df docs: add v1.8.19-alpha release notes 2026-09-28 12:23:05 -04:00
archipelago 63cb68451a fix(aiui): keep embedded chat background transparent 2026-09-22 05:04:59 -04:00
archipelago 17cfebbe26 chore: publish release v1.8.18-alpha 2026-09-20 11:49:39 -04:00
archipelago 379fb930fc chore: prepare release v1.8.18-alpha
Demo images / Build & push demo images (push) Failing after 43s
2026-09-20 11:45:35 -04:00
archipelago 1bebdeac0f Prepare v1.8.18-alpha release notes 2026-09-18 06:36:43 -04:00
archipelago f458591132 Document Minibits description customization limits
Demo images / Build & push demo images (push) Failing after 45s
2026-09-15 16:13:14 -04:00
archipelago 6155539254 Confirm Primal automatic-comment cause and successful workaround 2026-09-15 16:11:09 -04:00
archipelago 76e0f1f3b6 Trace Primal Spark auto-comment failure against Framework address 2026-09-15 16:09:16 -04:00
archipelago ba6ce2cdb6 Record live LNURL comment limit investigation 2026-09-15 16:06:21 -04:00
archipelago 8212049f57 Shorten ecash backup copy and stack card actions 2026-09-15 16:03:52 -04:00
archipelago 5814f47659 docs: verify Framework Cashu address and preserved proofs 2026-09-15 15:58:16 -04:00
archipelago 94f5e892c3 docs: track authenticated Cashu address setup and remaining verification 2026-09-15 15:47:48 -04:00
archipelago a3b6467047 fix(ecash): guide unseeded wallets through Lightning address setup 2026-09-15 15:45:32 -04:00
archipelago 66db6497ec docs: record successful Framework reboot verification 2026-09-15 15:27:13 -04:00
archipelago 81be17f09f docs: record Framework live evidence and staged fix validation 2026-09-15 15:15:34 -04:00
archipelago 4237fb5e79 fix(wallet): prioritize LND boot and reject unavailable balances 2026-09-15 15:09:08 -04:00
archipelago 4302138b4f docs: make Framework LND incident a persistent investigation blocker 2026-09-15 14:56:01 -04:00
archipelago 3b9b74dae5 chore: publish release v1.8.17-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-09-15 12:56:18 -04:00
archipelago 4021c1f496 chore: prepare release v1.8.17-alpha 2026-09-15 12:53:06 -04:00
archipelago 5f8de584bc docs: add v1.8.17-alpha release notes
Demo images / Build & push demo images (push) Failing after 42s
2026-09-15 12:33:24 -04:00
chaum 38de1b3310 Merge pull request 'fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn' (#160) from fix/minibits-already-redeemed into main 2026-09-15 16:32:53 +00:00
archipelago abfbccc906 fix(ecash): preserve retryable claims and resume relay backlogs 2026-09-15 12:31:49 -04:00
ssmithxandClaude Sonnet 5 9d4e74e094 docs: redact node hostname from the Minibits incident writeup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:19:23 +00:00
ssmithxandClaude Sonnet 5 db355b759c fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn
claim_and_redeem retried every redeem failure indefinitely, including a
terminal one: mint error 11001 "Token Already Spent" (a claim replayed by a
relay-watermark edge case, or already redeemed by an earlier run). On
archy-x250-pa3 this pinned pending_claims at 1 forever and hammered
mint.minibits.cash's swap endpoint every ~6s, with the UI permanently
showing "a payment arrived but couldn't be redeemed yet".

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:12:00 +00:00
71 changed files with 5051 additions and 703 deletions
+31
View File
@@ -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.
+37
View File
@@ -2,6 +2,43 @@
## Unreleased ## 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) ## 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. - 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.
+3 -2
View File
@@ -46,13 +46,14 @@ interface RateBucket {
const rateBuckets = new Map<string, 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(() => { setInterval(() => {
const now = Date.now() const now = Date.now()
for (const [key, bucket] of rateBuckets) { for (const [key, bucket] of rateBuckets) {
if (now > bucket.resetAt) rateBuckets.delete(key) if (now > bucket.resetAt) rateBuckets.delete(key)
} }
}, 5 * 60_000) }, 5 * 60_000).unref()
function getClientIp(req: IncomingMessage): string { function getClientIp(req: IncomingMessage): string {
return req.socket.remoteAddress ?? 'unknown' return req.socket.remoteAddress ?? 'unknown'
+1
View File
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
// Only embedded when explicitly requested via ?embedded param // Only embedded when explicitly requested via ?embedded param
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded') const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag ;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
+4 -4
View File
@@ -2,13 +2,13 @@
<div <div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300" class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]" :class="[]"
:style="isDark :style="isEmbedded
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: isEmbedded
? { background: 'transparent' } ? { background: 'transparent' }
: isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: { backgroundColor: '#f5f4f1' }" : { 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 --> <!-- Desktop layout -->
<div <div
+15 -6
View File
@@ -57,12 +57,8 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6]) /* Standalone canvas fallback. Embedded mode overrides this below so
EXCEPT the embedded Chat page, which intentionally goes transparent so Archy's wallpaper remains visible through the iframe. */
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
no background-color here, "transparent" fell through to the browser's
default white canvas instead. Match the theme's own dark/light default so
nothing above this ever needs to guess. */
background-color: #0a0a0a; background-color: #0a0a0a;
} }
@@ -70,6 +66,19 @@ html.light body {
background-color: #faf9f6; 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 ===== */ /* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
@layer components { @layer components {
+1 -1
View File
@@ -54,7 +54,7 @@ app:
if [ -n "$RPC_TXRELAY_AUTH" ]; then 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"; 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; 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; 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 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; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+1 -1
View File
@@ -60,7 +60,7 @@ app:
if [ -n "$RPC_TXRELAY_AUTH" ]; then 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"; 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; 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; 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 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; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.16-alpha" version = "1.8.21-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.16-alpha" version = "1.8.21-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
+23 -1
View File
@@ -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."}"#, 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, StatusCode::NOT_FOUND,
"text/plain", "text/plain",
hyper::Body::from("Content not found"), 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"),
))
}
} }
} }
+13
View File
@@ -138,6 +138,19 @@ impl ApiHandler {
cors_origin: &str, cors_origin: &str,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/"); 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}"); let url = format!("{LND_REST_BASE_URL}{suffix}");
// LND REST serves a self-signed cert and requires the admin macaroon. // LND REST serves a self-signed cert and requires the admin macaroon.
// A bare reqwest::get() uses the default client, which rejects the // A bare reqwest::get() uses the default client, which rejects the
+176 -99
View File
@@ -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 /// 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 /// 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 /// 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 /// un-redeemed notes; for Cashu the proofs are received back. Report the actual
/// the seller already claimed the token (then the value is genuinely gone). /// 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) { async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
let res = match backend { let res = match backend {
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token) "fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
.await .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, _ => ecash::receive_token(data_dir, token).await,
}; };
match res { match res {
Ok(sats) => tracing::info!( Ok(sats) => {
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale" tracing::info!("paid download: reclaimed {sats} sats after failed sale");
), format!("Refunded {sats} sats to your wallet.")
Err(e) => tracing::warn!(
"paid download: could not reclaim {backend} ecash (the peer may have already \
claimed it): {e:#}"
),
} }
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 { impl RpcHandler {
@@ -463,17 +567,10 @@ impl RpcHandler {
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id) crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
.await .await
{ {
use base64::Engine; let mut result = paid_content_response(&bytes, &mime, 0);
return Ok(serde_json::json!({ result["already_owned"] = serde_json::json!(true);
"owned": true, result["filename"] = serde_json::json!(o.filename);
"already_owned": true, return Ok(result);
"filename": o.filename,
"mime_type": mime,
"size_bytes": bytes.len(),
"paid_sats": 0,
"data_base64":
base64::engine::general_purpose::STANDARD.encode(&bytes),
}));
} }
// Cache record exists but bytes are gone — fall through and // Cache record exists but bytes are gone — fall through and
// repurchase rather than stranding the user. // repurchase rather than stranding the user.
@@ -545,16 +642,14 @@ impl RpcHandler {
let path = format!("/content/{}", content_id); let path = format!("/content/{}", content_id);
// Surface a real reason instead of the generic sanitized error (#30): // 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 // A bearer token must not be replayed after an ambiguous delivery.
// here means the peer is genuinely unreachable on both transports. // A transport error can mean the seller received it without replying.
let (response, transport) = match crate::fips::dial::PeerRequest::new( let (response, transport) =
fips_npub.as_deref(), match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone()) .header("X-Payment-Token", token_str.clone())
.single_delivery()
.timeout(std::time::Duration::from_secs(900)) .timeout(std::time::Duration::from_secs(900))
.send_get() .send_get()
.await .await
@@ -564,9 +659,10 @@ impl RpcHandler {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e); tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
// The token was already minted/spent — reclaim it so the buyer // The token was already minted/spent — reclaim it so the buyer
// doesn't lose the value when the seller was simply unreachable. // 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; reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({ 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 { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// Payment was rejected by the seller. Surface the most likely cause // A 402 can mean mint validation, network failure, underpayment,
// per backend — for ecash both sides must share a redemption network // or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
// (a Cashu mint, or a Fedimint federation).
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
tracing::warn!( tracing::warn!(
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}" "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 // Seller couldn't redeem the token — reclaim it so the buyer keeps
// their funds (the spent-but-unredeemed-notes case the user hit). // their funds (the spent-but-unredeemed-notes case the user hit).
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;
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",
};
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"error": format!( "error": format!("The seller could not verify the payment. {refund}")
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
your wallet. Try the other ecash type, or use a shared mint/federation."
)
})); }));
} }
@@ -609,9 +697,9 @@ impl RpcHandler {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
tracing::warn!("paid download: seller {onion} returned {status}: {body}"); 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!({ 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()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_string()); .unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = response let bytes = match response.bytes().await {
.bytes() Ok(bytes) => bytes,
.await Err(error) => {
.context("Failed to read response body")?; 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 // Persist the purchase so it "stays unlocked" for this buyer: cache the
// bytes + metadata keyed by (onion, content_id). The gallery then renders // 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:#}"); 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): // The durable purchased-content cache above is primary. A Files copy
// Photos for images/video, Music for audio, Documents otherwise — // remains optional: a stopped FileBrowser must not undo a paid download.
// same buckets the Cloud view uses. The in-app viewer still plays let filed = async {
// from the purchase cache; this makes the file ALSO show up where let auth = self.handle_filebrowser_token().await?;
// files live, on every device, without relying on a browser let token = auth
// download. Best-effort: never fail a paid download over it. .get("token")
{ .and_then(|v| v.as_str())
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") { .context("FileBrowser omitted its authentication token")?;
"Photos" let client = reqwest::Client::builder()
} else if mime_type.starts_with("audio/") { .no_proxy()
"Music" .redirect(reqwest::redirect::Policy::none())
} else { .timeout(std::time::Duration::from_secs(30))
"Documents" .build()?;
}; file_purchase_in_files(
let base = std::path::Path::new(&filename) &client,
.file_name() "http://127.0.0.1:8083",
.and_then(|n| n.to_str()) token,
.unwrap_or("download") &filename,
.to_string(); &mime_type,
let dir = self.config.data_dir.join("filebrowser").join(folder); &bytes,
if let Err(e) = tokio::fs::create_dir_all(&dir).await { )
tracing::warn!("paid download: cannot create {}: {e}", dir.display()); .await
} else {
// Don't clobber an existing file of the same name: "x.jpg"
// → "x (2).jpg" etc.
let mut target = dir.join(&base);
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (base.clone(), String::new()),
};
let mut n = 2;
while target.exists() {
target = dir.join(format!("{stem} ({n}){ext}"));
n += 1;
} }
match tokio::fs::write(&target, &bytes).await { .await;
Ok(()) => tracing::info!("paid download: filed into {}", target.display()), match filed {
Err(e) => tracing::warn!( Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
"paid download: filing into {} failed (non-fatal): {e}", Err(error) => tracing::warn!(
target.display() "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()); tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
Ok(serde_json::json!({ let mut result = paid_content_response(&bytes, &mime_type, price_sats);
"data": encoded, result["ecash_backend"] = serde_json::json!(used_backend);
"size": bytes.len(), Ok(result)
"paid_sats": price_sats,
"ecash_backend": used_backend,
"mime_type": mime_type,
"owned": true,
}))
} }
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a /// 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)."
);
}
}
+213 -51
View File
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
pending_open_local_balance: Option<LndAmount>, 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 { 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> { 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_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes); let macaroon_hex = hex::encode(&macaroon_bytes);
@@ -85,45 +164,26 @@ impl RpcHandler {
.build() .build()
.context("Failed to create HTTP client")?; .context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client let get_info: LndGetInfoResponse = get_lnd_json(
.get(format!("{LND_REST_BASE_URL}/v1/getinfo")) &client,
.header("Grpc-Metadata-macaroon", &macaroon_hex) &format!("{LND_REST_BASE_URL}/v1/getinfo"),
.send() &macaroon_hex,
.await )
.context("LND REST connection failed")? .await?;
.json() let channel_balance: LndChannelBalanceResponse = get_lnd_json(
.await &client,
.context("Failed to parse LND getinfo response")?; &format!("{LND_REST_BASE_URL}/v1/balance/channels"),
&macaroon_hex,
let channel_balance: LndChannelBalanceResponse = match client )
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels")) .await?;
.header("Grpc-Metadata-macaroon", &macaroon_hex) let wallet_balance: LndBalanceResponse = get_lnd_json(
.send() &client,
.await &format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
{ &macaroon_hex,
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse { )
local_balance: None, .await?;
pending_open_local_balance: None, let (balance_sats, channel_balance_sats, pending_open_balance) =
}), checked_balances(wallet_balance, channel_balance)?;
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 (identity_pubkey, uris) = map_identity(&get_info); let (identity_pubkey, uris) = map_identity(&get_info);
@@ -135,18 +195,9 @@ impl RpcHandler {
num_peers: get_info.num_peers.unwrap_or(0), num_peers: get_info.num_peers.unwrap_or(0),
synced_to_chain: get_info.synced_to_chain.unwrap_or(false), synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
block_height: get_info.block_height.unwrap_or(0), block_height: get_info.block_height.unwrap_or(0),
balance_sats: wallet_balance balance_sats,
.total_balance channel_balance_sats,
.and_then(|s| s.parse().ok()) pending_open_balance,
.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),
}; };
Ok(serde_json::to_value(info)?) Ok(serde_json::to_value(info)?)
@@ -268,6 +319,76 @@ impl RpcHandler {
mod tests { mod tests {
use super::*; 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. /// A real compressed secp256k1 pubkey shape: 66 hex characters.
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"; const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
@@ -341,3 +462,44 @@ mod tests {
assert!(!is_valid_identity_pubkey(&"g".repeat(66))); 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"
);
}
}
+62 -1
View File
@@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
/// RPC-unreachable and locked-wallet states are deliberately NOT handled /// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the /// here — container-down is crash-recovery's job, and unlocking needs the
/// operator. /// 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() { pub(crate) fn spawn_lnd_health_watchdog() {
tokio::spawn(async move { tokio::spawn(async move {
let mut bad_minutes: u32 = 0; let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None; let mut last_restart: Option<tokio::time::Instant> = None;
let mut last_height: Option<u64> = None;
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await; 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 { let Ok(bytes) = read_lnd_admin_macaroon().await else {
bad_minutes = 0; // no LND on this node (or not set up yet) bad_minutes = 0; // no LND on this node (or not set up yet)
continue; continue;
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
bad_minutes = 0; // down/locked — not the wedge signature bad_minutes = 0; // down/locked — not the wedge signature
continue; continue;
}; };
if !resp.status().is_success() {
bad_minutes = 0;
continue;
}
let Ok(info) = resp.json::<serde_json::Value>().await else { let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0; bad_minutes = 0;
continue; continue;
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
.get("num_pending_channels") .get("num_pending_channels")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0); .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 { if !wedged {
bad_minutes = 0; bad_minutes = 0;
continue; continue;
@@ -239,3 +272,31 @@ impl RpcHandler {
Ok((client, macaroon_hex)) Ok((client, macaroon_hex))
} }
} }
#[cfg(test)]
mod watchdog_dependency_tests {
use super::bitcoin_ready_for_lnd_watchdog;
use crate::bitcoin_status::BitcoinNodeStatus;
use serde_json::json;
#[test]
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
let mut status = BitcoinNodeStatus::default();
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.ok = true;
status.blockchain_info = Some(json!({"initialblockdownload":true}));
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.blockchain_info = Some(json!({"initialblockdownload":false}));
assert!(bitcoin_ready_for_lnd_watchdog(&status));
status.stale = true;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.stale = false;
status.ok = false;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.ok = true;
status.age_ms = 30_000;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.age_ms = 0;
status.blockchain_info = Some(json!({}));
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
}
}
@@ -326,6 +326,10 @@ impl RpcHandler {
// an older version pins it so install_fresh resolves that image and the // an older version pins it so install_fresh resolves that image and the
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md. // update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") { 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()) { if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
persist_install_version_selection(package_id, version).await; persist_install_version_selection(package_id, version).await;
} }
@@ -153,8 +153,18 @@ impl RpcHandler {
let default = app_catalog::catalog_default_version(app_id); let default = app_catalog::catalog_default_version(app_id);
let cfg = version_config::read(app_id); let cfg = version_config::read(app_id);
let installed = installed_version(app_id).await; 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!({ Ok(serde_json::json!({
"bitcoinPrune": bitcoin_prune,
"id": app_id, "id": app_id,
"supportsVersions": supports_versions(app_id), "supportsVersions": supports_versions(app_id),
"default": default, "default": default,
+23 -1
View File
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
.trim() .trim()
.trim_end_matches('.'); .trim_end_matches('.');
let lower = detail.to_lowercase(); 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") Some("verifying blocks after restart")
} else if lower.contains("connection reset") { } else if lower.contains("connection reset") {
Some("starting up and not yet accepting RPC connections") Some("starting up and not yet accepting RPC connections")
@@ -340,3 +344,21 @@ mod tests {
assert!(msg.len() < 260); 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);
}
}
}
+31 -6
View File
@@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
/// should reference (`localhost/<base>:latest` for build, registry /// should reference (`localhost/<base>:latest` for build, registry
/// URL for pull). /// URL for pull).
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> { 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 local_image_compat = format!("localhost/{}:local", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base); let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
@@ -322,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
for dir in spec.build_dir_candidates { for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile"); let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) { 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 { 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 // changed since it was built. Without this staleness check an
// already-present image is reused forever, so edits to the baked-in // already-present image is reused forever, so edits to the baked-in
// context (Dockerfile, nginx.conf, …) never reach the node — this is // context (Dockerfile, nginx.conf, …) never reach the node — this is
@@ -849,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
if !matches_known_shape { if !matches_known_shape {
return Ok(true); 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 { for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile"); let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) { if fs::try_exists(&dockerfile).await.unwrap_or(false) {
// Conservative on any timeout/error inside: reuse the cache. // 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) 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)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
for tag in ["local", "latest"] {
let image = format!("localhost/{}:{tag}", spec.image_base);
let unit = build_unit(spec, &image).render();
assert_eq!(managed_local_image(spec, &unit), Some(image));
}
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
assert_eq!(
managed_local_image(spec, &build_unit(spec, &registry).render()),
None
);
}
}
use super::*; use super::*;
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> { fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
+107 -117
View File
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
Ok(EnsureOutcome::Written) 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<()> { pub async fn ensure_wallet_initialized() -> Result<()> {
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; 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"; let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
if file_exists_as_root(wallet_db).await { 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 { if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(()); return Ok(());
} }
match unlock_existing_wallet().await? { if !bitcoin_rpc_ready().await {
true => { tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(()); return Ok(());
} }
false => { unlock_existing_wallet_no_wipe().await?;
// Every candidate password was actively rejected: this wallet was
// created with a password this node no longer has, so it can never
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
// locked with an unknown password is already inaccessible, so wipe +
// recreate it on the per-node secret to self-heal at boot.
recreate_wallet_destructively().await?;
wait_for_admin_macaroon(admin_macaroon).await?; wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(()); 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?; init_wallet_via_rest().await?;
wait_for_admin_macaroon(admin_macaroon).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. /// Canonical on-host admin macaroon — same path the RPC layer reads.
const LND_ADMIN_MACAROON: &str = const LND_ADMIN_MACAROON: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; "/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"; 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 { async fn file_exists_as_root(path: &str) -> bool {
if std::path::Path::new(path).exists() { if std::path::Path::new(path).exists() {
return true; 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 — // exactly the nodes least able to afford it. Waiting longer costs nothing —
// a wrong password still exits on the first pass via `all_rejected`. // a wrong password still exits on the first pass via `all_rejected`.
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS { 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; let mut all_rejected = true;
for pw in &candidates { for pw in &candidates {
match try_unlock_once(&client, pw).await { 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. /// Unlock the existing wallet, preserving its identity and channel data when
/// /// passwords are unavailable or rejected. Used by boot and credential rotation.
/// `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.
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> { pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
match unlock_existing_wallet().await? { match unlock_existing_wallet().await? {
true => Ok(()), 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 /// Current LND wallet state via the unauthenticated `/v1/state` endpoint
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable. /// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
async fn wallet_state(client: &reqwest::Client) -> Option<String> { 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::Value(seed) => seed,
UnlockerResponse::WalletAlreadyExists => { UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?; unlock_existing_wallet_no_wipe().await?;
return Ok(()); return Ok(());
} }
}; };
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
.await; .await;
} }
UnlockerResponse::WalletAlreadyExists => { UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?; unlock_existing_wallet_no_wipe().await?;
} }
} }
@@ -1203,3 +1152,44 @@ mod tests {
.is_empty()); .is_empty());
} }
} }
#[cfg(test)]
mod bitcoin_readiness_tests {
use super::bitcoin_readiness_response;
use serde_json::json;
#[test]
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
for response in [
json!({}),
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
json!({"result":{"blocks":null}}),
] {
assert!(!bitcoin_readiness_response(&response));
}
// Initial sync is supported by LND. Loading the database is not.
for ibd in [true, false] {
assert!(bitcoin_readiness_response(
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
));
}
}
}
#[cfg(test)]
mod syncing_wallet_state_tests {
#[test]
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
assert!(super::wallet_is_unlocked(Some(state)));
}
for state in [
None,
Some("LOCKED"),
Some("NON_EXISTING"),
Some("WAITING_TO_START"),
Some("unknown"),
] {
assert!(!super::wallet_is_unlocked(state));
}
}
}
@@ -798,6 +798,10 @@ fn host_port_bindings_drifted(
} }
async fn ensure_user_podman_socket() -> Result<()> { 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"; let socket_path = "/run/user/1000/podman/podman.sock";
if podman_socket_accepts_connections(socket_path).await { if podman_socket_accepts_connections(socket_path).await {
return Ok(()); return Ok(());
@@ -1170,15 +1174,21 @@ impl ReconcileReport {
fn cascade_pairs_for_report<'r>( fn cascade_pairs_for_report<'r>(
report: &'r ReconcileReport, report: &'r ReconcileReport,
user_stopped: &std::collections::HashSet<String>, user_stopped: &std::collections::HashSet<String>,
changed_backends: &HashSet<String>,
) -> Vec<(&'r str, &'static str)> { ) -> Vec<(&'r str, &'static str)> {
let mut pairs = Vec::new(); let mut pairs = Vec::new();
for (backend, action) in &report.actions { for (backend, action) in &report.actions {
if !matches!( if !matches!(
action, action,
ReconcileAction::Installed | ReconcileAction::Started ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
) { ) {
continue; 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) { for dep in crate::app_ops::address_caching_dependents(backend) {
let dep_untouched = report let dep_untouched = report
.actions .actions
@@ -1192,6 +1202,25 @@ fn cascade_pairs_for_report<'r>(
pairs 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)] #[derive(Debug, Default)]
pub struct AdoptionReport { pub struct AdoptionReport {
pub adopted: Vec<String>, pub adopted: Vec<String>,
@@ -1864,7 +1893,7 @@ impl ProdContainerOrchestrator {
// Durable installation record, consulted alongside the perishable // Durable installation record, consulted alongside the perishable
// `was_running` snapshot for desired-state recovery below. // `was_running` snapshot for desired-state recovery below.
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await; 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>, Vec<LoadedManifest>,
std::collections::HashMap<String, String>, std::collections::HashMap<String, String>,
) = { ) = {
@@ -1895,15 +1924,50 @@ impl ProdContainerOrchestrator {
.collect(); .collect();
(filtered, names) (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. // Live container names (any state), for the same recovery check.
let present_containers: std::collections::HashSet<String> = self let listed_containers = self.runtime.list_containers().await.ok();
.runtime let present_containers: HashSet<String> = listed_containers
.list_containers() .as_ref()
.await .map(|cs| cs.iter().map(|c| c.name.clone()).collect())
.map(|cs| cs.into_iter().map(|c| c.name).collect())
.unwrap_or_default(); .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 mut report = ReconcileReport::default();
let disk_gb = self.disk_gb().await; 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) // Register every candidate before the (sequential, possibly slow)
// pass so the scanner overlays queued-but-down apps as Restarting // pass so the scanner overlays queued-but-down apps as Restarting
// instead of Stopped. Each app is deregistered as its turn finishes, // instead of Stopped. Each app is deregistered as its turn finishes,
@@ -1943,7 +2007,7 @@ impl ProdContainerOrchestrator {
} }
if mode == ReconcileMode::ExistingOnly if mode == ReconcileMode::ExistingOnly
&& requires_archival_bitcoin(&app_id) && requires_archival_bitcoin(&app_id)
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB && bitcoin_pruned
{ {
report.record( report.record(
&app_id, &app_id,
@@ -2078,7 +2142,20 @@ impl ProdContainerOrchestrator {
// state recovery, repair recreate, boot InstallMissing) moves the // state recovery, repair recreate, boot InstallMissing) moves the
// address behind a running dependent's back — §C "restart lnd after // address behind a running dependent's back — §C "restart lnd after
// ANY bitcoin recreate". // 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 // Same rule as the RPC cascade: hold the dependent's op lock
// across the restart; skip when a worker is mid-sequence. // across the restart; skip when a worker is mid-sequence.
let lock = crate::app_ops::op_lock(dep); let lock = crate::app_ops::op_lock(dep);
@@ -3217,6 +3294,9 @@ impl ProdContainerOrchestrator {
} }
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> { async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
if cfg!(test) {
return Ok(());
}
let Some(network) = manifest.app.container.network.as_deref() else { let Some(network) = manifest.app.container.network.as_deref() else {
return Ok(()); return Ok(());
}; };
@@ -3711,6 +3791,17 @@ impl ProdContainerOrchestrator {
} }
let mut env = manifest.app.environment.clone(); let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts)); 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}} // FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
// derived_env (works on Knots/Core/any distro). The old hardcoded // derived_env (works on Knots/Core/any distro). The old hardcoded
@@ -6064,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] #[tokio::test]
async fn install_resolves_derived_and_secret_env_before_create() { async fn install_resolves_derived_and_secret_env_before_create() {
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
@@ -6335,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] #[test]
fn cascade_pairs_cover_backend_recreate_with_running_dependent() { fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
use std::collections::HashSet; use std::collections::HashSet;
@@ -6346,6 +6540,7 @@ app:
failures: vec![], failures: vec![],
}; };
let none = HashSet::new(); let none = HashSet::new();
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
// Backend recreated while lnd sat running (NoOp) → cascade. // Backend recreated while lnd sat running (NoOp) → cascade.
let r = report(vec![ let r = report(vec![
@@ -6353,7 +6548,7 @@ app:
("lnd", ReconcileAction::NoOp), ("lnd", ReconcileAction::NoOp),
]); ]);
assert_eq!( assert_eq!(
cascade_pairs_for_report(&r, &none), cascade_pairs_for_report(&r, &none, &changed),
vec![("bitcoin-knots", "lnd")] vec![("bitcoin-knots", "lnd")]
); );
@@ -6363,7 +6558,7 @@ app:
("lnd", ReconcileAction::NoOp), ("lnd", ReconcileAction::NoOp),
]); ]);
assert_eq!( assert_eq!(
cascade_pairs_for_report(&r, &none), cascade_pairs_for_report(&r, &none, &changed),
vec![("bitcoin-core", "lnd")] vec![("bitcoin-core", "lnd")]
); );
@@ -6372,7 +6567,7 @@ app:
("bitcoin-knots", ReconcileAction::NoOp), ("bitcoin-knots", ReconcileAction::NoOp),
("lnd", 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 // Dependent itself (re)started this pass → it already resolved the
// fresh address; no cascade. // fresh address; no cascade.
@@ -6380,7 +6575,7 @@ app:
("bitcoin-knots", ReconcileAction::Installed), ("bitcoin-knots", ReconcileAction::Installed),
("lnd", ReconcileAction::Started), ("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. // User-stopped dependent is never bounced.
let r = report(vec![ let r = report(vec![
@@ -6388,14 +6583,50 @@ app:
("lnd", ReconcileAction::NoOp), ("lnd", ReconcileAction::NoOp),
]); ]);
let stopped: HashSet<String> = ["lnd".to_string()].into(); 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. // Non-backend recreates don't cascade anything.
let r = report(vec![ let r = report(vec![
("grafana", ReconcileAction::Installed), ("grafana", ReconcileAction::Installed),
("lnd", ReconcileAction::NoOp), ("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] #[tokio::test]
+174 -5
View File
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
pub no_new_privileges: bool, pub no_new_privileges: bool,
pub cpu_quota: Option<u32>, pub cpu_quota: Option<u32>,
pub restart_policy: RestartPolicy, pub restart_policy: RestartPolicy,
pub stop_grace_secs: Option<u64>,
} }
impl QuadletUnit { impl QuadletUnit {
@@ -216,6 +217,10 @@ impl QuadletUnit {
let _ = writeln!(s, "[Container]"); let _ = writeln!(s, "[Container]");
let _ = writeln!(s, "ContainerName={}", self.name); let _ = writeln!(s, "ContainerName={}", self.name);
let _ = writeln!(s, "Image={}", self.image); 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 // Pull=never: companions are pre-pulled or built. A missing image
// must surface as a unit start failure, not a silent retry storm. // must surface as a unit start failure, not a silent retry storm.
let _ = writeln!(s, "Pull=never"); let _ = writeln!(s, "Pull=never");
@@ -350,6 +355,15 @@ impl QuadletUnit {
// the unit stuck in deactivating. Health/status remains app-level state, // the unit stuck in deactivating. Health/status remains app-level state,
// not a systemd start gate. // not a systemd start gate.
let _ = writeln!(s, "TimeoutStartSec=0"); 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 // Restart policy + 10s backoff. RestartSec keeps a crash-loop
// from saturating the journal. Companions: Always. Backends: // from saturating the journal. Companions: Always. Backends:
// OnFailure (clean stops stay stopped). // OnFailure (clean stops stay stopped).
@@ -525,6 +539,9 @@ impl QuadletUnit {
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a // Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
// cleanly-exited app deleted and unrestarted. See RestartPolicy. // cleanly-exited app deleted and unrestarted. See RestartPolicy.
restart_policy: RestartPolicy::Always, 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. /// Resolve the per-user quadlet dir under $HOME. Created if missing.
pub async fn unit_dir() -> Result<PathBuf> { 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") let home = std::env::var_os("HOME")
.map(PathBuf::from) .map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?; .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 /// corruption — so the orchestrator passes the per-app grace here. Never waits
/// less than `QUADLET_STOP_TIMEOUT`. /// less than `QUADLET_STOP_TIMEOUT`.
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> { 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 { match systemctl_user_status(&["stop", service], timeout).await {
Ok(status) if status.success() => Ok(()), Ok(status) if status.success() => Ok(()),
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")), 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( async fn systemctl_user_status(
args: &[&str], args: &[&str],
timeout: Duration, timeout: Duration,
) -> Result<std::process::ExitStatus> { ) -> 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"); let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args); cmd.arg("--user").args(args);
cmd.kill_on_drop(true); 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> { 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"); let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args); cmd.arg("--user").args(args);
cmd.kill_on_drop(true); 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. /// that systemd no longer knows about.
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> { pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
let svc = format!("{unit_name}.service"); 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 // Stop first; ignore failure (unit may already be down). BOUNDED — on
// rootless podman a generated unit can wedge in "deactivating" while // rootless podman a generated unit can wedge in "deactivating" while
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop` // `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 // the package entry is stranded in `Removing` (a ghost in My Apps that also
// blocks reinstall). If the graceful stop times out, escalate to // blocks reinstall). If the graceful stop times out, escalate to
// SIGKILL + reset-failed so teardown always proceeds. // SIGKILL + reset-failed so teardown always proceeds.
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT) if systemctl_user_status(&["stop", &svc], timeout)
.await .await
.is_err() .is_err()
{ {
let _ = kill_and_reset_service(&svc).await; let _ = kill_and_reset_service(&svc).await;
} }
let path = dir.join(format!("{unit_name}.container"));
if fs::try_exists(&path).await.unwrap_or(false) { if fs::try_exists(&path).await.unwrap_or(false) {
match fs::remove_file(&path).await { match fs::remove_file(&path).await {
Ok(()) => {} 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 // Bounded so a hung podman store can't re-introduce the stall this function
// exists to avoid. // exists to avoid.
let _ = tokio::time::timeout( let _ = tokio::time::timeout(
QUADLET_STOP_TIMEOUT, timeout,
Command::new("podman") Command::new("podman")
.args(["rm", "-f", unit_name]) .args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
.status(), .status(),
) )
.await; .await;
@@ -960,6 +1014,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
/// Is the quadlet-generated service currently active? /// Is the quadlet-generated service currently active?
pub async fn is_active(service: &str) -> bool { pub async fn is_active(service: &str) -> bool {
if cfg!(test) {
return false;
}
Command::new("systemctl") Command::new("systemctl")
.args(["--user", "is-active", "--quiet", service]) .args(["--user", "is-active", "--quiet", service])
.status() .status()
@@ -973,6 +1030,118 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; 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] #[test]
fn render_emits_secret_env_by_reference_never_value() { fn render_emits_secret_env_by_reference_never_value() {
let u = QuadletUnit { let u = QuadletUnit {
+495 -60
View File
@@ -238,6 +238,11 @@ pub enum ServeResult {
Forbidden, Forbidden,
/// Content not found. /// Content not found.
NotFound, 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. /// 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>, range: Option<ByteRange>,
owner_session: bool, owner_session: bool,
) -> Result<ServeResult> { ) -> 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 catalog = load_catalog(data_dir).await?;
let item = match catalog.items.iter().find(|i| i.id == id) { let item = match catalog.items.iter().find(|i| i.id == id) {
Some(i) => i, 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 // Check access control
if !owner_session { if !owner_session {
match &item.access { match &item.access {
@@ -307,9 +386,13 @@ pub async fn serve_content(
// Each path only counts when the sharer accepts that method. // Each path only counts when the sharer accepts that method.
let mut authorized = false; let mut authorized = false;
if let Some(token) = payment_token { if let Some(token) = payment_token {
if (method_accepted(&item.access, "ecash") let method = if token.trim().starts_with("cashu") {
|| method_accepted(&item.access, "fedimint")) "ecash"
&& verify_payment_token(data_dir, token, *price_sats).await } else {
"fedimint"
};
if method_accepted(&item.access, method)
&& verify(token.to_owned(), *price_sats).await
{ {
authorized = true; authorized = true;
} }
@@ -336,73 +419,127 @@ pub async fn serve_content(
} }
} }
let file_path = content_file_path(data_dir, item); Ok(prepared)
if !file_path.exists() { }
// The catalog entry survived (it's a separate JSON file) but its
// backing file is gone — most likely lost in an unrelated data-dir
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
// outlived a filebrowser reinstall that wiped the files themselves).
// Leaving the entry in place would keep advertising it as available
// to every peer forever, each hitting the exact same dead end this
// one just did. Prune it so it stops being offered.
warn!(
content_id = %id,
filename = %item.filename,
"content catalog entry's file is missing on disk — pruning the stale entry"
);
prune_missing_content_entry(data_dir, id).await;
return Ok(ServeResult::NotFound);
}
let metadata = fs::metadata(&file_path) async fn prepare_content(
.await data_dir: &Path,
.context("Failed to read file metadata")?; path: PathBuf,
let total_size = metadata.len(); range: Option<ByteRange>,
mime: String,
// Handle range request for streaming ) -> Result<ServeResult> {
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;
use tokio::io::{AsyncReadExt, AsyncSeekExt}; 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 .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 .await
.context("Failed to seek")?; .context("Reading shared content range")?;
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
);
return Ok(ServeResult::Partial { return Ok(ServeResult::Partial {
bytes: buf, bytes,
mime_type: item.mime_type.clone(), mime_type: mime,
start, start,
end, end,
total: total_size, total,
}); });
} }
let mut bytes = Vec::new();
let bytes = fs::read(&file_path) file.read_to_end(&mut bytes)
.await .await
.context("Failed to read content file")?; .context("Reading shared content")?;
Ok(ServeResult::Ok(bytes, mime))
}
debug!("Serving content '{}' ({} bytes)", id, bytes.len()); fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
Ok(ServeResult::Ok(bytes, item.mime_type.clone())) 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. /// 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. /// 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. /// 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 { 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 { 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"); 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")
);
}
}
+268 -7
View File
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error() 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. /// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips"; 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` /// 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. /// is preserved so a cold hole-punched path still gets time to establish.
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client { 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() reqwest::Client::builder()
.no_proxy()
.redirect(delivery_redirect_policy(single))
.timeout(timeout) .timeout(timeout)
.connect_timeout(Duration::from_secs(8)) .connect_timeout(Duration::from_secs(8))
.user_agent("archipelago-fips/1") .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, /// robust". Only connect/timeout errors are retried (a real HTTP response,
/// including 4xx/5xx, is returned as-is for the caller to interpret). /// 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> { 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(); let retry = rb.try_clone();
match rb.send().await { match rb.send().await {
Ok(resp) => Ok(resp), 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 // Brief pause so the hole-punch packets from the first attempt can
// traverse before we re-dial onto the warmed path. // traverse before we re-dial onto the warmed path.
tokio::time::sleep(Duration::from_millis(600)).await; 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 /// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope. /// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>, 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> { impl<'a> PeerRequest<'a> {
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
fips_timeout: None, fips_timeout: None,
service: None, service: None,
record_data_dir: 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 /// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path. /// (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 { 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 // Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to // fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back. // 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(); telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips); self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, 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) { if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
match self.try_fips_get().await? { match self.try_fips_get().await? {
Some(resp) => { 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(); telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips); self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips)); return Ok((resp, crate::transport::TransportKind::Fips));
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
} else { } else {
budget 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); let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers { for (k, v) in &self.headers {
rb = rb.header(*k, v); 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(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)) => { Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail); telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!( tracing::info!(
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
} else { } else {
budget 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); let mut rb = c.get(&url);
for (k, v) in &self.headers { for (k, v) in &self.headers {
rb = rb.header(*k, v); 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(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)) => { Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail); telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!( tracing::info!(
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
.context("Invalid Tor SOCKS proxy URL")?; .context("Invalid Tor SOCKS proxy URL")?;
reqwest::Client::builder() reqwest::Client::builder()
.proxy(proxy) .proxy(proxy)
.redirect(delivery_redirect_policy(self.single_delivery))
.timeout(self.timeout) .timeout(self.timeout)
.build() .build()
.context("Build Tor HTTP client") .context("Build Tor HTTP client")
@@ -759,4 +843,181 @@ mod tests {
let err = decode_response(0xAABB, &r, "x").unwrap_err(); let err = decode_response(0xAABB, &r, "x").unwrap_err();
assert!(err.to_string().contains("no AAAA")); 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();
}
} }
@@ -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());
}
}
+2
View File
@@ -7,3 +7,5 @@
pub mod ai_permissions; pub mod ai_permissions;
pub mod session_policy; pub mod session_policy;
pub mod transport; pub mod transport;
pub mod bitcoin_storage;
+30
View File
@@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
/// service unit that inherits systemd's default protections (i.e. none /// service unit that inherits systemd's default protections (i.e. none
/// of ours), escaping the namespace. /// of ours), escaping the namespace.
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> { 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![ let mut full: Vec<&str> = vec![
"systemd-run", "systemd-run",
"--wait", "--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 /// 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. /// (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> { 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![ let mut full: Vec<&str> = vec![
"systemd-run", "systemd-run",
"--wait", "--wait",
+75 -62
View File
@@ -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(); let mut all_target: Vec<u64> = send_denoms.clone();
all_target.extend(&change_denoms); 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 // Mark original proofs as spent
wallet.mark_spent(&indices); 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 // Verify all mints in the token are accepted
let accepted = load_accepted_mints(data_dir).await?; let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() { 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); 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 // for the log. Remember the last one so a total failure can tell the user
// *why* instead of just "nothing was received". // *why* instead of just "nothing was received".
let mut last_reason: Option<String> = None; let mut last_reason: Option<String> = None;
let mut all_already_redeemed = true;
// Swap proofs at each mint // Swap proofs at each mint
for entry in &token.token { 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; received_total += amount;
} }
Err(e) => { 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()); last_reason = Some(e.to_string());
// Continue with other mints if any // 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 { if received_total == 0 {
match last_reason { return Err(receive_failure(last_reason, all_already_redeemed));
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
None => anyhow::bail!("Failed to receive any proofs from token"),
}
} }
wallet.record_tx( wallet.record_tx(
@@ -1243,6 +1248,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
Ok(received_total) 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}). /// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
/// For backwards compatibility during migration period. /// For backwards compatibility during migration period.
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> { 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, token_str: &str,
required_sats: u64, required_sats: u64,
) -> Result<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_") { if token_str.starts_with("cashuSend_") {
let amount = token_str anyhow::bail!("Legacy ecash cannot authorize a paid download");
.split('_')
.nth(1)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if amount < required_sats {
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
amount,
required_sats
);
}
let received = receive_legacy_token(data_dir, token_str).await?;
return Ok(received);
} }
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes // 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) // Parse and validate the token (cashuA or cashuB)
let token = CashuToken::deserialize(token_str)?; 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 { if total < required_sats {
anyhow::bail!( anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
"Insufficient payment: {} sats, need {} sats",
total,
required_sats
);
} }
// Verify mints are accepted
let accepted = load_accepted_mints(data_dir).await?; let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() { if !accepted
if !accepted.mints.iter().any(|m| m == mint_url) { .mints
anyhow::bail!("Mint '{}' not accepted", mint_url); .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 client = mint_client(data_dir, &entry.mint).await?;
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let result = client
let target_amounts = amount_to_denominations(entry_total); .swap_at_least(
&entry.proofs,
match client.swap(&entry.proofs, &target_amounts).await { &amount_to_denominations(total),
Ok(result) => { required_sats,
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum(); )
wallet.add_proofs(&entry.mint, result.new_proofs); .await?;
received_total += amount; let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
} // Load after the network call, so an unrelated wallet update during the
Err(e) => { // swap is not overwritten with a pre-swap snapshot.
warn!("Payment verification failed at mint {}: {}", entry.mint, e); let mut wallet = load_wallet(data_dir).await?;
} wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
}
}
if received_total < required_sats {
anyhow::bail!(
"Payment verification failed: only {} of {} sats verified",
received_total,
required_sats
);
}
wallet.record_tx( wallet.record_tx(
TransactionType::Receive, TransactionType::Receive,
@@ -1632,6 +1629,18 @@ fn default_mint_url() -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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 super::*;
use tempfile::TempDir; use tempfile::TempDir;
@@ -2443,3 +2452,7 @@ mod tests {
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin"); assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
} }
} }
#[cfg(test)]
#[path = "payment_tests.rs"]
mod payment_tests;
+257 -25
View File
@@ -47,7 +47,8 @@
//! key, a crash mid-loop) must not silently lose the coins, so every fetched //! key, a crash mid-loop) must not silently lose the coins, so every fetched
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/ //! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
//! redeem is attempted, and stays there — retried on every later poll — until //! 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. //! 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 //! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
//! accepted-mints allow-list: the address is inherently backed by that one //! 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. /// already-spent token) but wasteful and noisy.
#[serde(default)] #[serde(default)]
pub last_dm_seen_at: u64, 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 /// Event ids already queued from the relay. `created_at` has only
/// one-second resolution, so a strict `since = last + 1` watermark can /// one-second resolution, so a strict `since = last + 1` watermark can
/// permanently miss a second payment published later in the same second. /// 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(), created_at: chrono::Utc::now().to_rfc3339(),
pending_claims: Vec::new(), pending_claims: Vec::new(),
last_dm_seen_at: 0, last_dm_seen_at: 0,
relay_scan: None,
seen_dm_ids: Vec::new(), seen_dm_ids: Vec::new(),
last_receipt_id: 0, last_receipt_id: 0,
last_receipt_sats: 0, last_receipt_sats: 0,
@@ -652,6 +657,16 @@ pub struct ClaimOutcome {
pub receipt_at: u64, 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 { const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
claimed_count: 0, claimed_count: 0,
received_sats: 0, received_sats: 0,
@@ -697,38 +712,58 @@ fn outcome_with_latest_receipt(
/// three real payments that `/claim` never surfaced. Best-effort: a relay /// three real payments that `/claim` never surfaced. Best-effort: a relay
/// error here must not abort the poll, since `pending_claims` may still hold /// error here must not abort the poll, since `pending_claims` may still hold
/// earlier fetches worth retrying. /// 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( async fn fetch_relay_dms(
our_pubkey: nostr_sdk::PublicKey, our_pubkey: nostr_sdk::PublicKey,
server_pubkey: nostr_sdk::PublicKey, server_pubkey: nostr_sdk::PublicKey,
since: u64, since: u64,
) -> Vec<(String, u64, String, String)> { resume: Option<RelayScan>,
) -> RelayBatch {
let client = Client::default(); 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 { if let Err(e) = client.add_relay(*url).await {
warn!("Minibits: could not add relay {url}: {e}"); warn!("Minibits: could not add relay {url}: {e}");
} }
} }
client.connect().await; client.connect().await;
}
// Give relays a moment to finish the WebSocket handshake before the // Give relays a moment to finish the WebSocket handshake before the
// fetch's own timeout starts consuming that time. // fetch's own timeout starts consuming that time.
tokio::time::sleep(std::time::Duration::from_millis(400)).await; tokio::time::sleep(std::time::Duration::from_millis(400)).await;
// Nostr timestamps have one-second resolution. Query the boundary second let batch = collect_relay_pages(since, resume, |scan| {
// inclusively: a later-published payment may legitimately share that let client = &client;
// timestamp. `seen_dm_ids` performs the exact deduplication locally. async move {
let filter = Filter::new() let mut filter = Filter::new()
.author(server_pubkey) .author(server_pubkey)
.pubkey(our_pubkey) .pubkey(our_pubkey)
.kind(Kind::from(4u16)) .kind(Kind::from(4u16))
.since(Timestamp::from(since)) .since(Timestamp::from(scan.since))
.limit(200); .limit(scan.limit);
if let Some(until) = scan.until {
let result = match client filter = filter.until(Timestamp::from(until));
}
let events = client
.fetch_events(filter, std::time::Duration::from_secs(5)) .fetch_events(filter, std::time::Duration::from_secs(5))
.await .await?;
{ Ok(events
Ok(events) => {
let mut out: Vec<(String, u64, String, String)> = events
.into_iter() .into_iter()
.map(|e| { .map(|e| {
( (
@@ -738,18 +773,83 @@ async fn fetch_relay_dms(
e.id.to_hex(), e.id.to_hex(),
) )
}) })
.collect(); .collect())
out.sort_by_key(|(_, created_at, _, _)| *created_at);
out
} }
})
.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) => { Err(e) => {
warn!("Minibits: relay fetch for claim DMs failed: {e}"); warn!("Minibits: relay fetch failed; preserving scan cursor: {e}");
Vec::new() break;
} }
}; };
let count = events.len();
client.shutdown().await; let oldest = events.iter().map(|e| e.1).min();
result 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( 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 // 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 // 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). // 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; let batch = fetch_relay_dms(
for (content, created_at, author, event_id) in 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 { if author != state.server_nostr_pubkey {
warn!("Minibits: ignoring claim DM from unexpected pubkey {author}"); warn!("Minibits: ignoring claim DM from unexpected pubkey {author}");
continue; continue;
@@ -964,6 +1071,14 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
sats += got; sats += got;
info!("Minibits: redeemed a claimed payment ({got} sats)"); 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) => { Err(e) => {
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll"); warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
still_pending.push(claim.clone()); still_pending.push(claim.clone());
@@ -994,6 +1109,123 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
+126 -34
View File
@@ -71,10 +71,28 @@ pub struct MintResult {
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call /// 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 /// 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`. /// 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> { fn describe_mint_error_code(code: i64) -> Option<&'static str> {
Some(match code { Some(match code {
10001 => "The mint rejected these coins as invalid.", 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.", 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.", 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.", 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 /// translation layered on top via `.context()` so `{}` — what reaches the
/// wallet user — shows something actionable instead of raw mint JSON. /// wallet user — shows something actionable instead of raw mint JSON.
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error { fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
let friendly = describe_mint_error_body(status, body); let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly) 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. /// HTTP client for a single Cashu mint.
@@ -487,6 +526,21 @@ impl MintClient {
/// Swap proofs for new proofs of different denominations. /// Swap proofs for new proofs of different denominations.
/// This is how we "receive" a token — swap it for fresh proofs that only we know. /// 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> { 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?; let keyset = self.get_active_sat_keyset().await?;
// NUT-02: a mint may charge a per-input fee, and it rejects the swap // 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 // should equal outputs less fee`). Applied here rather than at each
// call site so send, receive and cross-mint swaps are all covered. // call site so send, receive and cross-mint swaps are all covered.
// Fee-free mints (Minibits) compute 0 and are unaffected. // Fee-free mints (Minibits) compute 0 and are unaffected.
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum(); anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
let fee = match self.get_keysets().await { let inputs_total = inputs
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks), .iter()
Err(e) => { .try_fold(0u64, |sum, p| sum.checked_add(p.amount))
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint"); .context("Input amount overflow")?;
0 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 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 owned_targets: Vec<u64>;
let target_amounts: &[u64] = if requested > spendable { let target_amounts: &[u64] = if requested > spendable {
if spendable == 0 { if spendable == 0 {
@@ -514,7 +587,10 @@ impl MintClient {
debug!( debug!(
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee" "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 &owned_targets
} else { } else {
target_amounts target_amounts
@@ -559,6 +635,9 @@ impl MintClient {
let mut new_proofs = Vec::new(); let mut new_proofs = Vec::new();
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) { 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 c_prime = sig.c_prime_as_pubkey()?;
let mint_key = keyset.key_for_amount(*amount)?; let mint_key = keyset.key_for_amount(*amount)?;
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?; 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. /// 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 /// 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 /// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
/// they build a token. The mint then reads the `0x01` version, expects 33 /// 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 /// bytes, and rejects the swap — reported as
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with /// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
/// a Minibits-issued token, 2026-08-17). /// a Minibits-issued token, 2026-08-17).
/// ///
/// The id only names which keyset signed the proof, so restoring the full /// 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 /// 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 /// safe to attempt: the mint still verifies the proof signature. Unknown
/// verification at the mint and no coins move. Anything already valid, or /// or ambiguous short IDs are rejected before redemption.
/// with no unambiguous match, is passed through untouched so the mint's async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
/// own error is what the operator sees.
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id)); let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
if !needs_repair { if !needs_repair {
return proofs.to_vec(); return Ok(proofs.to_vec());
} }
// The mint's own keyset list, in the reference implementation's shape // The mint's own keyset list, in the reference implementation's shape
// so its NUT-02 resolver can consume it directly. // so its NUT-02 resolver can consume it directly.
let known = match self.get_cdk_keysets().await { let known = self.get_cdk_keysets().await?;
Ok(k) => k,
Err(e) => {
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
return proofs.to_vec();
}
};
proofs proofs
.iter() .iter()
.cloned() .cloned()
.map(|mut p| { .map(|mut p| {
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) { if is_truncated_v2_keyset_id(&p.id) {
debug!("Expanded short keyset id {} to {} for swap", p.id, full); p.id = super::cashu::resolve_keyset_id(&p.id, &known)
p.id = full; .context("The mint cannot resolve this short keyset ID unambiguously")?;
} }
p Ok(p)
}) })
.collect() .collect()
} }
@@ -777,7 +848,7 @@ impl MintClient {
let mut all_new_proofs = Vec::new(); let mut all_new_proofs = Vec::new();
for entry in &token.token { for entry in &token.token {
if entry.mint != self.url { if entry.mint.trim_end_matches('/') != self.url {
debug!( debug!(
"Skipping proofs from different mint {} (ours: {})", "Skipping proofs from different mint {} (ours: {})",
entry.mint, self.url entry.mint, self.url
@@ -788,8 +859,7 @@ impl MintClient {
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(total); let target_amounts = amount_to_denominations(total);
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await; let result = self.swap(&entry.proofs, &target_amounts).await?;
let result = self.swap(&proofs, &target_amounts).await?;
all_new_proofs.extend(result.new_proofs); all_new_proofs.extend(result.new_proofs);
} }
@@ -803,6 +873,28 @@ impl MintClient {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[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());
}
}
}
+60 -18
View File
@@ -989,7 +989,7 @@
// ── State ─────────────────────────────────────────────────────── // ── State ───────────────────────────────────────────────────────
let unit = 'sats'; 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 peerSort = { col: 'peer', dir: 1 };
let activityFilter = 'all'; let activityFilter = 'all';
let logsLoaded = false; let logsLoaded = false;
@@ -1142,9 +1142,19 @@
} }
async function refreshAll() { async function refreshAll() {
if (state.refreshing) return;
state.refreshing = true;
const icon = document.getElementById('refreshIcon'); const icon = document.getElementById('refreshIcon');
if (icon) icon.classList.add('animate-spin-slow'); if (icon) icon.classList.add('animate-spin-slow');
try { 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([ const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
lndSafe('/v1/getinfo', null), lndSafe('/v1/getinfo', null),
lndSafe('/v1/channels', { channels: [] }), lndSafe('/v1/channels', { channels: [] }),
@@ -1166,10 +1176,17 @@
state.invoices = (invoices && invoices.invoices) || []; state.invoices = (invoices && invoices.invoices) || [];
state.txns = (txns && txns.transactions) || []; state.txns = (txns && txns.transactions) || [];
// Balances are separate so one failing endpoint can't blank the rest. // Preserve known balances on outage; never decode an error as zero.
state.onchain = await lndSafe('/v1/balance/blockchain', null); const [onchain, chanbal] = await Promise.all([
state.chanbal = await lndSafe('/v1/balance/channels', null); 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 { } finally {
state.refreshing = false;
if (icon) icon.classList.remove('animate-spin-slow'); if (icon) icon.classList.remove('animate-spin-slow');
} }
renderAll(); renderAll();
@@ -1192,11 +1209,17 @@
const pill = document.getElementById('headerStatusPill'); const pill = document.getElementById('headerStatusPill');
const dot = document.getElementById('headerStatusDot'); const dot = document.getElementById('headerStatusDot');
if (!g) { const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
setText('headerStatusText', 'Unreachable'); if (!g || waiting) {
pill.className = 'pill bad'; setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
dot.className = 'status-dot-sm bg-red'; pill.className = 'pill warn';
document.getElementById('syncCard').style.display = 'none'; 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; return;
} }
@@ -1237,6 +1260,11 @@
} }
// ── Balances ──────────────────────────────────────────────────── // ── Balances ────────────────────────────────────────────────────
function validBalance(value) {
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
}
function renderBalances() { function renderBalances() {
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance)); const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance); const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
@@ -1253,22 +1281,23 @@
const haveOnchain = !!state.onchain; const haveOnchain = !!state.onchain;
const haveChan = !!cb; const haveChan = !!cb;
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null); setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND'); setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
setBalance('balLightning', haveChan ? lnLocal : null); 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'); : lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null); 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'); : onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
setText('liqLocal', fmtAmount(lnLocal)); const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
setText('liqRemote', fmtAmount(lnRemote)); setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
const total = lnLocal + lnRemote; const total = lnLocal + lnRemote;
const localPct = total > 0 ? (lnLocal / total) * 100 : 50; const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
document.getElementById('liqBarLocal').style.width = localPct + '%'; document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%'; document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
setText('liqHint', total > 0 setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).' ? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
: 'Open a channel to start sending and receiving over Lightning.'); : 'Open a channel to start sending and receiving over Lightning.');
} }
@@ -1284,6 +1313,15 @@
function renderSummary() { function renderSummary() {
const g = state.info; 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 chans = state.channels;
const active = chans.filter(c => c.active).length; const active = chans.filter(c => c.active).length;
const inactive = chans.length - active; const inactive = chans.length - active;
@@ -1321,6 +1359,10 @@
function renderChannels() { function renderChannels() {
const el = document.getElementById('channelList'); const el = document.getElementById('channelList');
if (!el) return; 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(); const q = (document.getElementById('channelFilter').value || '').toLowerCase();
let list = state.channels.slice(); let list = state.channels.slice();
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q)); if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
+41
View File
@@ -3,6 +3,47 @@
Working backlog of forward-looking items not yet scoped into a dedicated plan 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. 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) ## Dev & build process (priority)
- Formalize the contributor workflow: releases, CI, maintainers, automated - 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.
+405
View File
@@ -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.
+358
View File
@@ -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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.16-alpha", "version": "1.8.21-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.16-alpha", "version": "1.8.21-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.16-alpha", "version": "1.8.21-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
+1
View File
@@ -42,6 +42,7 @@ export interface PackageVersionsResponse {
pinnedVersion: string | null pinnedVersion: string | null
autoUpdate: boolean autoUpdate: boolean
versions: CatalogVersionInfo[] versions: CatalogVersionInfo[]
bitcoinPrune?: boolean | null
} }
export interface AppGatePortStatus { 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>
+41 -44
View File
@@ -3,6 +3,9 @@ import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue' 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 // Ecash (Cashu) wallet backup card — the same shape as the node recovery
// phrase and the Lightning seed cards, deliberately: a third reveal pattern // phrase and the Lightning seed cards, deliberately: a third reveal pattern
// would be a third thing to learn. // would be a third thing to learn.
@@ -102,12 +105,14 @@ async function submitReveal() {
} }
function closeReveal() { function closeReveal() {
const established = revealedWords.value.length > 0
showRevealModal.value = false showRevealModal.value = false
revealedWords.value = [] revealedWords.value = []
revealPassword.value = '' revealPassword.value = ''
revealCode.value = '' revealCode.value = ''
revealPassphrase.value = '' revealPassphrase.value = ''
showRevealPassphrase.value = false showRevealPassphrase.value = false
if (established) emit('ready')
} }
async function copyRevealedWords() { async function copyRevealedWords() {
@@ -221,61 +226,54 @@ async function restoreFromPhrase() {
Your ecash has no backup yet Your ecash has no backup yet
</div> </div>
<div class="flex items-start justify-between gap-4"> <div class="flex flex-col gap-3">
<div class="min-w-0"> <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"> <p v-if="status?.active && status?.source === 'node-seed'" class="text-sm leading-relaxed text-white/60">
Your ecash wallet has its own 24-word phrase, derived from this node's recovery Your node's recovery phrase also recovers this ecash phrase. Reveal its 24 words
phrase — so the words you already wrote down cover your ecash too. Reveal it here to restore in a compatible Cashu wallet without sharing your node's phrase.
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> </p>
<p v-else-if="status?.active" class="text-sm text-white/60"> <p v-else-if="status?.active" class="text-sm leading-relaxed text-white/60">
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to Save your 24-word ecash phrase to restore this wallet here or in another
restore your ecash into another wallet (Minibits, Nutstash, compatible Cashu wallet.
<span class="font-mono">cdk-cli</span>).
</p> </p>
<p v-else class="text-sm text-white/60"> <p v-else class="text-sm leading-relaxed text-white/60">
Ecash is a bearer instrument: the coins live in a file on this node, and right now If this node's coin file is lost, your ecash is lost. Set up a phrase to recover
nothing can bring them back if that file is lost. Setting up a backup phrase fixes future coins; existing coins aren't covered.
that for every coin minted from then on.
<template v-if="status?.derivable_from_node_seed"> <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>
<template v-else> <template v-else>
This node has no encrypted seed backup to derive from, so the phrase will be its This node has no saved seed, so write down and keep the new phrase separately.
own — you'll need to write these words down and keep them.
</template> </template>
</p> </p>
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90"> <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 {{ status?.source === 'imported' ? 'This imported phrase' : 'This phrase' }} is separate
phrase{{ status?.source === 'imported' ? ' — it was imported' : '' }}, so restoring from your node's backup. <strong>Only these words recover this ecash wallet.</strong>
the node will not bring the ecash back. Only these words will.
</p> </p>
</div> </div>
<button <button
type="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' : ''" :class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
@click="openReveal" @click="openReveal"
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button> >{{ status?.active ? 'Reveal' : (setupOnly ? 'Set up address' : 'Set up backup') }}</button>
</div> </div>
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10"> <div v-if="status?.active && !setupOnly" class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-start justify-between gap-4"> <div class="flex flex-col gap-3">
<p class="text-sm text-white/60 min-w-0"> <p class="text-sm leading-relaxed text-white/60 min-w-0">
<span class="text-white/80 font-medium">Restore from this phrase.</span> <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 Recover unspent coins from your mint. Safe to repeat; coins you already hold
are still unspent. Safe to run at any time — it never duplicates coins you already won't be duplicated.
hold.
</p> </p>
<button <button
type="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" :disabled="restoring"
@click="restoreFromPhrase" @click="restoreFromPhrase"
>{{ restoring ? 'Scanning…' : 'Restore' }}</button> >{{ 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> <p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
</div> </div>
<div class="mt-4 pt-4 border-t border-white/10"> <div v-if="!setupOnly" class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-start justify-between gap-4"> <div class="flex flex-col gap-3">
<p class="text-sm text-white/60 min-w-0"> <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> <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 Import a phrase from Minibits, Nutstash or <span class="font-mono">cdk-cli</span>
<span class="font-mono">cdk-cli</span> — so its coins can be restored here. to restore its coins here.
</p> </p>
<button <button
type="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" @click="openImport"
>Import</button> >Import</button>
</div> </div>
@@ -319,7 +317,7 @@ async function restoreFromPhrase() {
</template> </template>
<template v-else> <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 Paste the 24-word phrase from the other wallet. The coins already in this wallet
stay spendable either way. stay spendable either way.
</p> </p>
@@ -376,9 +374,8 @@ async function restoreFromPhrase() {
</h3> </h3>
<template v-if="revealedWords.length === 0"> <template v-if="revealedWords.length === 0">
<p class="text-sm text-white/60 mb-4"> <p class="text-sm leading-relaxed text-white/60 mb-4">
Confirm your credentials to Confirm your credentials to {{ status?.active ? 'reveal' : 'set up' }} your ecash phrase.
{{ status?.active ? 'display the 24-word ecash phrase' : 'derive and display your ecash backup phrase' }}.
</p> </p>
<form @submit.prevent="submitReveal" class="space-y-3"> <form @submit.prevent="submitReveal" class="space-y-3">
<div> <div>
@@ -407,12 +404,12 @@ async function restoreFromPhrase() {
<SeedRevealPanel :words="revealedWords" /> <SeedRevealPanel :words="revealedWords" />
<p class="text-xs text-white/40 mt-3"> <p class="text-xs text-white/40 mt-3">
<template v-if="revealedSource === 'node-seed'"> <template v-if="revealedSource === 'node-seed'">
Derived from this node's recovery phrase — restoring the node restores this Your node's recovery phrase recovers this ecash wallet too. Use these words
ecash wallet too. These words also restore it into any NUT-13 wallet. separately in a compatible Cashu (NUT-13) wallet.
</template> </template>
<template v-else> <template v-else>
This phrase is independent of the node's recovery phrase. It is the Write these words down. They are the <strong>only</strong> way to recover
<strong>only</strong> way to restore this ecash wallet — write it down. this ecash wallet; your node's phrase won't recover it.
</template> </template>
</p> </p>
<div class="flex gap-2 pt-4"> <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" 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"> <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. any time in Settings → System.
</p> </p>
</div> </div>
@@ -35,6 +35,8 @@
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p> <p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div> </div>
<BitcoinPruningChoice v-if="isBitcoin && !loading" v-model="prune" />
<template #footer> <template #footer>
<div class="flex gap-2 mt-6"> <div class="flex gap-2 mt-6">
<button <button
@@ -58,9 +60,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseModal from './BaseModal.vue' import BaseModal from './BaseModal.vue'
import BitcoinPruningChoice from './BitcoinPruningChoice.vue'
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client' import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
import { displayVersion } from '@/utils/version' import { displayVersion } from '@/utils/version'
@@ -73,13 +76,16 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508"). // 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 { t } = useI18n()
const loading = ref(false) const loading = ref(false)
const versions = ref<CatalogVersionInfo[]>([]) const versions = ref<CatalogVersionInfo[]>([])
const selected = ref('') 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. // Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
function optionLabel(v: CatalogVersionInfo): string { function optionLabel(v: CatalogVersionInfo): string {
@@ -92,12 +98,16 @@ function optionLabel(v: CatalogVersionInfo): string {
async function load() { async function load() {
loading.value = true loading.value = true
prune.value = false
pruneKnown.value = false
versions.value = [] versions.value = []
selected.value = '' selected.value = ''
try { try {
const info = await rpcClient.getPackageVersions(props.appId) const info = await rpcClient.getPackageVersions(props.appId)
// catalog_versions() returns the list default(=latest)-first, so versions[0] // catalog_versions() returns the list default(=latest)-first, so versions[0]
// is the latest — pre-select it. // is the latest — pre-select it.
pruneKnown.value = typeof info.bitcoinPrune === 'boolean'
prune.value = info.bitcoinPrune === true
versions.value = info.versions || [] versions.value = info.versions || []
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest' selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
} catch (err) { } catch (err) {
@@ -111,7 +121,7 @@ async function load() {
function confirm() { function confirm() {
if (!selected.value) return if (!selected.value) return
emit('confirm', selected.value) emit('confirm', selected.value, isBitcoin.value && (pruneKnown.value || prune.value) ? prune.value : undefined)
} }
watch( watch(
@@ -77,8 +77,13 @@
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4"> <div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
{{ t('receiveBitcoin.lnAddressLoading') }} {{ t('receiveBitcoin.lnAddressLoading') }}
</div> </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"> <div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
{{ t('receiveBitcoin.lnAddressUnavailable') }} {{ t('receiveBitcoin.lnAddressUnavailable') }}
<button type="button" class="glass-button rounded-lg px-3 py-2 ml-2" @click="loadLnAddress">Retry</button>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -132,6 +137,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue' import CopyButton from '@/components/CopyButton.vue'
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue' import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive' import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
import { useLightningRequired } from '@/composables/useLightningRequired' import { useLightningRequired } from '@/composables/useLightningRequired'
@@ -214,6 +220,7 @@ const error = ref('')
const lnAddress = ref('') const lnAddress = ref('')
const lnAddressLoading = ref(false) const lnAddressLoading = ref(false)
const lnAddressError = ref(false) const lnAddressError = ref(false)
const lnAddressNeedsSetup = ref(false)
// A payment the backend fetched (and so already consumed at Minibits) but // 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 // 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. // 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 if (lnAddress.value || lnAddressLoading.value) return
lnAddressLoading.value = true lnAddressLoading.value = true
lnAddressError.value = false lnAddressError.value = false
lnAddressNeedsSetup.value = false
try { try {
const res = await rpcClient.call<{ address?: string }>({ const res = await rpcClient.call<{ address?: string }>({
method: 'wallet.ecash-lnaddress', method: 'wallet.ecash-lnaddress',
@@ -245,6 +253,16 @@ async function loadLnAddress() {
} }
} catch { } catch {
lnAddressError.value = true 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 { } finally {
lnAddressLoading.value = false lnAddressLoading.value = false
} }
@@ -185,7 +185,7 @@
@change="saveExplorer" @change="saveExplorer"
/> />
<p class="text-[11px] text-white/40 mt-1"> <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> </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"> <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 = '' 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 () => { it('asks for a separate backup passphrase only after password decryption fails', async () => {
vi.mocked(rpcClient.call) vi.mocked(rpcClient.call)
.mockResolvedValueOnce({ .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 { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue' import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import EcashSeedBackup from '../EcashSeedBackup.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
@@ -39,6 +40,51 @@ beforeEach(() => {
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of // 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. // path a future change could regress, so it's worth pinning down.
describe('ReceiveBitcoinModal — ecash tab click', () => { 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 () => { 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) 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}` }) 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 () => { it('asks for consent only when Mempool genuinely is not installed', async () => {
containerState = 'not-installed' containerState = 'not-installed'
const { openTx, pendingTx } = useTxExplorer() const { openTx, pendingTx } = useTxExplorer()
@@ -84,3 +102,23 @@ describe('useTxExplorer.openTx', () => {
expect(pendingTx.value).toBe(TX) 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)
})
})
+9 -3
View File
@@ -17,8 +17,8 @@ import { ref } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useAppLauncherStore } from '@/stores/appLauncher'
import { useContainerStore } from '@/stores/container' import { useContainerStore } from '@/stores/container'
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com' export const DEFAULT_TX_EXPLORER = 'https://mempool.space'
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide' export const EXPLORER_PLACEHOLDER = DEFAULT_TX_EXPLORER
const KEY = 'archipelago.tx-explorer.v1' const KEY = 'archipelago.tx-explorer.v1'
@@ -30,7 +30,13 @@ interface TxExplorerPrefs {
function loadPrefs(): TxExplorerPrefs { function loadPrefs(): TxExplorerPrefs {
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false } const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
try { 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 { } catch {
return defaults return defaults
} }
+14 -9
View File
@@ -672,6 +672,11 @@ async function handleInstall(app: MarketplaceApp) {
return return
} }
if (installingApps.has(app.id) || isInstalled(app.id)) 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 // 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 // front via a full-screen modal (latest pre-selected) instead of silently
// installing the default. Best-effort — if the lookup fails we install directly. // installing the default. Best-effort — if the lookup fails we install directly.
@@ -686,19 +691,19 @@ async function handleInstall(app: MarketplaceApp) {
startInstall(app) startInstall(app)
} }
function startInstall(app: MarketplaceApp, versionOverride?: string) { function startInstall(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
if (app.source === 'local') { if (app.source === 'local') {
installApp(app, versionOverride) installApp(app, versionOverride, prune)
} else { } else {
installCommunityApp(app, versionOverride) installCommunityApp(app, versionOverride, prune)
} }
} }
function onInstallModalConfirm(version: string) { function onInstallModalConfirm(version: string, prune?: boolean) {
const app = installModalApp.value const app = installModalApp.value
showInstallModal.value = false showInstallModal.value = false
installModalApp.value = null installModalApp.value = null
if (app) startInstall(app, version) if (app) startInstall(app, version, prune)
} }
function viewAppDetails(app: MarketplaceApp) { function viewAppDetails(app: MarketplaceApp) {
@@ -774,25 +779,25 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000) 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 if (installingApps.has(app.id) || isInstalled(app.id)) return
queueInstall(app) queueInstall(app)
installToast(app) installToast(app)
try { try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl 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) { } catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err) if (import.meta.env.DEV) console.error('Installation failed:', err)
failInstall(app, 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 if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
queueInstall(app) queueInstall(app)
installToast(app) installToast(app)
try { 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) { if ((app as Record<string, unknown>).containerConfig) {
installParams.containerConfig = (app as Record<string, unknown>).containerConfig installParams.containerConfig = (app as Record<string, unknown>).containerConfig
} }
+14 -1
View File
@@ -133,6 +133,7 @@
class="order-2 lg:order-none" class="order-2 lg:order-none"
:animate="animateCards" :animate="animateCards"
:wallet-connected="walletConnected" :wallet-connected="walletConnected"
:wallet-balance-unavailable="walletBalanceUnavailable"
:wallet-onchain="walletOnchain" :wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning" :wallet-lightning="walletLightning"
:wallet-ecash="walletEcash" :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 // readout instead; a rail only becomes a number when a call actually
// succeeds, so a real 0 is still a real 0. // succeeds, so a real 0 is still a real 0.
const walletConnected = ref(false) const walletConnected = ref(false)
const walletBalanceUnavailable = ref(false)
const walletOnchain = ref<number | null>(null) const walletOnchain = ref<number | null>(null)
const walletLightning = ref<number | null>(null) const walletLightning = ref<number | null>(null)
const walletEcash = 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. // call, which is what makes the card feel like an app launch.
const balances = Promise.allSettled([ const balances = Promise.allSettled([
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true }) 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(() => { .catch(() => {
// A single slow poll must NOT flip the card to "disconnected" and // A single slow poll must NOT flip the card to "disconnected" and
// hide balances the user already knows — busy nodes routinely blow // hide balances the user already knows — busy nodes routinely blow
// the 5s budget mid-payment or during IO storms (a test node user // the 5s budget mid-payment or during IO storms (a test node user
// report: balances vanished while a payment settled). Only call it // report: balances vanished while a payment settled). Only call it
// disconnected after three consecutive failures (~30s of silence). // disconnected after three consecutive failures (~30s of silence).
walletBalanceUnavailable.value = true
walletInfoFailures += 1 walletInfoFailures += 1
if (walletInfoFailures >= 3) walletConnected.value = false if (walletInfoFailures >= 3) walletConnected.value = false
}), }),
+43 -6
View File
@@ -137,7 +137,7 @@
:tier-label="getAppTier(app.id)" :tier-label="getAppTier(app.id)"
:install-blocked-reason="installBlockedReason(app.id)" :install-blocked-reason="installBlockedReason(app.id)"
@view="viewAppDetails" @view="viewAppDetails"
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)" @install="handleInstall(app)"
@launch="launchInstalledApp" @launch="launchInstalledApp"
/> />
</div> </div>
@@ -153,7 +153,13 @@
</div> </div>
</div> </div>
<!-- End Scrollable Apps Section --> <!-- End Scrollable Apps Section -->
<InstallVersionModal
:show="showInstallModal"
:app-id="installModalApp?.id || ''"
:app="installModalApp"
@close="showInstallModal = false; installModalApp = null"
@confirm="onInstallModalConfirm"
/>
</div> </div>
</template> </template>
@@ -175,11 +181,13 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout' import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource' import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue' import RefreshIndicator from '@/components/RefreshIndicator.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories' import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue' import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import { import {
type MarketplaceApp, type MarketplaceApp,
INSTALLED_ALIASES, INSTALLED_ALIASES,
MULTI_VERSION_APP_IDS,
getAppTier, getAppTier,
categorizeCommunityApp, categorizeCommunityApp,
getCuratedAppList, getCuratedAppList,
@@ -206,6 +214,8 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
// Installation state — uses global store so it persists across navigation // Installation state — uses global store so it persists across navigation
const installingApps = server.installingApps 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' const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
function installToast(app: MarketplaceApp) { function installToast(app: MarketplaceApp) {
@@ -518,7 +528,34 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000) 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 if (installingApps.has(app.id) || isInstalled(app.id)) return
const blocked = installBlockedReason(app.id) const blocked = installBlockedReason(app.id)
if (blocked) { if (blocked) {
@@ -536,7 +573,7 @@ async function installApp(app: MarketplaceApp) {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ await rpcClient.call({
method: 'package.install', 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, timeout: 600000,
}) })
} catch (err) { } 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 if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
const blocked = installBlockedReason(app.id) const blocked = installBlockedReason(app.id)
if (blocked) { if (blocked) {
@@ -558,7 +595,7 @@ async function installCommunityApp(app: MarketplaceApp) {
installToast(app) installToast(app)
try { 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 if (app.containerConfig) installParams.containerConfig = app.containerConfig
await rpcClient.call({ await rpcClient.call({
method: 'package.install', method: 'package.install',
+20 -3
View File
@@ -74,7 +74,7 @@
<button <button
v-if="!isInstalled" v-if="!isInstalled"
@click="installApp" @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)" :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" 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>
</div> </div>
<BitcoinPruningChoice
v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded"
v-model="pruneOnInstall"
class="hidden md:block"
/>
<!-- Mobile: Two Column Grid Layout --> <!-- Mobile: Two Column Grid Layout -->
<div class="md:hidden"> <div class="md:hidden">
<!-- Top: Icon + Info --> <!-- Top: Icon + Info -->
@@ -137,6 +143,7 @@
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }} {{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
</option> </option>
</select> </select>
<BitcoinPruningChoice v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded" v-model="pruneOnInstall" class="mb-4" />
<!-- Bottom: Action Buttons --> <!-- Bottom: Action Buttons -->
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
@@ -153,7 +160,7 @@
<button <button
v-else v-else
@click="installApp" @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)" :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" 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> </template>
<script setup lang="ts"> <script setup lang="ts">
import BitcoinPruningChoice from '@/components/BitcoinPruningChoice.vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro' import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
import { useRouter, useRoute } from 'vue-router' 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. // 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 installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
const selectedInstallVersion = ref('') 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 backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX' 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 // cached entry is missing or past its TTL, so a repeat open inside the TTL
// paints from cache with no new RPC. // paints from cache with no new RPC.
async function loadInstallVersions() { async function loadInstallVersions() {
if (versionsResource.data.value === null || versionsResource.isStale.value) { if (isBitcoinInstall.value || versionsResource.data.value === null || versionsResource.isStale.value) {
await versionsResource.refresh() await versionsResource.refresh()
} }
const info = versionsResource.data.value 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) { if (!info || !info.supportsVersions || info.versions.length < 2) {
installVersions.value = [] installVersions.value = []
return return
@@ -701,6 +716,7 @@ async function installApp() {
id: app.value.id, id: app.value.id,
dockerImage: app.value.dockerImage, dockerImage: app.value.dockerImage,
version: chosenVersion, version: chosenVersion,
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
} }
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
await rpcClient.call({ await rpcClient.call({
@@ -717,6 +733,7 @@ async function installApp() {
id: app.value.id, id: app.value.id,
url: installUrl, url: installUrl,
version: chosenVersion, version: chosenVersion,
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
}, },
timeout: 600000, timeout: 600000,
}) })
@@ -2,6 +2,9 @@ import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Marketplace from '../Marketplace.vue' 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 // Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
// mounting a view directly with its heavier deps mocked at the module // mounting a view directly with its heavier deps mocked at the module
@@ -44,22 +47,37 @@ vi.mock('@/composables/useMarketplaceApp', () => ({
})) }))
vi.mock('@/composables/useToast', () => ({ 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', () => ({ vi.mock('@/api/rpc-client', () => ({
rpcClient: { rpcClient: {
call: vi.fn(), call: vi.fn(),
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }), 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)', () => { describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))) vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
routerPushMock.mockClear() routerPushMock.mockClear()
toastErrorMock.mockClear() toastErrorMock.mockClear()
toastInfoMock.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(() => { afterEach(() => {
@@ -89,4 +107,38 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
wrapper.unmount() 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() 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 () => { it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
const wrapper = mountHomeHost() const wrapper = mountHomeHost()
await settle() await settle()
@@ -54,6 +54,12 @@
</div> </div>
</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 --> <!-- Incoming Transactions Panel -->
<transition name="incoming-tx-slide"> <transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20"> <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<{ const props = defineProps<{
animate: boolean animate: boolean
walletConnected: boolean walletConnected: boolean
walletBalanceUnavailable?: boolean
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is // `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
// what lets the card show a pixel readout instead of claiming a figure. // what lets the card show a pixel readout instead of claiming a figure.
walletOnchain: number | null walletOnchain: number | null
@@ -362,6 +362,73 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <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 --> <!-- v1.8.16-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
+18 -18
View File
@@ -1,30 +1,30 @@
{ {
"changelog": [ "changelog": [
"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 Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
"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.", "Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.", "Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
"Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed." "Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.16-alpha", "new_version": "1.8.21-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f", "sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
"size_bytes": 64851944 "size_bytes": 64748176
}, },
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz", "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.16-alpha.tar.gz", "name": "archipelago-frontend-1.8.21-alpha.tar.gz",
"new_version": "1.8.16-alpha", "new_version": "1.8.21-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722", "sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
"size_bytes": 98799570 "size_bytes": 97152546
} }
], ],
"release_date": "2026-09-15", "release_date": "2026-09-30",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d", "signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha" "version": "1.8.21-alpha"
} }
+66 -5
View File
@@ -618,7 +618,7 @@
}, },
"container": { "container": {
"custom_args": [ "custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${BITCOIN_PRUNE:-0}\" = \"1\" ] || [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
], ],
"data_uid": "100101:100101", "data_uid": "100101:100101",
"derived_env": [ "derived_env": [
@@ -768,7 +768,7 @@
}, },
"container": { "container": {
"custom_args": [ "custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${BITCOIN_PRUNE:-0}\" = \"1\" ] || [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
], ],
"data_uid": "100101:100101", "data_uid": "100101:100101",
"derived_env": [ "derived_env": [
@@ -1378,6 +1378,67 @@
}, },
"version": "0.1.0-preview" "version": "0.1.0-preview"
}, },
"cuprate-ui": {
"image": "source.archipelago-foundation.org/lfg2025/cuprate-ui:1.7.123-alpha",
"manifest": {
"app": {
"container": {
"build": {
"context": "/opt/archipelago/docker/cuprate-ui",
"dockerfile": "Dockerfile",
"tag": "localhost/cuprate-ui:local"
}
},
"dependencies": [
{
"app_id": "cuprate"
}
],
"description": "Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx\ninside a container, serves a static status dashboard, and proxies\n/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the\npublished host port for the container's 18089). No credentials are\ninjected — the restricted RPC is Monero's own safe-for-public subset — so\nthe nginx.conf is baked into the image and there is no rendered-config\nbind-mount like bitcoin-ui's.\n",
"environment": [],
"health_check": {
"endpoint": "http://127.0.0.1:18091",
"interval": "30s",
"path": "/",
"retries": 3,
"timeout": "5s",
"type": "http"
},
"id": "cuprate-ui",
"metadata": {
"author": "Archipelago",
"category": "money",
"icon": "/assets/img/app-icons/cuprate.svg",
"repo": "https://github.com/Cuprate/cuprate",
"tier": "optional"
},
"name": "Cuprate UI",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 18091,
"host": 18091,
"protocol": "tcp",
"session_passthrough": true
}
],
"resources": {
"memory_limit": "64Mi"
},
"security": {
"network_policy": "host",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": []
}
},
"version": "1.7.123-alpha"
},
"electrs-ui": { "electrs-ui": {
"image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha", "image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha",
"manifest": { "manifest": {
@@ -5464,7 +5525,7 @@
"tag": "NOSTR IDENTITY // YOUR NODE" "tag": "NOSTR IDENTITY // YOUR NODE"
}, },
"schema": 1, "schema": 1,
"signature": "e716a9069021af87a2252d7561c01153f17c5630d7c36d8fdc1be1c7aa40560d09557513c0e09835a6b76b52b4f7edf0619cbaff02ac1d9d818066f67046e401", "signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"storefront": { "storefront": {
"popular": [ "popular": [
@@ -5485,10 +5546,10 @@
"id": "archipelago-source", "id": "archipelago-source",
"installLabel": "Install GitWorkshop", "installLabel": "Install GitWorkshop",
"launchLabel": "Open GitWorkshop", "launchLabel": "Open GitWorkshop",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy", "path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"tag": "NGIT // NOSTR // NO SILO" "tag": "NGIT // NOSTR // NO SILO"
} }
] ]
}, },
"updated": "2026-09-15" "updated": "2026-09-29"
} }
+18 -18
View File
@@ -1,30 +1,30 @@
{ {
"changelog": [ "changelog": [
"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 Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
"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.", "Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.", "Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
"Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed." "Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.16-alpha", "new_version": "1.8.21-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f", "sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
"size_bytes": 64851944 "size_bytes": 64748176
}, },
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz", "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.16-alpha.tar.gz", "name": "archipelago-frontend-1.8.21-alpha.tar.gz",
"new_version": "1.8.16-alpha", "new_version": "1.8.21-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722", "sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
"size_bytes": 98799570 "size_bytes": 97152546
} }
], ],
"release_date": "2026-09-15", "release_date": "2026-09-30",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d", "signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha" "version": "1.8.21-alpha"
} }
+3 -4
View File
@@ -112,10 +112,9 @@ VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*
if [ "$SKIP_GATES" = "0" ]; then if [ "$SKIP_GATES" = "0" ]; then
stage "release-gate-harness" bash tests/release/run.sh stage "release-gate-harness" bash tests/release/run.sh
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict 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; # The release harness runs the full backend suite inside namespaces.
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H). # Never execute unrestricted tests on a node with live wallets/services.
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
else else
echo; echo "═══ [gates] SKIPPED (--skip-gates)" echo; echo "═══ [gates] SKIPPED (--skip-gates)"
fi fi
+42 -11
View File
@@ -47,13 +47,33 @@ podman_rootless() {
} }
port_is_listening() { port_is_listening() {
local port="$1" local port="$1" protocol="${2:-tcp}" listeners
local protocol="${2:-tcp}"
case "$protocol" in case "$protocol" in
tcp) ss -ltn 2>/dev/null ;; tcp) listeners=$(ss -ltn 2>/dev/null) || return 2 ;;
udp) ss -lun 2>/dev/null ;; udp) listeners=$(ss -lun 2>/dev/null) || return 2 ;;
*) return 1 ;; *) return 2 ;;
esac | awk '{print $4}' | grep -Eq "(^|:)$port$" esac
# Consume the whole snapshot. grep -q closed the old pipe early, so awk
# received SIGPIPE and pipefail turned a FOUND port into a failed check.
awk -v port="$port" '$4 ~ ("(^|:)" port "$") { found=1 } END { exit !found }' <<< "$listeners"
}
restart_rootless_container() {
local name="$1" unit
unit=$(podman_rootless inspect "$name" --format '{{index .Config.Labels "PODMAN_SYSTEMD_UNIT"}}' 2>/dev/null) || return 1
if [[ "$unit" =~ ^[a-zA-Z0-9_.@-]+\.service$ ]]; then
# Respect the managed service's shutdown timeout and --rm lifecycle.
# Raw podman restart uses a short timeout and races Quadlet cleanup.
if [ "$(id -u)" = 0 ]; then
sudo -u archipelago env XDG_RUNTIME_DIR="/run/user/$(id -u archipelago)" systemctl --user restart "$unit"
else
systemctl --user restart "$unit"
fi
else
local grace=30
case "$name" in bitcoin|bitcoin-core|bitcoin-knots) grace=600 ;; lnd) grace=330 ;; esac
podman_rootless restart --time "$grace" "$name"
fi
} }
run_fix() { run_fix() {
@@ -573,19 +593,27 @@ fix_missing_rootless_ports() {
bindings=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{printf "%s %s\n" $p .HostPort}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u) bindings=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{printf "%s %s\n" $p .HostPort}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
[ -n "$bindings" ] || continue [ -n "$bindings" ] || continue
local missing=() local missing=() inspection_failed=false status
local container_binding host_port protocol local container_binding host_port protocol
while read -r container_binding host_port; do while read -r container_binding host_port; do
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue [ -n "$container_binding" ] && [ -n "$host_port" ] || continue
protocol="${container_binding##*/}" protocol="${container_binding##*/}"
if ! port_is_listening "$host_port" "$protocol"; then status=0
missing+=("$host_port/$protocol") port_is_listening "$host_port" "$protocol" || status=$?
fi case "$status" in
0) ;;
1) missing+=("$host_port/$protocol") ;;
*) inspection_failed=true ;;
esac
done <<< "$bindings" done <<< "$bindings"
if $inspection_failed; then
log "WARN: cannot inspect listeners for $name; leaving it running"
continue
fi
if [ ${#missing[@]} -gt 0 ]; then if [ ${#missing[@]} -gt 0 ]; then
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}" log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
if podman_rootless restart "$name" >/dev/null 2>&1; then if restart_rootless_container "$name" >/dev/null 2>&1; then
fixed=true fixed=true
else else
log "WARN: failed to restart $name for missing rootlessport listener(s)" log "WARN: failed to restart $name for missing rootlessport listener(s)"
@@ -676,6 +704,9 @@ fix_archipelago_dialout() {
# ── Main ───────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────
# Allow regression tests to source helpers without running repairs.
[[ "${BASH_SOURCE[0]}" != "$0" ]] && return 0
# If remote host provided, run via SSH # If remote host provided, run via SSH
if [ -n "$1" ] && [ "$1" != "--local" ]; then if [ -n "$1" ] && [ "$1" != "--local" ]; then
REMOTE_HOST="$1" REMOTE_HOST="$1"
+10 -8
View File
@@ -78,15 +78,17 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX) STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
echo "Staging frontend archive in $STAGING_DIR..." echo "Staging frontend archive in $STAGING_DIR..."
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/" cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
# Bake AIUI in so fresh installs pick it up. OTA already # create-release.sh folds the freshly built AIUI into FRONTEND_DIST.
# carries-forward the existing aiui/ if the tarball lacks one # Never overlay it with the older demo bundle (or nest aiui/aiui/).
# (update.rs:922), but including it here makes the tarball if [ ! -f "$STAGING_DIR/aiui/index.html" ] || \
# the single source of truth instead of relying on a side- [ ! -f "$STAGING_DIR/aiui/BUILD-INFO" ]; then
# effect of the in-place swap. echo "Error: fresh AIUI payload missing from frontend dist" >&2
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then exit 1
echo " Including AIUI from demo/aiui/"
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
fi fi
grep -Fxq "commit=$(git -C "$PROJECT_ROOT" rev-parse HEAD)" "$STAGING_DIR/aiui/BUILD-INFO" || {
echo "Error: AIUI payload was not built from the current commit" >&2
exit 1
}
# OTA bridge for nodes running older updaters: they only know how to # OTA bridge for nodes running older updaters: they only know how to
# apply the backend binary and frontend archive. Carry host runtime # apply the backend binary and frontend archive. Carry host runtime
# assets inside the frontend tarball; the new backend promotes them # assets inside the frontend tarball; the new backend promotes them
+5 -9
View File
@@ -169,15 +169,11 @@ else
fi fi
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball # Build AIUI from the same source as the release. The checked-in demo bundle
# bakes it from demo/aiui independently, but build-iso-release.sh's # can predate source fixes and must never overwrite the production payload.
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two bash "$SCRIPT_DIR/build-aiui.sh"
# consecutive releases (.127, .129) because this fold-in was manual. rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then cp -r "$PROJECT_ROOT/aiui/packages/app/dist" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
echo " AIUI folded into web/dist from demo/aiui"
fi
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale # npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
# dist would ship with a perfectly valid sha256. Require the freshly built # dist would ship with a perfectly valid sha256. Require the freshly built
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Compile normally; execute unit tests away from real wallets, service buses,
# container storage, processes and networking. Never silently fall back to host.
set -euo pipefail
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
command -v systemd-run >/dev/null
command -v unshare >/dev/null
command -v setpriv >/dev/null
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
metadata=$(mktemp)
trap 'rm -f "$metadata"' EXIT
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" -p archipelago --bin archipelago \
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then
python3 - "$metadata" <<'PYDIAG'
import json,sys
for line in open(sys.argv[1]):
try: item=json.loads(line)
except json.JSONDecodeError: continue
rendered=item.get('message',{}).get('rendered') if item.get('reason')=='compiler-message' else None
if rendered: print(rendered,file=sys.stderr,end='')
PYDIAG
exit 1
fi
executable=$(python3 - "$metadata" <<'PY'
import json,sys
found=[]
for line in open(sys.argv[1]):
try: item=json.loads(line)
except json.JSONDecodeError: continue
if item.get('reason')=='compiler-artifact' and item.get('profile',{}).get('test') and item.get('executable'):
found.append(item['executable'])
assert len(found)==1, f'Expected one unit test executable, got {len(found)}'
print(found[0])
PY
)
[[ -x "$executable" ]]
unit="archy-isolated-tests-$(date +%s)-$$"
sudo -n systemd-run --unit="$unit" --wait --pipe --collect \
--property="WorkingDirectory=$REPO/core" \
--property=PrivateNetwork=yes --property=PrivateTmp=yes --property=PrivateDevices=yes \
--property=ProtectSystem=strict --property=ProtectHome=read-only \
--property=NoNewPrivileges=yes \
--property='TemporaryFileSystem=/run:rw /var/lib/archipelago:rw /var/lib/containers:rw /root:rw' \
--setenv=ARCHY_TEST_ISOLATED=1 \
/usr/bin/unshare --pid --fork --mount-proc --kill-child \
/usr/bin/setpriv --bounding-set=-all,+chown,+dac_override,+fowner,+setuid,+setgid,+kill \
"$executable" --test-threads=4 "$@"
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Exercise actual manifest entrypoints with a fake bitcoind; no node data touched."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[2]
class PruningEntrypoint(unittest.TestCase):
def test_auto_and_user_choice_for_both_bitcoin_implementations(self):
for app in ('bitcoin-core', 'bitcoin-knots'):
manifest = yaml.safe_load((ROOT / 'apps' / app / 'manifest.yml').read_text())
command = manifest['app']['container']['custom_args'][0]
for disk, choice, pruned in [(500,'0',True),(999,'0',True),(1000,'0',False),(2000,'0',False),(2000,'1',True),(500,'1',True)]:
with self.subTest(app=app,disk=disk,choice=choice), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
binary = root / 'bitcoind'
binary.write_text('#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n')
binary.chmod(0o755)
env = dict(os.environ, PATH=directory+':'+os.environ['PATH'], DISK_GB=str(disk), BITCOIN_PRUNE=choice,
BITCOIN_RPC_USER='test',BITCOIN_RPC_PASS='test')
# Isolate the ephemeral RPC config too.
script = command.replace('/tmp/rpc.conf',str(root/'rpc.conf'))
args = json.loads(subprocess.check_output(['sh','-c',script],env=env,text=True))
self.assertEqual('-prune=50000' in args,pruned)
self.assertEqual('-txindex=1' in args,not pruned)
self.assertIn('-server=1',args)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# No real service/container operations: all external operations are replaced.
set -euo pipefail
source "$(dirname "$0")/../../scripts/container-doctor.sh"
ss() {
[[ "${SS_FAIL:-0}" == 0 ]] || return 1
printf 'LISTEN 0 4096 *:8333 *:*\n'
# More than a pipe buffer, reliably reproducing grep -q / pipefail SIGPIPE.
awk 'BEGIN { for(i=0;i<20000;i++) print "LISTEN 0 4096 127.0.0.1:1234 *:*" }'
}
port_is_listening 8333
port_is_listening 1234 udp
if port_is_listening 833; then exit 1; else [[ $? == 1 ]]; fi
if SS_FAIL=1 port_is_listening 8333; then exit 1; else [[ $? == 2 ]]; fi
calls=$(mktemp)
trap 'rm -f "$calls"' EXIT
podman_rootless() {
case "$1" in
ps) echo bitcoin-core ;;
inspect)
if [[ "$*" == *PODMAN_SYSTEMD_UNIT* ]]; then echo "${TEST_UNIT:-bitcoin-core.service}";
else echo '8333/tcp 8333'; fi ;;
restart) echo "podman $*" >> "$calls" ;;
*) exit 1 ;;
esac
}
id() { echo 1000; }
systemctl() { echo "systemctl $*" >> "$calls"; }
# Healthy listener and failed ss inspection must not restart anything.
fix_missing_rootless_ports && exit 1
SS_FAIL=1 fix_missing_rootless_ports && exit 1
[[ ! -s "$calls" ]]
# A real missing listener restarts its managed unit, preserving stop timeout.
ss() { echo 'LISTEN 0 4096 *:1234 *:*'; }
fix_missing_rootless_ports
grep -Fx 'systemctl --user restart bitcoin-core.service' "$calls"
: > "$calls"
TEST_UNIT='<no value>' restart_rootless_container bitcoin-core
grep -Fx 'podman restart --time 600 bitcoin-core' "$calls"
echo 'PASS: healthy/missing/failed listener checks and safe managed/unmanaged restart'
+109
View File
@@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('docker/lnd-ui/index.html', 'utf8');
function extract(name) {
const start = html.indexOf(' function '+name+'(');
const end = html.indexOf('\n }', start)+10;
assert.ok(start >= 0 && end > start);
return html.slice(start, end);
}
function fixture() {
const elements = new Map();
const el = id => { if (!elements.has(id)) elements.set(id,{style:{},textContent:'',className:''}); return elements.get(id) };
const ctx = {state:{info:null,readiness:null}, document:{getElementById:el}, setText:(id,v)=>el(id).textContent=v,fmtCount:String};
vm.createContext(ctx);
vm.runInContext(extract('renderHeader')+'\n'+extract('validBalance'),ctx);
return {ctx,el};
}
test('missing, starting and syncing Bitcoin each show waiting and recover',()=>{
const {ctx,el}=fixture();
for (const [state,message] of [['waiting_install','Waiting for Bitcoin to be installed'],['waiting_start','Waiting for Bitcoin to start'],['waiting_sync','Waiting for Bitcoin to sync']]) {
ctx.state.readiness={state,message}; ctx.renderHeader();
assert.equal(el('headerStatusText').textContent,message);
assert.equal(el('syncCard').style.display,'');
assert.match(el('syncSubtitle').textContent,/automatically/);
assert.equal(el('syncPercent').textContent,'');
}
ctx.state.readiness={state:'bitcoin_ready'};
ctx.state.info={synced_to_chain:true,synced_to_graph:true};
ctx.renderHeader();
assert.equal(el('headerStatusText').textContent,'Running');
assert.equal(el('syncCard').style.display,'none');
ctx.state.info=null;ctx.state.readiness=null;ctx.renderHeader();
assert.equal(el('headerStatusText').textContent,'Connecting to LND');
});
test('balances reject missing, malformed, fractional and negative values; real zero remains valid',()=>{
const {ctx}=fixture();
for (const v of [null,undefined,'',{},false,-1,'-1','garbage',1.5,'1.5',Infinity,Number.MAX_SAFE_INTEGER+1]) assert.equal(ctx.validBalance(v),false,String(v));
for(const v of [0,'0',123,'123']) assert.equal(ctx.validBalance(v),true,String(v));
});
test('failed and partial balance polls retain known balances and label them stale; recovery clears flags',async()=>{
const {ctx,el}=fixture();
el('refreshIcon').classList={add(){},remove(){}};
const start=html.indexOf(' async function refreshAll()');
const end=html.indexOf('\n }',start)+10;
vm.runInContext(html.slice(start,end),ctx);
let responses={
'/v1/getinfo':{synced_to_chain:true},
'/v1/balance/blockchain':{confirmed_balance:'500',unconfirmed_balance:'0'},
'/v1/balance/channels':{local_balance:{sat:'250'}},
};
ctx.lndSafe=async(path,fallback)=>responses[path]??fallback;
ctx.renderAll=()=>{};
await ctx.refreshAll();
assert.equal(ctx.state.onchain.confirmed_balance,'500');
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
responses={};await ctx.refreshAll();
assert.equal(ctx.state.onchain.confirmed_balance,'500');
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
assert.equal(ctx.state.onchainStale,true);assert.equal(ctx.state.chanbalStale,true);
responses={'/v1/balance/blockchain':{confirmed_balance:'0'},'/v1/balance/channels':{error:'locked'}};
await ctx.refreshAll();
assert.equal(ctx.state.onchain.confirmed_balance,'0');
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
assert.equal(ctx.state.onchainStale,false);assert.equal(ctx.state.chanbalStale,true);
responses={'/v1/balance/blockchain':{confirmed_balance:'600'},'/v1/balance/channels':{local_balance:{sat:'300'}}};
await ctx.refreshAll();
assert.equal(ctx.state.onchain.confirmed_balance,'600');
assert.equal(ctx.state.chanbal.local_balance.sat,'300');
assert.equal(ctx.state.onchainStale,false);assert.equal(ctx.state.chanbalStale,false);
});
test('waiting renders promptly without querying unavailable LND endpoints, then resumes after Bitcoin sync',async()=>{
const {ctx,el}=fixture();
el('refreshIcon').classList={add(){},remove(){}};
const start=html.indexOf(' async function refreshAll()');
vm.runInContext(html.slice(start,html.indexOf('\n }',start)+10),ctx);
let readiness={state:'waiting_sync',message:'Waiting for Bitcoin to sync'};
const calls=[];let renders=0;
ctx.lndSafe=async(path,fallback)=>{calls.push(path);return path==='/archy-status'?readiness:fallback};
ctx.renderAll=()=>{renders++;ctx.renderHeader()};
await ctx.refreshAll();
assert.deepEqual(calls,['/archy-status']);
assert.equal(renders,1);
assert.equal(el('headerStatusText').textContent,'Waiting for Bitcoin to sync');
assert.equal(ctx.state.onchain,undefined);
assert.equal(ctx.state.refreshing,false);
readiness={state:'bitcoin_ready',message:'Bitcoin is ready'};
await ctx.refreshAll();
assert.ok(calls.includes('/v1/getinfo'));
assert.ok(calls.includes('/v1/balance/blockchain'));
});
test('cold waiting never invents zero channel capacity or an empty wallet recommendation',()=>{
const {ctx,el}=fixture();
Object.assign(ctx,{num:v=>Number(v)||0,fmtAmount:String,fmtAmountShort:String,setBalance:(id,v)=>el(id).value=v});
vm.runInContext(extract('renderBalances')+'\n'+extract('renderSummary')+'\n'+extract('renderChannels'),ctx);
ctx.state.channels=[];
ctx.renderBalances();ctx.renderSummary();ctx.renderChannels();
for(const id of ['liqLocal','liqRemote','statActiveChannels','healthPending','chActive']) assert.equal(el(id).textContent,'—');
assert.equal(el('balTotal').value,null);
assert.match(el('liqHint').textContent,/waiting for LND/);
assert.doesNotMatch(el('channelList').innerHTML,/No payment channels yet/);
ctx.state.info={};ctx.state.chanbal={local_balance:{sat:'0'}};
ctx.renderBalances();
assert.equal(el('liqLocal').textContent,'0');
});
+5 -4
View File
@@ -72,6 +72,9 @@ summary() {
stage "git-diff-check" git diff --check stage "git-diff-check" git diff --check
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "manifest-shell" python3 scripts/check-manifest-shell.py stage "manifest-shell" python3 scripts/check-manifest-shell.py
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py
stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
# Validate the artifact that will actually be signed and published, not only # Validate the artifact that will actually be signed and published, not only
@@ -166,9 +169,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
# 2026-08-20 1500s died at unit 427/429 (the archipelago bin test, the biggest # 2026-08-20 1500s died at unit 427/429 (the archipelago bin test, the biggest
# link) on a loaded, swapping dev box, again without running a single test. # link) on a loaded, swapping dev box, again without running a single test.
# 3600s leaves headroom; a warm target/ finishes in a fraction of it. # 3600s leaves headroom; a warm target/ finishes in a fraction of it.
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \ stage "cargo-test-isolated" timeout 3600 bash scripts/test-backend-isolated.sh
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision
# ── Stage 4: live node smoke ───────────────────────────────────────── # ── Stage 4: live node smoke ─────────────────────────────────────────
if [[ $LIVE -eq 1 ]]; then if [[ $LIVE -eq 1 ]]; then
@@ -215,7 +216,7 @@ if [[ $LIVE -eq 1 ]]; then
[ -z "$st" ] && continue [ -z "$st" ] && continue
seen=1 seen=1
echo "LND($port) state: $st" echo "LND($port) state: $st"
echo "$st" | grep -q "RPC_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; } echo "$st" | grep -qE "UNLOCKED|RPC_ACTIVE|SERVER_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; }
echo "$st" | grep -qE "NON_EXISTING|WAITING_TO_START" && { echo "OK: LND wallet not initialized yet — not a lock regression"; exit 0; } echo "$st" | grep -qE "NON_EXISTING|WAITING_TO_START" && { echo "OK: LND wallet not initialized yet — not a lock regression"; exit 0; }
done done
[ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; } [ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; }