fc5b51ab2f88b9fe112930529b3dfb5dc3d91121
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
ffec7d3114 |
fix(ecash): pay the mint's input fee, and stop a damaged wallet from being erased
Two independent fixes, both found while exercising the routes headlessly. **Mint fees (NUT-02).** A mint may charge a per-input fee and rejects any swap whose outputs don't equal inputs minus that fee — `11005 Transaction inputs should equal outputs less fee`, which is what sending hit against testnut.cashu.space. We ignored the fee entirely, so the wallet could not spend at ANY fee-charging mint; Minibits charges zero, which is why production never saw it. `MintKeyset`/`KeysetInfo` now carry `input_fee_ppk`, `swap_fee_for` computes the NUT-02 sum (rounded up), and `MintClient::swap` reduces its outputs to cover it — applied there rather than at each call site so send, receive and cross-mint swaps are all covered at once. Inputs from a keyset the mint doesn't list contribute no fee: the mint is the authority, and guessing high would burn the sender's coins. **Damaged-wallet erasure.** `load_wallet` used `unwrap_or_default()`, so a truncated `ecash.json` read as an EMPTY wallet — and because the next operation saves the wallet back, that empty state was then written over the only copy of the proofs. A corrupt file became permanent loss. Now a file that exists but doesn't parse fails with a message naming the file and stating the coins are still in it, and the bytes are left untouched for recovery; an empty file is still treated as a fresh wallet, since a create that never got its first write is not damage. The accepted-mints list gets the same treatment, where corruption would have silently reset the operator to trusting only the default mint. Writes are now atomic (temp + fsync + rename) for both files. The previous plain write truncated the real file first, which is exactly how a wallet ends up unparseable after a crash or power cut. Tests cover: a damaged file errors and survives on disk, an empty file is fresh, saving leaves no temp behind and round-trips, and — guarding the on-disk contract against exactly this update — a verbatim pre-update wallet file still loads with its balance, proofs and history intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
26638aa621 |
fix(ecash): sign with the mint's SAT keyset, not whichever came first
`get_active_sat_keyset` picked the first keyset with a non-empty key map,
and `MintKeyset` had no `unit` field to filter on — so on a multi-unit mint
the wallet signed sat-denominated mint/swap requests against a usd or eur
keyset. The mint refuses that with `11013 Unit unsupported`, which is
exactly what claiming minted coins hit against testnut.cashu.space (it
serves usd, eur, msat and sat keysets). Minibits is sat-only, so this
latent bug never surfaced in production — the test-mint switch found it on
its first run.
MintKeyset now carries `unit` and `active`, both defaulted so a sat-only
mint that omits them still parses, and selection filters to sat and prefers
an active keyset.
Also: pin BIP-39 seed derivation to the specification's own test vectors.
The node's entire identity hangs off `Mnemonic::to_seed("")`, and the
`bip39` crate is no longer version-pinned (the exact pin had to be relaxed
so `cashu` could resolve). A bump that changed derivation would silently
re-key every node on the fleet and orphan every backup; both vectors —
empty passphrase and the NFKD-exercising passphrase arm — now fail the
suite instead. Verified byte-identical under the newly resolved 2.2.2.
And the route script polls the mint's quote state before claiming: the test
mint settles its own invoices, but not instantly, so claiming immediately
raced the settlement and reported a spurious "Quote not paid".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
be2cfb8293 |
feat(ecash): adopt the reference NUT-02 resolver + real/test network switch
Demo images / Build & push demo images (push) Failing after 2m26s
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
|
||
|
|
2277fc4684 |
fix(ecash): redeem tokens whose keyset id was truncated to the old length
A Minibits token could not be redeemed on framework-pt: the mint answered POST /v1/swap with a bare 422, which the RPC sanitizer turned into "Operation failed. Check server logs for details." The journal had the real reason: inputs[0].id: NUT02: ID length invalid, expected 8 bytes (short/v1) or 33 bytes (v2) The token carried keyset id 01fc0ec0e59cd6fa — exactly the first 8 bytes of the mint's active 33-byte id 01fc0ec0e59cd6fa01b7a88f…a821. NUT-02 v2 ids are 33 bytes behind a 0x01 version byte; the sending wallet cut it to the 8 bytes that were the whole id under v1. The mint reads the version, expects 33 bytes, and rejects it — so the length complaint is right even though 8 bytes is legal for a 0x00-prefixed v1 id. The id only names which keyset signed a proof, and the short form is a prefix of the full one, so it can be repaired: before swapping, any 8-byte 0x01-prefixed id is expanded against GET /v1/keysets (new MintClient::get_keysets — it lists inactive keysets too, and coins from a retired keyset stay spendable). Preferring the active keyset on a prefix tie. Attempting this is safe: an id naming the wrong keyset fails signature verification at the mint and no coins move. Anything already valid, or with no unambiguous match, is passed through so the mint's own error still reaches the operator. Token decoding now also checks keyset ids locally, so an id that is not hex or is neither NUT-02 length fails with a message naming the format instead of a raw 422 from the mint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b67e1527a2 | Archipelago — open-source initial import |