Compare commits

...
Author SHA1 Message Date
archipelago c188d9de78 fix(lifecycle): abort unsafe declarative uninstall 2026-08-23 07:59:40 -04:00
archipelago 37a82fd2f9 fix(cuprate): avoid Penpot RPC port collision 2026-08-23 01:43:09 -04:00
archipelagoandClaude Opus 5 f1b5d2d267 fix(cuprate): stop publishing the unauthenticated unrestricted RPC
The manifest bound cuprated's unrestricted RPC (full node control) to
0.0.0.0 inside the container with
i_know_what_im_doing_allow_public_unrestricted_rpc = true, relying on
ports[].bind: 127.0.0.1 to keep it private. That only restricts the HOST
side. Verified live on archi-dev-box 2026-08-22: a peer container got a
valid unauthenticated get_info off container port 18081 — and still did
after cuprate was moved to its own network, because podman bridges route
to each other unless created with --opt isolate=true, which the
orchestrator's auto-create does not pass. Every app on the node could
therefore drive full node control with no credential.

The PR justified this as the pattern bitcoin-knots already uses, but
knots writes rpcuser/rpcpassword from generated secrets, so a 0.0.0.0
bind there still is not control without credentials. cuprated has no RPC
authentication at all, so the two are not equivalent.

Unrestricted RPC is now left at cuprated's own default — container
loopback only, published nowhere, reachable by nothing — which is what
upstream intends by refusing a non-local bind without an explicit
override. Restricted RPC (the safe-for-public subset wallets use) and p2p
are unchanged, and health_check moves to 18089 since 18184 is gone.

Re-verified after the change: peer container gets connection refused on
18081 (exit 7), restricted RPC and the health endpoint still answer, the
node still syncs, validator APPROVED, 76/76 container tests pass
including the unauthenticated-port canary (still 28 — an auth: local
port was removed, not an auth: none one).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 03:10:53 -04:00
ssmithxandClaude Sonnet 5 d6b48ce095 feat(apps): package Cuprate, an alternative Monero node
Full-node daemon: P2P + Monero's own restricted RPC (the safe-for-public
subset wallets use as a "remote node") are auth:none like bitcoin/electrumx's
equivalents; unrestricted RPC (full node control) stays gated auth:local.
readonly_root works cleanly since the upstream image is FROM scratch with
ownership fixed at build time — no runtime chown/setuid needed, unlike
bitcoin-knots/core.

Verified locally end-to-end before committing: built the upstream Dockerfile,
confirmed the generated Cuprated.toml against `cuprated --generate-config`/
`--dry-run`, and ran the real image with the manifest's exact ports/volumes —
including discovering that cuprated's own 127.0.0.1-default RPC bind is
unreachable through a published host port and needs to bind 0.0.0.0
internally with ports[].bind:127.0.0.1 doing the actual restriction, the
same pattern bitcoin-knots' RPC port already uses in this repo.

Bumps the unauthenticated_ports_are_all_accounted_for canary (26 -> 28) for
cuprate's two auth:none ports, per that test's own review-before-updating
contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 13:58:06 +00:00
archipelago 9c5164372e chore: release v1.8.4-alpha 2026-08-20 07:15:01 -04:00
archipelagoandClaude Opus 5 e38b148d8e fix(release): spell out how the mnemonic prompt actually submits
The signer reads stdin to EOF, so pressing Enter submits nothing and a
second paste simply appends to the first. Step [6b/8] said only "paste the
release master mnemonic when prompted", which gives no hint that Ctrl-D is
what ends the input — a 24-word phrase arrived today as "invalid word
count: 89", about four pastes concatenated by someone reasonably assuming
Enter had not worked.

sign-manifest.sh already explains this properly; create-release.sh now says
the same thing, including that pasting twice is itself a failure mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:27:42 -04:00
archipelagoandClaude Opus 5 e03a2fed89 fix(release): surface frontend build failures instead of hiding them
`npm run build 2>&1 | tail -3` threw away npm's exit status, so a failed
build was indistinguishable from a good one. The run continued and blamed
the next check instead — "the frontend build no-opped or its output is
stale" — which points at a stale dist rather than at the build error that
actually happened, and cost a diagnosis cycle today.

Success still prints the same quiet 3 lines; a failure now prints the real
error, keeps the full log, and aborts on the spot.

Verified both branches with a stubbed npm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 05:24:37 -04:00
archipelagoandClaude Opus 5 3d7de3e902 fix(release): a dateless changelog header silently skipped the release
Demo images / Build & push demo images (push) Failing after 38s
create-release aborted at [4/8] with "web/dist/neode-ui does not contain
v1.8.4-alpha — the frontend build no-opped or its output is stale". The
build had not no-opped: it was fresh, and simply had no 1.8.4 string to
embed.

sync-whats-new.py only matches '## vX.Y.Z (YYYY-MM-DD)'. The entry read
'## v1.8.4-alpha (draft — date set at cut)', so the version was invisible
to it: the gate's whats-new-sync stage reported "87 versions, all present"
while the release being cut had no What's New block. That modal is the
only place a version string appears in the frontend, so the bundle carried
none and the freshness check — correctly — refused it, while naming the
wrong cause. Step [5/8] only greps for '^## v1.8.4-alpha (' so it passed
the draft too.

Three changes: date the v1.8.4-alpha entry, insert the modal block it was
owed, and make the sync tool refuse any version header without a real date
instead of skipping it. Skipping is what let a wrong "all present" through.

Verified: the draft header now fails the check with an explicit message,
the dated one passes (88 versions, up from 87), and a rebuilt bundle
contains 1.8.4-alpha where it did not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 03:01:39 -04:00
archipelagoandClaude Opus 5 60c1db98bb fix(ui-tests): raise the vitest timeout so a busy box cannot fail the gate
Demo images / Build & push demo images (push) Failing after 40s
Four unrelated tests failed the release gate at once today — every one of
them "Test timed out in 5000ms", none an assertion. Wall times were 6.3s,
16.5s, 5.5s and 36.2s for tests that normally finish in milliseconds
(useModalKeyboard's takes 349ms on an idle box), and the whole suite took
405s against its usual ~70s. The cause was CPU starvation from a
concurrent cargo build, not anything in the code.

The 5s default says nothing about these tests and everything about the
machine: this box also runs a live node, so a gate run can always collide
with a build or container churn. 20s survives that while still bounding a
genuine hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 02:06:14 -04:00
archipelagoandClaude Opus 5 f5b112c508 fix(gate): stop reporting a compile timeout as a test failure
cargo-test-weekly failed twice today with exit 124 at unit 427/429 — the
non-incremental test-profile build running out of wall clock mid-compile,
before a single test executed. The summary said only "FAIL: cargo-test-
weekly", which reads as a broken test and sends you hunting for one that
does not exist.

Two changes: the ceiling goes 1500s -> 3600s (580s was already found too
short; 1500s now dies on the biggest link on a loaded, swapping box), and
stage() names exit 124 as a timeout rather than printing a bare code.

Verified both reporting branches: a timed-out stage and an ordinary
non-zero exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:50:52 -04:00
archipelagoandClaude Opus 5 5ccef0ac2f feat(release): attach the installer ISO to the Gitea release automatically
Publishing the ISO was a manual step printed as a reminder at the end of
build-iso-release.sh: upload the ISO, its .sha256 and the signed checksum
JSON by hand. Only the OTA binary and frontend tarball were automated.

publish-release-assets.sh now uploads all three when an ISO for the
version exists in image-recipe/results/, with the same supply-chain rules
the OTA manifest already gets: the checksum JSON must be signed by the
pinned release root, the signature must cryptographically verify, and the
image must still match its own .sha256 (a truncated or half-copied ISO is
exactly what a signed checksum exists to expose). After upload it
confirms every asset landed at its exact local size.

The stage runs AFTER main is pushed, deliberately. The ISO is not
referenced by releases/manifest.json, so no node's OTA path depends on
it — running it last means a slow or failed multi-GB upload can never
delay or strand an OTA release that has already been verified. When no
ISO exists yet (the usual case, since the ISO build needs the tag this
script pushes) it explains how to build and attach one, and exits clean.

Uploads take a max-time argument: 4h and a progress bar for the ISO,
where the previous fixed 15-minute silent ceiling would have killed a
multi-GB transfer partway through.

Verified with a stubbed harness: no-ISO skip, missing .sha256, unsigned
checksum, wrong signing key, corrupted image, happy path, and a truncated
upload caught by the size check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:33:54 -04:00
archipelagoandClaude Opus 5 e79ab37da7 chore(release): bump version to 1.8.4-alpha
Demo images / Build & push demo images (push) Failing after 40s
Left uncommitted by an aborted create-release run on 2026-08-19: the
version bump landed in the tree but the release never reached its tag or
manifest. Committing it so the tree is clean before the release is re-cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:04:04 -04:00
archipelagoandClaude Opus 5 d75963de10 fix(gate): show which UI test failed instead of swallowing it
The ui-unit-tests stage piped vitest through `tail -4`, which cut off the
failure block. A red gate reported "1 failed | 999 passed" and nothing
else — no file, no test name, no assertion — so the failure could not be
diagnosed after the run.

Success still prints the quiet 4-line summary; failure now dumps the full
log and keeps it on disk so a scrolled-off terminal isn't the end of it.

Verified both paths: green run unchanged, and a deliberately failing spec
now surfaces its file, test name, assertion and line number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:33:09 -04:00
archipelagoandClaude Opus 5 c788dff42d style: apply cargo fmt so the release gate can run
The release gate's first real stage is `cargo fmt --check`, and it had
44 diffs across 15 files — enough to abort `create-release.sh` at step 0
before it touched a version number. Some of that drift is mine from the
last two days, some predates it in files I never opened
(bootstrap.rs, ghost_reaper.rs, openwrt/router.rs), and one is the
regenerated fips/app_ports.rs.

No behaviour change — rustfmt only.

Gate now: 8 of 9 green. The remaining red is cargo-test-weekly exiting
124, which is the 25-minute `timeout` expiring during a cold
CARGO_INCREMENTAL=0 rebuild on a loaded node — the tests never started.
Not a test failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:38:41 -04:00
archipelagoandClaude Opus 5 bd299318c0 chore(catalog): re-sign the catalog with the new pins, and fix a stale firewall list
Demo images / Build & push demo images (push) Failing after 41s
Regenerates both catalogs from the manifests so the 15 pin bumps become
real. The catalog overrides on-disk manifests on every node, so until
now those bumps were edited but inert.

There are two catalogs and regenerating one is not enough:
generate-app-catalog.sh writes releases/app-catalog.json (the signed one
nodes fetch), while generate-app-catalog.py writes app-catalog/catalog.json
and neode-ui/public/catalog.json (the source pair, the second baked into
the frontend app store). check-app-catalog-drift.py --release --strict
reads the *source* catalog, so regenerating only the release one left it
failing and would have aborted the ISO gate at stage 1 — after the
signing and tagging were already done. Drift is now 0.

The regeneration also rewrote fips/app_ports.rs, which had not been
regenerated since the initial open-source import. Diffing the port values
rather than the reformat: 36 -> 37, a single addition, **8187 — Alby
Hub**. Its port has never been in the FIPS firewall allow-list, and by
the same token neither has any app onboarded since that import. Nothing
else changed.

Catalog signed by the pinned release root and verified with
`ceremony verify`; registry trust floor checked before signing, both
hosts trusted by the deployed fleet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:02:40 -04:00
archipelagoandClaude Opus 5 8bc161f40f fix(ecash): repair two mangled warning messages
Both import refusals reached the operator with runs of ~18 spaces mid
sentence — "Importing a different one                  means coins
minted…". The string literals had been written as single long lines with
the line-continuation whitespace baked in rather than escaped, so Rust
preserved it verbatim.

Only visible once the sanitizer stopped swallowing these messages, which
is its own small lesson: the text had been wrong since it was written and
nothing could show it.

Cosmetic, but not trivially so — this is the warning that stops someone
replacing the phrase their balance was minted under, and text that looks
broken is text people stop reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:31:52 -04:00
archipelagoandClaude Opus 5 c19c411b91 chore(apps): mirror and bump the upgrades that carry no data migration
With registry push access, the 24 mirror-backed apps stopped being
blocked. Ten images are now mirrored (single-platform amd64, matching
the existing convention) and their pins moved:

  alby-hub          v1.23.0       -> v1.24.0
  mempool-frontend  v3.0.1        -> v3.3.1     (mempool, archy-mempool-web)
  mempool-backend   v3.0.0        -> v3.3.1
  fedimintd         v0.10.0       -> v0.10.1
  gatewayd          v0.10.0       -> v0.10.1
  nostr-rs-relay    0.9.0         -> 0.10.0
  portainer         2.39.1        -> 2.39.6
  vaultwarden       1.30.0-alpine -> 1.37.1-alpine
  jellyfin          10.8.13       -> 10.11.11
  home-assistant    2026.7.3      -> 2026.8.2

Every one verified pullable from our mirror after copying, so none can
become an image-not-found on a node. image-versions.sh moved in lockstep
— it is the baseline the update badge compares against when the catalog
does not cover an app, and leaving it behind would have kept advertising
an update that had already been applied.

Chosen by risk, not by count: these are patch/minor bumps with no data
migration. The ones held back are held for a reason each — Postgres
15->18 and 16->18 refuse to start on an older cluster, Redis 7->8,
Valkey 7->9, Nextcloud 29->32 must go one major at a time, plus
uptime-kuma 1->2, grafana 10->13, electrumx 1->2, photoprism, and
core-lightning's three years of schema migrations. Those are each a
migration plan, not a pin edit. LND (v0.18.4 -> v0.21.2) is held
separately: it is only a minor bump by version but it migrates its
channel database irreversibly, and this box holds real funds.

Note the checker still reports several of these as behind, and that is
correct: it reads the *catalog* pin, which is what nodes actually act on.
These land when the catalog is regenerated and re-signed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:22:42 -04:00
archipelagoandClaude Opus 5 7d6e52537a chore(apps): bump the pins that can move without mirroring
Of the 33 apps behind upstream, these five pull straight from a public
registry, so their targets exist already and the bump is real work rather
than a promise:

  strfry          1.0.4        -> 1.1.1
  netbird (nginx) 1.27-alpine  -> 1.31.3-alpine
  pine    (nginx) 1.27-alpine  -> 1.31.3-alpine
  pine-piper      2.2.2        -> 2.4.2
  nostr-rs-relay  0.8.9        -> 0.10.0

All five targets verified present upstream with skopeo before editing, so
none of these can turn into an image-not-found on a node.

Deliberately NOT bumped here, though they are also direct-pull:
core-lightning (v23.08 -> v26.06, ~3 years of schema migrations), gitea
(four minors of DB migrations), and netbird-server/netbird-dashboard —
which have to move in lockstep and carry their own migrations. Those are
each a piece of work, not a line edit.

The other 24 are blocked on something else entirely: their images live in
our mirror and none of the upgrade targets have been mirrored yet, so a
pin bump alone would break every install. That needs registry push
credentials.

These take effect when the catalog is regenerated and re-signed — the
catalog overrides on-disk manifests, so editing here changes nothing on a
node until the signing ceremony.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:54:05 -04:00
archipelagoandClaude Opus 5 86923f05a5 fix(ecash): stop the sanitizer eating the import safety rails
Live-checking the import route on the node showed both of its refusals
arriving as "Operation failed. Check server logs for details."

That is not merely opaque here, it is unsafe. The two messages are the
feature's safety rails: "That is not a valid BIP-39 recovery phrase —
check for typos" is the only help someone gets when a pasted phrase has
a bad word, and "This wallet already has a backup phrase… reveal and
write down the current phrase first, then confirm to replace it" is the
warning that stops an operator orphaning the words their balance was
minted under. Masked, the first is unactionable and the second is
invisible — the confirmation checkbox would be the only clue that
anything was at stake.

Same for "no backup phrase yet, nothing to restore from" and the NUT-09
message naming a mint that cannot restore at all.

Caught only because the refusal paths were exercised against the live
node rather than trusted from the unit tests, which see the real message
and never meet the sanitizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:43:47 -04:00
archipelagoandClaude Opus 5 ee40880ce5 feat(ecash): import a backup phrase from another NUT-13 wallet
Demo images / Build & push demo images (push) Failing after 2m2s
Bring-your-own, the open question the migration plan left. Point this
wallet at a phrase you already hold — Minibits, Nutstash, cdk-cli — and
its coins become restorable here, which is the other half of "these
words are portable".

Replacing an established phrase is the one genuinely lossy thing this
module can do, so it is treated that way. The coins already held stay
spendable: they are proofs, not derivations, and nothing here touches
`ecash.json`. But they were minted under the *old* phrase, so a restore
will no longer find them. Hence an explicit confirm, a prompt to reveal
and write down the current phrase first, and — most importantly — the
replaced phrase is archived beside the wallet, never overwritten. It may
be the last copy of the words a balance was minted under, and quietly
destroying that is precisely what this module exists to prevent.

Re-importing the phrase already in use is a no-op rather than a
replacement, so it archives nothing.

Counters are deliberately left alone. They are per-keyset and
seed-relative, so under a new seed they merely start high, which costs
nothing because a restore scans from zero regardless. Resetting them
would be the dangerous choice on the day someone imports the phrase they
were already using.

`imported` is its own provenance rather than reusing `independent`: both
mean the node's recovery phrase does not cover the wallet, but only one
of them means the operator already knows where else the words live.

15 NUT-13 tests green, 1000 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:09:19 -04:00
archipelagoandClaude Opus 5 c1a79fdd69 fix(apps): never recommend a release candidate, and flag major jumps
Two things the first run of this script got wrong, both found by reading
its own output rather than by a test.

It recommended MariaDB `13.0.1-ubi10-rc` — a release candidate — because
ordering strips the suffix, so an RC outranks every stable tag
numerically. Pre-releases are now excluded, with one exception that
matters here: a project whose stable line *is* suffixed. LND ships
`-beta` and always has, so a blanket exclusion would report it as
permanently current. The rule is therefore "no pre-release unless the pin
we are on is itself one", which keeps LND honest and MariaDB stable.

And "33 behind" is not an actionable list, because the entries are not
the same kind of work. A patch bump is a pin change; a major bump is
where the data migrations live — Postgres refuses to start on an older
cluster, Nextcloud requires one major at a time. Each row now says which
it is, and the summary names the majors separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:55:18 -04:00
archipelagoandClaude Opus 5 59440ef1ac fix(wallet): a seen receipt stays seen across refreshes
Demo images / Build & push demo images (push) Failing after 2m6s
fc98c1d8 replaced the five-minute timer with "stays until seen", but
kept "seen" in component state — so every page load forgot it and the
entire ecash history came back as new. That is worse than the timer it
replaced: the old behaviour at least let receipts go, this one resurrected
them on every refresh. Reported from the node, and correctly.

Acknowledgement now lives in localStorage, capped at 300 keys.

That opens the opposite trap: on a browser with nothing stored, treating
the whole history as unseen is the same wall of old receipts from the
other direction. So a first run seeds everything older than five minutes
as already seen — the window survives as a first-run heuristic, not as
an expiry. Unreadable storage takes the same path, because reading a
corrupt value as "nothing acknowledged" is the refresh bug wearing a hat.

Also guards the balance readout against NaN. `sats == null` does not
catch it, and arithmetic over a missing field produces it, so it would
have rendered as the literal text "NaN sats" — worse than the zero the
component exists to prevent, since a zero at least looks like a number.

Frontend: 1000 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:30:57 -04:00
archipelagoandClaude Opus 5 603291008b fix(test): restore the ecash network before bouncing the service
The restore proof's cleanup set the network back *after* restarting
archipelago, so the call landed on a socket that wasn't listening yet
and failed silently. A fully green run left the node parked on testnet —
the one outcome a cleanup path must never produce, and worse for being
invisible.

Network first, while the RPC is still up; then the wallet file, then the
restart, then wait for the service back so a check running straight
afterwards doesn't meet a dead socket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:15:31 -04:00
archipelagoandClaude Opus 5 212e349b19 refine(wallet): the balance readout scans as one column, not a barcode
Demo images / Build & push demo images (push) Failing after 2m7s
Seeing it on the node settled the shape. Two rows of per-cell delays
read as a dense flicker — closer to a progress bar than a display, and
short enough against the row's text to look like an underline.

Three rows laid out column-first fixes both: the three cells of a column
now share a delay, so the lit column travels across as a single scan
line, and at 11px the matrix sits with the text rather than under it.

Verified in a real browser against the live node with the balance RPCs
held open: five placeholders, five distinct rail colours (white, orange,
yellow, purple, blue), 42 cells each, all animating, each announcing
what it is waiting for — and no "0 sats" anywhere on screen while the
calls were in flight. They gave way to real figures on arrival, with
Lightning's genuine 0 correctly shown as a figure rather than left
shimmering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:05:33 -04:00
archipelagoandClaude Opus 5 fa6fe32ef9 feat(wallet): a balance that isn't loaded yet says so, in pixels
Demo images / Build & push demo images (push) Failing after 2m18s
An unloaded balance rendered as `0`. Zero is not a loading state — it is
a number, and it is the one number that frightens people. Someone
opening the dashboard while the RPCs were still in flight was told, in
the wallet's own typeface, that their money was gone.

There is no formatting fix for that. The fix is to stop claiming a
figure we do not have, so `null` now means "not known yet" and `0` means
"none", and the two are kept apart end to end: the refs start at null,
a rail becomes a number only when its call actually succeeds, and a
snapshot key that was never written stays unknown instead of becoming a
zero.

In place of the figure, a small dot-matrix scans in the rail's own
colour. It inherits currentColor, so on-chain shimmers orange, Lightning
yellow, Cashu purple, Fedimint blue and Ark teal with no colour table to
keep in sync — and it is sized to the figure it stands in for, so
nothing jumps when the real number lands. It carries role="status" and
names what it is waiting for; a shimmering box with no text is nothing
at all to a screen reader.

Two consequences worth stating. The total is withheld until every rail
that makes it up is known — summing nulls as zero would show a total
*lower* than the rails beneath it, which is worse than showing nothing
because it looks authoritative. And the Ark row stays hidden while its
balance is unknown, since "unknown" must not be read as "> 0" on the
many nodes with no Ark sidecar.

The LND app UI had the same bug in a different shape: its tiles start as
an em-dash, but renderBalances() runs on every poll including before the
first response, and `num(null && …)` is 0 — so the dashes were painted
over with "0 sats" almost immediately. Same treatment, in plain CSS.

Also fixes a stale assertion in AppHeroSection's suite, which has been
red since 9ccc325a changed "Restarting..." to a real ellipsis; and two
test proofs that used a plausible-looking hex string for `C`. The V3
codec never parses that field so it went unnoticed, but the V4 encoder
hands it to the reference implementation, which checks the point is
actually on secp256k1. Real curve points now.

Frontend: 996 tests green. Backend: 1436 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:54:12 -04:00
archipelagoandClaude Opus 5 fc98c1d8dd fix(wallet): an incoming payment no longer disappears before you look
Demo images / Build & push demo images (push) Failing after 2m10s
Chasing the "selecting incoming clears a pending token, and there's a
timeout if you don't click" report led here. Instant rails — Lightning,
Cashu, Fedimint, Ark — settle immediately, so there is no confirmation
to wait for and no natural moment for a receipt to leave the Incoming
badge. It was leaving on a five-minute wall clock instead.

So a payment could arrive, raise the badge, and evaporate before anyone
looked; and opening the panel a few minutes late showed nothing, because
the payment you came to check on had already aged out. Worse, once the
count hit zero the badge silently changed meaning — the same click that
opened the panel now navigated to the transactions view instead.

For ecash that is the worst case available. It leaves no public ledger
entry, so this panel was the only place the receipt was ever shown; once
it timed out there was nowhere left to look.

Instant-rail receipts now stay until they have actually been seen, which
is the same unread model the mesh inbox uses. Closing the panel is what
marks them seen, not opening it — marking on open would make a row
vanish under the cursor of someone still reading it. On-chain is
untouched: a confirmation count is a real signal and already does this
job.

Also keys the list on a derived id. Instant rails have no txid, so
`:key="tx.tx_hash"` was `""` for every one of them.

This is my reading of the reported symptoms rather than a confirmed
repro — the operator should check it matches what they saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:30:16 -04:00
archipelagoandClaude Opus 5 e30516316b fix(ecash): give every node a backup phrase, and prove restore works
Demo images / Build & push demo images (push) Failing after 2m11s
Running the route suite on this box surfaced that the backup was
unreachable here: `identity/master_seed.enc` is written during
onboarding, and any node onboarded before that step existed simply does
not have one. Reveal bailed with "this node has no encrypted seed
backup", and restore followed it down.

But the choice on such a node was never "derived phrase or independent
phrase" — it was "independent phrase or no backup at all", and a wallet
whose coins can be restored from words the operator holds beats one
whose coins die with a single file. So it now generates one, recorded as
`independent`, and every surface that shows it says plainly that
restoring the node will not bring the ecash back — only these words
will. `derivable_from_node_seed` lets the card say which kind you are
about to get *before* you write anything down.

Also: a mint that never implemented NUT-09 answered restore with a bare
404, which surfaced as "mint returned 404 with no further detail" —
true, and useless to someone trying to get their coins back. It now
names the limitation.

The route suite was reading `result.amount_sats` from mint-claim, which
answers with `minted_sats`. A working claim had been reporting as a
failure; that was one of the two reds carried over from yesterday.

The real gap, though, was that "recovered 0 sats" passes on a wallet
with nothing to find — exactly the shape of a backup that looks fine
until the day you need it. test-ecash-restore.sh does the test that
settles it: mint, **delete the wallet file**, restore, check the coins
came back. On this box: 87 sats before the wipe, 0 after, 61 recovered
from the phrase alone — every coin minted since the phrase existed, and
none of the 26 sats minted before it, which used random secrets and
never could come back. Testnet only, and it refuses to run otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:27:01 -04:00
archipelagoandClaude Opus 5 cbbd20e22e test(ecash): cover cashuB emission, the backup phrase, and restore
Four things the suite could not previously catch:

- The emitted token is cashuA. It is still valid, so nothing fails — the
  send succeeds and the receiver redeems it. The only symptom of cashuB
  encoding falling back is a warning in the journal nobody reads, which
  is exactly the kind of silent regression a route check exists for.
- The wallet has no backup phrase. Without one the coins live in exactly
  one file and nothing can bring them back.
- The phrase changes between reveals, which would orphan every coin
  minted under the previous one.
- Restore double-counts. It runs against a live wallet, so running it
  twice must leave the balance where it was.

Reveal is also asserted to refuse a wrong password: it is the one route
here that hands out key material, and a session alone must not be enough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:06:27 -04:00
archipelagoandClaude Opus 5 eb48eab946 feat(apps): find out when an app has fallen behind upstream
Nodes offer an update when the signed catalog pins something newer than
what's running, and that machinery is fine. The missing step was the one
before it: nothing told *us* when upstream shipped. A pin could sit at
fedimintd v0.10.0 for months while every node in the fleet correctly and
confidently reported "up to date".

The reason nothing could tell us is that a manifest records only our
mirror — `source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0`
says nothing about the project it was mirrored from. So this adds an
optional `app.upstream` block naming the real source, and a script that
asks each one what it has released.

Running it answers the question that prompted this. Of 58 apps, 28 are
behind, including LND v0.18.4-beta against v0.21.2-beta, Bitcoin Core
28.4 against 31.1, and fedimintd/gatewayd v0.10.0 against v0.10.1.

Two choices worth stating. An app with no `upstream` block is reported
as UNTRACKED rather than skipped — a silent skip is how this stayed
invisible, and before this commit all 58 were silently skipped. And a
suggestion prefers our own tag variant: telling someone pinned to
`postgres:16.13-alpine` that the newest tag is `18.6-trixie` is true and
useless, because swapping the base image is a different decision from
bumping a version.

Five apps are deliberately left untracked (barkd, immich-postgres,
indeedhub-minio, lightning-stack, pine-whisper): I could not establish
their upstream with confidence, and a wrong `repo` produces a confident
wrong verdict, which is worse than an honest gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:04:33 -04:00
archipelagoandClaude Opus 5 59fffc809f feat(ecash): the wallet can now be restored from a phrase (NUT-13)
Demo images / Build & push demo images (push) Failing after 2m15s
Until now every Cashu proof this node held was backed by a secret drawn
from OsRng and written to exactly one file. Losing wallet/ecash.json
lost the coins outright — no phrase to write down, and nothing the mint
could do about it. Ecash is a bearer instrument, so "one file, no
backup" was the sharpest edge in the wallet.

NUT-13 derives each proof's secret and blinding factor from (seed,
keyset id, counter) instead. The wallet becomes a phrase, and the coins
can be re-derived and re-claimed — here or in any other NUT-13 wallet.

