feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive #156
Closed
ssmithx
wants to merge 9 commits from
feat/minibits-lnurl-receive into main
pull from: feat/minibits-lnurl-receive
merge into: :main
:main
:fix/paid-content-payment-not-lost
:fix/mint-error-detail-swallowed
:investigate/framework-lnd-startup
:fix/minibits-already-redeemed
:cuprate-fixes
:feature/dojobay-app
:fix/bitcoin-core-tor-service-name
:docs/openwrt-gateway-setup
:cuprate-cpu-fix
:fix/cuprate-explicit-logging-levels
:companion/session-2026-08-31
:companion/0.5.28-deploy-handoff
:companion/0.5.28-ship
:companion/0.5.28
:app-bumps-mirror-pending
:cuprate-archyapp
:docs/todo-list
:companion/0.5.27-version-meta
:companion/0.5.27-ship
:companion/0.5.27-clipboard-qr-restart
:ux-at-last
:fix/mesh-send-content-inline-federation-fallback
:cashu-error-messages
:gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf
:feat/podsteadr-app-package
:rotate-release-root
:chore/aiui-monorepo-migration
:wip/phase-13-p13-02
:wip/phase-13-p13-01
:fix/bitcoin-conf-conflict-crash-loop
:demo-build
:archy-hwconfig
:openwrt-enhancements
:a3-10-endpoint-fallback
:public-prelaunch
:release/1.7.115-prep
:fix/web-listener-ipv6
:fips-companion-5g-hardening
:fix/connection-accept-deadlock
:fix/demo-images-path-filter
:fix/companion-autologin-replay-intro
:networking-profits-dashboard
:identities-mobile-polish
:companion-qr-scan-fix
:companion-qr-pairing
:intro-reliability-video-perf
:login-bg-continuity
:audio-bottom-bar
:demo-intro-every-visit
:intro-entrance-fixes
:cloud-feedback-demo-content
:cloud-tabs-search
:ark-wallet-ui-demo
:demo-nginx-app-assets
:demo-ui-fixes
:ark-wallet-barkd
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
489995ced0 |
fix(ecash): reduce Minibits relay churn/privacy leak and page past a 200-DM claim backlog
fetch_relay_dms connected to all three CLAIM_RELAY_URLS (the Minibits relay plus the two public fallbacks, relay.damus.io and nos.lol) on every 8s poll, even though the module's own docs already described RELAY_URL as the primary with the public relays meant only as a fallback. In practice this meant 3 fresh WebSocket connections every poll and broadcasting the wallet's derived Nostr pubkey's DM activity to two public relays it didn't need to touch. - Query RELAY_URL alone first; only add and query the public fallbacks when it's unreachable (via try_connect_relay). Happy path is now one connection per poll instead of three, and the public relays only see this pubkey's traffic when the primary is actually down. - Page through the DM filter instead of a single limit(200) fetch: a relay returns the newest `limit` events for a filter, so a backlog of more than 200 DMs since the last poll (e.g. a long-offline node) silently skipped the older ones forever, since `since` never advanced past them. Capped at 5 pages so a relay that never stops returning full pages can't hang the poll. - Moved the ensure_mint_accepted doc comment back above its own function — it had been glued onto fetch_relay_dms by an earlier edit. - Timestamp::as_u64() -> as_secs() to clear the deprecation warning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZnFgeUBKY5UAfyJFsYccS |
||
|
|
4e410d7c98 |
fix(ecash): guard Minibits claim polls against races and stop replayed claims retrying forever
The UI polls wallet.ecash-lnaddress-claim every 8s, but a single poll (auth + /claim + relay fetch + redeem loop) can outlast that interval. Two overlapping claim_and_redeem runs then loaded the same last_dm_seen_at, fetched/redeemed the same claims, and last-writer-wins on save — rewinding the watermark and/or double-redeeming. A double-redeemed or state-loss-replayed claim then failed forever as "already spent" with no way to leave pending_claims, leaving a permanent orange retry banner. - STATE_LOCK (backend) + an in-flight guard (UI) serialize claim polls and the lnaddress registration/token-refresh path, so two callers can't race on minibits.json. - pending_claims now tracks per-claim attempts (PendingClaim, migrating transparently from the old plain-string shape); a claim that fails MAX_CLAIM_ATTEMPTS times is dropped instead of retried forever. - A redeem failure recognized as mint error 11001 (already redeemed) is treated as terminal and dropped immediately — the value was already swept, so retrying it is pointless. ClaimOutcome gains dropped_count so the two drop reasons (harmless vs. real loss) are visible to the caller. - save_state now writes via temp-file + rename instead of truncating minibits.json in place — the exact disk-full failure mode that corrupted this file on archy-x250-pa3, 2026-09-08, could otherwise destroy pending_claims tokens that /claim had already consumed server-side (unrecoverable, unlike relay DMs). - minibits_error no longer panics on a multi-byte UTF-8 boundary when truncating a server error body (was byte-slicing, not char-safe). - register_profile's name-collision check now matches the structured error.name == ALREADY_EXISTS instead of a raw "already" substring, so an unrelated error message doesn't burn a retry attempt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZnFgeUBKY5UAfyJFsYccS |
||
|
|
fc5b51ab2f |
fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll
Confirmed live 2026-09-08 against three real Lightning payments to a registered @minibits.cash address: POST /claim (the only claim source claim_and_redeem checked) always returned an empty array, no matter how long or how often it was polled. Independently queried wss://relay.minibits.cash and found all three payments sitting there as NIP-04-encrypted kind-4 DMs, #p-tagged to the wallet's own Nostr pubkey and authored by the Minibits service key — that is the actual delivery channel for a payment made to the address, and this module never looked at it. fetch_relay_dms queries CLAIM_RELAY_URLS (the service's own relay plus two public fallbacks) for kind-4 events tagged to our pubkey, feeding matching content into the existing pending_claims retry pipeline unchanged. A new last_dm_seen_at watermark stops the same (immutable, never-expiring) relay event from being re-fetched and re-attempted on every poll. The REST /claim call stays in place alongside it in case it serves some other payment path — this only adds the missing one. fix(ecash): trim stray whitespace before parsing a cashuA/cashuB token Once the relay fix above surfaced the three real payments, all three failed to redeem with "Invalid base64 in cashuB token" — the decrypted NIP-04 content had a trailing space after the base64 payload (Minibits' own encoding), which every base64 alphabet in decode_token_base64 rejects outright. CashuToken::deserialize now trims the whole token string before touching the "cashuA"/"cashuB" prefix or payload. This is a general robustness fix, not just a Minibits workaround — the same stray-whitespace failure could hit a hand-pasted token from a clipboard copy just as easily. Both fixes verified end-to-end against production: all three stuck payments (20 + 5 + 20 = 45 sats) redeemed cleanly on the first poll after deploying this build to archy-x250-pa3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a |
||
|
|
3768395e59 |
fix(ui): escape a second live vue-i18n message-compile crash + add a full-sweep test
Same class of bug as the Minibits address label
(settings.passwordNeedSpecial: "...(!@#$%^&* etc.)" — a bare @ vue-i18n
parses as linked-message syntax). This one is live in
ChangePasswordSection.vue's password-strength validator: typing a new
password with no special character throws this exact
SyntaxError the moment the message is rendered. Fixed the same way
({'@'} escaping).
Added locales/__tests__/i18nMessagesCompile.test.ts, which walks every
string in every locale file and asks the real vue-i18n compiler to
parse it — confirmed it fails on both bad strings before their fixes
and passes clean now, with no other landmines left in either locale
file. This closes the whole bug class rather than just these two
instances; a future bad interpolation string fails `npm test` instead
of only a live crash report.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
|
||
|
|
6041eb6306 |
fix(ui): escape the literal @ in the Minibits address label
Root cause of "click Receive, click Ecash, the modal disappears" (in
both the browser and the Android companion's WebView, since both host
the same neode-ui bundle): vue-i18n treats a bare @ as the start of
"linked message" syntax. receiveBitcoin.lnAddressLabel ("Your
@minibits.cash address:") isn't valid linked-message syntax, so
*compiling* that message throws a SyntaxError the instant it's first
rendered — i.e. the moment wallet.ecash-lnaddress resolves and the
address section becomes visible. The uncaught render-function error
blanks the whole teleported modal, which is indistinguishable from it
just closing.
Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in
a headless Chromium — a Vitest run with `t` mocked to a no-op, which is
how the existing component test suite covers this file, cannot catch a
bad message string at all. Fixed by escaping the @ as {'@'} — the same
pattern the codebase already uses for settings.domainNamePlaceholder
("user{'@'}example.com"). Added a regression test using the real
vue-i18n instance instead of the mocked one; verified it fails on the
old string and passes on the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
|
||
|
|
3be6f45fe8 |
test(ui): guard the ecash-tab-click path in ReceiveBitcoinModal
Operator report (2026-09-08): clicking the Ecash tab appeared to close the whole Receive modal. Added a regression test simulating the exact click, both for wallet.ecash-lnaddress succeeding and failing — the tab switch alone never emits `close` or unmounts the dialog in either case, so this isn't reproduced by a plain component-level click; the investigation continues with the reporter for a browser-console repro. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a |
||
|
|
3f52e4cd78 |
fix(ecash): recover from a truncated/corrupt Minibits state file
archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since removed) while a client had the ecash receive tab open. save_state's write landed mid-truncate, leaving wallet/minibits.json at 0 bytes. load_state then hard-failed every wallet.ecash-lnaddress call with "EOF while parsing a value", surfaced in the UI as "Lightning address unavailable" — permanently, since nothing ever cleared the bad file. Registration is idempotent per pubkey (re-registering returns the same lud16 Minibits already assigned), so there's no reason a corrupt local mirror of that state should be fatal. load_state now treats an empty or unparseable state file the same as a missing one — re-register and recover the same address — instead of erroring. Manually cleared the stuck file on archy-x250-pa3 as an immediate fix; this closes the gap so it self-heals next time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a |
||
|
|
76d565fb18 |
fix(ecash): stop Minibits LN-address claims from being silently lost
A Minibits /claim response consumes the payment server-side the instant it's returned — it can never be re-fetched. claim_and_redeem previously decrypted/redeemed each claim inline and just warn!-logged any failure, so a mint-unreachable blip, a stale cached server key, or an operator who'd edited their accepted-mints list to drop the default mint (via streaming.configure-mints) could make a real payment vanish with nothing but a log line to show for it — claimed_count/received_sats still came back as a clean 0, identical to "nothing arrived." Now: every fetched claim is persisted to MinibitsState.pending_claims before decrypt/redeem is attempted, survives failures across polls instead of being dropped, and claim_and_redeem no longer bails out on a fetch error without first retrying whatever was already pending. ensure_mint_accepted self-heals the accepted-mints allow-list so the Minibits mint (the address is inherently backed by it) can't be excluded out from under a claim. ClaimOutcome gains failed_count, threaded through wallet.ecash-lnaddress-claim and shown in ReceiveBitcoinModal so a stuck claim is visible instead of silent. Also fixes the server_nostur_pubkey field-name typo (no live state to migrate — this feature hasn't shipped yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a |
||
|
|
6effc6b574 |
feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive
The wallet used Minibits only as a Cashu mint, so the node could hold and swap ecash there but had no addressable name at it. This derives a LUD-16 Lightning address (name@minibits.cash) from the node's own ecash wallet and surfaces it in the ecash Receive tab above the existing paste-token box. Identity reuses the NUT-13 ecash phrase, so there is no second secret: - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app stores, so restoring the same phrase recovers the same address both ways; - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic, pinned by a unit test against the NIP-06 vector so a bump cannot silently move the derivation and orphan the profile). Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42 challenge/verify -> JWT, idempotent /profile registration with collision retry, and /claim polling that NIP-04-decrypts each token (service pubkey read from the address's own LUD-16 metadata, constant fallback) and redeems it through ecash::receive_token. Mainnet-only; state cached 0600 in wallet/minibits.json. New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal fetches the address on tab open, renders QR + copy, and sweeps claims while open; a registration failure is non-fatal so paste-token still works. Verified end-to-end against production: registered a disposable @minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the claim poll returns cleanly. |