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
Collaborator

Summary

Adds a @minibits.cash Lightning address (LUD-16) to the ecash Receive tab, derived from and authenticated by the node's existing NUT-13 ecash seed (no second secret to back up). Any Lightning wallet can pay this address; the sats land as ecash.

This branch also includes several fixes found and verified live against real payments while getting the feature working end-to-end on archy-x250-pa3 (see commit messages for full detail on each):

  • Claim persistence / no silent fund loss: a Minibits claim is consumed server-side the instant it's fetched and can never be re-fetched. claim_and_redeem now persists every fetched claim before attempting decrypt/redeem, so a local failure retries next poll instead of losing the coins. Also self-heals the accepted-mints allow-list so the Minibits mint can never be excluded out from under a claim.
  • Corrupt-state recovery: a disk-full incident on archy-x250-pa3 truncated wallet/minibits.json to 0 bytes mid-write, which made every wallet.ecash-lnaddress call hard-fail forever ("Lightning address unavailable"). load_state now treats an empty/corrupt state file the same as a missing one and re-registers (registration is idempotent per pubkey, so it recovers the same address).
  • A real render crash ("click Receive, click Ecash, the modal disappears" — reproduced in both the browser and the Android companion's WebView): vue-i18n treats a bare @ as "linked message" syntax, so the address label's literal @minibits.cash crashed the message compiler the instant the address loaded. Fixed by escaping it as {'@'} (the same pattern the codebase already used elsewhere); found and fixed a second live instance of the same bug (a password-strength validator message) and added a full-locale-sweep test so this class of bug can't ship silently again.
  • Wrong claim delivery channel: POST /claim — the only source claim_and_redeem checked — never actually returns anything for a real Lightning payment. Confirmed live: Minibits delivers a payment as a NIP-04-encrypted Nostr DM published to relays, not via that REST endpoint. Now fetches from wss://relay.minibits.cash (+ fallbacks) instead, feeding the existing pending-claims retry pipeline unchanged.
  • Stray whitespace broke token parsing: once claims were actually being fetched, decryption revealed a trailing space in Minibits' own encoding that broke every base64 alphabet CashuToken::deserialize tried. Now trims the token string first — a general robustness fix (also protects a hand-pasted token with clipboard whitespace), not just a Minibits workaround.

Test plan

  • cargo test (backend, wallet:: — 120 passed) and vitest run (frontend, 1056+ passed), both clean
  • vue-tsc --noEmit clean
  • Deployed to archy-x250-pa3 and verified end-to-end against three real external Lightning payments (20 + 5 + 20 = 45 sats), all redeemed correctly on the first poll after the relay + whitespace fixes landed
  • Regression tests added for each fix: pending-claim retry, mint self-heal, corrupt-state recovery, the vue-i18n crash (both the specific string and a full-locale-sweep compiler test), and the token-whitespace trim

🤖 Generated with Claude Code

## Summary Adds a `@minibits.cash` Lightning address (LUD-16) to the ecash Receive tab, derived from and authenticated by the node's existing NUT-13 ecash seed (no second secret to back up). Any Lightning wallet can pay this address; the sats land as ecash. This branch also includes several fixes found and verified live against real payments while getting the feature working end-to-end on `archy-x250-pa3` (see commit messages for full detail on each): - **Claim persistence / no silent fund loss**: a Minibits claim is consumed server-side the instant it's fetched and can never be re-fetched. `claim_and_redeem` now persists every fetched claim before attempting decrypt/redeem, so a local failure retries next poll instead of losing the coins. Also self-heals the accepted-mints allow-list so the Minibits mint can never be excluded out from under a claim. - **Corrupt-state recovery**: a disk-full incident on `archy-x250-pa3` truncated `wallet/minibits.json` to 0 bytes mid-write, which made every `wallet.ecash-lnaddress` call hard-fail forever ("Lightning address unavailable"). `load_state` now treats an empty/corrupt state file the same as a missing one and re-registers (registration is idempotent per pubkey, so it recovers the same address). - **A real render crash** ("click Receive, click Ecash, the modal disappears" — reproduced in both the browser and the Android companion's WebView): `vue-i18n` treats a bare `@` as "linked message" syntax, so the address label's literal `@minibits.cash` crashed the message compiler the instant the address loaded. Fixed by escaping it as `{'@'}` (the same pattern the codebase already used elsewhere); found and fixed a second live instance of the same bug (a password-strength validator message) and added a full-locale-sweep test so this class of bug can't ship silently again. - **Wrong claim delivery channel**: `POST /claim` — the only source `claim_and_redeem` checked — never actually returns anything for a real Lightning payment. Confirmed live: Minibits delivers a payment as a NIP-04-encrypted Nostr DM published to relays, not via that REST endpoint. Now fetches from `wss://relay.minibits.cash` (+ fallbacks) instead, feeding the existing pending-claims retry pipeline unchanged. - **Stray whitespace broke token parsing**: once claims were actually being fetched, decryption revealed a trailing space in Minibits' own encoding that broke every base64 alphabet `CashuToken::deserialize` tried. Now trims the token string first — a general robustness fix (also protects a hand-pasted token with clipboard whitespace), not just a Minibits workaround. ## Test plan - [x] `cargo test` (backend, `wallet::` — 120 passed) and `vitest run` (frontend, 1056+ passed), both clean - [x] `vue-tsc --noEmit` clean - [x] Deployed to `archy-x250-pa3` and verified end-to-end against three real external Lightning payments (20 + 5 + 20 = 45 sats), all redeemed correctly on the first poll after the relay + whitespace fixes landed - [x] Regression tests added for each fix: pending-claim retry, mint self-heal, corrupt-state recovery, the vue-i18n crash (both the specific string and a full-locale-sweep compiler test), and the token-whitespace trim 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ssmithx added 7 commits 2026-09-08 22:33:33 +00:00
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.
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
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
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
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
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
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
Author
Collaborator

Branch analysis: feat/minibits-lnurl-receive

Date: 2026-09-08 · Repo: /home/debian/archy · Analysis only, no changes made.

Scope

  • 6 commits on top of main (base db52c06), plus 2 uncommitted files
    (core/archipelago/src/wallet/cashu.rs, core/archipelago/src/wallet/minibits.rs).
  • Commits: 6effc6b (feature), 76d565f (lossless claims), 3f52e4c (state
    self-heal), 3be6f45 / 6041eb6 / 3768395 (UI + i18n escapes + sweep test).

What the branch does

Gives the node a @minibits.cash LUD-16 Lightning address derived from the
node's existing ecash seed (NIP-06 Nostr keys at m/44'/1237'/0'/0/0,
seedHash = sha256(bip39 seed)), so restoring the same phrase in the Minibits
app recovers the same address. Adds RPCs wallet.ecash-lnaddress (register,
idempotent) and wallet.ecash-lnaddress-claim (redeem arrived payments), and a
QR + 8s claim-poll panel on the Cashu receive tab. Later commits harden claims
(persist before redeem, retry queue, accepted-mints self-heal), recover from a
truncated state file, and fix live vue-i18n @-compile crashes.