The phrase is its own 24 words, derived from the node master seed over a
fixed HKDF path. Both halves matter: it is still covered by the node's
recovery phrase, so there is nothing extra to write down; but it is
portable, so restoring ecash into Minibits or cdk-cli does not mean
handing over the key to the entire node.

It sits on disk unencrypted, deliberately. The master seed needs the
operator's password to open, which no background mint or swap can ask
for; and this file lives beside wallet/ecash.json, which already holds
spendable bearer secrets in plaintext. It regenerates exactly those
secrets, so it is the same sensitivity class as the file next to it.
0600, like identity/nostr_secret, which is derived and persisted the
same way.

Counters are reserved *before* the mint call and never rolled back. A
gap costs a restore scan a few extra probes; a reused counter costs a
coin, because two proofs with the same secret can only be spent once.

Restore is the half that cannot be done offline: a re-derived secret is
not money until the mint's signature over it exists. /v1/restore returns
those signatures; unblinding reconstitutes the proofs. It is additive
and idempotent — coins already held are skipped by secret, spent ones
are counted but not added — so it is safe to press on a working wallet,
which is when someone is most likely to reach for it.

Existing nodes activate on the first visit to Settings → Ecash backup
phrase: that password prompt is the only moment the master seed can
legitimately be opened. New nodes get it at onboarding. Until then the
behaviour is exactly as before — valid proofs, no backup — and the card
says so rather than implying a backup already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:56:34 -04:00
archipelagoandClaude Opus 5 579287ba48 feat(ecash): emit cashuB tokens, and share one payment success screen
Most wallets — Minibits, Nutstash, cdk-cli — default to reading cashuB
(V4) now, so that is what we send. cashuA stays as the fallback rather
than the default: it is still valid everywhere, so a token this wallet
cannot express in V4 (a multi-mint one) is worth sending in V3 rather
than failing the send outright. That path warns, because by the time
`send_token_at` serializes, the proofs are already marked spent.

The V4 encoder is the reference implementation's, not ours. The envelope
puts the keyset id and signature on the wire as raw CBOR bytes under
single-letter keys, and a token subtly wrong there is money the receiver
cannot redeem — so upstream owns the encoding, the way it already owns
keyset-id resolution. Our own hand-written decoder reads what upstream
writes in the new test, which is agreement between two independent
implementations rather than a round trip through one codec.

Two refusals are deliberate and tested: a multi-mint token has no V4
form, and a truncated v2 keyset id must never be baked into a token we
emit (the framework-pt case) — it is only resolvable against the mint's
keyset list.

