100 Commits
Author SHA1 Message Date
minibits-cash c0159bc078 Fix converted amount input size 2026-09-11 23:49:18 +02:00
minibits-cash c9eefcfc0d Clean up logs 2026-09-11 23:10:24 +02:00
minibits-cash bf5a165477 Fix stacked resubscriptions 2026-09-11 22:45:05 +02:00
minibits-cashandClaude Opus 5 ace952dd7e Upgrade nostr-tools from the fork to upstream 2.25.2
The fork existed only to strip the package's `exports` map so metro would
bundle the TS source; upstream now ships a build that react-native
consumes directly (README documents the polyfills, all of which the app
already installs), so the fork has no reason to exist.

Fallout of the upgrade:

- `subscribeMany` now takes a single filter rather than an array.
- `SimplePool` drops a relay from its map when it gives up on it, so an
  empty map - not a `false` status - is what "everything is down" looks
  like now. Opt into `enableReconnect` so the pool restores dropped
  connections and refires their subscriptions with an advanced `since`.
- `message-port-polyfill` was there for the old nostr-tools; nothing in
  the tree references MessagePort any more.
- The published build is plain CJS/ESM, so jest can load the real library
  and the mock that stood in for it is gone. Nested @noble copies need
  babel, hence the transformIgnorePatterns tweak.

Covered by a NIP-06 derivation test against the standard vector, so the
identity the wallet derives from a seed phrase is provably unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:23:17 +02:00
minibits-cashandClaude Opus 5 2632b3dcf7 Link zaps to addressable events with an naddr
A zap to a zapstore app (kind 32267) was linked on njump as an nevent
built from the zap request's `e` tag, which njump cannot resolve. Zaps to
addressable events carry an `a` tag (kind:pubkey:d) - encode that as an
naddr instead, and pass the zap request's relay hints into both forms.

Fixes #199

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:12:22 +02:00
minibits-cashandClaude Opus 5 38fbfb8309 Adopt the amount-entry layout on OnchainTransferScreen
Same shape as the other amount screens, but with two things they do not have.

The BIP21 URI can name the amount. It stays editable — the payee's amount is a
suggestion, not a commitment — but the user is then confirming an amount rather
than entering one, and amount entry hides everything else. On this screen
"everything else" includes the Bitcoin address being paid, which is the one thing
worth checking before paying it. So the screen opens in entry mode only when the
URI left the amount out; tapping the amount still opens it either way.

Editability depends on data that arrives late. hasNoOnchainMint is answered from
cached mint info, and a mint whose capabilities were never fetched reports
"cannot melt onchain" until the background refresh lands — so for the first few
hundred milliseconds the field is not editable. The old fixed 100ms timer fired
into that window, and since focus() on a non-editable field does not raise the
keyboard, the keyboard silently never appeared. Focus now waits for the field to
become editable, and the entry-mode focus deadline counts from that same moment
rather than from mount, so the screen cannot settle just as the autofocus fires.

Editability had been spelled out twice; it is now named once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:01:12 +02:00
minibits-cashandClaude Opus 5 e049bba69f Drop the caption's top margin on ReceiveScreen
Matches the spacing the other amount screens settled on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:49:38 +02:00
minibits-cashandClaude Opus 5 51bbadae06 Tighten the gap between mint name and balance in MintHeader
Two things were spacing the lines apart, neither of them deliberate.
CurrencyAmount pads itself on all four sides — useful when it stands alone, but
here the top edge only pushes the balance away from the name — and the xxs text
preset's line box is half again as tall as its text, so the name carried empty
space below it too.

The balance's top padding is now dropped at the call site (the sides stay: the
name aligns its right edge to them), and the name takes the font's own line
height instead of the preset's — tight enough to close the gap, loose enough
that a descender is not clipped.

The name was already bounded and single-line, so it already ellipsised;
ellipsizeMode is now stated rather than left to the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:41:13 +02:00
minibits-cashandClaude Opus 5 a0ab95ac07 Fill the header colour to the screen bottom until the keyboard is real
The colour reached only as far as the ASSUMED keyboard height, so for the few
hundred milliseconds before the keyboard opened it stopped mid-screen and drew a
hard line across it.

"Where the colour reaches" and "where the amount is centred" are now separate
values, because they want different answers to the same uncertainty. The amount
is placed once, against the estimate, for the layout it is about to be in —
moving it as the keyboard arrives would shift it under the user. The colour has
no reason to stop at a keyboard that is not there yet, so it fills the screen and
closes up to the keyboard's edge as the keyboard rises, in step with it.

useKeyboardTop now says which of the two it is reporting: the value it delivers
on mount is a remembered or assumed height, not a keyboard on screen, and
closing the colour against that was the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:32:15 +02:00
minibits-cashandClaude Opus 5 edfd9419e4 Derive the header's bottom edge instead of measuring it
On Android the screen came up with its own background instead of the header
colour and an invisible (but typable) amount field, righting itself only once
something forced a re-layout.

Everything in this layout is positioned against the top of the animated area,
and that value came from measureInWindow on the wrapper. On Android that call
can answer 0 for a view that is laid out but not yet attached, and there is no
second chance — the wrapper's own layout does not change again, so its onLayout
never fires again. The fade-in that hides the first-frame snap waited on that
value, so it stayed at zero, and with it the opacity of the backdrop, the amount
block and the content.

The header is a status-bar inset plus a fixed row, and both numbers are known on
the first render, so the offset is now computed from them — no measurement, no
callback, correct on frame one. HEADER_HEIGHT is exported from Header for it.
The fade now waits only on the amount block's own onLayout, and even that has a
400ms backstop: the fade is what makes the screen visible, so it must never be
able to stay at zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:08:34 +02:00
minibits-cashandClaude Opus 5 98b50df594 Move the mint name beside the balance so the unit pill stops moving
The name sat above the pill in the header's centre block, which is centred as a
group — so the name's arrival pushed the pill down. It is resolved
asynchronously and lands a beat after the screen does, which is what made the
shift visible: the header settles, then re-settles.

The name now sits over the balance on the right, both right-aligned. That block
is present exactly when a mint is, so the name cannot shift anything by
appearing — it arrives with the block or not at all — and the pill's position no
longer depends on what is known about the mint.

The block is now rendered for any mint rather than only when a balance is shown,
so TranDetailScreen (hideBalance) keeps its mint name; the balance alone is what
hideBalance drops, and the tap-to-mask toggle is disabled when there is none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:50:33 +02:00
minibits-cashandClaude Opus 5 37132444f8 Extract the amount-entry layout and adopt it on three more screens
All four amount screens had byte-identical $headerContainer, $amountContainer
and $contentContainer styles, so this is one extraction rather than four copies
of an animation. AmountEntryLayout owns everything below a screen's own header —
both states, the geometry, the measurements — and useAmountEntry owns the state
that drives it. A screen hands over its amount block and its content and is
otherwise untouched; TopupScreen loses ~250 lines it no longer needs.

Two options cover what differs between the screens, and both mirror something
each screen already declares:

  isEnabled mirrors the AmountInput's own `editable`, and covers the amounts
  that arrive already decided — a scanned invoice, a Cashu request that names
  its own amount, a transaction that has moved on to pending. Entry mode stays
  shut for those.

  initiallyExpanded mirrors the screen's autofocus condition. Only TransferScreen
  needs it: it focuses the amount for LNURL pay alone, so that is the only case
  where it opens in entry mode. Its Continue button lives in the content, so it
  is hidden while the amount is being entered and appears when the amount is
  confirmed.

Two things the shared version needed that a single screen did not:

  Entry mode is driven by focus, so a screen that opened expanded and never
  received one would sit there with no keyboard, no content, and no way out but
  the back button. It now waits 1.5s for the focus it expected and settles if it
  does not arrive.

  Tapping the empty space around the amount now confirms it. On iOS that is the
  ONLY way to: the amount fields use a decimal pad, which has no return key
  there, and entry mode has hidden everything else that could have taken focus.

No business logic changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:43:46 +02:00
minibits-cashandClaude Opus 5 d6614b2877 Let the swap hint animate fully shut
Yoga floors a node's height at its own padding and border, so the hint container
could never reach zero: it bottomed out at the SWAP_HINT_BASELINE_SLACK padding
that lifts the arrows onto their optical centre, and left exactly that much blank
space wedged between the amount and its converted value in the settled layout —
enough to throw the amount, the converted amount and the caption out of vertical
alignment.

The slack now rides on the glyph's bottom margin instead. It spaces the glyph
identically, since the box centres the glyph plus its margin, but a child's
margin is not part of the parent's height floor, so the container collapses to
nothing as intended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:27:37 +02:00
minibits-cashandClaude Opus 5 91c11e9123 Space the swap arrows optically and centre the whole amount block
Two things read wrong in amount entry.

The arrows sat closer to the converted amount than to the amount above them,
because they were centred in the gap between the two text BOXES — and a text box
is taller than the digits it draws. A 56px amount carries roughly a quarter of an
em of empty descender space below its digits; the 16px converted line carries the
same fraction of a much smaller em. The gap above was therefore already padded and
the gap below was not. The hint's height is now built from the numbers actually
being reasoned about — the clear space wanted on each side, the glyph, and the
slack the top amount's own descender already contributes — and that slack is
applied as bottom padding, which lifts the arrows onto the optical centre. Both
gaps also got wider.

Centring measured the AmountInput alone, so the caption below it hung past the
middle and the block as a whole sat low. The amount, its converted value, the
hint and the caption now live in one box that hugs its content, and that box's
centre is what gets centred. $headerContainer could not serve: its height is
fixed, so its centre describes the settled layout rather than the content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:21:45 +02:00
minibits-cashandClaude Opus 5 1e4530a0df Prototype animated amount entry on TopupScreen
The screen now has two shapes. While the amount is being entered it is nothing
but the amount: the header colour fills everything the keyboard leaves visible,
and the amount block sits in the middle of it — close to where the wallet
balance sat on the screen the user just came from. Confirming the amount settles
it back into the familiar layout, in step with the keyboard going down.

Both shapes are the same tree. Nothing is mounted, unmounted or re-laid-out to
switch between them: the amount block and the content are translated, the
content is faded, and only the header colour — split out into an absolutely
positioned backdrop so it can grow without moving anything — changes height. The
whole transition therefore runs on the UI thread.

Driven off focus rather than off the submit handler, so the collapse rides the
keyboard down for free and an amount that fails validation puts the layout back
instead of stranding the user on a screen with no keyboard and nothing else on
it. A short grace period on blur keeps the two amount fields' focus swap from
collapsing and re-expanding the screen.

AmountInput gains an opt-in swap hint: a gap between the amount and its
converted value with a tappable pair of arrows in it, the only cue that the
converted line is an input too. It animates open and shut with the screen and
is off for every screen that has not asked for it.

No business logic changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:12:46 +02:00
minibits-cash c282ee5baf Fix transaction detail of import from backup tx 2026-09-07 21:39:49 +02:00
minibits-cash 2bd9f05cb6 Add transaction item on completed import, fix bad imports across the repo 2026-09-07 17:31:16 +02:00
minibits-cash 32b0b029fb New import strategy merging with existing wallet fi any 2026-09-07 17:31:16 +02:00
minibits-cash 4401ec3c84 Fix backup export and import. Encrypt backup to the seed. 2026-09-07 17:31:16 +02:00
minibits-cashandClaude Opus 5 03e532e7c9 Delegate keyset choice to cashu-ts, reuse the restore wallet, persist lazy keychain updates
Three changes to WalletStore, all following from what 4.10 now offers.