Verified (ran, without modifying anything)

  • cargo check -p archipelago: clean (one new deprecation warning from the
    uncommitted code: Timestamp::as_u64 → as_secs, minibits.rs:574).
  • Targeted Rust tests: 28 passed, 1 ignored (live-minibits test, #[ignore]).
  • UI tests (ReceiveBitcoinModal.test.ts, .i18n.test.ts,
    i18nMessagesCompile.test.ts): 5 passed.
  • Filter::pubkey() in the locked nostr 0.44.2 maps to the #p tag query
    (confirmed in vendored crate source, filter.rs:587) — so the DM filter
    correctly matches kind-4 events tagged to our pubkey; combined with the
    author == server_nostr_pubkey check (the correct authorization gate), the
    relay-DM intake is sound in principle.
  • Testnet guards present in both lnaddress() and claim_and_redeem().
  • State file (wallet/minibits.json, holds a bearer JWT) written 0600.
  • NIP-06 derivation locked by a test vector; seed_hash = sha256(BIP-39 seed)
    also vector-tested.
  • web/ is gitignored; the local dist rebuild (16:23 today) does contain
    all new lnAddress* strings — the "grep the built bundle" gate passes.

Issues

High — overlapping-claim race (uncommitted diff)

pollLnClaims (ReceiveBitcoinModal.vue:276-279) uses setInterval every 8s
with no in-flight guard, and neither wallet.ecash-lnaddress-claim nor
ecash::receive_token holds any lock (grep confirms no Mutex/RwLock/Semaphore
in dispatcher or ecash paths). Worst-case single poll duration — auth (2 HTTP
calls) + /claim POST (30s client timeout) + 800ms relay-handshake sleep + 10s
fetch_events + redeem loop (mint swaps) — far exceeds 8s. Two concurrent
claim_and_redeem runs then:

  1. both load_state the same last_dm_seen_at before either saves,
  2. fetch the same DMs and push duplicates into their separate copies,
  3. last-writer-wins on save_state → watermark rewound and/or pending
    tokens duplicated,
  4. → double redeem → "already spent" failures that never leave
    pending_claims → permanent orange "pending retry" banner and endless
    pointless retries.

Fix: a backend guard (per-data-dir mutex around claim_and_redeem) plus an
in-flight flag in the UI poll.

Medium — state loss poisons pending_claims forever

The 3f52e4c self-heal treats a corrupt/truncated minibits.json as "no
profile" — correct for the address (re-registration is idempotent per pubkey) —
but it also resets last_dm_seen_at to 0. Relays never forget: every
historical, already-redeemed DM is re-fetched, fails redeem as a double-spend,
and stays in pending_claims retrying forever (no drop-after-N-failures path,
no dedup of spent tokens). failed_count can never return to 0.

Medium — non-atomic state writes

save_state uses fs::write (truncate-then-write). The exact disk-full
truncation observed on archy-x250-pa3 can therefore destroy pending_claims
tokens that POST /claim already consumed server-side (unrecoverable —
/claim is once-only). Relay-sourced DMs are re-fetchable, /claim-sourced
ones are not. Fix: write-temp-then-rename.

Low — committed code

  • minibits_error (minibits.rs:199): &body[..body.len().min(180)] panics
    if a multi-byte UTF-8 char straddles byte 180 — a panic inside the error
    path of an RPC handler. Use chars().take(180) or floor to a char boundary.
  • register_profile: is_taken test is body.contains("already") — a
    non-collision error whose message contains "already" burns all 6 name
    attempts. Prefer the structured error.name == ALREADY_EXISTS.
  • fetch_relay_dms limit(200) + watermark jumping to max created_at: if
    more than 200 DMs ever accumulate since the last poll (relays return the
    newest 200), the older ones are silently skipped forever. Unlikely, but
    silent.

Low — uncommitted diff

  • Doc-comment mangling: fetch_relay_dms was inserted between
    ensure_mint_accepted's doc block and its signature — the
    "Make sure the Minibits mint is on the accepted-mints allow-list…" paragraph
    is now glued onto fetch_relay_dms, and ensure_mint_accepted has no doc
    comment at all.
  • New deprecation warning: Timestamp::as_u64 (minibits.rs:574) → as_secs.
  • Every 8s poll spins up 3 fresh WebSocket connections (connect → 800ms
    sleep → fetch → shutdown), including public relays relay.damus.io and
    nos.lol. Wasteful, and leaks the derived nostr pubkey's DM traffic to two
    public relays (metadata privacy). Consider a long-lived client or polling
    only the service relay first.

Process / design notes

  • The two working-tree files (relay-DM channel + cashu whitespace-trim fix) are
    tested and good but uncommitted/unpushed — against the repo's "commit &
    push every unit of work" rule. The cashu.rs trim fix is an independent
    regression fix (real 2026-09-08 incident: trailing space in Minibits NIP-04
    DM content) and deserves its own commit.
  • Opening the ecash receive tab silently registers a profile with a
    third-party service
    (random name derived from the node seed; mainnet-only,
    idempotent). Worth confirming that as a product default.
  • Claims are polled only while the modal is open; payments arriving while
    closed wait until the next open. Safe (relay DMs persist) and consistent
    with the "keep this screen open" hint.
  • Aside (not branch-related): origin's URL embeds a plaintext access token in
    .git/config — consider rotating/scoping it or using a credential helper.

Bottom line

Solid, well-documented feature with unusually good regression tests (each fix
carries the incident it fixes). Ship-blocking concerns are the uncommitted
relay-DM channel's concurrency story (overlapping polls racing on state) and
the related pending-claim poisoning; the minibits_error slice panic is a
cheap fix worth doing in the same pass. Everything committed builds green and
all targeted tests pass.

# Branch analysis: `feat/minibits-lnurl-receive` Date: 2026-09-08 · Repo: `/home/debian/archy` · Analysis only, no changes made. ## Scope - 6 commits on top of `main` (base `db52c06`), plus **2 uncommitted files** (`core/archipelago/src/wallet/cashu.rs`, `core/archipelago/src/wallet/minibits.rs`). - Commits: `6effc6b` (feature), `76d565f` (lossless claims), `3f52e4c` (state self-heal), `3be6f45` / `6041eb6` / `3768395` (UI + i18n escapes + sweep test). ## What the branch does Gives the node a `@minibits.cash` LUD-16 Lightning address derived from the node's existing ecash seed (NIP-06 Nostr keys at `m/44'/1237'/0'/0/0`, `seedHash = sha256(bip39 seed)`), so restoring the same phrase in the Minibits app recovers the same address. Adds RPCs `wallet.ecash-lnaddress` (register, idempotent) and `wallet.ecash-lnaddress-claim` (redeem arrived payments), and a QR + 8s claim-poll panel on the Cashu receive tab. Later commits harden claims (persist before redeem, retry queue, accepted-mints self-heal), recover from a truncated state file, and fix live vue-i18n `@`-compile crashes. ## Verified (ran, without modifying anything) - `cargo check -p archipelago`: clean (one **new deprecation warning** from the uncommitted code: `Timestamp::as_u64` → `as_secs`, minibits.rs:574). - Targeted Rust tests: **28 passed, 1 ignored** (live-minibits test, `#[ignore]`). - UI tests (`ReceiveBitcoinModal.test.ts`, `.i18n.test.ts`, `i18nMessagesCompile.test.ts`): **5 passed**. - `Filter::pubkey()` in the locked **nostr 0.44.2** maps to the `#p` tag query (confirmed in vendored crate source, `filter.rs:587`) — so the DM filter correctly matches kind-4 events tagged *to* our pubkey; combined with the `author == server_nostr_pubkey` check (the correct authorization gate), the relay-DM intake is sound in principle. - Testnet guards present in both `lnaddress()` and `claim_and_redeem()`. - State file (`wallet/minibits.json`, holds a bearer JWT) written **0600**. - NIP-06 derivation locked by a test vector; `seed_hash` = sha256(BIP-39 seed) also vector-tested. - `web/` is gitignored; the local dist rebuild (16:23 today) **does** contain all new `lnAddress*` strings — the "grep the built bundle" gate passes. ## Issues ### High — overlapping-claim race (uncommitted diff) `pollLnClaims` (ReceiveBitcoinModal.vue:276-279) uses `setInterval` every 8s with **no in-flight guard**, and neither `wallet.ecash-lnaddress-claim` nor `ecash::receive_token` holds any lock (grep confirms no Mutex/RwLock/Semaphore in dispatcher or ecash paths). Worst-case single poll duration — auth (2 HTTP calls) + `/claim` POST (30s client timeout) + 800ms relay-handshake sleep + 10s `fetch_events` + redeem loop (mint swaps) — far exceeds 8s. Two concurrent `claim_and_redeem` runs then: 1. both `load_state` the same `last_dm_seen_at` before either saves, 2. fetch the same DMs and push duplicates into their separate copies, 3. last-writer-wins on `save_state` → watermark **rewound** and/or pending tokens duplicated, 4. → double redeem → "already spent" failures that never leave `pending_claims` → permanent orange "pending retry" banner and endless pointless retries. Fix: a backend guard (per-data-dir mutex around `claim_and_redeem`) plus an in-flight flag in the UI poll. ### Medium — state loss poisons `pending_claims` forever The 3f52e4c self-heal treats a corrupt/truncated `minibits.json` as "no profile" — correct for the address (re-registration is idempotent per pubkey) — but it also resets `last_dm_seen_at` to 0. Relays never forget: every historical, already-redeemed DM is re-fetched, fails redeem as a double-spend, and stays in `pending_claims` retrying forever (no drop-after-N-failures path, no dedup of spent tokens). `failed_count` can never return to 0. ### Medium — non-atomic state writes `save_state` uses `fs::write` (truncate-then-write). The exact disk-full truncation observed on archy-x250-pa3 can therefore destroy `pending_claims` tokens that `POST /claim` **already consumed server-side** (unrecoverable — `/claim` is once-only). Relay-sourced DMs are re-fetchable, `/claim`-sourced ones are not. Fix: write-temp-then-rename. ### Low — committed code - `minibits_error` (minibits.rs:199): `&body[..body.len().min(180)]` **panics** if a multi-byte UTF-8 char straddles byte 180 — a panic inside the error path of an RPC handler. Use `chars().take(180)` or floor to a char boundary. - `register_profile`: `is_taken` test is `body.contains("already")` — a non-collision error whose message contains "already" burns all 6 name attempts. Prefer the structured `error.name == ALREADY_EXISTS`. - `fetch_relay_dms` `limit(200)` + watermark jumping to max `created_at`: if more than 200 DMs ever accumulate since the last poll (relays return the *newest* 200), the older ones are silently skipped forever. Unlikely, but silent. ### Low — uncommitted diff - **Doc-comment mangling:** `fetch_relay_dms` was inserted *between* `ensure_mint_accepted`'s doc block and its signature — the "Make sure the Minibits mint is on the accepted-mints allow-list…" paragraph is now glued onto `fetch_relay_dms`, and `ensure_mint_accepted` has no doc comment at all. - New deprecation warning: `Timestamp::as_u64` (minibits.rs:574) → `as_secs`. - Every 8s poll spins up **3 fresh WebSocket connections** (connect → 800ms sleep → fetch → shutdown), including public relays `relay.damus.io` and `nos.lol`. Wasteful, and leaks the derived nostr pubkey's DM traffic to two public relays (metadata privacy). Consider a long-lived client or polling only the service relay first. ### Process / design notes - The two working-tree files (relay-DM channel + cashu whitespace-trim fix) are tested and good but **uncommitted/unpushed** — against the repo's "commit & push every unit of work" rule. The `cashu.rs` trim fix is an independent regression fix (real 2026-09-08 incident: trailing space in Minibits NIP-04 DM content) and deserves its own commit. - Opening the ecash receive tab **silently registers a profile with a third-party service** (random name derived from the node seed; mainnet-only, idempotent). Worth confirming that as a product default. - Claims are polled only while the modal is open; payments arriving while closed wait until the next open. Safe (relay DMs persist) and consistent with the "keep this screen open" hint. - Aside (not branch-related): `origin`'s URL embeds a plaintext access token in `.git/config` — consider rotating/scoping it or using a credential helper. ## Bottom line Solid, well-documented feature with unusually good regression tests (each fix carries the incident it fixes). Ship-blocking concerns are the **uncommitted** relay-DM channel's concurrency story (overlapping polls racing on state) and the related pending-claim poisoning; the `minibits_error` slice panic is a cheap fix worth doing in the same pass. Everything committed builds green and all targeted tests pass.
Author
Collaborator

I am implementing fixes for above issues.

I am implementing fixes for above issues.
ssmithx added 2 commits 2026-09-09 03:58:14 +00:00
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
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
chaum closed this pull request 2026-09-12 13:37:17 +00:00
Collaborator

Closed at maintainer request pending a fresh review/requirements pass. The branch remains available for reference.

Closed at maintainer request pending a fresh review/requirements pass. The branch remains available for reference.

Pull request closed

This pull request cannot be reopened because the branch was deleted.
Sign in to join this conversation.