Also folds SendBitcoinModal onto the shared PaymentSuccessPane it had a
private copy of, so on-chain, Lightning and ecash all show the same
screen and the copyable-identifier row is defined once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:56:15 -04:00
111 changed files with 4609 additions and 415 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## v1.8.4-alpha (draft — date set at cut)
## v1.8.4-alpha (2026-08-20)
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
+16 -16
View File
@@ -73,7 +73,7 @@
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -193,13 +193,13 @@
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -374,11 +374,11 @@
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -459,12 +459,12 @@
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
@@ -552,19 +552,19 @@
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
+3
View File
@@ -2,6 +2,9 @@ app:
id: aiui
name: AI Assistant
version: 0.1.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
+7 -1
View File
@@ -2,11 +2,17 @@ app:
id: alby-hub
name: Alby Hub
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: getAlby/hub
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
category: money
container:
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0
pull_policy: if-not-present
dependencies:
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-btcpay-db
name: BTCPay Postgres
version: "15.17"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres backend for BTCPay and NBXplorer.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/mariadb
description: MariaDB backend for the mempool explorer stack.
container:
+7 -1
View File
@@ -2,11 +2,17 @@ app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
pull_policy: if-not-present
network: archy-net
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dgarage/NBXplorer
description: BTCPay blockchain indexer service.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoin/bitcoin
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
+6
View File
@@ -2,6 +2,12 @@ app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoinknots/bitcoin
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
+3
View File
@@ -2,6 +2,9 @@ app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
+3
View File
@@ -2,6 +2,9 @@ app:
id: botfights
name: BotFights
version: 1.2.11
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: btcpay-server
name: BTCPay Server
version: 2.4.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: btcpayserver/btcpayserver
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: core-lightning
name: Core Lightning (CLN)
version: 23.08.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ElementsProject/lightning
description: Lightning Network implementation in C. Lightweight alternative to LND.
container:
+151
View File
@@ -0,0 +1,151 @@
app:
id: cuprate
name: Cuprate
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
# Cuprate has no stable release yet — this is explicitly work-in-progress
# software (see upstream README). The image tag below pins the exact
# commit built, since "0.1.0-preview" alone is not reproducible.
version: 0.1.0-preview
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: Cuprate/cuprate
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
# no newer tagged release as of this writing. Re-pin to a tagged release
# once upstream cuts one.
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
pull_policy: if-not-present
network: archy-net
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
# appended as its argv, matching the project's own systemd unit
# (cuprated.service) invocation exactly.
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
# user at build time and runs as it unconditionally (USER 1000:1000,
# no shell to switch users at runtime) — same pattern as
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
# apps/barkd. The bind-mounted data dir must be owned by that literal
# uid or cuprated dies on a permission error the first time it writes.
data_uid: "1000:1000"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 300Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
# can run fully read-only with an empty capability set.
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
# P2P. Cuprate's own default listen address is already 0.0.0.0
# (p2p.clear_net.listen_on), so no config override is needed — only the
# host-side port differs from Monero's canonical 18080 because that
# number is already taken on this fleet by lnd's REST port.
- host: 18183
container: 18080
protocol: tcp
auth: none
auth_rationale: >-
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
# Unrestricted RPC (full node control) is deliberately NOT published.
# cuprated has no RPC authentication, and for a published port to reach
# it the service would have to bind 0.0.0.0 inside the container — at
# which point every other app can reach it directly on 18081, since
# ports[].bind only restricts the HOST side and podman bridges route to
# each other (verified live 2026-08-22: a peer container on archy-net
# got an unauthenticated get_info, from a *different* network). That is
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
# rpcpassword it writes from generated secrets. So unrestricted RPC is
# left at cuprated's own default — container loopback only, reachable by
# nothing — which is also what upstream intends by refusing a non-local
# bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. A dashboard login
# would break wallet clients connecting programmatically, same
# reasoning as electrumx's port. The daemon still uses its canonical
# container port 18089, but Penpot already owns host port 18089, so this
# maps the public host port to the free 18090 instead.
- host: 18090
container: 18089
protocol: tcp
auth: none
auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
volumes:
- type: bind
source: /var/lib/archipelago/cuprate
target: /home/cuprate
options: [rw]
# Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21):
# - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
# - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default
# (canonical 18089), and advertise stays false — this node is not
# opting in to being listed as a public remote node over the p2p
# network, just reachable if someone points a wallet at it directly.
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
# default (127.0.0.1) looks like the obviously-correct choice for a
# port meant to stay loopback-only, but verified live (2026-08-21)
# that a service bound literally to 127.0.0.1 *inside* the container
# is unreachable through the host's published port — connections
# reset regardless of how long the daemon has been up. Binding
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
# actual restriction is the same pattern apps/bitcoin-knots already
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC.
files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml
content: |
network = "Mainnet"
target_max_memory = 3000000000
[rpc.restricted]
enable = true
overwrite: false
health_check:
type: tcp
# Restricted RPC — the only RPC surface published now.
endpoint: localhost:18090
interval: 30s
timeout: 5s
retries: 3
start_period: 5m
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Cuprate
repo: https://github.com/Cuprate/cuprate
+3
View File
@@ -2,6 +2,9 @@ app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
+3
View File
@@ -2,6 +2,9 @@ app:
id: electrs-ui
name: Electrs UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies
+6
View File
@@ -2,6 +2,12 @@ app:
id: electrumx
name: ElectrumX
version: 1.18.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: spesmilo/electrumx
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint-clientd
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: fedimint-gateway
name: Fedimint Gateway
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container:
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: fedimint
name: Fedimint Guardian
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+6
View File
@@ -2,6 +2,12 @@ app:
id: filebrowser
name: File Browser
version: 2.27.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: filebrowser/filebrowser
description: Baseline Archipelago file manager service.
container:
+3
View File
@@ -2,6 +2,9 @@ app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
+6
View File
@@ -2,6 +2,12 @@ app:
id: gitea
name: Gitea
version: "1.23"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: go-gitea/gitea
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
+6
View File
@@ -2,6 +2,12 @@ app:
id: grafana
name: Grafana
version: 10.2.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: grafana/grafana
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: homeassistant
name: Home Assistant
version: 2026.7.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: home-assistant/core
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2
pull_policy: if-not-present
network: pasta
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich-redis
name: Immich Redis
version: "7-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: valkey/valkey
description: Valkey (Redis-compatible) cache for Immich.
# Container named immich_redis (underscore) to match runtime per-app references
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich
name: Immich
version: "2.7.4"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: immich-app/immich
description: Self-hosted photo and video backup with mobile apps and search.
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
+3
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub-api
name: IndeedHub API
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub backend API (Nostr auth, media, payments).
category: community
+3
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub-ffmpeg
name: IndeedHub FFmpeg Worker
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub background media transcoding worker.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-postgres
name: IndeedHub Postgres
version: "16.13-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres database backend for IndeedHub.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-redis
name: IndeedHub Redis
version: "7.4.8-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/redis
description: Redis queue/cache backend for IndeedHub.
category: community
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-relay
name: IndeedHub Nostr Relay
version: "0.9.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
category: community
@@ -11,7 +17,7 @@ app:
container_name: indeedhub-relay
container:
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [relay]
+3
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub
name: IndeeHub
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: community
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: jellyfin
name: Jellyfin
version: 10.8.13
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: jellyfin/jellyfin
description: Free media server. Stream movies, music, and photos.
container:
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11
pull_policy: if-not-present
network: pasta
+3
View File
@@ -2,6 +2,9 @@ app:
id: lnd-ui
name: LND UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for LND. Runs nginx inside a
container and serves static assets. LND connection info is fetched
+6
View File
@@ -2,6 +2,12 @@ app:
id: lnd
name: LND
version: 0.18.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: lightningnetwork/lnd
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: mempool-api
name: Mempool API
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Backend API for mempool explorer.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1
pull_policy: if-not-present
network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: mempool
name: Mempool Explorer
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image_signature: cosign://...
pull_policy: if-not-present
+3
View File
@@ -2,6 +2,9 @@ app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: netbird-dashboard
name: NetBird Dashboard
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/dashboard
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
category: networking
+6
View File
@@ -2,6 +2,12 @@ app:
id: netbird-server
name: NetBird Server
version: "0.71.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/netbird
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
category: networking
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: netbird
name: NetBird
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
category: networking
@@ -12,7 +18,7 @@ app:
container_name: netbird
container:
image: docker.io/library/nginx:1.27-alpine
image: docker.io/library/nginx:1.31.3-alpine
pull_policy: if-not-present
network: netbird-net
# Self-signed TLS cert materialised before create — the dashboard needs a
+6
View File
@@ -2,6 +2,12 @@ app:
id: nextcloud
name: Nextcloud
version: "29"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: nextcloud/server
description: Your own private cloud. File sync, calendars, contacts.
container:
+8 -2
View File
@@ -1,11 +1,17 @@
app:
id: nostr-rs-relay
name: Nostr Relay (Rust)
version: 0.8.0
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
container:
image: scsibug/nostr-rs-relay:0.8.9
image: scsibug/nostr-rs-relay:0.10.0
image_signature: cosign://...
pull_policy: verify-signature
data_uid: "1000:1000"
+6
View File
@@ -2,6 +2,12 @@ app:
id: phoenixd
name: phoenixd
version: 0.9.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ACINQ/phoenixd
description: Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.
category: money
+6
View File
@@ -2,6 +2,12 @@ app:
id: photoprism
name: PhotoPrism
version: "240915"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: photoprism/photoprism
description: AI-powered photo management with facial recognition.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: pine-openwakeword
name: Pine Wake Word (openWakeWord)
version: "2.1.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-openwakeword
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home
+8 -2
View File
@@ -1,7 +1,13 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.2.2"
version: "2.4.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-piper
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
@@ -12,7 +18,7 @@ app:
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.2.2
image: docker.io/rhasspy/wyoming-piper:2.4.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: pine
name: Pine
version: "1.3.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
category: home
@@ -13,7 +19,7 @@ app:
container_name: pine
container:
image: docker.io/library/nginx:1.27-alpine
image: docker.io/library/nginx:1.31.3-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
+7 -1
View File
@@ -2,11 +2,17 @@ app:
id: portainer
name: Portainer
version: 2.19.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: portainer/portainer
description: Container management web UI for the local Podman socket.
category: development
container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.1
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
pull_policy: if-not-present
data_uid: "1000:1000"
+3
View File
@@ -2,6 +2,9 @@ app:
id: router
name: Mesh Router
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: searxng
name: SearXNG
version: 1.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: searxng/searxng
description: Privacy-respecting metasearch engine. Search the web without tracking.
container:
+8 -2
View File
@@ -1,11 +1,17 @@
app:
id: strfry
name: Strfry Nostr Relay
version: 0.9.0
version: 1.1.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: hoytech/strfry
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
container:
image: dockurr/strfry:1.0.4
image: dockurr/strfry:1.1.1
image_signature: cosign://...
pull_policy: verify-signature
+6
View File
@@ -2,6 +2,12 @@ app:
id: uptime-kuma
name: Uptime Kuma
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: louislam/uptime-kuma
description: Self-hosted uptime monitoring.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: vaultwarden
name: Vaultwarden
version: 1.30.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dani-garcia/vaultwarden
description: Self-hosted password vault with zero-knowledge encryption.
container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine
pull_policy: if-not-present
network: pasta
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.3-alpha"
version = "1.8.4-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.3-alpha"
version = "1.8.4-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+14 -6
View File
@@ -105,10 +105,7 @@ async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>)
/// Discard queued input for `d` — used while no kiosk session exists so the
/// bounded channel can't fill with stale events.
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
let _ = tokio::time::timeout(d, async {
while rx.recv().await.is_some() {}
})
.await;
let _ = tokio::time::timeout(d, async { while rx.recv().await.is_some() {} }).await;
}
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
@@ -219,7 +216,11 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
}
Some("c") => {
let b = msg.get("b").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
let b = msg
.get("b")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 3);
let (button, buttons) = match b {
2 => ("middle", 4),
3 => ("right", 2),
@@ -255,7 +256,14 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
}
}
fn mouse_event(id: u64, kind: &str, cursor: &Cursor, button: &str, buttons: u32, clicks: u32) -> Value {
fn mouse_event(
id: u64,
kind: &str,
cursor: &Cursor,
button: &str,
buttons: u32,
clicks: u32,
) -> Value {
json!({
"id": id,
"method": "Input.dispatchMouseEvent",
@@ -268,6 +268,10 @@ impl RpcHandler {
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
+5 -1
View File
@@ -667,7 +667,11 @@ impl RpcHandler {
.get("state")
.and_then(|v| v.as_str())
.map(|s| s == "SETTLED")
.unwrap_or_else(|| body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false));
.unwrap_or_else(|| {
body.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
});
let amt_paid_sat = body
.get("amt_paid_sat")
.and_then(|v| v.as_str())
@@ -172,6 +172,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"No pending seed generation",
"Submitted words",
"Already set up",
// Ecash backup phrase — these two ARE the feature's safety rails, and
// masking them made it dangerous rather than merely opaque. "That is
// not a valid BIP-39 recovery phrase… check for typos" is the whole
// help someone gets when a pasted phrase has a bad word; and "This
// wallet already has a backup phrase… reveal and write down the
// current phrase first, then confirm to replace it" is the warning
// that stops an operator orphaning the words their balance was minted
// under. Behind "check server logs" the first is unactionable and the
// second is invisible.
"That is not a valid BIP-39",
"This wallet already has a backup phrase",
"This wallet has no backup phrase yet",
// Restore against a mint that never implemented NUT-09.
"This mint does not support restoring",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
@@ -195,6 +209,27 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
mod sanitize_tests {
use super::sanitize_error_message;
/// The ecash import errors are the feature's safety rails. If the
/// sanitizer eats them, a bad paste gives no hint and — worse — the
/// warning about replacing an established phrase never reaches the person
/// about to do it.
#[test]
fn ecash_backup_phrase_errors_reach_the_operator() {
for msg in [
"That is not a valid BIP-39 recovery phrase: invalid checksum. Check for typos",
"This wallet already has a backup phrase. Importing a different one means coins \
minted under the current phrase will no longer be restorable from words",
"This wallet has no backup phrase yet, so there is nothing to restore from.",
"This mint does not support restoring from a backup phrase (NUT-09).",
] {
let out = sanitize_error_message(msg);
assert_ne!(
out, "Operation failed. Check server logs for details.",
"swallowed: {msg}"
);
}
}
#[test]
fn password_required_sentinel_passes_through_verbatim() {
// The UI machine-reads this sentinel (isPasswordRequired checks
+6 -2
View File
@@ -43,8 +43,12 @@ impl RpcHandler {
// plus a blocking SSH verify per candidate. Inline, one click of
// "scan for routers" held a tokio worker for that whole time.
let routers = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current()
.block_on(detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password))
tokio::runtime::Handle::current().block_on(detect::scan_subnet(
subnet,
prefix,
&ssh_user,
&ssh_password,
))
})
.await
.context("openwrt scan task")?;
@@ -365,8 +365,18 @@ impl RpcHandler {
// after uninstall. The reconciler owns a manifest map independent of
// podman state, so a raw `podman rm` alone is not enough.
if let Some(orchestrator) = &self.orchestrator {
let mut teardown_errors = Vec::new();
for app_id in orchestrator_uninstall_app_ids(package_id) {
let _ = orchestrator.remove(&app_id, preserve_data).await;
if let Err(err) = orchestrator.remove(&app_id, preserve_data).await {
teardown_errors.push(format!("{app_id}: {err:#}"));
}
}
if !teardown_errors.is_empty() {
return Err(anyhow::anyhow!(
"Uninstall {} aborted: failed to remove declarative app unit(s): {}",
package_id,
teardown_errors.join("; ")
));
}
}
@@ -2182,6 +2192,11 @@ mod tests {
assert!(!is_missing_container_error("Error: OCI runtime error"));
}
#[test]
fn single_app_uninstall_targets_its_declarative_unit() {
assert_eq!(orchestrator_uninstall_app_ids("cuprate"), vec!["cuprate"]);
}
#[test]
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
+12
View File
@@ -52,6 +52,18 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
.parse()
.context("Invalid mnemonic in memory")?;
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
// Establish the ecash wallet's NUT-13 phrase here too — this is the last
// moment the master seed exists in plaintext during onboarding, and the
// ecash wallet needs its own phrase on disk to mint restorable proofs
// without a password prompt on every background swap. Best-effort: a node
// that fails here still onboards, mints valid coins, and can establish the
// phrase later from Settings → Back up ecash.
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
if let Err(e) = crate::wallet::nut13::establish_from_master(data_dir, &master).await {
tracing::warn!("Could not establish the ecash wallet phrase at onboarding: {e:#}");
}
*state = None;
Ok(true)
}
+175
View File
@@ -246,6 +246,181 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-seed-status` — whether this wallet has a NUT-13 phrase
/// yet, and therefore whether its coins can be restored at all.
///
/// Deliberately says nothing secret. `active: false` is the honest answer
/// for a node that predates NUT-13: its existing proofs live in exactly one
/// file and nothing can bring them back, which the UI needs to be able to
/// say plainly rather than implying a backup exists.
pub(super) async fn handle_wallet_ecash_seed_status(&self) -> Result<serde_json::Value> {
let data_dir = &self.config.data_dir;
let active = crate::wallet::nut13::seed_exists(data_dir);
let source = match crate::wallet::nut13::load_seed(data_dir).await {
Ok(Some(seed)) => Some(seed.source()),
_ => None,
};
// A phrase can always be established. Whether the node has an
// encrypted master seed decides only *which kind*: derived from it
// (the node's 24 words already cover the ecash), or independent (the
// phrase is the only copy). The UI needs both facts to set the right
// expectation before the operator commits to writing something down.
Ok(serde_json::json!({
"active": active,
"source": source,
"can_activate": true,
"derivable_from_node_seed": crate::seed::seed_exists(data_dir),
}))
}
/// `wallet.ecash-seed-reveal` — show the ecash wallet's 24 words, and
/// establish them from the node's master seed if this is the first time.
///
/// Gated exactly like `seed.reveal` and `lnd.seed-reveal`: authenticated
/// session, password re-verification, TOTP when enabled. The words are
/// returned to the caller only and never logged.
///
/// Reveal doubles as activation because the master seed is encrypted at
/// rest: this password prompt is the only moment the node can legitimately
/// open it, so it is also the only moment the ecash phrase can be derived
/// from it. A node that has never been here mints valid but unrecoverable
/// proofs; one visit fixes that for every proof minted afterwards.
pub(super) async fn handle_wallet_ecash_seed_reveal(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use zeroize::Zeroize;
let params = params.unwrap_or_default();
let data_dir = &self.config.data_dir;
let mut password = self.verify_reveal_auth(&params, "the ecash seed").await?;
// Already established: just open it. No master seed needed, so this
// still works on a node whose backup passphrase has been forgotten.
if let Some(seed) = crate::wallet::nut13::load_seed(data_dir).await? {
password.zeroize();
let words = seed.words();
return Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": false,
}));
}
// No encrypted master seed to derive from — common on nodes onboarded
// before that step existed. The choice here is not "derived or
// independent", it is "independent or no backup at all", so we make
// one and label it honestly. Every surface that shows an
// `independent` phrase says the node's own recovery phrase does not
// cover it.
if !crate::seed::seed_exists(data_dir) {
password.zeroize();
let seed = crate::wallet::nut13::establish_independent(data_dir).await?;
let words = seed.words();
return Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": true,
}));
}
// The backup passphrase may differ from the login password — same
// fallback `seed.reveal` uses.
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| password.clone());
let master = crate::seed::load_seed_encrypted(data_dir, &passphrase).await;
password.zeroize();
let mnemonic = master.map_err(|_| {
anyhow::anyhow!(
"Could not decrypt the saved seed. If you set a separate backup \
passphrase during setup, enter that passphrase."
)
})?;
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
let seed = crate::wallet::nut13::establish_from_master(data_dir, &master).await?;
let words = seed.words();
Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": true,
}))
}
/// `wallet.ecash-seed-import` — adopt a phrase from another NUT-13 wallet.
///
/// Gated like every other route that touches key material. Replacing an
/// established phrase additionally needs `confirm: true`, because coins
/// minted under the old one stop being restorable from words — they stay
/// spendable, but a restore will not find them. The old phrase is archived
/// beside the wallet rather than overwritten.
pub(super) async fn handle_wallet_ecash_seed_import(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use zeroize::Zeroize;
let params = params.unwrap_or_default();
let words = params
.get("words")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("A recovery phrase is required"))?
.to_string();
let confirm = params
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut password = self.verify_reveal_auth(&params, "the ecash seed").await?;
password.zeroize();
let seed =
crate::wallet::nut13::import_mnemonic(&self.config.data_dir, &words, confirm).await?;
Ok(serde_json::json!({
"source": seed.source(),
"word_count": seed.words().len(),
}))
}
/// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13
/// phrase by asking a mint which re-derived secrets it has signed.
///
/// Defaults to the wallet's own mint; `mint_url` targets another one, for
/// a wallet whose coins were spread across mints.
pub(super) async fn handle_wallet_ecash_restore(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mint_url = match params.get("mint_url").and_then(|v| v.as_str()) {
Some(url) if !url.trim().is_empty() => url.trim().to_string(),
_ => {
crate::wallet::ecash::load_wallet(&self.config.data_dir)
.await?
.mint_url
}
};
let outcome =
crate::wallet::ecash::restore_from_seed(&self.config.data_dir, &mint_url).await?;
Ok(serde_json::json!({
"mint_url": mint_url,
"recovered_sats": outcome.recovered_sats,
"recovered_proofs": outcome.recovered_proofs,
"already_spent": outcome.already_spent,
"keysets_scanned": outcome.keysets_scanned,
}))
}
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({
+32 -10
View File
@@ -184,14 +184,17 @@ pub async fn ensure_doctor_installed() {
Err(e) => warn!("nginx listener repair failed (non-fatal): {:#}", e),
}
match run_ha_rpc_proxy_bind_repair().await {
Ok(true) => info!(
"HA bitcoind RPC forwarder rebound dynamically — survives network moves now"
),
Ok(true) => {
info!("HA bitcoind RPC forwarder rebound dynamically — survives network moves now")
}
Ok(false) => debug!("HA bitcoind RPC forwarder absent or already dynamic"),
Err(e) => warn!("HA RPC forwarder bind repair failed (non-fatal): {:#}", e),
}
match run_pull_never_image_repair().await {
Ok(n) if n > 0 => info!(retagged = n, "Healed quadlet image refs orphaned by registry rename"),
Ok(n) if n > 0 => info!(
retagged = n,
"Healed quadlet image refs orphaned by registry rename"
),
Ok(_) => debug!("All quadlet image refs resolve locally"),
Err(e) => warn!("Quadlet image ref repair failed (non-fatal): {:#}", e),
}
@@ -704,7 +707,12 @@ fn parse_socat_static_bind(exec_line: &str) -> Option<(String, String)> {
}
// Only rewrite units pinned to a concrete address; a unit already using
// a computed bind (or none) needs no heal.
let bind = after_listen.split("bind=").nth(1)?.split(',').next()?.trim();
let bind = after_listen
.split("bind=")
.nth(1)?
.split(',')
.next()?
.trim();
if !bind.chars().all(|c| c.is_ascii_digit() || c == '.') || bind.starts_with("127.") {
return None;
}
@@ -731,7 +739,10 @@ async fn run_ha_rpc_proxy_bind_repair() -> Result<bool> {
Ok(s) => s,
Err(_) => return Ok(false), // node never grew the forwarder
};
let Some(exec_line) = unit.lines().find(|l| l.trim_start().starts_with("ExecStart=")) else {
let Some(exec_line) = unit
.lines()
.find(|l| l.trim_start().starts_with("ExecStart="))
else {
return Ok(false);
};
let Some((port, target)) = parse_socat_static_bind(exec_line) else {
@@ -833,7 +844,11 @@ async fn run_pull_never_image_repair() -> Result<usize> {
}
async fn podman_stdout(args: &[&str]) -> String {
match tokio::process::Command::new("podman").args(args).output().await {
match tokio::process::Command::new("podman")
.args(args)
.output()
.await
{
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
_ => String::new(),
}
@@ -859,7 +874,8 @@ const NGINX_SITES: [&str; 2] = [
"/etc/nginx/sites-available/archipelago-http",
"/etc/nginx/sites-available/archipelago",
];
const NGINX_RESTART_DROPIN: &str = "/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
const NGINX_RESTART_DROPIN: &str =
"/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
/// Global IPv4 addresses on this host, minus Tailscale CGNAT (100.64/10) —
/// the same exclusion `setup-node-ca.sh` applies, for the same reason.
@@ -964,7 +980,10 @@ async fn run_nginx_listener_repair() -> Result<bool> {
let status = host_sudo(&["sh", "-lc", &script]).await?;
match status.code() {
Some(0) => changed = true,
Some(3) => warn!(site, "nginx listener repair failed its config test — rolled back"),
Some(3) => warn!(
site,
"nginx listener repair failed its config test — rolled back"
),
_ => warn!(site, "nginx listener repair helper failed"),
}
}
@@ -1826,7 +1845,10 @@ mod tests {
let healed = retarget_https_listeners(cfg, &present).expect("must heal");
assert!(healed.contains("listen 192.168.1.50:443 ssl;"));
assert!(healed.contains("listen 10.44.0.1:443 ssl;"));
assert!(!healed.contains("192.168.63.240"), "stale listener must be dropped");
assert!(
!healed.contains("192.168.63.240"),
"stale listener must be dropped"
);
// Untouched lines survive, and the repair is idempotent.
assert!(healed.contains("listen 80 default_server;"));
assert!(healed.contains("ssl_certificate /x;"));
+16 -7
View File
@@ -216,14 +216,20 @@ pub async fn reap_for_app(app_id: &str) -> usize {
let app_id = app_id.to_string();
reap_matching(move |g| {
g.name.as_deref().is_some_and(|n| {
n == app_id || n.starts_with(&format!("{app_id}-")) || n.ends_with(&format!("-{app_id}"))
n == app_id
|| n.starts_with(&format!("{app_id}-"))
|| n.ends_with(&format!("-{app_id}"))
})
})
.await
}
async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
let ghosts: Vec<Ghost> = find_ghosts().await.into_iter().filter(|g| pred(g)).collect();
let ghosts: Vec<Ghost> = find_ghosts()
.await
.into_iter()
.filter(|g| pred(g))
.collect();
if ghosts.is_empty() {
return 0;
}
@@ -237,7 +243,10 @@ async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
);
kill_ghost(ghost).await;
}
info!(count = ghosts.len(), "ghost reaper: reaped ghost containers");
info!(
count = ghosts.len(),
"ghost reaper: reaped ghost containers"
);
ghosts.len()
}
@@ -280,7 +289,9 @@ mod tests {
#[test]
fn a_truncated_or_missing_id_is_not_reapable() {
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none());
assert!(
parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none()
);
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none());
}
@@ -295,9 +306,7 @@ mod tests {
let app = app.to_string();
let gh = g(name);
gh.name.as_deref().is_some_and(|n| {
n == app
|| n.starts_with(&format!("{app}-"))
|| n.ends_with(&format!("-{app}"))
n == app || n.starts_with(&format!("{app}-")) || n.ends_with(&format!("-{app}"))
})
};
assert!(matches("gitea", "gitea"));
+2 -2
View File
@@ -7,6 +7,6 @@
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 23000, 32838, 50002,
8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380,
11434, 18081, 18083, 23000, 32838, 50002,
];
+6 -2
View File
@@ -538,8 +538,12 @@ async fn same_serial_device(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let ra = fs::canonicalize(a).await.unwrap_or_else(|_| PathBuf::from(a));
let rb = fs::canonicalize(b).await.unwrap_or_else(|_| PathBuf::from(b));
let ra = fs::canonicalize(a)
.await
.unwrap_or_else(|_| PathBuf::from(a));
let rb = fs::canonicalize(b)
.await
.unwrap_or_else(|_| PathBuf::from(b));
ra == rb
}
+61
View File
@@ -39,6 +39,7 @@ const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
const CASHU_ENTROPY_INFO: &[u8] = b"archipelago/cashu/bip39-entropy/v1";
// ─── MasterSeed ─────────────────────────────────────────────────────────
@@ -300,6 +301,30 @@ pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
Ok(entropy)
}
/// Derive the ecash (Cashu, NUT-13) wallet's own 24-word BIP-39 mnemonic.
///
/// The ecash wallet gets a **separate mnemonic** rather than being handed the
/// node's own 24 words, and both halves of that matter:
///
/// - it is still covered by the node's recovery phrase, because it is derived
/// from the master seed over a fixed domain-separated path — restore the
/// node from its words and the same ecash wallet comes back, with nothing
/// extra for the operator to write down;
/// - but it is *portable*. NUT-13 is a standard, so these words restore the
/// ecash in Minibits, Nutstash or `cdk-cli`. Showing the node seed here
/// would have made "back up my ecash" and "hand over the key to the entire
/// node" the same action.
///
/// One-way by construction: HKDF cannot be run backwards, so a leaked ecash
/// mnemonic does not expose the master seed or any other derived key.
pub fn derive_cashu_mnemonic(seed: &MasterSeed) -> Result<bip39::Mnemonic> {
let mut entropy = hkdf_derive_32(seed.as_bytes(), CASHU_ENTROPY_INFO)?;
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
.map_err(|e| anyhow::anyhow!("Failed to derive the ecash mnemonic: {}", e));
entropy.zeroize();
mnemonic
}
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
@@ -657,6 +682,42 @@ mod tests {
assert_eq!(e1.len(), 16);
}
/// The ecash mnemonic must be reproducible from the node's words alone —
/// that reproducibility is the entire backup story ("your 24 words already
/// cover your ecash").
#[test]
fn cashu_mnemonic_is_reproducible_from_the_node_seed() {
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let a = derive_cashu_mnemonic(&seed).unwrap();
let b = derive_cashu_mnemonic(&seed).unwrap();
assert_eq!(a.to_string(), b.to_string());
assert_eq!(a.word_count(), 24);
// A different node seed must yield a different ecash wallet, or two
// nodes would derive each other's coins.
let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
assert_ne!(
a.to_string(),
derive_cashu_mnemonic(&other_seed).unwrap().to_string()
);
}
/// It must NOT be the node's own phrase. Restoring ecash into a
/// third-party wallet means handing these words over, and that must never
/// be the same as handing over the node.
#[test]
fn cashu_mnemonic_is_not_the_node_mnemonic() {
let (node_mnemonic, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let cashu = derive_cashu_mnemonic(&seed).unwrap();
assert_ne!(cashu.to_string(), node_mnemonic.to_string());
// And knowing the ecash words must not re-derive the node seed: they
// are a one-way HKDF descendant, so the seeds they expand to differ.
let cashu_seed = MasterSeed::from_mnemonic(&cashu);
assert_ne!(cashu_seed.as_bytes(), seed.as_bytes());
}
#[test]
fn test_generate_produces_24_words() {
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
+1 -2
View File
@@ -664,8 +664,7 @@ impl Server {
.map(|(a, _)| *a)
.unwrap_or(0)
+ 1;
let delay =
(90u64 << attempts.min(10)).min(86_400);
let delay = (90u64 << attempts.min(10)).min(86_400);
notify_backoff.insert(
node.did.clone(),
(attempts, now + Duration::from_secs(delay)),
+186 -17
View File
@@ -1,22 +1,22 @@
//! Cashu token format (NUT-00) — serialization and deserialization.
//!
//! Emits the cashuA (V3) token format:
//! cashuA<base64url_encoded_json>
//! Reads and writes both wire versions:
//!
//! Token JSON structure:
//! {
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
//! "memo": "<optional>"
//! }
//! - **cashuA (V3)** — `cashuA<base64url_encoded_json>`, whose JSON is the
//! structs below verbatim:
//! ```text
//! { "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>",
//! "secret": "<str>", "C": "<hex>" }] }], "memo": "<optional>" }
//! ```
//! - **cashuB (V4)** — `cashuB<base64url_encoded_cbor>`, a CBOR map keyed by
//! the spec's single letters (t/i/p/a/s/c/m/u/d/w) rather than the JSON
//! names above, with the keyset id (`i`) and signature (`c`) as raw bytes.
//! Those are hex-encoded into `Proof` on the way in so the rest of the
//! wallet never has to know which version a token arrived in.
//!
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
//! by default now:
//! cashuB<base64url_encoded_cbor>
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
//! convention so the rest of the wallet doesn't need to know which version
//! a token arrived in.
//! `serialize_v4` is what we emit — most wallets default to cashuB now —
//! with `serialize` (cashuA) kept for older receivers and as the fallback
//! for the one token shape V4 cannot express (multi-mint).
use anyhow::{Context, Result};
use bitcoin::secp256k1::PublicKey;
@@ -24,10 +24,16 @@ use bitcoin::secp256k1::PublicKey;
// itself is built on). Used for the parts of NUT-00/02 that move with the
// spec — token parsing and keyset ids — while the structs below stay ours
// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md).
use cashu::nuts::nut00::{Proof as CdkProof, Token as CdkToken};
use cashu::nuts::nut01::PublicKey as CdkPublicKey;
use cashu::nuts::nut02::{
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
};
use cashu::nuts::CurrencyUnit as CdkCurrencyUnit;
use cashu::secret::Secret as CdkSecret;
use cashu::{Amount as CdkAmount, MintUrl as CdkMintUrl};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
/// Prefix for V3 (JSON) tokens.
const CASHU_A_PREFIX: &str = "cashuA";
@@ -148,6 +154,58 @@ impl CashuToken {
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
}
/// Encode as a cashuB (V4, CBOR) token string — the format most wallets
/// default to today.
///
/// Built through the reference implementation rather than by hand. The V4
/// envelope puts the keyset id and the signature on the wire as raw CBOR
/// bytes under single-letter keys, and a token that is subtly wrong there
/// is money the receiver cannot redeem — so upstream owns the encoding,
/// the same way it owns keyset-id resolution.
///
/// V4 is single-mint by construction, so a multi-mint token — which only
/// our internal plumbing ever builds — has no V4 form and is refused
/// here; `send_token_at` falls back to cashuA for it.
pub fn serialize_v4(&self) -> Result<String> {
let entry = match self.token.as_slice() {
[only] => only,
[] => anyhow::bail!("Token has no entries"),
many => anyhow::bail!(
"cashuB carries one mint per token; this token spans {}",
many.len()
),
};
let mint_url = CdkMintUrl::from_str(&entry.mint)
.with_context(|| format!("Token has an unusable mint URL: {}", entry.mint))?;
// `unit` is optional on our struct and on V3; V4 requires one. Every
// proof this wallet holds is denominated in sats (the mint's SAT
// keyset is selected explicitly at signing time), so that is the
// right default rather than a guess.
let unit = CdkCurrencyUnit::from_str(self.unit.as_deref().unwrap_or("sat"))
.with_context(|| format!("Token has an unusable unit: {:?}", self.unit))?;
let proofs = entry
.proofs
.iter()
.map(|p| {
let keyset_id = CdkId::from_str(&p.id).with_context(|| {
format!("Proof carries a keyset id cashuB cannot encode: {}", p.id)
})?;
let c = CdkPublicKey::from_hex(&p.c)
.context("Proof carries an unparseable signature C")?;
Ok(CdkProof::new(
CdkAmount::from(p.amount),
keyset_id,
CdkSecret::new(p.secret.clone()),
c,
))
})
.collect::<Result<Vec<_>>>()?;
Ok(CdkToken::new(mint_url, proofs, self.memo.clone(), unit).to_string())
}
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
pub fn deserialize(token_str: &str) -> Result<Self> {
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
@@ -553,6 +611,111 @@ mod tests {
assert_eq!(decoded.memo, Some("test token".to_string()));
}
#[test]
fn a_v4_token_we_emit_is_readable_by_our_own_v4_decoder() {
// Cross-implementation check: upstream's encoder writes the CBOR,
// our hand-written decoder reads it back. Agreement between two
// independent implementations is the evidence that matters here —
// a round trip through one codec would prove nothing about the wire.
let token = CashuToken {
token: vec![TokenEntry {
mint: "https://testnut.cashu.space".to_string(),
// Real curve points (G and 2G). The V3 codec never parses `C`,
// so its tests get away with a plausible-looking hex string —
// the V4 encoder hands it to the reference implementation,
// which checks the point is actually on secp256k1.
proofs: vec![
Proof {
amount: 8,
id: "009a1f293253e41e".to_string(),
secret: "abcdef1234567890".to_string(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.to_string(),
},
Proof {
amount: 2,
id: "009a1f293253e41e".to_string(),
secret: "fedcba0987654321".to_string(),
c: "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
.to_string(),
},
],
}],
memo: Some("ten sats".to_string()),
unit: Some("sat".to_string()),
};
let encoded = token.serialize_v4().expect("V4 encoding must succeed");
assert!(encoded.starts_with("cashuB"), "{encoded}");
let decoded = CashuToken::deserialize(&encoded).expect("our decoder must read it");
assert_eq!(decoded.total_amount(), 10);
assert_eq!(decoded.token[0].mint, "https://testnut.cashu.space");
assert_eq!(decoded.memo, Some("ten sats".to_string()));
// Every proof survives byte-for-byte, including the hex convention we
// impose on the raw-bytes CBOR fields.
let mut got: Vec<_> = decoded
.all_proofs()
.iter()
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
.collect();
got.sort();
let mut want: Vec<_> = token
.all_proofs()
.iter()
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
.collect();
want.sort();
assert_eq!(got, want);
}
#[test]
fn a_multi_mint_token_has_no_v4_form_and_says_so() {
// V4 is single-mint by construction. `send_token_at` relies on this
// failing (rather than silently dropping an entry) to fall back to
// cashuA — the proofs are already spent by the time it serializes.
let one = |mint: &str| TokenEntry {
mint: mint.to_string(),
proofs: vec![Proof {
amount: 1,
id: "009a1f293253e41e".to_string(),
secret: "s".to_string(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
}],
};
let token = CashuToken {
token: vec![one("https://mint-a.example"), one("https://mint-b.example")],
memo: None,
unit: Some("sat".to_string()),
};
let err = token
.serialize_v4()
.expect_err("two mints cannot be one V4 token");
assert!(err.to_string().contains("one mint per token"), "{err}");
// …and cashuA, the fallback, still carries it.
assert!(token.serialize().unwrap().starts_with("cashuA"));
}
#[test]
fn a_truncated_keyset_id_is_refused_by_the_v4_encoder() {
// The framework-pt case. A short v2 id is only resolvable against the
// mint's keyset list, so it must never be baked into a token we emit.
let token = CashuToken::new(
"https://mint.minibits.cash/Bitcoin",
vec![Proof {
amount: 1,
id: "01fc0ec0e59cd6fa".to_string(),
secret: "s".to_string(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
}],
);
let err = token.serialize_v4().expect_err("short id must not encode");
assert!(err.to_string().contains("keyset id"), "{err}");
}
#[test]
fn test_amount_to_denominations() {
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
@@ -602,7 +765,10 @@ mod tests {
let short = "01fc0ec0e59cd6fa";
let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
assert!(is_truncated_v2_keyset_id(short));
assert!(full.starts_with(short), "short form must prefix the full id");
assert!(
full.starts_with(short),
"short form must prefix the full id"
);
// It must survive token validation so the swap path can repair it,
// rather than being rejected as malformed.
assert!(validate_keyset_id(short).is_ok());
@@ -621,7 +787,10 @@ mod tests {
let err = validate_keyset_id("00112233445566778899")
.expect_err("9-byte keyset id must be rejected");
let msg = err.to_string();
assert!(msg.contains("10-byte") || msg.contains("unsupported keyset id"), "{msg}");
assert!(
msg.contains("10-byte") || msg.contains("unsupported keyset id"),
"{msg}"
);
// Non-hex ids (the original base64 keyset format) are named as such
// rather than reported as a length problem.
+284 -24
View File
@@ -6,6 +6,7 @@
use super::cashu::{amount_to_denominations, CashuToken, Proof};
use super::mint_client::MintClient;
use super::nut13::RecoverySource;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
@@ -396,9 +397,8 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
mints: vec![network.default_mint()],
}
} else {
serde_json::from_str(&content).with_context(|| {
format!("Accepted-mints file {} is damaged", path.display())
})?
serde_json::from_str(&content)
.with_context(|| format!("Accepted-mints file {} is damaged", path.display()))?
};
Ok(mints)
}
@@ -416,13 +416,26 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
Ok(())
}
/// Build a mint client whose proofs are **restorable from the wallet phrase**.
///
/// Every output such a client creates has its secret derived via NUT-13
/// (`wallet/nut13.rs`) rather than drawn from randomness, so the coins can be
/// re-derived and re-claimed if `wallet/ecash.json` is ever lost. That is the
/// only difference from `MintClient::new`, and it is the reason this wallet
/// has a backup story at all — so every mint/swap path in this module goes
/// through here. On a node with no phrase yet the source is absent and the
/// behaviour is exactly as it was before: valid proofs, no backup.
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await))
}
/// Request a mint quote — returns a Lightning invoice to pay.
pub async fn mint_quote(
data_dir: &Path,
amount_sats: u64,
) -> Result<super::mint_client::MintQuote> {
let wallet = load_wallet(data_dir).await?;
let client = MintClient::new(&wallet.mint_url)?;
let client = mint_client(data_dir, &wallet.mint_url).await?;
client.mint_quote(amount_sats).await
}
@@ -430,7 +443,7 @@ pub async fn mint_quote(
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let client = MintClient::new(&mint_url)?;
let client = mint_client(data_dir, &mint_url).await?;
let result = client.mint_tokens(quote_id, amount_sats).await?;
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
@@ -452,7 +465,7 @@ pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> R
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
let wallet = load_wallet(data_dir).await?;
let client = MintClient::new(&wallet.mint_url)?;
let client = mint_client(data_dir, &wallet.mint_url).await?;
client.melt_quote(bolt11).await
}
@@ -460,7 +473,7 @@ pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_cli
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let client = MintClient::new(&mint_url)?;
let client = mint_client(data_dir, &mint_url).await?;
// Get the melt quote to know the amount needed
let quote = client.melt_quote(bolt11).await?;
@@ -583,8 +596,8 @@ pub async fn swap_between_mints(
);
}
let from = MintClient::new(from_mint)?;
let to = MintClient::new(to_mint)?;
let from = mint_client(data_dir, from_mint).await?;
let to = mint_client(data_dir, to_mint).await?;
// 1. Mint quote on the target → invoice to pay.
let mint_quote = to
@@ -722,13 +735,13 @@ async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result
)
}
/// Create a cashuA token string to send to a peer, drawing from the home mint.
/// Create an ecash token string to send to a peer, drawing from the home mint.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
let mint_url = load_wallet(data_dir).await?.mint_url;
send_token_at(data_dir, &mint_url, amount_sats).await
}
/// Create a cashuA token denominated in a specific mint's tokens.
/// Create an ecash token denominated in a specific mint's tokens.
///
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
@@ -755,7 +768,7 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
// If there's overpayment, swap to get exact change
let send_proofs = if overpayment > 0 {
let client = MintClient::new(&mint_url)?;
let client = mint_client(data_dir, &mint_url).await?;
let send_denoms = amount_to_denominations(amount_sats);
let change_denoms = amount_to_denominations(overpayment);
@@ -804,9 +817,20 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
selected_proofs
};
// Serialize as cashuA token
// Emit cashuB (V4) — what Minibits, Nutstash and cdk-cli read by default.
// cashuA stays the fallback rather than the default: it is still valid and
// every wallet accepts it, so a token this wallet cannot express in V4 is
// worth sending in V3 rather than failing the send outright. The warning
// exists so that never happens silently — at this point in `send_token_at`
// the proofs are already marked spent.
let token = CashuToken::new(&mint_url, send_proofs);
let token_str = token.serialize()?;
let token_str = match token.serialize_v4() {
Ok(v4) => v4,
Err(e) => {
warn!("Falling back to a cashuA token — cashuB encoding failed: {e:#}");
token.serialize()?
}
};
wallet.record_tx(
TransactionType::Send,
@@ -898,7 +922,7 @@ fn plan_payment(
PaymentPlan::Insufficient
}
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
/// Build an ecash token to pay a seeder `amount_sats`, denominated in one of the
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
/// we don't already hold the right mint. Returns the token string ready to send.
///
@@ -1018,7 +1042,7 @@ pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
let pending = load_pending_swaps(data_dir).await?;
let mut reclaimed = 0u64;
for swap in pending {
let to = match MintClient::new(&swap.to_mint) {
let to = match mint_client(data_dir, &swap.to_mint).await {
Ok(c) => c,
Err(e) => {
warn!(
@@ -1151,7 +1175,7 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
.sum()
}
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Handle legacy format for backwards compatibility
if token_str.starts_with("cashuSend_") {
@@ -1184,7 +1208,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Swap proofs at each mint
for entry in &token.token {
let client = MintClient::new(&entry.mint)?;
let client = mint_client(data_dir, &entry.mint).await?;
match client.receive_token(&token).await {
Ok(new_proofs) => {
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
@@ -1300,7 +1324,7 @@ pub async fn verify_and_receive_payment(
return Ok(received);
}
// Parse and validate cashuA token
// Parse and validate the token (cashuA or cashuB)
let token = CashuToken::deserialize(token_str)?;
let total = token.total_amount();
@@ -1325,7 +1349,7 @@ pub async fn verify_and_receive_payment(
let mut received_total = 0u64;
for entry in &token.token {
let client = MintClient::new(&entry.mint)?;
let client = mint_client(data_dir, &entry.mint).await?;
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(entry_total);
@@ -1361,6 +1385,235 @@ pub async fn verify_and_receive_payment(
Ok(received_total)
}
// ── Restore from the NUT-13 phrase ─────────────────────────────────────────
/// How many counters to probe per `/v1/restore` call.
const RESTORE_BATCH: u32 = 100;
/// How many consecutive empty batches end a keyset's scan.
///
/// Counters are consumed in order but gaps happen: a reservation is persisted
/// before the mint call, so any failed mint or swap burns its counters. Three
/// empty batches is 300 unused counters in a row — far beyond any realistic
/// run of failures, while still terminating quickly on a fresh wallet.
const RESTORE_GAP_BATCHES: u32 = 3;
/// What a restore found.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct RestoreOutcome {
/// Sats recovered and added to the wallet.
pub recovered_sats: u64,
/// Proofs added.
pub recovered_proofs: usize,
/// Proofs the mint had signed but which are already spent — the wallet's
/// history, not its balance. Reported because "found nothing" and "found
/// only coins you already spent" mean very different things to someone
/// staring at an empty balance.
pub already_spent: usize,
/// Keysets scanned at the mint.
pub keysets_scanned: usize,
}
/// Rebuild this wallet's proofs from its NUT-13 phrase by asking a mint which
/// of the re-derived secrets it has signed.
///
/// This is the half of the backup that cannot be done offline. The phrase
/// re-derives every secret the wallet ever used, but a secret alone is not
/// money — the mint's signature over it is. `/v1/restore` returns those
/// signatures, and unblinding them reconstitutes the proofs.
///
/// Additive and idempotent by design: proofs already in the wallet are skipped
/// by secret, and anything the mint reports as spent is counted but not added.
/// So a restore can be run against a *working* wallet without duplicating
/// coins or resurrecting spent ones, which matters because the most likely
/// time to press this button is when something already looks wrong.
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
let recovery = RecoverySource::load(data_dir).await.ok_or_else(|| {
anyhow::anyhow!(
"This wallet has no backup phrase yet, so there is nothing to restore from. \
Set one up in Settings → Ecash backup phrase."
)
})?;
let client = MintClient::new(mint_url)?;
// Every keyset, not just the active one: coins signed by a retired keyset
// are still spendable, and skipping it would leave them behind.
let keysets: Vec<_> = client
.get_keysets()
.await
.context("Could not list the mint's keysets")?
.into_iter()
.collect();
let mut wallet = load_wallet(data_dir).await?;
let known_secrets: std::collections::HashSet<String> = wallet
.proofs
.iter()
.map(|p| p.proof.secret.clone())
.collect();
let mut outcome = RestoreOutcome::default();
let mut found: Vec<Proof> = Vec::new();
for keyset in &keysets {
// The mint's public keys for this keyset — needed to unblind.
let keys = match client.get_keyset(&keyset.id).await {
Ok(k) => k,
Err(e) => {
warn!("Skipping keyset {} during restore: {e:#}", keyset.id);
continue;
}
};
if !keys.unit.eq_ignore_ascii_case("sat") {
continue;
}
outcome.keysets_scanned += 1;
let mut counter = 0u32;
let mut empty_batches = 0u32;
let mut highest_seen: Option<u32> = None;
while empty_batches < RESTORE_GAP_BATCHES {
// Re-derive this batch's outputs. The amount is deliberately 0:
// the mint matches a restore on the blinded message `B_` alone and
// returns the true amount in its signature — we do not know what
// denomination each counter was used for, and guessing would be
// wrong for most of them.
let mut derived = Vec::with_capacity(RESTORE_BATCH as usize);
let mut outputs = Vec::with_capacity(RESTORE_BATCH as usize);
for i in 0..RESTORE_BATCH {
let n = counter + i;
let (secret, r) = match recovery.derive_at(&keyset.id, n) {
Ok(pair) => pair,
// A keyset id NUT-13 cannot address — nothing was ever
// derived for it, so there is nothing to find.
Err(e) => {
debug!("Cannot derive for keyset {}: {e:#}", keyset.id);
break;
}
};
let blinded = super::bdhke::blind_message(&secret, &r)?;
outputs.push(super::cashu::BlindedMessageRequest {
amount: 0,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
derived.push((n, secret, r, hex::encode(blinded.b_prime.serialize())));
}
if outputs.is_empty() {
break;
}
let restored = client.restore(&outputs).await?;
if restored.is_empty() {
empty_batches += 1;
counter += RESTORE_BATCH;
continue;
}
empty_batches = 0;
for (b_prime, sig) in restored {
let Some((n, secret, r, _)) = derived.iter().find(|(_, _, _, b)| *b == b_prime)
else {
warn!("Mint restored an output we did not send — ignoring");
continue;
};
let mint_key = match keys.key_for_amount(sig.amount) {
Ok(k) => k,
Err(e) => {
warn!(
"Restored a {} sat output with no matching key: {e:#}",
sig.amount
);
continue;
}
};
let c_prime = sig.c_prime_as_pubkey()?;
let c = super::bdhke::unblind_signature(&c_prime, r, &mint_key)?;
highest_seen = Some(highest_seen.map_or(*n, |h: u32| h.max(*n)));
let secret = String::from_utf8_lossy(secret).to_string();
if known_secrets.contains(&secret) {
continue; // already in the wallet
}
found.push(Proof {
amount: sig.amount,
id: keyset.id.clone(),
secret,
c: hex::encode(c.serialize()),
});
}
counter += RESTORE_BATCH;
}
// Never hand out a counter this keyset has already used. The scan may
// have found coins beyond where the counter file thought we were —
// reusing those would mint proofs that collide with existing ones.
if let Some(highest) = highest_seen {
if let Err(e) =
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1).await
{
warn!("Could not advance the NUT-13 counter after restore: {e:#}");
}
}
}
if found.is_empty() {
return Ok(outcome);
}
// Only unspent proofs are money. The mint signed every one of these at
// some point, including the ones already spent — adding those would
// inflate the balance with coins that fail on first use.
let states = client
.check_state(&found)
.await
.context("Could not check which restored coins are still unspent")?;
// NUT-07 answers in request order. Insist on that rather than assuming it:
// a mismatched length would pair a proof with someone else's verdict and
// credit spent coins as spendable.
if states.len() != found.len() {
anyhow::bail!(
"Mint returned {} proof states for {} restored coins — refusing to \
decide which are spendable",
states.len(),
found.len()
);
}
let mut keep = Vec::new();
for (proof, state) in found.iter().zip(states.iter()) {
if state.state.eq_ignore_ascii_case("UNSPENT") {
keep.push(proof.clone());
} else {
outcome.already_spent += 1;
}
}
outcome.recovered_sats = keep.iter().map(|p| p.amount).sum();
outcome.recovered_proofs = keep.len();
if !keep.is_empty() {
wallet.add_proofs(mint_url, keep);
wallet.record_tx(
TransactionType::Receive,
outcome.recovered_sats,
&format!(
"Restored {} sats from the backup phrase",
outcome.recovered_sats
),
mint_url,
"",
);
save_wallet(data_dir, &wallet).await?;
info!(
"Restored {} sats ({} proofs) from the ecash backup phrase",
outcome.recovered_sats, outcome.recovered_proofs
);
}
Ok(outcome)
}
/// Check the wallet balance.
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
let wallet = load_wallet(data_dir).await?;
@@ -2061,7 +2314,11 @@ mod tests {
// mint — never the real coins.
save_network(dir, EcashNetwork::Testnet).await.unwrap();
let test_wallet = load_wallet(dir).await.unwrap();
assert_eq!(test_wallet.balance(), 0, "test wallet must not see real coins");
assert_eq!(
test_wallet.balance(),
0,
"test wallet must not see real coins"
);
assert!(test_wallet.mint_url.contains("testnut"));
assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut"));
@@ -2090,7 +2347,6 @@ mod tests {
assert_eq!(back.proofs[0].proof.secret, "real");
}
#[tokio::test]
async fn a_damaged_wallet_file_fails_loudly_and_is_left_on_disk() {
let tmp = TempDir::new().unwrap();
@@ -2104,7 +2360,9 @@ mod tests {
// It must NOT read as an empty wallet: that is what caused the real
// balance to be overwritten with nothing on the next save.
let err = load_wallet(dir).await.expect_err("damaged wallet must error");
let err = load_wallet(dir)
.await
.expect_err("damaged wallet must error");
assert!(
err.to_string().contains("damaged"),
"error should name the problem: {err}"
@@ -2121,7 +2379,9 @@ mod tests {
std::fs::create_dir_all(dir.join("wallet")).unwrap();
std::fs::write(dir.join("wallet/ecash.json"), " \n").unwrap();
// A create that never got its first write is not damage.
let w = load_wallet(dir).await.expect("empty file is a fresh wallet");
let w = load_wallet(dir)
.await
.expect("empty file is a fresh wallet");
assert_eq!(w.balance(), 0);
}
+192 -35
View File
@@ -12,9 +12,11 @@ use super::cashu::{
amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest,
CashuToken, KeysetInfo, MintKeyset, Proof,
};
use super::nut13::RecoverySource;
use anyhow::{Context, Result};
use bitcoin::secp256k1;
use serde::{Deserialize, Serialize};
use tracing::debug;
use tracing::{debug, warn};
/// Default timeout for mint API calls.
const MINT_TIMEOUT_SECS: u64 = 10;
@@ -130,10 +132,19 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
pub struct MintClient {
url: String,
client: reqwest::Client,
/// NUT-13 output source. When set, every proof this client creates has a
/// secret derived from the wallet's phrase and is therefore restorable;
/// when absent, secrets are random and live only in `wallet/ecash.json`.
recovery: Option<RecoverySource>,
}
impl MintClient {
/// Create a new mint client for the given mint URL.
///
/// Proofs minted through a client built this way are **not** recoverable
/// from the wallet phrase. Prefer `ecash::mint_client`, which attaches the
/// NUT-13 source; this stays for callers with no data directory (probes,
/// keyset lookups, tests).
pub fn new(mint_url: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
@@ -143,6 +154,7 @@ impl MintClient {
Ok(Self {
url: mint_url.trim_end_matches('/').to_string(),
client,
recovery: None,
})
}
@@ -151,13 +163,70 @@ impl MintClient {
Self {
url: mint_url.trim_end_matches('/').to_string(),
client,
recovery: None,
}
}
/// Derive this client's blinded outputs from the wallet's NUT-13 phrase,
/// so the proofs it creates can be restored from those words.
pub fn with_recovery(mut self, recovery: Option<RecoverySource>) -> Self {
self.recovery = recovery;
self
}
pub fn url(&self) -> &str {
&self.url
}
/// Build the blinded messages for a batch of output amounts, together with
/// the `(secret, blinding factor, amount)` needed to unblind the mint's
/// signatures afterwards.
///
/// Prefers NUT-13 derivation so the resulting proofs are restorable. Falls
/// back to random secrets when this wallet has no phrase yet, or when the
/// keyset id is one NUT-13 cannot address — a random secret still mints a
/// perfectly valid, spendable proof, so refusing here would break the
/// wallet to protect a backup that does not exist.
async fn blinded_outputs(
&self,
keyset_id: &str,
amounts: &[u64],
) -> Result<(
Vec<BlindedMessageRequest>,
Vec<(Vec<u8>, secp256k1::SecretKey, u64)>,
)> {
let derived = match &self.recovery {
Some(source) => match source.next_outputs(keyset_id, amounts.len()).await {
Ok(pairs) => Some(pairs),
Err(e) => {
warn!("Minting unrecoverable proofs — NUT-13 derivation failed: {e:#}");
None
}
},
None => None,
};
let mut blinded_messages = Vec::with_capacity(amounts.len());
let mut blinding_data = Vec::with_capacity(amounts.len());
for (i, &amount) in amounts.iter().enumerate() {
let (secret, r) = match &derived {
Some(pairs) => pairs[i].clone(),
None => (bdhke::generate_secret(), bdhke::random_blinding_factor()),
};
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount,
id: keyset_id.to_string(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, amount));
}
Ok((blinded_messages, blinding_data))
}
// ── Keyset discovery (NUT-01, NUT-02) ──
/// Fetch the active keyset from the mint.
@@ -210,6 +279,36 @@ impl MintClient {
Ok(keysets)
}
/// Fetch one keyset's public keys by id (NUT-01 `GET /v1/keys/{id}`).
///
/// `/v1/keys` returns only what the mint will still *sign* with, but a
/// restore has to unblind signatures made by keysets that have since been
/// retired — those coins are still spendable, and skipping their keysets
/// would quietly leave money behind.
pub async fn get_keyset(&self, keyset_id: &str) -> Result<MintKeyset> {
let url = format!("{}/v1/keys/{}", self.url, keyset_id);
let res = self
.client
.get(&url)
.send()
.await
.context("Failed to fetch a mint keyset")?;
if !res.status().is_success() {
anyhow::bail!("Mint keyset request failed: {}", res.status());
}
let body: serde_json::Value = res.json().await.context("Failed to parse mint keyset")?;
let keysets: Vec<MintKeyset> = serde_json::from_value(
body.get("keysets")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse keyset")?;
keysets
.into_iter()
.find(|k| k.id == keyset_id)
.ok_or_else(|| anyhow::anyhow!("Mint did not return keyset {keyset_id}"))
}
/// Get the active keyset for the "sat" unit.
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
let keysets = self.get_keys().await?;
@@ -224,9 +323,7 @@ impl MintClient {
.filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat"))
// Prefer a keyset the mint will still sign with.
.max_by_key(|k| k.active)
.ok_or_else(|| {
anyhow::anyhow!("No active sat keyset found at mint {}", self.url)
})
.ok_or_else(|| anyhow::anyhow!("No active sat keyset found at mint {}", self.url))
}
// ── Mint quotes (NUT-04) ──
@@ -276,21 +373,8 @@ impl MintClient {
let keyset = self.get_active_sat_keyset().await?;
let denominations = amount_to_denominations(amount);
let mut blinded_messages = Vec::new();
let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount)
for &denom in &denominations {
let secret = bdhke::generate_secret();
let r = bdhke::random_blinding_factor();
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount: denom,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, denom));
}
let (blinded_messages, blinding_data) =
self.blinded_outputs(&keyset.id, &denominations).await?;
let url = format!("{}/v1/mint/bolt11", self.url);
let client = reqwest::Client::builder()
@@ -427,28 +511,17 @@ impl MintClient {
"The mint's fee ({fee} sat) consumes this whole amount — nothing would be left"
);
}
debug!("Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee");
debug!(
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
);
owned_targets = amount_to_denominations(spendable);
&owned_targets
} else {
target_amounts
};
let mut blinded_messages = Vec::new();
let mut blinding_data = Vec::new();
for &amount in target_amounts {
let secret = bdhke::generate_secret();
let r = bdhke::random_blinding_factor();
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, amount));
}
let (blinded_messages, blinding_data) =
self.blinded_outputs(&keyset.id, target_amounts).await?;
let url = format!("{}/v1/swap", self.url);
let res = self
@@ -543,6 +616,90 @@ impl MintClient {
Ok(states)
}
// ── Restore (NUT-09) ──
/// Ask the mint which of a batch of blinded messages it has signed before,
/// and hand back its signatures for those.
///
/// This is the half of the backup story the mint owns. A NUT-13 phrase can
/// re-derive every secret this wallet ever used, but not the mint's
/// signature over them — without that a re-derived secret is not yet money.
/// `/v1/restore` closes the gap: send the blinded messages again, get back
/// the signatures the mint already issued, unblind, and the proofs exist
/// again.
///
/// The response echoes the subset of `outputs` it recognised alongside the
/// matching `signatures`, so the caller matches on `B_` rather than
/// assuming positions line up — mints are free to return fewer, and
/// assuming otherwise would pair a signature with the wrong secret and
/// silently produce unspendable proofs.
pub async fn restore(
&self,
outputs: &[BlindedMessageRequest],
) -> Result<Vec<(String, BlindSignature)>> {
if outputs.is_empty() {
return Ok(Vec::new());
}
let url = format!("{}/v1/restore", self.url);
let res = self
.client
.post(&url)
.json(&serde_json::json!({ "outputs": outputs }))
.send()
.await
.context("Failed to ask the mint to restore outputs")?;
if !res.status().is_success() {
let status = res.status();
// NUT-09 is optional. A mint that never implemented it answers 404
// or 405, which `mint_error` would render as "mint returned 404
// with no further detail" — true, and useless to someone trying to
// get their coins back. Name the actual limitation instead.
if matches!(status.as_u16(), 404 | 405 | 501) {
anyhow::bail!(
"This mint does not support restoring from a backup phrase (NUT-09). \
Your coins are safe, but they can only be recovered from a wallet \
file backup while they stay at {}",
self.url
);
}
let body = res.text().await.unwrap_or_default();
return Err(mint_error("Restore", status, &body));
}
let body: serde_json::Value = res
.json()
.await
.context("Failed to parse the mint's restore response")?;
let echoed: Vec<BlindedMessageRequest> = serde_json::from_value(
body.get("outputs")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse restored outputs")?;
let signatures: Vec<BlindSignature> = serde_json::from_value(
body.get("signatures")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse restored signatures")?;
if echoed.len() != signatures.len() {
anyhow::bail!(
"Mint restored {} outputs but {} signatures — refusing to pair them",
echoed.len(),
signatures.len()
);
}
Ok(echoed
.into_iter()
.map(|o| o.b_prime)
.zip(signatures)
.collect())
}
/// Receive a CashuToken by swapping its proofs for fresh ones.
/// This prevents double-spend and ensures only we can spend the new proofs.
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
+1
View File
@@ -7,4 +7,5 @@ pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod mint_client;
pub mod nut13;
pub mod profits;
+785
View File
@@ -0,0 +1,785 @@
//! NUT-13 deterministic secrets — what makes the ecash wallet restorable.
//!
//! Until this module existed, every Cashu proof this node held was backed by a
//! secret drawn from `OsRng` and written to exactly one file. Losing
//! `wallet/ecash.json` lost the coins outright: there was no phrase to write
//! down, and no amount of talking to the mint could reconstruct them. Ecash is
//! a bearer instrument, so "one file, no backup" was the sharpest edge in the
//! wallet.
//!
//! [NUT-13] fixes that by deriving each proof's secret and blinding factor
//! from `(wallet seed, keyset id, counter)` instead of from randomness. The
//! wallet is then a *phrase*, and the coins can be re-derived and re-claimed
//! from the mint — here, or in any other NUT-13 wallet.
//!
//! Three pieces live here:
//!
//! - **The wallet seed** (`wallet/cashu_seed.json`) — a 24-word BIP-39
//! mnemonic derived from the node's master seed, so the node's own recovery
//! phrase already covers the ecash. See [`crate::seed::derive_cashu_mnemonic`]
//! for why it is a *separate* phrase rather than the node's own.
//! - **The counters** (`wallet/cashu_counters.json`) — the next unused counter
//! per keyset. Recovery metadata, not funds: losing it costs a restore scan,
//! never coins.
//! - **The derivation itself** — delegated to the reference implementation, so
//! the secrets a third-party wallet re-derives from these words are the same
//! ones we did.
//!
//! ## Why the seed sits on disk in the clear
//!
//! The node's master seed is encrypted at rest and needs the operator's
//! password to open, which no background mint/swap can ask for. This file is
//! not encrypted, and that is deliberate: it lives in the same directory as
//! `wallet/ecash.json`, which already holds spendable bearer secrets in
//! plaintext. A NUT-13 seed regenerates exactly those same secrets, so it is
//! the same sensitivity class as the file beside it — encrypting one and not
//! the other would buy nothing. It is written 0600, matching
//! `identity/nostr_secret`, which is derived and persisted the same way.
//!
//! [NUT-13]: https://github.com/cashubtc/nuts/blob/main/13.md
use anyhow::{Context, Result};
use bitcoin::secp256k1::SecretKey;
use cashu::nuts::nut01::SecretKey as CdkSecretKey;
use cashu::nuts::nut02::Id as CdkId;
use cashu::secret::Secret as CdkSecret;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tokio::fs;
use tracing::{debug, warn};
/// The wallet's BIP-39 phrase. One file for both networks: NUT-13 derivation
/// is keyed by keyset id, and a testnet mint's keysets never collide with a
/// real mint's, so the two purses cannot derive each other's secrets.
const SEED_FILE: &str = "wallet/cashu_seed.json";
/// Next-unused counter per keyset.
const COUNTER_FILE: &str = "wallet/cashu_counters.json";
/// Serialises counter reservation within this process. Reservation is a
/// read-modify-write of one small file, and two concurrent mints handing out
/// the same counter would mean two proofs with the same secret — the mint
/// signs both and only one is ever spendable.
static COUNTER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// On-disk shape of `wallet/cashu_seed.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredSeed {
/// The 24-word BIP-39 phrase.
mnemonic: String,
/// How this wallet got its phrase — see [`SeedSource`].
#[serde(default)]
source: SeedSource,
/// When it was first written, for the operator's benefit.
#[serde(default)]
created_at: String,
}
/// Where an ecash wallet's phrase came from, which decides what restoring the
/// *node* gets you back.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeedSource {
/// Derived from the node's master seed. The node's 24 words restore this
/// ecash wallet too — nothing extra to write down.
#[default]
NodeSeed,
/// Generated independently of the node seed. Still a perfectly good
/// NUT-13 wallet, but restoring the node from its recovery phrase will
/// *not* bring it back — only these words will.
Independent,
/// Supplied by the operator, from another NUT-13 wallet. Same caveat as
/// `Independent` — the node's recovery phrase does not cover it — but it
/// is worth telling apart, because these words exist somewhere else too
/// and the operator already knows where.
Imported,
}
impl SeedSource {
/// Does restoring the *node* from its recovery phrase bring this wallet
/// back? Only a derived phrase can promise that.
pub fn covered_by_node_seed(&self) -> bool {
matches!(self, Self::NodeSeed)
}
}
/// A loaded ecash wallet seed, ready to derive secrets from.
#[derive(Clone)]
pub struct EcashSeed {
/// BIP-39 seed bytes — the NUT-13 input.
seed: [u8; 64],
mnemonic: bip39::Mnemonic,
source: SeedSource,
}
impl std::fmt::Debug for EcashSeed {
/// Never let the phrase or the seed bytes reach a log line.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EcashSeed")
.field("source", &self.source)
.finish_non_exhaustive()
}
}
impl EcashSeed {
fn from_mnemonic(mnemonic: bip39::Mnemonic, source: SeedSource) -> Self {
Self {
seed: mnemonic.to_seed(""),
mnemonic,
source,
}
}
/// The 24 words, for the backup screen. Everything else about this type
/// keeps them out of reach.
pub fn words(&self) -> Vec<String> {
self.mnemonic.words().map(|w| w.to_string()).collect()
}
pub fn source(&self) -> SeedSource {
self.source
}
/// Derive the NUT-13 secret and blinding factor for one output.
///
/// Delegated to the reference implementation rather than reimplemented:
/// NUT-13 uses BIP-32 for v1 keyset ids and an HMAC-SHA256 KDF for v2, and
/// getting either subtly wrong yields a wallet whose words restore
/// *nothing* — a failure that only shows up on the day it matters.
pub fn derive_output(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
let id = CdkId::from_str(keyset_id)
.with_context(|| format!("Keyset id {keyset_id} is not one NUT-13 can derive for"))?;
let secret = CdkSecret::from_seed(&self.seed, id, counter)
.context("NUT-13 secret derivation failed")?;
let blinding = CdkSecretKey::from_seed(&self.seed, id, counter)
.context("NUT-13 blinding-factor derivation failed")?;
let blinding = SecretKey::from_slice(&blinding.to_secret_bytes())
.context("NUT-13 produced a blinding factor secp256k1 rejects")?;
Ok((secret.to_bytes(), blinding))
}
}
impl Drop for EcashSeed {
fn drop(&mut self) {
use zeroize::Zeroize;
self.seed.zeroize();
}
}
fn seed_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_FILE)
}
/// Is this wallet backed by a phrase yet?
pub fn seed_exists(data_dir: &Path) -> bool {
seed_path(data_dir).exists()
}
/// Load the wallet seed, or `None` if this node has never established one.
///
/// A *damaged* seed file is an error, not a `None`: silently treating it as
/// "no seed" would send the wallet back to unrecoverable random secrets while
/// telling the operator their backup was fine.
pub async fn load_seed(data_dir: &Path) -> Result<Option<EcashSeed>> {
let path = seed_path(data_dir);
let Ok(content) = fs::read_to_string(&path).await else {
return Ok(None);
};
let stored: StoredSeed = serde_json::from_str(&content)
.with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?;
let mnemonic: bip39::Mnemonic = stored
.mnemonic
.parse()
.map_err(|e| anyhow::anyhow!("The stored ecash phrase is not valid BIP-39: {e}"))?;
Ok(Some(EcashSeed::from_mnemonic(mnemonic, stored.source)))
}
/// Establish the wallet seed from the node's master seed, writing it if this
/// node does not have one yet.
///
/// Idempotent, and deliberately **never overwrites**: an existing phrase is
/// the only thing that can re-derive the proofs already minted under it, so a
/// re-derivation that disagreed (a different master seed after a restore from
/// different words, say) must not be allowed to replace it. The existing seed
/// is returned instead, and the mismatch is logged.
pub async fn establish_from_master(
data_dir: &Path,
master: &crate::seed::MasterSeed,
) -> Result<EcashSeed> {
let derived = crate::seed::derive_cashu_mnemonic(master)?;
if let Some(existing) = load_seed(data_dir).await? {
if existing.mnemonic != derived {
warn!(
"The ecash wallet's phrase does not match the one this node's master seed \
derives — keeping the existing phrase, because it is what the current \
proofs were minted under. Back it up from Settings; the node's own \
recovery phrase does not cover this wallet."
);
}
return Ok(existing);
}
write_seed(data_dir, &derived, SeedSource::NodeSeed).await?;
debug!("Established the ecash wallet seed from the node master seed");
Ok(EcashSeed::from_mnemonic(derived, SeedSource::NodeSeed))
}
/// Establish a wallet seed that is **not** derived from the node's master
/// seed, for a node that has no encrypted master seed to derive from.
///
/// Plenty of nodes are in that position: `identity/master_seed.enc` is written
/// during onboarding, and any node onboarded before that step existed simply
/// does not have one. The choice there is not "derived phrase or independent
/// phrase" — it is "independent phrase or **no backup at all**", and a wallet
/// whose coins can be restored from words the operator holds is strictly
/// better than one whose coins die with a single file.
///
/// The cost is stated plainly rather than hidden: the phrase is recorded as
/// [`SeedSource::Independent`], and every surface that shows it says that
/// restoring the node will *not* bring this wallet back — only these words
/// will. That is a real obligation on the operator, so it must never be the
/// silent default when derivation was possible; [`establish_from_master`] is
/// what a node with a master seed gets.
pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
if let Some(existing) = load_seed(data_dir).await? {
return Ok(existing);
}
// Same guarded generation path as the node's own seed: a named CSPRNG and
// the degenerate-entropy check, not a dependency's default (KEY-05).
let (mnemonic, _seed) = crate::seed::MasterSeed::generate()?;
write_seed(data_dir, &mnemonic, SeedSource::Independent).await?;
warn!(
"Established an INDEPENDENT ecash backup phrase: this node has no encrypted \
master seed to derive one from, so restoring the node will not restore this \
ecash wallet — only the phrase itself will."
);
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Independent))
}
/// Adopt a phrase the operator supplies, from another NUT-13 wallet.
///
/// This is the "bring your own" path: it points the wallet at someone else's
/// derivation, which is what makes coins held in Minibits, Nutstash or
/// `cdk-cli` restorable here.
///
/// Replacing a phrase is the one genuinely lossy thing this module can do.
/// Coins already in `wallet/ecash.json` stay spendable — they are proofs, not
/// derivations, and nothing here touches them — but they were minted under
/// the *old* phrase, so a future restore will no longer find them. The old
/// phrase is therefore archived rather than overwritten, and replacing an
/// established one needs `confirm`. An operator who imports by mistake must
/// not lose the only copy of the words their balance was minted under.
///
/// Counters are deliberately left alone. They are per-keyset and seed-
/// relative, so under a new seed they merely start high — which costs nothing,
/// since a restore scans from zero regardless. Resetting them would be the
/// dangerous choice if the imported phrase turned out to be the one already
/// in use.
pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> {
let mnemonic: bip39::Mnemonic = words
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.parse()
.map_err(|e| {
anyhow::anyhow!(
"That is not a valid BIP-39 recovery phrase: {e}. Check for typos — \
every word must come from the BIP-39 word list, and the phrase as \
a whole carries a checksum."
)
})?;
if let Some(existing) = load_seed(data_dir).await? {
if existing.mnemonic == mnemonic {
// Importing the phrase already in use: nothing to do, and
// certainly nothing to archive.
return Ok(existing);
}
if !confirm {
anyhow::bail!(
"This wallet already has a backup phrase. Importing a different one \
means coins minted under the current phrase will no longer be \
restorable from words — they stay spendable, but a restore will \
not find them. Reveal and write down the current phrase first, \
then confirm to replace it."
);
}
archive_seed(data_dir).await?;
}
write_seed(data_dir, &mnemonic, SeedSource::Imported).await?;
warn!("Ecash backup phrase REPLACED by an imported one (the previous phrase, if any, was archived)");
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported))
}
/// Move the current seed file aside, timestamped, before it is replaced.
///
/// Never deleted and never overwritten: this file may be the last copy of the
/// words a balance was minted under, and the whole point of the module is that
/// such a thing is not casually destroyed.
async fn archive_seed(data_dir: &Path) -> Result<()> {
let from = seed_path(data_dir);
if !from.exists() {
return Ok(());
}
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json"));
fs::rename(&from, &to).await.with_context(|| {
format!(
"Could not archive the previous ecash phrase to {}",
to.display()
)
})?;
warn!("Previous ecash phrase archived to {}", to.display());
Ok(())
}
/// Write the seed file at 0600, creating the wallet directory if needed.
async fn write_seed(data_dir: &Path, mnemonic: &bip39::Mnemonic, source: SeedSource) -> Result<()> {
let path = seed_path(data_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create the wallet directory")?;
}
let stored = StoredSeed {
mnemonic: mnemonic.to_string(),
source,
created_at: chrono::Utc::now().to_rfc3339(),
};
let content =
serde_json::to_string_pretty(&stored).context("Failed to serialize the ecash seed")?;
fs::write(&path, content)
.await
.context("Failed to write the ecash seed")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to restrict permissions on the ecash seed")?;
}
Ok(())
}
// ── Counters ───────────────────────────────────────────────────────────────
/// On-disk shape of `wallet/cashu_counters.json`.
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoredCounters {
/// keyset id → next unused counter.
#[serde(default)]
counters: BTreeMap<String, u32>,
}
/// Reserve `count` consecutive counters for `keyset_id` and return the first.
///
/// Written to disk **before** the outputs are used, and never rolled back on
/// failure. A gap in the sequence costs a restore scan a few extra probes; a
/// *reused* counter costs a coin, because two proofs with the same secret can
/// only ever be spent once. So the asymmetry is resolved in favour of gaps.
pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) -> Result<u32> {
let _guard = COUNTER_LOCK.lock().await;
let path = data_dir.join(COUNTER_FILE);
let mut state: StoredCounters = match fs::read_to_string(&path).await {
Ok(content) if !content.trim().is_empty() => serde_json::from_str(&content)
.with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?,
_ => StoredCounters::default(),
};
let start = *state.counters.get(keyset_id).unwrap_or(&0);
let next = start
.checked_add(u32::try_from(count).context("Absurd output count")?)
.context("NUT-13 counter space exhausted for this keyset")?;
state.counters.insert(keyset_id.to_string(), next);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create the wallet directory")?;
}
let content =
serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?;
fs::write(&path, content)
.await
.context("Failed to persist ecash counters")?;
Ok(start)
}
/// Read the next-unused counter for a keyset without reserving anything.
pub async fn counter_for(data_dir: &Path, keyset_id: &str) -> u32 {
let path = data_dir.join(COUNTER_FILE);
let Ok(content) = fs::read_to_string(&path).await else {
return 0;
};
serde_json::from_str::<StoredCounters>(&content)
.ok()
.and_then(|s| s.counters.get(keyset_id).copied())
.unwrap_or(0)
}
/// Move a keyset's counter forward to at least `next`, so a restore that found
/// coins beyond the recorded point cannot hand the same counters out again.
pub async fn advance_counter_to(data_dir: &Path, keyset_id: &str, next: u32) -> Result<()> {
let current = counter_for(data_dir, keyset_id).await;
if next > current {
reserve_counters(data_dir, keyset_id, (next - current) as usize).await?;
}
Ok(())
}
// ── The source handed to the mint client ───────────────────────────────────
/// Supplies NUT-13 outputs to [`crate::wallet::mint_client::MintClient`].
///
/// Holds the data directory as well as the seed because reserving a counter is
/// a disk write that has to happen before the outputs are handed out.
#[derive(Clone, Debug)]
pub struct RecoverySource {
seed: EcashSeed,
data_dir: PathBuf,
}
impl RecoverySource {
/// Build a recovery source for this node, or `None` when the wallet has no
/// seed yet. Callers fall back to random secrets in that case, which is
/// exactly the pre-NUT-13 behaviour — correct, just not restorable.
pub async fn load(data_dir: &Path) -> Option<Self> {
match load_seed(data_dir).await {
Ok(Some(seed)) => Some(Self {
seed,
data_dir: data_dir.to_path_buf(),
}),
Ok(None) => None,
Err(e) => {
warn!("Ecash wallet seed unusable, minting unrecoverable proofs: {e:#}");
None
}
}
}
/// Reserve and derive `count` outputs for `keyset_id`.
pub async fn next_outputs(
&self,
keyset_id: &str,
count: usize,
) -> Result<Vec<(Vec<u8>, SecretKey)>> {
// Fail the derivation *before* burning counters if this keyset id is
// one NUT-13 cannot address.
let start = reserve_counters(&self.data_dir, keyset_id, count).await?;
(0..count)
.map(|i| self.seed.derive_output(keyset_id, start + i as u32))
.collect()
}
/// Derive one output at an explicit counter, without reserving — the
/// restore scan's probe, which must be able to re-derive the past.
pub fn derive_at(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
self.seed.derive_output(keyset_id, counter)
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::MasterSeed;
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
/// A real NUT-02 v1 keyset id (the one in the NUT test vectors).
const V1_KEYSET: &str = "009a1f293253e41e";
/// A NUT-02 v2 keyset id — 33 bytes, version byte 0x01. The two versions
/// take different derivation paths in the spec, so both need covering.
const V2_KEYSET: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
fn seed() -> EcashSeed {
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let mnemonic = crate::seed::derive_cashu_mnemonic(&master).unwrap();
EcashSeed::from_mnemonic(mnemonic, SeedSource::NodeSeed)
}
/// The whole promise of NUT-13: the same phrase and counter must give back
/// the same secret, or a restore finds nothing.
#[test]
fn the_same_phrase_and_counter_rederive_the_same_output() {
let a = seed();
let b = seed();
for keyset in [V1_KEYSET, V2_KEYSET] {
let (s1, r1) = a.derive_output(keyset, 7).unwrap();
let (s2, r2) = b.derive_output(keyset, 7).unwrap();
assert_eq!(s1, s2, "secret must be reproducible ({keyset})");
assert_eq!(
r1.secret_bytes(),
r2.secret_bytes(),
"blinding factor must be reproducible ({keyset})"
);
}
}
/// Different counters — and different keysets — must not collide, or two
/// proofs would share a secret and only one could ever be spent.
#[test]
fn different_counters_and_keysets_give_different_outputs() {
let s = seed();
let (a, _) = s.derive_output(V1_KEYSET, 0).unwrap();
let (b, _) = s.derive_output(V1_KEYSET, 1).unwrap();
let (c, _) = s.derive_output(V2_KEYSET, 0).unwrap();
assert_ne!(a, b, "counter must separate secrets");
assert_ne!(a, c, "keyset must separate secrets");
}
/// The secret must look like the one the rest of the wallet expects: a
/// 32-byte value, hex-encoded, carried as ASCII bytes — the same shape
/// `bdhke::generate_secret` produces.
#[test]
fn a_derived_secret_has_the_shape_the_wallet_already_uses() {
let (secret, _) = seed().derive_output(V1_KEYSET, 0).unwrap();
assert_eq!(secret.len(), 64, "32 bytes, hex-encoded");
let text = String::from_utf8(secret).expect("secret must be ASCII hex");
assert!(hex::decode(&text).is_ok(), "{text}");
}
/// A truncated v2 id cannot address a keyset, and must fail loudly rather
/// than deriving from a prefix that means nothing.
#[test]
fn an_unaddressable_keyset_id_is_refused() {
let err = seed()
.derive_output("01fc0ec0e59cd6fa", 0)
.expect_err("short v2 id must not derive");
assert!(err.to_string().contains("NUT-13"), "{err}");
}
#[tokio::test]
async fn counters_are_reserved_in_order_and_never_reused() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
assert_eq!(reserve_counters(d, V1_KEYSET, 3).await.unwrap(), 0);
assert_eq!(reserve_counters(d, V1_KEYSET, 2).await.unwrap(), 3);
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
// A second keyset counts independently.
assert_eq!(reserve_counters(d, V2_KEYSET, 1).await.unwrap(), 0);
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
}
/// Reservation must survive a process restart — the file is the state.
#[tokio::test]
async fn reserved_counters_persist_across_reloads() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
reserve_counters(d, V1_KEYSET, 4).await.unwrap();
// Nothing cached in memory: read it back cold.
assert_eq!(counter_for(d, V1_KEYSET).await, 4);
assert_eq!(reserve_counters(d, V1_KEYSET, 1).await.unwrap(), 4);
}
#[tokio::test]
async fn establishing_the_seed_is_idempotent_and_never_overwrites() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
assert!(!seed_exists(d));
let first = establish_from_master(d, &master).await.unwrap();
assert!(seed_exists(d));
assert_eq!(first.source(), SeedSource::NodeSeed);
let second = establish_from_master(d, &master).await.unwrap();
assert_eq!(first.words(), second.words());
// A *different* master seed must not replace the phrase the existing
// proofs were minted under.
let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_master) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
let third = establish_from_master(d, &other_master).await.unwrap();
assert_eq!(
first.words(),
third.words(),
"an established ecash phrase must never be silently replaced"
);
}
/// A phrase from another wallet must derive that wallet's secrets — that
/// is the entire point of importing one.
#[tokio::test]
async fn an_imported_phrase_derives_the_other_wallets_secrets() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Stand in for the other wallet: a known phrase and what it derives.
let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap();
let expected = EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported)
.derive_output(V1_KEYSET, 3)
.unwrap();
let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(imported.source(), SeedSource::Imported);
assert!(!imported.source().covered_by_node_seed());
assert_eq!(imported.derive_output(V1_KEYSET, 3).unwrap().0, expected.0);
// And it is what the wallet uses from now on.
let reloaded = load_seed(d).await.unwrap().expect("persisted");
assert_eq!(reloaded.words(), imported.words());
}
#[tokio::test]
async fn importing_over_an_established_phrase_needs_confirmation() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let original = establish_from_master(d, &master).await.unwrap();
let original_words = original.words();
// Refused without confirmation — replacing a phrase silently would
// orphan every coin minted under it.
let (other, _) = MasterSeed::generate().unwrap();
let err = import_mnemonic(d, &other.to_string(), false)
.await
.expect_err("must not replace without confirmation");
assert!(
err.to_string().contains("already has a backup phrase"),
"{err}"
);
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
original_words,
"a refused import must change nothing"
);
// Confirmed: replaced, and the old phrase archived rather than lost.
import_mnemonic(d, &other.to_string(), true).await.unwrap();
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
other.words().map(|w| w.to_string()).collect::<Vec<_>>()
);
let archived: Vec<_> = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.collect();
assert_eq!(archived.len(), 1, "the replaced phrase must be kept");
}
/// Re-importing the phrase already in use is a no-op, not a replacement —
/// it must not archive anything or churn the file.
#[tokio::test]
async fn importing_the_current_phrase_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let first = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
let again = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(first.words(), again.words());
let archived = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.count();
assert_eq!(archived, 0);
}
#[tokio::test]
async fn a_malformed_phrase_is_refused_with_something_actionable() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Right shape, wrong checksum — the commonest real mistake.
let bad = TEST_MNEMONIC.replace(" art", " abandon");
let err = import_mnemonic(d, &bad, false).await.expect_err("checksum");
assert!(err.to_string().contains("BIP-39"), "{err}");
assert!(!seed_exists(d), "a rejected phrase must not be written");
assert!(import_mnemonic(d, "not a phrase", false).await.is_err());
assert!(import_mnemonic(d, "", false).await.is_err());
}
/// Whitespace and casing vary wildly in what people paste out of other
/// wallets; the words are what matter.
#[tokio::test]
async fn a_pasted_phrase_survives_untidy_whitespace() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let messy = format!(" {} ", TEST_MNEMONIC.replace(' ', "\n "));
let imported = import_mnemonic(d, &messy, false).await.unwrap();
assert_eq!(imported.words().len(), 24);
assert_eq!(imported.words().join(" "), TEST_MNEMONIC);
}
#[tokio::test]
async fn the_seed_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
establish_from_master(d, &master).await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(seed_path(d))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only");
}
}
/// A damaged seed file must not read back as "this wallet has no backup" —
/// that would quietly return the wallet to unrecoverable random secrets.
#[tokio::test]
async fn a_damaged_seed_file_is_an_error_not_an_absence() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
fs::create_dir_all(d.join("wallet")).await.unwrap();
fs::write(seed_path(d), "{ truncated").await.unwrap();
assert!(load_seed(d).await.is_err());
assert!(
RecoverySource::load(d).await.is_none(),
"an unusable seed must not be presented as a working one"
);
}
#[tokio::test]
async fn the_recovery_source_hands_out_consecutive_outputs() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
establish_from_master(d, &master).await.unwrap();
let source = RecoverySource::load(d).await.expect("seed was established");
let first = source.next_outputs(V1_KEYSET, 2).await.unwrap();
let second = source.next_outputs(V1_KEYSET, 2).await.unwrap();
assert_eq!(first.len(), 2);
// Counters advanced, so no secret repeats across the two batches.
let secrets: std::collections::HashSet<_> = first
.iter()
.chain(second.iter())
.map(|(s, _)| s.clone())
.collect();
assert_eq!(secrets.len(), 4, "counters must not be handed out twice");
// And the batch is exactly what re-deriving counters 0..4 gives.
for (i, (secret, _)) in first.iter().chain(second.iter()).enumerate() {
let (expected, _) = source.derive_at(V1_KEYSET, i as u32).unwrap();
assert_eq!(secret, &expected);
}
}
}
+10 -1
View File
@@ -1746,6 +1746,15 @@ app:
}
}
exempt.sort();
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
// upstream's own safe-for-public
// subset that wallets connect to directly as a "remote node" over
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
// cuprate's unrestricted RPC (full node control) stays loopback-only
// (auth: local), not in this set.
//
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
// loopback-only JSON API whose own generated http password
// authenticates every request (added with the phoenixd onboarding,
@@ -1762,7 +1771,7 @@ app:
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
26,
28,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
+4 -2
View File
@@ -29,8 +29,10 @@ impl Router {
.with_context(|| format!("no address for {}", addr))?;
let tcp = TcpStream::connect_timeout(&resolved, std::time::Duration::from_secs(5))
.with_context(|| format!("TCP connect to {}", addr))?;
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30))).ok();
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30)))
.ok();
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30)))
.ok();
Ok(tcp)
}
+45 -6
View File
@@ -190,6 +190,16 @@
.text-white-40 { color: rgba(255,255,255,0.4); }
.text-green { color: #4ade80; } .text-orange { color: #fb923c; } .text-red { color: #f87171; }
.text-purple { color: #a78bfa; } .text-yellow { color: #facc15; } .text-btc { color: #f7931a; }
/* Pixel readout shown in place of a balance that isn't known yet.
An unloaded balance used to render as "0 sats" — zero is a number,
not a loading state, and it is the one number that frightens
people. It inherits currentColor, so each tile shimmers in its own
rail colour. */
.bal-pixels { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(3, 4px); grid-auto-columns: 4px; gap: 1px; vertical-align: 0.1em; }
.bal-pixels i { width: 4px; height: 4px; border-radius: 0.5px; background: currentColor; opacity: 0.16; animation: bal-pixel-scan 1.6s ease-in-out infinite; }
@keyframes bal-pixel-scan { 0%, 70%, 100% { opacity: 0.16; } 25% { opacity: 1; } 45% { opacity: 0.42; } }
@media (prefers-reduced-motion: reduce) { .bal-pixels i { animation: none; opacity: 0.35; } }
.bg-green { background: #4ade80; } .bg-yellow { background: #facc15; } .bg-red { background: #f87171; }
.bg-grey { background: rgba(255,255,255,0.35); }
@@ -1070,6 +1080,25 @@
}
function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
// 28 cells = 14 columns x 2 rows, delays staggered so the lit column
// travels across the matrix.
// 14 columns x 3 rows, laid out column-first so the three cells of a
// column share a delay and the lit column scans across as one line.
const BAL_PIXELS = '<span class="bal-pixels" role="status" aria-label="Loading balance">' +
Array.from({ length: 42 }, function (_, i) {
return '<i style="animation-delay:' + (Math.floor(i / 3) * 55) + 'ms"></i>';
}).join('') + '</span>';
// Render a balance, or the pixel readout when it is not known yet.
// `sats` must be null/undefined for "not loaded" — passing 0 here
// means the node genuinely has nothing, and says so.
function setBalance(id, sats) {
const el = document.getElementById(id);
if (!el) return;
if (sats === null || sats === undefined) { el.innerHTML = BAL_PIXELS; return; }
el.textContent = fmtAmount(sats);
}
function setHtml(id, html) { const el = document.getElementById(id); if (el) el.innerHTML = html; }
// Classify a peer address the way Umbrel's peers table does.
@@ -1216,12 +1245,22 @@
const lnRemote = num(cb && ((cb.remote_balance && cb.remote_balance.sat) ?? 0));
const lnPending = num(cb && ((cb.pending_open_local_balance && cb.pending_open_local_balance.sat) ?? 0));
setText('balTotal', fmtAmount(onchainConfirmed + lnLocal));
setText('balTotalSub', state.onchain || cb ? 'on-chain + lightning' : 'balances unavailable');
setText('balLightning', fmtAmount(lnLocal));
setText('balLightningSub', lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setText('balOnchain', fmtAmount(onchainConfirmed));
setText('balOnchainSub', onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
// This function runs on every poll, including before the first
// response lands — at which point `state.onchain`/`state.chanbal`
// are still null and every figure below computed to 0. The tiles
// therefore claimed a zero balance on load. A rail with no data
// yet gets the pixel readout instead.
const haveOnchain = !!state.onchain;
const haveChan = !!cb;
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
setBalance('balLightning', haveChan ? lnLocal : null);
setText('balLightningSub', !haveChan ? 'waiting for LND'
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
setText('liqLocal', fmtAmount(lnLocal));
setText('liqRemote', fmtAmount(lnRemote));
+40
View File
@@ -17,6 +17,45 @@ orchestrator code rather than a per-app installer, but it is not
manifest-declared, and the direction of travel is to replace each case with a
reusable manifest primitive.
## Upstream tracking
A node only offers an app update when the signed catalog pins a newer image
than the one running. That works — but nothing was telling *us* when upstream
had shipped something new, because a manifest records only our mirror
(`source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0`), which says
nothing about the project it was mirrored from. So a pin could sit still for
months while every node in the fleet correctly reported "up to date".
`upstream` closes that loop. It is metadata for the release process, never
read by the orchestrator:
```yaml
app:
id: fedimint
version: 0.10.0
upstream:
kind: github # github | dockerhub | internal | manual
repo: fedimint/fedimint
```
| `kind` | Meaning | Needs |
|--------|---------|-------|
| `github` | Watch a project's releases, then its tags. | `repo: owner/name` |
| `dockerhub` | Watch a Docker Hub repository's tags. | `repo: namespace/name` |
| `internal` | Built by this project — there is no upstream feed. | — |
| `manual` | Has releases, but not anywhere machine-readable. | `url:` for a human |
`scripts/check-upstream-releases.py` reads these and prints what is behind;
it exits non-zero when anything tracked has fallen behind, so a release pass
can gate on it. Export `GITHUB_TOKEN` first — a full sweep needs more than
GitHub's 60-per-hour anonymous quota.
An app with **no** `upstream` block is reported as `UNTRACKED` rather than
skipped: silently skipping unknowns is exactly how this gap stayed invisible.
Leaving it out is therefore fine and honest; guessing a wrong `repo` is not,
because a wrong source produces a confident wrong verdict.
## Top-level fields (`app:`)
| Field | Type | Required | Notes |
@@ -37,6 +76,7 @@ reusable manifest primitive.
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
| _anything else_ | — | — | Unknown keys are absorbed into an `extensions` map (serde flatten) and treated as transitional metadata — e.g. `container_name`, `metadata`, `category`, `bitcoin_integration`, `lightning_integration`. These are **not** typed schema; do not rely on them being validated. |
## `container:` (ContainerConfig)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.3-alpha",
"version": "1.8.4-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.3-alpha",
"version": "1.8.4-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.3-alpha",
"version": "1.8.4-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

+16 -16
View File
@@ -73,7 +73,7 @@
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -193,13 +193,13 @@
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -374,11 +374,11 @@
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -459,12 +459,12 @@
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
@@ -552,19 +552,19 @@
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* A balance figure, or — while it is still unknown — a pixel readout in place
* of it.
*
* The problem this exists for: an unloaded balance used to render as `0`.
* Zero is not "loading", it is a *number*, and it is the one number that
* frightens people. Someone opening the dashboard while the RPCs are still in
* flight was told, in the wallet's own typeface, that their money was gone.
* There is no formatting fix for that — the fix is to not claim a figure we
* do not have yet.
*
* So `sats` is nullable, and `null` means "not known yet" rather than "none".
* Callers must keep that distinction alive: a balance ref should start at
* `null` and only become a number when a call actually succeeds.
*
* The placeholder is a small dot-matrix that scans in the rail's own colour —
* it inherits `currentColor`, so the on-chain row shimmers orange, Lightning
* yellow, Cashu purple, Fedimint blue and Ark teal with no colour mapping to
* keep in sync. It is deliberately about as wide as the figure it stands in
* for, so nothing jumps when the real number lands.
*/
const props = withDefaults(
defineProps<{
/** Balance in sats, or null/undefined while it is still unknown. */
sats: number | null | undefined
/** Trailing unit. Set to '' for bare figures. */
suffix?: string
/** Named for screen readers, e.g. "on-chain balance". */
label?: string
}>(),
{ suffix: 'sats', label: 'balance' },
)
// 14 columns × 3 rows, laid out column-first so all three cells of a column
// share a delay and the lit column travels across as a single scan line —
// that is what makes it read as a readout rather than a progress bar.
const COLUMNS = 14
const ROWS = 3
const CELLS = COLUMNS * ROWS
const STEP_MS = 55
/** Delay for cell `i` (1-based), constant within a column. */
function cellDelay(i: number): string {
return `${Math.floor((i - 1) / ROWS) * STEP_MS}ms`
}
/**
* Built as one string rather than interpolated around a `<template>`, so the
* space before the unit cannot be eaten by Vue's whitespace condensing — and
* so a test reading `.text()` sees exactly what a person reads on screen.
*/
/**
* Is there a figure to show at all?
*
* `null`/`undefined` mean "not known yet" — but so does a NaN or an Infinity,
* which is what arithmetic on a missing field quietly produces. Those render
* as the literal text "NaN sats", which is worse than the zero this component
* exists to prevent: at least a zero looks like a number.
*/
const known = computed(() => props.sats != null && Number.isFinite(props.sats))
const display = computed(() => {
if (!known.value) return ''
const figure = (props.sats as number).toLocaleString()
return props.suffix ? `${figure} ${props.suffix}` : figure
})
</script>
<template>
<span
v-if="!known"
class="balance-pixels"
role="status"
aria-live="polite"
:aria-label="`Loading ${props.label}`"
:title="`Loading ${props.label}…`"
>
<span v-for="i in CELLS" :key="i" class="balance-pixel" :style="{ animationDelay: cellDelay(i) }" />
</span>
<span v-else>{{ display }}</span>
</template>
<style scoped>
.balance-pixels {
display: inline-grid;
/* Column-first: children fill top-to-bottom, then across, so consecutive
cells share a column and the delay below scans horizontally. */
grid-auto-flow: column;
grid-template-rows: repeat(3, 3px);
grid-auto-columns: 3px;
gap: 1px;
/* Centred on the text it stands in for, so the row height is unchanged when
the real figure replaces it. */
vertical-align: 0.05em;
}
.balance-pixel {
width: 3px;
height: 3px;
border-radius: 0.5px;
background: currentColor;
opacity: 0.16;
animation: balance-pixel-scan 1.6s ease-in-out infinite;
}
@keyframes balance-pixel-scan {
0%, 70%, 100% { opacity: 0.16; }
25% { opacity: 1; }
45% { opacity: 0.42; }
}
/* Motion is decoration here — the dimmed matrix still reads as "no figure
yet", which is the part that carries the meaning. */
@media (prefers-reduced-motion: reduce) {
.balance-pixel {
animation: none;
opacity: 0.35;
}
}
</style>
+413
View File
@@ -0,0 +1,413 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
// would be a third thing to learn.
//
// Two things make this one different from those:
//
// 1. Revealing is also *activating*. The node's master seed is encrypted at
// rest, so this password prompt is the only moment the ecash phrase can be
// derived from it. Until an operator comes here once, a node that predates
// NUT-13 mints coins that no phrase can bring back — and the card says so
// rather than implying a backup already exists.
// 2. These are standard BIP-39 words for a NUT-13 wallet, so they restore in
// Minibits, Nutstash or cdk-cli. Hence `SeedRevealPanel` without `aezeed`:
// the SeedQR tab is genuinely useful here.
type SeedStatus = {
active: boolean
source: 'node-seed' | 'independent' | 'imported' | null
can_activate: boolean
/** Whether a phrase can be *derived* from the node's recovery phrase. When
* false the wallet still gets a backup — it is just independent, and the
* operator has to keep it themselves. Saying which one they are about to
* get, before they write anything down, is the whole point of this flag. */
derivable_from_node_seed: boolean
}
const status = ref<SeedStatus | null>(null)
const statusLoaded = ref(false)
async function loadStatus() {
try {
status.value = await rpcClient.call<SeedStatus>({
method: 'wallet.ecash-seed-status',
timeout: 5000,
})
statusLoaded.value = true
} catch {
// A blip must not hide the card permanently — leave whatever we had.
}
}
onMounted(loadStatus)
const showRevealModal = ref(false)
const revealPassword = ref('')
const revealCode = ref('')
const revealPassphrase = ref('')
const revealing = ref(false)
const revealError = ref('')
const revealedWords = ref<string[]>([])
const revealedSource = ref<string | null>(null)
const wordsCopied = ref(false)
function openReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
revealError.value = ''
revealedWords.value = []
showRevealModal.value = true
}
async function submitReveal() {
if (revealing.value || !revealPassword.value) return
revealing.value = true
revealError.value = ''
try {
const params: Record<string, string> = { password: revealPassword.value }
if (revealCode.value) params.code = revealCode.value
if (revealPassphrase.value) params.passphrase = revealPassphrase.value
const res = await rpcClient.call<{ words: string[]; source: string }>({
method: 'wallet.ecash-seed-reveal',
params,
})
revealedWords.value = res.words || []
revealedSource.value = res.source ?? null
// Activation may just have happened — refresh so the card stops offering
// to set up a backup that now exists.
void loadStatus()
} catch (e: unknown) {
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
} finally {
revealing.value = false
}
}
function closeReveal() {
showRevealModal.value = false
revealedWords.value = []
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
}
async function copyRevealedWords() {
try {
await navigator.clipboard.writeText(revealedWords.value.join(' '))
wordsCopied.value = true
setTimeout(() => { wordsCopied.value = false }, 2000)
} catch { /* clipboard unavailable */ }
}
// ── Import ─────────────────────────────────────────────────────────────────
// Bring-your-own: point this wallet at another NUT-13 wallet's derivation, so
// coins held in Minibits, Nutstash or cdk-cli become restorable here.
//
// Replacing an established phrase is the one lossy thing on this screen. The
// coins already held stay spendable — they are proofs, not derivations — but
// they were minted under the old phrase, so a restore will no longer find
// them. Hence the explicit confirmation, and the reminder to write the
// current phrase down first.
const showImportModal = ref(false)
const importWords = ref('')
const importPassword = ref('')
const importCode = ref('')
const importConfirm = ref(false)
const importing = ref(false)
const importError = ref('')
const importDone = ref(false)
const importWordCount = computed(
() => importWords.value.trim().split(/\s+/).filter(Boolean).length,
)
function openImport() {
importWords.value = ''
importPassword.value = ''
importCode.value = ''
importConfirm.value = false
importError.value = ''
importDone.value = false
showImportModal.value = true
}
async function submitImport() {
if (importing.value || !importPassword.value || importWordCount.value === 0) return
importing.value = true
importError.value = ''
try {
const params: Record<string, string | boolean> = {
words: importWords.value.trim(),
password: importPassword.value,
confirm: importConfirm.value,
}
if (importCode.value) params.code = importCode.value
await rpcClient.call({ method: 'wallet.ecash-seed-import', params })
importDone.value = true
importWords.value = ''
await loadStatus()
} catch (e: unknown) {
importError.value = e instanceof Error ? e.message : 'Import failed'
} finally {
importing.value = false
}
}
// ── Restore ────────────────────────────────────────────────────────────────
// The other half of the backup. Safe to run against a working wallet: the
// backend skips coins already held and never re-adds spent ones, so this is
// the button to reach for when the balance looks wrong, not just after a
// disaster.
const restoring = ref(false)
const restoreMsg = ref('')
const restoreError = ref('')
async function restoreFromPhrase() {
if (restoring.value) return
restoring.value = true
restoreMsg.value = ''
restoreError.value = ''
try {
const res = await rpcClient.call<{
recovered_sats: number
recovered_proofs: number
already_spent: number
keysets_scanned: number
}>({ method: 'wallet.ecash-restore', timeout: 180000 })
if (res.recovered_sats > 0) {
restoreMsg.value = `Recovered ${res.recovered_sats.toLocaleString()} sats (${res.recovered_proofs} coins).`
} else if (res.already_spent > 0) {
restoreMsg.value = `Nothing to recover — the ${res.already_spent} coin(s) found at this mint were already spent.`
} else {
restoreMsg.value = `Nothing to recover: no coins from this phrase at this mint (${res.keysets_scanned} keyset(s) checked).`
}
} catch (e: unknown) {
restoreError.value = e instanceof Error ? e.message : 'Restore failed'
} finally {
restoring.value = false
}
}
</script>
<template>
<div
v-if="statusLoaded"
class="glass-card px-6 py-6 mb-6"
:class="!status?.active ? 'border border-orange-400/40' : ''"
>
<div v-if="!status?.active" class="flex items-center gap-2 mb-3 text-orange-300 text-sm font-medium" role="alert">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
</svg>
Your ecash has no backup yet
</div>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
phrase — so the words you already wrote down cover your ecash too. Reveal it here
if you want to restore your ecash into another wallet (Minibits, Nutstash,
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
</p>
<p v-else-if="status?.active" class="text-sm text-white/60">
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to
restore your ecash into another wallet (Minibits, Nutstash,
<span class="font-mono">cdk-cli</span>).
</p>
<p v-else class="text-sm text-white/60">
Ecash is a bearer instrument: the coins live in a file on this node, and right now
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
that for every coin minted from then on.
<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.
</template>
<template v-else>
This node has no encrypted seed backup to derive from, so the phrase will be its
own — you'll need to write these words down and keep them.
</template>
</p>
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
This wallet's phrase was <strong>not</strong> derived from the node's recovery
phrase{{ status?.source === 'imported' ? ' — it was imported' : '' }}, so restoring
the node will not bring the ecash back. Only these words will.
</p>
</div>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
@click="openReveal"
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
</div>
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-start justify-between gap-4">
<p class="text-sm text-white/60 min-w-0">
<span class="text-white/80 font-medium">Restore from this phrase.</span>
Asks your mint which coins it has signed for these words and puts back any that
are still unspent. Safe to run at any time — it never duplicates coins you already
hold.
</p>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
:disabled="restoring"
@click="restoreFromPhrase"
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
</div>
<p v-if="restoreMsg" role="status" aria-live="polite" class="mt-3 text-xs alert-success px-3 py-2 rounded-lg">{{ restoreMsg }}</p>
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
</div>
<div class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-start justify-between gap-4">
<p class="text-sm text-white/60 min-w-0">
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
Point this wallet at a phrase you already have — from Minibits, Nutstash or
<span class="font-mono">cdk-cli</span> — so its coins can be restored here.
</p>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="openImport"
>Import</button>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="showImportModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="showImportModal = false"
>
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="import-ecash-seed-title">
<h3 id="import-ecash-seed-title" class="text-lg font-semibold text-white mb-1">Import an ecash phrase</h3>
<template v-if="importDone">
<p class="text-sm text-white/70 my-4">
Imported. This wallet now derives its coins from that phrase — run
<span class="text-white/90 font-medium">Restore</span> to pull in the coins it
owns at your mint.
</p>
<button type="button" @click="showImportModal = false" class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-4">
Paste the 24-word phrase from the other wallet. The coins already in this wallet
stay spendable either way.
</p>
<form @submit.prevent="submitImport" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">
Recovery phrase
<span class="text-white/30">({{ importWordCount }} word{{ importWordCount === 1 ? '' : 's' }})</span>
</label>
<textarea v-model="importWords" rows="3" spellcheck="false" autocapitalize="none" autocomplete="off" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono focus:outline-none focus:border-white/30" placeholder="abandon abandon abandon …"></textarea>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="importPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="importCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<label v-if="status?.active" class="flex items-start gap-2 text-xs text-orange-300/90 bg-orange-500/10 border border-orange-400/20 rounded-lg px-3 py-2">
<input type="checkbox" v-model="importConfirm" class="mt-0.5 shrink-0" />
<span>
Replace this wallet's current phrase. Coins minted under the old one stay
spendable but a restore will no longer find them — reveal and write the
current phrase down first. The old phrase is archived on the node, not deleted.
</span>
</label>
<p v-if="importError" role="alert" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ importError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="showImportModal = false" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button
type="submit"
:disabled="importing || !importPassword || importWordCount === 0 || (status?.active && !importConfirm)"
class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50"
>{{ importing ? 'Importing…' : 'Import' }}</button>
</div>
</form>
</template>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="showRevealModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeReveal"
>
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-ecash-seed-title">
<h3 id="reveal-ecash-seed-title" class="text-lg font-semibold text-white mb-1">
{{ status?.active ? 'Reveal ecash phrase' : 'Set up ecash backup' }}
</h3>
<template v-if="revealedWords.length === 0">
<p class="text-sm text-white/60 mb-4">
Confirm your credentials to
{{ status?.active ? 'display the 24-word ecash phrase' : 'derive and display your ecash backup phrase' }}.
</p>
<form @submit.prevent="submitReveal" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<div v-if="!status?.active">
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" />
</div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
{{ revealing ? 'Verifying…' : (status?.active ? 'Reveal' : 'Set up') }}
</button>
</div>
</form>
</template>
<template v-else>
<SeedRevealPanel :words="revealedWords" />
<p class="text-xs text-white/40 mt-3">
<template v-if="revealedSource === 'node-seed'">
Derived from this node's recovery phrase — restoring the node restores this
ecash wallet too. These words also restore it into any NUT-13 wallet.
</template>
<template v-else>
This phrase is independent of the node's recovery phrase. It is the
<strong>only</strong> way to restore this ecash wallet — write it down.
</template>
</p>
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
</div>
</template>
</div>
</div>
</Teleport>
</template>
+23 -96
View File
@@ -2,47 +2,16 @@
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
<template v-if="successInfo">
<div class="text-center py-4">
<div class="send-success-badge mx-auto mb-6">
<ScreensaverRing size="badge" />
<div class="send-success-burst">
<div class="burst-core">
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</div>
<div v-if="successInfo.amount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successInfo.amount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-1">SENT</div>
<p class="text-sm text-white/50 mb-6">{{ successInfo.methodLabel }}</p>
<div v-if="successInfo.hash || successInfo.txid || successInfo.note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
<div v-if="successInfo.hash">
<p class="text-xs text-white/50 mb-1">Payment hash</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
<CopyButton class="shrink-0" :value="successInfo.hash" />
</div>
</div>
<div v-if="successInfo.txid">
<p class="text-xs text-white/50 mb-1">Transaction ID</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
<CopyButton class="shrink-0" :value="successInfo.txid" />
</div>
</div>
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
</div>
<div class="flex gap-3">
<button @click="sendAnother" class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium">Send another</button>
<button @click="close" class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold">Done</button>
</div>
</div>
<PaymentSuccessPane
:amount="successInfo.amount"
verb="SENT"
:method-label="successInfo.methodLabel"
:rows="successRows"
:note="successInfo.note"
again-label="Send another"
@again="sendAnother"
@done="close"
/>
</template>
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
@@ -255,7 +224,7 @@ import { rpcClient } from '@/api/rpc-client'
import { useLightningRequired } from '@/composables/useLightningRequired'
import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
const { t } = useI18n()
const lightning = useLightningRequired()
@@ -320,6 +289,18 @@ const successInfo = ref<{
} | null>(null)
const ecashToken = ref('')
// The identifiers worth keeping from a completed send, in the shape the
// shared success pane takes. Which ones exist depends on the rail: Lightning
// has a payment hash, on-chain has a txid.
const successRows = computed<SuccessRow[]>(() => {
const info = successInfo.value
if (!info) return []
const rows: SuccessRow[] = []
if (info.hash) rows.push({ label: 'Payment hash', value: info.hash })
if (info.txid) rows.push({ label: 'Transaction ID', value: info.txid })
return rows
})
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
const sendAll = ref(false)
const onchainBalance = ref<number | null>(null)
@@ -711,57 +692,3 @@ async function send() {
}
}
</script>
<style scoped>
/* Success badge (FED-06) — the branded ScreensaverRing carries the motion,
with the emerald pop-in check centred over it. */
.send-success-badge {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.send-success-badge {
width: 192px;
height: 192px;
}
}
.send-success-burst {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 7rem;
height: 7rem;
}
.burst-core {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
}
.burst-check {
stroke-dasharray: 32;
stroke-dashoffset: 32;
animation: burst-draw 0.45s ease-out 0.25s forwards;
}
@keyframes burst-pop {
from { transform: scale(0.3); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes burst-draw {
to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
.burst-core, .burst-check { animation: none; }
.burst-check { stroke-dashoffset: 0; }
}
</style>
@@ -221,15 +221,15 @@
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-teal-400 font-medium"><BalanceAmount :sats="arkBalance?.spendable_sats" label="spendable Ark balance" /></p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Pending</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-white/70 font-medium"><BalanceAmount :sats="arkBalance?.pending_sats" label="pending Ark balance" /></p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-white/70 font-medium"><BalanceAmount :sats="arkBalance?.onchain_sats" label="on-chain Ark balance" /></p>
</div>
</div>
@@ -318,6 +318,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const { t } = useI18n()
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BalanceAmount from '../BalanceAmount.vue'
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
import i18n from '@/i18n'
/**
* The distinction this whole component exists to protect: `0` is a balance,
* `null` is the absence of one. Rendering the first when you mean the second
* tells someone their money is gone, in the wallet's own typeface. Every case
* below is really one assertion — that the two never get confused.
*/
describe('BalanceAmount', () => {
it('shows the pixel readout when the balance is not known yet', () => {
const w = mount(BalanceAmount, { props: { sats: null, label: 'on-chain balance' } })
expect(w.find('.balance-pixels').exists()).toBe(true)
expect(w.text()).not.toContain('0')
})
it('treats undefined the same as null', () => {
// Optional props (`arkBalance?.spendable_sats`) arrive as undefined, not
// null, and must not fall through to a figure.
const w = mount(BalanceAmount, { props: { sats: undefined } })
expect(w.find('.balance-pixels').exists()).toBe(true)
})
it('never prints NaN at a person', () => {
// Arithmetic over a missing field produces NaN, which is not caught by a
// null check and renders as the literal text "NaN sats" — worse than the
// zero this component exists to prevent, because at least a zero looks
// like a number.
for (const bad of [NaN, Infinity, -Infinity]) {
const w = mount(BalanceAmount, { props: { sats: bad } })
expect(w.find('.balance-pixels').exists()).toBe(true)
expect(w.text()).toBe('')
}
})
it('shows a genuine zero as a figure, not as loading', () => {
// The inverse mistake: a node that really has no coins must be told so
// plainly, not left shimmering forever.
const w = mount(BalanceAmount, { props: { sats: 0 } })
expect(w.find('.balance-pixels').exists()).toBe(false)
expect(w.text()).toBe('0 sats')
})
it('formats a real balance with thousands separators', () => {
const w = mount(BalanceAmount, { props: { sats: 9922 } })
expect(w.text()).toBe('9,922 sats')
})
it('can drop the unit for bare figures', () => {
const w = mount(BalanceAmount, { props: { sats: 21, suffix: '' } })
expect(w.text()).toBe('21')
})
it('announces what is loading instead of being silently empty', () => {
// A shimmering box with no text is nothing at all to a screen reader.
const w = mount(BalanceAmount, { props: { sats: null, label: 'Cashu balance' } })
const el = w.find('.balance-pixels')
expect(el.attributes('role')).toBe('status')
expect(el.attributes('aria-label')).toBe('Loading Cashu balance')
})
it('inherits the rail colour rather than hard-coding one', () => {
// The pixels are painted with currentColor, which is what makes the
// on-chain row orange and the Cashu row purple with no colour table to
// keep in sync. Guard the mechanism: a literal colour here would drift.
const w = mount(BalanceAmount, { props: { sats: null } })
expect(w.find('.balance-pixel').exists()).toBe(true)
expect(w.html()).not.toMatch(/background:\s*#|rgb\(/)
})
it('renders a 14x3 matrix scanned column by column', () => {
// Column-first layout is what makes the lit column travel across as one
// scan line; per-cell delays would make it crawl diagonally instead.
const w = mount(BalanceAmount, { props: { sats: null } })
const cells = w.findAll('.balance-pixel')
expect(cells.length).toBe(42)
// The three cells of a column share a delay; the next column steps on.
const delay = (i: number) => cells[i]?.attributes('style') ?? ''
expect(delay(0)).toBe(delay(1))
expect(delay(1)).toBe(delay(2))
expect(delay(3)).not.toBe(delay(2))
})
})
describe('HomeWalletCard balances', () => {
const base = {
animate: false,
walletConnected: true,
walletOnchain: null,
walletLightning: null,
walletEcash: null,
walletFedimint: null,
walletArk: null,
walletTransactions: [],
isDev: false,
}
const mountCard = (props: Record<string, unknown>) =>
mount(HomeWalletCard, { props: { ...base, ...props }, global: { plugins: [i18n] } })
it('shows no figures at all before anything has loaded', () => {
const w = mountCard({})
// Six rows could be showing 0 sats here; none of them may.
expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0)
expect(w.text()).not.toMatch(/\b0 sats\b/)
})
it('withholds the total until every rail it sums is known', () => {
// A total computed with nulls as 0 would read *lower* than the rails
// beneath it — worse than showing nothing, because it looks authoritative.
const w = mountCard({ walletOnchain: 5000, walletLightning: null, walletEcash: 0, walletFedimint: 0 })
expect(w.text()).not.toContain('5,000 sats\n')
expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0)
})
it('sums the total once every rail has reported', () => {
const w = mountCard({ walletOnchain: 9000, walletLightning: 900, walletEcash: 22, walletFedimint: 0 })
expect(w.text()).toContain('9,922 sats')
expect(w.findAll('.balance-pixels').length).toBe(0)
})
it('shows an empty wallet as zero rather than as loading', () => {
const w = mountCard({ walletOnchain: 0, walletLightning: 0, walletEcash: 0, walletFedimint: 0 })
expect(w.findAll('.balance-pixels').length).toBe(0)
expect(w.text()).toContain('0 sats')
})
it('keeps the Ark row hidden while its balance is unknown', () => {
// Ark only appears once barkd reports something; "unknown" must not be
// read as "> 0" and conjure a row on the many nodes with no Ark sidecar.
const loaded = { walletOnchain: 1, walletLightning: 0, walletEcash: 0, walletFedimint: 0 }
expect(mountCard({ ...loaded, walletArk: null }).text()).not.toContain('Ark')
expect(mountCard({ ...loaded, walletArk: 0 }).text()).not.toContain('Ark')
expect(mountCard({ ...loaded, walletArk: 7 }).text()).toContain('Ark')
})
})
+9 -5
View File
@@ -121,8 +121,8 @@
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-white/60">On-chain wallet balance</span>
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
{{ walletOnchainSats.toLocaleString() }} sats
<span class="text-sm font-mono" :class="(walletOnchainSats ?? 0) >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
<BalanceAmount :sats="walletOnchainSats" label="on-chain balance" />
</span>
</div>
<p class="text-xs text-white/45 mt-1">Minimum 150,000 · maximum 1,500,000 on-chain sats required.</p>
@@ -136,10 +136,10 @@
</button>
<button
@click="completeFundStep(step)"
:disabled="walletOnchainSats <= 0"
:disabled="(walletOnchainSats ?? 0) <= 0"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
>
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
{{ walletOnchainSats == null ? 'Checking balance…' : walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
</button>
</div>
</template>
@@ -224,6 +224,7 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -435,7 +436,10 @@ function goBack() {
// ── Fund-wallet step: live sync status + on-chain balance ───────────────────
const showFundModal = ref(false)
const walletOnchainSats = ref(0)
// null while the first balance call is still out — "0 sats" and "we haven't
// asked yet" must not look the same on a screen that then says
// "Waiting for funds…".
const walletOnchainSats = ref<number | null>(null)
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
const fundStepActive = computed(() => {
+19 -7
View File
@@ -678,9 +678,19 @@ const showScanModal = ref(false); const showSendModal = ref(false); const showRe
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
// Balances start as `null`, not 0. Zero is a *number*, and it is the one
// number that frightens people — rendering it before any call has returned
// told someone opening the dashboard, in the wallet's own typeface, that
// their money was gone. `null` means "not known yet" and paints a pixel
// readout instead; a rail only becomes a number when a call actually
// succeeds, so a real 0 is still a real 0.
const walletConnected = ref(false)
const walletOnchain = ref<number | null>(null)
const walletLightning = ref<number | null>(null)
const walletEcash = ref<number | null>(null)
const walletFedimint = ref<number | null>(null)
let walletInfoFailures = 0
const walletArk = ref(0)
const walletArk = ref<number | null>(null)
const walletTransactions = ref<WalletTransaction[]>([])
// Overlay the local Mempool app when it's running; otherwise route through
@@ -728,11 +738,13 @@ function hydrateWalletSnapshot() {
const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY)
if (!raw) return
const s = JSON.parse(raw)
walletOnchain.value = s.onchain ?? 0
walletLightning.value = s.lightning ?? 0
walletEcash.value = s.ecash ?? 0
walletFedimint.value = s.fedimint ?? 0
walletArk.value = s.ark ?? 0
// A snapshot key that isn't there was never known — leave it unknown
// rather than inventing a zero for it.
walletOnchain.value = s.onchain ?? null
walletLightning.value = s.lightning ?? null
walletEcash.value = s.ecash ?? null
walletFedimint.value = s.fedimint ?? null
walletArk.value = s.ark ?? null
walletConnected.value = s.connected === true
if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions
} catch { /* corrupt/absent snapshot — fresh load fills in */ }
@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import i18n from '@/i18n'
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
/**
* Instant rails (Lightning, Cashu, Fedimint, Ark) settle immediately, so
* there is no confirmation count to retire an incoming receipt from the
* badge. It used to leave on a five-minute wall clock, which meant a payment
* could arrive and vanish before anyone looked — and for ecash, which leaves
* no public ledger entry, this panel was the only place the receipt was ever
* shown.
*
* These cases pin the replacement: a receipt stays until it has been seen.
*/
const now = () => Math.floor(Date.now() / 1000)
function tx(over: Record<string, unknown> = {}) {
return {
tx_hash: '',
amount_sats: 1000,
direction: 'incoming' as const,
num_confirmations: 1,
time_stamp: now(),
total_fees: 0,
dest_addresses: [],
label: '',
block_height: 0,
kind: 'cashu' as const,
...over,
}
}
const base = {
animate: false,
walletConnected: true,
walletOnchain: 0,
walletLightning: 0,
walletEcash: 0,
walletFedimint: 0,
walletArk: 0,
isDev: false,
}
const mountCard = (transactions: ReturnType<typeof tx>[]) =>
mount(HomeWalletCard, {
props: { ...base, walletTransactions: transactions },
global: { plugins: [i18n] },
})
// Acknowledgement is stored per-browser, so each case starts from a clean
// slate — otherwise one test's "seen" set silently satisfies the next.
beforeEach(() => localStorage.clear())
describe('incoming payments', () => {
it('retires a receipt on acknowledgement, never on age alone', async () => {
// On a browser that has acknowledged before, an unacknowledged receipt
// stays put however old it is — age is not the signal, being seen is.
// (A brand-new browser is the separate first-run case below.)
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify(['some-earlier-receipt']))
const w = mountCard([tx({ time_stamp: now() - 7200 })])
expect(w.text()).toContain('Incoming 1')
})
it('does not clear the receipt merely because the panel was opened', async () => {
// "Selecting incoming clears a pending token" — opening must show it,
// not consume it. Someone reading the row must be able to keep reading.
const w = mountCard([tx()])
await w.find('button').trigger('click')
expect(w.text()).toContain('Incoming Transactions')
expect(w.text()).toContain('+1,000 sats')
})
it('marks it seen once the panel is closed again', async () => {
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify([]))
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click') // open
await badge.trigger('click') // close — acknowledges
expect(w.text()).not.toContain('Incoming 1')
})
it('still surfaces a payment that arrives after an earlier one was seen', async () => {
const first = tx({ amount_sats: 1000, time_stamp: now() - 60 })
const w = mountCard([first])
const badge = w.find('button')
await badge.trigger('click')
await badge.trigger('click')
expect(w.text()).not.toContain('Incoming 1')
// A different payment must not inherit the first one's acknowledgement.
await w.setProps({ walletTransactions: [first, tx({ amount_sats: 2500, time_stamp: now() })] })
expect(w.text()).toContain('Incoming 1')
})
it('leaves on-chain transactions on their confirmation count', () => {
// On-chain has a real signal and is deliberately untouched: it drops out
// at three confirmations regardless of whether anyone looked.
const unconfirmed = mountCard([tx({ kind: 'onchain', num_confirmations: 0, tx_hash: 'abc' })])
expect(unconfirmed.text()).toContain('Incoming 1')
const settled = mountCard([tx({ kind: 'onchain', num_confirmations: 6, tx_hash: 'abc' })])
expect(settled.text()).not.toContain('Incoming 1')
})
it('lists several instant receipts separately even without txids', () => {
// Instant rails carry no txid, so keying the list on tx_hash gave every
// row the same empty key and Vue reused one node for all of them.
const w = mountCard([
tx({ amount_sats: 1000, time_stamp: now() - 10 }),
tx({ amount_sats: 2000, time_stamp: now() - 20, kind: 'lightning' }),
tx({ amount_sats: 3000, time_stamp: now() - 30, kind: 'fedimint' }),
])
expect(w.text()).toContain('Incoming 3')
})
it('does not show a receipt again after a refresh', async () => {
// The bug this whole model was supposed to prevent, and briefly caused:
// "seen" lived in component state, so every page load forgot it and the
// entire ecash history came back as new. Remounting is a refresh.
const received = tx({ amount_sats: 4200, time_stamp: now() - 30 })
const first = mountCard([received])
const badge = first.find('button')
await badge.trigger('click')
await badge.trigger('click')
expect(first.text()).not.toContain('Incoming 1')
const afterRefresh = mountCard([received])
expect(afterRefresh.text()).not.toContain('Incoming 1')
})
it('does not greet a brand-new browser with the whole history', () => {
// Nothing acknowledged yet and a long history: treating all of it as
// unseen would be the same wall of old receipts, just from the other
// direction. Only what is genuinely recent counts as news on a first run.
const old = [
tx({ amount_sats: 100, time_stamp: now() - 86400 }),
tx({ amount_sats: 200, time_stamp: now() - 3600 }),
tx({ amount_sats: 300, time_stamp: now() - 600 }),
]
expect(mountCard(old).text()).not.toContain('Incoming')
// …but a receipt from moments ago still is.
localStorage.clear()
expect(mountCard([...old, tx({ amount_sats: 400, time_stamp: now() - 5 })]).text())
.toContain('Incoming 1')
})
it('survives unreadable storage without resurrecting the history', () => {
// A corrupt value must not read as "nothing has been acknowledged" — that
// is precisely the refresh bug wearing a different hat.
localStorage.setItem('archy-seen-incoming-v1', '{not json')
const w = mountCard([tx({ amount_sats: 100, time_stamp: now() - 86400 })])
expect(w.text()).not.toContain('Incoming')
})
it('does not silently turn into a navigation button while the panel is open', async () => {
// The badge is two controls in one: with receipts it toggles the panel,
// without them it navigates to the full transactions view. If the list
// empties while the panel is open, the click under the user's cursor used
// to change meaning and take them to another screen.
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click')
expect(w.text()).toContain('Incoming Transactions')
await w.setProps({ walletTransactions: [] })
await badge.trigger('click')
expect(w.emitted('showTransactions')).toBeUndefined()
})
})
@@ -68,7 +68,9 @@ describe('AppHeroSection', () => {
it('disables app controls while a container action is running', () => {
const wrapper = mountHero({ pendingAction: 'restart' })
expect(wrapper.text()).toContain('Restarting...')
// A real ellipsis, not three dots — the label changed in 9ccc325a and
// this assertion was left behind.
expect(wrapper.text()).toContain('Restarting…')
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
})
@@ -2,6 +2,7 @@
export const GENERATED_APP_PORTS: Record<string, number> = {
"aiui": 5180,
"alby-hub": 8187,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
@@ -38,6 +39,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
export const GENERATED_APP_TITLES: Record<string, string> = {
"aiui": "AI Assistant",
"alby-hub": "Alby Hub",
"archy-btcpay-db": "BTCPay Postgres",
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
@@ -82,6 +84,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"netbird-server": "NetBird Server",
"nextcloud": "Nextcloud",
"nostr-rs-relay": "Nostr Relay (Rust)",
"phoenixd": "phoenixd",
"photoprism": "PhotoPrism",
"pine": "Pine",
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
+118 -27
View File
@@ -22,7 +22,7 @@
</div>
<div class="flex items-center gap-2">
<button
@click="incomingTxCount > 0 ? (showIncomingTxPanel = !showIncomingTxPanel) : $emit('showTransactions')"
@click="incomingTxCount > 0 || showIncomingTxPanel ? toggleIncomingPanel() : $emit('showTransactions')"
:class="incomingTxCount > 0 ? 'incoming-tx-badge' : 'text-white/50 hover:text-white/80 text-xs px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors'"
class="shrink-0"
>
@@ -59,14 +59,14 @@
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<button @click="toggleIncomingPanel" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
:key="txKey(tx)"
class="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
:class="isOnchain(tx) ? 'cursor-pointer' : ''"
@click="isOnchain(tx) && $emit('openInMempool', tx.tx_hash)"
@@ -117,7 +117,7 @@
<span class="text-lg text-orange-500 font-bold">&#x20bf;</span>
<span class="text-sm font-medium text-white">{{ t('web5.totalBitcoin') }}</span>
</div>
<span class="text-white text-sm font-semibold">{{ walletTotal.toLocaleString() }} sats</span>
<span class="text-white text-sm font-semibold"><BalanceAmount :sats="walletTotal" label="total balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -126,7 +126,7 @@
</svg>
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
<span class="text-orange-500 text-sm font-medium"><BalanceAmount :sats="walletOnchain" label="on-chain balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -135,7 +135,7 @@
</svg>
<span class="text-sm text-white/80">{{ t('web5.lightning') }}</span>
</div>
<span class="text-yellow-400 text-sm font-medium">{{ walletLightning.toLocaleString() }} sats</span>
<span class="text-yellow-400 text-sm font-medium"><BalanceAmount :sats="walletLightning" label="Lightning balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -146,7 +146,7 @@
</svg>
<span class="text-sm text-white/80">Cashu</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
<span class="text-purple-400 text-sm font-medium"><BalanceAmount :sats="walletEcash" label="Cashu balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -155,7 +155,7 @@
</svg>
<span class="text-sm text-white/80">Fedimint</span>
</div>
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
<span class="text-blue-400 text-sm font-medium"><BalanceAmount :sats="walletFedimint" label="Fedimint balance" /></span>
</div>
<!-- Only rendered once barkd reports a balance — most nodes don't run the Ark sidecar -->
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
@@ -165,7 +165,7 @@
</svg>
<span class="text-sm text-white/80">Ark</span>
</div>
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
<span class="text-teal-400 text-sm font-medium"><BalanceAmount :sats="walletArk" label="Ark balance" /></span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
@@ -198,7 +198,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch } from 'vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -220,11 +221,13 @@ export interface WalletTransaction {
const props = defineProps<{
animate: boolean
walletConnected: boolean
walletOnchain: number
walletLightning: number
walletEcash: number
walletFedimint: number
walletArk?: number
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
// what lets the card show a pixel readout instead of claiming a figure.
walletOnchain: number | null
walletLightning: number | null
walletEcash: number | null
walletFedimint: number | null
walletArk?: number | null
walletTransactions: WalletTransaction[]
isDev: boolean
}>()
@@ -241,32 +244,120 @@ defineEmits<{
const showIncomingTxPanel = ref(false)
const walletTotal = computed(() =>
props.walletOnchain + props.walletLightning + props.walletEcash + props.walletFedimint + (props.walletArk ?? 0)
)
// The total is only a number once every rail that makes it up is. Summing
// with nulls treated as 0 would quietly under-report the balance — a total
// smaller than the rails beneath it is worse than showing nothing.
const walletTotal = computed<number | null>(() => {
const rails = [props.walletOnchain, props.walletLightning, props.walletEcash, props.walletFedimint]
if (rails.some(v => v == null)) return null
return rails.reduce((a: number, b) => a + (b as number), 0) + (props.walletArk ?? 0)
})
function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'
}
// Instant rails (lightning/cashu/fedimint/ark) settle immediately — there is
// no confirmation to wait for, so they only get the "incoming" treatment for
// a short window after receipt instead of sitting in the badge forever.
const INSTANT_INCOMING_WINDOW_SECS = 5 * 60
const nowSecs = ref(Math.floor(Date.now() / 1000))
let nowTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => { nowTimer = setInterval(() => { nowSecs.value = Math.floor(Date.now() / 1000) }, 30000) })
onUnmounted(() => { if (nowTimer) clearInterval(nowTimer) })
// Instant rails (lightning/cashu/fedimint/ark) settle immediately, so there is
// no confirmation to wait for and no natural moment for them to leave the
// badge. They used to drop out on a five-minute wall clock, which meant a
// receipt could appear and then silently disappear before anyone looked at it
// — and if you opened the panel a few minutes late, the payment you came to
// check on had already evaporated. For ecash that is the worst case
// available: it leaves no public ledger entry, so this panel was the only
// place the receipt was ever shown.
//
// So they stay until they have actually been *seen*. Opening the panel is
// what marks them seen, which is the same unread model the mesh inbox uses.
// On-chain is unchanged — a confirmation count is a real signal and does the
// job by itself.
//
// "Seen" has to outlive the page, or this is worse than the timer it
// replaced: held only in component state, every refresh forgot the
// acknowledgement and the whole ecash history came back as new. It is stored
// per-node in localStorage, capped so it cannot grow without bound.
const SEEN_KEY = 'archy-seen-incoming-v1'
const SEEN_CAP = 300
// On a browser that has never stored an acknowledgement, treating the entire
// history as unseen would greet the user with a wall of old receipts. Only
// what arrived in the last few minutes is genuinely news on a first run.
const FIRST_RUN_GRACE_SECS = 5 * 60
function loadSeen(): { seen: Set<string>; firstRun: boolean } {
try {
const raw = localStorage.getItem(SEEN_KEY)
if (raw === null) return { seen: new Set(), firstRun: true }
const parsed: unknown = JSON.parse(raw)
return { seen: new Set(Array.isArray(parsed) ? (parsed as string[]) : []), firstRun: false }
} catch {
// Unreadable storage must not resurrect the history — treat it as a fresh
// start rather than as "nothing acknowledged".
return { seen: new Set(), firstRun: true }
}
}
const initial = loadSeen()
const seenIncoming = ref(initial.seen)
let needsFirstRunSeeding = initial.firstRun
function persistSeen() {
try {
// Keep the newest entries; the oldest can never resurface anyway, because
// an instant-rail receipt that far back is long gone from the history the
// backend returns.
const keys = [...seenIncoming.value].slice(-SEEN_CAP)
seenIncoming.value = new Set(keys)
localStorage.setItem(SEEN_KEY, JSON.stringify(keys))
} catch { /* storage full or unavailable — acknowledgement stays in-session */ }
}
function txKey(tx: WalletTransaction): string {
// Instant rails have no txid to key on, so fall back to rail+time+amount.
return tx.tx_hash || `${tx.kind ?? 'onchain'}:${tx.time_stamp}:${tx.amount_sats}`
}
// Seed the first run once history actually arrives — at mount the list is
// still empty, so there is nothing to judge yet.
watch(
() => props.walletTransactions,
(txs) => {
if (!needsFirstRunSeeding || txs.length === 0) return
needsFirstRunSeeding = false
const cutoff = Math.floor(Date.now() / 1000) - FIRST_RUN_GRACE_SECS
for (const tx of txs) {
if (tx.direction !== 'incoming' || isOnchain(tx)) continue
if (tx.time_stamp < cutoff) seenIncoming.value.add(txKey(tx))
}
persistSeen()
},
{ immediate: true },
)
const incomingTransactions = computed(() =>
props.walletTransactions.filter(tx => {
if (tx.direction !== 'incoming') return false
if (isOnchain(tx)) return tx.num_confirmations < 3
return nowSecs.value - tx.time_stamp < INSTANT_INCOMING_WINDOW_SECS
return !seenIncoming.value.has(txKey(tx))
})
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
/// Open or close the panel. Closing is what acknowledges the instant-rail
/// receipts currently listed — marking them on *open* would make a row vanish
/// under the cursor of someone still reading it.
function toggleIncomingPanel() {
if (showIncomingTxPanel.value) {
for (const tx of incomingTransactions.value) {
if (!isOnchain(tx)) seenIncoming.value.add(txKey(tx))
}
// Vue tracks Set mutations, but reassigning keeps the dependency obvious.
seenIncoming.value = new Set(seenIncoming.value)
persistSeen()
showIncomingTxPanel.value = false
return
}
showIncomingTxPanel.value = true
}
function railBadge(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Instant'
if (tx.kind === 'cashu') return 'Cashu'
@@ -362,6 +362,22 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.4-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.4-alpha</span>
<span class="text-xs text-white/40">August 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (auth: open), documented in the developer guide.</p>
<p>**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.</p>
<p>**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.</p>
<p>**"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.</p>
<p>**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the "Radio detected" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.</p>
<p>**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so "the radio is working" and "the internet is doing the radio's job" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show "unknown" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)</p>
<p>**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle.</p>
</div>
</div>
<!-- v1.8.3-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { appConfirm } from '@/composables/useAppConfirm'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
const { t } = useI18n()
@@ -317,6 +318,11 @@ defineExpose({ loadBackups })
</div>
</div>
<!-- Ecash backup phrase — sits beside the node phrase because it IS
derived from it; an operator asking "what do I need to write down?"
should find both answers in one place. -->
<EcashSeedBackup />
<!-- Reveal recovery phrase modal -->
<Teleport to="body">
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">
+10
View File
@@ -12,6 +12,16 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
// Vitest's 5s default is not a statement about these tests — the whole
// 1000-test suite runs in ~70s on an idle box. It is a statement about
// the machine. This one also runs a live node, so a release gate can
// collide with a cargo build or a container churn, and starved workers
// blow 5s on tests that normally take milliseconds: on 2026-08-20 four
// unrelated tests timed out at once (one after 36s of wall clock) purely
// from CPU contention, failing the gate with nothing actually broken.
// 20s keeps real hangs bounded while surviving a busy box.
testTimeout: 20_000,
hookTimeout: 20_000,
setupFiles: ['./vitest.setup.ts'],
root: '.',
passWithNoTests: true,
+21 -17
View File
@@ -1,29 +1,33 @@
{
"changelog": [
"**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.",
"**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.",
"**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files."
"**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the \"app is restarting\" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.",
"**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.",
"**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.",
"**\"Are you sure?\" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.",
"**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the \"Radio detected\" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.",
"**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so \"the radio is working\" and \"the internet is doing the radio's job\" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show \"unknown\" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)",
"**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle."
],
"components": [
{
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago",
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.3-alpha",
"sha256": "23b608bfce575212edb873ded3e125505f42be0db6dc06ce5f9c1e51c232c22d",
"size_bytes": 59900696
"new_version": "1.8.4-alpha",
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
"size_bytes": 63850680
},
{
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago-frontend-1.8.3-alpha.tar.gz",
"name": "archipelago-frontend-1.8.3-alpha.tar.gz",
"new_version": "1.8.3-alpha",
"sha256": "13ca772e9a36c266b67e6eff2d51366616cdbedeab3a05ba5eba340332d3d6d4",
"size_bytes": 97615528
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
"new_version": "1.8.4-alpha",
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
"size_bytes": 97641658
}
],
"release_date": "2026-08-14",
"signature": "35d5f56ef77cda1866051a3ff06f6e09ff0a23ac7b000a1ac4cf24e04cecabff00be7f3e240fc82ec605bff80c5690c55c296a300018562940d71ffc5df26c00",
"release_date": "2026-08-20",
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.3-alpha"
"version": "1.8.4-alpha"
}

Some files were not shown because too many files have changed in this diff Show More