1. Keyset selection. getOptimalKeyset mirrored KeyChain.getCheapestKeyset by
hand, and the two orderings had diverged: 4.10 (#836) sorts by keyset id
VERSION first, then fee, then final_expiry, while the copy sorted by fee and
used version only as a tiebreak. They disagree whenever a mint prices its
newer keyset above an older one, and the copy would then keep minting on the
superseded keyset — which is the one a mint can retire, stranding ecash on it.
The library's order is now used.

This is a deliberate behaviour change, not just dedup: a user CAN pay a higher
input fee after this, when a mint prices its current keyset above a retired
one. keysetSelection.test.ts pins the ordering against the real library and
also pins the divergence, asserting the old fee-first rule would have chosen
differently, so the decision stays visible.

Delegating also picks up two things the copy could not express: getCheapestKeyset
filters on hasKeys, so a keyset whose keys are missing is never selected (it
cannot create outputs) rather than being selected and failing later; and
hasHexId classifies odd-length hex ids as legacy (#840), which the old
/^[0-9a-f]+$/i test accepted. getWallet now builds the KeyChain cache once and
uses it for both the choice and loadMintFromCache.

2. Restore. WalletStore.restore built a CashuMint, a CashuWallet and called
loadMint() on EVERY batch. SeedRecoveryScreen calls it once per 50 counters, so
a recovery scanning a few hundred indices repeated getInfo/getKeysets/getKeys
that many times, and discarded the BIP-32 parent-node cache that 4.7.2 (#802)
added to the deriver — per-instance, and meant for exactly this repeated
derivation. The instance is now reused per mintUrl|unit|keysetId, held in
volatile state so the seed is never snapshotted, and cleared by resetWallets.

3. Keychain writeback. cashu-ts updates its keychain mid-operation — lazily
loading keys for a keyset we lack, or repairing an unknown id via loadMint(true)
— and nothing persisted it, so the next wallet built from the Mint model
re-fetched the same keys. Newly created wallets now subscribe to
on.keychainUpdated and write the cache back. This matters more since the
receive-path ensureKeysetKeys loop was removed: that lazy fetch is now the only
thing loading those keys. The handler swallows its errors — it runs inside the
caller's operation and must not break it, and initKeyset throws on a unit the
wallet does not support, which a multi-unit mint produces.

Also de-flakes proofSelectionRotating.test.ts from the earlier commit. Two of
its assertions compared two independent selectProofsRGLI runs, but RGLI is
Randomized Greedy with Local Improvement and may return different, equally
valid sets per call, so those comparisons failed intermittently (caught by
repeated full-suite runs, ~2 in 5). They now assert stable properties —
rotating adds no stale bias when nothing is stale, RGLI never force-includes a
whole stale bucket, and rotating is always dearer when one exists — over
repeated draws instead of equality between single runs.

Verified: tsc --noEmit unchanged against baseline (89 pre-existing, none new),
48 suites / 650 tests pass across six consecutive full runs, clean device
reload with all mints hydrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:50:49 +02:00
minibits-cashandClaude Opus 5 6d7cd0f299 Delete the receive-path ensureKeysetKeys loop, now redundant
WalletStore.receive pre-loaded keys for every keyset that signed an incoming
token's proofs, because cashu-ts DLEQ-verifies each input proof carrying a
DLEQ and threw "Undefined key for amount N in keyset X" when those keys were
absent. That bites after a mint migration, where the keyset that signed all
existing ecash goes inactive and getKeys() — which returns ACTIVE keysets
only, per NUT-01 — stops returning it.

cashu-ts 4.9 made the loop redundant. Traced in the shipped bundle:
wallet.receive -> prepareSwapToReceive calls _ensureOperableKeysets over the
token's own proof ids, and it runs BEFORE the DLEQ loop that used to throw.
With fetchKeys unset it filters to exactly the input keysets lacking keys and
calls the same keyChain.ensureKeysetKeys the loop called, then additionally
repairs ids it does not recognise at all via loadMint(true) — which the loop
could not do, since ensureKeysetKeys only resolves keysets already known.

inactiveKeysetKeys.test.ts is re-pointed accordingly. Its KeyChain-level tests
stay, reframed as the mechanism cashu-ts uses internally rather than as the
fix the wallet relies on, and a new describe supplies the evidence that
licenses the deletion: a Wallet loaded from an active-keys-only cache — what
WalletStore.getWallet really builds — is handed a token signed by the inactive
keyset, and prepareSwapToReceive is shown to fetch that keyset by id, land the
keys in the keychain, and prepare without throwing.

That block also pins the precondition the deletion depends on:
_ensureOperableKeysets returns early when the wallet has no mint info, so on
such a wallet there is no safety net at all. WalletStore.getWallet always
loads mint info first, via loadMintFromCache or loadMint, which is what makes
removing the loop safe — asserted so the invariant cannot break silently.

Not reachable on a device: it needs proofs signed by a keyset the mint has
retired, which a mint will not issue on request. The unit test is the coverage.

Verified: tsc --noEmit unchanged against baseline (89 pre-existing, none new),
47 suites / 642 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:27:58 +02:00
minibits-cashandClaude Opus 5 a5ac3d7487 Stop deleting the melt recovery record when a melt may have succeeded
WalletStore.payLightningMelt deleted the melt recovery record for any error
whose message did not mention a timeout or a network failure. That heuristic
is wrong for the case cashu-ts 4.10 now names explicitly.

MeltChangeError is raised by completeMelt only AFTER the mint executed the
payment: the inputs are spent, the payment stands, and solely the NUT-08
change could not be reconstructed. Its message mentions neither timeout nor
network, so the old test fell through to the delete branch — one step before
TransferOperationApi._handleExecuteError re-checks the quote, finds it PAID,
and calls recoverMeltQuoteChange, which reads exactly that record. Recovery
then failed with "MeltPreview not found", was swallowed as "Change recovery
failed", and the transaction was marked RECOVERED with zero change. The
payment went through and the user silently forfeited the change.

This predates 4.10 — on 4.7.1 the same path was reachable whenever
createMeltChangeProofs threw, which was MORE likely then, since 4.10 loads
the change keyset's keys before building. 4.10 only made it a named type.

The fix is not to special-case one error but to give the record a coherent
owner. WalletStore cannot know whether the mint acted on a request that
threw, so it no longer guesses: both melt catches keep the record. Removal
moves to the code that learns the quote's terminal state:

  PAID     recoverMeltQuoteChange / _unblindMeltChange remove it (already did)
  PENDING  kept — the async monitor still needs it
  UNPAID   removed, in _handleExecuteError and the async revert path

The UNPAID half also closes a pre-existing leak: an async melt resolving
UNPAID left an orphaned row forever, because the only two cleanups sat on the
PAID path. Reproduced on device — two failed melts left two orphans while a
successful one cleaned up after itself. It is included here rather than split
out because fixing only the catch would have made that leak worse, and
meltRecovery.test.ts already documents the intended contract as "removed on
terminal success/failure".

Adds meltChangeError.test.ts pinning the cashu-ts contract this now depends
on: the type is exported, distinguishable by instanceof, and carries
outputData and quote. It also pins the bug itself — asserting that the old
message heuristic would NOT have kept the record — so the reasoning cannot
quietly rot.

Verified: tsc --noEmit unchanged against baseline (89 pre-existing, none new),
47 suites / 637 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:16:06 +02:00
minibits-cashandClaude Opus 5 0efd00cc5b Retire three cashu-ts workarounds made redundant by 4.10
Each of these existed only because the library lacked something it now has.

mintInfoSummary widened GetInfoResponse with icon_url, tos_url and urls —
fields every mint publishes but cashu-ts did not type. 4.10 (#1004) carries
all three plus time and max_array_length, so the intersection is gone and
MintInfo is a plain alias.

mintUrl reimplemented cashu-ts's canonical form because normalizeUrl was
@internal; its docblock carried a standing obligation to keep the copy in
step by hand. 4.8 (#845) renamed it normalizeMintUrl and made it public, so
the copy is replaced by delegation and that obligation is gone. The https
requirement is ours and stricter, so it stays, applied to the normalized url.
A side effect worth naming: cashu-ts also rejects credentials, query strings,
fragments and percent-encoded paths, which previously fell to the later
new CashuMint() call — delegating moves those rejections strictly earlier.

WalletStore read nuts['19'] behind four @ts-ignore comments to decide whether
to record a NUT-19 in-flight request. isSupported(19).supported drops the
ignores and is deliberately stricter: cashu-ts reports supported only when
cached_endpoints is non-empty, whereas a bare truthy nuts.19 object also
matched a mint advertising the key with no cache behind it. Recording an
in-flight request there was never useful, since the replay it enables depends
on the mint actually caching the response.

Adds proofSelectionRotating.test.ts to pin the one behavior change 4.10 makes
that a device cannot reach: the default selector moved to selectProofsRotating
(#818), which force-includes whole stale keyset buckets. It is decision-neutral
— it documents both selectors rather than blessing one — and guards the
invariant that matters either way: with includeFees, the selected set must
cover the target plus the input fee on itself.

Verified: tsc --noEmit unchanged against baseline (89 pre-existing, none new),
46 suites / 629 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:58:29 +02:00
minibits-cashandClaude Opus 5 f5b7c24699 Upgrade cashu-ts from 4.7.1 to 4.10.0
Mechanical dependency bump with no source changes, so that any behavior
change is attributable to the library alone.

The lockfile moves only the cashu-ts entry: @scure/bip32, @scure/base,
@noble/curves and @noble/hashes all stay at 2.2.0. That matters because
patches/@scure+bip32+2.2.0.patch swaps hmac-sha512 for react-native-quick-crypto
on the BIP-32 derivation path, and is pinned to that exact version — a float
to 2.3.x would fail patch-package and silently drop native acceleration from
seed recovery. All 9 patches still apply.

Verified: tsc --noEmit is unchanged against baseline (89 pre-existing errors
before and after, none new, none resolved), 45 jest suites / 618 tests pass,
and the android Metro bundle builds with the 4.10 symbols present.

Not covered here: cashu-ts's internal proof selection default changed to
selectProofsRotating (#818), which only runs inside wallet.send() against a
live mint and so is invisible to the suite. That is the subject of the
follow-up device testing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:56:54 +02:00
minibits-cash a957d71287 Fix and expand the NWC transaction list, report full wallet balance over NWC 2026-09-04 14:25:48 +02:00
minibits-cash 7d6be05eab Ensure up to date keysets on receive 2026-08-11 14:25:47 +02:00
minibits-cash 66108db73e Wallet and headers margins alignment 2026-08-10 23:12:11 +02:00
minibits-cash b017795b65 MintInfo screen redesign 2026-08-10 22:21:14 +02:00
minibits-cash 79d720f694 Hide tab bar on ReceiveScreen 2026-08-07 14:22:22 +02:00
minibits-cashandClaude Opus 4.8 bfd4c44c8c Load inactive keyset keys on receive; sync keys[].active
Receiving ecash after a mint migration failed with "Undefined key for
amount N in keyset X". Diagnosed against the live nutshell->cdk migration
(mint.minibits.cash/Bitcoin -> /sat): cdk migrates the keyset that signed
all existing ecash (00107937...) as INACTIVE and issues a new active v2
keyset.

The wallet only ever fetches ACTIVE keys — getKeys() with no id, which by
NUT-01 returns active keysets only. cashu-ts DLEQ-verifies every input proof
that carries a DLEQ (nutshell attaches one), regardless of requireDleq, and
throws when the signing keyset's keys are not loaded. So every incoming
proof from the now-inactive keyset is unreceivable.

Fix: before receive, ensure the wallet's keychain holds keys for every
keyset that signed the incoming proofs, via cashu-ts ensureKeysetKeys, which
fetches /v1/keys/{id} (served for inactive keysets too), verifies, and
no-ops once present. Proven end to end against the live cdk mint: the
inactive keyset goes from no-keys to verified-with-amount-16.

Also a second, lower-severity bug it exposed: Mint.setIsActive updated only
the keysets array, never the parallel keys array — which carries its own
MintKeys.active. getKeys() never re-fetches an inactive keyset, so its keys
entry kept a stale active:true forever after a migration. setIsActive now
mirrors the flag onto the keys entry too.

Not device-tested — the receive path needs a real device against a mint with
an inactive signing keyset, i.e. the cdk test mint while it is still up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:35:37 +02:00
minibits-cash e5286973aa Update README 2026-08-04 15:06:06 +02:00
minibits-cash a368a48b22 Bump version 2026-08-04 14:46:58 +02:00
minibits-cash 8b7c9e4d36 Support keysets v2, improve and extend prioritization of inactive keysets to swap or spend them 2026-08-04 14:46:58 +02:00
minibits-cashandClaude Opus 4.8 e2416dacee Add Onyx (deep black) theme
New AMOLED-style theme with pure-black surfaces, wired into the theme
picker, splash screen, and useThemeColor. Reuses the Default launcher
icon, so no native/launcher-icon config is required.

- colors: add ThemeCode.ONYX, palette.black, and the onyx color block
- cards: make the drop shadow theme-aware (cardShadow) and drop it for
  Onyx, where a glow reads as a grey halo on black; applied in Card and
  the two Contacts list "fake cards"
- separator: darker than the card fill so list rows stay visible on black
- active tab icon: Bitcoin brand orange, matching the mint/SAT marks
- fix a latent Button bug exposed by Onyx: MintBalanceSelector always
  passed a LeftAccessory that rendered null, tripping the button's
  paddingLeft reduction and clipping plain secondary-button labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 14:46:58 +02:00
minibits-cashandClaude Opus 4.8 ff88d1b00f Require the seed to be copied before recovery can be opened
Recovery ends in saveWalletKeys with whatever mnemonic is typed into it. A
user who reaches it without the phrase in hand and enters a different one
overwrites the very keys this screen exists to protect — and on an orphaned
seed those keys are the only remaining route to the old wallet's ecash. So
the copy is a precondition, not ceremony: recovery is about to ask for
exactly this phrase.

The row stays visible but reads as unavailable (dimmed, neutral icon) and
its subtitle says what to do; tapping explains why rather than doing
nothing.

Start fresh is deliberately NOT gated the same way. It already carries a
confirmation naming what is lost, and a user with an empty wallet has a
legitimate reason to discard without keeping anything — forcing a copy there
would be a pointless step on the one path where the seed genuinely does not
matter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 14:46:58 +02:00
minibits-cashandClaude Opus 4.8 a3051d327a Ask before resuming a seed that outlived its wallet
Deleting an app on iOS removes its container but NOT its keychain items, so
a reinstall comes back with the seed intact and every derivation counter
gone with the database. Onboarding treated that as a happy path — keys
found, reuse them, carry on — which resumes a used seed at counter 0 and
re-derives blinded secrets the mint has already signed. That is the
duplicate _B that SeedRecoveryScreen advances the counter to avoid; the mint
then either rejects the outputs or returns proofs that are already spent.

Not a regression: counters died with an iOS reinstall when they lived in
MMKV too. But a native release is exactly what prompts people to delete and
reinstall, so it is worth closing first.

Only ONE of the onboarding cases is unsafe, and the discriminator is the
schema, not the keys. Keys with an intact database mean a replayed
onboarding or terms re-agreement — the wallet and its counters are fine and
the user should not be interrupted. Keys with NO database mean the container
was wiped and the keychain survived it. Android reaches that state with no
keys at all (the keystore dies with the uid, allowBackup="false" stops a
Backup restore) and needs no handling, as the tests pin.

The fact is knowable exactly once, inside _createOrUpdateSchema, and is gone
immediately after: by the time any screen renders, setupRootStore has long
since called getInstance(). So instance.ts records it and setupRootStore
snapshots the pair at startup.

The snapshot is the subtle part. A live "keys exist AND schema is new" check
triggers on ITSELF: on a fresh install the schema IS new, so the moment
onboarding saves its keys the condition turns true, and anyone who backs out
and returns is offered the chance to reset a seed thirty seconds old. The
question is not "are there keys now" but "were there keys before we ran".

What the user is offered, per their own seed:
- RECOVER — keep it. Recovery walks the derivation space, moves each counter
  past what the mint has seen, restores the ecash, and recovers the profile
  from the seedHash so the minibits.cash address survives.
- START FRESH — discard it. Safe by construction, but it abandons whatever
  the old seed holds AND changes their identity: the Nostr keypair is
  NIP-06-derived from the mnemonic and walletId is regenerated, so the
  address changes and contacts can no longer reach them. Hence the mnemonic
  is shown and copyable before this is offered, and it takes a confirmation.

SeedRecoveryScreen now sets isOnboarded, because it has become an exit from
onboarding and was previously only ever reached from a wallet that was
already past it. A no-op for that caller.

Also drops torService from the services barrel: the file is zero bytes, so
it is not a module, and the export was a standing tsc error. Nothing imports
it. Net tsc errors 91 -> 90; no new ones.

NOT device-tested — the iOS reinstall path needs a real device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 14:46:58 +02:00
minibits-cashandClaude Opus 4.8 9ff5e1f39c Pin the built schema against the migrated one; drop the dead workflows
instance.ts is strictly either/or: a database with no version row is BUILT
from createSchemaQueries, everything else is MIGRATED. Nothing checked that
the two agree, and today nothing would notice — the shipped native bundle is
v26, so every real device (brand-new installs included) takes the migrate
path and the build path runs only on simulators.

The next native release inverts that. It makes createSchemaQueries the floor
for every new install while existing wallets keep the migrated shape forever,
so any disagreement forks the population permanently and every migration
written afterwards is correct for only one half.

They agree today. This keeps it that way: both databases are built side by
side and compared table by table — names, types, nullability, defaults and
primary keys. Verified by sabotage rather than by the green tick: a column
added to a live schema constant with no migration behind it fails it.

Also deletes jsCodePush-{PROD,TEST}.yaml. Releases go through hot-updater
now, so these describe a path that no longer exists. PROD had rotted anyway
— both branches of its version-change check set IS_VERSION_CHANGED=true, so
it would deploy unconditionally and then fail creating a tag that already
existed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:35:58 +02:00
minibits-cashandClaude Opus 4.8 34114a5c1e Merge branch 'onchain'
Onchain (NUT-30) mint and melt, plus the storage rework it required:
derivation counters keyed by keysetId, mints and their keysets mastered
in SQLite, and every table referencing mints by stable id rather than by
url. Upgrade path verified on device from db v26 / rootStore v32 — the
shape the last native release installs — through to db v35 / v39, and on
production Android and iOS wallets with long histories.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:19:20 +02:00
minibits-cash b741719932 Bump version 2026-07-17 11:15:49 +02:00
minibits-cashandClaude Opus 4.8 8e73efe42a Carry mints into SQLite by looking, not by asking the version
A test wallet lost every mint on restart. Confirmed on device.

The mints were in neither place. postProcessSnapshot strips them from every
save, and the one-time seed that was supposed to copy them into SQLite never
ran — so the snapshot stopped carrying them while the table stayed empty, and
the next launch had nothing to load.

The seed was gated on `currentVersion < 39`, and the version lied.
rootStore.version is `types.optional(types.number, rootStoreModelVersion)`, so a
factory reset stamps a wallet as fully migrated on the spot. Restore an older
snapshot over that — install the v26 native bundle, whose rootStoreModelVersion
is 32 — and the version stays 39: `39 < 39` skips the seed forever, on a wallet
that has never seeded anything.

The circumstances were unusual; the design was wrong. Gating a DATA migration on
a version number assumes the version and the data agree, and SQLite and the MMKV
snapshot are separate engines that fail independently — the database migration
can roll back while the rootStore migration commits, or vice versa. When they
disagree, the version is bookkeeping and the data is the truth.

hydrateMintsFromDatabase now converges on the actual state, every launch:

  - table has mints          -> SQLite wins; the snapshot no longer carries them
  - table empty, store has   -> they exist only in the snapshot; write them through
  - both empty               -> a fresh wallet; nothing to do

No version consulted, so it cannot be skipped, and it is idempotent (an upsert by
mint id). The <39 block is gone, with a note on why it must not come back.

The mintId backfills in <38 stay version-gated, and correctly so: those migrate
pre-existing rows once. This one is not that — it is a reconciliation between two
stores of the same fact, and reconciliations must look.

Tests: 510 pass. Two reproduce the device's exact state (mints only in the
snapshot, no migration running) and fail against the version-gated design.

Recovery for an already-emptied wallet needs no code: re-adding the mint at the
same url reattaches its proofs (proofs.mintUrl never moved) and addMint restores
the derivation index from mint_counters, which is keyed by keysetId and so was
never touched. No blinded-secret reuse — which is what keying counters by keyset,
and giving Mint.id referential authority only, were for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:10:33 +02:00
minibits-cashandClaude Opus 4.8 fa35fa1c05 Drop every table on factory reset, not a hand-listed seven
cleanAll IS the factory reset (DeveloperScreen). It named seven tables while the
schema has eleven, so a reset silently left wallet_counters,
onchain_mint_quotes, mints and mint_keysets behind.

That became a privacy bug in the previous commit: moving mints into SQLite meant
a factory reset no longer removed the user's mints or their onchain deposit
addresses. Introduced by that move and missed, because the drop list is
hand-maintained and nothing pointed at it.

It also cost a device. A test wallet upgrading from a genuine v26 install still
failed with "duplicate column name: mintId", and the cause was this list: an
earlier broken build's createSchemaQueries had created onchain_mint_quotes WITH
mintId — that write COMMITTED even though the migration batch rolled back — and
the factory reset then failed to remove it. Reinstalling the v26 native bundle
recreated dbversion and seeded 26, so migration 31's `CREATE TABLE IF NOT EXISTS`
silently skipped the leftover instead of building it fresh, and migration 33's
ALTER collided with the mintId already there, taking every migration down with
it. Reproduced, same error string.

The list now comes from sqlite_master rather than from source. It cannot drift as
tables are added, and it clears artifacts from any past bug — including exactly
the leftover above, which is what a hand-written list can never do.

No released build can reach that contaminated state (these commits are unpushed),
so the migrations are deliberately NOT hardened with defensive DROPs: the
invariant would hold — a create-migration only runs below its own version, so its
table cannot legitimately exist — but the blast radius of getting that wrong is
data loss, and the scenario is self-inflicted. Fully uninstalling the app is the
correct recovery.

Tests: 506 pass. The three new ones fail against the old list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:48:57 +02:00
minibits-cashandClaude Opus 4.8 f8ed2d4a71 Fix the upgrade path; master mints in SQLite
Two things, in one commit because the test that proves the first needs the second.

Fix the upgrade path (a bug shipped in 2f7a276)

A wallet at db v29 came up with a zero balance. instance.ts ran
createSchemaQueries on EVERY launch, before migrations. `CREATE TABLE IF NOT
EXISTS` skips the tables a device already has — but silently creates the ones it
does not, at TODAY's shape. A device predating a table therefore received it
fully formed, and the migration that adds a column to that table then died on
"duplicate column name". The batch is atomic, so EVERY migration rolled back:
hence the cascade of "no such column: mintId" and a wallet with nothing in it.

Concretely: v29 predates onchain_mint_quotes (v31), so it was built already
carrying the mintId that v33 exists to add.

This is the same replay trap that made v26/v28/v29/v31 freeze their column
lists. I fixed it for migrations sharing live constants and missed that
createSchemaQueries does the same thing, on every existing database.

The fix is strictly either/or. Only dbversion is created unconditionally (reading
the version needs it); then the version decides. Fresh install → build at the
latest shape and record it. Existing database → migrations own every shape
change, so each table is created by ITS migration at THAT version's shape, which
is what keeps the later ALTERs valid.

The gap was structural: every db suite starts from a FRESH in-memory database,
where instance.ts builds the latest schema and seeds the version — so migrations
never run at all. The path every user takes had no coverage. dbUpgradePath.test.ts
closes it via a __seedNextDatabase hook on the op-sqlite mock, covering every
version 26→34 through the real instance.ts and asserting the money, the
derivation counter, the added columns, and that the repos work afterwards.
Reverted to the shipped ordering, 16 of its 17 tests fail.

The v26 fixture is VERIFIED against tag v0.4.3-beta.3 — the last released native
bundle, `_dbVersion = 26`, `rootStoreModelVersion = 32`, which is where even a
brand new install starts today before OTA. Its createSchemaQueries builds exactly
those four tables and its column lists match the fixture one for one. It is frozen
on purpose: a fixture that tracks schema.ts describes a device that never existed.

Master mints in SQLite (Stage 1)

Mints were the last core entity persisted by serializing the whole MST tree.
Since postProcessSnapshot already strips proofs and transactions, mints — with
every keyset's `keys` map — were the largest thing left in it, and
JSON.stringify(snapshot) runs on EVERY MST action anywhere, including every proof
mutation during a send. New tables: mints, and mint_keysets keyed by keysetId
(matching mint_counters; keyset and keys stored as whole JSON so fields like
final_expiry, which feeds NUT-02 v2 id derivation, cannot be dropped by an
enumerated column list).

SQLite is the authority, MST the cache — as for proofs and transactions. Reads
and MobX reactivity are unchanged. Persistence is one onSnapshot observer per
mint rather than a write-through in each of ~20 Mint mutators, where forgetting
one is silent staleness; it is equality-guarded on the PERSISTED payload, so a
proofsCounters change cannot churn the row, and attached only after load.

The rename is now ONE transaction across the mint row and its proofs
(mintsRepo.updateMintUrl). The standalone updateProofsMintUrl is deleted so the
non-atomic path cannot come back. Previously the url lived in MMKV and the proofs
in SQLite, so a crash between the two writes left proofs owned by no mint: the
money vanished from every per-mint balance while still counting in the total, and
could not be spent.

postProcessSnapshot strips mints, so ExportBackup had to change in the same
commit: getSnapshot(mintsStore).mints is now ALWAYS empty, and a backup taken
from it would contain zero mints, raise no error, and reveal the loss only on
restore. The build moved onto the store as a tested `backupSnapshot` view.
ImportBackup persists explicitly, since applySnapshot nodes arrive already-formed
and observers fire only on change.

Two bugs the tests caught before the device could

- types.Date rejects an ISO string, so loading threw on typecheck: every launch
  after the migration would have failed to load any mint.
- proofsCounters came back EMPTY, because loading bypasses initKeyset. The
  counter hydrate would have had nothing to fill, the counter would be recreated
  at 0 on first use, and derivation would reuse blinded secrets the mint had
  already signed — the exact fund loss this branch began with, reintroduced by
  its own fix. Neither is SQL; no mirror-style test could have found them.

Sabotage-verified: dropping the counter shells, building the backup from
getSnapshot, and skipping the proofs in the rename each fail the suite. The first
of those did NOT fail until the assertion was added, which is why it was checked.

Tests: 503 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:48:51 +02:00
minibits-cashandClaude Opus 4.8 73610e4db8 Run the db tests against the real code, not copies of it
The db suites hand-copied both the schema and each repo's SQL, then asserted
against the copy. That proves nothing about the code the app runs, and it drifted
six times across this branch while staying green — counters.test.ts was still
asserting the OLD (mintUrl, keysetId) key long after it was gone, and
transactionsMintUrl.test.ts kept passing while testing a function that had been
deleted.

Rather than make the copies track production, this removes them.

The op-sqlite jest mock now backs connection.ts's driver seam with node:sqlite.
connection.ts documents itself as "the single seam between the rest of the app
and the native SQLite library", so mocking exactly there means tests run the
production path end to end: real connection.ts (param sanitizing, result
adaptation, BEGIN/COMMIT batch emulation), real instance.ts (schema creation AND
the real migration runner), real repos. Net -418 lines, and no production code
changed.

- counters, proofReservation, onchainQuotes, meltRecovery, inFlightRequests and
  nut20's counter half now call Database.* directly. No copied DDL, no copied SQL.
- The migration suites import their SQL from the MIGRATIONS registry. Their
  PRE-migration shapes stay hand-written ON PURPOSE — those are frozen history and
  must never track today's schema, which is the whole reason v26/v28/v29/v31 froze
  their column lists. That distinction is now stated in each file so the next
  person does not "helpfully" point them at schema.ts and reintroduce the replay
  bug.

It found a bug on contact: walletCountersRepo allocates an index with a single
`INSERT … ON CONFLICT DO UPDATE … RETURNING`. The mock routed statements by
leading keyword, sent it down .run(), and the allocation failed — exactly the
point, since the mirror had RE-IMPLEMENTED that statement rather than running it
and so could never exercise RETURNING.

Two smaller gains from using the real path: counters' rollback test now trips the
real sanitizeParams guard instead of a hand-rolled NOT NULL violation, and
proofReservation locks real MST Proof nodes, because the repo calls isAlive() —
which only answers for an actual node, so plain objects never took production's
path.

Limits, recorded in the mock: Node's SQLite is not op-sqlite's build (version and
compile flags may differ), so this proves our SQL and our logic, not the exact
native binary — device testing still owns that. And each test FILE shares one
in-memory database (instance.ts caches its connection), so suites clear tables in
beforeEach rather than rebuilding.

Tests: 441 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:13:36 +02:00
minibits-cashandClaude Opus 4.8 c56729aa38 Detect orphaned proofs; untangle the model layer so stores are testable
Two things, coupled because the first could not be tested without the second.

Detect the proofs/mint desync, and change nothing

`proofs.mintUrl` is a denormalized copy of a mint's LOCATOR, joined to
`mint.mintUrl` by string equality across two persistence engines — proofs in
SQLite, mint.mintUrl in the MMKV snapshot. A crash between those writes during
a mint-url edit desyncs them, and the balance view then counts the proofs in the
unit total while attributing them to no mint, which also makes them unspendable
(send and melt select by mint). Money visible in the header, owned by nothing.

reportOrphanedProofs() reports that and does nothing else. Deliberate: the sats
are the user's, and hiding them, refusing to start, or forcing a recovery are
all worse outcomes than a total that reads slightly high. The state self-heals
once a mint is (re-)added at that url.

It runs at STARTUP, not from `balances`, for two reasons. `balances` is a MobX
computed that re-runs on every proof and mint change; and mint removal
legitimately produces this exact state for a moment — MintsScreen destroys the
mint in one action and moves its proofs to SPENT in the next, so reactions
observe the gap. Detecting there would have fired on every normal removal, which
is how an alert teaches you to ignore it. At startup the tree is settled, so
anything found is a genuine persisted desync.

Untangle the model layer

MST stores could not be instantiated in a test at all, which is why this repo
has no store tests — only mirrored SQL and pure functions. The cause was not
jest: the model layer transitively imported most of the app. Five real defects:

- logService imported `../models` and destructured rootStoreInstance at MODULE
  SCOPE, while models/index eagerly instantiates the root store. A leaf logging
  service pulling the root store inverted the graph, and whether it worked came
  down to which module loaded first — the app has an entry order that survives
  it, a test importing a model directly reads RootStoreModel as undefined and
  throws during import. Now a deferred require, resolved on use.
- Mint imported the `../theme` BARREL, which re-exports useThemeColor ->
  ../services — the whole service layer, to read two colour constants.
- Five models imported the `../services` BARREL for log/Database, dragging in
  walletService -> syncQueueService -> notificationService (notifee).
- currency.ts imported the `../../components` BARREL for currency icons: a
  service module depending on the entire UI. (ChfIcon was already imported
  directly — it was inconsistent as well as wrong.)
- generateId lived in utils.ts beside a react-native-flash-message toast, so
  generating an id pulled in the UI stack. Split into its own module; utils.ts
  re-exports it, so existing callers are unaffected.

None of these change behaviour: same modules, narrower paths.

Test harness

jest already used the react-native preset; the gap was native and
source-shipped packages that the model layer reaches at import time. Mocks for
MMKV (Map-backed, behaves), op-sqlite, Sentry and nostr-tools, plus resolver
mappings for @scure/bip39 wordlists and nostr-tools' subpaths — its vendored
@noble copies ship source and importing them would cross versions on crypto
code, so the surface is mocked instead. op-sqlite and nostr-tools THROW if
actually called: a test that silently derives a bogus key and asserts on it is
worse than one that stops and explains. Real DB semantics stay where they were,
on node:sqlite with the production SQL.

Mocking Sentry rather than logService means suites can let the real logger load.
The full RootStore still cannot instantiate (AuthStore/NwcStore/
WalletProfileStore pull the service layer), so orphanedProofs.test.ts uses a
minimal root — enough for the money models, and enough for what comes next.

Also removed: Mint.setRandomColor and theme's getRandomIconColor. The helper's
only caller was that action, and the action had no callers at all.

Tests: 433 pass, including 12 new ones against the real MST models — the first
store tests in the repo. They pin the promise above: an orphaned proof still
counts in the unit total, reporting mutates nothing, and it heals when the mint
returns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:49:38 +02:00
minibits-cashandClaude Opus 4.8 2f7a2763f2 Reference mints by stable id, not by url
Completes the mint-identity work. A mint url is a network locator and mints
move, but the url had become the de facto foreign key for nearly every
persisted row, so a url edit had to fan out across the schema and mostly did
not. Each table now references the mint by an identity that a move cannot
disturb, and setMintUrl shrinks to the two things that genuinely hold a url:
Mint.mintUrl and the proofs.mintUrl cache.

Which identity, per table

mintId (Mint.id) where the row needs the mint itself:

- onchain_mint_quotes. The critical one: a quote's address stays creditable for
  as long as the mint exists (rows are never deleted), so the reference has to
  outlive a move. It followed row.mintUrl, and the watcher swallows errors by
  design — a renamed mint stranded deposits permanently and silently.
- reservations. A url edit racing an open send did not merely misfile the
  proofs; commitReservation resolved the mint by url, so it threw "Mint not
  found" and aborted the commit of an operation the mint had already performed.
- transactions. `mint` was two things at once, switched by status: a historical
  record of where a finished payment happened, AND a live pointer dialled for an
  open one. That conflation is why a rename had to rewrite in-flight rows —
  rewriting the very column that records the past. mintId takes the identity
  job, so `mint` is frozen as history and the status-scoped rewrite is retired.

No reference at all where the row already has a parent:

- inflight_requests, melt_recovery are CHILD rows of a transaction (their
  primary key IS transactionId), so the parent owns "which mint". Their mintUrl
  and keysetId copies had ZERO readers — the keyset that is used comes from
  inside meltPreview. Both dropped; the one mint-scoped query joins through
  transactions.mintId. Nothing left to go stale.

Mint.id gets referential authority only, never identity authority: findById
answers "which mint is this row about?" and must never answer "are these the
same mint?" — it is random and unrelated to the keys, so that question stays
with the keysets. The backfills are IS NULL-guarded so a resolved row can never
be re-pointed at whichever mint now answers an old url.

Backfills run from JS (v38 seed), not SQL: mints live in the MST/MMKV snapshot,
so nothing in SQL can map url -> id. Matching on url is trustworthy at exactly
that moment and no other — until now a url could not change without these rows
being rewritten to match. The join is spent once, at rest, instead of on every
rename.

Migration-system fixes found along the way

- _dbVersion is now DERIVED from the migration list. It was a hand-maintained
  literal, and it was already wrong: it said 33 while migration 34 existed, so
  34 would never have run. The failure is silent and asymmetric — fresh installs
  build from schema.ts and are fine, while upgrading devices land on a schema
  the code does not have. dbMigrationRegistry.test.ts pins this and the ordering
  invariants.
- Migrations 26/28/29/31 built their tables from the LIVE schema constants. A
  device replaying them would get today's shape, and the later ALTER adding the
  column would fail with "duplicate column name" — breaking upgrades from
  exactly the versions those migrations serve. Historical shapes are frozen
  locally now, and the schema.ts header no longer recommends the sharing.
- rootStoreModelVersion was left at 37 while the seed guarded on < 38, so the
  backfill would have re-run on every launch forever.

Tests: 421 pass. Six hand-mirrored suites had drifted from the schema they
claim to mirror; transactionsMintUrl.test.ts was worse — still passing while
testing a function this commit deletes, so it is removed. The mirroring pattern
is worth revisiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:41:00 +02:00
minibits-cashandClaude Opus 4.8 9d759f83ab Key derivation counters by keysetId; make mint URL change safe
A mint URL is a network locator, not an identity, but it had become the de
facto foreign key for most persisted state. Changing a mint's URL therefore
had to rewrite every one of those references, and only proofs were ever
rewritten. This removes the URL from the key space where it never belonged,
and repairs the rename for what remains.

Counters (the fund-losing one)

mint_counters was PRIMARY KEY (mintUrl, keysetId), which asserts a key space
that does not exist: NUT-13 derives from (seed, keysetId, counter), with no
mint component in either path (`m/129372'/0'/{keysetIdInt}'/{counter}'` for
`00` ids, HMAC-SHA256 over the id for v2 `01`). Two rows could track ONE
derivation path independently, and a URL edit left the row unaddressable —
hydration matched on URL, found nothing, and silently restarted the counter
at 0, reusing blinded secrets the mint had already signed.

Re-keyed on keysetId alone, which is sound because keyset ids are already
globally unique wallet-wide, enforced at the door by isCollidingKeysetId per
NUT-02. Migration 32 collapses duplicates to MAX(counter), so it HEALS
wallets already split by this rather than only preventing new splits — a
too-high counter skips indices, a too-low one reuses them.

This also deletes code: persistCounter no longer walks getParent() for a URL,
so a counter detached from its Mint now persists instead of dropping its
write.

Keyset collisions and NUT-02 v2

isCollidingKeysetId applied the mod-2^31-1 keysetIdInt check to every id. That
integer only exists on the deprecated BIP-32 path; v2 ids derive by HMAC over
the full 32 bytes and never compute it. Checking it there would reject a
legitimate mint over a number nothing consumes, and re-impose v1's ~2^31
birthday bound on ids whose whole point is full-width SHA-256 resistance. The
check is now gated on derivation kind; exact-id equality still always applies.

Mint URL change

- Validation is shared with addMint via a new normalizeMintUrl, so adding and
  renaming can no longer disagree. The rename previously did neither the
  trailing-slash strip (NUT-00 MUST) nor the https check.
- Canonical form matches cashu-ts normalizeUrl (`href` then strip trailing
  slashes). WalletStore compares our stored string to CashuMint.mintUrl to
  find cached instances, so normalizing the raw input would let
  `https://Mint.Example` be stored while cashu-ts held `https://mint.example`:
  every cache lookup missing, two spellings looking like two mints. Pinned by
  tests asserting agreement with CashuMint.mintUrl.
- The onion exemption tested `includes('.onion')`, so `http://evil.example/.onion`
  bought a plain-http exemption for an ordinary host. It now tests the parsed
  hostname. `startsWith('https')` also passed `https-evil://host`; now protocol
  equality.
- Duplicate detection uses mintExists (normalized), not alreadyExists
  (literal), which missed a trailing-slash twin and let one real mint become
  two Mint nodes. Renaming to the URL already held is now a no-op, not an error.
- hostname is recomputed; it used to keep the old mint's host forever.
- transactions.mint is repointed for IN-FLIGHT rows only. That column means two
  things by status: for a terminal row it is a historical record of where the
  payment happened, but for an open one it is a live pointer the wallet still
  calls (checkLightningMintQuote, checkLightningMeltQuote/checkOnchainMeltQuote,
  findByUrl on revert/receive). Stale, it strands a paid topup at a dead URL
  forever. One UPDATE, so the status test cannot straddle a transition.
- ProofsStore.updateMintUrl now writes SQLite before memory; the reverse left
  the UI showing a balance the database never received.

Still URL-keyed, and documented on setMintUrl: onchain mint quotes, in-flight
requests, melt recovery and open reservations. Renaming a mint with any of
those outstanding still strands them. They need a stable mint id, which is the
next step.

Tests: 413 pass. The two new v2 collision tests fail against the previous
code and pass here, while the v1 cases pass in both. counters.test.ts mirrored
the production SQL by hand and so had asserted the old key — including a test
that two mints sharing a keyset id keep independent counters, exactly the
unsound behaviour removed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:41:02 +02:00
minibits-cash 714a3bc3d0 Navigation display logic 2026-07-15 15:15:03 +02:00
minibits-cash d92a89f7ce Guard onchain pay when no mint can settle it
Pasting a Bitcoin address with only bolt11 mints installed let the user set an
amount, press Continue, and receive the mint's bare "not found" — a 404 for a
question the wallet already had the information to answer.

Three gaps, all in a row:

- The mint was chosen before any capability check. The mount effect took
  route.mintUrl or simply the first mint with a balance, and MintBalanceSelector
  (which does gate on supportsMelt('onchain')) only appears AFTER a quote exists —
  so there was no way to reach a capable mint before the failing call. Selection
  now happens over the mints that can actually settle, and the mint carried in
  from the Pay screen is treated as the suggestion it is: it is whichever mint the
  user happened to be looking at, and nothing about scanning a Bitcoin address
  says it can melt onchain.

- Capabilities could be stale, which would have made this guard WORSE than none.
  supportsMelt reads cached mintInfo, and a mint whose info was never fetched
  reports hasUnknownCapabilities, which resolves to "assume bolt11" — i.e. not
  onchain. A capable mint would have been declared incapable purely because we had
  not asked it lately. The screen now triggers the stale-info refresh (getMint())
  for every candidate before deciding any of them cannot pay.

- requestQuote never checked at all. It does now, as a last line of defence.

When nothing can pay, the screen says so and offers no Continue button: nothing
the user can type makes it work, and offering the action anyway is what turns a
limitation into a bug report. "No mint holds this currency" and "no mint can send
Bitcoin onchain" stay separate messages — different problems, different fixes, and
one message for both would send the user to solve the wrong one.

87 tsc (unchanged baseline), 335/335, i18n clean.
2026-07-15 00:10:22 +02:00
minibits-cash 022cd3e0aa Stop sizing the amount field by measuring its own text
The clipped "1,00" was a layout bug, not a formatting one: state held "1000"
and the formatter produced "1,000" correctly, but the field only had room to draw
four characters.

The amount inputs sit in a header with alignItems: 'center' and carry no width,
so Yoga sizes them by measuring their text. That measurement is taken from the
LAYOUT style (fontSize: verticalScale(56)) while what is actually drawn comes
from the animated style (fontSize: 56) — Reanimated applies fontSize outside
Yoga. The two only have to disagree by a few percent for the last glyph to fall
outside the measured box. Typing never showed it, because the text is measured
and drawn afresh on each keystroke; formatting on blur is what first made the box
and its contents disagree about how much text there was.

Both halves fixed: the animated style now scales the same base size the layout
style uses, and the field is given a definite width so text measurement cannot
decide its layout at all.

87 tsc (unchanged baseline), 335/335.
2026-07-14 23:49:17 +02:00
minibits-cash 5e8932d411 Fix truncated amount: derive the grouped display, never store it
"1000" confirmed as "1,00". Two mechanisms, both from writing the grouped string
back into the controlled TextInput's value:

- Android echoes a programmatically-set `value` back out through onChangeText,
  where handleTopChange rewrites the first comma to a dot (a decimal-comma
  keyboard types "1,5" and means 1.5). So "1,000" came back as "1.000".

- maxLength was clipping. It caps typing, but Android's filter applies to
  programmatically-set text too, and the grouped form is longer than the number
  it shows.

Stripping on focus was not enough, because it only closed the window the USER
could reach — not the one the platform reaches on its own.

State now holds the plain number and the grouped text is derived at render time,
only while the field is unfocused. Losing focus is what confirms an amount, so
that alone produces the requested behaviour, and derived text cannot be read back
in as input. The typing cap moves from maxLength into the change handlers, where
it applies to typing and nothing else. Parents keep receiving the plain number, so
nothing downstream has to know about any of this.

87 tsc (unchanged baseline), 335/335.
2026-07-14 23:42:01 +02:00
minibits-cash c96c4ebfef Group the amount in AmountInput once the user confirms it
Done in the component, not per screen, so it applies to every editable amount at
once: Topup, Transfer, OnchainTransfer, Send, Receive, NfcPay and the Cashu
payment request. The converted (bottom) amount was already formatted on
end-editing; the top one was left raw, with an abandoned attempt commented out.

The value is STRIPPED of grouping again on focus, and that half is the part that
matters. AmountInput rewrites the first comma to a dot on input, so a
decimal-comma keyboard can type "1,5" and mean 1.5 — which also means a grouped
value left sitting in a focused field would be reinterpreted the moment it was
touched: "12,345" sats becomes 12.345, rounds to 12, and the wallet sends a
thousandth of what the user meant. Stripping on focus keeps the two meanings of
"," from ever meeting. Pinned in tests, including what the bug would have cost.

Two things had to move with it:

- maxLength was 9 and caps TYPING, not display. Grouped, nine digits is
  "999,999,999" (11 chars, or 14 with a fiat mantissa), and Android's maxLength
  filter truncates programmatically-set text too — a confirmed amount would have
  rendered as "999,999,9". The field is always ungrouped while focused, so the
  cap now applies only then and enforces exactly the same digit limit as before.

- Three screens gated on parseInt(amountString) > 0. parseInt("12,345") is 12, so
  the > 0 answer stayed right by luck; they now use toNumber like every other
  reader of these strings.

87 tsc (unchanged baseline), 336/336.
2026-07-14 23:33:57 +02:00
minibits-cash 2a21321ab2 Format onchain fee tiers through formatCurrency, and log the mint's real tiers
fee_reserve is an absolute MAXIMUM the mint may spend on miner fees, denominated
in the quote's unit — not a feerate. NUT-30 never exposes sat/vB, and whatever
the mint does not spend comes back as NUT-08 change, which is why every tier
reads "up to".

It arrives in the unit's BASE denomination, and I was printing it raw with
numbro. For sat that is identical (precision 1, mantissa 0) so it looked correct,
but on a usd-denominated mint a fee_reserve of 400 means $4.00 and would have
rendered as "400 USD" — a fee claimed a hundred times too large. The min/max
amount gates and the insufficient-funds message had the same bug. All now go
through formatCurrency, as the rest of the wallet does.

Also log the tiers the mint actually returned, so what the screen shows can be
checked against what the mint said without reverse-engineering it from the UI —
fakewallet scales its reserve with the amount, so the number moves.

87 tsc (unchanged baseline), 328/328.
2026-07-14 23:27:43 +02:00
minibits-cash 346e07cf8b Onchain pay: fold the fee row into the payment card
Address, payee description and network fee are three facts about one payment, not
three decisions — three separate cards read as three decisions. They now share a
card, separated by rules.

Separators are computed from what is actually below each row rather than
hardcoded, because both optional rows come and go: the memo only when the BIP21
URI carried one, the fee only once a quote exists.

"Change" is now the standard secondary preset rather than a tertiary text link,
constrained only so it does not stretch the row it sits in. The fee row stays
tappable only when there is more than one tier — with a single tier it is a
statement of fact, and a row that depresses under the finger but does nothing is
worse than one that plainly does not.

87 tsc (unchanged baseline), 328/328.
2026-07-14 23:18:18 +02:00
minibits-cash 4a3a7efbb4 Debug-only mock for multi-tier onchain fee options
The CDK fakewallet returns exactly ONE fee_option, so against the only mint we
can test against the picker always collapses to its single-tier read-only row and
the multi-tier path never runs. mockOnchainFeeTiers fabricates a slower/cheaper
and a faster/dearer tier around whatever the mint sent. Flip MOCK_FEE_TIERS in
OnchainTransferScreen to see three.

What is mocked is the CHOICE, not the payment. NUT-30 requires the mint to reject
a melt whose fee_index it never offered, so a fabricated index cannot be paid
with — the mint's real tier survives the mock untouched, and payableFeeIndex
resolves any mocked selection back to it on submit. The melt therefore goes
through at the mint's real price whichever tier is picked; only the displayed
estimate was invented. Mocked rows say so in plain text, because an unmarked
invented fee reserve sitting next to real ones is exactly the sort of thing that
gets believed later.

No-op when the mint already offered more than one tier: overwriting real tiers
with invented ones would be worse than useless.

87 tsc (unchanged baseline), 328/328.
2026-07-14 23:13:22 +02:00
minibits-cash 493a5cb0c3 Onchain pay: one card, and honour the payee's BIP21 description
Address and description now share a single card, separated by a rule, instead of
sitting in two cards that read as two unrelated decisions.

The description is READ-ONLY and there is no field for the user to write their
own. A NUT-30 melt request carries {quote, fee_index, inputs, outputs}, and a
Bitcoin transaction has nowhere to put a message either, so nothing the user
typed could ever reach the recipient — and everywhere else in this wallet a memo
IS for the payee, so an editable one here would mean the same word promising
something different on one screen.

What the payee sends is a different thing entirely. BIP21 label/message is their
own text arriving with the request, so the wallet honours it exactly as it
honours a bolt11 invoice's description: shown on the pay screen, saved to
transaction.memo, visible in history. The row is absent when the URI carried
none.

87 tsc (unchanged baseline), 320/320, i18n clean.
2026-07-14 23:01:02 +02:00
minibits-cash e50ef5e1ae Accept non-mainnet Bitcoin addresses in debug builds only
The CDK fakewallet backend settles onchain melts against a regtest chain, so
refusing bcrt1... addresses everywhere made the payout rail impossible to
exercise end to end. Debug builds now pay them; release builds still refuse, and
there is no setting that changes that — the only way to pay a testnet address is
to be running a debug bundle you built yourself.

The flag is an explicit `allowNonMainnet` parameter defaulting to
ALLOW_NON_MAINNET_PAY (= __DEV__), not a global read inside the check: it keeps
the decision testable, and passing `true` from production code would be a
visible thing to review. It relaxes the NETWORK only — a debug build is still
not a build that pays typos, and the checksum is still verified.

Both enforcement points keep the guard (parser and TransferOperationApi.prepare),
and a non-mainnet payout is logged as a warning when it happens.

Tests pin both sides of the rule explicitly rather than depending on whatever
__DEV__ is under jest. 320/320, 87 tsc (unchanged baseline).
2026-07-14 22:29:20 +02:00
minibits-cash 183ef1cf56 Stage 6b: onchain melt UI — Pay screen, address parsing, fee picker
LightningPayScreen becomes PayScreen: it can no longer call itself Lightning
now that it also takes Bitcoin addresses. Paste, auto-paste and scan all route
through the same parser.

New OnchainTransferScreen rather than a third mode inside TransferScreen. The
lightning screen is built around a payment request that already carries its
amount and a quote it can fetch as soon as a mint is chosen; onchain has neither
(the user types the amount, and the quote returns a LIST of fee tiers). Folding
that in would have meant re-auditing every `!encodedInvoice &&` render gate on a
screen where a mistake breaks lightning payments. The SERVICE layer is still a
single shared TransferOperationApi - this is a screen, not a second
implementation.

Address handling (src/services/bitcoin/bitcoinUtils.ts):

- Checksums verified locally, so a mistyped address fails on the screen the user
  is looking at rather than one round-trip later. Witness version selects the
  checksum constant - v0 must be bech32, v1+ bech32m - and accepting either for
  both would let a corrupted address through whenever it satisfied the other.

- MAINNET ONLY, and this is the point rather than a detail. The CDK fakewallet
  hands out REGTEST deposit addresses for onchain topup quotes, so anyone testing
  this wallet has a bcrt1q... in their clipboard - the single most likely wrong
  thing to be pasted into Pay. It is decoded (so we can say "wrong network"
  rather than "unknown data") and then refused. Pinned in tests with the exact
  address the fakewallet produced.

- Unified QR: LIGHTNING WINS. A bitcoin: URI carrying a lightning= invoice
  offers both rails and lightning is cheaper and faster for everyone, so the
  invoice is taken and the address goes unused.

Fee tiers default to the MIDDLE one, rounding to the cheaper side: the user can
always choose to pay more, but a wallet must never round a fee up on their
behalf. The picker collapses to a read-only row when the mint returns one tier -
which is the common case, not the edge case. Tiers are sorted before selection
because fee_index is the mint's IDENTIFIER for a tier, not its rank.

PENDING is styled as the SUCCESS state here, not a warning: NUT-30 requires the
mint to answer PENDING and broadcast in the background, so every onchain payment
lands there. The money has left; only confirmation is outstanding. The
transaction detail surfaces the outpoint (txid:vout) once broadcast - the only
handle on the payment that does not depend on the mint - and offers a manual
status check, since the sweep only runs on the ~60s pending cadence.

87 tsc errors (unchanged baseline), 318/318 tests, i18n clean.
2026-07-14 18:05:28 +02:00
minibits-cash 6b5b1da356 Stage 6a: onchain melt service layer (NUT-30)
One melt lifecycle, two rails. TransferOperationApi now handles both bolt11
(NUT-05) and onchain (NUT-30) melts; what differs between them is resolved once
in resolveTransferMethod rather than branched on at each site that needs a fee or
an expiry. There is deliberately one copy of the proof reservation, the
preemptive swap, and the execute-error recovery matrix.

cashu-ts' prepareMelt is already method-agnostic (it derives the NUT-08 blank
count from inputs - quote.amount, not from fee_reserve), and fee_index rides
along as extraPayload on completeMelt. So the prepare -> persist meltPreview ->
complete split that melt-change recovery depends on survives intact.

Substance, beyond the plumbing:

- Onchain change can arrive at PENDING. The mint knows its miner fee the moment
  it builds the transaction, so it may return the unclaimed reserve with the
  spec-mandated PENDING response. bolt11's PENDING branch drops change on the
  floor (correctly - there is none yet); doing that here would strand signed
  proofs that nothing would ever look for again. execute() now commits change if
  present, and _finalizePaid subtracts what was already returned so banked change
  is not reported as fee.

- Settlement is quote-driven, not proof-driven. The mint spending our inputs
  means it BROADCAST, not that the transaction confirmed. sync's _dispatchFinalize
  therefore routes TRANSFER_ONCHAIN to refresh() (which asks the mint and only
  completes on PAID) rather than finalize(), and sync now reports the status the
  dispatch actually reached instead of assuming COMPLETED - otherwise it would
  announce a Bitcoin payment as landed while it sat unconfirmed in the mempool.

- Onchain transfers are never expired. The melt quote's expiry bounds executing
  the quote, not confirming the payment, which can outlive it by many blocks.

- Mainnet only. The CDK fakewallet hands out regtest deposit addresses for topup
  quotes, so testers end up with one in their clipboard; pasting it back into Pay
  must not spend. Refused in the parser and again in prepare(), so a screen that
  forgets the check cannot move money.

No websocket and no poller for onchain: settlement is bounded by block times, so
the existing ~60s pending sweep is already finer-grained than what it waits for.
Melts go through SyncQueue for the same counter-serialisation reason mints do.

87 tsc errors (unchanged baseline), 310/310 tests, i18n clean.
2026-07-14 16:30:15 +02:00
minibits-cashandClaude Opus 4.8 b0b75e0607 Bump cashu-ts to 4.7.1 and drop the onchain counter workaround
4.7.1 fixes the defect we routed around: mintProofsOnchain (and
mintProofsBolt12) narrowed their config to {keysetId} and so silently
dropped onCountersReserved — the callback that reports which derivation
indices the library consumed. Without it our keyset counter would desync
and risk blinded-secret reuse, so mintOnchainProofs called the generic
mintProofs('onchain', ...) instead, which took the full MintProofsConfig.

4.7.1 widens the config on both helpers to Omit<MintProofsConfig, 'privkey'>
(cashubtc/cashu-ts#784), so the typed helper now carries the callback and the
workaround is unnecessary. Reverted to mintProofsOnchain(amount, quote,
privkey, config).

That this typechecks is itself the check: under 4.7.0 passing
onCountersReserved to that config was an excess-property error.

260/260 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:35:11 +02:00
minibits-cashandClaude Opus 4.8 e1c911e0a2 Polish onchain topup UI and conform it to the transaction lifecycle
First iteration of onchain minting is working end to end against a fakewallet
CDK mint (quotes signed, deposits detected, ecash issued). This is the cleanup
that came out of driving it on device.

Transaction lifecycle (onchainTopupOperationApi)

  createQuote() went straight to PENDING, skipping DRAFT and PREPARED. It now
  runs DRAFT -> PREPARED -> PENDING like every other operation.

  DRAFT was a real gap, not a formality: if the mint refused the quote there was
  no transaction record at all, and a NUT-20 index had already been burned with
  nothing to explain the hole in the sequence. The row is now opened before the
  mint is contacted and marked ERROR on failure, mirroring how topupTask handles
  a failed bolt11 prepare/execute.

  PREPARED is transient here — but it is transient for bolt11 too (topupTask
  calls prepare() and execute() back to back), so it is a crash marker rather
  than a state a user sits in. Onchain has no "arm the watcher" step to separate
  it from PENDING, because the watcher is driven by the quote row: persisting the
  quote IS the registration.

  Waiting-for-a-deposit deliberately stays PENDING rather than becoming a
  long-lived PREPARED, because pendingHistory filters on PENDING alone — anything
  else drops the topup out of the pending list, which is exactly where a user
  goes looking for it.

  _findOrCreateTransaction now also reuses a PREPARED row. A crash between the
  PREPARED and PENDING writes would otherwise have settled the deposit onto a
  second transaction, leaving two rows for one deposit.

UI

  TransactionListItem rendered no amount for either onchain type: amounts are
  only drawn for explicitly enumerated types, and neither was in a list, so they
  fell through to nothing. Both are now handled (topup positive, transfer
  negative).

  QRCodeBlock takes an optional `hints` prop: a question-mark button joins the
  action row and opens the explanation in a bottom sheet. The onchain warnings
  ("the amount is only a suggestion", "ecash appears once the deposit confirms")
  were in a card below the QR, which is below the fold on the tallest element of
  the screen — so nobody read them. They are not decorative: without them an
  underpayment reads as the wallet losing money.

  TranDetailScreen gains a Payment request row in both topup cards (bolt11 and
  onchain), one line with a middle ellipsis and a Copy button. Middle, not tail:
  for an address or invoice the head and tail are what a user checks against a
  block explorer, and the middle is noise.

  Mint names in the selector truncate instead of wrapping, so rows keep an even
  height. Passing an ellipsize mode is what makes ListItem apply numberOfLines=1.

  Mint-selector buttons become short parallel nouns with rail icons (Invoice /
  Address). The non-en locales still carried the long form, which would have
  blown the button width out in those languages.

260/260 tests pass; typecheck introduces no new errors; 747/747 i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:30:06 +02:00
minibits-cashandClaude Opus 4.8 be6dc69224 Fix stale mint capabilities hiding the onchain topup option
Reported from device: with an 11k sat amount on a mint that advertises onchain
and NUT-20, the "Bitcoin address" button never appeared.

Two causes, both mine.

1. A missing `time` was being read as FRESH. The stamp only started being
   written when the capability model landed (d008d45), so every mint info
   cached before that has `time: undefined`. `now - undefined` is NaN, and
   every comparison against NaN is false — so `now - time > TTL` reported
   those records as fresh and never refetched them. A mint that had since
   gained onchain support kept looking like one that never had it, forever.

   isMintInfoStale() now treats a missing or non-finite timestamp as stale, and
   lives in its own module (no stores, no services) so the NaN behaviour is
   pinned by tests rather than rediscovered on a device.

2. Nothing on TopupScreen ever refreshed capabilities. The screen gates on the
   mint's cached NUT-06 info but otherwise never talks to the mint, so even
   with (1) fixed there was no trigger. Selecting a mint now kicks getMint(),
   which only hits the network when the info is genuinely stale; the screen is
   an observer, so the option appears by itself once fresher capabilities land.

Also logs the gate inputs (supportsOnchain / nut20 / mintMin / floor / amount),
since "the button is missing" is otherwise indistinguishable from a dozen
different causes.

260/260 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:56:37 +02:00
minibits-cashandClaude Opus 4.8 1a2c763dd6 Route the manual onchain deposit check through SyncQueue
"Check for deposits" called refreshQuote directly, bypassing the queue. Minting
derives blinded secrets from the keyset counter — mintOnchainProofs advances the
wallet's counter to our stored value, mints, then writes back what the library
consumed. The watcher sweep can be doing exactly that at the same moment, so two
concurrent mints on one keyset would advance to the SAME starting counter and
derive the SAME blinded secrets. Reusing a blinded secret is precisely the hazard
the counters-in-SQLite work exists to prevent.

SyncQueue runs at concurrency 1, so enqueueOnchainQuoteCheck() is now the only
way a quote check may start, and both callers (the watcher and the transaction
detail) go through it. Duplicate tasks for one quote are harmless: they run in
sequence, and the second recomputes the mintable balance from the mint's own
monotonic numbers, so it finds nothing to do rather than minting twice.

createQuote stays outside the queue deliberately — it never mints, and the only
counter it touches is the NUT-20 one, which is allocated in a single atomic
INSERT..ON CONFLICT..RETURNING and so is race-proof by construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:43:43 +02:00
minibits-cashandClaude Opus 4.8 eba3c92827 Add onchain topup UI (Stage 5 of onchain)
Onchain topup is now reachable: RECEIVE > Bitcoin > enter amount > pick mint >
"Bitcoin address" > scan the BIP21 QR. The deposit is picked up by the Stage 4
watcher and minted automatically.

The rail choice rides on MintBalanceSelector's existing secondary-confirm slot
(SendScreen already uses it for "Lock"), so no new screen or modal: primary
stays "Create invoice", and a second button appears only when the SELECTED
mint advertises onchain for this unit AND the amount clears the floor. Below
the floor the option is hidden rather than shown-and-rejected — a sub-minimum
onchain deposit increases amount_paid for nobody and, per NUT-30, is "not
recoverable through the mint quote protocol".

onchainTopupFloor is max(our floor, the mint's min_amount), because the mint's
number cannot be trusted to protect anyone: the CDK test mint advertised
min_amount: 1, below Bitcoin's own 546-sat dust limit, and we watched it be
wrong in two independent ways. NUT-30 evaluates min_amount PER UTXO and such
deposits cannot be aggregated to clear the bar, so dust is simply gone. Our
floor is 10k sats.

buildBip21Uri converts sats to BTC via toFixed(8) rather than dividing and
stringifying: naive division yields 0.000010000000000000001 for 1000 sats and
exponent form (1e-8) for small values, neither of which is a valid BIP21
amount. Tests pin both cases.

The amount in the URI is only a hint — a sender can edit or ignore it — so the
QR is captioned to say the balance settles to whatever actually arrives, and
that ecash appears only once the deposit confirms. Without that, an
underpayment looks like a bug.

TranDetailScreen gets an OnchainTopupInfoBlock with "check for deposits". This
is not a nicety: an onchain address never expires (the mint returns expiry:
null), so money sent after the 7-day watch window closes is still credited and
still mintable — the wallet just is not looking. The action reopens the watch
window and re-checks immediately, which is the recovery path for anything the
watcher missed, and the reason quote rows (and their NUT-20 counterIndex) are
kept forever.

254/254 tests pass; typecheck introduces no new errors; all tx keys resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:39:41 +02:00
minibits-cashandClaude Opus 4.8 ba38696bdc Add onchain deposit watcher and mint pipeline (Stage 4 of onchain)
Onchain topup now works end to end without UI: create a quote, and when a
deposit confirms the wallet mints it and settles a transaction. Stage 5 adds
the screen; this is the machinery under it.

The watcher is QUOTE-driven, not transaction-driven, and that is the one
structural difference from the bolt11 path. handlePendingQueue walks PENDING
transactions, which works for invoices because they are single-use. An
onchain address can be paid AGAIN after its transaction has COMPLETED, so a
transaction-driven sweep would miss precisely the deposits that need
catching. getWatchedOnchainMintQuotes() knows whether money is outstanding;
the transactions do not. No new poller and no websocket: onchain settlement
is bounded by block times, so the existing ~60s pending-check cadence is
already far finer-grained than what it waits for.

mintOnchainProofs goes through cashu-ts's GENERIC mintProofs('onchain', ...)
rather than the mintProofsOnchain sugar. The sugar narrows its config to
{keysetId} and silently drops onCountersReserved — the callback that tells us
which derivation indices the library consumed. Losing it would desync our
counter and risk blinded-secret reuse. The generic form takes the full
MintProofsConfig, which carries both privkey (NUT-20) and onCountersReserved.

Adds a TOPUP_ONCHAIN case to the in-flight retry. Same hazard as TOPUP but
worse to leave unhandled: if the mint processed a mint request and we never
saw the response, it has already counted the ecash as issued, so the quote
reads as drained, the watcher stops looking at it, and the proofs are
stranded. Replaying the identical request hits the mint's NUT-19 cache
(/v1/mint/onchain is cached) and returns the same signatures.

Amount handling reflects that a NUT-30 deposit is not what we asked for:

  - the amount the user typed is only a BIP21 hint, so a transaction settles
    to what was actually minted, not to what was requested;
  - a deposit may exceed the mint's max_amount, so the request is capped and
    the remainder stays credited on the quote, where the watch rule keeps it
    visible and the next sweep collects it;
  - mintableAmount clamps at zero, so a mint reporting issued > paid cannot
    talk us into a negative-amount request.

A second deposit to an already-COMPLETED address gets its own transaction
rather than mutating the old one, which would make balanceAfter meaningless.

245/245 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:19:11 +02:00
minibits-cashandClaude Opus 4.8 0f527ad056 Add onchain transaction types and quote store (Stage 3 of onchain)
An onchain mint quote is a Bitcoin address, not a one-shot invoice: it can
take several deposits, and the mint tracks amount_paid / amount_issued
against it while the wallet mints the difference. That state cannot live on
a transaction row — a transaction has one fixed amount — so it gets its own
table, and transactions point at it through the existing transactions.quote
column (N transactions : 1 quote, one per mint operation).

New TransactionType TOPUP_ONCHAIN / TRANSFER_ONCHAIN. TransactionStatus is
unchanged: PENDING carries awaiting-confirmations, and the fine-grained
onchain state lives on the quote row. That is the whole reason the quote is
modelled separately.

The watch rule is the substance here. Two facts from the spec drive it:

  - the mint returns expiry: null, so a deposit address NEVER dies. Funds
    sent to a long-abandoned address stay creditable forever, and nothing
    server-side bounds how long we poll a quote nobody paid.
  - a quote can be paid more than once, so "have we minted yet" is the wrong
    question.

So a quote is watched while:

    amountPaid > amountIssued                  (money credited, not yet minted)
    OR (amountIssued = 0 AND watchUntil > now) (still waiting for a first payment)

Keying the first clause on the unminted BALANCE rather than on "has ever
minted" is deliberate: the latter would archive a partially drained quote and
abandon funds the mint is holding for us. watchUntil is the wallet's own
deadline (7 days), mirroring the 24h fallback bolt11 topup applies to an
invoice with no expiry tag — expiring the WATCH never expires the FUNDS.

Rows are never deleted, archived or not, because counterIndex is the NUT-20
derivation index and the only way to re-derive the key that signs a mint
request for that quote. Drop the row and a late deposit becomes permanently
unspendable. extendOnchainMintQuoteWatch() re-opens the window for the
"check for deposits" action the transaction detail will offer (Stage 5).

Amount updates are monotonic (MAX), like the derivation counters: a stale
response can only be a no-op, never a regression. A regressing amountIssued
would show unminted money that is not there — and mint it twice.

Melt gets no table: an onchain melt quote is one-shot and terminal, so its
durable state fits on the transaction row (quote id, fee, and a new outpoint
column for the txid:vout, which is the only way a user can follow an onchain
send). outpoint added to updateTransaction's column whitelist, without which
writes silently no-op.

235/235 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:55:28 +02:00
minibits-cash 615951b688 Translations 2026-07-13 12:56:26 +02:00
minibits-cashandClaude Opus 4.8 7d6b6ac6c3 Use the default grey for the Bitcoin option icon
Inherit ListItem's textDim default rather than tinting the mark orange,
so it matches faMoneyBill1 in the same sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:55:17 +02:00
minibits-cashandClaude Opus 4.8 d008d452ac Add mint capability model (Stage 2 of onchain)
Onchain (NUT-30) means a mint can no longer be assumed to speak bolt11:
a mint may support onchain only, or gain/lose a method over time. The
wallet needs to read what each mint actually advertises and offer only
that.

Fixes the refresh first, because it was doubly broken and nothing built
on mintInfo could be trusted without it:

  - `time` was never written. Every caller passed a raw GetInfoResponse
    through a cast (`info as GetInfoResponse & {time: number}`), so
    mintInfo.time was undefined, `now - undefined` was NaN, and
    `NaN > ttl` is false — the TTL could never fire.
  - The check also sat below getMint's early return for an already-cached
    cashu-ts instance, so it was unreachable for any mint touched once
    this session.

Together those meant mintInfo was fetched when a mint was added and then
never refreshed again. setMintInfo now stamps the time itself (callers
cannot forget), and the staleness check runs even for cached instances,
fire-and-forget so it never delays the operation that triggered it.

Capability views on the Mint model read nuts[4]/nuts[5], which is where
onchain advertises itself — it has no nuts[30] block, it is just another
method entry, so capability is always a (method, unit) question. Two
rules: onchain additionally requires NUT-20 (the mint MUST reject an
onchain quote with no pubkey, error 20009), and when info was never
cached bolt11 is assumed while anything newer must prove itself —
assuming bolt11 exactly preserves today's behaviour and cannot strand an
upgrading user with an empty menu.

Mints that cannot serve an operation are now listed but disabled in the
Topup/Transfer selectors, with the reason in place of the hostname;
hiding them would leave the user hunting for a mint that silently
vanished.

WalletScreen's Send/Receive sheets are relabelled ahead of onchain: the
verb moves to the sheet heading so the rows become plain nouns (Ecash /
Bitcoin) that read as a real either/or, and the descriptions give the
criteria users actually decide on — speed and cost — rather than protocol
names. faBolt would be wrong once onchain exists, so the rail row now
uses the circled faBitcoin mark, tinted to match the BtcIcon SVG already
used by CurrencySign. Neither row asks the user to pick a rail: paying
infers it from what they paste, and topping up asks in TopupScreen where
the mint and unit are known.

221/221 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:45:48 +02:00
minibits-cashandClaude Opus 4.8 c7fd763eca Add NUT-20 quote-signing foundation (Stage 1 of onchain)
NUT-30 onchain mint quotes MUST be locked to a pubkey (the mint rejects
them with 20009 otherwise), so onchain needs NUT-20 quote signing. This
is net-new: bolt11 topup does not sign quotes today.

Adds the two pieces, with no production caller yet — Stage 5 (onchain
receive) is the first consumer:

  wallet_counters (new table, migration v30)
    NUT-20's path m/129373'/20'/0'/0'/{counter} has no mint or keyset
    component, so unlike mint_counters this counter is wallet-global.
    Keyed by purpose name so future wallet-global counters need no
    migration. No seed: no NUT-20 quote has ever existed, so an absent
    row (index 0) is correct for new and upgrading wallets alike.

  walletCountersRepo
    Allocation is BURN-FORWARD: one atomic INSERT..ON CONFLICT..RETURNING
    commits the index before the caller uses it, so a failed quote request
    or a crash can only SKIP an index, never hand the same one out twice.
    Reuse is what we cannot allow — two quotes sharing a pubkey lets the
    mint link them (NUT-20 asks for a unique key per quote precisely to
    prevent that) and makes the signature ambiguous. Being atomic, it is
    also safe against concurrent foreground/NWC-background allocation.

  services/cashu/nut20.ts
    deriveQuoteKeypair() is pure (seed in, keypair out) so it works from
    the off-MST background paths that will need to sign. Only the integer
    index is ever persisted; the privkey is re-derived from the keychain
    seed at mint time, which may be days later once an onchain deposit
    confirms. No key material lands in SQLite.

Also fixes the react-native-quick-crypto jest mock, which claimed to make
bip32 work but did not: it required the bare 'crypto' specifier (an empty
shim under the RN preset) and lacked __esModule, so Babel's interop double-
wrapped it and every quickCrypto.createHmac/.pbkdf2Sync call resolved to
undefined. Our patches to @scure/bip32 and @scure/bip39 route through
quick-crypto, so nothing that derives keys could be tested until now.

Tests pin the derivation against a vector computed independently of this
codebase (a clean @scure/bip32 install), so it cross-checks the path rather
than enshrining our own output. Note the exported signMintQuote /
verifyMintQuoteSignature in cashu-ts are the LEGACY aggregation (no domain
tag, no amounts); the library's own mint path signs with the spec's
Cashu_MintQuoteSig_v1, so what we transmit is spec-correct. The wire format
is proven when a real mint accepts a signed request, in Stage 5.

209/209 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:32:29 +02:00
minibits-cashandClaude Opus 4.8 13d09ebcef Bump cashu-ts to 4.7.0 (onchain/NUT-30 prerequisite)
4.7.0 is the first stable release carrying onchain mint/melt support
(added in 4.5.0), which NUT-30 work depends on.

The checkProofsStates call needed no change: our proofs already carry
both `secret` and `id`, so it was already binding to the v5-compatible
signature rather than the `secret`-only overload deprecated in 4.5.1.
Corrected the surrounding comments, which claimed v3.x semantics and
described secret-based mapping where the code maps by index (which is
correct — cashu-ts batches by 100 and re-indexes each batch by Y, so
states come back in input order).

Verified: typecheck introduces no new errors, 190/190 tests pass, and
lightning topup + melt (incl. fee reserve and change return, tested
against a non-cashu wallet) work end-to-end against a CDK test mint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:59:50 +02:00
minibits-cash 78f2eb6faa Padding in NWC card 2026-07-10 15:29:05 +02:00
minibits-cash fb0126b122 Make further screens immersive 2026-07-10 14:50:16 +02:00
minibits-cash 51414feda7 Polish Send / Receive buttons 2026-07-10 12:51:22 +02:00
minibits-cashandClaude Opus 4.8 52a1e0db70 Redesign bottom navigation as a floating pill
Replace the default BottomTabBar with a custom FloatingTabBar: a centered,
fully rounded container painted in the screen background colour, with tighter
icon spacing and slightly smaller icons. It is absolutely positioned, so tab
screens render to the bottom edge and cards scroll underneath it.

Because the default bar was a flex sibling of the screens, it reserved layout
space. The floating bar reports its footprint plus a top gap through
setTabBarHeight instead, and Screen pads by that value unless a screen opts
into `contentUnderTabBar`. The gap keeps bottom-aligned content (WalletScreen's
Scan button, PrivateContacts' Add button) off the pill.

The bar hides once the user scrolls away from the top of a list and returns
only when they scroll back. Visibility follows the scroll offset rather than
the direction, so it never slides in mid-list or when scrolling merely stops,
and a programmatic scroll-to-top cannot strand it off-screen.

Transactions, Settings and both Contacts tabs are immersive. The contacts lists
were restructured: a Card used to wrap the FlatList, clipping it at the card's
bounds, so the list is now the page scroller with the card look moved onto its
contentContainerStyle. PrivateContacts' Add button floats above the list.

Also convert Card's shadow to a single cross-platform boxShadow. Pairing
`elevation` with the iOS shadow* props rendered a darker, lower shadow on
Android; boxShadow is honoured identically on both. Exported as `cardBoxShadow`
for the two contacts lists that reproduce the card look on scroll content.

The bar deliberately ignores the keyboard. Under edge-to-edge Android the IME
overlays the window rather than resizing it, so keyboardDidHide is not reliably
emitted and the bar latched off-screen; the keyboard covers the bar anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:40:59 +02:00
minibits-cash 64bc6ac6f2 Fix MintInfoScreen 2026-07-10 09:47:19 +02:00
minibits-cash 273ab8e687 Agree to updated TCs 2026-07-08 16:49:02 +02:00
minibits-cash 876aa606b5 Fix NWC card clip 2026-07-08 16:36:21 +02:00
minibits-cash 45cef1c60a New welcome screen 2026-07-08 15:44:11 +02:00
minibits-cash ddbd1a6848 Bump version 2026-06-27 23:56:24 +02:00
minibits-cash 81699251aa Fix fee estimation 2026-06-27 23:55:40 +02:00
minibits-cash 74c1b77765 Fix foreground service 2026-06-27 23:55:08 +02:00
minibits-cash 670c0f5c98 Fix layout 2026-06-16 23:10:11 +02:00
minibits-cash 33d7bc07c0 UX improvements (Wallet, NFC) 2026-06-14 23:25:15 +02:00
minibits-cash 4a73b4c877 NFC error handling 2026-06-12 21:52:55 +02:00
minibits-cash 3d1f303bda iOS NFC error handling 2026-06-10 22:43:14 +02:00
minibits-cashandClaude Opus 4.8 58e72a28a9 Merge branch 'fix/melt-change-dleq-recovery'
fix(melt): recover NUT-08 change when mint reorders signatures

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:58:00 +02:00
minibits-cashandClaude Opus 4.8 3caf54fa35 fix(melt): recover NUT-08 change when mint reorders signatures
Some mints (nutshell < 0.20.1) return paid-melt-quote change[]
signatures in an order that does not match the blank outputs the
wallet sent. cashu-ts OutputData.toProof assumes positional pairing
and throws on its DLEQ check, so mapping over the array aborted on the
first mismatch and the catch discarded ALL change — recording it as
fee. A user lost ~100k sats of change on a single transfer this way.

Add CashuUtils.recoverMeltChange, a resilient helper that:
  - matches each signature to the blank whose blinded message makes the
    mint's DLEQ proof verify (verifyDLEQProof as alignment oracle),
    correctly re-pairing reordered change while keeping DLEQ as a hard
    guarantee;
  - on a genuine DLEQ failure (no blank verifies) falls back to
    unblinding the positional blank WITHOUT DLEQ so funds are recovered
    rather than dropped, logged at error level;
  - returns stats so callers can surface anomalies.

Both melt finalize paths (transferOperationApi._finalizePaid and
meltOperations.handlePendingMeltTask) use the helper and append a
COMPLETED-status tx.data entry with the recovery stats when a genuine
error occurred (no-DLEQ fallback / unmatched) — benign reordering is
not flagged.

Covered by __tests__/recoverMeltChange.test.ts (in-order, shuffled,
fewer-sigs-than-blanks, duplicate denominations, no-DLEQ fallback,
unmatched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:55:04 +02:00
minibits-cash bbe8688249 Merge branch 'perf/snapshot-persistence-dedup'
Dedup redundant MMKV snapshot writes (serialized-payload equality guard) and
make the Mint derivation counter volatile so per-derivation bumps no longer
trigger whole-tree snapshot writes. SQLite remains the counter authority.
2026-06-09 15:31:12 +02:00
minibits-cashandClaude Opus 4.8 05c069e4f8 chore(logging): redact secrets in keyChain trace logs
Truncate or omit sensitive values in trace-level logs: Nostr private key,
base64 seed and auth token now log only an 8-char prefix, and saveWalletKeys
logs walletId instead of the full key material.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:30:43 +02:00
minibits-cashandClaude Opus 4.8 8f6e468361 perf(persistence): dedup MMKV snapshot writes + make derivation counter volatile
The root onSnapshot listener wrote the whole MST snapshot to MMKV on every
action, even when postProcessSnapshot stripped the only changed field
(proofs, transactions, counters, tokens) to a byte-identical payload — MST
still fires onSnapshot and re-serializes regardless. During wallet
transactions this produced a storm of redundant whole-tree writes.

Tier A: guard the write by comparing the serialized payload against the last
one and skipping identical writes (stringify once, persist the raw string via
saveString to avoid a second serialize). Measured ~77% of writes eliminated in
steady-state operation at ~0.25ms/fire overhead. Behind a toggleable
PROFILE_SNAPSHOT_PERSISTENCE constant: cumulative invocations/writes/skipped/
totalMs trace, off by default.

Tier B: move Mint derivation `counter` from a persisted prop (stripped to 0 in
postProcessSnapshot) to volatile state, so per-derivation bumps never invalidate
the root snapshot at all. The counter is already SQLite-mastered (mint_counters)
and hydrated on startup. Reconciled the paths that fed counters through a
snapshot — backup export re-injects live.counter (unchanged), import and the
v<33 migration now seed SQLite from the raw snapshot then re-hydrate the cache
(applySnapshot no longer loads the volatile value). Removed the now-dead
seedCountersToDatabase. No model version bump required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:27:48 +02:00
minibits-cashandClaude Opus 4.8 bf356f3ba7 Merge branch 'feat/counters-sqlite-migration'
Counters → SQLite migration: move mint counters, melt counter values, and
in-flight requests off MMKV/MST into SQLite with atomic write-through,
startup hydration, seeding, and tests; remove the old counterBackups.

NWC background optimization: process pushed commands directly (no WS
re-fetch), adaptive listener lifetime, lean cold-wake hydration, async
melt with lifetime-bound preimage wait, and coalesced keychain reads.

Release: bump to 0.4.3-beta.8 and upgrade HotUpdater 0.23.0 → 0.32.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:24:03 +02:00
minibits-cashandClaude Opus 4.8 d774fde9ce chore(release): bump to 0.4.3-beta.8; upgrade HotUpdater 0.23.0→0.32.0
Upgrade react-native HotUpdater (0.23.0 → 0.32.0): drops SWCompression /
BitByteData pods and bumps OpenSSL-Universal to 3.6.2000. Set the iOS
launch scheme to Release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:23:37 +02:00
minibits-cashandClaude Opus 4.8 24a9a85b9f perf(nwc): coalesce concurrent KeyChain reads in getCachedWalletKeys [Stage 4a]
Several NWC pushes can wake the app simultaneously (e.g. a wallet
connecting fires get_balance + list_transactions together, or rapid
zaps). Each caller previously raced past the walletKeys cache check
before any populated it, so each triggered its own ~2s cold KeyChain
read.

Park the in-flight read in a volatile promise; concurrent callers await
the same fetch instead of hitting secure storage independently. The
shared promise resolves to validated keys so the originator and all
awaiters get identical success/error. Volatile (never persisted) so a
fresh cold start still does exactly one fresh read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:09:29 +02:00
minibits-cashandClaude Opus 4.8 0d43095108 perf(nwc): async melt with lifetime-bound preimage wait [Stage 6]
NWC pay_invoice forced SYNCHRONOUS melt, holding the background context
(JS, foreground service, SyncQueue) for the entire lightning settlement —
the worst offender for the OS background budget, especially on iOS and for
slow/stuck payments.

Switch NWC melt to async (preferAsync: true, matching all other flows):
the mint ACKs immediately and the existing monitor (ws/poll) finalizes on
settlement. payInvoice then waits for the preimage only as long as the
background is alive — resolves on ev_asyncMeltResult (fast common case →
reply with preimage) or ev_nwcListenerClosing (Stage 2 teardown), bound by
a safety cap. No separate per-pay timeout.

If still pending at teardown: resolve to a typed 'pending' outcome (no
NIP-47 wire reply — never fabricate a false error/success). The payment
finalizes later via the monitor / next foreground; zaps confirm via the
NIP-57 receipt (recipient's zapper publishes 9735 at settlement,
independent of our reply); non-zap clients retry and the mint dedups by
payment hash. Daily limit reserved conservatively on the pending path.

- transferOperationApi: drop the nwcEvent sync/async distinction.
- events.ts: ev_nwcListenerClosing; notificationService emits it on close.
- NwcStore: waitForAsyncMeltResult, typed NwcPending outcome, dispatcher
  skips sending pending, settled path re-reads preimage/amounts from SQLite.

tsc clean, suite 15/163 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:12:18 +02:00
minibits-cashandClaude Opus 4.8 3b992723ff perf(nwc): lean cold-wake hydration (skip JWT + proofs) [Stage 4a]
Measurement showed cold-wake setupRootStore is dominated by the keychain
JWT load (~63ms fixed, NWC never uses it) and loadProofs (scales with
wallet size, builds an MST node per proof synchronously). Make the
background NWC wake skip both.

- setupRootStore: {skipTokens, skipProofs} options. skipProofs also skips
  orphan-reservation recovery (bundled with proof loading). Foreground
  app-open still runs a FULL setup and SQLite is authoritative, so a lean
  wake is reconciled when the user opens the app.
- notificationService._nwcRequestHandler: lean wake (skipTokens+skipProofs).
- ProofsStore.ensureProofsLoaded(): load proofs + orphan recovery on demand
  (no-op once hydrated). Mutating NWC commands (pay/multi_pay/make_invoice)
  call it before selecting proofs — covers follow-up commands too, so the
  worst case of a bug is a gracefully-failed pay, never a wrong spend.
- get_balance reads SQLite (proofsRepo.getMintBalanceWithMaxBalance) instead
  of the MST proof map; the per-connection daily-limit cap is unchanged.
- loadRecentTx kept (list_transactions still reads MST history; skipping it
  would force a rewrite for only ~20ms).

Read-only commands now answer with no proof/JWT load; pay loads proofs
lazily (its cost is intrinsic anyway). tsc clean, suite 15/163 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:01:27 +02:00
minibits-cashandClaude Opus 4.8 c515ffcc73 chore(nwc): separate keychain-token load from loadProofs timing [Stage 0]
The stateHydrated marker sat before loadTokensFromKeyChain, so the JWT
keychain load was misattributed into the loadProofs bucket. Add a
tokensLoaded marker and a distinct loadTokens phase so the breakdown is
accurate (matters for the Stage 4 decision and on heavier wallets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:22:22 +02:00
minibits-cashandClaude Opus 4.8 cc9f012c23 chore(nwc): instrument setupRootStore cold-hydration phases [Stage 0]
Isolate the per-phase timings (mmkvLoad, applySnapshot, loadProofs,
hydrateCounters, recoverOrphans, loadRecentTx, total) plus proofCount and
stateBytes into a single info-level summary, so a background NWC wake
shows exactly where cold-hydration time goes. Drives the Stage 4
lean-hydration decision (hypothesis: applySnapshot + loadProofs dominate).

Measurement only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:13:00 +02:00
minibits-cashandClaude Opus 4.8 489c44e4f4 perf(nwc): adaptive listener lifetime instead of flat 30s [Stage 2]
The NWC listener / Android foreground service was held for a fixed 30s
after every wake, even when a single quick command finished in ~2s.

Replace the flat setTimeout with an idle-aware poller that closes ~8s
after the SyncQueue goes idle (no NWC command queued or running), capped
at 30s. An in-flight task keeps the window open (never closes mid
pay_invoice) and each follow-up command extends it via queue activity —
so bursts still get the fast-follow-up benefit while the idle tail is
trimmed, cutting foreground-service / websocket hold time per wake.

Uses the existing SyncQueue.getAllTasksDetails(['idle','running']) count.
tsc clean, suite 15/163 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:08:42 +02:00
minibits-cashandClaude Opus 4.8 2f350a346c perf(nwc): process pushed command directly; drop WS re-fetch [Stage 1]
The FCM push payload already carries the NWC request event, but the
background handler discarded it and spun up a WebSocket subscription to
re-fetch the same event before processing — adding a relay round-trip to
the first (often only) command.

- NwcStore.receivePushedEvent: dedup-mark the pushed event, set the
  follow-up listener window from it, and dispatch it on the SyncQueue
  (connection-level handler, same as the WS path). Must run before the
  listener opens so the event can never be processed twice.
- notificationService._nwcRequestHandler: process the pushed event
  directly, THEN open the listener purely for follow-ups.
- index.js: remove the dead HANDLE_NWC_REQUEST_TASK foreground-service
  branch (no notification ever triggered it) + now-unused imports.
- fix a double eventsBatch.push in listenForNwcEvents.onevent.

First command no longer waits on a WS connect+subscribe+round-trip; the
listener still catches follow-ups. No state-model changes (still runs
after setupRootStore — that's Stage 4). tsc clean, suite 15/163 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:02:52 +02:00
minibits-cashandClaude Opus 4.8 3321096fe2 refactor(counters): remove counterBackups (SQLite retention supersedes it)
counterBackups preserved a removed mint's counters in MMKV for restore on
re-add. That's now redundant: mint_counters rows are never deleted on mint
removal and addMint restores via hydrateCountersFromDatabase. Keeping both
also caused a latent double-advance on re-add (updateMintCountersFromBackup
bumped SQLite on top of the retained row).

- MintsStore: remove counterBackups field, CounterBackupModel/CounterBackup
  type, addOrUpdateCounterBackup + updateMintCountersFromBackup, the call in
  removeMint/addMint, and the counterBackups loop in seedCountersToDatabase.
  removeMint now just detaches; addMint relies on hydrate.
- snapshot compat: MintsStore.preProcessSnapshot strips counterBackups from
  old snapshots so applySnapshot tolerates the removed field.
- backup export: drop the counterBackups field (old backups that contain it
  are stripped on import via the same preProcessSnapshot).
- one-time seed (rootStoreModelVersion 35->36): _runMigrations copies any
  removed-mint counters from the RAW pre-upgrade snapshot's counterBackups
  into SQLite, so a later re-add still restores them. Monotonic.

Full suite green (15 suites / 163 tests). counterBackups was the last
counter-adjacent field still in MMKV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:02:04 +02:00