"LND thinks I do not have a channel" while the wallet showed plenty of
liquidity (framework-pt, 2026-09-01): the send gate sums outbound over
FULLY-OPEN channels only, which is correct — a just-opened channel
sits in LND's pending list until it has ~3 confirmations, and an
open channel can have all its balance on the far side — but the modal
then claimed the node had NO channel at all, in every one of those
states, and pointed the user at opening another one.
The gate already fetched the full channel list; it now records WHY
liquidity is zero and the modal says the truth per state:
- pending channels -> "your new channel is waiting for on-chain
confirmations, it unlocks automatically, nothing is needed from you"
(and no "Open a channel" button — that would send the user to fix
a problem they don't have, possibly opening a second channel)
- open channels, zero on the needed side -> "balance is on the far
side — you can receive but there's nothing to send right now"
- payment refused with a routing/liquidity error -> says so, instead
of claiming no channels
- only a genuinely channel-less node keeps the open-one guidance
Eleven unit tests pin the state machine, including the regression
case (pending-only -> 'pending', not 'none') and fail-open on RPC
errors.
The companion-agent queue from the 2026-08-30 handoff, complete:
- Backup & Restore: hub sub-page, SAF export/import sealed in the
node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, byte-compatible
with core backup.rs), merge-only restore, no cloud.
- Remote Signer: the phone is the NIP-46 bunker — nsec generate/import,
nostrconnect:// QR pairing (scanner + OS deep link), per-request
approve/deny card, NIP-44 v2 transport with NIP-04 receive fallback,
wire-faithful to rust-nostr's reference bunker. Crypto pinned to the
official NIP-44 + BIP-340 vectors; e2e harness included.
- #61 residual: banner + manual intro trigger + overlay all gate on
isCompanionApp() (web-side, vitest-covered).
- Hub modal: new sub-pages like Nodes/FIPS, 70% height cap, node ULA
display/copy in the Nodes list, fipssh Termux helper (npub→ULA is a
pure public-key function — verified against the fips crate).
Issues #61 (comment), #128, #139 closed on the tracker.
Only the auto-popup was companion-gated — inside the companion WebView
users still saw the 'install the companion' banner in the App Store and
could pop the intro overlay through it. Gates all three paths on
isCompanionApp(): CompanionBanner self-hides, openCompanionIntro() is a
no-op, and the manual-open watcher in CompanionIntroOverlay refuses to
open (the overlay's raw window check also moves to the canonical helper
so there is exactly one detection). No APK change.
Two reports from a fresh install without a cable:
(a) No way to see the WiFi password being typed. Every password field in
the app was a bare type=password input. PasswordRevealInput is the
reusable fix — masked by default, one-tap eye toggle, v-model and
enter pass-through — first applied to the WiFi prompt in ServerModals
so a long key typed from across the room can be verified.
(b) WiFi settings are undiscoverable with no wired internet. New
OnboardingNetworkCallout floats over every onboarding step when the
node has NO physical link at all (no ethernet up, no WiFi associated
— polled from network.list-interfaces, self-dismissing the moment a
link exists) and deep-links 'Connect to WiFi' to
/dashboard/server?open=wifi, which Server.vue consumes by popping the
WiFi picker on arrival. Deliberately scoped the other way too:
Archipelago is offline-first, so 'no internet' never nags — only 'no
link at all', only during onboarding (the wrapper hosts /login too;
the callout is restricted to /onboarding/* routes), and a failed probe
stays silent. The query is consumed via history.replaceState so a
KeepAlive tab-return never re-pops the modal, and Server.vue keeps
reading it from the real URL rather than vue-router — its
KeepAlive-mounted tests have no router context to give.
Verification: full frontend suite 1023/1023; type-check clean; production
build clean with both new strings confirmed in the emitted bundles
(OnboardingWrapper + Server chunks).
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>
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>
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>
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>
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>
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>
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>
Redeeming ecash reported success as one line of small green text, while an
on-chain or Lightning payment got the full moment — amount, verb, and the
identifiers you can copy. That asymmetry matters most for ecash: it leaves
no public ledger entry, so if the payment is ever questioned there is
nothing to look up afterwards. Whatever isn't copyable at that instant is
simply gone.
The success pane is extracted from SendBitcoinModal into a shared
PaymentSuccessPane so Cashu and Fedimint show the *same* screen rather
than a lookalike, and the copyable-row treatment is defined once. Each
caller passes the identifiers its protocol actually has; ecash receive now
shows the issuing mint (newly returned by wallet.ecash-receive) and the
redeemed token itself, clamped so a long token doesn't flood the pane.
Also: the test-ecash switch is a proper toggle (role="switch", keyboard
focusable) rather than a checkbox — it selects which purse the wallet is
looking at, so it should read as a mode you are in.
SendBitcoinModal still carries its own copy of the markup; consolidating it
onto the shared component is a follow-up, deliberately not done in the same
change as the money-path wiring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Executes steps 1-3 of docs/cashu-cdk-migration-plan.md, plus the test-coin
switch needed to exercise these routes without spending real sats.
Protocol layer: depend on `cashu` 0.17.5 (MIT, the crate CDK is built on,
default-features off, `wallet` only). Keyset ids now go through upstream's
`Id::from_short_keyset_id` / `ShortKeysetId` instead of the prefix match
hand-rolled in 2277fc46 — same repair, but implemented by the reference
code that defines the rule, so the next spec turn is a version bump rather
than another incident. `MintClient` feeds it the mint's `/v1/keysets` in
upstream's own `KeySetInfo` shape, parsing entries individually so one
keyset in an unmodelled unit can't block resolving the id we need.
Adding the crate required relaxing `bip39 = "=2.1.0"` to `"2.1"` (resolves
2.2.2): the exact pin held `unicode-normalization` at 0.1.22 and no
resolution existed otherwise. The pin carried no recorded rationale; seed
tests cover the bump.
Network switch: `wallet.ecash-network` / `wallet.ecash-set-network`, with a
Test mode toggle in Wallet Settings → Cashu. Cashu has no testnet, so this
points the wallet at the public `testnut` mint — but crucially each network
gets its OWN wallet and accepted-mints file, because test and real proofs
in one purse would be spendable interchangeably and the balance would be a
lie. Mainnet keeps the original filenames, so existing funds files are
untouched and switching is reversible: tests assert a real balance survives
a round trip through test mode.
Headless coverage: scripts/test-ecash-routes.sh drives every ecash RPC over
the real HTTP path (network get/set, balance, history, mint quote + claim,
send, receive, double-redeem refusal, garbage input, melt quote), restores
the node's original network on exit, and exits non-zero with the failure
count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
window.confirm blocks the JS event loop, which froze companion remote
input while open — the remote user could raise the mesh "Clear" prompt
(or reboot / backup-delete / uninstall confirms) and then never dismiss
it, because the synthetic events that would dismiss it queue behind the
dialog itself.
New promise-based appConfirm() (useAppConfirm.ts) + one AppConfirmModal
mounted globally in App.vue, built on BaseModal (Teleport-to-body,
full-viewport backdrop, glass card — the canonical modal contract). All
six native confirm() call sites migrated: mesh clear-all, mesh message
delete, dashboard reboot, backup delete, backup USB copy, app uninstall.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- lnd.createinvoice now returns r_hash_hex; new lnd.invoicestatus RPC
looks the invoice up (SETTLED + amt_paid_sat). E2E-verified on this
box: real invoice minted, status polls settled:false until paid.
- Receive modal: Lightning polls settlement every 3s and flips to the
on-chain-style success view — straight to the green check + amount
(no broadcast step; settlement is final). Raw bolt11 text removed:
QR + CopyButton only. State fully reset per open/close.
- CopyButton is now the wallet's only copy affordance: the ark-address
and ecash-token holdouts swapped in, their ad-hoc handlers deleted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Requests were spread evenly around the orbit, so they could sit BEHIND
the globe: the blinking call-to-action was invisible and the chart read
as mis-scaled until the user hand-rotated. Now requests cluster tightly
at one stage angle (spacing shrinks as count grows) and, when a NEW
request arrives, the camera steers to face the cluster front-and-center
(depth ∝ sin(angle−rotY); front = angle+π/2) — arrival only, so a user
who rotates away isn't fought. Static/reduced-motion paths snap+render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- NetworkMap3D: kiosks keep static PLACEMENT (the rAF-fragile intro was
the blank-screen cause) but re-attach the half-rate ticker — the calm
orbit is back; and measure() now renders explicitly when no ticker runs,
so resizes repaint instead of leaving a stale/blank/mis-scaled
projection (also fixes reduced-motion users on any screen).
- Gloss v3 scoped to .logo-gloss opt-in (screensaver, intro, login,
splash tap-logo) — it had leaked onto every logo-gradient-border user,
including the dashboard header, via AnimatedLogo's default border.
- normalize-app-icon.py output now carries intrinsic 512x512 dimensions:
a viewBox-only SVG collapses to nothing in auto-sized tiles (the
'transparent icon in My Apps' report); both app icons regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GSAP entrance intro needs healthy rAF delivery to reach opacity 1;
on a paint-starved kiosk it stalls and the federation/peers screen reads
as BLANK until a lucky refresh. Kiosks now take the existing staticMode
branch (no intro, no ticker — everything lands in place instantly); the
2D default and 2D/3D toggle stay. Half-rate tick kept for any future
non-static kiosk path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The animated federation map froze the framework-pt 4K TV: the launcher
held every machine to the HD 5500-era choppy-audio flags (single raster
thread, GpuRasterization banned) while the map wrote SVG attrs at 60fps.
- Launcher: two flag tiers. legacy = the proven conservative set; modern
(Intel gen8+, 'NNth Gen' models, AMD Ryzen) = default raster threads +
GPU rasterization. Classified from /proc/cpuinfo (11 model strings
covered by tests in-session); KIOSK_GRAPHICS=performance|quality in
kiosk-display.conf overrides; headless unchanged. Reaches deployed
kiosks via the include_str! self-heal, same as the vsync fix.
- system.kiosk-display.get/set: carries a 'graphics' field alongside
'preset'; setting one no longer clobbers the other.
- Settings → Display: Graphics picker (Auto / Compatibility / Quality).
- NetworkMap3D: kiosks default to the 2D projection (remembered toggle
still works) and tick at half rate with carried-over deltas — same
spin speed, half the paint cost.
- Changelog: curated Unreleased notes for all of the above + the gate
frame-embedding fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>