561 Commits

Author SHA1 Message Date
minibits-cash
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>
v0.4.3-beta.16
2026-07-17 11:19:20 +02:00
minibits-cash
b741719932 Bump version 2026-07-17 11:15:49 +02:00
minibits-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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-cash
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