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>
This commit is contained in:
minibits-cash
2026-07-17 11:19:20 +02:00
113 changed files with 12537 additions and 1654 deletions

46
__mocks__/nostr-tools.js Normal file
View File

@@ -0,0 +1,46 @@
/**
* Jest manual mock for nostr-tools and all of its subpaths.
*
* nostr-tools ships TypeScript SOURCE and vendors its own @noble/curves and
* @noble/hashes (also source) which import old-style subpaths jest cannot resolve.
* Mapping those would silently redirect the nested copies onto the top-level @noble
* packages — different versions, on crypto code. Not a trade worth making to run a
* test.
*
* This matters far beyond nostr: `services/keyChain` imports nostr-tools, the
* services barrel imports keyChain, and the MODEL layer imports that barrel — so
* nostr-tools loads for every store test even though none goes near nostr.
*
* Calls throw rather than returning plausible fakes: a test that silently derives a
* bogus key and asserts on it is worse than one that stops and explains. Code that
* genuinely needs nostr should mock the calling service (as the existing suites do
* with nostrService).
*/
const makeNamespace = path => {
const cache = new Map()
return new Proxy(
{},
{
get(_target, prop) {
if (prop === '__esModule') return true
if (prop === 'then') return undefined
if (typeof prop === 'symbol') return undefined
if (!cache.has(prop)) {
const name = `${path}.${String(prop)}`
const fn = () => {
throw new Error(
`[nostr-tools mock] ${name}() was called in a test. This mock exists ` +
`only so the module graph resolves — nostr-tools ships TS source ` +
`with vendored @noble packages. Mock the calling service instead.`,
)
}
cache.set(prop, fn)
}
return cache.get(prop)
},
},
)
}
module.exports = makeNamespace('nostr-tools')

128
__mocks__/op-sqlite.js Normal file
View File

@@ -0,0 +1,128 @@
/**
* Jest mock for @op-engineering/op-sqlite — a REAL database, backed by node:sqlite.
*
* op-sqlite is native and cannot load under jest. Rather than stub it out, this
* implements its driver surface on top of Node's built-in SQLite, so tests run the
* PRODUCTION code path end to end: connection.ts (its param sanitizing, result
* adaptation and BEGIN/COMMIT batch emulation), instance.ts (schema creation and
* the real migration runner), and every repo — all real.
*
* Why this matters: the db suites used to hand-copy both the DDL and each repo's
* SQL, and those copies drifted from production repeatedly while staying green.
* A test that asserts against its own copy of the schema proves nothing about the
* schema. Mocking at connection.ts's documented seam — "the single seam between
* the rest of the app and the native SQLite library" — removes the copies entirely.
*
* Each test FILE gets its own in-memory database (jest resets the module registry
* per file, so instance.ts re-runs and rebuilds the schema). Tests within a file
* share it; clear the tables you use in beforeEach, or use distinct keys.
*
* NOT covered here: op-sqlite is not SQLite-the-same-build. Node's SQLite may differ
* in version and compile flags, so this proves our SQL and our logic, not the exact
* native binary's behaviour. Device testing still owns that.
*/
const {DatabaseSync} = require('node:sqlite')
/**
* Statements that RETURN rows. Everything else reports changes/insertId instead.
*
* The RETURNING clause matters: walletCountersRepo allocates an index with a single
* `INSERT … ON CONFLICT DO UPDATE … RETURNING`, which leads with INSERT but yields a
* row. Routing it by leading keyword alone hands back nothing and the allocation
* fails.
*/
const returnsRows = sql =>
/^\s*(select|pragma|with|explain)/i.test(sql) || /\breturning\b/i.test(sql)
/** Transaction control cannot be prepared as a statement; exec it directly. */
const isTransactionControl = sql => /^\s*(begin|commit|rollback|savepoint|release)\b/i.test(sql)
/**
* node:sqlite binds a narrower set of types than the native driver.
*
* connection.ts's sanitizeValue runs BEFORE this and is what the app relies on, so
* it has already rejected anything genuinely unbindable. This only bridges the two
* types Node will not take directly — booleans (SQLite has no boolean type; real
* drivers coerce to 0/1) and ArrayBuffer (Node wants a view).
*/
const toBindable = value => {
if (typeof value === 'boolean') return value ? 1 : 0
if (value instanceof ArrayBuffer) return new Uint8Array(value)
return value
}
/**
* Test hook: pre-populate the NEXT database opened.
*
* Lets a test hand instance.ts a database that already exists at some older
* version, which is the only way to exercise the real upgrade path — the one that
* runs on every user's device and that a fresh in-memory database never touches.
* Consumed once, so it cannot leak into the next open.
*/
let _seedNextDatabase = null
const __seedNextDatabase = seed => {
_seedNextDatabase = seed
}
const open = () => {
const db = new DatabaseSync(':memory:')
if (_seedNextDatabase) {
_seedNextDatabase(db)
_seedNextDatabase = null
}
const executeSync = (query, params) => {
if (isTransactionControl(query)) {
db.exec(query)
return {rows: [], rowsAffected: 0}
}
const bound = (params ?? []).map(toBindable)
const statement = db.prepare(query)
if (returnsRows(query)) {
return {rows: statement.all(...bound), rowsAffected: 0}
}
const result = statement.run(...bound)
return {
rows: [],
rowsAffected: Number(result.changes ?? 0),
// Only meaningful for INSERT; harmless elsewhere and matches op-sqlite.
insertId: result.lastInsertRowid != null ? Number(result.lastInsertRowid) : undefined,
}
}
return {
executeSync,
execute: async (query, params) => executeSync(query, params),
// op-sqlite's own batch is async and all-or-nothing. connection.ts does not use
// it for the synchronous path (it emulates that with BEGIN/COMMIT over
// executeSync), so this only serves executeBatchAsync.
executeBatch: async commands => {
let rowsAffected = 0
db.exec('BEGIN')
try {
for (const [query, params] of commands) {
rowsAffected += executeSync(query, params).rowsAffected ?? 0
}
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
return {rowsAffected}
},
close: () => db.close(),
delete: () => {},
}
}
module.exports = {
open,
__seedNextDatabase,
IOS_DOCUMENT_PATH: '/mock/ios/documents',
ANDROID_FILES_PATH: '/mock/android/files',
}
module.exports.__esModule = true

56
__mocks__/react-native-mmkv.js vendored Normal file
View File

@@ -0,0 +1,56 @@
/**
* Jest manual mock for react-native-mmkv.
*
* MMKV is a native module, so it cannot load under jest. Everything in the model
* layer reaches it transitively — `Mint -> theme -> useThemeColor -> services ->
* mmkvStorage` — which is what made MST stores impossible to instantiate in a test
* at all.
*
* Backed by a plain Map per instance id, so it behaves like real storage for the
* small surface mmkvStorage uses (getString/set/delete/clearAll). Tests that care
* about persistence can therefore round-trip; tests that do not can ignore it.
*/
class MMKV {
constructor(config = {}) {
// Keyed by id so two MMKV instances stay isolated, as they are natively.
const id = config.id ?? 'default'
if (!MMKV._stores.has(id)) MMKV._stores.set(id, new Map())
this._store = MMKV._stores.get(id)
}
getString(key) {
const value = this._store.get(key)
return typeof value === 'string' ? value : undefined
}
set(key, value) {
this._store.set(key, value)
}
delete(key) {
this._store.delete(key)
}
clearAll() {
this._store.clear()
}
getAllKeys() {
return Array.from(this._store.keys())
}
contains(key) {
return this._store.has(key)
}
}
/** Every instance ever created, by id. Reset between tests via __resetMMKV(). */
MMKV._stores = new Map()
/** Test helper: drop all stored data so suites cannot leak state into each other. */
const __resetMMKV = () => {
MMKV._stores.clear()
}
module.exports = {MMKV, __resetMMKV}
module.exports.__esModule = true

View File

@@ -5,8 +5,20 @@
* (Node environment, no native module) we simply delegate to Node's crypto.
* This lets dependencies that load it at import time — e.g. @scure/bip32 via
* @cashu/cashu-ts — be required without the native `QuickCrypto` module.
*
* `__esModule` is load-bearing. Our patches to @scure/bip32 and @scure/bip39
* (see patches/) rewire them onto quick-crypto for native speed, using a DEFAULT
* import: `import quickCrypto from 'react-native-quick-crypto'` and then calling
* `quickCrypto.createHmac(...)` / `.pbkdf2Sync(...)`. Without the `__esModule`
* marker, Babel's interop wraps this CJS module again — the default import then
* resolves to `{default: crypto}` instead of `crypto`, and every call lands on
* `undefined`. With it, interop hands back `default` directly.
*/
const crypto = require('crypto')
// `node:` prefix is deliberate: the bare `crypto` specifier resolves to an empty
// shim under the react-native jest preset, which silently yields a module with no
// createHmac/pbkdf2Sync on it.
const crypto = require('node:crypto')
module.exports = crypto
module.exports.default = crypto
module.exports.__esModule = true

View File

@@ -0,0 +1,40 @@
/**
* Jest manual mock for @sentry/react-native.
*
* Sentry ships untransformed ESM, and `transformIgnorePatterns` does not cover it,
* so any module reaching it fails to parse. `services/logService` imports it at
* load time and the whole app imports logService — which is why every existing
* suite mocks logService wholesale just to avoid this.
*
* Mocking Sentry instead of logService lets the REAL logService load, so tests can
* exercise code that logs (and assert on it) rather than replacing the logger.
*
* A Proxy backs every property with a lazily-created jest.fn(), so this keeps
* working as the Sentry surface changes — no enumeration to drift. `logger` is a
* nested namespace and gets the same treatment.
*/
const makeNamespace = () => {
const fns = new Map()
return new Proxy(
{},
{
get(_target, prop) {
// Jest/Node poke at these during interop and inspection; answering with a
// mock fn confuses both.
if (prop === '__esModule') return true
if (prop === 'then') return undefined
if (typeof prop === 'symbol') return undefined
if (prop === 'logger') {
if (!fns.has('logger')) fns.set('logger', makeNamespace())
return fns.get('logger')
}
if (!fns.has(prop)) fns.set(prop, jest.fn())
return fns.get(prop)
},
},
)
}
module.exports = makeNamespace()

View File

@@ -0,0 +1,96 @@
/**
* Grouping separators on confirmed amounts.
*
* AmountInput SHOWS a confirmed amount grouped ("12,345") but never STORES it that way:
* state holds the plain number and the grouped text is derived at render time, only while
* the field is unfocused.
*
* That split is the whole design, and this pins why it has to be. These are controlled
* TextInputs, and AmountInput rewrites the first comma to a dot on input so a
* decimal-comma keyboard can type "1,5" and mean 1.5. Keep a grouped string in state and
* that rewrite gets a chance at it: "1,000" becomes "1.000", which is 1 — a thousandfold
* error, silent, in the field that decides how much money leaves the wallet. Derived
* display cannot be read back in as input, so the two meanings of "," never meet.
*
* @jest-environment node
*/
import {formatNumber, toNumber} from '../src/utils/number'
/** What AmountInput renders when a field is unfocused. Never written to state. */
const formatForDisplay = (v: string, mantissa: number) => {
const n = toNumber(v.replace(/,/g, ''))
if (n === undefined || !Number.isFinite(n)) return v
return formatNumber(n, mantissa)
}
/** What AmountInput does to every keystroke, before it reaches state. */
const normalizeDecimalComma = (v: string) => v.replace(',', '.')
describe('formatForDisplay groups a confirmed amount to the unit precision', () => {
// sat: mantissa 0. Fractional sats do not exist, and every screen rounds to an integer
// before doing anything with the value anyway.
it('groups sats without a mantissa', () => {
expect(formatForDisplay('1000', 0)).toBe('1,000')
expect(formatForDisplay('12345', 0)).toBe('12,345')
expect(formatForDisplay('999999999', 0)).toBe('999,999,999')
expect(formatForDisplay('0', 0)).toBe('0')
})
// fiat: mantissa 2.
it('pads a fiat amount to two places', () => {
expect(formatForDisplay('1234.5', 2)).toBe('1,234.50')
expect(formatForDisplay('12', 2)).toBe('12.00')
})
it('leaves unparseable text alone rather than blanking the field', () => {
expect(formatForDisplay('', 0)).toBe('')
})
// Screens pre-fill from BIP21 / melt quotes with numbro's thousandSeparated, so a
// grouped string can arrive from outside. Formatting it again must not compound.
it('is idempotent, so an already-grouped input survives', () => {
expect(formatForDisplay('1,000', 0)).toBe('1,000')
expect(formatForDisplay(formatForDisplay('12345', 0), 0)).toBe('12,345')
})
})
describe('the grouped form never reaches state', () => {
/**
* What storing the grouped value would have cost.
*
* The decimal-comma rewrite fires on whatever text the input hands back — and on
* Android a programmatically-set `value` is echoed straight back through onChangeText.
* So a grouped value in state gets exactly one keystroke, or one echo, before it means
* something else entirely.
*/
it('shows what a grouped value in state would have become', () => {
const grouped = '1,000'
expect(normalizeDecimalComma(grouped)).toBe('1.000')
expect(toNumber(normalizeDecimalComma(grouped))).toBe(1) // meant 1000
const bigger = '12,345'
expect(toNumber(normalizeDecimalComma(bigger))).toBe(12.345) // meant 12345
})
// Which is exactly why the rewrite is harmless against a plain value: there is no
// grouping comma for it to find, only a decimal one the user typed.
it('leaves a plain value — and the decimal-comma keyboard — untouched', () => {
expect(normalizeDecimalComma('1000')).toBe('1000')
expect(toNumber(normalizeDecimalComma('1000'))).toBe(1000)
// The rewrite's actual purpose.
expect(toNumber(normalizeDecimalComma('1,5'))).toBe(1.5)
})
})
describe('toNumber reads a grouped amount, whatever the source', () => {
// Parents pre-fill with grouped strings and AmountInput strips them on ingest, but the
// screens also parse their own amount state — so this has to hold either way.
it('parses grouped and plain alike', () => {
expect(toNumber('12,345')).toBe(12345)
expect(toNumber('12345')).toBe(12345)
expect(toNumber('1,234,567')).toBe(1234567)
expect(toNumber('1,234.56')).toBe(1234.56)
})
})

View File

@@ -0,0 +1,257 @@
import {
decodeBitcoinAddress,
isBitcoinAddress,
isPayableBitcoinAddress,
parseBip21,
findBitcoinAddress,
} from '../src/services/bitcoin/bitcoinUtils'
/**
* A REAL deposit address handed out by the CDK fakewallet backend for an onchain
* topup quote. Anyone testing this wallet ends up with one of these in their
* clipboard, so it is the single most likely wrong thing to be pasted into Pay.
*/
const FAKEWALLET_REGTEST = 'bcrt1qq723ledhgscxenun8z2pt3atxtnqef3csv0hl9'
// BIP-173 / BIP-350 test vectors plus real-world addresses.
const P2PKH = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'
const P2SH = '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'
const P2WPKH = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'
const P2WSH = 'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3'
const P2TR = 'bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297'
const TESTNET_P2WPKH = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'
const TESTNET_P2PKH = 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn'
describe('decodeBitcoinAddress', () => {
it('decodes mainnet legacy addresses', () => {
expect(decodeBitcoinAddress(P2PKH)).toEqual({
address: P2PKH,
network: 'mainnet',
kind: 'P2PKH',
})
expect(decodeBitcoinAddress(P2SH)).toEqual({
address: P2SH,
network: 'mainnet',
kind: 'P2SH',
})
})
it('decodes segwit v0 addresses and distinguishes P2WPKH from P2WSH by program length', () => {
expect(decodeBitcoinAddress(P2WPKH)).toEqual({
address: P2WPKH,
network: 'mainnet',
kind: 'P2WPKH',
})
expect(decodeBitcoinAddress(P2WSH)).toEqual({
address: P2WSH,
network: 'mainnet',
kind: 'P2WSH',
})
})
it('decodes taproot (bech32m)', () => {
expect(decodeBitcoinAddress(P2TR)).toEqual({
address: P2TR,
network: 'mainnet',
kind: 'P2TR',
})
})
it('decodes testnet addresses', () => {
expect(decodeBitcoinAddress(TESTNET_P2WPKH)?.network).toBe('testnet')
expect(decodeBitcoinAddress(TESTNET_P2PKH)?.network).toBe('testnet')
})
it('accepts uppercase segwit and normalizes it', () => {
// QR encoders uppercase bech32 to stay in alphanumeric mode.
expect(decodeBitcoinAddress(P2WPKH.toUpperCase())?.address).toBe(P2WPKH)
})
it('rejects mixed-case segwit (BIP-173)', () => {
const mixed = 'bc1QW508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'
expect(decodeBitcoinAddress(mixed)).toBeUndefined()
})
// The checksum is the whole point: a wallet that accepts a typo'd address is a
// wallet that sends money nowhere. Both encodings must actually verify.
it('rejects a corrupted base58 checksum', () => {
expect(decodeBitcoinAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3')).toBeUndefined()
})
it('rejects a corrupted bech32 checksum', () => {
expect(decodeBitcoinAddress('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5')).toBeUndefined()
})
// Witness version selects the checksum constant. Accepting either constant for
// either version would let a corrupted address through whenever it happened to
// satisfy the other one.
it('rejects a v0 address encoded with bech32m', () => {
// BIP-350 invalid vector: v0 witness with bech32m checksum.
expect(
decodeBitcoinAddress('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh'),
).toBeUndefined()
})
it('rejects a v1 address encoded with bech32', () => {
// BIP-350 invalid vector: v1 witness with bech32 (not bech32m) checksum.
expect(
decodeBitcoinAddress('bc1p38j9r5y49hruaue7wxjce0updqjuyyx0kh56v8s25huc6995vvpql3jow4'),
).toBeUndefined()
})
it('rejects an unknown human-readable prefix', () => {
expect(decodeBitcoinAddress('ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4')).toBeUndefined()
})
it('rejects lightning invoices, empty strings and noise', () => {
expect(decodeBitcoinAddress('')).toBeUndefined()
expect(decodeBitcoinAddress(' ')).toBeUndefined()
expect(decodeBitcoinAddress('lnbc1u1p...')).toBeUndefined()
expect(decodeBitcoinAddress('not an address')).toBeUndefined()
})
it('isBitcoinAddress is a predicate over the same rules', () => {
expect(isBitcoinAddress(P2TR)).toBe(true)
expect(isBitcoinAddress('nope')).toBe(false)
})
it('decodes the CDK fakewallet regtest address rather than rejecting it outright', () => {
// Recognising it is what lets the pay flow say "wrong network" instead of
// "unknown data". Refusing to PAY it is isPayableBitcoinAddress's job.
expect(decodeBitcoinAddress(FAKEWALLET_REGTEST)).toEqual({
address: FAKEWALLET_REGTEST,
network: 'regtest',
kind: 'P2WPKH',
})
})
})
describe('isPayableBitcoinAddress', () => {
// The flag is passed explicitly rather than left to default, so these pin BOTH sides
// of the rule regardless of what `__DEV__` happens to be under jest.
const RELEASE = {allowNonMainnet: false}
const DEBUG = {allowNonMainnet: true}
it('accepts mainnet addresses of every script type, in any build', () => {
for (const address of [P2PKH, P2SH, P2WPKH, P2WSH, P2TR]) {
expect(isPayableBitcoinAddress(address, RELEASE)).toBe(true)
expect(isPayableBitcoinAddress(address, DEBUG)).toBe(true)
}
})
/**
* The loss vector this exists for: a CDK fakewallet topup hands the user a REGTEST
* deposit address, it sits in their clipboard, and Pay auto-pastes it. In a build a
* user can install, that is never a payment — it is a mistake.
*/
it('refuses the CDK fakewallet regtest address in a release build', () => {
expect(isPayableBitcoinAddress(FAKEWALLET_REGTEST, RELEASE)).toBe(false)
})
/**
* ...and accepts it in a debug build, because settling a melt against the fakewallet's
* regtest chain is the only way to exercise the onchain payout rail end to end.
*/
it('accepts the CDK fakewallet regtest address in a debug build', () => {
expect(isPayableBitcoinAddress(FAKEWALLET_REGTEST, DEBUG)).toBe(true)
})
it('refuses every non-mainnet address in a release build', () => {
expect(isPayableBitcoinAddress(TESTNET_P2WPKH, RELEASE)).toBe(false)
expect(isPayableBitcoinAddress(TESTNET_P2PKH, RELEASE)).toBe(false)
})
it('accepts testnet as well as regtest in a debug build', () => {
expect(isPayableBitcoinAddress(TESTNET_P2WPKH, DEBUG)).toBe(true)
expect(isPayableBitcoinAddress(TESTNET_P2PKH, DEBUG)).toBe(true)
})
// The network flag relaxes the NETWORK, never the checksum. A debug build must not
// become a build that pays typos.
it('refuses input that is not an address at all, even in a debug build', () => {
expect(isPayableBitcoinAddress('nope', DEBUG)).toBe(false)
expect(isPayableBitcoinAddress('', DEBUG)).toBe(false)
// Corrupted base58 checksum, mainnet prefix.
expect(isPayableBitcoinAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3', DEBUG)).toBe(false)
})
})
describe('parseBip21', () => {
it('parses a bare bitcoin: URI', () => {
expect(parseBip21(`bitcoin:${P2WPKH}`)).toEqual({address: P2WPKH})
})
it('converts the BTC amount to sats', () => {
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0.0001`)?.amountSat).toBe(10000)
expect(parseBip21(`bitcoin:${P2WPKH}?amount=1`)?.amountSat).toBe(100000000)
})
// 0.0001 * 1e8 is 9999.999999999999 in binary floating point. Truncating would
// under-request by a sat; the round-trip through buildBip21Uri must be stable.
it('rounds rather than truncates the float conversion', () => {
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0.00010000`)?.amountSat).toBe(10000)
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0.00000001`)?.amountSat).toBe(1)
})
it('parses label, message and a unified lightning invoice', () => {
const parsed = parseBip21(
`bitcoin:${P2WPKH}?amount=0.001&label=Alice&message=Thanks&lightning=LNBC1U1PABC`,
)
expect(parsed).toEqual({
address: P2WPKH,
amountSat: 100000,
label: 'Alice',
message: 'Thanks',
lightning: 'lnbc1u1pabc',
})
})
it('accepts an uppercase scheme', () => {
expect(parseBip21(`BITCOIN:${P2WPKH.toUpperCase()}`)?.address).toBe(P2WPKH)
})
it('ignores an unusable amount instead of failing the URI', () => {
expect(parseBip21(`bitcoin:${P2WPKH}?amount=abc`)?.amountSat).toBeUndefined()
expect(parseBip21(`bitcoin:${P2WPKH}?amount=-1`)?.amountSat).toBeUndefined()
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0`)?.amountSat).toBeUndefined()
})
it('rejects a URI whose address does not check out', () => {
expect(parseBip21('bitcoin:not-an-address')).toBeUndefined()
expect(parseBip21('bitcoin:1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3')).toBeUndefined()
})
// A lightning-only unified URI is legal BIP21, but an onchain melt cannot use it.
it('rejects an addressless URI', () => {
expect(parseBip21('bitcoin:?lightning=lnbc1u1pabc')).toBeUndefined()
})
it('rejects non-BIP21 input', () => {
expect(parseBip21(P2WPKH)).toBeUndefined()
expect(parseBip21('lightning:lnbc1')).toBeUndefined()
})
})
describe('findBitcoinAddress', () => {
it('finds a bare address', () => {
expect(findBitcoinAddress(P2TR)).toBe(P2TR)
})
it('finds a BIP21 URI inside surrounding text', () => {
const found = findBitcoinAddress(`Pay me here: bitcoin:${P2WPKH}?amount=0.001 thanks!`)
expect(found).toBe(`bitcoin:${P2WPKH}?amount=0.001`)
})
it('finds an address embedded in pasted prose', () => {
expect(findBitcoinAddress(`send to ${P2PKH} please`)).toBe(P2PKH)
})
it('skips candidates that fail their checksum', () => {
expect(findBitcoinAddress('send to 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3 please')).toBeUndefined()
})
it('returns undefined for text with no address', () => {
expect(findBitcoinAddress('just some words')).toBeUndefined()
expect(findBitcoinAddress('')).toBeUndefined()
})
})

View File

@@ -1,340 +1,239 @@
/**
* Derivation-counter tests (mint_counters migration).
* Derivation counters (mint_counters), against the REAL repo and a real database.
*
* Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`,
* `seedCounters`, and the `counterUpdate` folded into `commitReservation`
* implement on top of `executeBatch`. The counter is the BIP32 derivation
* high-water mark; the single most important invariant is that it is MONOTONIC
* — a stored counter can never move backward — because a regression would let
* the next derivation reuse a blinded secret.
* The counter is the NUT-13 derivation high-water mark for a keyset. Its single
* most important invariant is that it is MONOTONIC — a stored counter can never
* move backward — because a regression would let the next derivation reuse a
* blinded secret the mint has already signed.
*
* As with proofReservation.test.ts we mirror the exact production SQL using
* node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device.
* Rows are keyed by keysetId ALONE: NUT-13 derives from (seed, keysetId, counter)
* with no mint component, so one keyset id means one derivation sequence no matter
* which url served it. See MINT_COUNTERS_COLUMNS.
*
* @jest-environment node
* This suite calls the production `Database.*` functions. It used to hand-copy both
* the schema and each statement, and those copies drifted from production while
* staying green — including asserting the OLD (mintUrl, keysetId) key long after it
* was gone. The op-sqlite jest mock now backs the real driver seam with node:sqlite,
* so there is nothing left to copy: connection.ts, instance.ts (schema + the real
* migration runner) and the repos all run for real.
*/
import {DatabaseSync} from 'node:sqlite'
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
const NOW = '2026-06-04T00:00:00.000Z'
// ── Schema (mirrors schema.ts) ──────────────────────────────────────────────
const CREATE_MINT_COUNTERS = `CREATE TABLE mint_counters (
mintUrl TEXT NOT NULL,
keysetId TEXT NOT NULL,
unit TEXT,
counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT,
PRIMARY KEY (mintUrl, keysetId)
)`
const CREATE_PROOFS = `CREATE TABLE proofs (
id TEXT NOT NULL,
amount INTEGER NOT NULL,
secret TEXT PRIMARY KEY NOT NULL,
C TEXT NOT NULL,
unit TEXT,
tId INTEGER,
mintUrl TEXT,
state TEXT NOT NULL DEFAULT 'UNSPENT',
updatedAt TEXT
)`
const CREATE_RESERVATIONS = `CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
createdAt TEXT NOT NULL
)`
import {Database} from '../src/services/db'
import {_dbVersion} from '../src/services/db/migrations'
const MINT = 'https://mint.test'
// ── Mirrored Database primitives (exact production SQL) ─────────────────────
/** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */
function setCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, value: number) {
db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
counter = MAX(counter, excluded.counter),
unit = excluded.unit,
updatedAt = excluded.updatedAt`,
).run(mintUrl, keysetId, unit, value, NOW)
}
/** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */
function bumpCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, delta: number) {
if (delta <= 0) return
db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
counter = counter + ?,
updatedAt = excluded.updatedAt`,
).run(mintUrl, keysetId, unit, delta, NOW, delta)
}
function getCounter(db: DatabaseSync, mintUrl: string, keysetId: string): number | undefined {
const row = db
.prepare('SELECT counter FROM mint_counters WHERE mintUrl = ? AND keysetId = ?')
.get(mintUrl, keysetId) as {counter: number} | undefined
return row?.counter
}
function counterRowCount(db: DatabaseSync): number {
const {n} = db.prepare('SELECT COUNT(*) AS n FROM mint_counters').get() as {n: number}
return n
}
/** countersRepo.seedCounters — idempotent monotonic batch. */
function seedCounters(
db: DatabaseSync,
seeds: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}>,
) {
db.exec('BEGIN')
try {
for (const s of seeds) setCounter(db, s.mintUrl, s.keysetId, s.unit ?? null, s.counter)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function insertProof(db: DatabaseSync, secret: string, amount: number, state = 'UNSPENT') {
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, '2026-01-01')`,
).run(amount, secret, state)
}
function getProofState(db: DatabaseSync, secret: string): string {
const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as
| {state: string}
| undefined
return row?.state ?? ''
}
/**
* commitReservation with a folded counterUpdate (the step-4 atomic commit):
* new proofs + a monotonic counter upsert + reservation delete, all in one txn.
* One in-memory database per test FILE (instance.ts caches its connection), so
* clear the tables between tests rather than rebuilding.
*/
function commitWithCounter(
db: DatabaseSync,
reservationId: string,
changes: {
newProofs?: Array<{secret: string; amount: number; state: string}>
counterUpdate?: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}>
},
) {
db.exec('BEGIN')
try {
const insertNew = db.prepare(
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, ?)`,
beforeEach(() => {
Database.getInstance().executeBatch([
['DELETE FROM mint_counters'],
['DELETE FROM proofs'],
['DELETE FROM reservations'],
['DELETE FROM transactions'],
])
})
const counterOf = (keysetId: string) => Database.getCounter(keysetId)?.counter
const insertProof = (secret: string, amount: number, tId = 1, state: 'UNSPENT' | 'PENDING' = 'UNSPENT') =>
Database.addOrUpdateProofs(
[{id: 'keyset1', amount, secret, C: 'C' + secret, mintUrl: MINT, unit: 'sat', tId} as any],
state,
)
for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW)
for (const cu of changes.counterUpdate ?? []) {
setCounter(db, cu.mintUrl, cu.keysetId, cu.unit ?? null, cu.counter)
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_MINT_COUNTERS)
db.exec(CREATE_PROOFS)
db.exec(CREATE_RESERVATIONS)
return db
}
// ── Tests ───────────────────────────────────────────────────────────────────
const proofStateOf = (secret: string): string | undefined =>
Database.getInstance().execute('SELECT state FROM proofs WHERE secret = ?', [secret]).rows?.item(0)
?.state
describe('Derivation counters (mint_counters)', () => {
test('the database is at the current schema version', () => {
// Guards the whole suite: these run against instance.ts's real schema + the
// real migration registry, so a version mismatch means the rest is testing
// something other than production.
expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(_dbVersion)
})
describe('setCounter — monotonic', () => {
test('inserts a new row when none exists', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 42)
expect(getCounter(db, MINT, 'k1')).toBe(42)
db.close()
Database.setCounter('k1', 'sat', 42)
expect(counterOf('k1')).toBe(42)
})
test('raises to a higher value', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 150)
expect(getCounter(db, MINT, 'k1')).toBe(150)
db.close()
Database.setCounter('k1', 'sat', 100)
Database.setCounter('k1', 'sat', 150)
expect(counterOf('k1')).toBe(150)
})
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 50) // stale / replayed writer
expect(getCounter(db, MINT, 'k1')).toBe(100)
db.close()
Database.setCounter('k1', 'sat', 100)
Database.setCounter('k1', 'sat', 50) // stale / replayed writer
expect(counterOf('k1')).toBe(100)
})
test('an equal value is a no-op', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 100)
expect(getCounter(db, MINT, 'k1')).toBe(100)
db.close()
Database.setCounter('k1', 'sat', 100)
Database.setCounter('k1', 'sat', 100)
expect(counterOf('k1')).toBe(100)
})
})
describe('bumpCounter — relative advance', () => {
test('inserts from 0 when no row exists', () => {
const db = freshDb()
bumpCounter(db, MINT, 'k1', 'sat', 10)
expect(getCounter(db, MINT, 'k1')).toBe(10)
db.close()
Database.bumpCounter('k1', 'sat', 10)
expect(counterOf('k1')).toBe(10)
})
test('adds to the existing value', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
bumpCounter(db, MINT, 'k1', 'sat', 10)
expect(getCounter(db, MINT, 'k1')).toBe(110)
db.close()
Database.setCounter('k1', 'sat', 100)
Database.bumpCounter('k1', 'sat', 10)
expect(counterOf('k1')).toBe(110)
})
test('a non-positive delta is a no-op', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
bumpCounter(db, MINT, 'k1', 'sat', 0)
bumpCounter(db, MINT, 'k1', 'sat', -5)
expect(getCounter(db, MINT, 'k1')).toBe(100)
db.close()
Database.setCounter('k1', 'sat', 100)
Database.bumpCounter('k1', 'sat', 0)
Database.bumpCounter('k1', 'sat', -5)
expect(counterOf('k1')).toBe(100)
})
})
describe('primary key isolation', () => {
test('different keysets on the same mint are independent', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k2', 'sat', 7)
expect(getCounter(db, MINT, 'k1')).toBe(100)
expect(getCounter(db, MINT, 'k2')).toBe(7)
expect(counterRowCount(db)).toBe(2)
db.close()
describe('primary key — one keyset, one counter', () => {
test('different keysets are independent', () => {
Database.setCounter('k1', 'sat', 100)
Database.setCounter('k2', 'sat', 7)
expect(counterOf('k1')).toBe(100)
expect(counterOf('k2')).toBe(7)
expect(Database.getCounters()).toHaveLength(2)
})
test('the same keyset id on different mints is independent', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, 'https://other.test', 'k1', 'sat', 5)
expect(getCounter(db, MINT, 'k1')).toBe(100)
expect(getCounter(db, 'https://other.test', 'k1')).toBe(5)
db.close()
// The inverse of this used to be asserted (and implemented): a
// (mintUrl, keysetId) key let ONE keyset carry two counters. Both drove the
// same derivation path, so the lower one handed out indices the mint had
// already signed against the higher.
test('one keyset id has exactly ONE counter, whatever mint served it', () => {
Database.setCounter('k1', 'sat', 100)
Database.setCounter('k1', 'sat', 5) // same keyset seen via another url
expect(counterOf('k1')).toBe(100) // monotonic, not a second row
expect(Database.getCounters()).toHaveLength(1)
})
test('getCounter returns undefined for an unknown keyset', () => {
expect(Database.getCounter('nope')).toBeUndefined()
})
})
describe('seedCounters — one-time MMKV→SQLite copy', () => {
describe('seedCounters — one-time MST/MMKV copy', () => {
test('seeds every supplied counter', () => {
const db = freshDb()
seedCounters(db, [
{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100},
{mintUrl: MINT, keysetId: 'k2', unit: 'sat', counter: 50},
Database.seedCounters([
{keysetId: 'k1', unit: 'sat', counter: 100},
{keysetId: 'k2', unit: 'sat', counter: 50},
])
expect(getCounter(db, MINT, 'k1')).toBe(100)
expect(getCounter(db, MINT, 'k2')).toBe(50)
db.close()
expect(counterOf('k1')).toBe(100)
expect(counterOf('k2')).toBe(50)
})
test('is idempotent — re-running never lowers an advanced counter', () => {
const db = freshDb()
// First upgrade seed copies the (then current) MMKV values.
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}])
// Wallet advances past it during normal use.
setCounter(db, MINT, 'k1', 'sat', 175)
// A later launch re-runs the seed with the now-stale snapshot value.
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}])
// The advanced SQLite value wins — the seed cannot regress it.
expect(getCounter(db, MINT, 'k1')).toBe(175)
db.close()
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}])
Database.setCounter('k1', 'sat', 175) // wallet advances during normal use
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}]) // stale re-run
expect(counterOf('k1')).toBe(175)
})
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 9999}])
expect(getCounter(db, MINT, 'k1')).toBe(9999)
db.close()
Database.setCounter('k1', 'sat', 100)
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 9999}])
expect(counterOf('k1')).toBe(9999)
})
test('an empty seed is a no-op', () => {
expect(Database.seedCounters([])).toEqual({seeded: 0})
})
})
describe('atomic commit (counterUpdate folded into commitReservation)', () => {
const openReservation = (id: string, transactionId: number) =>
Database.openReservation(
{id, transactionId, mintId: 'mint1', mintUrl: MINT, unit: 'sat', operationType: 'send', lockedProofs: []},
[],
)
test('persists the counter in the SAME txn as the new proofs', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
Database.setCounter('k1', 'sat', 100)
openReservation('res-1', 1)
commitWithCounter(db, 'res-1', {
newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}],
counterUpdate: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 110}],
Database.commitReservation('res-1', {
newProofs: [
{
proofs: [{id: 'keyset1', amount: 50, secret: 'new1', C: 'C'} as any],
state: 'UNSPENT',
mintUrl: MINT,
unit: 'sat',
tId: 1,
},
],
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
})
expect(getProofState(db, 'new1')).toBe('UNSPENT')
expect(getCounter(db, MINT, 'k1')).toBe(110)
db.close()
})
test('a failed commit batch rolls back BOTH the proofs and the counter', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
// Force a failure mid-batch (NOT NULL violation on amount) AFTER the
// proof insert and counter upsert have run in the same transaction.
expect(() => {
db.exec('BEGIN')
try {
db.prepare(
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`,
).run()
setCounter(db, MINT, 'k1', 'sat', 110)
// Violates NOT NULL on amount → aborts the whole batch.
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`,
).run()
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}).toThrow()
// Neither the proof nor the counter advance survived.
expect(getProofState(db, 'new1')).toBe('')
expect(getCounter(db, MINT, 'k1')).toBe(100)
db.close()
expect(proofStateOf('new1')).toBe('UNSPENT')
expect(counterOf('k1')).toBe(110)
// Committing also clears the reservation row.
expect(Database.getOpenReservations()).toHaveLength(0)
})
test('counterUpdate stays monotonic inside the commit batch', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 200)
Database.setCounter('k1', 'sat', 200)
openReservation('res-2', 2)
// A commit carrying a stale (lower) counter must not regress it.
commitWithCounter(db, 'res-2', {
newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}],
counterUpdate: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 150}],
Database.commitReservation('res-2', {
newProofs: [
{
proofs: [{id: 'keyset1', amount: 10, secret: 'new2', C: 'C'} as any],
state: 'UNSPENT',
mintUrl: MINT,
unit: 'sat',
tId: 2,
},
],
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}],
})
expect(getProofState(db, 'new2')).toBe('UNSPENT')
expect(getCounter(db, MINT, 'k1')).toBe(200)
db.close()
expect(proofStateOf('new2')).toBe('UNSPENT')
expect(counterOf('k1')).toBe(200)
})
test('a failing commit rolls back BOTH the proofs and the counter', () => {
Database.setCounter('k1', 'sat', 100)
openReservation('res-3', 3)
// A non-finite amount is rejected by connection.ts's param sanitizing, mid
// batch — after the counter upsert has already run inside the transaction.
expect(() =>
Database.commitReservation('res-3', {
newProofs: [
{
proofs: [{id: 'keyset1', amount: Number.NaN, secret: 'bad', C: 'C'} as any],
state: 'UNSPENT',
mintUrl: MINT,
unit: 'sat',
tId: 3,
},
],
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
}),
).toThrow()
// All-or-nothing: no proof, no counter advance, and the reservation stands.
expect(proofStateOf('bad')).toBeUndefined()
expect(counterOf('k1')).toBe(100)
expect(Database.getOpenReservations()).toHaveLength(1)
})
})
})

View File

@@ -0,0 +1,47 @@
/**
* Invariants of the migration registry itself (services/db/migrations.ts).
*
* These guard the wiring rather than any one migration's SQL. The failure they
* exist for is silent and asymmetric: a migration whose version is not below
* `_dbVersion` never runs (the runner applies only `currentVersion <
* migration.version`, then stores `_dbVersion`), so upgrading devices end up on a
* schema the code does not have and fail later with a missing column — while a
* FRESH install is perfectly fine, because it builds from schema.ts and skips
* migrations entirely. That asymmetry is what makes it easy to ship.
*
* migrations.ts imports only a type from ./connection (elided at runtime), so it
* loads without the native op-sqlite module.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {_dbVersion, MIGRATIONS} from '../src/services/db/migrations'
describe('migration registry', () => {
test('_dbVersion equals the newest migration version', () => {
const newest = Math.max(...MIGRATIONS.map(m => m.version))
expect(_dbVersion).toBe(newest)
})
test('every migration version is unique', () => {
const versions = MIGRATIONS.map(m => m.version)
expect(new Set(versions).size).toBe(versions.length)
})
test('migrations are in ascending order', () => {
// The runner concatenates matching migrations in array order and runs them as
// one batch, so the array order IS the execution order — a later-versioned
// entry placed earlier would apply out of sequence.
const versions = MIGRATIONS.map(m => m.version)
expect(versions).toEqual([...versions].sort((a, b) => a - b))
})
test('no migration is empty', () => {
for (const m of MIGRATIONS) {
expect(m.queries.length).toBeGreaterThan(0)
}
})
})

View File

@@ -0,0 +1,286 @@
/**
* The UPGRADE path: an existing database at an old version, driven through the real
* instance.ts (createSchemaQueries + the real migration registry).
*
* This is the path every user's device takes and the one nothing covered. Every
* other 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, and the
* interaction between createSchemaQueries and the registry is never exercised.
*
* That gap shipped a real bug. createSchemaQueries runs `CREATE TABLE IF NOT EXISTS`
* for the CURRENT shape of every table, on every launch. On a device old enough not
* to have a table yet, it therefore created it at TODAY's shape — and the migration
* that later adds a column to that table then failed with "duplicate column name",
* rolling back the whole batch and leaving the database unmigrated. A wallet at v29
* showed a zero balance.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
// ── A device at db v26 ───────────────────────────────────────────────────────
//
// v26 is the baseline that matters most: it is what the last released NATIVE bundle
// (tag v0.4.3-beta.3, `_dbVersion = 26`, `rootStoreModelVersion = 32`) creates, so
// even a BRAND NEW install today starts here and then OTA-updates the JS to the
// current migrations. It is the common path, not an old edge case.
//
// VERIFIED against that tag rather than reconstructed: its createSchemaQueries
// builds exactly these four tables, and its TRANSACTIONS_COLUMNS (25),
// PROOFS_COLUMNS (12) and RESERVATIONS_COLUMNS (7) match the shapes below column for
// column. mint_counters (v27), melt_recovery (v28), inflight_requests (v29),
// wallet_counters (v30), onchain_mint_quotes (v31) and mints/mint_keysets (v35) do
// not exist there yet.
//
// Frozen deliberately: this is history, and must NOT be re-pointed at schema.ts. A
// fixture that tracks today's shape describes a device that never existed and stops
// testing the upgrade at all.
const SCHEMA_V26 = [
`CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL, paymentId TEXT, type TEXT, amount INTEGER, unit TEXT,
fee INTEGER, data TEXT, keysetId TEXT, sentFrom TEXT, sentTo TEXT, profile TEXT,
memo TEXT, mint TEXT, quote TEXT, paymentRequest TEXT, zapRequest TEXT,
inputToken TEXT, outputToken TEXT, proof TEXT, balanceAfter INTEGER, noteToSelf TEXT,
tags TEXT, status TEXT, expiresAt TEXT, createdAt TEXT
)`,
`CREATE TABLE proofs (
id TEXT NOT NULL, amount INTEGER NOT NULL, secret TEXT PRIMARY KEY NOT NULL, C TEXT NOT NULL,
dleq_r TEXT, dleq_s TEXT, dleq_e TEXT, unit TEXT, tId INTEGER, mintUrl TEXT,
state TEXT NOT NULL DEFAULT 'UNSPENT', updatedAt TEXT
)`,
`CREATE TABLE dbversion (id INTEGER PRIMARY KEY NOT NULL, version INTEGER, createdAt TEXT)`,
`CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL, transactionId INTEGER NOT NULL, mintUrl TEXT NOT NULL,
unit TEXT NOT NULL, operationType TEXT NOT NULL, lockedProofs TEXT NOT NULL, createdAt TEXT NOT NULL
)`,
]
const KEYSET = '009a1f293253e41e'
const MINT_URL = 'https://mint.test'
const seedV26 = (db: any) => {
for (const sql of SCHEMA_V26) db.exec(sql)
db.prepare(`INSERT INTO dbversion (id, version, createdAt) VALUES (1, 26, '2026-01-01')`).run()
// Real user data, so the assertions below mean something.
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, unit, tId, mintUrl, state, updatedAt)
VALUES (?, 100, 's1', 'C', 'sat', 1, ?, 'UNSPENT', '2026-01-01')`,
).run(KEYSET, MINT_URL)
db.prepare(
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
VALUES (1, 'TOPUP', 100, 'sat', '{}', ?, 'COMPLETED', '2026-01-01')`,
).run(MINT_URL)
}
/**
* A device at version N, built honestly: the v26 schema, then the REAL migrations
* replayed up to N. Stamping a different version onto the v26 fixture would describe
* a device that never existed — one claiming v31 without the table v31 creates — and
* would fail for that reason rather than a real one.
*/
const seedAtVersion = (startVersion: number) => (db: any) => {
seedV26(db)
const {MIGRATIONS} = require('../src/services/db/migrations')
for (const migration of MIGRATIONS) {
if (migration.version > 26 && migration.version <= startVersion) {
for (const [sql] of migration.queries) db.exec(sql)
}
}
db.prepare('UPDATE dbversion SET version = ?').run(startVersion)
// A derivation counter, once the table it lives in exists. Losing this is the one
// failure that re-issues blinded secrets the mint has already signed.
if (startVersion >= 27) {
const hasMintUrl = (db.prepare(`PRAGMA table_info(mint_counters)`).all() as any[]).some(
c => c.name === 'mintUrl',
)
if (hasMintUrl) {
db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, 'sat', 342, '2026-01-01')`,
).run(MINT_URL, KEYSET)
} else {
db.prepare(
`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, 'sat', 342, '2026-01-01')`,
).run(KEYSET)
}
}
}
/** Open a database at `startVersion` and let the REAL instance.ts upgrade it. */
const upgradeFrom = (startVersion: number) => {
jest.resetModules()
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('@op-engineering/op-sqlite').__seedNextDatabase(seedAtVersion(startVersion))
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {Database} = require('../src/services/db')
const {_dbVersion} = require('../src/services/db/migrations')
return {Database, _dbVersion, db: Database.getInstance()}
}
/** The baseline the last released native bundle ships. */
const upgradeFromV26 = () => upgradeFrom(26)
const columns = (db: any, table: string): string[] =>
(db.execute(`PRAGMA table_info(${table})`).rows?._array ?? []).map((r: any) => r.name)
describe('upgrading an existing database (the device path)', () => {
test('a v26 wallet — the last released native bundle — migrates to the current version', () => {
// The reported failure: this threw "duplicate column name: mintId", the batch
// rolled back, and the wallet came up with nothing.
const {Database, _dbVersion, db} = upgradeFromV26()
expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion)
})
test('the money survives', () => {
const {db} = upgradeFromV26()
const proof = db.execute(`SELECT * FROM proofs WHERE secret = 's1'`).rows?.item(0)
expect(proof.amount).toBe(100)
expect(proof.mintUrl).toBe(MINT_URL)
})
test('a derivation counter survives the v32 re-key', () => {
// From v27, the first version that HAS mint_counters. A v26 wallet has no such
// table at all — its counters are still in the MMKV snapshot, and the rootStore
// v33 seed is what copies them in.
//
// This is the one value whose loss re-issues blinded secrets the mint has
// already signed, and v32 re-keys the very table it lives in, from
// (mintUrl, keysetId) onto keysetId.
const {Database} = upgradeFrom(27)
expect(Database.getCounter(KEYSET)?.counter).toBe(342)
})
test('every column the later migrations add is present', () => {
const {db} = upgradeFromV26()
// v33 / v34: the mint-identity columns.
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
expect(columns(db, 'reservations')).toContain('mintId')
expect(columns(db, 'transactions')).toContain('mintId')
// v34 also rebuilt the child tables WITHOUT their duplicated mint reference.
expect(columns(db, 'inflight_requests')).not.toContain('mintUrl')
expect(columns(db, 'melt_recovery')).not.toContain('mintUrl')
})
test('tables introduced after v29 exist, at the current shape', () => {
const {db} = upgradeFromV26()
expect(columns(db, 'wallet_counters')).toContain('counter') // v30
expect(columns(db, 'onchain_mint_quotes')).toContain('counterIndex') // v31
expect(columns(db, 'mints')).toContain('mintUrl') // v35
expect(columns(db, 'mint_keysets')).toContain('keysetId') // v35
})
test('mint_counters is re-keyed onto keysetId alone', () => {
const {db} = upgradeFromV26()
expect(columns(db, 'mint_counters')).toEqual(
expect.arrayContaining(['keysetId', 'unit', 'counter', 'updatedAt']),
)
expect(columns(db, 'mint_counters')).not.toContain('mintUrl')
})
test('the repos work against the upgraded database', () => {
// The real point: not just that the DDL landed, but that the app can use it.
const {Database} = upgradeFromV26()
Database.setCounter('00ad268c4d1f5826', 'sat', 7)
expect(Database.getCounter('00ad268c4d1f5826')?.counter).toBe(7)
expect(Database.getMints()).toEqual([])
expect(Database.getWatchedOnchainMintQuotes()).toEqual([])
})
test('re-running the upgrade on an already-current database is a no-op', () => {
// Every subsequent launch takes this path.
jest.resetModules()
const {Database} = require('../src/services/db')
const {_dbVersion} = require('../src/services/db/migrations')
const db = Database.getInstance()
expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion)
// A second call must not re-run anything.
expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(_dbVersion)
})
// The bug's real shape: it bites at any version predating a table that a LATER
// migration alters. v29 is simply where it was reported. Rather than trust that
// one case, walk every version the registry knows about.
describe('every supported starting version reaches the current schema', () => {
test.each([26, 27, 28, 29, 30, 31, 32, 33, 34])('from v%i', startVersion => {
jest.resetModules()
require('@op-engineering/op-sqlite').__seedNextDatabase(seedAtVersion(startVersion))
const {Database} = require('../src/services/db')
const {_dbVersion} = require('../src/services/db/migrations')
const db = Database.getInstance()
expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion)
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
expect(columns(db, 'transactions')).toContain('mintId')
// The money, at every starting point.
expect(db.execute(`SELECT amount FROM proofs WHERE secret = 's1'`).rows?.item(0)?.amount).toBe(100)
})
})
})
describe('cleanAll — the factory reset', () => {
test('drops EVERY table, not a hand-listed subset', () => {
jest.resetModules()
const {Database} = require('../src/services/db')
Database.getInstance() // builds the current schema
Database.cleanAll()
const db = Database.getInstance()
const remaining = (
db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.rows?._array ?? []
).map((r: any) => r.name)
// The old list named seven tables while the schema had eleven, so a factory
// reset left the user's mints and their onchain deposit addresses behind.
expect(remaining).toEqual([])
})
test('also clears tables no list would know about', () => {
// The failure mode that broke a test device: a leftover table from a bad build
// outlived the reset, and the create-migration that should have rebuilt it
// skipped it (IF NOT EXISTS) — so the ALTER after it hit "duplicate column".
jest.resetModules()
require('@op-engineering/op-sqlite').__seedNextDatabase((db: any) => {
db.exec(`CREATE TABLE some_leftover_table (id TEXT)`)
})
const {Database} = require('../src/services/db')
Database.getInstance()
Database.cleanAll()
const db = Database.getInstance()
const remaining = (
db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.rows?._array ?? []
).map((r: any) => r.name)
expect(remaining).toEqual([])
})
test('is safe on an already-empty database', () => {
jest.resetModules()
const {Database} = require('../src/services/db')
Database.getInstance()
Database.cleanAll()
expect(() => Database.cleanAll()).not.toThrow()
})
})

View File

@@ -1,142 +1,136 @@
/**
* In-flight request tests (inFlightRequests → SQLite migration).
* In-flight requests (inflight_requests), against the REAL repo and a real database.
*
* Per-transaction request params stored so an op whose mint response was lost
* can be retried against the mint's idempotent endpoint. add() overwrites
* (set semantics), the per-mint query drives the recovery sweep, the row is
* deleted on success/terminal failure, and the upgrade seed is idempotent.
* Per-transaction request params for an operation that has reached the mint but
* whose response may be lost. Written before the network call so the op can be
* retried against the mint's idempotent (NUT-19) endpoint.
*
* Mirrors the production SQL against node:sqlite (the native driver needs a
* device), like meltRecovery.test.ts.
* The table is a CHILD of the transaction — its primary key IS transactionId — so
* it carries no mint reference. It once duplicated mintUrl and keysetId; keysetId
* had no reader at all, and mintUrl served exactly one query ("every request of
* this mint"), which now JOINs through the parent's mintId. One owner of the fact,
* so nothing here can go stale when a mint moves.
*
* @jest-environment node
* Calls the production `Database.*` functions rather than mirroring their SQL — the
* op-sqlite jest mock backs the real driver seam with node:sqlite. That matters most
* for the join: a hand-copied version proves nothing about the query the app runs.
*/
import {DatabaseSync} from 'node:sqlite'
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
const NOW = '2026-06-05T00:00:00.000Z'
const CREATE_INFLIGHT = `CREATE TABLE inflight_requests (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
)`
import {Database} from '../src/services/db'
const MINT = 'https://mint.test'
const MINT_ID = 'mint1111'
const OTHER_MINT_ID = 'mint9999'
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
/** The parent row the join reaches through. */
const addTransaction = (id: number, mintId: string | null) =>
Database.getInstance().execute(
`INSERT OR REPLACE INTO transactions (id, type, amount, unit, data, mint, mintId, status, createdAt)
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, 'PENDING', '2026-01-01')`,
[id, MINT, mintId],
)
function addInFlightRequest(
db: DatabaseSync,
transactionId: number,
mintUrl: string | null,
keysetId: string | null,
request: object,
) {
db.prepare(
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
}
function getInFlightRequest(db: DatabaseSync, transactionId: number) {
const row = db
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
.get(transactionId) as {transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null} | undefined
if (!row) return undefined
return {...row, request: JSON.parse(row.request)}
}
function getInFlightRequestsByMint(db: DatabaseSync, mintUrl: string) {
const rows = db
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`)
.all(mintUrl) as Array<{transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null}>
return rows.map(r => ({...r, request: JSON.parse(r.request)}))
}
function removeInFlightRequest(db: DatabaseSync, transactionId: number) {
db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId)
}
function seedInFlightRequest(
db: DatabaseSync,
transactionId: number,
mintUrl: string | null,
keysetId: string | null,
request: object,
) {
db.prepare(
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_INFLIGHT)
return db
}
// ── Tests ───────────────────────────────────────────────────────────────────
beforeEach(() => {
Database.getInstance().executeBatch([['DELETE FROM inflight_requests'], ['DELETE FROM transactions']])
})
describe('In-flight requests (inflight_requests)', () => {
test('stores and reads back a request (JSON round-trip)', () => {
const db = freshDb()
const request = {token: 'cashuA...', options: {keysetId: 'k1'}}
addTransaction(101, MINT_ID)
Database.addInFlightRequest(101, request)
addInFlightRequest(db, 101, MINT, 'k1', request)
const rec = getInFlightRequest(db, 101)!
const rec = Database.getInFlightRequest(101)!
expect(rec.transactionId).toBe(101)
expect(rec.mintUrl).toBe(MINT)
expect(rec.keysetId).toBe('k1')
expect(rec.request).toEqual(request)
db.close()
})
test('returns undefined when no entry exists', () => {
const db = freshDb()
expect(getInFlightRequest(db, 999)).toBeUndefined()
db.close()
expect(Database.getInFlightRequest(999)).toBeUndefined()
})
test('add OVERWRITES an existing entry (set semantics)', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 'first'})
addInFlightRequest(db, 101, MINT, 'k1', {v: 'second'})
addTransaction(101, MINT_ID)
Database.addInFlightRequest(101, {v: 'first'})
Database.addInFlightRequest(101, {v: 'second'})
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'})
db.close()
})
test('getInFlightRequestsByMint returns all rows for a mint', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
addInFlightRequest(db, 102, MINT, 'k1', {v: 2})
addInFlightRequest(db, 103, 'https://other.test', 'k9', {v: 3})
const forMint = getInFlightRequestsByMint(db, MINT)
expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102])
expect(getInFlightRequestsByMint(db, 'https://other.test')).toHaveLength(1)
db.close()
expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'second'})
})
test('remove deletes the entry', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
removeInFlightRequest(db, 101)
expect(getInFlightRequest(db, 101)).toBeUndefined()
expect(getInFlightRequestsByMint(db, MINT)).toHaveLength(0)
db.close()
addTransaction(101, MINT_ID)
Database.addInFlightRequest(101, {v: 1})
Database.removeInFlightRequest(101)
expect(Database.getInFlightRequest(101)).toBeUndefined()
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
})
test('seed is idempotent — does not overwrite an existing entry', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 'live'})
seedInFlightRequest(db, 101, MINT, 'k1', {v: 'snapshot'})
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'})
db.close()
describe('getInFlightRequestsByMintId — the per-mint recovery sweep', () => {
test('returns all rows of a mint, and only that mint', () => {
addTransaction(101, MINT_ID)
addTransaction(102, MINT_ID)
addTransaction(103, OTHER_MINT_ID)
Database.addInFlightRequest(101, {v: 1})
Database.addInFlightRequest(102, {v: 2})
Database.addInFlightRequest(103, {v: 3})
expect(
Database.getInFlightRequestsByMintId(MINT_ID)
.map(r => r.transactionId)
.sort(),
).toEqual([101, 102])
expect(Database.getInFlightRequestsByMintId(OTHER_MINT_ID)).toHaveLength(1)
})
// The point of joining on mintId rather than a url copy: a mint moving must
// never hide its own in-flight work. transactions.mint stays frozen as history;
// only the mint's live url moves, and the id is unaffected.
test("still finds a mint's requests regardless of the url on the transaction", () => {
addTransaction(101, MINT_ID)
Database.addInFlightRequest(101, {v: 1})
Database.getInstance().execute('UPDATE transactions SET mint = ? WHERE id = ?', [
'https://some-other.url',
101,
])
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(1)
})
// Correct, not a regression: the retry settles proofs onto its transaction and
// the handler branches on tx.type, so without the parent there is nothing to
// apply the result to.
test('a request whose transaction is gone is not returned', () => {
addTransaction(101, MINT_ID)
Database.addInFlightRequest(101, {v: 1})
Database.getInstance().execute('DELETE FROM transactions WHERE id = ?', [101])
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
})
test('a transaction with no mintId is not returned', () => {
addTransaction(101, null)
Database.addInFlightRequest(101, {v: 1})
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
})
})
describe('seedInFlightRequests — one-time MST/MMKV copy', () => {
test('is idempotent — does not overwrite an existing entry', () => {
addTransaction(101, MINT_ID)
Database.addInFlightRequest(101, {v: 'live'})
Database.seedInFlightRequests([{transactionId: 101, request: {v: 'snapshot'}}])
expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'live'})
})
test('an empty seed is a no-op', () => {
expect(Database.seedInFlightRequests([])).toEqual({seeded: 0})
})
})
})

View File

@@ -0,0 +1,141 @@
/**
* Keyset-id collision tests (CashuUtils.isCollidingKeysetId).
*
* NUT-02 requires that a wallet "reject any attempt at importing new keysets
* which IDs collide with any of the previously added keysets". This wallet
* enforces that WALLET-WIDE (every keyset of every mint), which is what makes a
* keyset id a sound primary key for a NUT-13 derivation counter — see
* MINT_COUNTERS_COLUMNS.
*
* Two distinct collision classes are covered here:
*
* - Exact id equality: always a collision. One id means one derivation
* sequence, so two mints sharing an id would share one counter.
*
* - keysetIdInt equality: a collision ONLY between ids that both derive via the
* deprecated BIP-32 path, where the id is reduced mod 2^31-1 to fit a
* hardened index and two distinct ids that are congruent therefore land on
* the SAME derivation path. NUT-02 v2 (`01`) ids derive by HMAC-SHA256 over
* the full id and never compute that integer, so the reduction cannot alias
* them and applying the check to them would reject a legitimate mint.
*/
jest.mock('../src/services/logService', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
}))
jest.mock('../src/services/nostrService', () => ({
NostrClient: {
getFirstTagValue: jest.fn(),
},
}))
import {CashuUtils} from '../src/services/cashu/cashuUtils'
const {isCollidingKeysetId} = CashuUtils
const MOD = BigInt(2 ** 31 - 1)
/** Build a v1 (`00`-prefixed, 8-byte) keyset id from its 7 data bytes. */
const v1 = (dataHex: string) => `00${dataHex.padStart(14, '0')}`
/** Build a v2 (`01`-prefixed, 33-byte) keyset id from its 32 data bytes. */
const v2 = (dataHex: string) => `01${dataHex.padStart(64, '0')}`
const keysetIdInt = (id: string) => BigInt(`0x${id}`) % MOD
describe('isCollidingKeysetId', () => {
describe('exact id equality — always a collision', () => {
test('detects a v1 id already held', () => {
const id = v1('a1b2c3d4e5f601')
expect(isCollidingKeysetId(id, [v1('0000000000ff01'), id])).toBe(true)
})
test('detects a v2 id already held', () => {
const id = v2('deadbeef')
expect(isCollidingKeysetId(id, [v2('feedface'), id])).toBe(true)
})
test('passes an id the wallet does not hold', () => {
expect(isCollidingKeysetId(v1('a1b2c3d4e5f601'), [v1('0000000000ff01')])).toBe(false)
})
test('passes against an empty wallet', () => {
expect(isCollidingKeysetId(v2('deadbeef'), [])).toBe(false)
})
})
describe('keysetIdInt aliasing — v1 (deprecated BIP-32 derivation)', () => {
// Two DISTINCT v1 ids that are congruent mod 2^31-1 derive at the same
// `m/129372'/0'/{int}'/...` path, so the second must be rejected even though
// the ids differ.
test('rejects two distinct v1 ids that reduce to the same derivation index', () => {
const stored = v1('00000000000001')
const colliding = (BigInt(`0x${stored}`) + MOD).toString(16).padStart(16, '0')
expect(colliding).not.toBe(stored)
expect(keysetIdInt(colliding)).toBe(keysetIdInt(stored))
expect(isCollidingKeysetId(colliding, [stored])).toBe(true)
})
test('passes two v1 ids that reduce to different indices', () => {
expect(isCollidingKeysetId(v1('00000000000002'), [v1('00000000000001')])).toBe(false)
})
})
describe('keysetIdInt aliasing — v2 (HMAC derivation) must NOT be int-checked', () => {
// The regression this guards: applying the mod-2^31-1 check to v2 ids
// rejects a legitimate mint over an integer nothing consumes, and re-imposes
// v1's ~2^31 birthday bound on ids whose whole point is full-width SHA-256
// collision resistance.
test('accepts two distinct v2 ids that happen to be congruent mod 2^31-1', () => {
const stored = v2('01')
const congruent = (BigInt(`0x${stored}`) + MOD).toString(16).padStart(66, '0')
expect(congruent).not.toBe(stored)
expect(congruent.startsWith('01')).toBe(true)
expect(keysetIdInt(congruent)).toBe(keysetIdInt(stored)) // the alias exists...
expect(isCollidingKeysetId(congruent, [stored])).toBe(false) // ...and is correctly ignored
})
test('a v2 id congruent with a stored v1 id is not a collision (different derivation paths)', () => {
const storedV1 = v1('00000000000001')
const target = keysetIdInt(storedV1)
// Craft a v2 id reducing to the same int as the v1 id above.
const base = BigInt(`0x${v2('00')}`)
const delta = (target - (base % MOD) + MOD) % MOD
const congruentV2 = (base + delta).toString(16).padStart(66, '0')
expect(congruentV2.startsWith('01')).toBe(true)
expect(keysetIdInt(congruentV2)).toBe(target)
// A v2 id never derives at m/129372'/0'/{int}'/…, so it cannot share a
// path with a v1 id no matter what the ints do.
expect(isCollidingKeysetId(congruentV2, [storedV1])).toBe(false)
})
test('exact equality still wins for v2 — int gating does not disable the real check', () => {
const id = v2('cafebabe')
expect(isCollidingKeysetId(id, [id])).toBe(true)
})
})
describe('mixed wallets', () => {
test('checks every stored id, not just the first', () => {
const target = v2('deadbeef')
expect(isCollidingKeysetId(target, [v1('00000000000001'), v2('feedface'), target])).toBe(true)
})
test('a v1 int alias is still caught when the wallet also holds v2 ids', () => {
const storedV1 = v1('00000000000001')
const colliding = (BigInt(`0x${storedV1}`) + MOD).toString(16).padStart(16, '0')
expect(isCollidingKeysetId(colliding, [v2('feedface'), storedV1])).toBe(true)
})
})
})

View File

@@ -1,33 +1,29 @@
/**
* Melt recovery tests (meltCounterValues → SQLite migration).
* Melt recovery (melt_recovery), against the REAL repo and a real database.
*
* Verifies the SQL-level semantics of meltRecoveryRepo: a per-transaction
* serialized meltPreview is stored before a melt is submitted so a paid-but-
* unconfirmed melt can be recovered and its change unblinded. The first stored
* preview for a transaction wins (idempotent), and the row is removed on
* terminal success/failure.
* A per-transaction serialized meltPreview is stored BEFORE a melt is submitted, so
* a paid-but-unconfirmed melt can be recovered and its change ecash unblinded. The
* first stored preview for a transaction wins (idempotent), and the row is removed
* on terminal success/failure.
*
* Mirrors the production SQL against node:sqlite, like proofReservation.test.ts
* and counters.test.ts (the native driver needs a device).
* The table is a CHILD of the transaction — its primary key IS transactionId — so
* it holds no mint reference: the parent owns that fact, and every reader arrives
* here already holding the transaction. It once duplicated mintUrl and keysetId;
* neither had a single reader (the keyset that IS used lives inside meltPreview).
*
* @jest-environment node
* Calls the production `Database.*` functions rather than mirroring their SQL: the
* op-sqlite jest mock backs the real driver seam with node:sqlite, so connection.ts,
* instance.ts and the repo all run for real.
*/
import {DatabaseSync} from 'node:sqlite'
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
const NOW = '2026-06-05T00:00:00.000Z'
import {Database} from '../src/services/db'
const CREATE_MELT_RECOVERY = `CREATE TABLE melt_recovery (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
)`
const MINT = 'https://mint.test'
// A representative StoredMeltPreview (shape from cashuUtils).
const previewFor = (keysetId: string, secret = 'aa') => ({
/** A representative StoredMeltPreview (shape from cashuUtils). */
const previewFor = (keysetId: string, secret = 'aa') =>
({
keysetId,
outputData: [
{
@@ -36,117 +32,77 @@ const previewFor = (keysetId: string, secret = 'aa') => ({
secret,
},
],
}) as any
const rowCount = () =>
Database.getInstance().execute('SELECT COUNT(*) AS n FROM melt_recovery').rows?.item(0)?.n
beforeEach(() => {
Database.getInstance().executeBatch([['DELETE FROM melt_recovery']])
})
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
function addMeltRecovery(
db: DatabaseSync,
transactionId: number,
mintUrl: string | null,
keysetId: string | null,
meltPreview: object,
) {
db.prepare(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(meltPreview), NOW)
}
function getMeltRecovery(db: DatabaseSync, transactionId: number) {
const row = db
.prepare(`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`)
.get(transactionId) as
| {transactionId: number; mintUrl: string | null; keysetId: string | null; meltPreview: string; createdAt: string | null}
| undefined
if (!row) return undefined
return {...row, meltPreview: JSON.parse(row.meltPreview)}
}
function removeMeltRecovery(db: DatabaseSync, transactionId: number) {
db.prepare(`DELETE FROM melt_recovery WHERE transactionId = ?`).run(transactionId)
}
function rowCount(db: DatabaseSync): number {
const {n} = db.prepare('SELECT COUNT(*) AS n FROM melt_recovery').get() as {n: number}
return n
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_MELT_RECOVERY)
return db
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('Melt recovery (melt_recovery)', () => {
test('stores and reads back a meltPreview (JSON round-trip)', () => {
const db = freshDb()
const preview = previewFor('k1')
Database.addMeltRecovery(101, preview)
addMeltRecovery(db, 101, MINT, 'k1', preview)
const rec = getMeltRecovery(db, 101)!
const rec = Database.getMeltRecovery(101)!
expect(rec.transactionId).toBe(101)
expect(rec.mintUrl).toBe(MINT)
expect(rec.keysetId).toBe('k1')
expect(rec.meltPreview).toEqual(preview)
db.close()
// The keyset lives INSIDE the preview — the row never duplicated it.
expect(rec.meltPreview.keysetId).toBe('k1')
})
test('returns undefined when no entry exists', () => {
const db = freshDb()
expect(getMeltRecovery(db, 999)).toBeUndefined()
db.close()
expect(Database.getMeltRecovery(999)).toBeUndefined()
})
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'first'))
// A second attempt for the same tx must not overwrite.
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'second'))
Database.addMeltRecovery(101, previewFor('k1', 'first'))
// A second attempt for the same tx must not overwrite: the preview describes
// outputs the mint may already have signed.
Database.addMeltRecovery(101, previewFor('k1', 'second'))
const rec = getMeltRecovery(db, 101)!
expect(rec.meltPreview.outputData[0].secret).toBe('first')
expect(rowCount(db)).toBe(1)
db.close()
expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('first')
expect(rowCount()).toBe(1)
})
test('remove deletes the entry (terminal success/failure)', () => {
const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
expect(rowCount(db)).toBe(1)
Database.addMeltRecovery(101, previewFor('k1'))
expect(rowCount()).toBe(1)
removeMeltRecovery(db, 101)
expect(getMeltRecovery(db, 101)).toBeUndefined()
expect(rowCount(db)).toBe(0)
db.close()
Database.removeMeltRecovery(101)
expect(Database.getMeltRecovery(101)).toBeUndefined()
expect(rowCount()).toBe(0)
})
test('entries for different transactions are independent', () => {
const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
addMeltRecovery(db, 102, MINT, 'k2', previewFor('k2'))
Database.addMeltRecovery(101, previewFor('k1'))
Database.addMeltRecovery(102, previewFor('k2'))
expect(getMeltRecovery(db, 101)!.keysetId).toBe('k1')
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2')
expect(Database.getMeltRecovery(101)!.meltPreview.keysetId).toBe('k1')
expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2')
removeMeltRecovery(db, 101)
expect(getMeltRecovery(db, 101)).toBeUndefined()
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2') // unaffected
db.close()
Database.removeMeltRecovery(101)
expect(Database.getMeltRecovery(101)).toBeUndefined()
expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2') // unaffected
})
test('seed is idempotent — does not overwrite an existing entry', () => {
const db = freshDb()
// Live entry already advanced/stored.
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'live'))
// Upgrade seed re-runs with the snapshot copy.
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'snapshot'))
describe('seedMeltRecoveries — one-time MST/MMKV copy', () => {
test('is idempotent — does not overwrite an existing entry', () => {
Database.addMeltRecovery(101, previewFor('k1', 'live'))
Database.seedMeltRecoveries([{transactionId: 101, meltPreview: previewFor('k1', 'snapshot')}])
expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live')
db.close()
expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('live')
})
test('carries over an entry that does not exist yet', () => {
Database.seedMeltRecoveries([{transactionId: 202, meltPreview: previewFor('k9', 'seeded')}])
expect(Database.getMeltRecovery(202)!.meltPreview.outputData[0].secret).toBe('seeded')
})
test('an empty seed is a no-op', () => {
expect(Database.seedMeltRecoveries([])).toEqual({seeded: 0})
})
})
})

View File

@@ -0,0 +1,207 @@
/**
* Mint capability views (Mint.ts).
*
* The wallet decides which payment options to offer from the mint's cached NUT-06
* info. Getting this wrong is user-visible in both directions: offer a method the
* mint does not have and the operation fails at quote time; hide one it does have
* and the user simply cannot use their money.
*
* Two rules carry most of the weight:
*
* - `onchain` (NUT-30) additionally requires NUT-20, because the mint MUST refuse
* an onchain mint quote that carries no pubkey (error 20009).
* - When capabilities are UNKNOWN (info never cached), bolt11 is assumed and
* anything else is not — assuming bolt11 preserves the wallet's behaviour to
* date and cannot strand a user with an empty menu.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
LogLevel: {ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', TRACE: 'TRACE'},
}))
jest.mock('../src/services', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
Database: {},
}))
// Mint.ts pulls in ../theme and ../services/wallet/currency, and currency imports
// the components barrel -> react-native-reanimated (ESM, not transformed here).
// Neither is needed to exercise the capability views, which read only mintInfo.
jest.mock('../src/theme', () => ({
colors: {palette: {iconBlue200: '#4dabf7'}},
getRandomIconColor: () => '#4dabf7',
}))
jest.mock('../src/services/wallet/currency', () => ({
MintUnits: ['btc', 'sat', 'msat', 'usd', 'eur'],
}))
// utils.ts -> react-native-flash-message -> react-native-iphone-screen-helper (ESM).
jest.mock('../src/utils/utils', () => ({
generateId: () => 'testmint',
}))
// cashuUtils -> nostrService -> ESM deps. Not reached by the capability views.
jest.mock('../src/services/cashu/cashuUtils', () => ({
CashuUtils: {},
}))
import {MintModel} from '../src/models/Mint'
type MethodEntry = {
method: string
unit: string
min_amount?: number | null
max_amount?: number | null
options?: {confirmations?: number}
}
/** Build a mint whose cached info advertises the given methods. */
const mintWith = (opts: {
mintMethods?: MethodEntry[]
meltMethods?: MethodEntry[]
nut20?: boolean
}) =>
MintModel.create({
mintUrl: 'https://mint.test',
mintInfo: {
name: 'test',
pubkey: 'aa',
version: 'test/1',
contact: [],
nuts: {
'4': {methods: opts.mintMethods ?? [], disabled: false},
'5': {methods: opts.meltMethods ?? [], disabled: false},
...(opts.nut20 === undefined ? {} : {'20': {supported: opts.nut20}}),
},
time: Math.floor(Date.now() / 1000),
} as any,
})
/** A mint whose info was never cached (offline when added, or a very old entry). */
const mintWithUnknownInfo = () => MintModel.create({mintUrl: 'https://mint.test'})
// Shapes taken from the live CDK test mint.
const BOLT11_SAT: MethodEntry = {method: 'bolt11', unit: 'sat', min_amount: 1, max_amount: 500000}
const ONCHAIN_SAT: MethodEntry = {
method: 'onchain',
unit: 'sat',
min_amount: 10000,
max_amount: 500000,
options: {confirmations: 1},
}
describe('method settings lookup', () => {
it('returns the advertised setting for a (method, unit) pair', () => {
const mint = mintWith({mintMethods: [BOLT11_SAT, ONCHAIN_SAT]})
expect(mint.mintMethodSetting('onchain', 'sat')).toMatchObject({
method: 'onchain',
min_amount: 10000,
options: {confirmations: 1},
})
})
it('is undefined for a method the mint does not advertise', () => {
const mint = mintWith({mintMethods: [BOLT11_SAT]})
expect(mint.mintMethodSetting('onchain', 'sat')).toBeUndefined()
})
it('does not match a supported method in the wrong unit', () => {
const mint = mintWith({mintMethods: [ONCHAIN_SAT]})
expect(mint.mintMethodSetting('onchain', 'usd')).toBeUndefined()
})
it('keeps mint and melt lists separate', () => {
// a mint may take onchain deposits without paying onchain out
const mint = mintWith({
mintMethods: [BOLT11_SAT, ONCHAIN_SAT],
meltMethods: [BOLT11_SAT],
nut20: true,
})
expect(mint.supportsMint('onchain', 'sat')).toBe(true)
expect(mint.supportsMelt('onchain', 'sat')).toBe(false)
})
})
describe('supportsMint / supportsMelt', () => {
it('supports bolt11 when advertised', () => {
const mint = mintWith({mintMethods: [BOLT11_SAT], meltMethods: [BOLT11_SAT]})
expect(mint.supportsMint('bolt11', 'sat')).toBe(true)
expect(mint.supportsMelt('bolt11', 'sat')).toBe(true)
})
it('does NOT support onchain mint without NUT-20, even when advertised', () => {
// the mint would reject the quote with 20009 (pubkey required), so an
// onchain method with no NUT-20 is unusable to us
const mint = mintWith({mintMethods: [BOLT11_SAT, ONCHAIN_SAT], nut20: false})
expect(mint.supportsMint('onchain', 'sat')).toBe(false)
expect(mint.supportsMint('bolt11', 'sat')).toBe(true)
})
it('supports onchain mint when advertised alongside NUT-20', () => {
const mint = mintWith({mintMethods: [BOLT11_SAT, ONCHAIN_SAT], nut20: true})
expect(mint.supportsMint('onchain', 'sat')).toBe(true)
})
it('allows onchain melt without NUT-20 (it gates mint quotes only)', () => {
const mint = mintWith({meltMethods: [ONCHAIN_SAT], nut20: false})
expect(mint.supportsMelt('onchain', 'sat')).toBe(true)
})
it('handles a pure-onchain mint (no bolt11 at all)', () => {
const mint = mintWith({
mintMethods: [ONCHAIN_SAT],
meltMethods: [ONCHAIN_SAT],
nut20: true,
})
expect(mint.supportsMint('bolt11', 'sat')).toBe(false)
expect(mint.supportsMelt('bolt11', 'sat')).toBe(false)
expect(mint.supportsMint('onchain', 'sat')).toBe(true)
expect(mint.supportsMelt('onchain', 'sat')).toBe(true)
})
})
describe('unknown capabilities (info never cached)', () => {
it('assumes bolt11, so an upgrading user never loses their Lightning options', () => {
const mint = mintWithUnknownInfo()
expect(mint.hasUnknownCapabilities).toBe(true)
expect(mint.supportsMint('bolt11', 'sat')).toBe(true)
expect(mint.supportsMelt('bolt11', 'sat')).toBe(true)
})
it('hides onchain until the mint positively advertises it', () => {
const mint = mintWithUnknownInfo()
expect(mint.supportsMint('onchain', 'sat')).toBe(false)
expect(mint.supportsMelt('onchain', 'sat')).toBe(false)
})
})
describe('setMintInfo stamps the fetch time', () => {
it('sets `time` so the staleness check can actually fire', () => {
// Callers pass a raw GetInfoResponse (which has no `time`). If the stamp
// were left to them, `now - undefined` is NaN, NaN > ttl is false, and the
// TTL would never fire — info would be cached forever.
const mint = mintWithUnknownInfo()
const before = Math.floor(Date.now() / 1000)
mint.setMintInfo({
name: 'test',
pubkey: 'aa',
version: 'test/1',
contact: [],
nuts: {'4': {methods: [], disabled: false}, '5': {methods: [], disabled: false}},
} as any)
expect(typeof mint.mintInfo!.time).toBe('number')
expect(mint.mintInfo!.time).toBeGreaterThanOrEqual(before)
expect(mint.hasUnknownCapabilities).toBe(false)
})
})

View File

@@ -0,0 +1,50 @@
/**
* Mint info staleness (WalletStore.isMintInfoStale).
*
* The reason this is its own test: a MISSING `time` must count as STALE.
*
* The stamp was only added when the capability model landed, so every mint info
* cached before that has `time: undefined`. `now - undefined` is NaN, and every
* comparison against NaN is false — so the obvious `now - time > TTL` check reports
* those records as FRESH, forever. A mint that has since gained onchain support then
* keeps looking like a mint that never had it, and the wallet quietly refuses to
* offer a feature the mint supports.
*
* That is not hypothetical: it is exactly what happened on device.
*
* @jest-environment node
*/
import {isMintInfoStale, MINT_INFO_TTL_SECONDS} from '../src/models/helpers/mintInfoStale'
const NOW = 1_800_000_000
const infoAt = (time?: number) => ({time} as any)
describe('isMintInfoStale', () => {
it('treats absent info as stale', () => {
expect(isMintInfoStale(undefined, NOW)).toBe(true)
})
it('treats info with NO timestamp as stale (the legacy-cache trap)', () => {
// `now - undefined` is NaN; `NaN > TTL` is false. A naive check calls this
// fresh and never refetches.
expect(isMintInfoStale(infoAt(undefined), NOW)).toBe(true)
})
it('treats a non-finite timestamp as stale', () => {
expect(isMintInfoStale(infoAt(NaN), NOW)).toBe(true)
})
it('is fresh within the TTL', () => {
expect(isMintInfoStale(infoAt(NOW - 60), NOW)).toBe(false)
expect(isMintInfoStale(infoAt(NOW), NOW)).toBe(false)
})
it('is stale past the TTL', () => {
expect(isMintInfoStale(infoAt(NOW - MINT_INFO_TTL_SECONDS - 1), NOW)).toBe(true)
})
it('is fresh exactly at the TTL boundary', () => {
expect(isMintInfoStale(infoAt(NOW - MINT_INFO_TTL_SECONDS), NOW)).toBe(false)
})
})

203
__tests__/mintUrl.test.ts Normal file
View File

@@ -0,0 +1,203 @@
/**
* Mint URL normalization + validation (services/cashu/mintUrl).
*
* The single definition of what a mint url may look like, shared by
* `MintsStore.addMint` and `Mint.setMintUrl`. Those two had drifted — adding a
* mint stripped the trailing slash and demanded https, renaming one did neither
* — so a rename could install a url that adding the same mint would have
* rejected.
*
* @jest-environment node
*/
// AppError pulls in logService -> Sentry, which is not loadable under the node
// test environment.
jest.mock('../src/services/logService', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
}))
import {Mint as CashuMint} from '@cashu/cashu-ts'
import {normalizeMintUrl, isOnionMintUrl} from '../src/services/cashu/mintUrl'
import AppError, {Err} from '../src/utils/AppError'
const expectValidationError = (fn: () => unknown) => {
expect(fn).toThrow(AppError)
try {
fn()
} catch (e: any) {
expect(e.name).toBe(Err.VALIDATION_ERROR)
}
}
describe('normalizeMintUrl', () => {
describe('trailing slashes (cashu spec: canonical form)', () => {
test('strips a single trailing slash', () => {
expect(normalizeMintUrl('https://mint.example/')).toBe('https://mint.example')
})
test('strips repeated trailing slashes', () => {
expect(normalizeMintUrl('https://mint.example///')).toBe('https://mint.example')
})
test('leaves a url with no trailing slash alone', () => {
expect(normalizeMintUrl('https://mint.example')).toBe('https://mint.example')
})
test('does NOT leave the slash the URL parser appends', () => {
// new URL('https://mint.example').href === 'https://mint.example/', so the
// strip has to happen AFTER canonicalization, not before it.
expect(normalizeMintUrl('https://mint.example')).not.toMatch(/\/$/)
})
test('preserves a path while stripping its trailing slash', () => {
expect(normalizeMintUrl('https://mint.example/cashu/')).toBe('https://mint.example/cashu')
})
test('trims surrounding whitespace (pasted urls)', () => {
expect(normalizeMintUrl(' https://mint.example/ ')).toBe('https://mint.example')
})
test('the two spellings of one mint normalize to the same string', () => {
// This is what makes the duplicate check able to see a trailing-slash twin.
expect(normalizeMintUrl('https://mint.example/')).toBe(normalizeMintUrl('https://mint.example'))
})
})
describe('canonical form (must equal what cashu-ts stores)', () => {
// WalletStore finds cached CashuMint/CashuWallet instances by comparing our
// stored string to CashuMint.mintUrl. If the two normalizations disagree,
// every cache lookup misses and the two spellings look like two mints.
test('lowercases the host', () => {
expect(normalizeMintUrl('https://Mint.Example')).toBe('https://mint.example')
})
test('lowercases the scheme', () => {
expect(normalizeMintUrl('HTTPS://mint.example')).toBe('https://mint.example')
})
test('drops the default https port', () => {
expect(normalizeMintUrl('https://mint.example:443')).toBe('https://mint.example')
})
test('keeps a non-default port', () => {
expect(normalizeMintUrl('https://mint.example:8443')).toBe('https://mint.example:8443')
})
test('preserves path case (paths are case-sensitive)', () => {
expect(normalizeMintUrl('https://mint.example/Cashu')).toBe('https://mint.example/Cashu')
})
test('host-case variants converge on one string', () => {
expect(normalizeMintUrl('https://MINT.example/')).toBe(normalizeMintUrl('https://mint.example'))
})
})
describe('agreement with cashu-ts', () => {
// Pins our output to the library's own normalizeUrl (which is @internal, so
// it can only be observed through the CashuMint constructor). If cashu-ts
// changes its canonical form, this fails rather than silently splitting the
// wallet's cache keys.
test.each([
'https://mint.example',
'https://mint.example/',
'https://mint.example///',
'https://Mint.Example',
'HTTPS://MINT.EXAMPLE/',
'https://mint.example:443/',
'https://mint.example:8443/cashu/',
'https://mint.example/Cashu',
])('normalizeMintUrl(%s) === new CashuMint(...).mintUrl', url => {
expect(normalizeMintUrl(url)).toBe(new CashuMint(url).mintUrl)
})
})
describe('https requirement', () => {
test('accepts https', () => {
expect(normalizeMintUrl('https://mint.example')).toBe('https://mint.example')
})
test('rejects plain http', () => {
expectValidationError(() => normalizeMintUrl('http://mint.example'))
})
test('rejects a non-http scheme', () => {
expectValidationError(() => normalizeMintUrl('ftp://mint.example'))
})
test('rejects a scheme merely PREFIXED with https', () => {
// `startsWith('https')` — the old check — passes this; it parses as scheme
// "https-evil:", which is not https at all.
expectValidationError(() => normalizeMintUrl('https-evil://mint.example'))
})
})
describe('onion exemption', () => {
test('accepts http for a .onion host (Tor authenticates the endpoint)', () => {
expect(normalizeMintUrl('http://abcdef.onion')).toBe('http://abcdef.onion')
})
test('accepts https for a .onion host', () => {
expect(normalizeMintUrl('https://abcdef.onion/')).toBe('https://abcdef.onion')
})
// The regression the hostname check closes: addMint tested
// `mintUrl.includes('.onion')`, so a '.onion' ANYWHERE in the string bought a
// plain-http exemption for an ordinary host.
test('does NOT let ".onion" in the PATH exempt a plain-http host', () => {
expectValidationError(() => normalizeMintUrl('http://evil.example/.onion'))
})
test('does NOT let ".onion" in the QUERY exempt a plain-http host', () => {
expectValidationError(() => normalizeMintUrl('http://evil.example?x=.onion'))
})
test('does NOT let a ".onion." subdomain prefix exempt a plain-http host', () => {
expectValidationError(() => normalizeMintUrl('http://x.onion.evil.example'))
})
})
describe('malformed input', () => {
test('rejects an empty string', () => {
expectValidationError(() => normalizeMintUrl(''))
})
test('rejects whitespace only', () => {
expectValidationError(() => normalizeMintUrl(' '))
})
test('rejects a non-url string', () => {
expectValidationError(() => normalizeMintUrl('not a url'))
})
test('rejects a scheme-less host', () => {
expectValidationError(() => normalizeMintUrl('mint.example'))
})
})
})
describe('isOnionMintUrl', () => {
test('true for a .onion hostname', () => {
expect(isOnionMintUrl('http://abcdef.onion')).toBe(true)
})
test('true for a .onion hostname with a port and path', () => {
expect(isOnionMintUrl('http://abcdef.onion:8080/cashu')).toBe(true)
})
test('false when .onion appears only in the path', () => {
expect(isOnionMintUrl('https://evil.example/.onion')).toBe(false)
})
test('false for an ordinary host', () => {
expect(isOnionMintUrl('https://mint.example')).toBe(false)
})
test('false (not a throw) for an unparseable url', () => {
expect(isOnionMintUrl('not a url')).toBe(false)
})
})

View File

@@ -0,0 +1,370 @@
/**
* Mint persistence through the MST layer: the per-mint observer, the startup
* hydrate, and the snapshot strip.
*
* SQLite is the authority for mints; the model is the in-memory cache the UI
* observes. Rather than a write-through in each of ~20 Mint mutators — where
* forgetting one is silent staleness — each mint carries one onSnapshot observer.
* These tests pin the parts of that arrangement which are easy to get subtly wrong:
* WHEN observers attach, that loading does not write back, and that a mint stripped
* from the snapshot still survives a restart.
*/
jest.mock('../src/services/nostrService', () => ({
// cashuUtils -> nostrService -> minibitsService -> models is an import CYCLE.
NostrClient: {getFirstTagValue: jest.fn()},
}))
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {types, getSnapshot} from 'mobx-state-tree'
import {MintsStoreModel} from '../src/models/MintsStore'
import {ProofsStoreModel} from '../src/models/ProofsStore'
import {Database} from '../src/services/db'
import {ProofModel} from '../src/models/Proof'
// A minimal root: the real RootStore additionally pulls AuthStore/NwcStore/
// WalletProfileStore and, through them, the whole service layer. getRootStore only
// needs the root to expose the stores actually used.
const TestRoot = types.model('RootStore', {
mintsStore: types.optional(MintsStoreModel, {}),
proofsStore: types.optional(ProofsStoreModel, {}),
})
const MINT_URL = 'https://mint.test'
// Real-shaped keyset ids ('00' + 14 hex). Placeholders like 'k1' are not hex, so
// isCollidingKeysetId reads them as legacy base64 ids and they alias onto the same
// derivation index — the wallet would reject the second as a collision.
const KEYSET_1 = '009a1f293253e41e'
const KEYSET_2 = '00ad268c4d1f5826'
const mintSnapshot = (overrides: Record<string, any> = {}) => ({
id: 'mint1111',
mintUrl: MINT_URL,
hostname: 'mint.test',
shortname: 'Test Mint',
units: ['sat'],
keysets: [{id: KEYSET_1, unit: 'sat', active: true, input_fee_ppk: 0}],
keys: [{id: KEYSET_1, unit: 'sat', keys: {'1': '02aa'}}],
// Every keyset has a counter shell in production (initKeyset creates it, and
// hydrateMintsFromDatabase rebuilds it). The values themselves are volatile and come
// from mint_counters.
proofsCounters: [{keyset: KEYSET_1, unit: 'sat'}],
color: '#abcdef',
status: 'ONLINE',
...overrides,
})
const makeRoot = (mints: any[] = []) => TestRoot.create({mintsStore: {mints}, proofsStore: {}})
const storedMintUrls = () => Database.getMints().map(m => m.mintUrl)
beforeEach(() => {
Database.getInstance().executeBatch([
['DELETE FROM mints'],
['DELETE FROM mint_keysets'],
['DELETE FROM proofs'],
['DELETE FROM mint_counters'],
])
})
describe('mint persistence', () => {
describe('the snapshot no longer carries mints', () => {
// The reason for the whole move: mints (with every keyset's keys map) were the
// largest thing left in the persisted tree, and JSON.stringify(snapshot) runs
// on EVERY MST action anywhere — including every proof mutation during a send.
test('getSnapshot reports no mints, whatever the store holds', () => {
const root = makeRoot([mintSnapshot()])
expect(root.mintsStore.mints).toHaveLength(1) // live
expect(getSnapshot(root.mintsStore).mints).toEqual([]) // persisted
})
test('but blockedMintUrls IS still persisted there', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.blockMint(root.mintsStore.mints[0] as any)
expect(getSnapshot(root.mintsStore).blockedMintUrls).toEqual([MINT_URL])
})
})
describe('the observer', () => {
test('persists a mint mutation without any explicit write', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.persistAllMints()
root.mintsStore.observeMints()
root.mintsStore.mints[0].setProp('shortname', 'Renamed')
expect(Database.getMints()[0].shortname).toBe('Renamed')
})
test('persists a keyset added later', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.persistAllMints()
root.mintsStore.observeMints()
root.mintsStore.mints[0].initKeyset({id: KEYSET_2, unit: 'sat', active: true} as any, [KEYSET_1])
expect(Database.getMints()[0].keysets.map(k => k.id).sort()).toEqual([KEYSET_1, KEYSET_2].sort())
})
test('stops persisting a mint once it is removed', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.persistAllMints()
root.mintsStore.observeMints()
root.mintsStore.removeMint(root.mintsStore.mints[0] as any)
expect(Database.getMints()).toEqual([])
})
// A counter bump must not churn the mint row: `counter` is volatile, so it never
// reaches a snapshot and cannot fire the observer at all.
test('a derivation-counter bump does not touch the mint row', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.persistAllMints()
root.mintsStore.observeMints()
const before = Database.getMints()[0]
root.mintsStore.mints[0].proofsCounters[0].increaseProofsCounter(5)
expect(Database.getMints()[0]).toEqual(before)
})
})
describe('hydrateMintsFromDatabase', () => {
test('hydrates the mints back, keysets and keys included', () => {
const seeded = makeRoot([mintSnapshot()])
seeded.mintsStore.persistAllMints()
// A fresh tree, as on the next launch: the snapshot carries no mints.
const restarted = makeRoot([])
expect(restarted.mintsStore.mints).toHaveLength(0)
restarted.mintsStore.hydrateMintsFromDatabase()
const mint = restarted.mintsStore.mints[0]
expect(mint.mintUrl).toBe(MINT_URL)
expect(mint.shortname).toBe('Test Mint')
expect(mint.keysets.map(k => k.id)).toEqual([KEYSET_1])
expect(mint.keys.map(k => k.id)).toEqual([KEYSET_1])
})
// The launch that migrates: the table is empty and the mints still come from the
// MMKV snapshot. Wiping them here would delete the user's mints.
test('never wipes what the snapshot restored when the table is empty', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.hydrateMintsFromDatabase()
expect(root.mintsStore.mints).toHaveLength(1)
expect(root.mintsStore.mints[0].mintUrl).toBe(MINT_URL)
})
// THE data-loss path, seen on a real device. rootStore.version defaults to the
// CURRENT rootStoreModelVersion, so a factory reset stamps a wallet as fully
// migrated on the spot; restore an older snapshot over that and a version-gated
// seed never runs. Meanwhile postProcessSnapshot strips mints from every save —
// so unless this converges on the DATA, the mints are in neither place and are
// gone on the next launch.
test('persists mints that exist ONLY in the snapshot, with no migration involved', () => {
const root = makeRoot([mintSnapshot()])
expect(Database.getMints()).toEqual([]) // table empty, as on that device
root.mintsStore.hydrateMintsFromDatabase()
// Written through purely because the data said so.
expect(Database.getMints().map(m => m.mintUrl)).toEqual([MINT_URL])
})
test('and those mints then survive a restart', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.hydrateMintsFromDatabase()
// The next launch: the snapshot no longer carries mints at all.
const restarted = makeRoot([])
restarted.mintsStore.hydrateMintsFromDatabase()
expect(restarted.mintsStore.mints.map(m => m.mintUrl)).toEqual([MINT_URL])
})
test('observes the snapshot-only mints too, so later edits persist', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.hydrateMintsFromDatabase()
root.mintsStore.mints[0].setProp('shortname', 'Renamed')
expect(Database.getMints()[0].shortname).toBe('Renamed')
})
test('does nothing when there are no mints anywhere (a fresh wallet)', () => {
const root = makeRoot([])
root.mintsStore.hydrateMintsFromDatabase()
expect(root.mintsStore.mints).toHaveLength(0)
expect(Database.getMints()).toEqual([])
})
// THE fund-loss path, and the reason hydrateMintsFromDatabase rebuilds the counter
// shells by hand. Loading bypasses initKeyset, which is what normally creates
// them. With no shell, hydrateCountersFromDatabase has nothing to fill, the
// counter is later created on demand at 0, and derivation re-issues blinded
// secrets the mint has already signed.
test('rebuilds a counter shell per keyset, so the real index can hydrate', () => {
const seeded = makeRoot([mintSnapshot()])
seeded.mintsStore.persistAllMints()
Database.setCounter(KEYSET_1, 'sat', 342)
const restarted = makeRoot([])
restarted.mintsStore.hydrateMintsFromDatabase()
// The shell must exist for the keyset...
expect(restarted.mintsStore.mints[0].proofsCounters.map(c => c.keyset)).toEqual([KEYSET_1])
// ...so that the authority's value lands on it, rather than the counter being
// recreated at 0 on first use.
restarted.mintsStore.hydrateCountersFromDatabase()
expect(restarted.mintsStore.mints[0].proofsCounters[0].counter).toBe(342)
})
test('rebuilds a shell for EVERY keyset, not just the first', () => {
const seeded = makeRoot([
mintSnapshot({
keysets: [
{id: KEYSET_1, unit: 'sat', active: true},
{id: KEYSET_2, unit: 'sat', active: false},
],
proofsCounters: [
{keyset: KEYSET_1, unit: 'sat'},
{keyset: KEYSET_2, unit: 'sat'},
],
}),
])
seeded.mintsStore.persistAllMints()
Database.setCounter(KEYSET_2, 'sat', 77)
const restarted = makeRoot([])
restarted.mintsStore.hydrateMintsFromDatabase()
restarted.mintsStore.hydrateCountersFromDatabase()
const counters = restarted.mintsStore.mints[0].proofsCounters
expect(counters.map(c => c.keyset).sort()).toEqual([KEYSET_1, KEYSET_2].sort())
expect(counters.find(c => c.keyset === KEYSET_2)!.counter).toBe(77)
})
test('preserves the mint id, so rows referencing it still resolve', () => {
const seeded = makeRoot([mintSnapshot()])
seeded.mintsStore.persistAllMints()
const restarted = makeRoot([])
restarted.mintsStore.hydrateMintsFromDatabase()
// onchain quotes, reservations and transactions all point at Mint.id.
expect(restarted.mintsStore.findById('mint1111')).toBeDefined()
})
})
describe('the rename, end to end', () => {
test('moves the mint and its proofs, in memory and on disk', async () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.persistAllMints()
root.mintsStore.observeMints()
Database.addOrUpdateProofs(
[
ProofModel.create({
id: KEYSET_1,
amount: 10,
secret: 's1',
C: 'C',
unit: 'sat',
tId: 1,
mintUrl: MINT_URL,
}) as any,
],
'UNSPENT',
)
await root.proofsStore.loadProofsFromDatabase()
root.mintsStore.mints[0].setMintUrl!('https://moved.test')
// Model
expect(root.mintsStore.mints[0].mintUrl).toBe('https://moved.test')
expect(root.mintsStore.mints[0].hostname).toBe('moved.test')
expect(root.proofsStore.getBySecret('s1')!.mintUrl).toBe('https://moved.test')
// Database
expect(storedMintUrls()).toEqual(['https://moved.test'])
// And the balance still finds the money — the failure this whole change is
// about is proofs left owned by no mint.
expect(root.proofsStore.findOrphanedProofs()).toEqual([])
expect(root.proofsStore.balances.mintBalances[0].balances.sat).toBe(10)
})
test('a rename survives a restart', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.persistAllMints()
root.mintsStore.observeMints()
root.mintsStore.mints[0].setMintUrl!('https://moved.test')
const restarted = makeRoot([])
restarted.mintsStore.hydrateMintsFromDatabase()
expect(restarted.mintsStore.mints[0].mintUrl).toBe('https://moved.test')
})
})
describe('the backup payload — the trap that loses data silently', () => {
// getSnapshot(mintsStore).mints is ALWAYS empty now. A backup built from the
// store snapshot would contain zero mints, raise no error, and only reveal the
// loss on restore. This is why the export lives on the store behind a test
// rather than inline in the screen.
test('contains the mints, unlike the store snapshot', () => {
const root = makeRoot([mintSnapshot()])
expect(getSnapshot(root.mintsStore).mints).toEqual([]) // the trap
expect(root.mintsStore.backupSnapshot.mints).toHaveLength(1) // the fix
expect(root.mintsStore.backupSnapshot.mints[0].mintUrl).toBe(MINT_URL)
})
test('carries the keysets, which the recovered wallet needs', () => {
const root = makeRoot([mintSnapshot()])
expect(root.mintsStore.backupSnapshot.mints[0].keysets.map((k: any) => k.id)).toEqual([KEYSET_1])
})
test('drops keys — they are re-fetched from the mint on import', () => {
const root = makeRoot([mintSnapshot()])
expect(root.mintsStore.backupSnapshot.mints[0].keys).toEqual([])
})
// A backup restored with counter 0 would re-derive blinded secrets the mint has
// already signed. `counter` is volatile, so it is absent from the snapshot and
// has to be put back deliberately.
test('re-injects the live derivation counter per keyset', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.mints[0].proofsCounters[0].setProofsCounter(342)
const [mint] = root.mintsStore.backupSnapshot.mints
expect(mint.proofsCounters[0].counter).toBe(342)
})
test('carries blockedMintUrls', () => {
const root = makeRoot([mintSnapshot()])
root.mintsStore.blockMint(root.mintsStore.mints[0] as any)
expect(root.mintsStore.backupSnapshot.blockedMintUrls).toEqual([MINT_URL])
})
test('is a plain detached copy — mutating it cannot touch the store', () => {
const root = makeRoot([mintSnapshot()])
const backup = root.mintsStore.backupSnapshot
backup.mints[0].mintUrl = 'https://tampered.test'
expect(root.mintsStore.mints[0].mintUrl).toBe(MINT_URL)
})
})
})

265
__tests__/mintsRepo.test.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* Mints and their keysets in SQLite (mintsRepo), against the REAL repo and a real
* database.
*
* Mints were the last core entity persisted only by serializing the whole MST tree
* to MMKV. Moving them here takes that cost off the hot path (every MST action
* anywhere re-serialized every mint's keys) and puts them in the same engine as the
* proofs, so a mint-url edit can finally commit atomically with the proofs it
* renames.
*
* What these pin, in order of how much they would hurt to get wrong:
* - a mint round-trips exactly, keys included — losing keysets or keys means the
* wallet cannot verify or spend that mint's ecash;
* - the rename moves the mint AND its proofs or neither;
* - removal keeps the derivation counters.
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {Database} from '../src/services/db'
import type {MintRecord} from '../src/services/db'
const MINT_URL = 'https://mint.test'
const keyset = (id: string, overrides: Record<string, any> = {}) => ({
id,
unit: 'sat',
active: true,
input_fee_ppk: 0,
...overrides,
})
const keysFor = (id: string) => ({id, unit: 'sat', keys: {'1': '02aa', '2': '02bb'}})
const mintRecord = (overrides: Partial<MintRecord> = {}): MintRecord => ({
id: 'mint1111',
mintUrl: MINT_URL,
hostname: 'mint.test',
shortname: 'Test Mint',
units: ['sat'],
keysets: [keyset('k1')],
keys: [keysFor('k1')],
mintInfo: {name: 'Test Mint', time: 1234},
color: '#abcdef',
status: 'ONLINE',
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
})
const proofRow = (secret: string, mintUrl: string) =>
Database.getInstance().execute(
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, unit, tId, mintUrl, state, updatedAt)
VALUES ('k1', 10, ?, 'C', 'sat', 1, ?, 'UNSPENT', '2026-01-01')`,
[secret, mintUrl],
)
const proofMintUrl = (secret: string): string | undefined =>
Database.getInstance().execute('SELECT mintUrl FROM proofs WHERE secret = ?', [secret]).rows?.item(0)
?.mintUrl
beforeEach(() => {
Database.getInstance().executeBatch([
['DELETE FROM mints'],
['DELETE FROM mint_keysets'],
['DELETE FROM proofs'],
['DELETE FROM mint_counters'],
])
})
describe('mints in SQLite', () => {
describe('round-trip', () => {
test('a mint survives a write/read cycle intact', () => {
const mint = mintRecord()
Database.upsertMint(mint)
const [loaded] = Database.getMints()
expect(loaded).toEqual(mint)
})
test('preserves keyset fields the wallet does not name', () => {
// MintKeyset carries final_expiry, which feeds NUT-02 v2 id derivation. The
// keyset is stored as a whole JSON object precisely so fields like this are
// not silently dropped by an enumerated column list.
Database.upsertMint(mintRecord({keysets: [keyset('k1', {final_expiry: 1799999999})]}))
expect(Database.getMints()[0].keysets[0]).toMatchObject({final_expiry: 1799999999})
})
test('carries multiple keysets and their keys', () => {
Database.upsertMint(
mintRecord({
keysets: [keyset('k1'), keyset('k2', {active: false})],
keys: [keysFor('k1'), keysFor('k2')],
}),
)
const [loaded] = Database.getMints()
expect(loaded.keysets.map(k => k.id).sort()).toEqual(['k1', 'k2'])
expect(loaded.keys.map(k => k.id).sort()).toEqual(['k1', 'k2'])
})
test('a keyset whose keys were never fetched loads with no keys entry', () => {
// keysets and keys are independent arrays in the model: the mint advertises a
// keyset before (or without) the wallet fetching its keys.
Database.upsertMint(mintRecord({keysets: [keyset('k1'), keyset('k2')], keys: [keysFor('k1')]}))
const [loaded] = Database.getMints()
expect(loaded.keysets).toHaveLength(2)
expect(loaded.keys.map(k => k.id)).toEqual(['k1'])
})
test('an absent mintInfo stays absent', () => {
Database.upsertMint(mintRecord({mintInfo: undefined}))
expect(Database.getMints()[0].mintInfo).toBeUndefined()
})
test('two mints are independent', () => {
Database.upsertMint(mintRecord())
Database.upsertMint(
mintRecord({id: 'mint2222', mintUrl: 'https://other.test', keysets: [keyset('k9')], keys: []}),
)
const loaded = Database.getMints()
expect(loaded).toHaveLength(2)
expect(loaded.find(m => m.id === 'mint2222')!.keysets.map(k => k.id)).toEqual(['k9'])
})
})
describe('upsert', () => {
test('is idempotent — re-writing the same mint changes nothing', () => {
const mint = mintRecord()
Database.upsertMint(mint)
Database.upsertMint(mint)
expect(Database.getMints()).toHaveLength(1)
expect(Database.getMints()[0]).toEqual(mint)
})
test('updates changed fields in place', () => {
Database.upsertMint(mintRecord())
Database.upsertMint(mintRecord({shortname: 'Renamed', status: 'OFFLINE'}))
const [loaded] = Database.getMints()
expect(loaded.shortname).toBe('Renamed')
expect(loaded.status).toBe('OFFLINE')
})
test('adds a newly advertised keyset without disturbing the existing ones', () => {
Database.upsertMint(mintRecord())
Database.upsertMint(
mintRecord({keysets: [keyset('k1'), keyset('k2')], keys: [keysFor('k1'), keysFor('k2')]}),
)
expect(Database.getMints()[0].keysets.map(k => k.id).sort()).toEqual(['k1', 'k2'])
})
// Keys and keyset metadata are fetched from the mint separately, so a
// metadata-only refresh must never erase keys already stored — without them the
// wallet cannot verify or spend that keyset.
test('a keyset refresh with no keys does NOT erase stored keys', () => {
Database.upsertMint(mintRecord())
expect(Database.getMints()[0].keys).toHaveLength(1)
Database.upsertMint(mintRecord({keys: []})) // metadata-only refresh
expect(Database.getMints()[0].keys.map(k => k.id)).toEqual(['k1'])
})
})
describe('the atomic rename', () => {
test('moves the mint AND its proofs together', () => {
Database.upsertMint(mintRecord())
proofRow('p1', MINT_URL)
proofRow('p2', MINT_URL)
Database.updateMintUrlWithProofs('mint1111', MINT_URL, 'https://moved.test', 'moved.test')
expect(Database.getMints()[0].mintUrl).toBe('https://moved.test')
expect(Database.getMints()[0].hostname).toBe('moved.test')
expect(proofMintUrl('p1')).toBe('https://moved.test')
expect(proofMintUrl('p2')).toBe('https://moved.test')
})
test('leaves another mint\'s proofs alone', () => {
Database.upsertMint(mintRecord())
proofRow('mine', MINT_URL)
proofRow('theirs', 'https://other.test')
Database.updateMintUrlWithProofs('mint1111', MINT_URL, 'https://moved.test', 'moved.test')
expect(proofMintUrl('mine')).toBe('https://moved.test')
expect(proofMintUrl('theirs')).toBe('https://other.test')
})
// The whole point of putting mints in the same engine as proofs. When the url
// lived in MMKV and the proofs in SQLite, a crash between the two writes left
// the proofs owned by no mint: the money vanished from every per-mint balance
// while still counting in the total, and could not be spent.
test('is all-or-nothing: a failure leaves BOTH untouched', () => {
Database.upsertMint(mintRecord())
proofRow('p1', MINT_URL)
// A non-finite param is rejected by connection.ts's sanitizing, mid batch.
expect(() =>
Database.updateMintUrlWithProofs('mint1111', MINT_URL, Number.NaN as any, 'moved.test'),
).toThrow()
expect(Database.getMints()[0].mintUrl).toBe(MINT_URL)
expect(proofMintUrl('p1')).toBe(MINT_URL)
})
})
describe('removal', () => {
test('deletes the mint and its keysets', () => {
Database.upsertMint(mintRecord())
Database.removeMintById('mint1111')
expect(Database.getMints()).toEqual([])
const keysetCount = Database.getInstance()
.execute('SELECT COUNT(*) AS n FROM mint_keysets')
.rows?.item(0)?.n
expect(keysetCount).toBe(0)
})
test('KEEPS the derivation counters', () => {
// mint_counters rows are keyed by keysetId and retained across removal, so a
// re-added mint recovers its real index instead of restarting at 0 — which
// would reuse blinded secrets the mint has already signed.
Database.upsertMint(mintRecord())
Database.setCounter('k1', 'sat', 342)
Database.removeMintById('mint1111')
expect(Database.getCounter('k1')?.counter).toBe(342)
})
test('leaves other mints alone', () => {
Database.upsertMint(mintRecord())
Database.upsertMint(mintRecord({id: 'mint2222', mintUrl: 'https://other.test'}))
Database.removeMintById('mint1111')
expect(Database.getMints().map(m => m.id)).toEqual(['mint2222'])
})
})
describe('seedMints — the one-time MMKV copy', () => {
test('copies mints across', () => {
Database.seedMints([mintRecord(), mintRecord({id: 'mint2222', mintUrl: 'https://other.test'})])
expect(Database.getMints()).toHaveLength(2)
})
test('is idempotent — upserts by id, so a re-run cannot duplicate a mint', () => {
Database.seedMints([mintRecord()])
Database.seedMints([mintRecord()])
expect(Database.getMints()).toHaveLength(1)
})
test('an empty seed is a no-op', () => {
expect(Database.seedMints([])).toEqual({seeded: 0})
})
})
})

304
__tests__/nut20.test.ts Normal file
View File

@@ -0,0 +1,304 @@
/**
* NUT-20 quote-locking key tests.
*
* Two halves:
*
* 1. DERIVATION (src/services/cashu/nut20.ts) — determinism, key format, a
* pinned regression vector, and a sign/verify round-trip through cashu-ts's
* own NUT-20 primitives.
*
* 2. THE COUNTER (src/services/db/walletCountersRepo.ts) — the burn-forward
* allocation and monotonic set, exercised through the REAL repo against a real
* database (the op-sqlite jest mock backs the driver seam with node:sqlite).
*
* The counter is the load-bearing part: handing the same index out twice would
* give two quotes the same pubkey, which lets the mint link them (NUT-20 asks
* for a unique key per quote precisely to prevent that) and makes the signature
* ambiguous. Skipping an index is harmless; reusing one is not.
*
* @jest-environment node
*/
import {Database} from '../src/services/db'
import {Amount, signMintQuote, verifyMintQuoteSignature} from '@cashu/cashu-ts'
import type {SerializedBlindedMessage} from '@cashu/cashu-ts'
import {hexToBytes} from '@noble/curves/utils.js'
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
// The DERIVATION half stubs the counter so it can pin the index it is handed
// (allocateQuoteKeypair must use whatever the counter returns, whatever that is).
// The COUNTER half below reaches past this mock with requireActual and exercises
// the real repo against a real database.
const mockAllocateNextCounter = jest.fn()
jest.mock('../src/services/db/walletCountersRepo', () => ({
NUT20_COUNTER: 'nut20',
allocateNextCounter: (name: string) => mockAllocateNextCounter(name),
}))
import {
allocateQuoteKeypair,
deriveQuoteKeypair,
quoteKeyDerivationPath,
} from '../src/services/cashu/nut20'
/**
* Seed for the standard BIP39 test mnemonic
* ("abandon" x11 + "about", empty passphrase) — the canonical value from the
* BIP39 spec's own test vectors, so it can be checked against the spec rather
* than against us. Pinned as bytes because NUT-20 derives from the seed, not the
* mnemonic; that keeps BIP39 (and its PBKDF2 native dep) out of this test.
*/
const SEED = hexToBytes(
'5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1' +
'9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4',
)
/**
* Pinned NUT-20 vector for m/129373'/20'/0'/0'/{0,1}.
*
* Computed INDEPENDENTLY (a clean @scure/bip32 install outside this codebase),
* not captured from our own output — so it cross-checks the implementation
* rather than enshrining whatever it happened to produce.
*
* Stage 1 validates derivation by round-trip only; a real mint accepting these
* signatures is not proven until the first onchain mint (Stage 5). Until then
* this vector is what stops the path from silently drifting. If it ever fails,
* the derivation path changed — that would orphan every existing quote, so do
* not "fix" it by updating the constant.
*/
const VECTOR = [
{
index: 0,
privkey: '26392b1fd4bd70c14f27c30368a896412ce5a44f22a3035ce820f91324c7d49b',
pubkey: '025c820f30db9b7d9479a0337aeed771162e291e9cd711ce8f42b1a188a39d3c9e',
},
{
index: 1,
privkey: '13dbe6792e239fd6a9b0d96263076e67507aef397453a314a6e68d5fc390cc22',
pubkey: '032acf3ccf5f01638ec9110a997d75797805c1e37f050de814263beceb7d8996a6',
},
]
const BLINDED_MESSAGES: SerializedBlindedMessage[] = [
{
amount: Amount.from(8),
id: '009a1f293253e41e',
B_: '035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d',
},
{
amount: Amount.from(2),
id: '009a1f293253e41e',
B_: '0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6',
},
]
const QUOTE_ID = '019e6d5a-2347-7000-8850-39c85ed1b5d3'
describe('NUT-20 quote key derivation', () => {
beforeEach(() => mockAllocateNextCounter.mockReset())
it('uses the NUT-20 path, with a non-hardened counter', () => {
// 129373' = cashu namespace, 20' = NUT-20; the counter is NOT hardened.
expect(quoteKeyDerivationPath(0)).toBe("m/129373'/20'/0'/0'/0")
expect(quoteKeyDerivationPath(42)).toBe("m/129373'/20'/0'/0'/42")
})
it('matches the pinned vector (independently computed)', () => {
for (const {index, privkey, pubkey} of VECTOR) {
expect(deriveQuoteKeypair(SEED, index)).toEqual({privkey, pubkey})
}
})
it('is deterministic for a given seed and index', () => {
expect(deriveQuoteKeypair(SEED, 7)).toEqual(deriveQuoteKeypair(SEED, 7))
})
it('gives a distinct key per index', () => {
const keys = [0, 1, 2, 3, 4].map(i => deriveQuoteKeypair(SEED, i).pubkey)
expect(new Set(keys).size).toBe(keys.length)
})
it('produces a 32-byte privkey and a 33-byte compressed pubkey', () => {
const {privkey, pubkey} = deriveQuoteKeypair(SEED, 3)
expect(privkey).toMatch(/^[0-9a-f]{64}$/)
expect(pubkey).toMatch(/^0[23][0-9a-f]{64}$/) // compressed: 02/03 prefix
})
it('rejects a negative or non-integer index', () => {
expect(() => deriveQuoteKeypair(SEED, -1)).toThrow()
expect(() => deriveQuoteKeypair(SEED, 1.5)).toThrow()
})
})
/**
* Round-trip through cashu-ts's NUT-20 primitives.
*
* These prove the DERIVED KEY is a usable NUT-20 signing key — they do not pin
* the wire format, and deliberately so. cashu-ts 4.7.0 carries TWO mint-quote
* message aggregations:
*
* - the spec's `Cashu_MintQuoteSig_v1` (domain tag, len32-prefixed quote, and
* per-output amount + B_), and
* - a legacy one: sha256(quote || B_0 || B_1 ...), with no domain tag and no
* amounts.
*
* The EXPORTED `signMintQuote` / `verifyMintQuoteSignature` are the LEGACY pair.
* The library's own mint path (prepareMint, and so mintProofsOnchain) signs with
* the spec-v1 aggregation and puts THAT in the request's `signature` field — so
* what Minibits actually sends is spec-correct. The wire format is proven when a
* real mint accepts a signed mint request (Stage 5); here we only need to know
* the key itself signs and verifies.
*
* Consequence: assertions below stick to what the legacy message commits to
* (quote id and B_). It does NOT commit to output amounts, so a tampered amount
* would still verify through this pair — which says nothing about the v1 format
* we actually transmit.
*/
describe('NUT-20 signing round-trip (via cashu-ts)', () => {
it('signs a mint request the matching pubkey verifies', () => {
const {privkey, pubkey} = deriveQuoteKeypair(SEED, 0)
const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES)
expect(verifyMintQuoteSignature(pubkey, QUOTE_ID, BLINDED_MESSAGES, signature)).toBe(true)
})
it('does not verify under a different quote key', () => {
const {privkey} = deriveQuoteKeypair(SEED, 0)
const {pubkey: otherPubkey} = deriveQuoteKeypair(SEED, 1)
const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES)
expect(verifyMintQuoteSignature(otherPubkey, QUOTE_ID, BLINDED_MESSAGES, signature)).toBe(
false,
)
})
it('does not verify against a different quote id', () => {
const {privkey, pubkey} = deriveQuoteKeypair(SEED, 0)
const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES)
expect(
verifyMintQuoteSignature(pubkey, 'some-other-quote-id', BLINDED_MESSAGES, signature),
).toBe(false)
})
it('does not verify against tampered outputs (B_ is committed)', () => {
const {privkey, pubkey} = deriveQuoteKeypair(SEED, 0)
const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES)
const tampered = [
{
...BLINDED_MESSAGES[0],
B_: '0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6',
},
BLINDED_MESSAGES[1],
]
expect(verifyMintQuoteSignature(pubkey, QUOTE_ID, tampered, signature)).toBe(false)
})
})
describe('allocateQuoteKeypair', () => {
beforeEach(() => mockAllocateNextCounter.mockReset())
it('allocates from the nut20 counter and derives that index', () => {
mockAllocateNextCounter.mockReturnValue(5)
const result = allocateQuoteKeypair(SEED)
expect(mockAllocateNextCounter).toHaveBeenCalledWith('nut20')
expect(result).toEqual({index: 5, ...deriveQuoteKeypair(SEED, 5)})
})
it('never repeats a keypair across calls', () => {
mockAllocateNextCounter.mockReturnValueOnce(0).mockReturnValueOnce(1)
const first = allocateQuoteKeypair(SEED)
const second = allocateQuoteKeypair(SEED)
expect(first.pubkey).not.toBe(second.pubkey)
})
})
// ── The counter, through the REAL repo ──────────────────────────────────────
//
// These call walletCountersRepo directly rather than mirroring its SQL. The
// allocation is a single RETURNING statement whose exact semantics ARE the safety
// property, so a hand-copy would prove nothing about the statement that runs.
// requireActual reaches past the module-level stub above: this half is about the
// real statements, not about isolating the derivation from them.
const {allocateNextCounter, getWalletCounter, setWalletCounter} = jest.requireActual(
'../src/services/db/walletCountersRepo',
)
describe('wallet_counters (burn-forward allocation)', () => {
// One in-memory database per test FILE (instance.ts caches its connection), so
// clear the table between tests rather than rebuilding.
beforeEach(() => {
Database.getInstance().executeBatch([['DELETE FROM wallet_counters']])
})
it('starts at index 0 when no row exists', () => {
expect(getWalletCounter('nut20')).toBe(0)
expect(allocateNextCounter('nut20')).toBe(0)
})
it('hands out consecutive indices and stores the next free one', () => {
const allocated = [0, 1, 2, 3].map(() => allocateNextCounter('nut20'))
expect(allocated).toEqual([0, 1, 2, 3])
expect(getWalletCounter('nut20')).toBe(4) // next free, not last used
})
it('never hands out the same index twice', () => {
const allocated = Array.from({length: 50}, () => allocateNextCounter('nut20'))
expect(new Set(allocated).size).toBe(allocated.length)
})
it('burns the index when the caller fails: a retry gets a NEW one', () => {
// The whole point of committing before the network call. Quote request
// dies -> index 0 is spent, never recycled.
const burned = allocateNextCounter('nut20')
const afterRetry = allocateNextCounter('nut20')
expect(burned).toBe(0)
expect(afterRetry).toBe(1)
expect(afterRetry).not.toBe(burned)
})
it('keeps counters independent per purpose name', () => {
expect(allocateNextCounter('nut20')).toBe(0)
expect(allocateNextCounter('other')).toBe(0)
expect(allocateNextCounter('nut20')).toBe(1)
expect(getWalletCounter('nut20')).toBe(2)
expect(getWalletCounter('other')).toBe(1)
})
it('setWalletCounter is monotonic: a lower value is a no-op', () => {
setWalletCounter('nut20', 10)
expect(getWalletCounter('nut20')).toBe(10)
setWalletCounter('nut20', 3) // stale writer
expect(getWalletCounter('nut20')).toBe(10)
setWalletCounter('nut20', 12)
expect(getWalletCounter('nut20')).toBe(12)
})
it('cannot walk back onto an index already handed out', () => {
const first = allocateNextCounter('nut20') // 0
allocateNextCounter('nut20') // 1
setWalletCounter('nut20', 0) // stale/replayed write
expect(allocateNextCounter('nut20')).toBe(2)
expect(allocateNextCounter('nut20')).not.toBe(first)
})
})

View File

@@ -0,0 +1,231 @@
/**
* Onchain (NUT-30) melt arithmetic: fee-tier selection and the payout floor.
*
* Same split as the topup arithmetic tests — jest pins the pure decisions, device
* testing covers the orchestration. What matters here is that the wallet never
* silently spends more of the user's money on miner fees than it was asked to, and
* never ranks fee tiers by a field that is not a rank.
*
* @jest-environment node
*/
import {
findFeeOption,
mockOnchainFeeTiers,
normalizeFeeOptions,
onchainMeltFloor,
onchainMeltTotal,
payableFeeIndex,
selectDefaultFeeOption,
MINIBITS_ONCHAIN_MELT_FLOOR_SAT,
} from '../src/services/wallet/operations/onchainAmounts'
/** cashu-ts hands `fee_reserve` over as an Amount object, not a number. */
const amount = (n: number) => ({toNumber: () => n})
describe('normalizeFeeOptions', () => {
it('unwraps cashu-ts Amount objects into plain numbers', () => {
const options = normalizeFeeOptions([
{fee_index: 0, fee_reserve: amount(400), estimated_blocks: 6},
])
expect(options).toEqual([{feeIndex: 0, feeReserve: 400, estimatedBlocks: 6}])
})
it('accepts plain numbers too', () => {
const options = normalizeFeeOptions([
{fee_index: 0, fee_reserve: 400, estimated_blocks: 6},
])
expect(options[0].feeReserve).toBe(400)
})
it('sorts cheapest first', () => {
const options = normalizeFeeOptions([
{fee_index: 0, fee_reserve: amount(2100), estimated_blocks: 1},
{fee_index: 1, fee_reserve: amount(400), estimated_blocks: 6},
{fee_index: 2, fee_reserve: amount(900), estimated_blocks: 3},
])
expect(options.map(o => o.feeReserve)).toEqual([400, 900, 2100])
})
// fee_index is the mint's IDENTIFIER for a tier, not its rank. A mint is free to
// hand back the expensive tier as fee_index 0 — selecting by position without
// sorting first would then pick the most expensive option as the "cheap" default.
it('does not assume fee_index encodes the ranking', () => {
const options = normalizeFeeOptions([
{fee_index: 7, fee_reserve: amount(2100), estimated_blocks: 1},
{fee_index: 3, fee_reserve: amount(400), estimated_blocks: 6},
])
expect(options[0].feeIndex).toBe(3)
expect(options[0].feeReserve).toBe(400)
})
it('handles an empty list without throwing', () => {
expect(normalizeFeeOptions([])).toEqual([])
})
})
describe('selectDefaultFeeOption', () => {
const tiers = (...reserves: number[]) =>
normalizeFeeOptions(
reserves.map((r, i) => ({
fee_index: i,
fee_reserve: amount(r),
estimated_blocks: reserves.length - i,
})),
)
// The CDK fakewallet returns exactly one option. The picker must not ask the user
// to choose from a list of one.
it('returns the only option when the mint offers one tier', () => {
const selected = selectDefaultFeeOption(tiers(400))
expect(selected?.feeReserve).toBe(400)
})
it('picks the middle tier when there is a true middle', () => {
expect(selectDefaultFeeOption(tiers(400, 900, 2100))?.feeReserve).toBe(900)
expect(selectDefaultFeeOption(tiers(100, 200, 300, 400, 500))?.feeReserve).toBe(300)
})
// With an even count there is no true middle. Round DOWN: the user can always
// choose to pay more, but a wallet must never round a fee up on their behalf.
it('rounds to the cheaper side when there is no true middle', () => {
expect(selectDefaultFeeOption(tiers(400, 2100))?.feeReserve).toBe(400)
expect(selectDefaultFeeOption(tiers(100, 200, 300, 400))?.feeReserve).toBe(200)
})
it('is undefined when the mint returned no tiers', () => {
// NUT-30 forbids this ("The mint MUST return at least one fee_options item"),
// so callers treat it as a broken quote rather than inventing a fee.
expect(selectDefaultFeeOption([])).toBeUndefined()
})
})
describe('findFeeOption', () => {
const options = normalizeFeeOptions([
{fee_index: 7, fee_reserve: amount(2100), estimated_blocks: 1},
{fee_index: 3, fee_reserve: amount(400), estimated_blocks: 6},
])
it('looks a tier up by the mint\'s fee_index, not by position', () => {
expect(findFeeOption(options, 7)?.feeReserve).toBe(2100)
expect(findFeeOption(options, 3)?.feeReserve).toBe(400)
})
it('is undefined for a fee_index the mint never offered', () => {
// The mint MUST reject a melt with an unoffered fee_index, so catching it here
// saves a round-trip and a burned quote.
expect(findFeeOption(options, 0)).toBeUndefined()
})
})
describe('onchainMeltFloor', () => {
it('applies our own floor when the mint asks for less', () => {
expect(onchainMeltFloor('sat', 1)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
expect(onchainMeltFloor('sat', 546)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
})
it('defers to the mint when it asks for more', () => {
expect(onchainMeltFloor('sat', 50000)).toBe(50000)
})
it('applies our floor when the mint advertises nothing usable', () => {
expect(onchainMeltFloor('sat')).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
expect(onchainMeltFloor('sat', 0)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
expect(onchainMeltFloor('sat', null)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
})
// The floor is denominated in sats, so it means nothing for other units.
it('defers entirely to the mint for non-sat units', () => {
expect(onchainMeltFloor('usd', 5)).toBe(5)
expect(onchainMeltFloor('usd')).toBe(0)
})
it('clears every script type\'s dust limit', () => {
// P2PKH dust is 546, P2WSH 330. An output below that is unspendable.
expect(MINIBITS_ONCHAIN_MELT_FLOOR_SAT).toBeGreaterThan(546)
})
})
describe('mockOnchainFeeTiers (debug-only picker mock)', () => {
const realTier = normalizeFeeOptions([
{fee_index: 0, fee_reserve: amount(400), estimated_blocks: 6},
])
it('turns the fakewallet\'s single tier into three', () => {
const tiers = mockOnchainFeeTiers(realTier)
expect(tiers).toHaveLength(3)
expect(tiers.map(t => t.feeReserve)).toEqual([160, 400, 1000])
})
/**
* The load-bearing property. NUT-30: "The mint MUST reject a melt request with a
* fee_index that was not returned in the quote." So the mint's own tier has to survive
* the mock intact — it is the only index that can actually be paid with.
*/
it('keeps the mint\'s real tier untouched, and marks only the invented ones', () => {
const tiers = mockOnchainFeeTiers(realTier)
const real = tiers.filter(t => !t.isMock)
expect(real).toHaveLength(1)
expect(real[0]).toEqual({feeIndex: 0, feeReserve: 400, estimatedBlocks: 6, isMock: false})
expect(tiers.filter(t => t.isMock)).toHaveLength(2)
})
it('gives invented tiers indices that cannot collide with the mint\'s', () => {
const tiers = mockOnchainFeeTiers(realTier)
const indices = tiers.map(t => t.feeIndex)
expect(new Set(indices).size).toBe(3)
for (const mock of tiers.filter(t => t.isMock)) {
expect(mock.feeIndex).not.toBe(0)
}
})
// Overwriting tiers a mint really sent with invented ones would be worse than useless.
it('is a no-op when the mint already offered more than one tier', () => {
const realTiers = normalizeFeeOptions([
{fee_index: 0, fee_reserve: amount(400), estimated_blocks: 6},
{fee_index: 1, fee_reserve: amount(900), estimated_blocks: 3},
])
expect(mockOnchainFeeTiers(realTiers)).toBe(realTiers)
})
it('is a no-op on an empty list', () => {
expect(mockOnchainFeeTiers([])).toEqual([])
})
})
describe('payableFeeIndex', () => {
const tiers = mockOnchainFeeTiers(
normalizeFeeOptions([{fee_index: 3, fee_reserve: amount(400), estimated_blocks: 6}]),
)
it('is the identity for a real tier', () => {
const real = tiers.find(t => !t.isMock)!
expect(payableFeeIndex(tiers, real)).toBe(3)
})
/**
* Selecting a fabricated tier must still submit an index the mint offered, or the melt
* is rejected outright. The CHOICE is mocked; the payment is real, at the mint's real
* price.
*/
it('falls back to the mint\'s real index for a fabricated tier', () => {
for (const mock of tiers.filter(t => t.isMock)) {
expect(payableFeeIndex(tiers, mock)).toBe(3)
}
})
it('is undefined when there is no real tier to fall back to', () => {
const orphan = {feeIndex: 99, feeReserve: 1, estimatedBlocks: 1, isMock: true}
expect(payableFeeIndex([orphan], orphan)).toBeUndefined()
})
})
describe('onchainMeltTotal', () => {
it('is amount + fee reserve + input fee, per NUT-30', () => {
expect(onchainMeltTotal(10000, 400, 2)).toBe(10402)
})
it('treats the input fee as optional', () => {
expect(onchainMeltTotal(10000, 400)).toBe(10400)
})
})

View File

@@ -0,0 +1,211 @@
/**
* Onchain (NUT-30) mint quotes, against the REAL repo and a real database.
*
* The watch rule is what these tests are really about. Two facts make it subtle:
*
* - the mint returns `expiry: null`, so a deposit address never dies 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 — "is there money credited that we have not minted" is the right one.
*
* Getting this wrong loses money in one direction (abandon a quote holding funds)
* or polls forever in the other.
*
* Calls the production `Database.*` functions rather than mirroring their SQL: the
* op-sqlite jest mock backs the real driver seam with node:sqlite. Dates are
* anchored to REAL now, because the real watch query compares against `new Date()`.
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {Database} from '../src/services/db'
const MINT = 'https://mint.test'
const MINT_ID = 'mint1111'
const daysFromNow = (n: number) => new Date(Date.now() + n * 86400000).toISOString()
const addQuote = (q: {
quote: string
counterIndex: number
amountRequested?: number | null
amountPaid?: number
amountIssued?: number
watchUntil?: string
mintId?: string
}) =>
Database.addOnchainMintQuote({
quote: q.quote,
mintId: q.mintId ?? MINT_ID,
mintUrl: MINT,
unit: 'sat',
address: 'bc1q' + q.quote,
counterIndex: q.counterIndex,
pubkey: '02' + 'a'.repeat(64),
amountRequested: q.amountRequested ?? null,
amountPaid: q.amountPaid,
amountIssued: q.amountIssued,
expiry: null, // the mint returns null — the address never dies
watchUntil: q.watchUntil,
})
const watchedIds = () =>
Database.getWatchedOnchainMintQuotes()
.map(r => r.quote)
.sort()
beforeEach(() => {
Database.getInstance().executeBatch([['DELETE FROM onchain_mint_quotes']])
})
describe('onchain mint quotes', () => {
describe('the watch rule', () => {
it('watches a fresh unpaid quote (window still open)', () => {
addQuote({quote: 'q1', counterIndex: 0})
expect(watchedIds()).toEqual(['q1'])
})
it('stops watching an unpaid quote once the window closes', () => {
// nothing ever arrived, and the mint will never expire the address for us —
// so the wallet has to draw the line itself
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
expect(watchedIds()).toEqual([])
})
it('watches a quote with credited-but-unminted funds', () => {
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0})
expect(watchedIds()).toEqual(['q1'])
})
it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => {
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
expect(watchedIds()).toEqual([])
})
it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => {
// The reason the rule keys on the unminted BALANCE and not on "has ever
// minted". Keying on amountIssued > 0 would archive this and abandon 20k sat
// the mint is holding for us.
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000})
expect(watchedIds()).toEqual(['q1'])
})
it('watches credited funds even after the window has closed', () => {
// the deadline bounds quotes nobody paid; it must never override money that
// is actually sitting at the mint
addQuote({
quote: 'q1',
counterIndex: 0,
amountPaid: 50000,
amountIssued: 0,
watchUntil: daysFromNow(-30),
})
expect(watchedIds()).toEqual(['q1'])
})
it('separates watched from archived across a realistic mix', () => {
addQuote({quote: 'fresh', counterIndex: 0})
addQuote({quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000})
addQuote({quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)})
addQuote({quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0})
addQuote({quote: 'partly-drained', counterIndex: 4, amountPaid: 30000, amountIssued: 10000})
expect(watchedIds()).toEqual(['fresh', 'has-funds', 'partly-drained'])
})
})
describe('amount updates are monotonic', () => {
it('applies a normal forward update', () => {
addQuote({quote: 'q1', counterIndex: 0})
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0)
expect(Database.getOnchainMintQuote('q1')).toMatchObject({amountPaid: 50000, amountIssued: 0})
})
it('ignores a stale response that would walk amounts BACKWARDS', () => {
// A slow reply landing after a fresh one must not resurrect an old view. If
// amountIssued regressed, the wallet would see unminted money that is not
// there — and mint it a second time.
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0) // stale: "nothing issued yet"
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
amountPaid: 50000,
amountIssued: 50000,
})
expect(watchedIds()).toEqual([]) // still archived, not resurrected
})
it('records a second deposit arriving on the same address', () => {
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
expect(watchedIds()).toEqual([])
Database.updateOnchainMintQuoteAmounts('q1', 70000, 50000) // paid again
expect(watchedIds()).toEqual(['q1']) // unminted balance -> back in the watch set
})
})
describe('archived quotes stay recoverable', () => {
it('keeps the row, and with it the NUT-20 counterIndex', () => {
// counterIndex is the only way to re-derive the key that signs a mint request
// for this quote. Delete the row and a late deposit becomes permanently
// unspendable.
addQuote({quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000})
expect(watchedIds()).toEqual([]) // archived
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
counterIndex: 42,
address: 'bc1qq1',
})
})
it('re-opens the watch window on user request', () => {
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
expect(watchedIds()).toEqual([])
Database.extendOnchainMintQuoteWatch('q1', 7) // "check for deposits"
expect(watchedIds()).toEqual(['q1'])
})
})
describe('the requested amount is only a hint', () => {
it('keeps amountRequested separate from what was actually paid', () => {
// The BIP21 amount is a suggestion the sender can ignore. Under- and
// overpayment are normal, so the two must never be conflated.
addQuote({quote: 'q1', counterIndex: 0, amountRequested: 50000})
Database.updateOnchainMintQuoteAmounts('q1', 42000, 0) // sender underpaid
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
amountRequested: 50000,
amountPaid: 42000,
})
})
it('allows a null requested amount', () => {
addQuote({quote: 'q1', counterIndex: 0, amountRequested: null})
expect(Database.getOnchainMintQuote('q1')!.amountRequested).toBeNull()
})
})
describe('the mint reference', () => {
it('finds a mint\'s quotes by id, not by the url they were created at', () => {
addQuote({quote: 'q1', counterIndex: 0})
addQuote({quote: 'q2', counterIndex: 1, mintId: 'other-mint'})
expect(Database.getOnchainMintQuotesByMintId(MINT_ID).map(r => r.quote)).toEqual(['q1'])
})
it('keeps mintUrl as the record of where the quote was created', () => {
addQuote({quote: 'q1', counterIndex: 0})
const row = Database.getOnchainMintQuote('q1')!
expect(row.mintId).toBe(MINT_ID)
expect(row.mintUrl).toBe(MINT)
})
})
})

View File

@@ -0,0 +1,127 @@
/**
* Onchain (NUT-30) mint-amount arithmetic.
*
* Same split as the other operation-lifecycle tests: jest pins what it can verify
* without bringing up MST stores / cashu-ts / SQLite, and runtime orchestration is
* verified on device.
*
* What IS worth pinning here is the arithmetic, because it decides how much money
* to mint. Both functions exist to refuse a mint response we did not expect — a
* negative mintable balance, or a deposit larger than the mint will issue in one
* operation — rather than passing it into a mint request.
*
* @jest-environment node
*/
import {
buildBip21Uri,
capMintAmount,
mintableAmount,
onchainTopupFloor,
MINIBITS_ONCHAIN_FLOOR_SAT,
} from '../src/services/wallet/operations/onchainAmounts'
describe('mintableAmount', () => {
it('is what the mint has credited but not yet issued', () => {
expect(mintableAmount(50000, 0)).toBe(50000)
})
it('is zero for a fully drained quote', () => {
expect(mintableAmount(50000, 50000)).toBe(0)
})
it('is the remainder for a partially drained quote', () => {
expect(mintableAmount(70000, 50000)).toBe(20000)
})
it('is zero before any deposit confirms', () => {
expect(mintableAmount(0, 0)).toBe(0)
})
it('clamps to zero if a mint ever reports issued > paid', () => {
// Should not happen. But an unclamped negative would flow into a mint
// request as a negative amount, so refuse it explicitly rather than by
// accident.
expect(mintableAmount(10000, 20000)).toBe(0)
})
})
describe('onchainTopupFloor', () => {
it('takes the wallet floor when the mint asks for less', () => {
// The CDK test mint really did advertise min_amount: 1 — below Bitcoin's own
// 546-sat dust limit. NUT-30 evaluates min_amount PER UTXO and says such a
// deposit is "not recoverable through the mint quote protocol", i.e. the money
// is gone. So the mint's number can never lower our bar.
expect(onchainTopupFloor('sat', 1)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
})
it('takes the mint floor when it is higher than ours', () => {
expect(onchainTopupFloor('sat', 50000)).toBe(50000)
})
it('falls back to the wallet floor when the mint advertises none', () => {
expect(onchainTopupFloor('sat', null)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
expect(onchainTopupFloor('sat', undefined)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
expect(onchainTopupFloor('sat', 0)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
})
it('defers to the mint for non-sat units (our floor is denominated in sats)', () => {
expect(onchainTopupFloor('usd', 500)).toBe(500)
expect(onchainTopupFloor('usd', null)).toBe(0)
})
})
describe('buildBip21Uri', () => {
it('encodes the amount in BTC, not sats', () => {
expect(buildBip21Uri('bc1qtest', 50000)).toBe('bitcoin:bc1qtest?amount=0.0005')
})
it('does not emit scientific notation for small amounts', () => {
// 1000 / 1e8 is 0.00001 in JS, but smaller values reach 1e-8 and stringify as
// exponent form, which is not a valid BIP21 amount.
expect(buildBip21Uri('bc1qtest', 1)).toBe('bitcoin:bc1qtest?amount=0.00000001')
expect(buildBip21Uri('bc1qtest', 10)).toBe('bitcoin:bc1qtest?amount=0.0000001')
})
it('does not leak float error into the amount', () => {
// naive (amountSat / 1e8).toString() gives 0.000010000000000000001 here
expect(buildBip21Uri('bc1qtest', 1000)).toBe('bitcoin:bc1qtest?amount=0.00001')
})
it('trims trailing zeros but keeps a whole-BTC amount valid', () => {
expect(buildBip21Uri('bc1qtest', 100_000_000)).toBe('bitcoin:bc1qtest?amount=1')
expect(buildBip21Uri('bc1qtest', 150_000_000)).toBe('bitcoin:bc1qtest?amount=1.5')
})
it('omits the amount entirely when there is none', () => {
// A bare address is still a valid BIP21 URI, and the amount is only a hint.
expect(buildBip21Uri('bc1qtest')).toBe('bitcoin:bc1qtest')
expect(buildBip21Uri('bc1qtest', 0)).toBe('bitcoin:bc1qtest')
})
})
describe('capMintAmount', () => {
it('leaves a mintable balance under the cap alone', () => {
expect(capMintAmount(50000, 500000)).toBe(50000)
})
it('caps a deposit larger than the mint will issue in one operation', () => {
// Asking for more than max_amount fails the whole request, which would
// strand a deposit we could take in instalments. The remainder stays
// credited on the quote and the next sweep collects it.
expect(capMintAmount(750000, 500000)).toBe(500000)
})
it('is a no-op when the mint advertises no maximum', () => {
expect(capMintAmount(50000, null)).toBe(50000)
expect(capMintAmount(50000, undefined)).toBe(50000)
})
it('ignores a nonsensical zero or negative cap', () => {
expect(capMintAmount(50000, 0)).toBe(50000)
expect(capMintAmount(50000, -1)).toBe(50000)
})
it('caps exactly at the boundary', () => {
expect(capMintAmount(500000, 500000)).toBe(500000)
})
})

View File

@@ -0,0 +1,184 @@
/**
* Balance behaviour when a proof's mintUrl matches no mint (ProofsStore).
*
* `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.
*
* What must hold when that happens:
* - the sats stay VISIBLE in the unit total (they are real and the user's), and
* - the wallet reports it, rather than silently swallowing it.
*
* Hiding the money, refusing to start, or forcing a recovery would all be worse
* outcomes than a total that reads a little high, and the state self-heals once a
* mint is (re-)added at that url.
*
* This is the first suite to instantiate MST stores. It uses a MINIMAL root
* (mintsStore + proofsStore) rather than the real RootStore, which additionally
* pulls AuthStore/NwcStore/WalletProfileStore and, through them, the whole service
* layer. getRootStore only needs the root to expose the stores actually used.
*/
jest.mock('../src/services/nostrService', () => ({
// cashuUtils -> nostrService -> minibitsService -> models is an import CYCLE;
// mocking nostrService cuts it, as every other suite here does.
NostrClient: {getFirstTagValue: jest.fn()},
}))
// The real logger would work here (jest maps Sentry to a mock), but react-native-logs
// dispatches to its transport on a timer, so it logs after teardown — and a real
// `log.error` is not a spy to assert on.
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {types} from 'mobx-state-tree'
import {MintsStoreModel} from '../src/models/MintsStore'
import {ProofsStoreModel} from '../src/models/ProofsStore'
import {log} from '../src/services/logService'
// Named 'RootStore' so getRootStore's typing lines up with the real tree.
const TestRoot = types.model('RootStore', {
mintsStore: types.optional(MintsStoreModel, {}),
proofsStore: types.optional(ProofsStoreModel, {}),
})
const MINT_URL = 'https://mint.test'
const MOVED_URL = 'https://moved.mint.test'
const mintSnapshot = (mintUrl: string) => ({
id: 'mint1111',
mintUrl,
units: ['sat'],
})
const proofSnapshot = (secret: string, amount: number, mintUrl: string, state = 'UNSPENT') => ({
id: 'keyset1',
amount,
secret,
C: 'C' + secret,
unit: 'sat',
tId: 1,
mintUrl,
state,
})
const makeStore = (mintUrl: string, proofs: Array<ReturnType<typeof proofSnapshot>>) =>
TestRoot.create({
mintsStore: {mints: [mintSnapshot(mintUrl)]},
proofsStore: {proofs: Object.fromEntries(proofs.map(p => [p.secret, p]))},
})
describe('proofs whose mint is not in the wallet', () => {
describe('balances — the money stays visible', () => {
test('an attached proof counts toward BOTH its mint and the unit total', () => {
const root = makeStore(MINT_URL, [proofSnapshot('a', 100, MINT_URL)])
const {balances} = root.proofsStore
expect(balances.mintBalances[0].balances.sat).toBe(100)
expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(100)
})
// The core promise: a desync must never hide the user's sats.
test('an orphaned proof still counts toward the unit total', () => {
const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)])
const {balances} = root.proofsStore
expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(100)
})
test('but is attributed to no mint, so the total can exceed the sum of mints', () => {
const root = makeStore(MOVED_URL, [
proofSnapshot('a', 100, MOVED_URL), // attached
proofSnapshot('b', 40, MINT_URL), // orphaned by a url edit
])
const {balances} = root.proofsStore
expect(balances.mintBalances[0].balances.sat).toBe(100)
expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(140)
})
test('reading balances does not throw', () => {
const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)])
expect(() => root.proofsStore.balances).not.toThrow()
})
})
describe('findOrphanedProofs', () => {
test('is empty in the normal case', () => {
const root = makeStore(MINT_URL, [proofSnapshot('a', 100, MINT_URL)])
expect(root.proofsStore.findOrphanedProofs()).toEqual([])
})
test('groups by url, with count and amount', () => {
const root = makeStore(MOVED_URL, [
proofSnapshot('a', 100, MINT_URL),
proofSnapshot('b', 40, MINT_URL),
proofSnapshot('c', 7, MOVED_URL), // attached, not reported
])
expect(root.proofsStore.findOrphanedProofs()).toEqual([
{mintUrl: MINT_URL, count: 2, amount: 140},
])
})
// Removing a mint leaves its proofs SPENT behind. Reporting those would fire on
// every legitimate removal.
test('ignores SPENT proofs', () => {
const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL, 'SPENT')])
expect(root.proofsStore.findOrphanedProofs()).toEqual([])
})
test('includes PENDING proofs — they are still the user\'s money', () => {
const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL, 'PENDING')])
expect(root.proofsStore.findOrphanedProofs()).toEqual([
{mintUrl: MINT_URL, count: 1, amount: 100},
])
})
})
describe('reportOrphanedProofs — observe, never act', () => {
beforeEach(() => jest.clearAllMocks())
test('stays silent when everything is attached', () => {
const root = makeStore(MINT_URL, [proofSnapshot('a', 100, MINT_URL)])
expect(root.proofsStore.reportOrphanedProofs()).toEqual([])
expect(log.error).not.toHaveBeenCalled()
})
test('logs at error (→ Sentry) with the url and stranded amount', () => {
const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)])
const reported = root.proofsStore.reportOrphanedProofs()
expect(reported).toEqual([{mintUrl: MINT_URL, count: 1, amount: 100}])
expect(log.error).toHaveBeenCalledTimes(1)
const params = (log.error as jest.Mock).mock.calls[0][2]
expect(params.totalAmount).toBe(100)
expect(params.orphaned).toEqual([{mintUrl: MINT_URL, count: 1, amount: 100}])
})
test('changes nothing — the proofs and the balance are untouched', () => {
const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)])
root.proofsStore.reportOrphanedProofs()
// Still present, still counted: reporting must never quarantine the money.
expect(root.proofsStore.proofs.size).toBe(1)
expect(root.proofsStore.getBySecret('a')!.mintUrl).toBe(MINT_URL)
const {balances} = root.proofsStore
expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(100)
})
test('self-heals once a mint exists at that url again', () => {
const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)])
expect(root.proofsStore.findOrphanedProofs()).toHaveLength(1)
// The rename is completed (or reverted) and the mint answers there again.
root.mintsStore.mints[0].setProp('mintUrl', MINT_URL)
expect(root.proofsStore.findOrphanedProofs()).toEqual([])
expect(root.proofsStore.balances.mintBalances[0].balances.sat).toBe(100)
})
})
})

View File

@@ -1,110 +1,33 @@
/**
* Proof reservation tests (Phase 5).
* Proof reservations, against the REAL repo and a real database.
*
* Verifies the SQL-level reservation semantics that `Database.openReservation`,
* `Database.commitReservation`, `Database.rollbackReservation`, and
* `Database.getOpenReservations` implement on top of `executeBatch`.
* A reservation is the wallet's atomic-commit primitive for an outgoing operation:
* open it to lock proofs to PENDING, then either commit (inputs SPENT, new proofs
* in, transaction row updated, reservation deleted) or roll back (every proof
* restored to its pre-reserve state AND tId). All of it in one SQLite transaction,
* because a partial apply here loses or strands ecash.
*
* We mirror the queries using node:sqlite + explicit BEGIN/COMMIT so the test
* can run in Jest (react-native-quick-sqlite needs a real device).
* This calls the production `Database.*` functions. It used to hand-copy both the
* schema and every statement — and a copy of the SQL proves nothing about the SQL
* the app runs. The op-sqlite jest mock now backs the real driver seam with
* node:sqlite, so connection.ts (param sanitizing, result adaptation, BEGIN/COMMIT
* batch emulation), instance.ts (schema + the real migration runner) and the repo
* all run for real.
*
* @jest-environment node
* The helpers below keep their original shapes so the assertions read unchanged;
* only their innards moved from mirrored SQL to the real API.
*/
import {DatabaseSync} from 'node:sqlite'
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
// ── Schema ────────────────────────────────────────────────────────────────────
import {Database} from '../src/services/db'
import {ProofModel} from '../src/models/Proof'
const CREATE_PROOFS = `CREATE TABLE proofs (
id TEXT NOT NULL,
amount INTEGER NOT NULL,
secret TEXT PRIMARY KEY NOT NULL,
C TEXT NOT NULL,
dleq_r TEXT,
dleq_s TEXT,
dleq_e TEXT,
unit TEXT,
tId INTEGER,
mintUrl TEXT,
state TEXT NOT NULL DEFAULT 'UNSPENT',
updatedAt TEXT
)`
const MINT = 'https://mint.test'
const MINT_ID = 'mint1111'
const CREATE_RESERVATIONS = `CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
createdAt TEXT NOT NULL
)`
// Minimal transactions table for the two-table atomicity tests (Phase 5b).
const CREATE_TRANSACTIONS = `CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
status TEXT,
data TEXT,
amount INTEGER,
fee INTEGER,
balanceAfter INTEGER,
outputToken TEXT,
keysetId TEXT,
proof TEXT
)`
function createSchema(db: DatabaseSync) {
db.exec(CREATE_PROOFS)
db.exec(CREATE_RESERVATIONS)
db.exec(CREATE_TRANSACTIONS)
}
function insertTransaction(db: DatabaseSync, id: number, status: string) {
db.prepare(`INSERT INTO transactions (id, status) VALUES (?, ?)`).run(id, status)
}
function getTransactionStatus(db: DatabaseSync, id: number): string {
const row = db.prepare('SELECT status FROM transactions WHERE id = ?').get(id) as
| {status: string}
| undefined
return row?.status ?? ''
}
function getTransactionRow(
db: DatabaseSync,
id: number,
): {status: string | null; data: string | null; balanceAfter: number | null} | undefined {
return db
.prepare('SELECT status, data, balanceAfter FROM transactions WHERE id = ?')
.get(id) as any
}
function insertProof(
db: DatabaseSync,
secret: string,
amount: number,
state: 'UNSPENT' | 'PENDING' | 'SPENT' = 'UNSPENT',
) {
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', ?, ?, 'C', 'https://mint.test', 'sat', 1, ?, '2026-01-01')`,
).run(amount, secret, state)
}
function getProofState(db: DatabaseSync, secret: string): string {
const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as
| {state: string}
| undefined
return row?.state ?? ''
}
function reservationCount(db: DatabaseSync): number {
const {n} = db.prepare('SELECT COUNT(*) AS n FROM reservations').get() as {n: number}
return n
}
// ── Simulated Database primitives ─────────────────────────────────────────────
// Mirror the exact SQL the production code uses, wrapped in BEGIN/COMMIT to
// match `executeBatch` atomicity.
type Db = ReturnType<typeof Database.getInstance>
type LockedProofSnapshot = {
secret: string
@@ -112,47 +35,6 @@ type LockedProofSnapshot = {
originalTId: number | null
}
function openReservation(
db: DatabaseSync,
reservation: {
id: string
transactionId: number
mintUrl: string
unit: string
operationType: string
lockedProofs: LockedProofSnapshot[]
},
proofsToLockSecrets: string[],
) {
const now = '2026-05-22T00:00:00.000Z'
db.exec('BEGIN')
try {
db.prepare(
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
reservation.id,
reservation.transactionId,
reservation.mintUrl,
reservation.unit,
reservation.operationType,
JSON.stringify(reservation.lockedProofs),
now,
)
// Reassign tId to the new operation alongside the state lock.
const updateProof = db.prepare(
`UPDATE proofs SET state = 'PENDING', tId = ?, updatedAt = ? WHERE secret = ?`,
)
for (const secret of proofsToLockSecrets) {
updateProof.run(reservation.transactionId, now, secret)
}
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
type CommitTransactionUpdate = {
id: number
status?: string
@@ -165,116 +47,144 @@ type CommitTransactionUpdate = {
proof?: string
}
/**
* One in-memory database per test FILE (instance.ts caches its connection), so
* "fresh" means cleared rather than rebuilt. Returns the real connection for the
* few assertions that read raw rows.
*/
function freshDb(): Db {
const db = Database.getInstance()
db.executeBatch([['DELETE FROM proofs'], ['DELETE FROM reservations'], ['DELETE FROM transactions']])
return db
}
function insertTransaction(_db: Db, id: number, status: string) {
Database.getInstance().execute(`INSERT INTO transactions (id, status) VALUES (?, ?)`, [id, status])
}
function getTransactionStatus(_db: Db, id: number): string {
return (
Database.getInstance().execute('SELECT status FROM transactions WHERE id = ?', [id]).rows?.item(0)
?.status ?? ''
)
}
function getTransactionRow(
_db: Db,
id: number,
): {status: string | null; data: string | null; balanceAfter: number | null} | undefined {
return Database.getInstance()
.execute('SELECT status, data, balanceAfter FROM transactions WHERE id = ?', [id])
.rows?.item(0) as any
}
function insertProof(
_db: Db,
secret: string,
amount: number,
state: 'UNSPENT' | 'PENDING' | 'SPENT' = 'UNSPENT',
) {
Database.addOrUpdateProofs([proofNode(secret, amount)], state)
}
/**
* A real MST Proof node.
*
* The repo calls mobx-state-tree's `isAlive()` on everything it locks, which only
* answers for an actual node — so a plain object here would not exercise the same
* path production takes.
*/
function proofNode(secret: string, amount: number, tId = 1) {
return ProofModel.create({
id: 'keyset1',
amount,
secret,
C: 'C',
unit: 'sat',
tId,
mintUrl: MINT,
}) as any
}
function getProofState(_db: Db, secret: string): string {
return (
Database.getInstance().execute('SELECT state FROM proofs WHERE secret = ?', [secret]).rows?.item(0)
?.state ?? ''
)
}
function getProofTId(_db: Db, secret: string): number | null {
const row = Database.getInstance()
.execute('SELECT tId FROM proofs WHERE secret = ?', [secret])
.rows?.item(0)
return row?.tId ?? null
}
function reservationCount(_db: Db): number {
return Database.getInstance().execute('SELECT COUNT(*) AS n FROM reservations').rows?.item(0)?.n ?? 0
}
/** Rebuild MST nodes for already-stored proofs, which is what the repo locks. */
function nodesForSecrets(secrets: string[]) {
return secrets.map(secret => {
const row = Database.getInstance()
.execute('SELECT * FROM proofs WHERE secret = ?', [secret])
.rows?.item(0)
return proofNode(secret, row?.amount ?? 1, row?.tId ?? 1)
})
}
function openReservation(
_db: Db,
reservation: {
id: string
transactionId: number
mintUrl: string
unit: string
operationType: string
lockedProofs: LockedProofSnapshot[]
},
proofsToLockSecrets: string[],
) {
Database.openReservation(
{...reservation, mintId: MINT_ID},
nodesForSecrets(proofsToLockSecrets),
)
}
function commitReservation(
db: DatabaseSync,
_db: Db,
reservationId: string,
changes: {
toSpent?: string[]
toUnspent?: string[]
newProofs?: Array<{
secret: string
amount: number
state: 'UNSPENT' | 'PENDING' | 'SPENT'
}>
newProofs?: Array<{secret: string; amount: number; state: 'UNSPENT' | 'PENDING' | 'SPENT'}>
transactionUpdate?: CommitTransactionUpdate
},
) {
const now = '2026-05-22T00:00:00.000Z'
db.exec('BEGIN')
try {
const updateSpent = db.prepare(
`UPDATE proofs SET state = 'SPENT', updatedAt = ? WHERE secret = ?`,
)
for (const s of changes.toSpent ?? []) updateSpent.run(now, s)
const updateUnspent = db.prepare(
`UPDATE proofs SET state = 'UNSPENT', updatedAt = ? WHERE secret = ?`,
)
for (const s of changes.toUnspent ?? []) updateUnspent.run(now, s)
const insertNew = db.prepare(
`INSERT OR REPLACE INTO proofs
(id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', ?, ?, 'C', 'https://mint.test', 'sat', 1, ?, ?)`,
)
for (const p of changes.newProofs ?? []) {
insertNew.run(p.amount, p.secret, p.state, now)
}
// Mirror the SQL the production code builds: dynamic UPDATE with only
// the supplied fields.
if (changes.transactionUpdate) {
const tu = changes.transactionUpdate
const setClauses: string[] = []
const params: (string | number | null)[] = []
const setIfDefined = (col: string, value: string | number | undefined) => {
if (value !== undefined) {
setClauses.push(`${col} = ?`)
params.push(value)
}
}
setIfDefined('status', tu.status)
setIfDefined('data', tu.data)
setIfDefined('amount', tu.amount)
setIfDefined('fee', tu.fee)
setIfDefined('balanceAfter', tu.balanceAfter)
setIfDefined('outputToken', tu.outputToken)
setIfDefined('keysetId', tu.keysetId)
setIfDefined('proof', tu.proof)
if (setClauses.length > 0) {
params.push(tu.id)
db.prepare(
`UPDATE transactions SET ${setClauses.join(', ')} WHERE id = ?`,
).run(...params)
}
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
Database.commitReservation(reservationId, {
toSpent: changes.toSpent ? nodesForSecrets(changes.toSpent) : undefined,
toUnspent: changes.toUnspent ? nodesForSecrets(changes.toUnspent) : undefined,
newProofs: changes.newProofs?.map(p => ({
proofs: [{id: 'keyset1', amount: p.amount, secret: p.secret, C: 'C'} as any],
state: p.state,
mintUrl: MINT,
unit: 'sat',
tId: 1,
})),
transactionUpdate: changes.transactionUpdate as any,
})
}
function rollbackReservation(
db: DatabaseSync,
reservationId: string,
lockedProofs: LockedProofSnapshot[],
) {
const now = '2026-05-22T00:00:00.000Z'
db.exec('BEGIN')
try {
// Restore BOTH state and tId from the pre-reserve snapshot.
const restore = db.prepare(
`UPDATE proofs SET state = ?, tId = ?, updatedAt = ? WHERE secret = ?`,
)
for (const snap of lockedProofs) {
restore.run(snap.originalState, snap.originalTId, now, snap.secret)
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
function rollbackReservation(_db: Db, reservationId: string, lockedProofs: LockedProofSnapshot[]) {
Database.rollbackReservation(reservationId, lockedProofs as any)
}
function getProofTId(db: DatabaseSync, secret: string): number | null {
const row = db.prepare('SELECT tId FROM proofs WHERE secret = ?').get(secret) as
| {tId: number | null}
| undefined
return row?.tId ?? null
}
function getOpenReservations(db: DatabaseSync): Array<{
id: string
lockedProofs: LockedProofSnapshot[]
}> {
const rows = db
.prepare('SELECT id, lockedProofs FROM reservations')
.all() as Array<{id: string; lockedProofs: string}>
return rows.map(r => ({id: r.id, lockedProofs: JSON.parse(r.lockedProofs)}))
function getOpenReservations(_db: Db): Array<{id: string; lockedProofs: LockedProofSnapshot[]}> {
return Database.getOpenReservations().map(r => ({
id: r.id,
lockedProofs: r.lockedProofs as LockedProofSnapshot[],
}))
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -282,8 +192,7 @@ function getOpenReservations(db: DatabaseSync): Array<{
describe('Proof reservations', () => {
describe('openReservation', () => {
test('atomically inserts reservation row + locks proofs to PENDING', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'sA', 100)
insertProof(db, 'sB', 200)
@@ -306,13 +215,10 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'sA')).toBe('PENDING')
expect(getProofState(db, 'sB')).toBe('PENDING')
expect(reservationCount(db)).toBe(1)
db.close()
})
test('captures originalState even when some proofs were already PENDING', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'sA', 100, 'UNSPENT')
insertProof(db, 'sB', 200, 'PENDING') // already locked by an earlier op
@@ -338,15 +244,12 @@ describe('Proof reservations', () => {
{secret: 'sA', originalState: 'UNSPENT', originalTId: null},
{secret: 'sB', originalState: 'PENDING', originalTId: null},
])
db.close()
})
})
describe('commitReservation', () => {
test('marks inputs SPENT, adds new proofs, deletes reservation in one txn', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'input1', 100)
insertProof(db, 'input2', 200)
@@ -379,13 +282,10 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'change1')).toBe('UNSPENT')
expect(getProofState(db, 'send1')).toBe('PENDING')
expect(reservationCount(db)).toBe(0)
db.close()
})
test('empty changes still removes the reservation row (offline-send case)', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 's1', 100)
openReservation(
@@ -406,15 +306,12 @@ describe('Proof reservations', () => {
// Proof stays PENDING (sent offline), reservation row gone
expect(getProofState(db, 's1')).toBe('PENDING')
expect(reservationCount(db)).toBe(0)
db.close()
})
})
describe('rollbackReservation', () => {
test('restores each proof to its originalState and deletes the row', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'sA', 100, 'UNSPENT')
insertProof(db, 'sB', 200, 'UNSPENT')
@@ -444,13 +341,10 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'sA')).toBe('UNSPENT')
expect(getProofState(db, 'sB')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0)
db.close()
})
test('preserves PENDING originalState (multi-op overlap)', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'sA', 100, 'PENDING')
// Reservation captures sA as already-PENDING
@@ -471,15 +365,12 @@ describe('Proof reservations', () => {
// Restored to PENDING (its original locked state), not to UNSPENT
expect(getProofState(db, 'sA')).toBe('PENDING')
db.close()
})
})
describe('orphan recovery', () => {
test('getOpenReservations returns all rows; rollback restores state', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'orphanA', 100)
insertProof(db, 'orphanB', 200)
@@ -513,22 +404,17 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'orphanA')).toBe('UNSPENT')
expect(getProofState(db, 'orphanB')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0)
db.close()
})
test('no orphans when there are no in-flight reservations', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 's1', 100)
expect(getOpenReservations(db)).toEqual([])
db.close()
})
test('multiple concurrent reservations all roll back independently', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'a1', 100)
insertProof(db, 'a2', 100)
insertProof(db, 'b1', 200)
@@ -572,15 +458,12 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'a2')).toBe('UNSPENT')
expect(getProofState(db, 'b1')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0)
db.close()
})
})
describe('atomicity', () => {
test('openReservation: inserting a duplicate id rolls back the whole batch', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 's1', 100, 'UNSPENT')
// First reservation succeeds
@@ -624,8 +507,6 @@ describe('Proof reservations', () => {
expect(getProofState(db, 's2')).toBe('UNSPENT')
// s1 is unaffected
expect(getProofState(db, 's1')).toBe('PENDING')
db.close()
})
})
@@ -645,19 +526,14 @@ describe('Proof reservations', () => {
// ─────────────────────────────────────────────────────────────────────
describe('tId propagation (regression: stuck-PENDING SEND, 2026-05-22)', () => {
test('openReservation reassigns each locked proof tId to the new transactionId', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
// Two proofs received originally by tx 110 and tx 123 respectively
// (simulates the dev log).
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`,
).run()
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`,
).run()
db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`)
db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`)
expect(getProofTId(db, 'inp_a')).toBe(110)
expect(getProofTId(db, 'inp_b')).toBe(123)
@@ -685,21 +561,14 @@ describe('Proof reservations', () => {
expect(getProofTId(db, 'inp_b')).toBe(157)
expect(getProofState(db, 'inp_a')).toBe('PENDING')
expect(getProofState(db, 'inp_b')).toBe('PENDING')
db.close()
})
test('rollback restores each proof to its individual originalTId', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`,
).run()
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`,
).run()
const db = freshDb()
db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`)
db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`)
openReservation(
db,
@@ -730,19 +599,14 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'inp_a')).toBe('UNSPENT')
expect(getProofState(db, 'inp_b')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0)
db.close()
})
test('proofs with no prior tId (null) are reassigned and restored to null', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
// Proof inserted without a tId — simulates an imported/restored
// proof that was never tied to a wallet transaction.
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, state, updatedAt)
VALUES ('keyset1', 1, 'orphan', 'C', 'https://mint.test', 'sat', 'UNSPENT', '2026-01-01')`,
).run()
db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, state, updatedAt)
VALUES ('keyset1', 1, 'orphan', 'C', 'https://mint.test', 'sat', 'UNSPENT', '2026-01-01')`)
expect(getProofTId(db, 'orphan')).toBe(null)
openReservation(
@@ -765,8 +629,6 @@ describe('Proof reservations', () => {
{secret: 'orphan', originalState: 'UNSPENT', originalTId: null},
])
expect(getProofTId(db, 'orphan')).toBe(null)
db.close()
})
})
@@ -780,8 +642,7 @@ describe('Proof reservations', () => {
// ─────────────────────────────────────────────────────────────────────
describe('atomic two-table commit (Phase 5b)', () => {
test('commit with transactionUpdate writes proofs AND transaction in one txn', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'input', 100)
insertTransaction(db, 200, 'PREPARED')
@@ -816,13 +677,10 @@ describe('Proof reservations', () => {
expect(row.data).toBe('[{"status":"COMPLETED"}]')
expect(row.balanceAfter).toBe(50)
expect(reservationCount(db)).toBe(0)
db.close()
})
test('a failed commit batch rolls back BOTH proof and transaction writes', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'input', 100)
insertTransaction(db, 201, 'PREPARED')
@@ -845,13 +703,11 @@ describe('Proof reservations', () => {
expect(() => {
db.exec('BEGIN')
try {
db.prepare(`UPDATE proofs SET state = 'SPENT' WHERE secret = ?`).run('input')
db.prepare(`UPDATE transactions SET status = 'COMPLETED' WHERE id = ?`).run(201)
db.execute(`UPDATE proofs SET state = 'SPENT' WHERE secret = ?`, ['input'])
db.execute(`UPDATE transactions SET status = 'COMPLETED' WHERE id = ?`, [201])
// This will conflict — reservation 'res-atomic' already exists
db.prepare(
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES ('res-atomic', 999, '', '', '', '[]', '')`,
).run()
db.execute(`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES ('res-atomic', 999, '', '', '', '[]', '')`)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
@@ -864,13 +720,10 @@ describe('Proof reservations', () => {
// statement in the batch fails, SQLite rolls back the entire txn.
expect(getProofState(db, 'input')).toBe('PENDING') // still locked
expect(getTransactionStatus(db, 201)).toBe('PREPARED') // still pre-finalize
db.close()
})
test('commit without transactionUpdate leaves transactions table untouched', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'p1', 100)
insertTransaction(db, 202, 'PENDING')
@@ -893,18 +746,13 @@ describe('Proof reservations', () => {
// Transaction status untouched — the caller is responsible for
// any post-commit updates that don't need atomicity.
expect(getTransactionStatus(db, 202)).toBe('PENDING')
db.close()
})
test('partial transactionUpdate only sets the provided columns', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
const db = freshDb()
insertProof(db, 'p2', 100)
db.prepare(
`INSERT INTO transactions (id, status, data, balanceAfter)
VALUES (203, 'PREPARED', 'old-data', 999)`,
).run()
db.execute(`INSERT INTO transactions (id, status, data, balanceAfter)
VALUES (203, 'PREPARED', 'old-data', 999)`)
openReservation(
db,
@@ -929,8 +777,6 @@ describe('Proof reservations', () => {
expect(row.status).toBe('REVERTED')
expect(row.data).toBe('old-data')
expect(row.balanceAfter).toBe(999)
db.close()
})
})
})

View File

@@ -7,39 +7,20 @@
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {DatabaseSync} from 'node:sqlite'
import {MIGRATIONS} from '../src/services/db/migrations'
// ── SQL copied verbatim from src/services/sqlite.ts migration 25 ──────────────
// ── The REAL migration, taken from the registry ─────────────────────────────
//
// Imported rather than copied: a copy of the SQL proves nothing about the SQL that
// actually runs on a device. The PRE-migration shape below stays hand-written on
// purpose — it is frozen history, and must not track today's schema.
const CREATE_V25 = `CREATE TABLE proofs_v25 (
id TEXT NOT NULL,
amount INTEGER NOT NULL,
secret TEXT PRIMARY KEY NOT NULL,
C TEXT NOT NULL,
dleq_r TEXT,
dleq_s TEXT,
dleq_e TEXT,
unit TEXT,
tId INTEGER,
mintUrl TEXT,
state TEXT NOT NULL DEFAULT 'UNSPENT',
updatedAt TEXT
)`
const INSERT_V25 = `INSERT INTO proofs_v25
(id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
SELECT
id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl,
CASE
WHEN isSpent = 1 THEN 'SPENT'
WHEN isPending = 1 THEN 'PENDING'
ELSE 'UNSPENT'
END,
updatedAt
FROM proofs`
const DROP_OLD = `DROP TABLE proofs`
const RENAME = `ALTER TABLE proofs_v25 RENAME TO proofs`
const MIGRATION_25 = MIGRATIONS.find(m => m.version === 25)!.queries.map(([sql]) => sql)
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -79,10 +60,7 @@ function insertOldProof(
}
function runMigration25(db: DatabaseSync) {
db.exec(CREATE_V25)
db.exec(INSERT_V25)
db.exec(DROP_OLD)
db.exec(RENAME)
for (const sql of MIGRATION_25) db.exec(sql)
}
function getState(db: DatabaseSync, secret: string): string {

View File

@@ -0,0 +1,250 @@
/**
* Repeatable migration tests for SQLite migration 32.
*
* Migration 32 re-keys `mint_counters` from PRIMARY KEY (mintUrl, keysetId) to
* PRIMARY KEY (keysetId).
*
* WHY: NUT-13 derives from (seed, keysetId, counter) — the mint url is not an
* input to any derivation path. `00` ids derive at
* `m/129372'/0'/{keysetIdInt}'/{counter}'`, v2 `01` ids by HMAC-SHA256 over the
* id. So (mintUrl, keysetId) asserted a key space that does not exist, with two
* consequences this migration closes:
*
* 1. Two rows could track ONE derivation path independently — the lower row
* would hand out indices the mint had already signed against the higher one.
* 2. A mint-url edit orphaned the row: hydration matched on url, found nothing,
* and silently restarted the counter at 0 — reusing blinded secrets from the
* very beginning of the keyset.
*
* The collapse to MAX(counter) HEALS a wallet already split by (2) rather than
* merely preventing new splits: a too-high counter skips indices (harmless), a
* too-low one reuses them (fund loss).
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {DatabaseSync} from 'node:sqlite'
import {MIGRATIONS} from '../src/services/db/migrations'
// ── The REAL migration, taken from the registry ─────────────────────────────
//
// Imported rather than copied: a copy of the SQL proves nothing about the SQL that
// actually runs on a device. The PRE-migration shape below stays hand-written on
// purpose — it is frozen history, and must not track today's schema.
const MIGRATION_32 = MIGRATIONS.find(m => m.version === 32)!.queries.map(([sql]) => sql)
// ── Helpers ──────────────────────────────────────────────────────────────────
const MINT_A = 'https://mint-a.test'
const MINT_B = 'https://mint-b.test'
/** The pre-32 schema: keyed by (mintUrl, keysetId). */
function createOldSchema(db: DatabaseSync) {
db.exec(`
CREATE TABLE mint_counters (
mintUrl TEXT NOT NULL,
keysetId TEXT NOT NULL,
unit TEXT,
counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT,
PRIMARY KEY (mintUrl, keysetId)
)
`)
}
function insertOld(
db: DatabaseSync,
mintUrl: string,
keysetId: string,
unit: string | null,
counter: number,
updatedAt: string,
) {
db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)`,
).run(mintUrl, keysetId, unit, counter, updatedAt)
}
function runMigration32(db: DatabaseSync) {
db.exec('BEGIN')
try {
for (const sql of MIGRATION_32) db.exec(sql)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
type CounterRow = {keysetId: string; unit: string | null; counter: number; updatedAt: string | null}
function allRows(db: DatabaseSync): CounterRow[] {
return db
.prepare('SELECT keysetId, unit, counter, updatedAt FROM mint_counters ORDER BY keysetId')
.all() as unknown as CounterRow[]
}
function getCounter(db: DatabaseSync, keysetId: string): number | undefined {
const row = db
.prepare('SELECT counter FROM mint_counters WHERE keysetId = ?')
.get(keysetId) as {counter: number} | undefined
return row?.counter
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
createOldSchema(db)
return db
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('SQLite migration 32 — re-key mint_counters on keysetId', () => {
test('carries a single-mint wallet across unchanged', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_A, 'k2', 'sat', 7, '2026-01-02')
runMigration32(db)
expect(allRows(db)).toEqual([
{keysetId: 'k1', unit: 'sat', counter: 342, updatedAt: '2026-01-01'},
{keysetId: 'k2', unit: 'sat', counter: 7, updatedAt: '2026-01-02'},
])
db.close()
})
test('HEALS a wallet split by a mint-url edit: collapses to MAX(counter)', () => {
const db = freshDb()
// The exact damage the old key allowed: mint renamed A -> B, wallet kept
// transacting under B while A's row stayed frozen at the pre-rename value.
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 400, '2026-02-01')
runMigration32(db)
const rows = allRows(db)
expect(rows).toHaveLength(1)
// 400, never 342: skipping indices is safe, reusing them is fund loss.
expect(rows[0].counter).toBe(400)
db.close()
})
test('the surviving row takes unit/updatedAt from the MAX(counter) row', () => {
const db = freshDb()
// Bare columns in a single-aggregate GROUP BY come from the row that matched
// the aggregate (documented SQLite behaviour), so the row stays internally
// consistent rather than mixing fields across rows.
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 400, '2026-02-01')
runMigration32(db)
expect(allRows(db)[0]).toEqual({
keysetId: 'k1',
unit: 'sat',
counter: 400,
updatedAt: '2026-02-01', // the winning row's timestamp
})
db.close()
})
test('collapses a three-way split to the single highest counter', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 10, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 900, '2026-02-01')
insertOld(db, 'https://mint-c.test', 'k1', 'sat', 55, '2026-03-01')
runMigration32(db)
expect(allRows(db)).toHaveLength(1)
expect(getCounter(db, 'k1')).toBe(900)
db.close()
})
test('distinct keysets are never merged, even across mints', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 100, '2026-01-01')
insertOld(db, MINT_B, 'k2', 'sat', 200, '2026-01-01')
runMigration32(db)
expect(getCounter(db, 'k1')).toBe(100)
expect(getCounter(db, 'k2')).toBe(200)
expect(allRows(db)).toHaveLength(2)
db.close()
})
test('an empty table migrates cleanly', () => {
const db = freshDb()
runMigration32(db)
expect(allRows(db)).toEqual([])
db.close()
})
test('preserves a null unit', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', null, 42, '2026-01-01')
runMigration32(db)
expect(allRows(db)[0]).toEqual({
keysetId: 'k1',
unit: null,
counter: 42,
updatedAt: '2026-01-01',
})
db.close()
})
test('the new table rejects a duplicate keysetId (the key is enforced, not just declared)', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 100, '2026-01-01')
runMigration32(db)
expect(() =>
db
.prepare(`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt) VALUES (?, ?, ?, ?)`)
.run('k1', 'sat', 5, '2026-04-01'),
).toThrow()
// The original value is untouched by the rejected write.
expect(getCounter(db, 'k1')).toBe(100)
db.close()
})
test('post-migration upserts stay monotonic on the new key', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 400, '2026-02-01')
runMigration32(db)
// countersRepo.buildCounterUpsert against the healed row.
const upsert = (value: number) =>
db
.prepare(
`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(keysetId) DO UPDATE SET
counter = MAX(counter, excluded.counter),
unit = excluded.unit,
updatedAt = excluded.updatedAt`,
)
.run('k1', 'sat', value, '2026-05-01')
// A writer still holding the pre-migration low value cannot regress it.
upsert(342)
expect(getCounter(db, 'k1')).toBe(400)
upsert(410)
expect(getCounter(db, 'k1')).toBe(410)
db.close()
})
})

View File

@@ -0,0 +1,292 @@
/**
* Repeatable migration tests for SQLite migration 33.
*
* Migration 33 adds `mintId` to `onchain_mint_quotes` and `reservations`, so those
* rows reference the owning mint by its stable id instead of by url.
*
* WHY: a mint url is a network LOCATOR and mints move. An onchain quote's address
* stays creditable for as long as the mint exists (rows are never deleted), so the
* reference has to outlive the move; following a stale url just polls a dead host
* forever, and silently, because the watcher swallows errors by design.
*
* The column is added EMPTY and backfilled from JS (the v38 seed), because mints
* live in the MST/MMKV snapshot rather than SQLite — no SQL statement can map
* url -> id. These tests cover the migration shape, the backfill semantics, and the
* replay hazard that made v26/v31 freeze their historical column lists.
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {DatabaseSync} from 'node:sqlite'
import {MIGRATIONS} from '../src/services/db/migrations'
// ── Historical shapes, as v26/v31 create them (frozen in migrations.ts) ───────
const CREATE_ONCHAIN_V31 = `CREATE TABLE onchain_mint_quotes (
quote TEXT PRIMARY KEY NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
counterIndex INTEGER NOT NULL,
pubkey TEXT NOT NULL,
amountRequested INTEGER,
amountPaid INTEGER NOT NULL DEFAULT 0,
amountIssued INTEGER NOT NULL DEFAULT 0,
expiry INTEGER,
watchUntil TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT
)`
const CREATE_RESERVATIONS_V26 = `CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
createdAt TEXT NOT NULL
)`
// ── The REAL migration, taken from the registry ─────────────────────────────
//
// Imported rather than copied: a copy of the SQL proves nothing about the SQL that
// actually runs on a device. The PRE-migration shapes above stay hand-written on
// purpose — they are frozen history, and must not track today's schema.
const MIGRATION_33 = MIGRATIONS.find(m => m.version === 33)!.queries.map(([sql]) => sql)
// ── Backfill, mirroring onchainQuotesRepo / reservationsRepo ─────────────────
function backfillOnchainMintQuoteMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE onchain_mint_quotes SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
function backfillReservationMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE reservations SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const MINT_A = {id: 'aaaa1111', mintUrl: 'https://a.mint.test'}
const MINT_B = {id: 'bbbb2222', mintUrl: 'https://b.mint.test'}
function runMigration33(db: DatabaseSync) {
db.exec('BEGIN')
try {
for (const sql of MIGRATION_33) db.exec(sql)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function insertQuote(db: DatabaseSync, quote: string, mintUrl: string) {
db.prepare(
`INSERT INTO onchain_mint_quotes
(quote, mintUrl, unit, address, counterIndex, pubkey, amountPaid, amountIssued, watchUntil, createdAt)
VALUES (?, ?, 'sat', 'bc1qaddr', 7, 'pub', 0, 0, '2026-12-01', '2026-01-01')`,
).run(quote, mintUrl)
}
function insertReservation(db: DatabaseSync, id: string, mintUrl: string) {
db.prepare(
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, 1, ?, 'sat', 'send', '[]', '2026-01-01')`,
).run(id, mintUrl)
}
function quoteMintId(db: DatabaseSync, quote: string): string | null {
const row = db.prepare('SELECT mintId FROM onchain_mint_quotes WHERE quote = ?').get(quote) as
| {mintId: string | null}
| undefined
return row?.mintId ?? null
}
function reservationMintId(db: DatabaseSync, id: string): string | null {
const row = db.prepare('SELECT mintId FROM reservations WHERE id = ?').get(id) as
| {mintId: string | null}
| undefined
return row?.mintId ?? null
}
function columns(db: DatabaseSync, table: string): string[] {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}>
return rows.map(r => r.name)
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_ONCHAIN_V31)
db.exec(CREATE_RESERVATIONS_V26)
return db
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('SQLite migration 33 — reference the mint by id', () => {
describe('shape', () => {
test('adds mintId to both tables', () => {
const db = freshDb()
expect(columns(db, 'onchain_mint_quotes')).not.toContain('mintId')
expect(columns(db, 'reservations')).not.toContain('mintId')
runMigration33(db)
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
expect(columns(db, 'reservations')).toContain('mintId')
db.close()
})
test('keeps mintUrl — it stays as the historical record', () => {
const db = freshDb()
runMigration33(db)
expect(columns(db, 'onchain_mint_quotes')).toContain('mintUrl')
expect(columns(db, 'reservations')).toContain('mintUrl')
db.close()
})
test('preserves existing rows, with mintId NULL until backfilled', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
const row = db.prepare('SELECT * FROM onchain_mint_quotes WHERE quote = ?').get('q1') as any
expect(row.mintId).toBeNull()
// The load-bearing column: without counterIndex the NUT-20 key cannot be
// re-derived and a deposit is unmintable.
expect(row.counterIndex).toBe(7)
expect(row.address).toBe('bc1qaddr')
expect(row.mintUrl).toBe(MINT_A.mintUrl)
db.close()
})
// The reason v26/v31 freeze their column lists instead of reading schema.ts.
// If a replayed old migration created today's shape, this ALTER would throw
// "duplicate column name" and the upgrade would fail outright.
test('re-running the ALTER on an already-migrated table throws', () => {
const db = freshDb()
runMigration33(db)
expect(() => db.exec(`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`)).toThrow()
db.close()
})
})
describe('backfill', () => {
test('resolves each row to its mint id', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
insertQuote(db, 'q2', MINT_B.mintUrl)
insertReservation(db, 'r1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A, MINT_B])).toBe(2)
expect(backfillReservationMintIds(db, [MINT_A, MINT_B])).toBe(1)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
expect(quoteMintId(db, 'q2')).toBe(MINT_B.id)
expect(reservationMintId(db, 'r1')).toBe(MINT_A.id)
db.close()
})
test('is idempotent — a second run updates nothing', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A])).toBe(1)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A])).toBe(0)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
db.close()
})
// The IS NULL guard: once a row is resolved, a later url match must never
// re-point it. After a rename the row's mintUrl is stale by design, so a
// re-run must not "correct" it toward whichever mint now answers that url.
test('never overwrites an id already resolved', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
backfillOnchainMintQuoteMintIds(db, [MINT_A])
// A different mint has since taken over that url.
const impostor = {id: 'cccc3333', mintUrl: MINT_A.mintUrl}
expect(backfillOnchainMintQuoteMintIds(db, [impostor])).toBe(0)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
db.close()
})
test('leaves a row whose mint is gone from the wallet NULL', () => {
const db = freshDb()
insertQuote(db, 'orphan', 'https://removed.mint.test')
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A, MINT_B])).toBe(0)
// Null means "no mint to talk to" — the quote is dead either way, and the
// resolver throws rather than guessing from the stale url.
expect(quoteMintId(db, 'orphan')).toBeNull()
db.close()
})
test('is a no-op with no mints', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [])).toBe(0)
expect(quoteMintId(db, 'q1')).toBeNull()
db.close()
})
})
describe('the point of the whole change', () => {
test('a quote still resolves after its mint moves url', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
backfillOnchainMintQuoteMintIds(db, [MINT_A])
// The mint moves. Nothing rewrites the quote row — that is the design.
const movedMint = {id: MINT_A.id, mintUrl: 'https://moved.mint.test'}
// Resolution is by id, so it still finds the mint and gets its LIVE url.
expect(quoteMintId(db, 'q1')).toBe(movedMint.id)
// Whereas the old url-keyed lookup now finds nothing — this is precisely the
// query that stranded deposits permanently.
const byOldUrl = db
.prepare('SELECT quote FROM onchain_mint_quotes WHERE mintUrl = ?')
.all(movedMint.mintUrl)
expect(byOldUrl).toHaveLength(0)
const byId = db.prepare('SELECT quote FROM onchain_mint_quotes WHERE mintId = ?').all(movedMint.id)
expect(byId).toHaveLength(1)
db.close()
})
})
})

View File

@@ -0,0 +1,260 @@
/**
* Repeatable migration tests for SQLite migration 34.
*
* Migration 34 finishes the mint-identity work:
*
* 1. `transactions.mintId` — splitting the two jobs `transactions.mint` was doing
* at once, switched by status: a historical record of where a finished payment
* happened, AND a live pointer the wallet dialled for an open one. That is why
* a mint-url edit used to rewrite in-flight rows — rewriting the very column
* that records the past. With mintId carrying identity, `mint` is frozen.
*
* 2. inflight_requests / melt_recovery rebuilt WITHOUT mintUrl and keysetId. Both
* are CHILD rows of a transaction (their primary key IS transactionId), so the
* parent already owns "which mint"; their copies had no readers at all. The one
* mint-scoped query joins through the parent instead.
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {DatabaseSync} from 'node:sqlite'
import {MIGRATIONS} from '../src/services/db/migrations'
// ── Pre-34 shapes (as v28/v29 create them; frozen in migrations.ts) ──────────
const CREATE_TRANSACTIONS_PRE34 = `CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
type TEXT,
amount INTEGER,
unit TEXT,
data TEXT,
mint TEXT,
status TEXT,
createdAt TEXT
)`
const CREATE_INFLIGHT_V29 = `CREATE TABLE inflight_requests (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
)`
const CREATE_MELT_RECOVERY_V28 = `CREATE TABLE melt_recovery (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
)`
// ── The REAL migration, taken from the registry ─────────────────────────────
//
// Imported rather than copied: a copy of the SQL proves nothing about the SQL that
// actually runs on a device. The PRE-migration shapes above stay hand-written on
// purpose — they are frozen history, and must not track today's schema.
const MIGRATION_34 = MIGRATIONS.find(m => m.version === 34)!.queries.map(([sql]) => sql)
/** Mirrors transactionsRepo.backfillTransactionMintIds (the v38 seed). */
function backfillTransactionMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE transactions SET mintId = ? WHERE mint = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const MINT_A = {id: 'aaaa1111', mintUrl: 'https://a.mint.test'}
const MINT_B = {id: 'bbbb2222', mintUrl: 'https://b.mint.test'}
function runMigration34(db: DatabaseSync) {
db.exec('BEGIN')
try {
for (const sql of MIGRATION_34) db.exec(sql)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function insertTx(db: DatabaseSync, id: number, mint: string, status = 'COMPLETED') {
db.prepare(
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, '2026-01-01')`,
).run(id, mint, status)
}
function columns(db: DatabaseSync, table: string): string[] {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}>
return rows.map(r => r.name)
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_TRANSACTIONS_PRE34)
db.exec(CREATE_INFLIGHT_V29)
db.exec(CREATE_MELT_RECOVERY_V28)
return db
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('SQLite migration 34 — transactions.mintId, and child tables normalized', () => {
describe('transactions.mintId', () => {
test('adds mintId while keeping mint', () => {
const db = freshDb()
runMigration34(db)
expect(columns(db, 'transactions')).toContain('mintId')
// `mint` stays: it is where the payment happened, and history needs it.
expect(columns(db, 'transactions')).toContain('mint')
db.close()
})
test('backfills mintId from the url, leaving mint untouched', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl)
insertTx(db, 2, MINT_B.mintUrl)
runMigration34(db)
expect(backfillTransactionMintIds(db, [MINT_A, MINT_B])).toBe(2)
const rows = db.prepare('SELECT id, mint, mintId FROM transactions ORDER BY id').all() as any[]
expect(rows[0]).toMatchObject({mint: MINT_A.mintUrl, mintId: MINT_A.id})
expect(rows[1]).toMatchObject({mint: MINT_B.mintUrl, mintId: MINT_B.id})
db.close()
})
test('leaves history of a removed mint with a null mintId', () => {
const db = freshDb()
insertTx(db, 1, 'https://removed.mint.test')
runMigration34(db)
expect(backfillTransactionMintIds(db, [MINT_A])).toBe(0)
const row = db.prepare('SELECT mint, mintId FROM transactions WHERE id = 1').get() as any
// Legitimate history: no mint to act on, but the url still records where it
// happened.
expect(row.mintId).toBeNull()
expect(row.mint).toBe('https://removed.mint.test')
db.close()
})
test('backfill is idempotent and never re-points a resolved row', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl)
runMigration34(db)
backfillTransactionMintIds(db, [MINT_A])
// A different mint has since taken over that url.
const impostor = {id: 'cccc3333', mintUrl: MINT_A.mintUrl}
expect(backfillTransactionMintIds(db, [impostor])).toBe(0)
const row = db.prepare('SELECT mintId FROM transactions WHERE id = 1').get() as any
expect(row.mintId).toBe(MINT_A.id)
db.close()
})
test('backfills in-flight and terminal rows alike', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl, 'PENDING')
insertTx(db, 2, MINT_A.mintUrl, 'COMPLETED')
runMigration34(db)
// Unlike the rewrite this replaces, the backfill is status-blind: mintId is
// identity, which a finished transaction has just as much as an open one.
expect(backfillTransactionMintIds(db, [MINT_A])).toBe(2)
db.close()
})
})
describe('child tables lose their duplicated mint reference', () => {
test('inflight_requests keeps only transactionId, request, createdAt', () => {
const db = freshDb()
runMigration34(db)
expect(columns(db, 'inflight_requests').sort()).toEqual(['createdAt', 'request', 'transactionId'])
db.close()
})
test('melt_recovery keeps only transactionId, meltPreview, createdAt', () => {
const db = freshDb()
runMigration34(db)
expect(columns(db, 'melt_recovery').sort()).toEqual(['createdAt', 'meltPreview', 'transactionId'])
db.close()
})
test('carries the surviving data across the rebuild', () => {
const db = freshDb()
db.prepare(
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (1, 'https://a.mint.test', 'k1', '{"v":1}', '2026-01-01')`,
).run()
db.prepare(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (2, 'https://a.mint.test', 'k1', '{"keysetId":"k1"}', '2026-01-02')`,
).run()
runMigration34(db)
const ifr = db.prepare('SELECT * FROM inflight_requests WHERE transactionId = 1').get() as any
expect(ifr.request).toBe('{"v":1}')
expect(ifr.createdAt).toBe('2026-01-01')
const melt = db.prepare('SELECT * FROM melt_recovery WHERE transactionId = 2').get() as any
// The keyset was never lost — it lives inside the preview, which is why the
// column duplicating it had no readers.
expect(JSON.parse(melt.meltPreview).keysetId).toBe('k1')
expect(melt.createdAt).toBe('2026-01-02')
db.close()
})
test('an empty child table rebuilds cleanly', () => {
const db = freshDb()
runMigration34(db)
expect(db.prepare('SELECT * FROM inflight_requests').all()).toEqual([])
expect(db.prepare('SELECT * FROM melt_recovery').all()).toEqual([])
db.close()
})
})
describe('the mint-scoped sweep, after the change', () => {
test('finds a mint\'s in-flight requests through the parent transaction', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl, 'PENDING')
insertTx(db, 2, MINT_B.mintUrl, 'PENDING')
db.prepare(
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (1, 'https://a.mint.test', 'k1', '{"v":1}', '2026-01-01'),
(2, 'https://b.mint.test', 'k2', '{"v":2}', '2026-01-01')`,
).run()
runMigration34(db)
backfillTransactionMintIds(db, [MINT_A, MINT_B])
const rows = db
.prepare(
`SELECT r.transactionId FROM inflight_requests r
JOIN transactions t ON t.id = r.transactionId
WHERE t.mintId = ?`,
)
.all(MINT_A.id) as any[]
expect(rows).toHaveLength(1)
expect(rows[0].transactionId).toBe(1)
db.close()
})
})
})

View File

@@ -0,0 +1,147 @@
/**
* Repeatable migration tests for SQLite migration 35.
*
* Migration 35 creates the `mints` and `mint_keysets` tables, moving the mints out
* of the MST/MMKV snapshot and into SQLite.
*
* WHY: postProcessSnapshot already strips proofs and transactions, so mints — with
* every keyset's `keys` map — had become the largest thing left in the persisted
* tree, and `JSON.stringify(snapshot)` runs on EVERY MST action anywhere, including
* every proof mutation during a send. It also puts mints in the same engine as the
* proofs, so a mint-url edit can commit atomically with the proofs it renames.
*
* The tables are created EMPTY: mints live in the snapshot, so nothing in SQL can
* read them. The v39 seed copies them once the store is hydrated.
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {DatabaseSync} from 'node:sqlite'
import {MIGRATIONS} from '../src/services/db/migrations'
// ── The REAL migration, taken from the registry ─────────────────────────────
//
// Imported rather than copied: a copy of the SQL proves nothing about the SQL that
// actually runs on a device.
const MIGRATION_35 = MIGRATIONS.find(m => m.version === 35)!.queries.map(([sql]) => sql)
function runMigration35(db: DatabaseSync) {
db.exec('BEGIN')
try {
for (const sql of MIGRATION_35) db.exec(sql)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function columns(db: DatabaseSync, table: string): string[] {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}>
return rows.map(r => r.name)
}
function tables(db: DatabaseSync): string[] {
return (
db.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`).all() as unknown as Array<{
name: string
}>
).map(r => r.name)
}
/** A pre-35 database: no mints tables at all. */
const freshDb = () => new DatabaseSync(':memory:')
describe('SQLite migration 35 — mints move into SQLite', () => {
test('creates both tables', () => {
const db = freshDb()
expect(tables(db)).not.toContain('mints')
runMigration35(db)
expect(tables(db)).toContain('mints')
expect(tables(db)).toContain('mint_keysets')
db.close()
})
test('mints carries the identity, the url and the JSON payloads', () => {
const db = freshDb()
runMigration35(db)
expect(columns(db, 'mints').sort()).toEqual(
['color', 'createdAt', 'hostname', 'id', 'mintInfo', 'mintUrl', 'shortname', 'status', 'units'].sort(),
)
db.close()
})
test('mint_keysets is keyed by keysetId and owned by mintId', () => {
const db = freshDb()
runMigration35(db)
expect(columns(db, 'mint_keysets').sort()).toEqual(['keys', 'keyset', 'keysetId', 'mintId', 'unit'].sort())
db.close()
})
test('starts empty — the v39 seed fills it, because SQL cannot read the MMKV snapshot', () => {
const db = freshDb()
runMigration35(db)
expect(db.prepare('SELECT * FROM mints').all()).toEqual([])
expect(db.prepare('SELECT * FROM mint_keysets').all()).toEqual([])
db.close()
})
describe('keys', () => {
test('a mint id is unique', () => {
const db = freshDb()
runMigration35(db)
const insert = () =>
db
.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`)
.run('mint1', 'https://a.test')
insert()
expect(insert).toThrow()
db.close()
})
// Matches mint_counters, which is also keyed by keysetId alone: the two agree on
// what a keyset is, and a keyset id is unique wallet-wide (isCollidingKeysetId).
test('a keyset id is unique across mints, not just within one', () => {
const db = freshDb()
runMigration35(db)
db.prepare(`INSERT INTO mint_keysets (keysetId, mintId, keyset) VALUES (?, ?, ?)`).run(
'k1',
'mint1',
'{}',
)
expect(() =>
db
.prepare(`INSERT INTO mint_keysets (keysetId, mintId, keyset) VALUES (?, ?, ?)`)
.run('k1', 'mint2', '{}'),
).toThrow()
db.close()
})
test('mints.mintUrl is NOT unique — the schema does not decide mint identity', () => {
// Identity is Mint.id. Two rows could legitimately share a url mid-rename, and
// the constraint that matters (one real mint, one Mint node) is enforced by
// keyset collision detection, not by a url index.
const db = freshDb()
runMigration35(db)
db.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`).run('mint1', 'https://a.test')
expect(() =>
db.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`).run('mint2', 'https://a.test'),
).not.toThrow()
db.close()
})
})
})

View File

@@ -0,0 +1,173 @@
/**
* The transfer-method resolver: the one place the two melt rails differ.
*
* `TransferOperationApi` reads everything rail-specific from here instead of branching
* on `method` at each site that needs a fee or an expiry, so this is where a mistake
* about a rail would actually live. Worth pinning:
*
* - where the fee reserve comes from (a field on the quote vs the SELECTED tier),
* - that an onchain transfer is never expirable,
* - that a fee_index the mint never offered is refused before it reaches the mint.
*
* @jest-environment node
*/
// Transaction.ts -> services -> logService -> @sentry/react-native (ESM, not transformed)
// and on into the MST store graph + op-sqlite. Mock the chain so the test stays pure,
// same as transactionStates.test.ts.
jest.mock('../src/services/logService', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
LogLevel: {ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', TRACE: 'TRACE'},
}))
jest.mock('../src/services', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
Database: {},
}))
import {resolveTransferMethod} from '../src/services/wallet/operations/transferMethods'
import {TransactionType} from '../src/models/Transaction'
/** cashu-ts hands amounts over as Amount objects, not numbers. */
const amount = (n: number) => ({toNumber: () => n}) as any
const onchainQuote = (feeOptions: Array<[number, number, number]>) =>
({
quote: 'quote-onchain-1',
amount: amount(50000),
unit: 'sat',
state: 'UNPAID',
expiry: 1800000000,
request: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
fee_options: feeOptions.map(([fee_index, fee_reserve, estimated_blocks]) => ({
fee_index,
fee_reserve: amount(fee_reserve),
estimated_blocks,
})),
selected_fee_index: null,
outpoint: null,
}) as any
describe('resolveTransferMethod — onchain', () => {
const QUOTE_EXPIRY = new Date('2027-01-01T00:00:00Z')
const ADDRESS = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'
const resolveOnchain = (feeIndex: number, feeOptions: Array<[number, number, number]>) =>
resolveTransferMethod({
method: 'onchain',
options: {
address: ADDRESS,
meltQuote: onchainQuote(feeOptions),
feeIndex,
quoteExpiry: QUOTE_EXPIRY,
},
})
it('records the onchain transaction type', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.transactionType).toBe(TransactionType.TRANSFER_ONCHAIN)
expect(resolved.method).toBe('onchain')
})
// An onchain quote has NO fee_reserve field — it has a list of tiers, and the reserve
// depends entirely on which one the user picked.
it('takes the fee reserve from the SELECTED tier', () => {
const tiers: Array<[number, number, number]> = [
[0, 400, 6],
[1, 900, 3],
[2, 2100, 1],
]
expect(resolveOnchain(0, tiers).feeReserve).toBe(400)
expect(resolveOnchain(1, tiers).feeReserve).toBe(900)
expect(resolveOnchain(2, tiers).feeReserve).toBe(2100)
})
// fee_index is the mint's identifier for a tier, not its position in the array.
it('selects by the mint\'s fee_index, not by array position', () => {
const tiers: Array<[number, number, number]> = [
[7, 2100, 1],
[3, 400, 6],
]
expect(resolveOnchain(3, tiers).feeReserve).toBe(400)
expect(resolveOnchain(7, tiers).feeReserve).toBe(2100)
})
// The mint MUST reject a fee_index it never offered, so catching it locally saves a
// round-trip and a quote that can then never be executed with a different index.
it('refuses a fee_index the mint never offered', () => {
expect(() => resolveOnchain(5, [[0, 400, 6]])).toThrow(/Fee index 5 was not offered/)
})
/**
* THE rule that keeps a real payment from being declared dead.
*
* A melt quote's expiry bounds EXECUTING the quote, not CONFIRMING the payment. Once
* the mint has broadcast, the transaction confirms on the chain's schedule and can
* easily outlive the quote it came from. If anything ever expires a pending onchain
* transfer on the strength of this date, it marks an irreversible, in-flight payment
* as failed and hides it from the user.
*/
it('is never expirable once pending', () => {
expect(resolveOnchain(0, [[0, 400, 6]]).expiresPendingTransfer).toBe(false)
})
it('carries the address as the payment request and has no payment id', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.paymentRequest).toBe(ADDRESS)
// Onchain has no preimage-style identifier. It gets an `outpoint` once broadcast.
expect(resolved.paymentId).toBeUndefined()
})
it('passes the quote id and expiry through', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.quoteId).toBe('quote-onchain-1')
expect(resolved.expiry).toBe(QUOTE_EXPIRY)
})
})
describe('resolveTransferMethod — bolt11', () => {
// BOLT11 spec test vector (2500 µBTC). Must actually decode — resolveTransferMethod
// derives the payment hash from it.
const INVOICE =
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'
const invoiceExpiry = new Date('2027-01-01T00:00:00Z')
const bolt11Quote = {
quote: 'quote-bolt11-1',
amount: amount(1000),
unit: 'sat',
state: 'UNPAID',
expiry: 1800000000,
request: INVOICE,
fee_reserve: amount(12),
payment_preimage: null,
} as any
it('takes the fee reserve straight off the quote and stays expirable', () => {
const resolved = resolveTransferMethod({
method: 'bolt11',
options: {encodedInvoice: INVOICE, meltQuote: bolt11Quote, invoiceExpiry},
})
expect(resolved.method).toBe('bolt11')
expect(resolved.transactionType).toBe(TransactionType.TRANSFER)
expect(resolved.feeReserve).toBe(12)
expect(resolved.paymentRequest).toBe(INVOICE)
// An expired invoice genuinely cannot be paid, so there IS nothing left to wait for.
expect(resolved.expiresPendingTransfer).toBe(true)
// bolt11 settles with a preimage, and the payment hash identifies it beforehand.
expect(typeof resolved.paymentId).toBe('string')
expect(resolved.paymentId).toHaveLength(64)
})
})

View File

@@ -138,13 +138,18 @@ describe('TransferOperationApi surface (compile-time)', () => {
'unit',
'amountToTransfer',
'meltQuote',
'invoiceExpiry',
'path',
'method',
// Rail-specific facts (fee reserve, expiry, tx type, quote id) resolved once,
// so the shared lifecycle never branches on `method` to find them.
'resolved',
'proofsToMeltFrom',
'proofsToMeltFromAmount',
'meltFeeReserve',
'lightningFeeReserve',
// Was `lightningFeeReserve`. Renamed when onchain melt landed: on that rail it
// is a miner fee, and it comes from the SELECTED fee tier rather than from a
// single field on the quote.
'feeReserve',
'preemptiveSwapFeePaid',
'nwcEvent',
]

View File

@@ -7,10 +7,32 @@ module.exports = {
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native|@react-native-community|@cashu|@noble|@scure|react-native-flash-message)/)',
],
// Several native or source-shipped packages are unusable under jest, and the
// MODEL layer reaches all of them at import time (Mint -> services barrel ->
// mmkvStorage / keyChain / db). That is what made MST stores impossible to
// instantiate in a test at all; these mappings are what make store tests possible.
moduleNameMapper: {
'^@noble/hashes/utils$': '@noble/hashes/utils.js',
// Same shape as the @noble mapping: the package only exports the explicit
// `.js` subpath, which jest's resolver will not infer.
'^@scure/bip39/wordlists/(.*)$': '@scure/bip39/wordlists/$1.js',
// quick-crypto's native module is unavailable under jest; route to a
// Node `crypto` shim so deps that import it at load time (e.g. bip32) work.
'^react-native-quick-crypto$': '<rootDir>/__mocks__/react-native-quick-crypto.js',
// MMKV and op-sqlite are native. MMKV is Map-backed and behaves; op-sqlite is
// only an import-time shim and throws if a statement is actually executed —
// use node:sqlite with the production SQL for real DB semantics (see the db
// suites).
'^react-native-mmkv$': '<rootDir>/__mocks__/react-native-mmkv.js',
'^@op-engineering/op-sqlite$': '<rootDir>/__mocks__/op-sqlite.js',
// Sentry ships untransformed ESM, so anything importing logService fails to
// parse. Mocking Sentry rather than logService lets the REAL logger load, so
// tests can cover code that logs instead of stubbing the logger away.
'^@sentry/react-native$': '<rootDir>/__mocks__/sentry-react-native.js',
// nostr-tools ships TS source and vendors its own @noble/curves + @noble/hashes
// (also source), importing subpaths jest cannot resolve. Mapping those would
// cross the nested copies onto the top-level @noble versions — on crypto code.
// Mock the surface instead; nothing in the model layer needs real nostr.
'^nostr-tools(/.*)?$': '<rootDir>/__mocks__/nostr-tools.js',
},
}

View File

@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.4.3-beta.15",
"version": "0.4.3-beta.16",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
@@ -22,7 +22,7 @@
"machine-translate": "npx @inlang/cli machine translate --project minibits.inlang"
},
"dependencies": {
"@cashu/cashu-ts": "^4.2.1",
"@cashu/cashu-ts": "^4.7.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",

View File

@@ -15,7 +15,7 @@ import {
formatCurrency,
getCurrency,
} from "../services/wallet/currency"
import { round, toNumber } from "../utils/number"
import { formatNumber, round, toNumber } from "../utils/number"
import { useStores } from "../models"
import { Text } from "./Text"
import { format } from "util"
@@ -72,6 +72,35 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
const [topValue, setTopValue] = useState(value)
const [bottomValue, setBottomValue] = useState("0")
// --- grouping separators ---
//
// A confirmed amount is SHOWN grouped ("12,345"), but state always holds the plain
// number. Formatting is a render concern, derived from the value, never written back
// into it.
//
// That split is not tidiness, it is the fix. These are controlled TextInputs, and
// Android echoes a programmatically-set `value` back out through `onChangeText` —
// where `handleTopChange` rewrites the first comma to a dot, because a decimal-comma
// keyboard types "1,5" and means 1.5. Store a grouped string and that echo turns
// "1,000" into "1.000" and then, re-formatted, into something shorter still. Keep the
// grouping out of state and the round trip cannot happen: what the change handler sees
// is always what the user typed.
const stripGrouping = (v: string) => v.replace(/,/g, '')
/** Group and pad an amount for DISPLAY: 12345 -> "12,345". Never stored. */
const formatForDisplay = (v: string, mantissa: number) => {
const n = toNumber(stripGrouping(v))
if (n === undefined || !Number.isFinite(n)) return v
return formatNumber(n, mantissa)
}
// Cap on what may be TYPED. Applied in the change handlers rather than via maxLength,
// because maxLength also clips programmatically-set text on Android — and the grouped
// display is longer than the number it shows ("999,999,999" is 11 characters, or 14
// with a fiat mantissa), so a maxLength that fit the typing limit would truncate the
// very value it had just formatted.
const MAX_TYPED_LENGTH = 9
const handleTopFocus = () => {
setHasTopAmountFocusedOnce(true)
setFocused('top')
@@ -137,9 +166,12 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
}
// keep internal state in sync with external `value`
//
// Stripped on the way in: a parent may hand us an already-grouped string (several
// screens pre-fill with numbro's thousandSeparated), and state must stay plain.
useEffect(() => {
log.trace(`[AmountInput.useEffect:mount] setTopValue`, value)
setTopValue(value)
setTopValue(stripGrouping(value))
// only show if
// - user has set conversion currency in settings
@@ -154,7 +186,7 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
if (canShow) {
log.trace(`[AmountInput.useEffect:mount] recalcBottom`, value)
setBottomValue(recalcBottom(value)) // ✅ always compute bottom from current top
setBottomValue(stripGrouping(recalcBottom(value))) // ✅ always compute bottom from current top
} else {
setBottomValue("0")
}
@@ -162,9 +194,9 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
useEffect(() => {
log.trace(`[AmountInput.useEffect:value] setTopValue`, value)
setTopValue(value)
setTopValue(stripGrouping(value))
if(!hasBeenFirstTimeConverted && value && toNumber(value) > 0) {
setBottomValue(recalcBottom(value))
setBottomValue(stripGrouping(recalcBottom(value)))
setHasBeenFirstTimeConverted(true)
}
@@ -172,33 +204,60 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
// input change handlers (bi-directional)
const handleTopChange = (text: string) => {
// "," -> "." so a decimal-comma keyboard can type "1,5" and mean 1.5. Safe only
// because state never holds a grouped value for this to collide with.
const normalized = text.replace(',', '.')
if (normalized.length > MAX_TYPED_LENGTH) return
setTopValue(normalized)
if (focused === "top") {
setBottomValue(recalcBottom(normalized))
setBottomValue(stripGrouping(recalcBottom(normalized)))
onChangeText?.(normalized) // parent receives "top" value (sat or fiat, as per `unit`)
}
}
const handleBottomChange = (text: string) => {
const normalized = text.replace(',', '.')
if (normalized.length > MAX_TYPED_LENGTH) return
setBottomValue(normalized)
if (focused === "bottom") {
const newTop = recalcTop(normalized)
const newTop = stripGrouping(recalcTop(normalized))
setTopValue(newTop)
onChangeText?.(newTop) // keep parent synced to "top" side
}
}
/**
* The amount is confirmed (keyboard "done", or focus left the field).
*
* Nothing is reformatted here, and nothing is pushed back to the parent: losing focus
* is all it takes for the field to render grouped, and the parent keeps the plain
* number it has had all along. The only work is recomputing the converted value from
* the CONFIRMED top rather than from whatever the bottom field holds — the two can be
* a keystroke apart.
*/
const onAmountEndEditing = () => {
// const formattedTop = recalcTop(bottomValue)
const formattedBottom = recalcBottom(topValue)
// setTopValue(formattedTop)
setBottomValue(formattedBottom)
setBottomValue(stripGrouping(recalcBottom(topValue)))
return onEndEditing?.()
}
const bottomCurrencyCode = topIsSat ? fiatCode : CurrencyCode.SAT
const currencySymbol = bottomCurrencyCode ? Currencies[bottomCurrencyCode]!.symbol : null
// Grouped only when the field is NOT being edited. This is the whole of the "format on
// confirm" behaviour: losing focus is what confirms an amount, and the grouped text is
// derived at render time, so it can never be read back in as input.
const topDisplayValue = hasTopAmountFocusedOnce
? topValue
: formatForDisplay(topValue, getCurrency(unit).mantissa)
const bottomMantissa = bottomCurrencyCode ? Currencies[bottomCurrencyCode]!.mantissa : 0
const bottomDisplayValue = hasBottomAmountFocusedOnce
? bottomValue
: formatForDisplay(bottomValue, bottomMantissa)
// --- animations (unchanged behavior) ---
const topScale = useSharedValue(1)
const bottomScale = useSharedValue(1)
@@ -213,19 +272,34 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
}
}, [focused, topScale, bottomScale])
const TOP_FONT_SIZE = verticalScale(56)
const defaultTopStyle: TextStyle = {
//marginTop: spacing.small,
padding: 0,
fontSize: verticalScale(56),
fontSize: TOP_FONT_SIZE,
fontFamily: typography.primary?.bold,
fontWeight: 'bold', // android
textAlign: "center",
color: focusedInputColor,
// A DEFINITE width, so the field is never sized by measuring its own text.
//
// These inputs sit in a header with `alignItems: 'center'`, so with no width Yoga
// asks the text how wide it is — and that measurement is made from the LAYOUT style,
// while what is actually drawn comes from the animated style below. The two only have
// to disagree slightly for the last glyph to fall outside the measured box: a
// confirmed "1,000" rendered as "1,00". Typing hid it, because the text is measured
// and drawn afresh on every keystroke; formatting on blur is what made the box and
// its contents disagree.
width: spacing.screenWidth * 0.9,
alignSelf: 'center',
}
// Same base size as the layout style. Reanimated applies fontSize outside Yoga, so a
// different number here means the text is drawn at one size and measured at another.
const animatedTopStyle = useAnimatedStyle(() => ({
transform: [{ scale: topScale.value }],
fontSize: 56 * topScale.value,
fontSize: TOP_FONT_SIZE * topScale.value,
}))
@@ -257,18 +331,16 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
const animatedSymbolStyle = useAnimatedStyle(() => ({
transform: [{ scale: bottomScale.value }], // sync with bottom input
fontSize: spacing.extraSmall * bottomScale.value, // scale font size
marginRight: focused === 'bottom' ? spacing.medium + bottomValue.length * 4.5 : spacing.tiny,
marginRight: focused === 'bottom' ? spacing.medium + bottomDisplayValue.length * 4.5 : spacing.tiny,
}))
const bottomCurrencyCode = topIsSat ? fiatCode : CurrencyCode.SAT
const currencySymbol = bottomCurrencyCode ? Currencies[bottomCurrencyCode]!.symbol : null
return (
<>
{/* Top input */}
<AnimatedTextInput
ref={ref}
value={topValue}
value={topDisplayValue}
onChangeText={handleTopChange}
onEndEditing={onAmountEndEditing}
onFocus={handleTopFocus}
@@ -279,7 +351,6 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
animatedTopStyle,
{ color: focused === 'top' ? focusedInputColor : convertedAmountColor }
]}
maxLength={9}
keyboardType="decimal-pad"
returnKeyType="done"
selectTextOnFocus={!hasTopAmountFocusedOnce}
@@ -302,7 +373,7 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
</Animated.Text>
<AnimatedTextInput
value={bottomValue}
value={bottomDisplayValue}
onChangeText={handleBottomChange}
onEndEditing={onAmountEndEditing}
onFocus={handleBottomFocus}
@@ -314,7 +385,6 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
animatedBottomStyle,
{ color: focused === 'bottom' ? focusedInputColor : convertedAmountColor }
]}
maxLength={9}
keyboardType="decimal-pad"
returnKeyType="done"
selectTextOnFocus={!hasBottomAmountFocusedOnce}

View File

@@ -18,6 +18,9 @@ import { IconDefinition, Transform } from "@fortawesome/fontawesome-svg-core"
// metro bundler tree-shaking issue: https://github.com/facebook/metro/issues/132
// relevant font-awesome docs: https://docs.fontawesome.com/apis/javascript/tree-shaking
// Circled Bitcoin mark, matching the silhouette of the BtcIcon SVG used by
// CurrencySign / CurrencyAmount.
import { faBitcoin } from "@fortawesome/free-brands-svg-icons/faBitcoin"
import { faTwitter } from "@fortawesome/free-brands-svg-icons/faTwitter"
import { faTelegramPlane } from "@fortawesome/free-brands-svg-icons/faTelegramPlane"
import { faDiscord } from "@fortawesome/free-brands-svg-icons/faDiscord"
@@ -110,7 +113,7 @@ export type IconTypes = keyof typeof iconRegistry
// TODO remove need for manual iconregistry?
// would be best to just import all of them, i guess, or figure out something smart
export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faCheck, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft, faMagnifyingGlass }
export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faCheck, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft, faMagnifyingGlass, faBitcoin }
interface IconProps extends TouchableOpacityProps {

View File

@@ -17,7 +17,7 @@ import {
} from "react-native"
import { useThemeColor, spacing } from "../theme"
import { useTabBarInset } from "../navigation/tabBarVisibility"
import { useHideTabBar, useTabBarInset } from "../navigation/tabBarVisibility"
import { ExtendedEdge, useSafeAreaInsetsStyle } from "../utils/useSafeAreaInsetsStyle"
interface BaseScreenProps {
@@ -47,6 +47,12 @@ interface BaseScreenProps {
* by `useTabBarInset()` so the last item stays reachable.
*/
contentUnderTabBar?: boolean
/**
* Hide the floating tab bar while this screen is focused, reclaiming its space.
* The reserved bottom room collapses to the safe-area inset. Use case-by-case,
* typically for a non-scrolling screen that wants the full height.
*/
hideTabBar?: boolean
/**
* By how much should we offset the keyboard? Defaults to 0.
*/
@@ -194,6 +200,15 @@ function ScreenWithScrolling(props: ScreenProps) {
/**
* Engages the tab-bar hide while mounted. Kept as a child so the useFocusEffect it
* relies on is only invoked when a screen actually opts in.
*/
function TabBarHider() {
useHideTabBar()
return null
}
export function Screen(props: ScreenProps) {
const {
backgroundColor = useThemeColor('background'),
@@ -201,6 +216,7 @@ export function Screen(props: ScreenProps) {
keyboardOffset = 0,
safeAreaEdges,
contentUnderTabBar = false,
hideTabBar = false,
} = props
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
@@ -209,6 +225,9 @@ export function Screen(props: ScreenProps) {
return (
<View style={[$containerStyle, { backgroundColor, paddingBottom }, $containerInsets]}>
{/* Rendered only when opted in, so screens outside a navigator (error, loading)
never call useFocusEffect and hit "Couldn't find navigation object". */}
{hideTabBar && <TabBarHider />}
<KeyboardAvoidingView
behavior={isIos ? "padding" : undefined}
keyboardVerticalOffset={keyboardOffset}

View File

@@ -181,8 +181,8 @@
"invalidRelayUrl": "Invalid relay URL.",
"lightningAddress": "Lightning address",
"lightningInvoiceSharedWaiting": "Lightning invoice has been shared, waiting to be paid by receiver.",
"lightningPayScreen_onPasteEmptyClipboard": "First copy Lightning invoice, address or pay code to pay to. Then paste.",
"lightningPayScreen_payHeading": "Pay",
"payScreen_onPasteEmptyClipboard": "First copy a Lightning invoice, address, pay code or a Bitcoin address to pay to. Then paste.",
"payScreen_payHeading": "Pay",
"loadingPublicInfo": "Loading public info",
"localBackupDisabled": "Local backup is off",
"localBackupEnabled": "Local backup is on",
@@ -303,8 +303,25 @@
"payCommon_payFromWallet": "Pay from wallet",
"payCommon_payMe": "Pay me",
"payCommon_payNow": "Pay now",
"onchainTransferScreen_broadcastTitle": "Payment sent",
"onchainTransferScreen_changeFee": "Change",
"onchainTransferScreen_feeHint": "Bitcoin payments are settled onchain. The miner fee depends on how fast you want the payment confirmed.",
"onchainTransferScreen_feeTierBlocks": "About %{blocks} blocks",
"onchainTransferScreen_feeTierReserve": "Up to %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "About %{blocks} blocks · up to %{amount} %{currency}",
"onchainTransferScreen_memoFromPayee": "Payment description",
"onchainTransferScreen_networkFee": "Network fee",
"onchainTransferScreen_noMintTitle": "No mint to pay from",
"onchainTransferScreen_noMintDesc": "You have no mint holding this currency. Add one and top it up before sending Bitcoin.",
"onchainTransferScreen_noOnchainMintTitle": "No mint can pay Bitcoin addresses",
"onchainTransferScreen_noOnchainMintDesc": "None of your mints holding this balance supports Bitcoin payouts. Add a mint that does, or pay with Lightning instead.",
"onchainTransferScreen_mintNoOnchainSupport": "This mint does not support Bitcoin payouts. Select a different mint.",
"onchainTransferScreen_noFeeOptions": "The mint returned no fee options for this payment and it can not be paid.",
"onchainTransferScreen_requestQuote": "Continue",
"onchainTransferScreen_selectFee": "Confirmation speed",
"onchainTransferScreen_toAddress": "Bitcoin address",
"payCommon_payToAddress": "Pay to this address",
"payCommon_payWithLightning": "Pay with Lightning",
"payCommon_payWithBitcoin": "Pay with Bitcoin",
"payCommon_receive": "Receive",
"payCommon_receiveEcash": "Receive ecash",
"payCommon_receiveInPerson": "Receive in person",
@@ -318,7 +335,7 @@
"pos_scanToPay": "Scan to pay",
"pos_waitingForPayment": "Waiting for payment...",
"payerMemo": "Memo for the payer",
"payWithLightningDesc": "Enter or paste Lightning address, invoice or LNURL pay code you want to pay to.",
"payWithBitcoinDesc": "Enter or paste a Lightning invoice, address or pay code, or a Bitcoin address you want to pay to.",
"pictureRetrieveFail": "Could not retrieve pictures from the server, try again later.",
"placeholderNpub": "npub...",
"placeholderRelay": "wss://...",
@@ -618,6 +635,7 @@
"tranDetailScreen_sentFrom": "Sent from",
"tranDetailScreen_sentTo": "Sent to",
"tranDetailScreen_status": "Status",
"tranDetailScreen_outpoint": "Bitcoin transaction",
"tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash has been successfully received after retry. New transaction has been created.",
"tranDetailScreen_tokenTracking": "Token tracking",
"tranDetailScreen_tokenTrackingDesc": "Logged ecash tokens for debugging purposes",
@@ -682,6 +700,9 @@
"transactionCommon_youSent": "You sent",
"transactionResult_lightningInvoicePaidFee": "Lightning invoice has been paid. Fee was %{fee}.",
"transactionResult_lightningPaymentFailed": "Lightning payment failed. Reserved ecash has been returned to spendable balance.",
"transactionResult_onchainPaymentBroadcast": "Bitcoin payment has been broadcast. It will complete once confirmed on the blockchain.",
"transactionResult_onchainPaymentConfirmed": "Bitcoin payment confirmed on the blockchain.",
"transactionResult_onchainPaymentFailed": "Bitcoin payment failed. Reserved ecash has been returned to spendable balance.",
"transferScreen_donationSuccessMessage": "Donation for %{donationForName} has been successfully paid and your wallet address has been updated. Thank you!",
"transferScreen_insufficientFunds": "There is not enough balance in %{currency} to pay the invoice amount and expected fees: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits does not yet support entering of payer identity data (LUD18).",
@@ -707,7 +728,7 @@
"updateScreen_updateNotAvailableDesc": "Good, you are running the latest version of the app.",
"updateScreen_updateNow": "Update and restart",
"updateScreen_updateSize": "Download size",
"userErrorMissingLightningData": "Missing Lightning invoice, paycode or address.",
"userErrorMissingPaymentData": "Missing Lightning invoice, paycode, address or Bitcoin address.",
"wallerScreen_mintButton": "Mint",
"walletAddressRecovery": "Wallet address recovery",
"walletAddressRecoveryDesc": "Use your seed phrase from another device to get your original wallet address on this device. This will not recover balances.",
@@ -721,17 +742,15 @@
"walletScreen_mintExists": "This mint already exists",
"walletScreen_pay": "Pay",
"walletScreen_paymentSentSuccess": "Payment request has been successfully sent.",
"walletScreen_payWithLightning": "Pay with Lightning",
"walletScreen_payWithLightningDesc": "Pay invoice or to a Lightning address",
"walletScreen_receiveEcash": "Receive Ecash",
"walletScreen_receiveEcashDesc": "Paste or scan ecash token",
"walletScreen_sendEcash": "Send ecash",
"walletScreen_sendEcashDesc": "Share ecash or send it to one of your contacts",
"walletScreen_optionBitcoin": "Bitcoin",
"walletScreen_optionBitcoinSendDesc": "Pay Lightning invoice or onchain address. Network fees apply.",
"walletScreen_optionEcashReceiveDesc": "Paste or scan an ecash token",
"walletScreen_optionEcash": "Ecash",
"walletScreen_optionEcashSendDesc": "Instant, wallet to wallet ecash transfer.",
"walletScreen_spentToday": "Spent today",
"walletScreen_startByFunding": "Start by funding your wallet",
"walletScreen_topup": "Topup",
"walletScreen_topupWithLightning": "Topup with Lightning",
"walletScreen_topupWithLightningDesc": "Create Lightning invoice to topup your balance",
"walletScreen_optionBitcoinReceiveDesc": "Top up over Lightning or onchain",
"warning": "Warning",
"welcomeScreen_creatingKeys": "Creating wallet keys...",
"welcomeScreen_creatingProfile": "Creating wallet profile...",
@@ -748,5 +767,22 @@
"welcomeScreen_terms_agreePrefix": "I have read and agree to the",
"welcomeScreen_terms_agreeTerms": "Terms",
"welcomeScreen_terms_agreeConjunction": "and",
"welcomeScreen_terms_agreePrivacy": "Privacy Policy"
"welcomeScreen_terms_agreePrivacy": "Privacy Policy",
"mintSelector_noTopupSupport": "Top up not supported for this currency",
"mintSelector_noPayoutSupport": "Payouts not supported for this currency",
"mintSelector_noOnchainPayoutSupport": "This mint does not support Bitcoin payouts",
"topupScreen_onchainAddress": "Address",
"topupScreen_ligtningInvoice": "Invoice",
"topupScreen_onchainAddressToPay": "Bitcoin address to pay",
"topupScreen_onchainAmountIsHint": "This is Bitcoin onchain address to be used to topup your wallet. The amount is only a suggestion. Your balance updates with whatever actually arrives, whether more or less.",
"topupScreen_onchainConfirmationsNeeded": "Your ecash appears once the deposit confirms on the Bitcoin network. Number of confirmations depends on the mint.",
"tranDetail_checkForDeposits": "Check for deposits",
"tranDetail_checkStatus": "Check status",
"tranDetail_onchainStillUnconfirmed": "The payment is still waiting for confirmation on the blockchain.",
"tranDetail_onchainNoDeposits": "No new deposits found at this address yet.",
"tranDetail_onchainOutpoint": "Bitcoin transaction",
"transactionType_topupOnchain": "Topup onchain",
"transactionType_transferOnchain": "Onchain payment",
"qr_hintHeading": "How does this work?",
"tranDetailScreen_paymentRequest": "Payment request"
}

View File

@@ -180,8 +180,8 @@
"invalidRelayUrl": "URL de retransmisión no válida.",
"lightningAddress": "Dirección de Lightning",
"lightningInvoiceSharedWaiting": "Se ha compartido una factura Lightning y está a la espera de ser pagada por el receptor.",
"lightningPayScreen_onPasteEmptyClipboard": "Primero, copia la factura Lightning, la dirección o el código de pago. Luego, pégalo.",
"lightningPayScreen_payHeading": "Pagar",
"payScreen_onPasteEmptyClipboard": "Primero copia una factura Lightning, dirección, código de pago o una dirección Bitcoin a la que pagar. Luego pega.",
"payScreen_payHeading": "Pagar",
"loadingPublicInfo": "Cargando información pública",
"localBackupDisabled": "La copia de seguridad local está desactivada",
"localBackupEnabled": "La copia de seguridad local está activada",
@@ -302,8 +302,25 @@
"payCommon_payFromWallet": "Pagar desde la billetera",
"payCommon_payMe": "Pagame",
"payCommon_payNow": "Pagar ahora",
"onchainTransferScreen_broadcastTitle": "Pago enviado",
"onchainTransferScreen_changeFee": "Cambiar",
"onchainTransferScreen_feeHint": "Los pagos en Bitcoin se liquidan en la cadena. La tarifa del minero depende de la rapidez con la que quieras que se confirme el pago.",
"onchainTransferScreen_feeTierBlocks": "Unos %{blocks} bloques",
"onchainTransferScreen_feeTierReserve": "Hasta %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "Unos %{blocks} bloques · hasta %{amount} %{currency}",
"onchainTransferScreen_memoFromPayee": "Descripción del pago",
"onchainTransferScreen_networkFee": "Tarifa de red",
"onchainTransferScreen_noMintTitle": "No hay ninguna casa de cambio desde la que pagar",
"onchainTransferScreen_noMintDesc": "No tienes ninguna casa de cambio con esta moneda. Añade una y recárgala antes de enviar Bitcoin.",
"onchainTransferScreen_noOnchainMintTitle": "Ninguna casa de cambio puede pagar a direcciones Bitcoin",
"onchainTransferScreen_noOnchainMintDesc": "Ninguna de tus casas de cambio con este saldo admite pagos en Bitcoin. Añade una que lo admita o paga con Lightning.",
"onchainTransferScreen_mintNoOnchainSupport": "Esta casa de cambio no admite pagos en Bitcoin. Selecciona otra.",
"onchainTransferScreen_noFeeOptions": "La casa de cambio no devolvió opciones de tarifa para este pago y no se puede pagar.",
"onchainTransferScreen_requestQuote": "Continuar",
"onchainTransferScreen_selectFee": "Velocidad de confirmación",
"onchainTransferScreen_toAddress": "Dirección Bitcoin",
"payCommon_payToAddress": "Pagar a esta dirección",
"payCommon_payWithLightning": "Pagar con Lightning",
"payCommon_payWithBitcoin": "Pagar con Bitcoin",
"payCommon_receive": "Recibir",
"payCommon_receiveEcash": "Recibir ecash",
"payCommon_receiveInPerson": "Recibir en persona",
@@ -317,7 +334,7 @@
"pos_scanToPay": "Escanea para pagar",
"pos_waitingForPayment": "Esperando el pago...",
"payerMemo": "Memo para el pagador",
"payWithLightningDesc": "Ingrese o pegue la dirección Lightning, la factura o el código de pago LNURL al que desea pagar.",
"payWithBitcoinDesc": "Introduce o pega una factura Lightning, dirección o código de pago, o una dirección Bitcoin a la que quieras pagar.",
"pictureRetrieveFail": "No se pudieron recuperar las imágenes del servidor, inténtelo nuevamente más tarde.",
"placeholderNpub": "npub...",
"placeholderRelay": "wss://...",
@@ -617,6 +634,7 @@
"tranDetailScreen_sentFrom": "Enviado desde",
"tranDetailScreen_sentTo": "Enviado a",
"tranDetailScreen_status": "Estado",
"tranDetailScreen_outpoint": "Transacción Bitcoin",
"tranDetailScreen_successRetrieveEcashAfterRetry": "El eCash se recibió correctamente tras reintentarlo. Se creó una nueva transacción.",
"tranDetailScreen_tokenTracking": "Seguimiento de tokens",
"tranDetailScreen_tokenTrackingDesc": "Tokens de ecash registrados para fines de depuración",
@@ -681,6 +699,9 @@
"transactionCommon_youSent": "Tú enviaste",
"transactionResult_lightningInvoicePaidFee": "La factura Lightning ha sido pagada. La tarifa fue %{fee}.",
"transactionResult_lightningPaymentFailed": "El pago Lightning falló. El ecash reservado ha sido devuelto al saldo disponible.",
"transactionResult_onchainPaymentBroadcast": "El pago de Bitcoin ha sido transmitido. Se completará una vez confirmado en la blockchain.",
"transactionResult_onchainPaymentConfirmed": "Pago de Bitcoin confirmado en la blockchain.",
"transactionResult_onchainPaymentFailed": "El pago de Bitcoin falló. El ecash reservado ha sido devuelto al saldo disponible.",
"transferScreen_donationSuccessMessage": "La donación para %{donationForName} se ha realizado correctamente y la dirección de tu billetera se ha actualizado. ¡Gracias!",
"transferScreen_insufficientFunds": "No hay suficiente saldo en %{currency} para pagar el importe de la factura y las tarifas previstas: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits aún no admite la introducción de datos de identidad del pagador (LUD18).",
@@ -706,7 +727,7 @@
"updateScreen_updateNotAvailableDesc": "Bueno, estás ejecutando la última versión de la aplicación.",
"updateScreen_updateNow": "Actualizar y reiniciar",
"updateScreen_updateSize": "Tamaño de descarga",
"userErrorMissingLightningData": "Falta factura de Lightning, código de pago o dirección.",
"userErrorMissingPaymentData": "Falta la factura Lightning, el código de pago, la dirección o la dirección Bitcoin.",
"wallerScreen_mintButton": "Menta",
"walletAddressRecovery": "Recuperación de la dirección de la billetera",
"walletAddressRecoveryDesc": "Usa tu frase inicial de otro dispositivo para obtener la dirección de tu billetera original en este. Esto no recuperará saldos.",
@@ -720,17 +741,15 @@
"walletScreen_mintExists": "Esta menta ya existe",
"walletScreen_pay": "Pagar",
"walletScreen_paymentSentSuccess": "La solicitud de pago ha sido enviada exitosamente.",
"walletScreen_payWithLightning": "Pagar con Lightning",
"walletScreen_payWithLightningDesc": "Pagar factura o a una dirección Lightning",
"walletScreen_receiveEcash": "Recibir Ecash",
"walletScreen_receiveEcashDesc": "Pegar o escanear el token de ecash",
"walletScreen_sendEcash": "Enviar dinero electrónico",
"walletScreen_sendEcashDesc": "Comparte ecash o envíaselo a uno de tus contactos",
"walletScreen_optionBitcoin": "Bitcoin",
"walletScreen_optionBitcoinSendDesc": "Factura Lightning o dirección onchain. Se aplican comisiones de red.",
"walletScreen_optionEcashReceiveDesc": "Pega o escanea un token ecash",
"walletScreen_optionEcash": "Ecash",
"walletScreen_optionEcashSendDesc": "Instantáneo, de billetera a billetera",
"walletScreen_spentToday": "Pasé hoy",
"walletScreen_startByFunding": "Comience por financiar su billetera",
"walletScreen_topup": "Recarga",
"walletScreen_topupWithLightning": "Recarga con Lightning",
"walletScreen_topupWithLightningDesc": "Crea una factura Lightning para recargar tu saldo",
"walletScreen_optionBitcoinReceiveDesc": "Recarga mediante Lightning u onchain",
"warning": "Advertencia",
"welcomeScreen_creatingKeys": "Creando claves de billetera...",
"welcomeScreen_creatingProfile": "Creando perfil de billetera...",
@@ -747,5 +766,22 @@
"welcomeScreen_terms_agreePrefix": "He leído y acepto los",
"welcomeScreen_terms_agreeTerms": "Términos",
"welcomeScreen_terms_agreeConjunction": "y la",
"welcomeScreen_terms_agreePrivacy": "Política de Privacidad"
"welcomeScreen_terms_agreePrivacy": "Política de Privacidad",
"mintSelector_noTopupSupport": "Recargas no soportadas para esta moneda",
"mintSelector_noPayoutSupport": "Pagos no soportados para esta moneda",
"mintSelector_noOnchainPayoutSupport": "Esta casa de cambio no admite pagos en Bitcoin",
"topupScreen_onchainAddress": "Dirección",
"topupScreen_onchainAddressToPay": "Dirección Bitcoin a pagar",
"topupScreen_onchainAmountIsHint": "El importe es solo una sugerencia. Tu saldo se actualizará con lo que realmente llegue, sea más o menos.",
"topupScreen_onchainConfirmationsNeeded": "Tu ecash aparecerá cuando el depósito se confirme en la red Bitcoin.",
"tranDetail_checkForDeposits": "Buscar depósitos",
"tranDetail_checkStatus": "Comprobar estado",
"tranDetail_onchainStillUnconfirmed": "El pago aún está esperando la confirmación en la blockchain.",
"tranDetail_onchainNoDeposits": "Aún no se encontraron nuevos depósitos en esta dirección.",
"tranDetail_onchainOutpoint": "Transacción Bitcoin",
"transactionType_topupOnchain": "Recarga onchain",
"transactionType_transferOnchain": "Pago onchain",
"qr_hintHeading": "¿Cómo funciona esto?",
"tranDetailScreen_paymentRequest": "Solicitud de pago",
"topupScreen_ligtningInvoice": "Factura"
}

View File

@@ -181,8 +181,8 @@
"invalidRelayUrl": "URL de relay inválido.",
"lightningAddress": "Endereço Lightning",
"lightningInvoiceSharedWaiting": "A invoice Lightning foi compartilhada, aguardando pagamento.",
"lightningPayScreen_onPasteEmptyClipboard": "Primeiro copie invoice Lightning, endereço ou código de pagamento. Depois cole.",
"lightningPayScreen_payHeading": "Pagar",
"payScreen_onPasteEmptyClipboard": "Primeiro copie uma fatura Lightning, endereço, código de pagamento ou um endereço Bitcoin para pagar. Depois cole.",
"payScreen_payHeading": "Pagar",
"loadingPublicInfo": "Carregando informações públicas",
"localBackupDisabled": "Backup local desativado",
"localBackupEnabled": "Backup local ativado",
@@ -303,8 +303,25 @@
"payCommon_payFromWallet": "Pagar da carteira",
"payCommon_payMe": "Pague-me",
"payCommon_payNow": "Pagar agora",
"onchainTransferScreen_broadcastTitle": "Pagamento enviado",
"onchainTransferScreen_changeFee": "Alterar",
"onchainTransferScreen_feeHint": "Os pagamentos em Bitcoin são liquidados na blockchain. A taxa de mineração depende da rapidez com que quer o pagamento confirmado.",
"onchainTransferScreen_feeTierBlocks": "Cerca de %{blocks} blocos",
"onchainTransferScreen_feeTierReserve": "Até %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "Cerca de %{blocks} blocos · até %{amount} %{currency}",
"onchainTransferScreen_memoFromPayee": "Descrição do pagamento",
"onchainTransferScreen_networkFee": "Taxa de rede",
"onchainTransferScreen_noMintTitle": "Nenhuma casa da moeda para pagar",
"onchainTransferScreen_noMintDesc": "Não tem nenhuma casa da moeda com esta moeda. Adicione uma e carregue-a antes de enviar Bitcoin.",
"onchainTransferScreen_noOnchainMintTitle": "Nenhuma casa da moeda pode pagar endereços Bitcoin",
"onchainTransferScreen_noOnchainMintDesc": "Nenhuma das suas casas da moeda com este saldo suporta pagamentos em Bitcoin. Adicione uma que suporte, ou pague com Lightning.",
"onchainTransferScreen_mintNoOnchainSupport": "Esta casa da moeda não suporta pagamentos em Bitcoin. Selecione outra.",
"onchainTransferScreen_noFeeOptions": "A casa da moeda não devolveu opções de taxa para este pagamento e não pode ser pago.",
"onchainTransferScreen_requestQuote": "Continuar",
"onchainTransferScreen_selectFee": "Velocidade de confirmação",
"onchainTransferScreen_toAddress": "Endereço Bitcoin",
"payCommon_payToAddress": "Pagar para este endereço",
"payCommon_payWithLightning": "Pagar com Lightning",
"payCommon_payWithBitcoin": "Pagar com Bitcoin",
"payCommon_receive": "Receber",
"payCommon_receiveEcash": "Receber ecash",
"payCommon_receiveInPerson": "Receber pessoalmente",
@@ -318,7 +335,7 @@
"pos_scanToPay": "Escaneie para pagar",
"pos_waitingForPayment": "Aguardando pagamento...",
"payerMemo": "Memorando para o pagador",
"payWithLightningDesc": "Insira ou cole endereço Lightning, invoice ou código de pagamento LNURL para pagar.",
"payWithBitcoinDesc": "Introduza ou cole uma fatura Lightning, endereço ou código de pagamento, ou um endereço Bitcoin que pretende pagar.",
"pictureRetrieveFail": "Não foi possível recuperar imagens do servidor, tente mais tarde.",
"placeholderNpub": "npub...",
"placeholderRelay": "wss://...",
@@ -618,6 +635,7 @@
"tranDetailScreen_sentFrom": "Enviado de",
"tranDetailScreen_sentTo": "Enviado para",
"tranDetailScreen_status": "Status",
"tranDetailScreen_outpoint": "Transação Bitcoin",
"tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash recebido com sucesso após nova tentativa. Nova transação criada.",
"tranDetailScreen_tokenTracking": "Rastreamento de token",
"tranDetailScreen_tokenTrackingDesc": "Tokens ecash registrados para depuração",
@@ -682,6 +700,9 @@
"transactionCommon_youSent": "Você enviou",
"transactionResult_lightningInvoicePaidFee": "A fatura Lightning foi paga. A taxa foi %{fee}.",
"transactionResult_lightningPaymentFailed": "O pagamento Lightning falhou. O ecash reservado foi devolvido ao saldo disponível.",
"transactionResult_onchainPaymentBroadcast": "O pagamento Bitcoin foi transmitido. Será concluído assim que confirmado na blockchain.",
"transactionResult_onchainPaymentConfirmed": "Pagamento Bitcoin confirmado na blockchain.",
"transactionResult_onchainPaymentFailed": "O pagamento Bitcoin falhou. O ecash reservado foi devolvido ao saldo disponível.",
"transferScreen_donationSuccessMessage": "Doação para %{donationForName} paga com sucesso e endereço atualizado. Obrigado!",
"transferScreen_insufficientFunds": "Saldo insuficiente em %{currency} para pagar invoice e taxas: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits ainda não suporta dados de identidade do pagador (LUD18).",
@@ -707,7 +728,7 @@
"updateScreen_updateNotAvailableDesc": "Ótimo, você está na versão mais recente do app.",
"updateScreen_updateNow": "Atualizar e reiniciar",
"updateScreen_updateSize": "Tamanho do download",
"userErrorMissingLightningData": "Faltando invoice Lightning, código de pagamento ou endereço.",
"userErrorMissingPaymentData": "Falta a fatura Lightning, código de pagamento, endereço ou endereço Bitcoin.",
"wallerScreen_mintButton": "Mint",
"walletAddressRecovery": "Recuperação de endereço da carteira",
"walletAddressRecoveryDesc": "Use sua frase seed de outro dispositivo para obter endereço original aqui. Isso não recupera saldos.",
@@ -721,17 +742,15 @@
"walletScreen_mintExists": "Este mint já existe",
"walletScreen_pay": "Pagar",
"walletScreen_paymentSentSuccess": "Solicitação de pagamento enviada com sucesso.",
"walletScreen_payWithLightning": "Pagar com Lightning",
"walletScreen_payWithLightningDesc": "Pagar invoice ou para endereço Lightning",
"walletScreen_receiveEcash": "Receber Ecash",
"walletScreen_receiveEcashDesc": "Colar ou escanear token ecash",
"walletScreen_sendEcash": "Enviar ecash",
"walletScreen_sendEcashDesc": "Compartilhar ecash ou enviar para contatos",
"walletScreen_optionBitcoin": "Bitcoin",
"walletScreen_optionBitcoinSendDesc": "Fatura Lightning ou endereço onchain. Aplicam-se taxas de rede.",
"walletScreen_optionEcashReceiveDesc": "Cole ou digitalize um token ecash",
"walletScreen_optionEcash": "Ecash",
"walletScreen_optionEcashSendDesc": "Instantâneo, de carteira para carteira",
"walletScreen_spentToday": "Gasto hoje",
"walletScreen_startByFunding": "Comece financiando sua carteira",
"walletScreen_topup": "Recarga",
"walletScreen_topupWithLightning": "Recarregar com Lightning",
"walletScreen_topupWithLightningDesc": "Criar invoice Lightning para recarregar saldo",
"walletScreen_optionBitcoinReceiveDesc": "Carregue via Lightning ou onchain",
"warning": "Aviso",
"welcomeScreen_creatingKeys": "Criando chaves da carteira...",
"welcomeScreen_creatingProfile": "Criando perfil da carteira...",
@@ -748,5 +767,22 @@
"welcomeScreen_terms_agreePrefix": "Li e concordo com os",
"welcomeScreen_terms_agreeTerms": "Termos",
"welcomeScreen_terms_agreeConjunction": "e a",
"welcomeScreen_terms_agreePrivacy": "Política de Privacidade"
"welcomeScreen_terms_agreePrivacy": "Política de Privacidade",
"mintSelector_noTopupSupport": "Carregamentos não suportados para esta moeda",
"mintSelector_noPayoutSupport": "Pagamentos não suportados para esta moeda",
"mintSelector_noOnchainPayoutSupport": "Esta casa da moeda não suporta pagamentos em Bitcoin",
"topupScreen_onchainAddress": "Endereço",
"topupScreen_onchainAddressToPay": "Endereço Bitcoin a pagar",
"topupScreen_onchainAmountIsHint": "O valor é apenas uma sugestão. O seu saldo será atualizado com o que realmente chegar, seja mais ou menos.",
"topupScreen_onchainConfirmationsNeeded": "O seu ecash aparecerá assim que o depósito for confirmado na rede Bitcoin.",
"tranDetail_checkForDeposits": "Procurar depósitos",
"tranDetail_checkStatus": "Verificar estado",
"tranDetail_onchainStillUnconfirmed": "O pagamento ainda aguarda confirmação na blockchain.",
"tranDetail_onchainNoDeposits": "Ainda não foram encontrados novos depósitos neste endereço.",
"tranDetail_onchainOutpoint": "Transação Bitcoin",
"transactionType_topupOnchain": "Carregamento onchain",
"transactionType_transferOnchain": "Pagamento onchain",
"qr_hintHeading": "Como é que isto funciona?",
"tranDetailScreen_paymentRequest": "Pedido de pagamento",
"topupScreen_ligtningInvoice": "Fatura"
}

View File

@@ -181,8 +181,8 @@
"invalidRelayUrl": "Neplatná relay URL adresa.",
"lightningAddress": "Lightning adresa",
"lightningInvoiceSharedWaiting": "Lightning invoice bola vyzdieľaná, čaká na zaplatenie príjemcom.",
"lightningPayScreen_onPasteEmptyClipboard": "Najprv skopíruj Lightning invoice, adresu alebo platobný kód, potom vlož.",
"lightningPayScreen_payHeading": "Zaplať",
"payScreen_onPasteEmptyClipboard": "Najprv skopírujte Lightning faktúru, adresu, platobný kód alebo Bitcoin adresu, ktorej chcete zaplatiť. Potom vložte.",
"payScreen_payHeading": "Zaplatiť",
"loadingPublicInfo": "Načítavam verejné informácie",
"localBackupDisabled": "Lokálna záloha je vypnutá",
"localBackupEnabled": "Lokálna záloha je zapnutá",
@@ -303,8 +303,25 @@
"payCommon_payFromWallet": "Zaplať z peňaženky",
"payCommon_payMe": "Zaplať mi",
"payCommon_payNow": "Zaplať teraz",
"onchainTransferScreen_broadcastTitle": "Platba odoslaná",
"onchainTransferScreen_changeFee": "Zmeniť",
"onchainTransferScreen_feeHint": "Bitcoinové platby sa vyrovnávajú priamo v blockchaine. Poplatok pre ťažiarov závisí od toho, ako rýchlo má byť platba potvrdená.",
"onchainTransferScreen_feeTierBlocks": "Približne %{blocks} blokov",
"onchainTransferScreen_feeTierReserve": "Až do %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "Približne %{blocks} blokov · až do %{amount} %{currency}",
"onchainTransferScreen_memoFromPayee": "Popis platby",
"onchainTransferScreen_networkFee": "Sieťový poplatok",
"onchainTransferScreen_noMintTitle": "Žiadna mincovňa, z ktorej platiť",
"onchainTransferScreen_noMintDesc": "Nemáte mincovňu s touto menou. Pridajte si ju a dobite ju skôr, než odošlete Bitcoin.",
"onchainTransferScreen_noOnchainMintTitle": "Žiadna mincovňa nedokáže platiť na Bitcoin adresy",
"onchainTransferScreen_noOnchainMintDesc": "Žiadna z vašich mincovní s týmto zostatkom nepodporuje Bitcoin výplaty. Pridajte mincovňu, ktorá ich podporuje, alebo zaplaťte cez Lightning.",
"onchainTransferScreen_mintNoOnchainSupport": "Táto mincovňa nepodporuje Bitcoin výplaty. Vyberte inú mincovňu.",
"onchainTransferScreen_noFeeOptions": "Mincovňa nevrátila žiadne možnosti poplatku pre túto platbu, nedá sa zaplatiť.",
"onchainTransferScreen_requestQuote": "Pokračovať",
"onchainTransferScreen_selectFee": "Rýchlosť potvrdenia",
"onchainTransferScreen_toAddress": "Bitcoin adresa",
"payCommon_payToAddress": "Zaplať na adresu",
"payCommon_payWithLightning": "Zaplať cez Lightning",
"payCommon_payWithBitcoin": "Zaplatiť Bitcoinom",
"payCommon_receive": "Prijmi",
"payCommon_receiveEcash": "Prijmy ecash",
"payCommon_receiveInPerson": "Prijmy osobne",
@@ -318,7 +335,7 @@
"pos_scanToPay": "Naskenuj a zaplať",
"pos_waitingForPayment": "Čakám na platbu...",
"payerMemo": "Poznámka pre príjemcu",
"payWithLightningDesc": "Zadaj Lightning adresu, invoice alebo LNURL kód ktorý chceš zaplatiť.",
"payWithBitcoinDesc": "Zadajte alebo vložte Lightning faktúru, adresu či platobný kód, alebo Bitcoin adresu, ktorej chcete zaplatiť.",
"pictureRetrieveFail": "Nejde načítať obrázky zo servera, skús neskôr.",
"placeholderNpub": "npub...",
"placeholderRelay": "wss://...",
@@ -618,6 +635,7 @@
"tranDetailScreen_sentFrom": "Poslané od",
"tranDetailScreen_sentTo": "Poslané komu",
"tranDetailScreen_status": "Status",
"tranDetailScreen_outpoint": "Bitcoin transakcia",
"tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash bol prijatý po opätovnom opakovaní. Nová transakcia vytvorená.",
"tranDetailScreen_tokenTracking": "Sledovanie tokenov",
"tranDetailScreen_tokenTrackingDesc": "Zaznamenané ecash tokeny na účely ladenia",
@@ -682,6 +700,9 @@
"transactionCommon_youSent": "Poslal si",
"transactionResult_lightningInvoicePaidFee": "Lightning faktúra bola zaplatená. Poplatok bol %{fee}.",
"transactionResult_lightningPaymentFailed": "Platba cez Lightning zlyhala. Rezervovaný ecash bol vrátený do disponibilného zostatku.",
"transactionResult_onchainPaymentBroadcast": "Bitcoinová platba bola odoslaná do siete. Dokončí sa po potvrdení v blockchaine.",
"transactionResult_onchainPaymentConfirmed": "Bitcoinová platba bola potvrdená v blockchaine.",
"transactionResult_onchainPaymentFailed": "Bitcoinová platba zlyhala. Rezervovaný ecash bol vrátený do disponibilného zostatku.",
"transferScreen_donationSuccessMessage": "Dar pre %{donationForName} bol úspešne zaplatený a vaša adresa peňaženky bola aktualizovaná. Ďakujeme!",
"transferScreen_insufficientFunds": "Nie je dostatočný zostatok %{currency} na zaplatenie sumy invoice a poplatku: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits ešte nepodporuje zadanie údajov platiteľa (LUD1á)",
@@ -707,7 +728,7 @@
"updateScreen_updateNotAvailableDesc": "Fajn, máš najnovšiu verziu aplikácie.",
"updateScreen_updateNow": "Aktualizuj a reštartuj",
"updateScreen_updateSize": "Veľkosť balíka",
"userErrorMissingLightningData": "Chýbajúca Lightning invoice, LNURL kód, alebo adresa.",
"userErrorMissingPaymentData": "Chýba Lightning faktúra, platobný kód, adresa alebo Bitcoin adresa.",
"wallerScreen_mintButton": "Mint",
"walletAddressRecovery": "Obnovenie adresy peňaženky",
"walletAddressRecoveryDesc": "Pomocou mnemonickej vety z iného mobilu prenes svoju pôvodnú adresu peňaženky na tento mobil. Táto funkcia neobnovuje zostatok ecash.",
@@ -721,17 +742,15 @@
"walletScreen_mintExists": "Tento mint už existuje",
"walletScreen_pay": "Zaplať",
"walletScreen_paymentSentSuccess": "Požiadavka na platbu bola odoslaná.",
"walletScreen_payWithLightning": "Zaplať cez Lightning",
"walletScreen_payWithLightningDesc": "Zaplať invoice alebo na Lightning adresu",
"walletScreen_receiveEcash": "Prijmi ecash",
"walletScreen_receiveEcashDesc": "Vlož alebo skenuj ecash token",
"walletScreen_sendEcash": "Pošli ecash",
"walletScreen_sendEcashDesc": "Zdieľaj ecash alebo pošli ecash jednému zo svojich kontaktov",
"walletScreen_optionBitcoin": "Bitcoin",
"walletScreen_optionBitcoinSendDesc": "Lightning faktúra alebo onchain adresa. Účtujú sa sieťové poplatky.",
"walletScreen_optionEcashReceiveDesc": "Vložte alebo naskenujte ecash token",
"walletScreen_optionEcash": "Ecash",
"walletScreen_optionEcashSendDesc": "Okamžite, z peňaženky do peňaženky",
"walletScreen_spentToday": "Dnes minuté",
"walletScreen_startByFunding": "Začni doplnením peňaženky",
"walletScreen_topup": "Pridaj",
"walletScreen_topupWithLightning": "Pridaj cez Lightning",
"walletScreen_topupWithLightningDesc": "Vytvor Lightning invoice na pridanie ecash",
"walletScreen_optionBitcoinReceiveDesc": "Dobitie cez Lightning alebo onchain",
"warning": "Upozornenie",
"welcomeScreen_creatingKeys": "Vytváram kľúče peňaženky...",
"welcomeScreen_creatingProfile": "Vytváram profil peňaženky...",
@@ -748,5 +767,22 @@
"welcomeScreen_terms_agreePrefix": "Prečítal som si a súhlasím s",
"welcomeScreen_terms_agreeTerms": "Podmienkami",
"welcomeScreen_terms_agreeConjunction": "a",
"welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov"
"welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov",
"mintSelector_noTopupSupport": "Dobitie nie je pre túto menu podporované",
"mintSelector_noPayoutSupport": "Výplaty nie sú pre túto menu podporované",
"mintSelector_noOnchainPayoutSupport": "Táto mincovňa nepodporuje Bitcoin výplaty",
"topupScreen_onchainAddress": "Adresa",
"topupScreen_onchainAddressToPay": "Bitcoin adresa na zaplatenie",
"topupScreen_onchainAmountIsHint": "Suma je len návrh. Váš zostatok sa aktualizuje podľa toho, čo skutočne dorazí — viac alebo menej.",
"topupScreen_onchainConfirmationsNeeded": "Váš ecash sa objaví, keď sa vklad potvrdí v sieti Bitcoin.",
"tranDetail_checkForDeposits": "Skontrolovať vklady",
"tranDetail_checkStatus": "Skontrolovať stav",
"tranDetail_onchainStillUnconfirmed": "Platba stále čaká na potvrdenie v blockchaine.",
"tranDetail_onchainNoDeposits": "Na tejto adrese zatiaľ neboli nájdené žiadne nové vklady.",
"tranDetail_onchainOutpoint": "Bitcoin transakcia",
"transactionType_topupOnchain": "Dobitie onchain",
"transactionType_transferOnchain": "Onchain platba",
"qr_hintHeading": "Ako to funguje?",
"tranDetailScreen_paymentRequest": "Platobná požiadavka",
"topupScreen_ligtningInvoice": "Faktúra"
}

View File

@@ -1,20 +1,43 @@
import {cast, detach, flow, getParent, getRoot, getSnapshot, IAnyStateTreeNode, Instance, isAlive, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
import {cast, detach, flow, getRoot, getSnapshot, IAnyStateTreeNode, Instance, isAlive, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
import {withSetPropAction} from './helpers/withSetPropAction'
import {
type GetInfoResponse,
type MintKeys as CashuMintKeys,
type MintKeyset as CashuMintKeyset,
type MintMethod,
type SwapMethod,
Mint as CashuMint,
} from '@cashu/cashu-ts'
import {colors, getRandomIconColor} from '../theme'
import { log, Database } from '../services'
// From '../theme/colors', NOT the '../theme' barrel. The barrel re-exports
// useThemeColor, which imports '../services' — so a barrel import here would make
// this model transitively depend on mmkvStorage, keyChain (and nostr-tools) and the
// database, purely to read two colour constants. colors.ts imports nothing but
// react-native.
import {colors} from '../theme/colors'
// Direct imports, NOT the '../services' barrel: that barrel re-exports
// walletService -> syncQueueService -> notificationService (notifee) and keyChain
// (nostr-tools), so a barrel import here makes this model transitively depend on
// most of the app — and on native modules — to read a logger and the db facade.
import { log } from '../services/logService'
import { Database } from '../services/db'
import AppError, { Err } from '../utils/AppError'
import { MintUnit, MintUnits } from '../services/wallet/currency'
import { getRootStore } from './helpers/getRootStore'
import { generateId } from '../utils/utils'
import { generateId } from '../utils/generateId'
import { Proof } from './Proof'
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
import { normalizeMintUrl } from '../services/cashu/mintUrl'
/**
* A mint payment method — the rail the mint settles on.
*
* Aliased from cashu-ts so the set cannot drift from the library's. Note the
* wallet only implements bolt11 today; `onchain` arrives with NUT-30 and
* `bolt12` is advertised by some mints but not yet supported here. The capability
* views below can answer for any of them.
*/
export type PaymentMethod = MintMethod
export type MintBalance = {
mintUrl: string
@@ -62,9 +85,11 @@ const migrateSnapshot = (snapshot: any): any => {
* counter persistence ("W1").
*
* `mode: 'set'` persists an absolute value monotonically (never lowers);
* `mode: 'bump'` advances by a relative delta. The (mint, keyset) identity is
* read from the parent Mint node — a counter is always nested two levels up
* (counter -> proofsCounters array -> Mint).
* `mode: 'bump'` advances by a relative delta. The keyset id is the entire
* identity: NUT-13 derives from (seed, keysetId, counter), so a counter needs no
* mint reference. This used to walk two levels up the tree for the parent Mint's
* url — which meant a counter not yet attached to a Mint silently dropped its
* write, and a mint-url edit orphaned the row it had been writing to.
*
* This fires the instant cashu derives (right after onCountersReserved, BEFORE
* the reservation commit), so the advance is durable the moment the mint could
@@ -77,29 +102,18 @@ const migrateSnapshot = (snapshot: any): any => {
* not break a wallet flow, and there is a complementary safety net —
* commitReservation re-persists this same value ATOMICALLY with the proofs
* ("W2", see reservationsRepo), so even if this write is dropped a successful
* commit cannot leave the counter behind its proofs. A detached instance (a
* counter freshly created by createProofsCounter, not yet pushed onto a Mint)
* has no parent and is a no-op here.
* commit cannot leave the counter behind its proofs.
*/
const persistCounter = (self: any, mode: 'set' | 'bump', value: number): void => {
let mintUrl: string | undefined
try {
mintUrl = getParent<any>(self, 2)?.mintUrl
} catch {
return // not attached to a Mint (freshly created counter) — nothing to persist
}
if (!mintUrl) return
try {
if (mode === 'set') {
Database.setCounter(mintUrl, self.keyset, self.unit, value)
Database.setCounter(self.keyset, self.unit, value)
} else {
Database.bumpCounter(mintUrl, self.keyset, self.unit, value)
Database.bumpCounter(self.keyset, self.unit, value)
}
} catch (e: any) {
log.error('[persistCounter]', 'Counter write-through failed', {
error: e?.message,
mintUrl,
keyset: self.keyset,
})
}
@@ -178,7 +192,68 @@ export const MintModel = types
})
.actions(withSetPropAction) // TODO? start to use across app to avoid pure setter methods, e.g. mint.setProp('color', '#ccc')
.views(self => ({
/**
* The mint's advertised setting for a (method, unit) pair, or undefined when
* it does not offer that combination.
*
* NUT-04 (mint) and NUT-05 (melt) each advertise a flat list of method
* settings. A payment method is just an entry in that list — `onchain`
* (NUT-30) has no `nuts['30']` block of its own, it simply appears as
* `{method: 'onchain', unit: 'sat', ...}`. So capability is always a
* question about a (method, unit) pair, never about a NUT number.
*
* The setting also carries `min_amount` / `max_amount` (and, for onchain,
* `options.confirmations`), which callers need for limit checks.
*/
mintMethodSetting(method: PaymentMethod, unit: MintUnit): SwapMethod | undefined {
return self.mintInfo?.nuts?.['4']?.methods?.find(
m => m.method === method && m.unit === unit,
)
},
meltMethodSetting(method: PaymentMethod, unit: MintUnit): SwapMethod | undefined {
return self.mintInfo?.nuts?.['5']?.methods?.find(
m => m.method === method && m.unit === unit,
)
},
/** Does the mint advertise NUT-20 (signed mint quotes)? */
get supportsNut20(): boolean {
return self.mintInfo?.nuts?.['20']?.supported === true
},
/** True while we have never managed to cache this mint's info. */
get hasUnknownCapabilities(): boolean {
return !self.mintInfo
},
}))
.views(self => ({
/**
* Can this mint MINT (topup) with `method` in `unit`?
*
* Two rules layered on the advertised methods:
*
* - `onchain` additionally requires NUT-20. NUT-30 depends on it and the
* mint MUST refuse an onchain quote with no pubkey (error 20009), so an
* onchain method without NUT-20 is unusable to us.
*
* - When capabilities are UNKNOWN (info never cached — mint offline when
* added, or a very old wallet entry), bolt11 is assumed supported and
* everything else is not. bolt11 has been the only method the wallet
* ever used, so assuming it exactly preserves today's behaviour and
* cannot strand a user with an empty menu; anything newer has to prove
* itself. The check is otherwise symmetric across methods.
*/
supportsMint(method: PaymentMethod, unit: MintUnit): boolean {
if (self.hasUnknownCapabilities) return method === 'bolt11'
if (!self.mintMethodSetting(method, unit)) return false
if (method === 'onchain') return self.supportsNut20
return true
},
/** Can this mint MELT (pay out) with `method` in `unit`? See supportsMint. */
supportsMelt(method: PaymentMethod, unit: MintUnit): boolean {
if (self.hasUnknownCapabilities) return method === 'bolt11'
if (!self.meltMethodSetting(method, unit)) return false
// NUT-20 gates mint quotes only; melt needs no quote signature.
return true
},
}))
.actions(self => ({
addKeyset(keyset: CashuMintKeyset) {
@@ -387,14 +462,6 @@ export const MintModel = types
self.addKeys(key)
log.trace('[initKeys]', {newKeys: key.id})
},
validateURL(url: string) {
try {
new URL(url)
return true
} catch (e) {
return false
}
},
}))
.actions(self => ({
refreshKeysets(freshKeysets: CashuMintKeyset[]) {
@@ -414,8 +481,18 @@ export const MintModel = types
self.initKeys(key)
}
},
setMintInfo(info: GetInfoResponse & {time: number}) {
self.mintInfo = info
/**
* Cache the mint's NUT-06 info, stamping the fetch time.
*
* The stamp is applied HERE rather than at call sites: `time` drives the
* staleness check in WalletStore.getMint, and every caller used to pass a
* raw GetInfoResponse through a cast, leaving `time` undefined. `now -
* undefined` is NaN, NaN > ttl is false, so the TTL never fired and info
* was cached forever — which is how mints kept advertising capabilities
* they no longer had.
*/
setMintInfo(info: GetInfoResponse) {
self.mintInfo = {...info, time: Math.floor(Date.now() / 1000)}
log.trace('[setMintInfo]', {mintUrl: self.mintUrl})
},
getProofsCounterByKeysetId(keysetId: string) {
@@ -437,26 +514,6 @@ export const MintModel = types
return false
}
},
setMintUrl(url: string) {
if(self.validateURL(url)) {
const mintsStore = getRootStore(self).mintsStore
//log.trace('[setMintUrl]', {mintsStore})
if(!mintsStore.alreadyExists(url)) {
const proofsStore = getRootStore(self).proofsStore
proofsStore.updateMintUrl(self.mintUrl, url) // update mintUrl on mint's proofs
self.mintUrl = url
return true
}
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {url})
} else {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid Mint URL.', {url})
}
},
setId() { // migration
self.id = generateId(8)
},
@@ -481,9 +538,6 @@ export const MintModel = types
self.shortname = shortname
}),
setRandomColor() {
self.color = getRandomIconColor()
},
setColor(color: string) {
self.color = color
},
@@ -521,6 +575,88 @@ export const MintModel = types
return self.keysets.map(k => k.id)
}
}))
// Declared after the block holding setHostname so it can call it — MST types
// `self` without the actions of its own block.
.actions(self => ({
/**
* Move this mint to a new url, carrying over the state that points at it
* BY url.
*
* A mint url is a network LOCATOR, not an identity — the mint here is the
* same mint (callers confirm that by matching keysets), it just answers
* somewhere else now. So everything addressed by the old location has to
* follow. What follows, and what deliberately does not:
*
* - proofs — repointed. `proofs.mintUrl` is how a balance is found, and it
* is the last denormalized copy of the url left in the schema.
* - transactions — nothing to do. `mint` is the url the payment happened at,
* a historical fact that must NOT move; `mintId` carries the identity.
* - derivation counters — nothing to do: keyed by keysetId, which no url
* edit can disturb (see MINT_COUNTERS_COLUMNS).
* - onchain mint quotes, open reservations — nothing to do: they reference
* the mint by `mintId` and resolve the live url through it at use time.
* - in-flight requests, melt recovery — nothing to do: they are child rows
* of a transaction and hold no mint reference at all; the one mint-scoped
* query joins through the parent's mintId.
*
* Validation runs through the same normalizer as `addMint`, so a rename can
* no longer install a url that adding the same mint would have rejected.
*/
setMintUrl(url: string) {
// Throws AppError(VALIDATION_ERROR) on a malformed or non-https url.
const normalized = normalizeMintUrl(url)
const currentMintUrl = self.mintUrl
// Renaming to the url already held (or merely its trailing-slash
// spelling) is a no-op, not a duplicate — the check below would
// otherwise match this very mint and reject.
if (normalized === currentMintUrl) {
return true
}
const {mintsStore, proofsStore} = getRootStore(self)
// mintExists (normalized), not alreadyExists (literal string compare):
// the latter misses a twin differing only by a trailing slash, which
// would let one real mint become two Mint nodes.
if (mintsStore.mintExists(normalized)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {url: normalized})
}
// ONE SQLite transaction for the mint row and its proofs.
//
// This is what mints-in-SQLite bought. The url used to live in the MMKV
// snapshot while the proofs lived in SQLite, so the rename spanned two
// engines with nothing joining them: a crash in between left the proofs
// pointing at a url no mint owned — the money vanished from every
// per-mint balance while still counting in the total, and was unspendable
// (send and melt select proofs by mint).
//
// Written BEFORE the model changes, so a failure throws with the tree
// untouched rather than leaving memory ahead of the database. The mint's
// own observer will re-persist the same row afterwards; the equality
// guard makes that a no-op.
let hostname: string | null = null
try {
hostname = new URL(normalized).hostname
} catch {
// normalizeMintUrl already parsed it, so this cannot realistically
// fail; hostname simply stays null rather than aborting the rename.
}
Database.updateMintUrlWithProofs(self.id, currentMintUrl, normalized, hostname)
// Mirror into the in-memory caches now that SQLite is durable.
proofsStore.updateMintUrlInMemory(currentMintUrl, normalized)
self.mintUrl = normalized
self.setHostname() // derived from mintUrl — stale until recomputed
log.debug('[setMintUrl]', 'Mint url updated', {currentMintUrl, updatedMintUrl: normalized})
return true
},
}))

View File

@@ -6,11 +6,17 @@ import {
isStateTreeNode,
detach,
flow,
onSnapshot,
getSnapshot,
IDisposer,
} from 'mobx-state-tree'
import {withSetPropAction} from './helpers/withSetPropAction'
import {MintModel, Mint} from './Mint'
import {MintModel, Mint, MintProofsCounter} from './Mint'
import {normalizeMintUrl} from '../services/cashu/mintUrl'
import {log} from '../services/logService'
import {Database} from '../services'
// Direct import, not the '../services' barrel — see the note in Mint.ts.
import {Database} from '../services/db'
import type {MintRecord} from '../services/db'
import AppError, { Err } from '../utils/AppError'
import {
Mint as CashuMint,
@@ -32,6 +38,36 @@ export type MintsByUnit = {
mints: Mint[]
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-mint persistence.
//
// SQLite is the authority for mints; this model is the in-memory cache (the same
// split as proofs and transactions). Rather than call a write-through from each of
// the ~20 Mint mutators — where forgetting one is SILENT staleness, the exact bug
// class this whole effort has been about — each mint gets one onSnapshot observer
// that persists its row whenever anything in its subtree changes. It cannot be
// forgotten, and it gives ImportBackup persistence for free (applySnapshot fires it).
//
// Derivation counters are unaffected: `counter` is volatile, so it never appears in
// a snapshot and a bump never fires these.
// ─────────────────────────────────────────────────────────────────────────────
/** Project a Mint snapshot onto the row shape SQLite stores. */
const toMintRecord = (snapshot: any): MintRecord => ({
id: snapshot.id,
mintUrl: snapshot.mintUrl,
hostname: snapshot.hostname,
shortname: snapshot.shortname,
units: [...(snapshot.units ?? [])],
keysets: [...(snapshot.keysets ?? [])],
keys: [...(snapshot.keys ?? [])],
mintInfo: snapshot.mintInfo,
color: snapshot.color,
status: snapshot.status,
// types.Date snapshots as a timestamp; the column stores ISO.
createdAt: snapshot.createdAt ? new Date(snapshot.createdAt).toISOString() : undefined,
})
export const MintsStoreModel = types
.model('MintsStore', {
mints: types.array(MintModel),
@@ -47,15 +83,81 @@ export const MintsStoreModel = types
const {counterBackups, ...rest} = s
return rest
})
/**
* Mints are loaded from SQLite on startup; only blockedMintUrls is persisted
* here. Same treatment as proofs and transactions, and the reason for the whole
* move: mints (with every keyset's `keys` map) were the largest thing left in
* the tree, and `JSON.stringify(snapshot)` runs on EVERY MST action anywhere —
* including every proof mutation during a send. Stripping them takes that cost
* off the hot path.
*
* NOTE for anything that reads the snapshot: `getSnapshot(mintsStore).mints` is
* now ALWAYS empty. Code that wants the real mints must read the live instances
* (see ExportBackupScreen, which does exactly that for proofs already).
*/
.postProcessSnapshot(snapshot => ({
// `as typeof snapshot.mints` keeps the element type: a bare [] would widen
// the snapshot type to never[], and every reader of a mints snapshot (the
// backup format above all) would stop type-checking against a real Mint.
mints: [] as typeof snapshot.mints,
blockedMintUrls: snapshot.blockedMintUrls,
}))
/**
* Observer bookkeeping. VOLATILE, and on the store rather than at module scope:
* this is per-store state, and a module-level map would be shared by every store
* in the process — which production never notices (there is one root store) and
* a test immediately does.
*/
.volatile(() => ({
/** Live per-mint observers, by mint id, so they can be disposed. */
mintObservers: new Map<string, IDisposer>(),
/** Last payload written per mint id — the equality guard's memory. */
lastPersistedMints: new Map<string, string>(),
}))
.views(self => ({
findByUrl: (mintUrl: string | URL) => {
const mint = self.mints.find(m => m.mintUrl === mintUrl)
return mint ? mint : undefined
},
/**
* Resolve a mint by its stable local id — the lookup for stored rows that
* must survive a mint-url edit.
*
* `Mint.id` is an arbitrary local surrogate, and that is precisely why it
* works as a foreign key: it carries no meaning that can go stale, unlike a
* url, which is a network locator mints do change. It answers exactly one
* question — "which mint is this row about?".
*
* It must NEVER answer "are these two the same mint?". It is random, with no
* relation to the mint's keys, so it cannot recognise one mint reached by two
* urls. That question belongs to the keysets — see
* CashuUtils.isCollidingKeysetId.
*/
findById: (mintId: string) => {
return self.mints.find(m => m.id === mintId)
},
get allKeysetIds() {
return self.mints.flatMap(m => m.keysetIds)
},
}))
.views(self => ({
/**
* The mint a transaction belongs to.
*
* Use this rather than findByUrl(tx.mint). `tx.mint` records where the
* payment happened and is deliberately never rewritten, so once a mint moves
* url it stops matching — every one of that mint's transactions would look
* like it belonged to a mint that no longer exists.
*
* There is deliberately NO fallback to the url. A null mintId means the mint
* is not in this wallet, and that IS the answer; guessing from the url would
* resolve to whichever mint now answers it, which is the precise confusion
* mintId exists to prevent.
*/
findByTransaction: (tx: {mintId?: string | null}) => {
return tx.mintId ? self.findById(tx.mintId) : undefined
},
}))
.actions(withSetPropAction)
.actions(self => ({
mintExists: (mintUrl: string | URL) => {
@@ -67,31 +169,172 @@ export const MintsStoreModel = types
* Load the authoritative counter values from SQLite into the in-memory
* cache (startup / foreground resume). Monotonic per counter, so a value
* already advanced in memory is never lowered.
*
* Matched on keysetId alone. Keyset ids are unique wallet-wide (enforced by
* CashuUtils.isCollidingKeysetId), so the owning mint is whichever one holds
* the keyset — and a mint-url edit no longer strands the row, which used to
* leave the counter at its volatile 0 and risk blinded-secret reuse.
*/
/**
* Write one mint through, skipping writes that would change nothing.
*
* The guard compares the PERSISTED payload, not the raw snapshot: a mint's
* subtree changes for reasons its row does not care about, and without this
* every such action would re-write the mint and all its keysets.
*
* Errors are logged, never thrown: persistence must not break a wallet flow,
* and the next mutation retries. A dropped write costs a stale row, which the
* following write corrects.
*/
persistMint(snapshot: any) {
const record = toMintRecord(snapshot)
const serialized = JSON.stringify(record)
if (self.lastPersistedMints.get(record.id) === serialized) return
try {
Database.upsertMint(record)
self.lastPersistedMints.set(record.id, serialized)
} catch (e: any) {
log.error('[persistMint]', 'Mint write-through failed', {
error: e?.message,
mintId: record.id,
})
}
},
/** Dispose one mint's observer and forget its guard entry. */
unobserveMint(mintId: string) {
self.mintObservers.get(mintId)?.()
self.mintObservers.delete(mintId)
self.lastPersistedMints.delete(mintId)
},
}))
.actions(self => ({
/** Attach (or replace) one mint's persistence observer. */
observeMint(mint: Mint) {
const mintId = mint.id as string
self.unobserveMint(mintId)
self.mintObservers.set(mintId, onSnapshot(mint as any, snapshot => self.persistMint(snapshot)))
},
}))
.actions(self => ({
/**
* (Re)attach the per-mint persistence observers.
*
* Must run AFTER the mints are in place, never before: attaching first means
* loading them from SQLite immediately writes them straight back. Disposes
* any existing observers, so it is safe to call again after applySnapshot
* has replaced the whole array with new nodes.
*/
observeMints() {
for (const mintId of [...self.mintObservers.keys()]) self.unobserveMint(mintId)
for (const mint of self.mints) self.observeMint(mint as Mint)
},
/**
* Write every mint through, unconditionally.
*
* For when mints arrive already-formed rather than by mutation — i.e.
* ImportBackup's applySnapshot. Observers only fire on CHANGE, so freshly
* applied nodes would otherwise never reach SQLite.
*/
persistAllMints() {
for (const mint of self.mints) self.persistMint(getSnapshot(mint as any))
},
}))
.actions(self => ({
hydrateCountersFromDatabase() {
const rows = Database.getCounters()
if (rows.length === 0) return
const countersByKeyset = new Map<string, MintProofsCounter>()
for (const mint of self.mints) {
for (const counter of mint.proofsCounters) {
countersByKeyset.set(counter.keyset, counter)
}
}
for (const row of rows) {
const mint = self.mints.find(m => m.mintUrl === row.mintUrl)
const counter = mint?.proofsCounters.find(c => c.keyset === row.keysetId)
if (counter) {
counter.hydrateCounterFromDb(row.counter)
countersByKeyset.get(row.keysetId)?.hydrateCounterFromDb(row.counter)
}
},
/**
* Reconcile the mints between SQLite (the authority) and whatever
* applySnapshot restored, and leave the two agreeing. Startup, every launch.
*
* CONVERGES on the actual state; it is deliberately NOT gated on a migration
* version. SQLite and the MMKV snapshot are different engines that fail
* independently, so "the seed already ran" is a claim about a version number,
* not about the data — and when the two disagree, the version loses. That is
* not hypothetical: rootStore.version defaults to the CURRENT
* rootStoreModelVersion, so a factory reset stamps a wallet as fully migrated
* on the spot. Restore an older snapshot over that and the version-gated seed
* never runs, while postProcessSnapshot strips mints from every save — the
* mints are then in neither place, and vanish on the next launch.
*
* Three cases, all handled by looking rather than asking:
* - table has mints → SQLite wins; the snapshot no longer carries them.
* - table empty, store has mints → they exist only in the snapshot (a wallet
* upgrading from before mints moved here). Write them through.
* - both empty → a fresh wallet, or one with no mints. Nothing to do.
*
* Observers are attached AFTER the array settles: attaching first would make
* loading write straight back.
*/
hydrateMintsFromDatabase() {
const records = Database.getMints()
if (records.length === 0) {
// Nothing stored. Anything the snapshot restored is now the only copy
// that exists, so it has to be persisted before the next save strips
// it. persistAllMints is an upsert by mint id, so this is safe to hit
// on any launch.
if (self.mints.length > 0) {
log.info('[hydrateMintsFromDatabase]', 'Mints found only in the snapshot — persisting', {
count: self.mints.length,
})
self.observeMints()
self.persistAllMints()
} else {
log.trace('[hydrateMintsFromDatabase]', 'No mints in the database or the snapshot')
self.observeMints()
}
return
}
self.mints.replace(
records.map(r =>
MintModel.create({
...r,
// The column stores ISO; types.Date takes a Date or a unix ms
// timestamp, never a string.
createdAt: r.createdAt ? new Date(r.createdAt) : undefined,
// Recreate the counter shells addMint would have built via
// initKeyset. They are NOT optional: hydrateCountersFromDatabase
// fills the real index into these, and with no shell to fill,
// the counter would be created on demand at 0 and re-derive
// blinded secrets the mint has already signed. The values
// themselves are volatile and come from mint_counters.
proofsCounters: (r.keysets ?? []).map((k: any) => ({
keyset: k.id,
unit: k.unit,
})),
} as any),
),
)
self.observeMints()
log.trace('[hydrateMintsFromDatabase]', {loaded: self.mints.length})
},
}))
.actions(self => ({
addMint: flow(function* addMint(mintUrl: string) {
if(!mintUrl) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL is required.')
}
// Cashu spec: mint URL must be stripped of trailing slashes
mintUrl = mintUrl.replace(/\/$/, '')
if(!mintUrl.includes('.onion') && !mintUrl.startsWith('https')) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL needs to start with https.')
}
// Strips trailing slashes (cashu spec) and requires https outside Tor.
// Shared with Mint.setMintUrl so adding and renaming cannot disagree
// about what a valid mint url is.
mintUrl = normalizeMintUrl(mintUrl)
if(self.mintExists(mintUrl)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {mintUrl})
@@ -144,10 +387,17 @@ export const MintsStoreModel = types
self.mints.push(mintInstance)
// SQLite retains derivation counters by (mintUrl, keysetId) across
// mint removal (rows are never deleted), so a re-added mint recovers
// its real counter from the authority here. Monotonic, so a genuinely
// new mint (no row) simply stays at 0.
// Write it once, then let the observer carry every later change. The
// explicit write is needed because observers fire on CHANGE, and the
// mint was fully built before it entered the tree.
self.persistMint(getSnapshot(mintInstance as any))
self.observeMint(mintInstance as Mint)
// SQLite retains derivation counters by keysetId across mint removal
// (rows are never deleted), so a re-added mint recovers its real
// counter from the authority here — now also when it is re-added under
// a DIFFERENT url, since the row is keyed by keyset, not location.
// Monotonic, so a genuinely new mint (no row) simply stays at 0.
self.hydrateCountersFromDatabase()
return mintInstance
@@ -213,8 +463,17 @@ export const MintsStoreModel = types
}
if (mintInstance) {
const mintId = mintInstance.id as string
// Dispose BEFORE destroying: the observer would otherwise fire on the
// teardown and try to persist a dying node.
self.unobserveMint(mintId)
// No counter backup needed: the mint's mint_counters rows are
// retained in SQLite and restored on re-add via hydrate.
// retained in SQLite and restored on re-add via hydrate — which is
// also why removeMintById deliberately leaves them behind.
Database.removeMintById(mintId)
detach(mintInstance)
destroy(mintInstance)
log.info('[removeMint]', 'Mint removed from MintsStore')
@@ -240,6 +499,43 @@ export const MintsStoreModel = types
get allMints() {
return self.mints
},
/**
* The mints as a backup payload.
*
* Built from the LIVE instances, and it must stay that way: mints are
* mastered in SQLite and stripped in postProcessSnapshot, so
* `getSnapshot(mintsStore).mints` is ALWAYS empty. A backup taken from the
* store snapshot would contain zero mints, raise no error, and only reveal
* the loss on restore. That is the single most destructive mistake available
* in this file, which is why the export lives here — behind a test — rather
* than inline in the screen.
*
* Two deliberate deviations from a plain snapshot:
* - `keys` are dropped: they are re-fetched from the mint on import, and
* they are the bulk of the payload.
* - `counter` is re-injected per keyset. It is volatile (mastered in
* mint_counters), so it is absent from the snapshot — and a backup
* restored with counter 0 would re-derive blinded secrets the mint has
* already signed.
*/
get backupSnapshot() {
return {
mints: self.mints.map((liveMint: Mint) => {
const mint: any = JSON.parse(JSON.stringify(getSnapshot(liveMint as any)))
mint.keys = []
mint.proofsCounters?.forEach((pc: any) => {
const live = liveMint.proofsCounters?.find(c => c.keyset === pc.keyset)
if (live) pc.counter = live.counter
})
return mint
}),
blockedMintUrls: [...self.blockedMintUrls],
}
},
get groupedByHostname() {
const grouped: Record<string, MintsByHostname> = {}

View File

@@ -11,11 +11,12 @@ import {
import { getRootStore } from './helpers/getRootStore'
import AppError, { Err } from '../utils/AppError'
import { Mint, MintBalance } from './Mint'
import { Database } from '../services'
// Direct import, not the '../services' barrel — see the note in Mint.ts.
import { Database } from '../services/db'
import { ReservationTransactionUpdate } from '../services/sqlite'
import { MintUnit } from '../services/wallet/currency'
import { CashuProof } from '../services/cashu/cashuUtils'
import { generateId } from '../utils/utils'
import { generateId } from '../utils/generateId'
import { ProofReservation } from '../services/wallet/proofReservation'
export const ProofsStoreModel = types
@@ -73,6 +74,42 @@ import {
return undefined
},
/**
* Spendable proofs whose `mintUrl` matches no mint in the wallet, grouped by
* that url.
*
* This should always be empty, and is now a residual safety net rather than a
* live hazard. `proofs.mintUrl` is a denormalized copy of a mint's LOCATOR,
* joined to `mint.mintUrl` by string equality. That copy used to be able to
* drift: the mint's url lived in the MMKV snapshot while its proofs lived in
* SQLite, so a mint-url edit spanned two engines with no transaction between
* them, and a crash in the gap left proofs owned by no mint. Both now live in
* SQLite and the rename is a single transaction (mintsRepo.updateMintUrl), so
* that path is closed — this stays because the consequence is severe enough to
* keep watching for: the balance view counts such proofs in the unit total
* while attributing them to no mint (see `balances`), and they cannot be
* spent, since send and melt select proofs by mint.
*
* SPENT proofs are excluded: they are outside the balance and are exactly
* what a removed mint leaves behind.
*/
findOrphanedProofs(): Array<{mintUrl: string; count: number; amount: number}> {
const knownMintUrls = new Set(getRootStore(self).mintsStore.allMints.map((m: Mint) => m.mintUrl))
const byMintUrl = new Map<string, {mintUrl: string; count: number; amount: number}>()
for (const proof of self.proofs.values()) {
if (proof.state === 'SPENT') continue
if (knownMintUrls.has(proof.mintUrl)) continue
const entry = byMintUrl.get(proof.mintUrl) ?? {mintUrl: proof.mintUrl, count: 0, amount: 0}
entry.count += 1
entry.amount += proof.amount
byMintUrl.set(proof.mintUrl, entry)
}
return Array.from(byMintUrl.values())
},
getByMint(
mintUrl: string,
options: {
@@ -146,6 +183,37 @@ import {
})
}),
/**
* Report (never fix, never throw) proofs left pointing at a mint the wallet
* does not have — see findOrphanedProofs for how that can happen.
*
* Deliberately observe-only. The proofs are the user's money: hiding them,
* refusing to start, or forcing a recovery would all be worse outcomes than a
* total that reads a little high, and the state is self-healing once a mint
* is (re-)added at that url. This exists so we learn it happened at all —
* otherwise the balance view swallows it silently.
*
* Call at STARTUP, once proofs and mints are both loaded. Doing this from the
* `balances` computed instead would misfire: it re-runs constantly, and mint
* removal produces this exact state for a moment (MintsScreen destroys the
* mint in one action, moves its proofs to SPENT in the next). At startup the
* tree is settled, so anything found here is a genuine, persisted desync.
*/
reportOrphanedProofs(): Array<{mintUrl: string; count: number; amount: number}> {
const orphaned = self.findOrphanedProofs()
if (orphaned.length === 0) return orphaned
// error → Sentry in prod: this should be unreachable, and if it is not we
// want to know which url and how much is stranded.
log.error('[reportOrphanedProofs]', 'Spendable proofs reference a mint not in the wallet', {
orphaned,
totalAmount: orphaned.reduce((sum, o) => sum + o.amount, 0),
knownMintUrls: getRootStore(self).mintsStore.allMints.map((m: Mint) => m.mintUrl),
})
return orphaned
},
// Lock proofs locally during an outgoing operation (send, melt prepare, etc.)
// Does NOT touch pendingByMintSecrets — that is mint-reported pending.
moveToPending(proofs: Proof[]) {
@@ -203,7 +271,19 @@ import {
}
},
updateMintUrl(currentMintUrl: string, updatedMintUrl: string) {
/**
* Mirror a mint-url edit onto the in-memory proofs — MEMORY ONLY.
*
* The SQLite write is deliberately not here. It belongs in the same
* transaction as the mint's own row (Database.updateMintUrlWithProofs, called
* from Mint.setMintUrl), because those two writes must not be separable: a
* crash between them leaves proofs pointing at a url no mint owns, and the
* money then counts toward the total while belonging to no mint — and cannot
* be spent, since send and melt select proofs by mint.
*
* Call this only AFTER that transaction commits.
*/
updateMintUrlInMemory(currentMintUrl: string, updatedMintUrl: string) {
const updateInMap = (map: typeof self.proofs) => {
for (const proof of map.values()) {
if (proof.mintUrl === currentMintUrl) {
@@ -219,8 +299,7 @@ import {
updateInMap(self.proofs)
Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl)
log.trace('[updateMintUrl] Updated mint URL in proofs')
log.trace('[updateMintUrlInMemory] Mirrored mint url onto proofs')
},
// Import proofs from backup without validation or side effects
@@ -280,11 +359,17 @@ import {
originalTId: p.tId ?? null,
}))
// Resolved here rather than asked of every caller: they all identify the
// mint by url, but the reservation must survive that url changing while
// the operation is open (see ProofReservation.mintId).
const mintId = getRootStore(self).mintsStore.findByUrl(opts.mintUrl)?.id ?? null
// ATOMIC: write reservation row + lock proofs to PENDING in one batch.
Database.openReservation(
{
id: reservationId,
transactionId: opts.transactionId,
mintId: mintId ?? undefined,
mintUrl: opts.mintUrl,
unit: opts.unit,
operationType: opts.operationType,
@@ -307,6 +392,7 @@ import {
return {
id: reservationId,
transactionId: opts.transactionId,
mintId,
mintUrl: opts.mintUrl,
unit: opts.unit,
operationType: opts.operationType,
@@ -338,14 +424,29 @@ import {
} = {},
): { added: Proof[] } {
const mintsStore = getRootStore(self).mintsStore
const mintInstance = mintsStore.findByUrl(reservation.mintUrl)
// Resolve by stable id, not by the url captured when the reservation
// opened: a mint-url edit may have landed while this operation was in
// flight, and a url lookup would then find nothing and abort the commit
// of an operation the mint has already performed. Falls back to the url
// for a pre-v33 reservation, which carries no mintId.
const mintInstance =
(reservation.mintId ? mintsStore.findById(reservation.mintId) : undefined) ??
mintsStore.findByUrl(reservation.mintUrl)
if (!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint not found for reservation', {
mintId: reservation.mintId,
mintUrl: reservation.mintUrl,
reservationId: reservation.id,
})
}
// The mint's url NOW, which is not necessarily reservation.mintUrl. Every
// write below — SQLite and the MST mirror alike — uses this: filing the
// new proofs under a url no mint owns makes the balance simply vanish.
const commitMintUrl = mintInstance.mintUrl
// Snapshot the current derivation counter for every keyset the new
// proofs were derived under (a cashu proof's `id` IS its keyset id).
// WalletStore already advanced the model counter to
@@ -354,7 +455,7 @@ import {
// backstop, so a committed proof can never outlive its counter even if
// the W1 write-through was dropped. Monotonic, so the normal-path
// double write is a harmless no-op.
const counterUpdate: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}> = []
const counterUpdate: Array<{keysetId: string; unit?: string; counter: number}> = []
const seenKeysets = new Set<string>()
for (const group of changes.newProofs ?? []) {
for (const proof of group.proofs) {
@@ -363,7 +464,6 @@ import {
const counter = mintInstance.getProofsCounter(proof.id)
if (counter) {
counterUpdate.push({
mintUrl: reservation.mintUrl,
keysetId: proof.id,
unit: counter.unit,
counter: counter.counter,
@@ -380,7 +480,7 @@ import {
newProofs: changes.newProofs?.map(group => ({
proofs: group.proofs,
state: group.state,
mintUrl: reservation.mintUrl,
mintUrl: commitMintUrl,
unit: reservation.unit,
tId: group.tId,
})),
@@ -412,7 +512,7 @@ import {
if (existing) {
if (existing.state === 'SPENT') continue
if (isAlive(existing)) {
existing.setProp('mintUrl', reservation.mintUrl)
existing.setProp('mintUrl', commitMintUrl)
existing.setProp('tId', group.tId)
existing.setProp('unit', reservation.unit)
existing.setProp('state', group.state)
@@ -422,7 +522,7 @@ import {
const node = ProofModel.create({
...proof,
amount: Number(proof.amount),
mintUrl: reservation.mintUrl,
mintUrl: commitMintUrl,
tId: group.tId,
unit: reservation.unit,
state: group.state,
@@ -612,6 +712,20 @@ import {
const targetMintMap = isPending ? mintPendingMap : mintBalancesMap
const targetUnitMap = isPending ? unitPendingMap : unitBalancesMap
// A proof whose mintUrl matches no mint contributes to the UNIT total but
// to no mint bucket, so the total can exceed the sum of the mints. That is
// deliberate: the sats are real and the user's, and showing a few
// unreachable ones is a far better failure than hiding them, blocking
// access, or forcing a recovery. It is also self-healing — a mint
// (re-)added at that url re-attaches them.
//
// Detection is NOT done here. `balances` is a MobX computed that re-runs
// on every proof and mint change, and mint removal legitimately produces
// this 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 in
// between. Reporting from here would fire on every removal AND on every
// recompute. The steady-state check runs once at startup instead — see
// reportOrphanedProofs.
const mintBalance = targetMintMap.get(proof.mintUrl)
if (mintBalance) {
mintBalance.balances[proof.unit]! += proof.amount

View File

@@ -9,9 +9,10 @@ import {RelaysStoreModel} from './RelaysStore'
import {WalletStoreModel} from './WalletStore'
import {NwcStoreModel} from './NwcStore'
import {AuthStoreModel} from './AuthStore'
import { log } from '../services'
// Direct import, not the '../services' barrel — see the note in Mint.ts.
import { log } from '../services/logService'
export const rootStoreModelVersion = 37 // Update this if model changes require migrations defined in setupRootStore.ts
export const rootStoreModelVersion = 39 // Update this if model changes require migrations defined in setupRootStore.ts
/**
* A RootStore model.
*/

View File

@@ -1,7 +1,8 @@
import { flow, Instance, isAlive, SnapshotIn, SnapshotOut, types } from 'mobx-state-tree'
import { log } from '../services/logService'
import { MintUnit } from '../services/wallet/currency'
import { Database } from '../services'
// Direct import, not the '../services' barrel — see the note in Mint.ts.
import { Database } from '../services/db'
import { withSetPropAction } from './helpers/withSetPropAction'
@@ -16,8 +17,28 @@ export enum TransactionType {
RECEIVE_OFFLINE = 'RECEIVE_OFFLINE',
RECEIVE_NOSTR = 'RECEIVE_NOSTR', // not used
RECEIVE_BY_PAYMENT_REQUEST = 'RECEIVE_BY_PAYMENT_REQUEST',
/** Mint over Lightning (bolt11). */
TOPUP = 'TOPUP',
/** Melt over Lightning (bolt11). */
TRANSFER = 'TRANSFER',
/**
* Mint from a Bitcoin onchain deposit (NUT-30).
*
* Separate from TOPUP because the underlying quote behaves differently: it is a
* reusable address rather than a one-shot invoice, so several of these can point
* at the SAME quote (one per mint operation) via `quote`. The amount is a hint
* until the deposit confirms — senders may under- or overpay — and settles to
* whatever was actually minted. See onchainQuotesRepo.
*/
TOPUP_ONCHAIN = 'TOPUP_ONCHAIN',
/**
* Melt to a Bitcoin onchain address (NUT-30).
*
* Always asynchronous: the mint validates, returns PENDING, and broadcasts in
* the background, so this sits in PENDING until the transaction confirms.
* `outpoint` holds the resulting txid:vout once broadcast.
*/
TRANSFER_ONCHAIN = 'TRANSFER_ONCHAIN',
}
export enum TransactionStatus {
@@ -61,7 +82,22 @@ export const TransactionModel = types
paymentId: types.maybe(types.maybeNull(types.string)),
quote: types.maybe(types.maybeNull(types.string)),
memo: types.maybe(types.maybeNull(types.string)),
/**
* The url this payment happened at — a HISTORICAL fact, frozen once written.
* Display this; never dial it. A mint may since have moved, and rewriting it
* would misreport where the money actually went.
*/
mint: types.string,
/**
* Stable id (Mint.id) of the mint — the identity, and the only reference to
* resolve when acting: reaching the mint, or asking whether its mint is still
* in the wallet. Survives a mint-url edit, which is why `mint` no longer has
* to.
*
* Null for a transaction of a mint that has been removed, and for pre-v34
* rows the v38 backfill could not match.
*/
mintId: types.maybe(types.maybeNull(types.string)),
paymentRequest: types.maybe(types.maybeNull(types.string)),
zapRequest: types.maybe(types.maybeNull(types.string)),
inputToken: types.maybe(types.maybeNull(types.string)),
@@ -73,6 +109,13 @@ export const TransactionModel = types
status: types.frozen<TransactionStatus>(),
expiresAt: types.maybe(types.maybeNull(types.Date)),
createdAt: types.optional(types.Date, new Date()),
/**
* `txid:vout` of the onchain payment (TRANSFER_ONCHAIN), once the mint has
* broadcast it. Null until then — the mint returns PENDING first and
* broadcasts in the background. Lets the user follow the payment on a block
* explorer, which for an onchain send is the only way to see where it got to.
*/
outpoint: types.maybe(types.maybeNull(types.string)),
})
.views(self => ({}))
.actions(withSetPropAction)

View File

@@ -204,6 +204,7 @@ const TERMINAL_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.RECOVERED,
])
/** Statuses in which a transaction is still open — the complement of TERMINAL_STATUSES. */
const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.DRAFT,
TransactionStatus.PREPARED,

View File

@@ -12,7 +12,8 @@ import {
TransactionStatus,
TransactionType,
} from './Transaction'
import { Database } from '../services'
// Direct import, not the '../services' barrel — see the note in Mint.ts.
import { Database } from '../services/db'
import { log } from '../services/logService'
import { getRootStore } from './helpers/getRootStore'
import { formatDistance } from 'date-fns'
@@ -84,6 +85,12 @@ export const TransactionsStoreModel = types
const dbTransfers = Database.getPendingTransfers()
return dbTransfers.map(t => TransactionModel.create({ ...t }))
},
/** Onchain melts the mint has taken but the chain has not yet confirmed. */
getPendingOnchainTransfers(): Transaction[] {
const dbTransfers = Database.getPendingOnchainTransfers()
return dbTransfers.map(t => TransactionModel.create({ ...t }))
},
}))
@@ -148,11 +155,24 @@ export const TransactionsStoreModel = types
log.trace('[pruneRecentByUnit]', `${recent.length - keepIds.size} pruned for unit ${unit}`)
},
/**
* Drop recent-list entries whose mint is no longer in the wallet.
*
* Tests mintId, not the url. `tx.mint` is where the payment happened and is
* never rewritten, so a mint that has since moved would fail a url test and
* its whole history would vanish from the list as though the mint had been
* removed. The id answers the question actually being asked: does this
* transaction's mint still exist?
*
* A null mintId means exactly that it does not (removed mint, or history
* older than the v38 backfill), so those are dropped too — which is what
* this prune is for.
*/
pruneRecentWithoutCurrentMint() {
const { mintsStore } = getRootStore(self)
const validUrls = new Set(mintsStore.allMints.map(m => m.mintUrl))
const validIds = new Set(mintsStore.allMints.map((m: Mint) => m.id))
const newList = self.recentByUnit.filter(tx => validUrls.has(tx.mint))
const newList = self.recentByUnit.filter(tx => tx.mintId && validIds.has(tx.mintId))
if (newList.length !== self.recentByUnit.length) {
self.recentByUnit.replace(newList)
log.trace('[pruneRecentWithoutCurrentMint]', `${self.recentByUnit.length - newList.length} removed`)
@@ -170,8 +190,21 @@ export const TransactionsStoreModel = types
.actions(self => ({
// ── Main add (push + safe prune) ──
addTransaction: flow(function* addTransaction(newTxData: Partial<Transaction>) {
const dbTx: Transaction = yield Database.addTransactionAsync(newTxData)
// Return type is annotated because the body reaches the root store, and an
// inferred type would loop: RootStore -> TransactionsStore -> RootStore.
addTransaction: flow(function* addTransaction(
newTxData: Partial<Transaction>,
): Generator<any, Transaction, any> {
// Resolved here rather than asked of every call site: they all identify
// the mint by url, but the row needs the id that survives a url edit
// (see Transaction.mintId). Callers may still pass mintId explicitly.
const mintId: string | null =
newTxData.mintId ??
(newTxData.mint
? (getRootStore(self).mintsStore.findByUrl(newTxData.mint)?.id as string | undefined) ?? null
: null)
const dbTx: Transaction = yield Database.addTransactionAsync({...newTxData, mintId})
const tx = TransactionModel.create(dbTx)
self.transactionsMap.set(String(dbTx.id), tx)

View File

@@ -1,9 +1,10 @@
import {Instance, SnapshotOut, types, flow, getRoot, getSnapshot} from 'mobx-state-tree'
import {Instance, SnapshotOut, types, flow, getRoot, getSnapshot, isAlive} from 'mobx-state-tree'
import {
Mint as CashuMint,
Wallet as CashuWallet,
KeyChain as CashuKeyChain,
MeltQuoteBolt11Response,
MeltQuoteOnchainResponse,
setGlobalRequestOptions,
type MintKeys,
type MintKeyset,
@@ -14,6 +15,7 @@ import {
GetKeysResponse,
GetInfoResponse,
MintQuoteBolt11Response,
MintQuoteOnchainResponse,
type ProofState,
type OperationCounters,
type MeltPreview,
@@ -28,6 +30,7 @@ import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
import { Proof } from './Proof'
import { InFlightRequest, Mint } from './Mint'
import { MINT_INFO_TTL_SECONDS, isMintInfoStale } from './helpers/mintInfoStale'
import { getRootStore } from './helpers/getRootStore'
import { Transaction } from './Transaction'
@@ -39,6 +42,16 @@ import { Transaction } from './Transaction'
so that new cashu-ts instances are re-used over app lifecycle.
*/
/**
* How long a cached mint NUT-06 info stays fresh, in seconds.
*
* mintInfo carries the mint's advertised payment methods and their min/max
* limits, which drive what the wallet offers the user. An hour keeps a mint that
* gains (or drops) a method visible reasonably soon, while costing at most one
* getInfo() per mint per hour.
*/
export {MINT_INFO_TTL_SECONDS, isMintInfoStale}
export type ExchangeRate = {
currency: CurrencyCode, // 1 EUR, USD, ...
rate: number // in satoshis
@@ -231,12 +244,50 @@ export const WalletStoreModel = types
const keys: WalletKeys = yield self.getCachedWalletKeys()
return keys.SEED.seedHash
}),
/**
* Re-fetch a mint's NUT-06 info if the cached copy is older than the TTL.
*
* Capabilities (which payment methods a mint supports, and their min/max
* limits) are read off `mintInfo`, so a copy that never refreshes means the
* wallet keeps offering — or hiding — the wrong options. Errors are logged
* and swallowed: a capability refresh must never break the operation that
* happened to trigger it.
*/
refreshMintInfoIfStale: flow(function* refreshMintInfoIfStale(
mintUrl: string,
cashuMint: CashuMint,
) {
try {
const mintInstance = self.getMintModelInstance(mintUrl)
if (!mintInstance) return
if (!isMintInfoStale(mintInstance.mintInfo)) return
const info: GetInfoResponse = yield cashuMint.getInfo()
// the mint may have been removed while the call was in flight
if (!isAlive(mintInstance)) return
mintInstance.setMintInfo!(info)
log.trace('[WalletStore.refreshMintInfoIfStale] refreshed', {mintUrl})
} catch (e: any) {
log.warn('[WalletStore.refreshMintInfoIfStale]', {mintUrl, error: e.message})
}
}),
}))
.actions(self => ({
getMint: flow(function* getMint(mintUrl: string) {
const mint = self.mints.find(m => m.mintUrl === mintUrl)
log.trace('[WalletStore.getMint]', {cachedMint: !!mint})
if (mint) {
// Refresh capabilities on a TTL even when the cashu-ts instance is already
// cached. This check used to live below this early return, so a mint touched
// once in a session never refreshed again for the rest of it. Fire-and-forget
// so the caller's operation is not delayed by a getInfo() round trip;
// observers re-render when fresher info lands.
void self.refreshMintInfoIfStale(mintUrl, mint as CashuMint)
return mint as CashuMint
}
@@ -269,12 +320,10 @@ export const WalletStoreModel = types
// sync wallet state with fresh keysets, active statuses and keys
mintInstance.refreshKeysets!(keysets)
// fetch and cache mintInfo if not already cached
const {mintInfo} = mintInstance
const now = Math.floor(Date.now() / 1000)
if(!mintInfo || now - mintInfo.time > 3600) {
// fetch and cache mintInfo if not already cached or gone stale
if(isMintInfoStale(mintInstance.mintInfo)) {
const info: GetInfoResponse = yield newMint.getInfo()
mintInstance.setMintInfo!(info as GetInfoResponse & {time: number})
mintInstance.setMintInfo!(info)
}
}
@@ -501,7 +550,7 @@ export const WalletStoreModel = types
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, receiveParams)
Database.addInFlightRequest(transactionId, receiveParams)
}
let reservedCounters: OperationCounters | undefined
@@ -616,7 +665,7 @@ export const WalletStoreModel = types
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, sendParams)
Database.addInFlightRequest(transactionId, sendParams)
}
let reservedCounters: OperationCounters | undefined
@@ -694,11 +743,14 @@ export const WalletStoreModel = types
const cashuWallet: CashuWallet = yield self.getWallet(mintUrl, unit, {withSeed: false})
// v3.x returns ProofState[] array, not grouped by state
// Proofs carry both `secret` and `id`, satisfying the v5-compatible
// signature; the pre-4.5.1 `secret`-only overload is deprecated.
const proofStatesArray: ProofState[] = yield cashuWallet.checkProofsStates(proofs)
// Transform array into grouped structure expected by the rest of the code
// Map proof states back to original proofs using secret matching
// Transform flat ProofState[] into the grouped structure the rest of
// the code expects. cashu-ts returns states in input order (it batches
// by 100 and re-indexes each batch by Y), so proofs[i] pairs with
// proofStatesArray[i].
const proofsByState: {[key in CheckStateEnum]: CashuProof[]} = {
SPENT: [],
PENDING: [],
@@ -805,6 +857,177 @@ export const WalletStoreModel = types
)
}
}),
/**
* Ask the mint for an onchain (NUT-30) mint quote.
*
* Note there is NO amount: the quote is a Bitcoin address that can receive
* any number of deposits, so the mint has nothing to price. The `pubkey` is
* mandatory — NUT-30 requires NUT-20 quote locking and the mint MUST reject
* a quote request without one (error 20009).
*
* The full response is returned (not just the address): the caller has to
* persist `quote`, and minting later needs the whole object, because
* cashu-ts decides to sign from `quote.pubkey`.
*/
createOnchainMintQuote: flow(function* createOnchainMintQuote(
mintUrl: string,
unit: MintUnit,
pubkey: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const quoteResponse: MintQuoteOnchainResponse =
yield cashuMint.createMintQuoteOnchain({unit, pubkey})
log.info('[WalletStore.createOnchainMintQuote]', {
mintUrl,
quote: quoteResponse.quote,
address: quoteResponse.request,
})
return quoteResponse
} catch (e: any) {
let message = 'The mint could not return an onchain mint quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions
throw new AppError(Err.MINT_ERROR, message, {
message: e.message,
caller: 'createOnchainMintQuote',
mintUrl,
})
}
}),
/** Current state of an onchain mint quote: amount_paid / amount_issued. */
checkOnchainMintQuote: flow(function* checkOnchainMintQuote(
mintUrl: string,
quote: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const quoteResponse: MintQuoteOnchainResponse =
yield cashuMint.checkMintQuoteOnchain(quote)
return quoteResponse
} catch (e: any) {
let message = 'The mint could not return the onchain mint quote state.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions
throw new AppError(Err.MINT_ERROR, message, {
message: e.message,
caller: 'checkOnchainMintQuote',
mintUrl,
quote,
})
}
}),
/**
* Mint ecash against a confirmed onchain deposit.
*
* Mirrors `mintProofs`: same counter discipline, same NUT-19 in-flight record,
* same error surface.
*
* `quote` is the whole MintQuoteOnchainResponse, not an id — cashu-ts signs the
* request (NUT-20) when the quote carries a `pubkey`, and it reads that off the
* quote object. `privkey` is derived on demand from the seed and the quote's
* stored counterIndex; it is never persisted.
*/
mintOnchainProofs: flow(function* mintOnchainProofs(
mintUrl: string,
amount: number,
unit: MintUnit,
quoteResponse: MintQuoteOnchainResponse,
privkey: string,
transactionId: number,
options?: {
increaseCounterBy?: number
inFlightRequest?: InFlightRequest<MintParams>
},
) {
const mintInstance = self.getMintModelInstance(mintUrl)
if (!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl})
}
const cashuWallet: CashuWallet = yield self.getWallet(mintUrl, unit, {withSeed: true})
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
// outputs error healing
if (options?.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy)
}
yield cashuWallet.counters.advanceToAtLeast(
cashuWallet.keysetId,
currentCounter.counter,
)
const mintParams: MintParams = options?.inFlightRequest?.request || {
amount,
quote: quoteResponse.quote,
options: {keysetId: cashuWallet.keysetId},
}
// Record the request BEFORE sending it. If the response is lost (network
// drop), the mint has already incremented amount_issued — the quote then
// looks drained and the ecash would be stranded. Replaying the identical
// request hits the mint's NUT-19 cache and returns the same signatures.
// Identical outputs depend on the counter NOT having advanced, which holds:
// onCountersReserved never fired, so we never wrote it back.
// @ts-ignore
if (cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintParams)
}
let reservedCounters: OperationCounters | undefined
try {
const proofs = yield cashuWallet.mintProofsOnchain(
mintParams.amount,
quoteResponse,
privkey,
{
keysetId: mintParams.options?.keysetId,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[mintOnchainProofs] Counters reserved', info)
},
},
)
Database.removeInFlightRequest(transactionId)
if (reservedCounters) {
currentCounter.setProofsCounter(reservedCounters.next)
}
log.debug('[WalletStore.mintOnchainProofs]', {
amount: mintParams.amount,
quote: quoteResponse.quote,
proofs: proofs.length,
})
return proofs
} catch (e: any) {
// Keep the in-flight record on timeout / network failure — those are
// exactly the cases where the mint may have processed the request and
// we simply did not hear back. Any other error means it did not.
if (
!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')
) {
Database.removeInFlightRequest(transactionId)
}
let message = 'Error on request to mint new ecash from an onchain deposit.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions
throw new AppError(Err.MINT_ERROR, message, {
message: e.message,
caller: 'mintOnchainProofs',
mintUrl,
quote: quoteResponse.quote,
})
}
}),
mintProofs: flow(function* mintProofs(
mintUrl: string,
amount: number,
@@ -854,7 +1077,7 @@ export const WalletStoreModel = types
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, mintParams)
Database.addInFlightRequest(transactionId, mintParams)
}
let reservedCounters: OperationCounters | undefined
@@ -1002,8 +1225,6 @@ export const WalletStoreModel = types
// even if the app dies right after the payment is submitted.
Database.addMeltRecovery(
transactionId,
mintUrl,
cashuWallet.keysetId,
CashuUtils.serializeMeltPreview(meltPreview),
)
@@ -1079,6 +1300,209 @@ export const WalletStoreModel = types
)
}
}),
/**
* Ask the mint what it would charge to send `amount` to a Bitcoin address (NUT-30).
*
* Unlike bolt11 the amount is not carried by the payment request, so this cannot be
* called until the user has entered one. The quote comes back with a list of
* `fee_options` tiers rather than a single `fee_reserve`; they are fixed for the
* quote's lifetime, and the caller picks one at execute time.
*/
createOnchainMeltQuote: flow(function* createOnchainMeltQuote(
mintUrl: string,
unit: MintUnit,
address: string,
amount: number,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const onchainQuote: MeltQuoteOnchainResponse = yield cashuMint.createMeltQuoteOnchain({
unit,
request: address,
amount,
})
log.info('[createOnchainMeltQuote]', {mintUrl, unit, amount}, {onchainQuote})
return onchainQuote
} catch (e: any) {
let message = 'The mint could not return the onchain melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'createOnchainMeltQuote',
request: {mintUrl, unit, address, amount},
}
)
}
}),
/**
* Execute an onchain melt (NUT-30).
*
* Deliberately the same two-step shape as `payLightningMelt`, because the reason for
* the split is the same: `prepareMelt` derives the NUT-08 change outputs from the
* keyset counter, and if the app dies between submitting the melt and receiving the
* response, the ONLY way to reconstruct that change is the meltPreview we wrote to
* SQLite before submitting. cashu-ts' one-shot `meltProofsOnchain` hides the split and
* would leave nothing to recover from.
*
* Two things differ from bolt11:
* - `fee_index` rides along as `extraPayload` on the melt request. It is not part of
* the quote and not part of prepare; the mint locks it into `selected_fee_index`
* when it executes, and MUST NOT execute the same quote again with a different one.
* - No `preferAsync`. NUT-30 mandates asynchrony ("The mint MUST return a PENDING
* state after validating the melt request and then broadcast in the background"),
* so there is no faster path to ask for.
*/
payOnchainMelt: flow(function* payOnchainMelt(
mintUrl: string,
unit: MintUnit,
meltQuote: MeltQuoteOnchainResponse,
proofsToMeltFrom: Proof[],
feeIndex: number,
transactionId: number,
options?: {
increaseCounterBy?: number,
}
) {
const mintInstance = self.getMintModelInstance(mintUrl)
if(!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl})
}
const cashuWallet = yield self.getWallet(
mintUrl,
unit,
{
withSeed: true,
}
)
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
// outputs error healing
if(options && options.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy)
}
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
log.trace('[WalletStore.payOnchainMelt] Preparing melt', {
localCounter: currentCounter.counter,
proofsCount: proofsToMeltFrom.length,
feeIndex,
})
let reservedCounters: OperationCounters | undefined
// Step 1: prepare (derives the deterministic change outputs)
const meltPreview: MeltPreview<MeltQuoteOnchainResponse> = yield cashuWallet.prepareMelt(
'onchain',
meltQuote,
CashuUtils.exportProofs(proofsToMeltFrom),
{
keysetId: cashuWallet.keysetId,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[payOnchainMelt] Counters reserved', info)
}
}
)
// Synchronous SQLite write BEFORE the melt is submitted, so the change is
// recoverable even if the app dies the moment after.
Database.addMeltRecovery(
transactionId,
CashuUtils.serializeMeltPreview(meltPreview),
)
if (reservedCounters) {
currentCounter.setProofsCounter(reservedCounters.next)
log.debug('[payOnchainMelt] Updated counter', {
keysetId: reservedCounters.keysetId,
start: reservedCounters.start,
count: reservedCounters.count,
next: reservedCounters.next
})
}
try {
// Step 2: submit. fee_index is the onchain-specific part of the request body.
const meltResponse: MeltProofsResponse<MeltQuoteOnchainResponse> =
yield cashuWallet.completeMelt(meltPreview, undefined, {
extraPayload: {fee_index: feeIndex},
})
// The mint answers PENDING (spec-mandated) but MAY already have returned the
// change, because it knows its actual fee the moment it builds the transaction.
// Keep the preview only while there is still change left to reconstruct later.
if (meltResponse.change.length > 0) {
Database.removeMeltRecovery(transactionId)
}
log.trace('[payOnchainMelt]', {meltResponse})
return meltResponse
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
Database.removeMeltRecovery(transactionId)
}
let message = 'Onchain payment failed.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'payOnchainMelt',
mintUrl,
code: e.code || undefined,
}
)
}
}),
/**
* Current state of an onchain melt quote.
*
* This — not the state of the input proofs — is what says whether an onchain payment
* has settled. The mint spending the inputs means it BROADCAST, not that the
* transaction confirmed. Only `PAID` means confirmed.
*/
checkOnchainMeltQuote: flow(function* checkOnchainMeltQuote(
mintUrl: string,
quote: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const quoteResponse: MeltQuoteOnchainResponse = yield cashuMint.checkMeltQuoteOnchain(
quote
)
log.info('[checkOnchainMeltQuote]', {quoteResponse})
return quoteResponse
} catch (e: any) {
let message = 'The mint could not return the state of an onchain melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'checkOnchainMeltQuote',
mintUrl,
}
)
}
}),
restore: flow(function* restore(
mintUrl: string,
seed: Uint8Array,

View File

@@ -0,0 +1,36 @@
import {type GetInfoResponse} from '@cashu/cashu-ts'
/**
* How long a cached mint NUT-06 info stays fresh, in seconds.
*
* mintInfo carries the mint's advertised payment methods and their min/max limits,
* which drive what the wallet offers the user. An hour picks up a mint that gains
* (or drops) a method reasonably soon, at a cost of at most one getInfo() per mint
* per hour.
*/
export const MINT_INFO_TTL_SECONDS = 3600
/**
* Is a cached mint info due a refresh?
*
* A MISSING `time` counts as STALE, and that is the entire reason this is a named
* function with tests rather than an inline comparison.
*
* The `time` stamp only started being written when the capability model landed, so
* every mint info cached before that has `time: undefined`. `now - undefined` is
* NaN, and every comparison against NaN is false — so the obvious
* `now - time > TTL` check reports those records as FRESH and never refetches them.
* A mint that has since gained onchain support then keeps looking like a mint that
* never had it, and the wallet quietly refuses to offer something the mint supports.
*
* Kept in its own module (no stores, no services) so it can be tested without
* dragging in the WalletStore import chain.
*/
export const isMintInfoStale = (
mintInfo: (GetInfoResponse & {time?: number}) | undefined,
nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean => {
if (!mintInfo) return true
if (!Number.isFinite(mintInfo.time)) return true
return nowSeconds - (mintInfo.time as number) > MINT_INFO_TTL_SECONDS
}

View File

@@ -11,6 +11,7 @@
*/
import {
applySnapshot,
getSnapshot,
IDisposer,
onSnapshot,
} from 'mobx-state-tree'
@@ -18,6 +19,7 @@ import * as Sentry from '@sentry/react-native'
import type { RootStore } from '../RootStore'
import { Database, MMKVStorage } from '../../services'
import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db'
import type { Mint } from '../Mint'
import { log } from '../../services/logService'
import { rootStoreModelVersion } from '../RootStore'
import AppError, { Err } from '../../utils/AppError'
@@ -81,6 +83,16 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO
const {proofsStore, walletProfileStore, authStore, userSettingsStore, transactionsStore, mintsStore} = rootStore
// Reconcile the mints between SQLite (the authority) and the snapshot just
// applied, and attach their persistence observers. Deliberately BEFORE the
// counter hydrate below: this replaces the mints array with fresh nodes, and
// the counters must attach to the nodes that survive.
//
// This also carries a wallet whose mints are still only in the snapshot into
// SQLite — on any launch, not just a migrating one. See
// hydrateMintsFromDatabase for why that must not be gated on a version.
mintsStore.hydrateMintsFromDatabase()
if(walletProfileStore.walletId) {
Sentry.setUser({ id: walletProfileStore.walletId })
}
@@ -94,6 +106,13 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO
// hydrate unspent and pending ecash proofs to model from database
if(!opts.skipProofs) {
await proofsStore.loadProofsFromDatabase()
// Both mints (applySnapshot, above) and proofs are now loaded, so the
// tree is settled — the one moment this check cannot produce a false
// positive. Observe-only: it reports a proofs/mint desync and changes
// nothing, because stranded sats reading in the total are a far better
// outcome for the user than a wallet that refuses to open.
proofsStore.reportOrphanedProofs()
}
const proofsHydrated = performance.now()
@@ -265,7 +284,7 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
for (const mint of restoredState?.mintsStore?.mints ?? []) {
for (const counter of mint?.proofsCounters ?? []) {
if (counter?.keyset && typeof counter.counter === 'number' && counter.counter > 0) {
seeds.push({mintUrl: mint.mintUrl, keysetId: counter.keyset, unit: counter.unit, counter: counter.counter})
seeds.push({keysetId: counter.keyset, unit: counter.unit, counter: counter.counter})
}
}
}
@@ -294,8 +313,6 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
if (entry?.meltPreview && typeof entry.transactionId === 'number') {
seeds.push({
transactionId: entry.transactionId,
mintUrl: mint.mintUrl,
keysetId: counter.keyset,
meltPreview: entry.meltPreview,
})
}
@@ -320,8 +337,6 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
if (entry?.request && typeof entry.transactionId === 'number') {
seeds.push({
transactionId: entry.transactionId,
mintUrl: mint.mintUrl,
keysetId: counter.keyset,
request: entry.request,
})
}
@@ -342,7 +357,7 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
for (const backup of restoredState?.mintsStore?.counterBackups ?? []) {
for (const c of backup?.counters ?? []) {
if (c?.keyset && typeof c.counter === 'number' && c.counter > 0) {
seeds.push({mintUrl: backup.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter})
seeds.push({keysetId: c.keyset, unit: c.unit, counter: c.counter})
}
}
}
@@ -356,6 +371,42 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
userSettingsStore.setIsOnboarded(false)
}
if(currentVersion < 38) {
// db v33 added onchain_mint_quotes.mintId and reservations.mintId so
// those rows reference the mint by its stable id rather than by url.
// The column had to be added empty: mints live in this MST snapshot,
// NOT in SQLite, so no SQL statement can map url -> id. Hence the
// backfill here, where the store is hydrated and the mapping exists.
//
// Matching on url is trustworthy at exactly this moment and no other:
// until now a mint's url could not change without these rows being
// rewritten too, so row.mintUrl still agrees with the mint. This spends
// that join ONCE, at rest, instead of on every rename.
//
// Rows left NULL belong to a mint no longer in the wallet and are dead
// regardless. The backfill only fills NULLs, so it is safe to re-run.
const mintRefs = rootStore.mintsStore.allMints.map((m: Mint) => ({
id: m.id as string,
mintUrl: m.mintUrl,
}))
if (mintRefs.length > 0) {
Database.backfillTransactionMintIds(mintRefs)
Database.backfillOnchainMintQuoteMintIds(mintRefs)
Database.backfillReservationMintIds(mintRefs)
}
}
// NOTE: copying the mints into SQLite (db v35) is deliberately NOT a step
// here. It is done by mintsStore.hydrateMintsFromDatabase on EVERY launch,
// keyed on whether the table is actually empty rather than on this version.
//
// A version-gated seed cannot be trusted for it: rootStore.version defaults
// to the current rootStoreModelVersion, so a factory reset stamps a wallet as
// fully migrated on the spot, and restoring an older snapshot over that
// skips the seed forever — while postProcessSnapshot strips mints from every
// save. The mints then exist in neither place and disappear on the next
// launch. Observed on a test device.
// Set once, after all steps succeed: if any step throws, the version is
// NOT bumped and the whole migration retries on the next launch.
rootStore.setVersion(rootStoreModelVersion)

View File

@@ -1,6 +1,10 @@
import React, { useContext, useEffect, useState } from 'react'
import { LayoutChangeEvent, View, ViewStyle } from 'react-native'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {
BottomTabBarHeightCallbackContext,
type BottomTabBarProps,
@@ -13,7 +17,14 @@ import { ScanIcon } from '../components/ScanIcon'
import { translate } from '../i18n'
import { useStores } from '../models'
import { spacing, useThemeColor } from '../theme'
import { tabBarHiddenProgress } from './tabBarVisibility'
import {
revealTabBar,
tabBarHiddenProgress,
useIsTabBarForcedHidden,
} from './tabBarVisibility'
/** Matches the forced-hide fade to the scroll-hide timing in tabBarVisibility. */
const HIDE_DURATION = 200
/** Slightly smaller than the spacing.large icons the bar used when it was full width. */
export const TAB_ICON_SIZE = verticalScale(21)
@@ -55,7 +66,17 @@ export function FloatingTabBar(props: BottomTabBarProps) {
// Scan is a screen inside WalletStack, so the tab state only reports that
// WalletNavigator is focused. Look one level down for the screen actually shown.
const focusedRoute = state.routes[state.index]
const isScanFocused = getFocusedRouteNameFromRoute(focusedRoute) === 'Scan'
const focusedNestedName = getFocusedRouteNameFromRoute(focusedRoute)
const isScanFocused = focusedNestedName === 'Scan'
// Any navigation — tab switch, push, or Back — reveals the bar, so a screen left
// scrolled down never returns to a screen that still has it parked off-screen.
// The tab bar re-renders on every navigation, so this key tracks the visible
// screen across both the tab state and the nested stack.
const focusKey = `${focusedRoute.key}:${focusedNestedName ?? ''}`
useEffect(() => {
revealTabBar()
}, [focusKey])
const gotoScan = () => {
// ScanScreen requires a unit. WalletScreen restores its mint-unit tab from
@@ -128,6 +149,17 @@ export function FloatingTabBar(props: BottomTabBarProps) {
/** What screens reserve: the bar's footprint plus the gap above it. */
const occupiedHeight = travel + TAB_BAR_TOP_GAP
// A screen can hide the bar entirely while focused. It then reserves only the
// safe-area inset instead of the bar's footprint, so it reclaims the room.
const forcedHidden = useIsTabBarForcedHidden()
const forcedProgress = useSharedValue(0)
useEffect(() => {
forcedProgress.value = withTiming(forcedHidden ? 1 : 0, {
duration: HIDE_DURATION,
})
}, [forcedHidden, forcedProgress])
// The bar takes no part in keyboard avoidance. It is absolutely positioned at the
// bottom of a window that the IME overlays rather than resizes (Android runs
// edge-to-edge; iOS avoids the keyboard inside `Screen`), so the keyboard simply
@@ -135,13 +167,14 @@ export function FloatingTabBar(props: BottomTabBarProps) {
// since `keyboardDidHide` is not reliably emitted when the window never resizes.
useEffect(() => {
if (barHeight === 0) return
setTabBarHeight?.(occupiedHeight)
}, [setTabBarHeight, occupiedHeight, barHeight])
setTabBarHeight?.(forcedHidden ? insets.bottom : occupiedHeight)
}, [setTabBarHeight, forcedHidden, insets.bottom, occupiedHeight, barHeight])
// Parks the bar fully below the screen edge.
// Parks the bar fully below the screen edge — from scroll, or a forced hide.
const $animatedBar = useAnimatedStyle(() => {
const progress = Math.max(tabBarHiddenProgress.value, forcedProgress.value)
return {
transform: [{ translateY: tabBarHiddenProgress.value * travel }],
transform: [{ translateY: progress * travel }],
}
}, [travel])

View File

@@ -10,7 +10,8 @@ import {
ReceiveScreen,
SendScreen,
ScanScreen,
LightningPayScreen,
PayScreen,
OnchainTransferScreen,
ContactsScreen,
PictureScreen,
ContactDetailScreen,
@@ -51,8 +52,9 @@ const WalletStack = createNativeStackNavigator({
Receive: ReceiveScreen,
Send: SendScreen,
Scan: ScanScreen,
LightningPay: LightningPayScreen,
Pay: PayScreen,
Transfer: TransferScreen,
TransferOnchain: OnchainTransferScreen,
Topup: TopupScreen,
CashuPaymentRequest: CashuPaymentRequestScreen,
//POS: POSScreen,

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext } from 'react'
import { useCallback, useContext, useSyncExternalStore } from 'react'
import { useFocusEffect } from '@react-navigation/native'
import { BottomTabBarHeightContext } from '@react-navigation/bottom-tabs'
import {
@@ -37,6 +37,56 @@ export function showTabBar() {
tabBarHiddenProgress.value = withTiming(0, { duration: SHOW_DURATION })
}
/**
* Force the bar back into view from the JS thread, regardless of its current
* state. Called on every focus change so that navigating — including Back to a
* screen with no scroll handler — always brings the bar back.
*/
export function revealTabBar() {
isHidden.value = false
tabBarHiddenProgress.value = withTiming(0, { duration: SHOW_DURATION })
}
// Screens can force the bar out of view entirely while focused (e.g. to give a
// non-scrolling screen the full height). A count rather than a boolean keeps the
// state correct when a focus transition briefly overlaps two hiding screens.
let forcedHiddenCount = 0
const forcedHiddenListeners = new Set<() => void>()
function setForcedHidden(delta: number) {
forcedHiddenCount = Math.max(0, forcedHiddenCount + delta)
forcedHiddenListeners.forEach(listener => listener())
}
/** Reactive read of whether any focused screen is forcing the bar hidden. */
export function useIsTabBarForcedHidden(): boolean {
return useSyncExternalStore(
listener => {
forcedHiddenListeners.add(listener)
return () => forcedHiddenListeners.delete(listener)
},
() => forcedHiddenCount > 0,
)
}
/**
* Hide the floating tab bar for as long as the calling screen is focused, and
* restore it on blur. Use case-by-case, for screens that want the full height.
* While hidden, the bar reports only the safe-area inset as its height, so screens
* reserve just that instead of the bar's footprint — see `useTabBarInset`.
*
* `enabled` may be toggled; the hook is always called so the rules of hooks hold.
*/
export function useHideTabBar(enabled = true) {
useFocusEffect(
useCallback(() => {
if (!enabled) return
setForcedHidden(1)
return () => setForcedHidden(-1)
}, [enabled]),
)
}
/**
* Vertical space the floating tab bar occupies, including its bottom offset.
* Returns 0 outside of the tabs navigator, and while the keyboard hides the bar.
@@ -56,14 +106,8 @@ export function useTabBarInset(): number {
* Pass `scrollY` to also track the offset, e.g. to drive an `AnimatedHeader`.
*/
export function useTabBarScrollHandler(scrollY?: SharedValue<number>) {
useFocusEffect(
useCallback(() => {
// A screen can be blurred while scrolled down, which leaves the bar parked.
isHidden.value = false
tabBarHiddenProgress.value = withTiming(0, { duration: SHOW_DURATION })
}, []),
)
// Revealing on focus change is handled centrally in FloatingTabBar, so a screen
// blurred mid-scroll doesn't leave the bar parked — see revealTabBar.
return useAnimatedScrollHandler(
{
onScroll: event => {

View File

@@ -319,7 +319,8 @@ const onMemoEndEditing = () => {
}
const onMemoDone = () => {
if (parseInt(amountToRequest) > 0) {
// toNumber, not parseInt: a confirmed amount is grouped, and parseInt('12,345') is 12.
if (toNumber(amountToRequest) > 0) {
memoInputRef.current?.blur()
amountInputRef.current?.blur()
onMemoEndEditing()
@@ -333,7 +334,8 @@ const onMintBalanceSelect = (balance: MintBalance) => {
}
const onMintBalanceCancel = () => {
dispatch({ type: 'HIDE_MINT_SELECTOR' })
return gotoWallet()
//dispatch({ type: 'HIDE_MINT_SELECTOR' })
}
const onMintBalanceConfirm = async () => {
@@ -411,7 +413,7 @@ const inputText = useThemeColor("text")
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={
mintBalanceToReceiveTo
@@ -487,7 +489,7 @@ return (
label="cashuPaymentRequest_to"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>

View File

@@ -143,21 +143,13 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
}
if(isMintsInBackup) {
exportedMintsStore = JSON.parse(JSON.stringify(getSnapshot(mintsStore)))
exportedMintsStore.mints.forEach((mint: any) => {
mint.keys = [];
// `counter` is stripped from snapshots (mastered in SQLite), so
// re-inject the live derivation index per keyset. Exporting a
// backup with counter 0 would risk blinded-secret reuse on the
// recovered wallet.
const liveMint = mintsStore.findByUrl(mint.mintUrl)
mint.proofsCounters?.forEach((pc: any) => {
const live = liveMint?.proofsCounters.find(c => c.keyset === pc.keyset)
if (live) pc.counter = live.counter
})
})
// NOT getSnapshot(mintsStore): mints are mastered in SQLite and stripped
// in snapshot postprocess (exactly as proofsStore is above), so the store
// snapshot reports zero mints. backupSnapshot builds from the live
// instances and re-injects the volatile derivation counters — it lives on
// the store, behind a test, because getting this wrong exports an empty
// backup with no error and only shows up on restore.
exportedMintsStore = mintsStore.backupSnapshot as MintsStoreSnapshot
//log.trace({exportedMintsStore})
}

View File

@@ -204,6 +204,15 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
applySnapshot(mintsStore, walletSnapshot.mintsStore)
applySnapshot(contactsStore, walletSnapshot.contactsStore)
// Mints are mastered in SQLite, and applySnapshot only puts them in the
// model — so write them through explicitly. The per-mint observers fire on
// CHANGE, and these nodes arrived already-formed, so nothing else would
// persist them; the mints would then vanish on the next launch, which
// hydrates from the database. Re-attach the observers too: applySnapshot
// replaced the array, so the previous ones point at destroyed nodes.
mintsStore.persistAllMints()
mintsStore.observeMints()
// The backup carries real derivation counters in its raw MST snapshot.
// `counter` is VOLATILE in the model (mastered in SQLite), so the
// applySnapshot above does NOT load it — read the values straight from
@@ -217,7 +226,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
// backup JSON still carries it, so read it off the raw value.
const counter = (pc as any).counter
if (pc?.keyset && typeof counter === 'number' && counter > 0) {
counterSeeds.push({mintUrl: mint.mintUrl, keysetId: pc.keyset, unit: pc.unit, counter})
counterSeeds.push({keysetId: pc.keyset, unit: pc.unit, counter})
}
}
}

View File

@@ -3,7 +3,7 @@ import { FlatList, Keyboard, LayoutAnimation, Platform, View, ViewStyle } from "
import { observer } from "mobx-react-lite"
import { Button, Card, Icon, IconTypes} from "../../components"
import { spacing, useThemeColor } from "../../theme"
import { MintBalance } from "../../models/Mint"
import { Mint, MintBalance } from "../../models/Mint"
import { MintUnit } from "../../services/wallet/currency"
import { useStores } from "../../models"
@@ -21,6 +21,16 @@ export const MintBalanceSelector = observer(function (props: {
secondaryConfirmIcon?: IconTypes
cancelIcon?: IconTypes
collapsible?: boolean
/**
* Which capability a mint needs to be usable here. Mints that lack it are
* listed but disabled, with `unsupportedReason` shown in place of the hostname
* — a mint silently missing from the list is more confusing than one that says
* why it cannot be used.
*
* Omit to disable no mint (the caller does not care about capability).
*/
requiredCapability?: (mint: Mint) => boolean
unsupportedReason?: string
onMintBalanceSelect: any
onSecondaryMintBalanceSelect?: any
onCancel?: any
@@ -90,15 +100,22 @@ export const MintBalanceSelector = observer(function (props: {
<FlatList<MintBalance>
data={props.mintBalances}
renderItem={({ item, index }) => {
const mint = mintsStore.findByUrl(item.mintUrl)!
const isDisabled = props.requiredCapability
? !props.requiredCapability(mint)
: false
return(
<MintListItem
key={item.mintUrl}
mint={mintsStore.findByUrl(item.mintUrl)!}
mint={mint}
mintBalance={item}
selectedUnit={props.unit}
onMintSelect={() => onMintSelect(item)}
isSelectable={true}
isSelected={!!props.selectedMintBalance ? props.selectedMintBalance.mintUrl === item.mintUrl : false}
isDisabled={isDisabled}
disabledReason={props.unsupportedReason}
separator={index === 0 || allVisible === false ? undefined : 'top'}
style={(!allVisible && collapsible && props.selectedMintBalance && props.selectedMintBalance.mintUrl !== item.mintUrl) ? {display: 'none'} : {}}
/>

View File

@@ -18,6 +18,17 @@ export const MintListItem = observer(function(props: {
isSelected?: boolean,
isSelectable: boolean,
isBlocked?: boolean,
/**
* The mint cannot serve the operation being offered (e.g. it does not support
* this payment method for this unit). The row stays visible but is inert and
* dimmed, and `disabledReason` says why — hiding it instead would leave the
* user wondering where their mint went.
*
* Distinct from `isBlocked`, which flags a user-blocked mint and only changes
* the right icon.
*/
isDisabled?: boolean,
disabledReason?: string,
isUnitVisible?: boolean,
separator?: 'bottom' | 'top' | 'both'
style?: ViewStyle
@@ -36,7 +47,7 @@ export const MintListItem = observer(function(props: {
margin: spacing.extraSmall
}
const {mint, mintBalance, selectedUnit, onMintSelect, isSelected, isSelectable, isBlocked, isUnitVisible, separator, style} = props
const {mint, mintBalance, selectedUnit, onMintSelect, isSelected, isSelectable, isBlocked, isDisabled, disabledReason, isUnitVisible, separator, style} = props
// log.trace('[MintListItem]', props)
@@ -44,12 +55,22 @@ export const MintListItem = observer(function(props: {
<ListItem
key={mint.mintUrl}
text={mint.shortname}
subText={mint.hostname}
// Keep the name (and the line below it) to a single line, truncating with
// an ellipsis — long mint names would otherwise wrap and push the row's
// height around, which is very visible in a list of them. Passing an
// ellipsize mode is what makes ListItem apply numberOfLines={1}; the
// same pairing TransactionListItem uses.
textStyle={$oneLine}
textEllipsizeMode='tail'
// when disabled, the reason replaces the hostname: it is the one thing
// the user needs to read on this row
subText={isDisabled && disabledReason ? disabledReason : mint.hostname}
subTextEllipsizeMode='tail'
leftIcon={isSelectable ? isSelected ? 'faCheckCircle' : 'faCircle' : undefined}
leftIconColor={isSelected ? iconSelectedColor as string : iconColor as string}
rightIcon={isBlocked ? 'faShieldHalved' : mint.status === MintStatus.OFFLINE ? 'faTriangleExclamation' : undefined}
rightIconColor={isBlocked ? iconBlockedColor : iconColor as string}
onPress={onMintSelect ? () => onMintSelect(mint, mintBalance) : undefined}
onPress={isDisabled ? undefined : onMintSelect ? () => onMintSelect(mint, mintBalance) : undefined}
RightComponent={mintBalance && selectedUnit &&
<CurrencyAmount
amount={mintBalance?.balances[selectedUnit] || 0}
@@ -61,7 +82,17 @@ export const MintListItem = observer(function(props: {
containerStyle={{alignSelf: 'stretch'}}
bottomSeparator={separator === 'bottom' || separator === 'both'}
topSeparator={separator === 'top' || separator === 'both'}
style={[{paddingHorizontal: spacing.tiny}, style]}
style={[{paddingHorizontal: spacing.tiny}, isDisabled ? $disabled : null, style]}
/>
)})
/** Truncate rather than wrap, so every row keeps the same height. */
const $oneLine: TextStyle = {
overflow: 'hidden',
}
/** Dim an unusable mint without hiding it. */
const $disabled: ViewStyle = {
opacity: 0.45,
}

View File

@@ -23,6 +23,7 @@ import {Mint} from '../models/Mint'
import {useStores} from '../models'
import {useHeader} from '../utils/useHeader'
import {log} from '../services/logService'
import {normalizeMintUrl} from '../services/cashu/mintUrl'
import AppError, { Err } from '../utils/AppError'
import {translate} from '../i18n'
import {MintListItem} from './Mints/MintListItem'
@@ -160,21 +161,30 @@ export const MintsScreen = observer(function MintsScreen({ route }: Props) {
if (!selectedMint) {return}
try {
if (isStateTreeNode(selectedMint)) { // update URL of existing mint
// checks if mint is reachable on new url, if it the same mint by checking keysets and syncs local data
if(mintsStore.alreadyExists(mintUrl)) {
// Normalize before anything else: rejects a malformed or non-https url
// without a network round-trip, and yields the exact string that will be
// stored — so the keyset check below runs against that same url.
const normalized = normalizeMintUrl(mintUrl)
// Fast-fail ahead of the fetch. setMintUrl re-checks and remains the
// authority; renaming to the url already held is a no-op there, so it
// must not be reported as a duplicate here either.
if(normalized !== selectedMint.mintUrl && mintsStore.mintExists(normalized)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint with this URL already exists.')
}
toggleAddMintModal() // close
setIsLoading(true)
const keysets: MintKeyset[] = await walletStore.getMintKeysets(mintUrl)
// Checks the mint is reachable on the new url AND is the same mint, by
// matching keysets against the ones we already hold.
const keysets: MintKeyset[] = await walletStore.getMintKeysets(normalized)
const matchingKeyset = keysets.find(keyset => selectedMint.keysets?.some(k => k.id === keyset.id))
if(!matchingKeyset) {
throw new AppError(Err.VALIDATION_ERROR, 'No keyset match, provided URL likely points to a different mint.')
}
selectedMint.setMintUrl!(mintUrl)
selectedMint.setMintUrl!(normalized)
}
} catch (e: any) {
handleError(e)

View File

@@ -1260,7 +1260,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
label="tranDetailScreen_trasferredTo"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>

File diff suppressed because it is too large Load Diff

View File

@@ -28,9 +28,9 @@ type Props = StaticScreenProps<{
mintUrl?: string,
}>
export const LightningPayScreen = function LightningPayScreen({ route }: Props) {
export const PayScreen = function PayScreen({ route }: Props) {
const navigation = useNavigation()
const lightningInputRef = useRef<TextInput>(null)
const paymentInputRef = useRef<TextInput>(null)
const {mintsStore} = useStores()
const isInternetReachable = useIsInternetReachable()
@@ -47,6 +47,16 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
setter(resultFromClipboard.encoded)
sideEffect()
}
// Auto-paste the RAW clipboard for a Bitcoin destination, not the parsed
// address: a BIP21 URI carries an amount and a label the user should see
// (and which onConfirm re-parses), and silently reducing it to a bare address
// would drop both without saying so.
if (resultFromClipboard.type === IncomingDataType.BTC_ADDRESS) {
setter(clipboard)
sideEffect()
}
return
} catch (e: any) {
return
@@ -73,12 +83,12 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
}
setUnitAndMint()
autoPaste(setLightningData, () => lightningInputRef.current?.blur())
autoPaste(setPaymentData, () => paymentInputRef.current?.blur())
return () => {}
}, [])
const [lightningData, setLightningData] = useState<string | undefined>(undefined)
const [paymentData, setPaymentData] = useState<string | undefined>(undefined)
const [unit, setUnit] = useState<MintUnit>('sat')
const [mint, setMint] = useState<Mint | undefined>(undefined)
const [error, setError] = useState<AppError | undefined>()
@@ -88,16 +98,16 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
const onPaste = async function() {
const clipboard = await Clipboard.getString()
if (clipboard.length === 0) {
infoMessage(translate('lightningPayScreen_onPasteEmptyClipboard'))
infoMessage(translate('payScreen_onPasteEmptyClipboard'))
return
}
setLightningData(clipboard)
lightningInputRef.current?.blur()
setPaymentData(clipboard)
paymentInputRef.current?.blur()
}
const gotoScan = async function () {
lightningInputRef.current?.blur()
paymentInputRef.current?.blur()
//@ts-ignore
navigation.navigate('Scan', {
mintUrl: mint?.mintUrl, unit
@@ -117,13 +127,13 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
const onConfirm = async function() {
if (!lightningData) {
setError({ code: 100, name: Err.VALIDATION_ERROR, message: translate("userErrorMissingLightningData")})
if (!paymentData) {
setError({ code: 100, name: Err.VALIDATION_ERROR, message: translate("userErrorMissingPaymentData")})
return
}
try {
const result = IncomingParser.findAndExtract(lightningData)
const result = IncomingParser.findAndExtract(paymentData)
log.trace('[onConfirm]', {mintUrl: mint?.mintUrl, unit, resultType: result.type})
@@ -139,8 +149,14 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
await IncomingParser.navigateWithIncomingData(result, navigation, unit, mint && mint.mintUrl)
}
// Bitcoin address / BIP21. findAndExtract has already verified the checksum
// and refused anything that is not mainnet.
if(result.type === IncomingDataType.BTC_ADDRESS) {
await IncomingParser.navigateWithIncomingData(result, navigation, unit, mint && mint.mintUrl)
}
} catch (e: any) {
e.params = lightningData
e.params = paymentData
handleError(e)
return
}
@@ -157,7 +173,7 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
const handleError = function(e: AppError): void {
// lightningInputRef.current?.blur()
// paymentInputRef.current?.blur()
setError(e)
}
@@ -178,7 +194,7 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text
preset="heading"
tx="lightningPayScreen_payHeading"
tx="payScreen_payHeading"
style={{color: headerTitle}}
/>
</View>
@@ -186,9 +202,9 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
<Card
HeadingComponent={
<ListItem
leftIcon='faBolt'
leftIcon='faPaperPlane'
leftIconColor={colors.palette.orange400}
tx="payCommon_payWithLightning"
tx="payCommon_payWithBitcoin"
bottomSeparator={true}
RightComponent={
<Button
@@ -206,13 +222,13 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
<Text
size='xs'
style={{color: hintText, padding: spacing.extraSmall}}
tx="payWithLightningDesc"
tx="payWithBitcoinDesc"
/>
<View style={{alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={lightningInputRef}
onChangeText={data => setLightningData(data)}
value={lightningData}
ref={paymentInputRef}
onChangeText={data => setPaymentData(data)}
value={paymentData}
autoCapitalize='none'
keyboardType='default'
maxLength={500}
@@ -222,7 +238,7 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
style={[$addressInput, {backgroundColor: inputBg, color: inputText}]}
/>
</View>
{!!lightningData && lightningData?.length > 1 ? (
{!!paymentData && paymentData?.length > 1 ? (
<View style={$buttonContainer}>
<Button
preset='default'

View File

@@ -462,7 +462,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
<TranItem
label="transactionCommon_receivedTo"
isFirst={true}
value={mintsStore.findByUrl(transaction.mint)?.shortname as string}
value={mintsStore.findByTransaction(transaction)?.shortname as string}
/>
{transaction?.memo && (
<TranItem

View File

@@ -23,6 +23,7 @@ import { translate } from '../i18n'
import { MintUnit } from '../services/wallet/currency'
import { Mint } from '../models/Mint'
import { CashuUtils } from '../services/cashu/cashuUtils'
import { BitcoinUtils } from '../services/bitcoin/bitcoinUtils'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
const hasAndroidCameraPermission = async () => {
@@ -173,7 +174,7 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
handleError(e)
break
}
case 'LightningPay':
case 'Pay':
try {
const invoiceResult = IncomingParser.findAndExtract(incoming, IncomingDataType.INVOICE)
return IncomingParser.navigateWithIncomingData(invoiceResult, navigation, unit, mint && mint.mintUrl)
@@ -239,6 +240,24 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
}
}
// Bitcoin address / BIP21, last in the chain so a unified QR carrying a
// lightning invoice is taken as an invoice — lightning wins.
const maybeBtcAddress = BitcoinUtils.findBitcoinAddress(incoming)
if(maybeBtcAddress) {
try {
log.trace('Found Bitcoin address instead of an invoice', 'onIncomingData')
// Throws (and reports) on a non-mainnet address, which is the
// likely mistake: fakewallet topup addresses are regtest.
const btcResult = IncomingParser.findAndExtract(maybeBtcAddress, IncomingDataType.BTC_ADDRESS)
await IncomingParser.navigateWithIncomingData(btcResult, navigation, unit, mint && mint.mintUrl)
return
} catch (e4: any) {
handleError(e4)
break
}
}
e.params = incoming
handleError(e)
break
@@ -278,7 +297,7 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
}
return (
<Screen contentContainerStyle={$screen}>
<Screen contentContainerStyle={$screen} contentUnderTabBar>
<Header
title={urDecoderProgress > 0 ? `Progress ${urDecoderProgress} %`: 'Scan QR code'}
titleStyle={{fontFamily: typography.primary?.medium}}
@@ -336,7 +355,7 @@ const $addressInput: TextStyle = {
const $bottomContainer: ViewStyle = {
position: 'absolute',
bottom: 0,
bottom: spacing.extraLarge * 3,
left: 0,
right: 0,
flex: 1,

View File

@@ -929,7 +929,8 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
Deselects amount and memo if amount is already filled in and user confirms the memo input.
*/
const onMemoDone = function () {
if (parseInt(amountToSend) > 0) {
// toNumber, not parseInt: a confirmed amount is grouped, and parseInt('12,345') is 12.
if (toNumber(amountToSend) > 0) {
memoInputRef && memoInputRef.current
? memoInputRef.current.blur()
: false
@@ -1180,8 +1181,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
const onMintBalanceCancel = async function () {
resetState()
gotoWallet()
return gotoWallet()
}
/*
@@ -1480,7 +1480,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={mintBalanceToSendFrom ? mintsStore.findByUrl(mintBalanceToSendFrom?.mintUrl) : undefined}
unit={unitRef.current}
@@ -1583,7 +1583,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
<TranItem
label="tranDetailScreen_sentTo"
isFirst={true}
value={mintsStore.findByUrl(transaction.mint)?.shortname as string}
value={mintsStore.findByTransaction(transaction)?.shortname as string}
/>
{transaction?.memo && (
<TranItem

View File

@@ -62,6 +62,8 @@ import {
import {MintHeader} from './Mints/MintHeader'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import {MintBalanceSelector} from './Mints/MintBalanceSelector'
import {OnchainTopupOperationApi} from '../services/wallet/operations/onchainTopupOperationApi'
import {buildBip21Uri, onchainTopupFloor} from '../services/wallet/operations/onchainAmounts'
import {QRCodeBlock} from './Wallet/QRCode'
import numbro from 'numbro'
import {TranItem} from './TranDetailScreen'
@@ -91,6 +93,11 @@ type TopupState = {
transactionId: number | undefined
transaction: Transaction | undefined
invoiceToPay: string
/**
* BIP21 URI for an onchain topup (`bitcoin:<addr>?amount=`). Mutually exclusive
* with `invoiceToPay` — a topup goes down one rail or the other.
*/
onchainUriToPay: string
lnurlWithdrawResult: LnurlWithdrawResult | undefined
resultModalInfo: { status: TransactionStatus; title?: string; message: string } | undefined
isLoading: boolean
@@ -115,6 +122,7 @@ type TopupAction =
| { type: 'HIDE_MINT_SELECTOR' }
| { type: 'TOPUP_START' }
| { type: 'INVOICE_READY'; transactionId: number; transactionStatus: TransactionStatus; encodedInvoice: string }
| { type: 'ONCHAIN_QUOTE_READY'; transactionId: number; onchainUri: string }
| { type: 'TOPUP_FAILED'; transactionStatus: TransactionStatus; resultModalInfo: { status: TransactionStatus; title?: string; message: string } }
| { type: 'TOPUP_COMPLETE'; transaction: Transaction; resultModalInfo: { status: TransactionStatus; message: string } }
| { type: 'DM_SENDING' }
@@ -141,6 +149,7 @@ const INITIAL_STATE: TopupState = {
transactionId: undefined,
transaction: undefined,
invoiceToPay: '',
onchainUriToPay: '',
lnurlWithdrawResult: undefined,
resultModalInfo: undefined,
isLoading: false,
@@ -207,6 +216,21 @@ function topupReducer(state: TopupState, action: TopupAction): TopupState {
isWithdrawModalVisible: state.paymentOption === ReceiveOption.LNURL_WITHDRAW,
}
case 'ONCHAIN_QUOTE_READY':
// Straight to PENDING: the address IS the pending state. Unlike an
// invoice there is nothing to expire on the mint's side, and no
// contact/LNURL flows apply — an onchain deposit is paid by whoever
// holds the address.
return {
...state,
isLoading: false,
transactionId: action.transactionId,
transactionStatus: TransactionStatus.PENDING,
onchainUriToPay: action.onchainUri,
invoiceToPay: '',
isMintSelectorVisible: false,
}
case 'TOPUP_FAILED':
return {
...state,
@@ -320,6 +344,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
transactionId,
transaction,
invoiceToPay,
onchainUriToPay,
lnurlWithdrawResult,
resultModalInfo,
isLoading,
@@ -572,7 +597,8 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
}
const onMemoDone = function () {
if (parseInt(amountToTopup) > 0) {
// toNumber, not parseInt: a confirmed amount is grouped, and parseInt('12,345') is 12.
if (toNumber(amountToTopup) > 0) {
memoInputRef && memoInputRef.current
? memoInputRef.current.blur()
: false
@@ -591,6 +617,94 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
dispatch({ type: 'SET_MINT_BALANCE', balance })
}
/** The requested amount, in the unit's smallest denomination. */
const amountToTopupInt = () =>
round(toNumber(amountToTopup) * getCurrency(unitRef.current).precision, 0)
/**
* Whether to offer an onchain address for the currently selected mint.
*
* Gated on BOTH the mint advertising onchain for this unit AND the amount
* clearing the floor — see onchainTopupFloor for why we do not simply trust the
* mint's own min_amount. Below the floor the option is not shown at all, rather
* than shown-and-rejected: a sub-minimum deposit is credited to nobody and is
* unrecoverable through the quote protocol.
*/
const selectedMint = mintBalanceToTopup
? mintsStore.findByUrl(mintBalanceToTopup.mintUrl)
: undefined
/**
* Capability gating reads the mint's CACHED NUT-06 info, and this screen never
* otherwise talks to the mint — so without this, nothing here would ever refresh
* it, and a mint that has since gained onchain support would keep looking like
* one that never had it.
*
* getMint only hits the network when the cached info is actually stale, and the
* screen is an observer, so the onchain option appears by itself once fresher
* capabilities land.
*/
useEffect(() => {
if (!mintBalanceToTopup?.mintUrl) return
walletStore.getMint(mintBalanceToTopup.mintUrl).catch((e: any) => {
log.warn('[TopupScreen] Could not refresh mint capabilities', {error: e.message})
})
}, [mintBalanceToTopup?.mintUrl])
const isOnchainTopupAvailable = (() => {
if (!selectedMint) return false
const supportsOnchain = selectedMint.supportsMint!('onchain', unitRef.current)
const mintMin = selectedMint.mintMethodSetting!('onchain', unitRef.current)?.min_amount as
| number
| null
const floor = onchainTopupFloor(unitRef.current, mintMin)
const amount = amountToTopupInt()
log.trace('[TopupScreen] onchain topup gate', {
mintUrl: selectedMint.mintUrl,
unit: unitRef.current,
supportsOnchain,
hasUnknownCapabilities: selectedMint.hasUnknownCapabilities,
supportsNut20: selectedMint.supportsNut20,
mintMin,
floor,
amount,
})
return supportsOnchain && amount >= floor
})()
/**
* Ask the mint for an onchain deposit address instead of an invoice.
*
* No queue task and no awaitable: unlike a bolt11 topup there is nothing to wait
* for here — the mint hands back an address immediately, and the deposit is
* picked up later by the watcher (OnchainOperationService, via performChecks).
*/
const onMintBalanceConfirmOnchain = async function () {
if (!mintBalanceToTopup) return
try {
dispatch({ type: 'TOPUP_START' })
const created = await OnchainTopupOperationApi.createQuote({
mintUrl: mintBalanceToTopup.mintUrl,
unit: unitRef.current,
amountRequested: amountToTopupInt(),
memo,
})
dispatch({
type: 'ONCHAIN_QUOTE_READY',
transactionId: created.transactionId,
onchainUri: buildBip21Uri(created.address, created.amountRequested),
})
} catch (e: any) {
handleError(e)
}
}
const onMintBalanceConfirm = async function () {
if (!mintBalanceToTopup) {
return
@@ -648,7 +762,8 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
}
const onMintBalanceCancel = async function () {
dispatch({ type: 'HIDE_MINT_SELECTOR' })
return gotoWallet()
//dispatch({ type: 'HIDE_MINT_SELECTOR' })
}
const sendAsNostrDM = async function () {
@@ -837,7 +952,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={
mintBalanceToTopup
@@ -916,7 +1031,26 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
selectedMintBalance={mintBalanceToTopup as MintBalance}
unit={unitRef.current}
title={translate("topup_mint")}
confirmTitle={translate("commonConfirmCreateInvoice")}
confirmIcon='faBolt'
confirmTitle={translate("topupScreen_ligtningInvoice")}
// The onchain option only appears for a mint that advertises it AND an
// amount above the floor. Below the floor it is hidden rather than
// rejected: a sub-minimum onchain deposit is credited to nobody and is
// unrecoverable through the quote protocol.
secondaryConfirmTitle={
isOnchainTopupAvailable ? translate('topupScreen_onchainAddress') : undefined
}
secondaryConfirmIcon={isOnchainTopupAvailable ? 'faBitcoin' : undefined}
onSecondaryMintBalanceSelect={
isOnchainTopupAvailable ? onMintBalanceConfirmOnchain : undefined
}
// A mint that can serve NEITHER rail for this unit cannot top up at all
// — list it, but inert.
requiredCapability={mint =>
mint.supportsMint!('bolt11', unitRef.current) ||
mint.supportsMint!('onchain', unitRef.current)
}
unsupportedReason={translate('mintSelector_noTopupSupport')}
onMintBalanceSelect={onMintBalanceSelect}
onCancel={onMintBalanceCancel}
onMintBalanceConfirm={onMintBalanceConfirm}
@@ -941,6 +1075,29 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
/>
</>
)}
{transactionStatus === TransactionStatus.PENDING && onchainUriToPay && (
/*
The amount is the one thing a user is likely to get wrong here. It rides
in the BIP21 URI, so a scanning wallet pre-fills it — but a sender typing
the address by hand, or editing the amount, can pay anything. Under- and
overpayment both work and the balance settles to whatever arrives, so this
has to be said, or an underpayment reads as a bug.
It lives behind the QR's help button rather than in a card below it: the
QR is the tallest thing on this screen and anything under it fell below
the fold, which is exactly why nobody saw this warning.
*/
<QRCodeBlock
qrCodeData={onchainUriToPay}
titleTx='topupScreen_onchainAddressToPay'
type='BitcoinAddress'
size={270}
hints={[
'topupScreen_onchainAmountIsHint',
'topupScreen_onchainConfirmationsNeeded',
]}
/>
)}
{transaction && transactionStatus === TransactionStatus.COMPLETED && (
<Card
style={{padding: spacing.medium}}
@@ -950,7 +1107,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
label="topup_to"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>

View File

@@ -14,6 +14,7 @@ import {
ViewStyle,
} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {infoMessage} from '../utils/utils'
import JSONTree from 'react-native-json-tree'
import Animated, {
Extrapolation,
@@ -60,6 +61,10 @@ import { MintUnit, formatCurrency, getCurrency } from "../services/wallet/curren
import { pollerExists } from '../utils/poller'
import { CommonActions, StaticScreenProps, useFocusEffect, useNavigation } from '@react-navigation/native'
import { QRCodeBlock } from './Wallet/QRCode'
import { Database } from '../services'
import { OnchainOperationService } from '../services/wallet/operations/onchainOperations'
import { MeltOperationService } from '../services/wallet/operations/meltOperations'
import { buildBip21Uri } from '../services/wallet/operations/onchainAmounts'
import { MintListItem } from './Mints/MintListItem'
import { Token, TokenMetadata, getDecodedToken, getTokenMetadata } from '@cashu/cashu-ts'
import FastImage from 'react-native-fast-image'
@@ -126,7 +131,7 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
setIsDataParsable(false)
}
const mintInstance = mintsStore.findByUrl(tx.mint)
const mintInstance = mintsStore.findByTransaction(tx)
if (mintInstance) {
setMint(mintInstance)
}
@@ -230,8 +235,12 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TOPUP:
return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TOPUP_ONCHAIN:
return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TRANSFER:
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TRANSFER_ONCHAIN:
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
default:
return `${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
}
@@ -386,6 +395,19 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
navigation={navigation}
/>
)}
{transaction.type === TransactionType.TOPUP_ONCHAIN && (
<OnchainTopupInfoBlock
transaction={transaction}
mint={mint}
navigation={navigation}
/>
)}
{transaction.type === TransactionType.TRANSFER_ONCHAIN && (
<OnchainTransferInfoBlock
transaction={transaction}
mint={mint}
/>
)}
{transaction.type === TransactionType.TRANSFER && (
<TransferInfoBlock
transaction={transaction}
@@ -681,7 +703,7 @@ const ReceiveInfoBlock = function (props: {
increaseProofsCounter(transaction.unit)
}
const mintInstance = mintsStore.findByUrl(transaction.mint)
const mintInstance = mintsStore.findByTransaction(transaction)
if(!mintInstance) {
throw new AppError(Err.NOTFOUND_ERROR, 'Mint not found in the wallet state, cannot retry receive', {
@@ -1359,6 +1381,352 @@ const SendInfoBlock = function (props: {
)
}
/**
* The request the payer was given — a bolt11 invoice for a lightning topup, a
* Bitcoin address for an onchain one.
*
* Kept to one line with a MIDDLE ellipsis rather than a tail one: for an address or
* an invoice, the head and tail are the parts worth seeing (they are what a user
* eyeballs against a block explorer or a sender's screen), while the middle is
* noise. Truncating the tail would hide the half that actually distinguishes it.
*/
const PaymentRequestItem = function (props: {transaction: Transaction}) {
const {transaction} = props
if (!transaction.paymentRequest) return null
const onCopy = function () {
try {
Clipboard.setString(transaction.paymentRequest as string)
} catch (e: any) {
infoMessage(translate('commonCopyFailParam', {param: e.message}))
}
}
return (
<ListItem
tx='tranDetailScreen_paymentRequest'
subText={transaction.paymentRequest}
subTextEllipsizeMode='middle'
topSeparator={true}
RightComponent={
<View style={$rightContainer}>
<Button preset='secondary' onPress={onCopy} tx='commonCopy' />
</View>
}
/>
)
}
/**
* Detail block for an onchain (NUT-30) topup.
*
* Separate from TopupInfoBlock because the two behave differently in the one way
* that matters here: a bolt11 invoice is single-use and expires, but an onchain
* deposit address NEVER expires (the mint returns `expiry: null`). Money sent to it
* long after the wallet stopped watching is still credited and still mintable — the
* wallet simply is not looking any more.
*
* So this block offers "check for deposits": it reopens the watch window and
* re-checks the quote immediately. That is the recovery path for anything the
* watcher's 7-day window missed, and it is the reason quote rows (and their NUT-20
* counterIndex) are kept forever rather than deleted on completion.
*/
const OnchainTopupInfoBlock = function (props: {
transaction: Transaction
mint?: Mint
navigation: any
}) {
const {transaction, mint} = props
const isInternetReachable = useIsInternetReachable()
const [isChecking, setIsChecking] = useState(false)
const [checkResult, setCheckResult] = useState<string | undefined>()
const labelColor = useThemeColor('textDim')
const onCheckForDeposits = async function () {
if (!isInternetReachable || !transaction.quote) return
setIsChecking(true)
setCheckResult(undefined)
try {
// Reopen the window first: the quote may well have been archived (that is
// usually WHY the user is here), and a check alone would not put it back
// into the watcher's set for the next deposit.
Database.extendOnchainMintQuoteWatch(transaction.quote)
// Through the queue, NOT straight to refreshQuote. Minting derives blinded
// secrets from the keyset counter, and the watcher sweep can be doing the
// same thing at the same moment; SyncQueue (concurrency 1) is what keeps
// the two from advancing to the same counter and reusing secrets.
const result: any = await OnchainOperationService.enqueueOnchainQuoteCheck(
transaction.quote,
)
if (result?.error) {
setCheckResult(result.error)
} else if (!result?.minted) {
setCheckResult(translate('tranDetail_onchainNoDeposits'))
}
// A successful mint settles the transaction; the observer re-renders it.
} catch (e: any) {
setCheckResult(e.message)
} finally {
setIsChecking(false)
}
}
return (
<>
<Card
label='Transaction data'
style={$dataCard}
ContentComponent={
<>
<TranItem
label="tranDetailScreen_amount"
value={transaction.amount}
unit={transaction.unit}
isCurrency={true}
isFirst={true}
/>
{transaction.memo && transaction.memo.length > 0 && (
<TranItem label="receiverMemo" value={transaction.memo as string} />
)}
<TranItem
label="tranDetailScreen_type"
value={transaction.type as string}
/>
<View style={{flexDirection: 'row', justifyContent: 'space-between'}}>
<TranItem
label="tranDetailScreen_status"
value={transaction.status as string}
/>
{isInternetReachable && transaction.quote && (
<Button
style={{marginTop: spacing.medium}}
preset="secondary"
tx="tranDetail_checkForDeposits"
onPress={onCheckForDeposits}
disabled={isChecking}
/>
)}
</View>
{checkResult && (
<Text
text={checkResult}
preset="formHelper"
style={{color: labelColor}}
/>
)}
{transaction.status === TransactionStatus.COMPLETED && (
<TranItem
label="tranDetailScreen_balanceAfter"
value={transaction.balanceAfter || 0}
unit={transaction.unit}
isCurrency={true}
/>
)}
<TranItem
label="tranDetailScreen_createdAt"
value={(transaction.createdAt as Date).toLocaleString()}
/>
<TranItem label="tranDetailScreen_id" value={`${transaction.id}`} />
</>
}
/>
{transaction.status === TransactionStatus.PENDING && transaction.paymentRequest && (
<View style={{marginBottom: spacing.small}}>
<QRCodeBlock
qrCodeData={buildBip21Uri(transaction.paymentRequest, transaction.amount)}
title={translate('topupScreen_onchainAddressToPay')}
type='BitcoinAddress'
size={spacing.screenWidth * 0.8}
/>
</View>
)}
<Card
labelTx='tranDetailScreen_topupTo'
style={$dataCard}
ContentComponent={
<>
{mint ? (
<MintListItem mint={mint} isSelectable={false} isUnitVisible={false} />
) : (
<Text text={transaction.mint} />
)}
<PaymentRequestItem transaction={transaction} />
</>
}
/>
</>
)
}
/**
* Detail block for an onchain melt (NUT-30).
*
* The two things worth surfacing here are the ones the user cannot get anywhere else:
* the address their money went to, and the `outpoint` (txid:vout) once the mint has
* broadcast. The outpoint is the only handle they have on the payment that does not
* depend on the mint — with it they can watch it confirm on any block explorer, which
* is exactly what they will want if the mint goes quiet.
*
* "Check status" is the manual counterpart to the pending sweep. Onchain settlement is
* measured in blocks, so the wallet only checks on the ~60s pending cadence; a user
* staring at an unconfirmed payment should not have to wait for it to come round.
*/
const OnchainTransferInfoBlock = function (props: {
transaction: Transaction
mint?: Mint
}) {
const {transaction, mint} = props
const {transactionsStore} = useStores()
const isInternetReachable = useIsInternetReachable()
const [isChecking, setIsChecking] = useState(false)
const [checkResult, setCheckResult] = useState<string | undefined>()
const labelColor = useThemeColor('textDim')
const onCheckStatus = async function () {
if (!isInternetReachable) return
setIsChecking(true)
setCheckResult(undefined)
try {
// Through the queue, NOT straight to refresh. Reconstructing NUT-08 melt change
// reads the keyset counter, and the pending sweep can be doing the same thing at
// the same moment; SyncQueue (concurrency 1) is what serialises them.
await MeltOperationService.enqueuePendingOnchainTransferCheck(transaction.id)
const refreshed = transactionsStore.findById(transaction.id)
if (refreshed?.status === TransactionStatus.PENDING) {
setCheckResult(translate('tranDetail_onchainStillUnconfirmed'))
}
// Anything else settled the transaction; the observer re-renders it.
} catch (e: any) {
setCheckResult(e.message)
} finally {
setIsChecking(false)
}
}
return (
<>
<Card
label='Transaction data'
style={$dataCard}
ContentComponent={
<>
<TranItem
label="tranDetailScreen_amount"
value={transaction.amount}
unit={transaction.unit}
isCurrency={true}
isFirst={true}
/>
<TranItem
label="transactionCommon_feePaid"
value={transaction.fee || 0}
unit={transaction.unit}
isCurrency={true}
/>
{transaction.memo && transaction.memo.length > 0 && (
<TranItem label="receiverMemo" value={transaction.memo as string} />
)}
<TranItem
label="tranDetailScreen_type"
value={transaction.type as string}
/>
<View style={{flexDirection: 'row', justifyContent: 'space-between'}}>
<TranItem
label="tranDetailScreen_status"
value={transaction.status as string}
/>
{isInternetReachable &&
transaction.status === TransactionStatus.PENDING && (
<Button
style={{marginTop: spacing.medium}}
preset="secondary"
tx="tranDetail_checkStatus"
onPress={onCheckStatus}
disabled={isChecking}
/>
)}
</View>
{checkResult && (
<Text
text={checkResult}
preset="formHelper"
style={{color: labelColor}}
/>
)}
{transaction.status === TransactionStatus.COMPLETED && (
<TranItem
label="tranDetailScreen_balanceAfter"
value={transaction.balanceAfter || 0}
unit={transaction.unit}
isCurrency={true}
/>
)}
<TranItem
label="tranDetailScreen_createdAt"
value={(transaction.createdAt as Date).toLocaleString()}
/>
<TranItem label="tranDetailScreen_id" value={`${transaction.id}`} />
</>
}
/>
<Card
labelTx='tranDetailScreen_trasferredTo'
style={$dataCard}
ContentComponent={
<>
{mint ? (
<MintListItem mint={mint} isSelectable={false} isUnitVisible={false} />
) : (
<Text text={transaction.mint} />
)}
<PaymentRequestItem transaction={transaction} />
{!!transaction.outpoint && (
<OutpointItem outpoint={transaction.outpoint} />
)}
</>
}
/>
</>
)
}
/**
* The onchain transaction the mint broadcast, as `txid:vout`.
*
* Copyable, because what a user does with it is paste it into a block explorer.
*/
const OutpointItem = function (props: {outpoint: string}) {
const labelColor = useThemeColor('textDim')
return (
<ListItem
leftIcon='faCubes'
leftIconColor={labelColor as string}
tx="tranDetailScreen_outpoint"
subText={props.outpoint}
subTextEllipsizeMode='middle'
topSeparator={true}
RightComponent={
<Button
preset='tertiary'
onPress={() => Clipboard.setString(props.outpoint)}
tx='commonCopy'
textStyle={{fontSize: 12, color: labelColor}}
/>
}
/>
)
}
const TopupInfoBlock = function (props: {
transaction: Transaction
isDataParsable: boolean
@@ -1554,7 +1922,8 @@ const TopupInfoBlock = function (props: {
labelTx='tranDetailScreen_topupTo'
style={$dataCard}
ContentComponent={
mint ? (
<>
{mint ? (
<MintListItem
mint={mint}
isSelectable={false}
@@ -1562,7 +1931,9 @@ const TopupInfoBlock = function (props: {
/>
) : (
<Text text={transaction.mint} />
)
)}
<PaymentRequestItem transaction={transaction} />
</>
}
/>
<BottomModal
@@ -1653,7 +2024,7 @@ const TransferInfoBlock = function (props: {
colorScheme: 'dark' | 'light'
}) {
const {transaction, mint, isDataParsable} = props
const {proofsStore, transactionsStore} = useStores()
const {proofsStore, transactionsStore, mintsStore} = useStores()
const navigation = useNavigation()
@@ -1673,8 +2044,13 @@ const TransferInfoBlock = function (props: {
const {paymentRequest, unit, mint} = transaction
if(!paymentRequest || !unit || !mint) {
log.error('[onPayDraftTransfer] Missing params', {paymentRequest, unit, mint})
// The mint's live url: this is handed to the Transfer screen to actually pay,
// so it must be where the mint answers NOW, not the url the draft was created
// at (transaction.mint, which is frozen as history).
const mintInstance = mintsStore.findByTransaction(transaction)
if(!paymentRequest || !unit || !mint || !mintInstance) {
log.error('[onPayDraftTransfer] Missing params', {paymentRequest, unit, mint, mintId: transaction.mintId})
setResultModalInfo({
status: TransactionStatus.ERROR,
@@ -1692,7 +2068,7 @@ const TransferInfoBlock = function (props: {
encodedInvoice: transaction.paymentRequest,
paymentOption: TransferOption.PASTE_OR_SCAN_INVOICE,
unit: transaction.unit,
mintUrl: transaction.mint,
mintUrl: mintInstance.mintUrl,
draftTransactionId: transaction.id
}
})

View File

@@ -98,6 +98,12 @@ export const TransactionListItem = observer(function (props: TransactionListProp
translate('transactionCommon_paidTo', {receiver: getProfileName(tx.sentTo)})
: translate('transactionCommon_payTo', {receiver: getProfileName(tx.sentTo)})
: translate('transactionCommon_youPaid'))
// Onchain has no contact/profile to name — the counterparty is an address,
// and for a topup it may not even be known (anyone can pay it).
case TransactionType.TOPUP_ONCHAIN:
return tx.memo ? tx.memo : translate('transactionType_topupOnchain')
case TransactionType.TRANSFER_ONCHAIN:
return tx.memo ? tx.memo : translate('transactionType_transferOnchain')
default:
return translate('transactionCommon_unknown')
}
@@ -167,7 +173,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
return (<Icon containerStyle={$txIconContainer} icon="faClock" size={spacing.medium} color={txErrorColor}/>)
}
if([TransactionType.RECEIVE, TransactionType.TOPUP, TransactionType.RECEIVE_BY_PAYMENT_REQUEST].includes(tx.type)) {
if([TransactionType.RECEIVE, TransactionType.TOPUP, TransactionType.TOPUP_ONCHAIN, TransactionType.RECEIVE_BY_PAYMENT_REQUEST].includes(tx.type)) {
if(tx.profile) {
const profilePicture = getProfilePicture(tx.profile)
if(profilePicture) {
@@ -265,7 +271,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
)}
</>
)}
{([TransactionType.TOPUP].includes(tx.type)) && (
{([TransactionType.TOPUP, TransactionType.TOPUP_ONCHAIN].includes(tx.type)) && (
<>
{[TransactionStatus.PENDING, TransactionStatus.EXPIRED].includes(tx.status) && (
<CurrencyAmount
@@ -293,7 +299,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
)}
</>
)}
{([TransactionType.SEND, TransactionType.TRANSFER].includes(tx.type)) && (
{([TransactionType.SEND, TransactionType.TRANSFER, TransactionType.TRANSFER_ONCHAIN].includes(tx.type)) && (
<CurrencyAmount
amount={-1 * tx.amount}
mintUnit={tx.unit}

View File

@@ -603,6 +603,12 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
}
const onMintBalanceCancel = async function () {
return gotoWallet()
//dispatch({ type: 'HIDE_MINT_SELECTOR' })
}
const gotoWallet = function() {
resetState()
navigation.dispatch(
@@ -927,7 +933,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={
mintBalanceToTransferFrom
@@ -1048,8 +1054,13 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
unit={unitRef.current}
title={translate('payCommon_payFrom')}
confirmTitle={translate('payCommon_payNow')}
// This screen pays a bolt11 invoice, so a mint that cannot melt
// bolt11 in this unit cannot be paid from. Onchain melt (NUT-30)
// will gate on its own method when it lands.
requiredCapability={mint => mint.supportsMelt!('bolt11', unitRef.current)}
unsupportedReason={translate('mintSelector_noPayoutSupport')}
onMintBalanceSelect={onMintBalanceSelect}
onCancel={gotoWallet}
onCancel={onMintBalanceCancel}
onMintBalanceConfirm={transfer}
/>
)}
@@ -1062,7 +1073,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
label="tranDetailScreen_trasferredTo"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>

View File

@@ -4,11 +4,11 @@ import { HCESession, NFCTagType4NDEFContentType, NFCTagType4 } from 'react-nativ
import NfcManager from 'react-native-nfc-manager'
import { UR, UREncoder } from '@gandlaf21/bc-ur';
import { infoMessage } from "../../utils/utils"
import { Button, Card, Icon, ListItem } from "../../components"
import { BottomModal, Button, Card, Icon, ListItem, Text } from "../../components"
import QRCode from "react-native-qrcode-svg"
import Clipboard from "@react-native-clipboard/clipboard"
import { verticalScale } from "@gocodingnow/rn-size-matters"
import { colors, spacing, ThemeCode } from "../../theme"
import { colors, spacing, ThemeCode, useThemeColor } from "../../theme"
import { translate, TxKeyPath } from "../../i18n"
import { log } from "../../services"
import { MMKVStorage } from "../../services/mmkvStorage"
@@ -21,7 +21,10 @@ import { useStores } from '../../models';
import AppError, { Err } from '../../utils/AppError';
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest'
// 'BitcoinAddress' carries a BIP21 URI (bitcoin:<addr>?amount=...), not a bare
// address, so a scanning wallet pre-fills the amount. Deliberately left out of the
// NFC-safe list below: onchain deposits are not a tap-to-pay flow.
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest' | 'BitcoinAddress'
const ANIMATED_QR_FRAGMENT_LENGTH = 150
const ANIMATED_QR_INTERVAL = 250
@@ -33,12 +36,27 @@ export const QRCodeBlock = function (props: {
type: QRCodeBlockTypes
size?: number
startNfcOnLoad?: boolean
/**
* Explanatory paragraphs for this QR code. When present, a question-mark button
* joins the action row below the code and opens them in a bottom sheet.
*
* The QR card sits on a white background and is usually the tallest thing on the
* screen, so a caption placed under it tends to fall below the fold and simply
* never gets read — which is how the onchain "the amount is only a hint" warning
* went unseen. Putting the explanation behind a button in the action row keeps it
* next to the thing it describes and always reachable.
*/
hints?: TxKeyPath[]
/** Heading for the hint sheet. Defaults to a generic "How does this work?". */
hintTitleTx?: TxKeyPath
}
) {
const { qrCodeData, title, titleTx, type, size, startNfcOnLoad = false } = props
const { qrCodeData, title, titleTx, type, size, startNfcOnLoad = false, hints, hintTitleTx } = props
const {mintsStore} = useStores()
const [isHintVisible, setIsHintVisible] = useState(false)
const hintTextColor = useThemeColor('textDim')
const [qrError, setQrError] = useState<Error | undefined>()
const [encodedV3Token, setEncodedV3Token] = useState<string | undefined>()
const [decodedToken, setDecodedToken] = useState<Token>()
@@ -287,6 +305,7 @@ export const QRCodeBlock = function (props: {
: require('../../../android/app/src/main/res/mipmap-xhdpi/ic_launcher.png')
return (
<>
<Card
heading={title}
headingTx={titleTx}
@@ -382,9 +401,43 @@ export const QRCodeBlock = function (props: {
}}
/>
)}
{hints && hints.length > 0 && (
<Button
preset="tertiary"
onPress={() => setIsHintVisible(true)}
LeftAccessory={() => <Icon icon='faCircleQuestion' size={spacing.medium} color={colors.light.text} />}
textStyle={{color: colors.light.text, fontSize: 14}}
pressedStyle={{backgroundColor: colors.light.buttonTertiaryPressed}}
style={{ minHeight: verticalScale(40), paddingVertical: verticalScale(spacing.tiny) }}
/>
)}
</View>
}
/>
{/*
Outside the Card on purpose: the card is forced white (it is a QR code
background), so its contents are hardcoded to light colours. The sheet is a
normal themed surface and must not inherit that.
*/}
<BottomModal
isVisible={isHintVisible}
headingTx={hintTitleTx ?? 'qr_hintHeading'}
ContentComponent={
<View style={{marginTop: spacing.small}}>
{hints?.map(hint => (
<Text
key={hint}
tx={hint}
preset='formHelper'
style={{marginBottom: spacing.small, color: hintTextColor}}
/>
))}
</View>
}
onBackButtonPress={() => setIsHintVisible(false)}
onBackdropPress={() => setIsHintVisible(false)}
/>
</>
)
}

View File

@@ -540,19 +540,19 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
}
const gotoLightningPay = function (mintUrl?: string) {
const gotoPay = function (mintUrl?: string) {
log.trace({mintUrl})
if(mintUrl) {
setIsMintsModalVisible(false)
// @ts-ignore
navigation.navigate('LightningPay', {
navigation.navigate('Pay', {
mintUrl,
unit: currentUnit
})
} else {
setIsSendModalVisible(false)
// @ts-ignore
navigation.navigate('LightningPay', {
navigation.navigate('Pay', {
unit: currentUnit
})
}
@@ -915,30 +915,44 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
mintsByUnit={groupedMints}
currentUnit={currentUnit}
onTopup={gotoTopup}
onLightningPay={gotoLightningPay}
onPay={gotoPay}
onMintInfo={gotoMintInfo}
/>
}
onBackButtonPress={toggleMintsModal}
onBackdropPress={toggleMintsModal}
/>
{/*
The verb lives in the sheet heading, so the rows are plain nouns: the two
options then read as a genuine either/or (which layer does the money move
on?) rather than repeating "Send"/"Receive" twice. Descriptions carry the
criteria a user actually decides on — speed and cost — instead of protocol
vocabulary.
Neither row commits to a rail. Paying resolves the rail from whatever the
user pastes (invoice / LN address / onchain address); topping up asks in
TopupScreen, where the mint and unit are known and unsupported methods can
be hidden. So the user is never made to pick a rail uninformed.
*/}
<BottomModal
isVisible={isSendModalVisible ? true : false}
//style={{alignItems: 'stretch'}}
headingTx="payCommon_send"
headingStyle={{textAlign: 'center'}}
ContentComponent={
<>
<ListItem
leftIcon='faMoneyBill1'
tx="walletScreen_sendEcash"
subTx="walletScreen_sendEcashDesc"
tx="walletScreen_optionEcash"
subTx="walletScreen_optionEcashSendDesc"
onPress={gotoSend}
bottomSeparator={true}
/>
<ListItem
leftIcon='faBolt'
tx="walletScreen_payWithLightning"
subTx="walletScreen_payWithLightningDesc"
onPress={() => gotoLightningPay()}
leftIcon='faBitcoin'
tx="walletScreen_optionBitcoin"
subTx="walletScreen_optionBitcoinSendDesc"
onPress={() => gotoPay()}
//bottomSeparator={true}
/>
{/*<ListItem
@@ -955,19 +969,21 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
<BottomModal
isVisible={isReceiveModalVisible ? true : false}
// style={{alignItems: 'stretch'}}
headingStyle={{textAlign: 'center'}}
headingTx="payCommon_receive"
ContentComponent={
<>
<ListItem
leftIcon='faMoneyBill1'
tx="walletScreen_receiveEcash"
subTx="walletScreen_receiveEcashDesc"
tx="walletScreen_optionEcash"
subTx="walletScreen_optionEcashReceiveDesc"
onPress={gotoTokenReceive}
bottomSeparator={true}
/>
<ListItem
leftIcon='faBolt'
tx='walletScreen_topupWithLightning'
subTx="walletScreen_topupWithLightningDesc"
leftIcon='faBitcoin'
tx='walletScreen_optionBitcoin'
subTx="walletScreen_optionBitcoinReceiveDesc"
onPress={() => gotoTopup()}
/>
</>
@@ -1083,7 +1099,7 @@ const MintsByUnitList = observer(function (props: {
mintsByUnit: MintsByUnit[]
currentUnit: MintUnit
onTopup: Function
onLightningPay: Function
onPay: Function
onMintInfo: Function
}) {
@@ -1177,7 +1193,7 @@ const MintsByUnitList = observer(function (props: {
)}
textStyle={{fontSize: 14, color}}
preset='secondary'
onPress={() => props.onLightningPay(mint.mintUrl)}
onPress={() => props.onPay(mint.mintUrl)}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),

View File

@@ -14,8 +14,9 @@ export * from './ScanScreen'
export * from './TranDetailScreen'
export * from './TranHistoryScreen'
export * from './TransferScreen'
export * from './OnchainTransferScreen'
export * from './TopupScreen'
export * from './LightningPayScreen'
export * from './PayScreen'
export * from './TokenReceiveScreen'
export * from './CashuPaymentRequestScreen'
export * from './NfcPayScreen'

View File

@@ -0,0 +1,284 @@
/**
* Bitcoin address and BIP21 URI parsing.
*
* Used on the way OUT (NUT-30 onchain melt): the user pastes or scans something and
* the wallet has to decide what it is before it can route them anywhere. Onchain
* payments are irreversible, so this errs towards refusing input it does not fully
* understand — the checksums are verified locally rather than left for the mint to
* catch, so a mistyped address fails on the screen the user is looking at instead of
* one round-trip later.
*
* Checksums catch typos, not mistakes: a valid address for the wrong recipient looks
* exactly like a valid address. Nothing here can help with that.
*/
import {bech32, bech32m, createBase58check} from '@scure/base'
import {sha256} from '@noble/hashes/sha2.js'
const base58Check = createBase58check(sha256)
export type BitcoinNetwork = 'mainnet' | 'testnet' | 'regtest'
export type BitcoinAddressInfo = {
address: string
network: BitcoinNetwork
/** Human-readable script type, for display and for logs. */
kind: 'P2PKH' | 'P2SH' | 'P2WPKH' | 'P2WSH' | 'P2TR' | 'SEGWIT'
}
/** Bech32 human-readable prefixes, per BIP-173 / BIP-350. */
const SEGWIT_PREFIXES: Record<string, BitcoinNetwork> = {
bc: 'mainnet',
tb: 'testnet',
bcrt: 'regtest',
}
/** Base58 version bytes. */
const BASE58_VERSIONS: Record<number, {network: BitcoinNetwork; kind: 'P2PKH' | 'P2SH'}> = {
0x00: {network: 'mainnet', kind: 'P2PKH'}, // 1...
0x05: {network: 'mainnet', kind: 'P2SH'}, // 3...
0x6f: {network: 'testnet', kind: 'P2PKH'}, // m... / n...
0xc4: {network: 'testnet', kind: 'P2SH'}, // 2...
}
/**
* Decode a segwit (bech32 / bech32m) address.
*
* The witness version decides the checksum constant: v0 MUST use bech32, v1+ (taproot
* and anything after it) MUST use bech32m. They are different checksums over the same
* alphabet, so accepting either for both versions would let a v0 address with a
* corrupted checksum through as long as it happened to satisfy the other constant.
* We decode with both and then insist the one that worked matches the version.
*/
const decodeSegwitAddress = (address: string): BitcoinAddressInfo | undefined => {
const lower = address.toLowerCase()
// BIP-173: mixed case is invalid. Checked before lowercasing loses the evidence.
if (address !== lower && address !== address.toUpperCase()) return undefined
const separator = lower.lastIndexOf('1')
if (separator < 1) return undefined
const network = SEGWIT_PREFIXES[lower.slice(0, separator)]
if (!network) return undefined
let words: number[]
let usedBech32m = false
try {
words = bech32.decode(lower as `${string}1${string}`, 90).words
} catch {
try {
words = bech32m.decode(lower as `${string}1${string}`, 90).words
usedBech32m = true
} catch {
return undefined
}
}
const version = words[0]
if (version === undefined || version > 16) return undefined
if (version === 0 && usedBech32m) return undefined
if (version > 0 && !usedBech32m) return undefined
let program: Uint8Array
try {
program = bech32.fromWords(words.slice(1))
} catch {
return undefined
}
if (program.length < 2 || program.length > 40) return undefined
// v0 is only ever defined for P2WPKH (20 bytes) and P2WSH (32).
if (version === 0 && program.length !== 20 && program.length !== 32) return undefined
let kind: BitcoinAddressInfo['kind'] = 'SEGWIT'
if (version === 0) kind = program.length === 20 ? 'P2WPKH' : 'P2WSH'
else if (version === 1 && program.length === 32) kind = 'P2TR'
return {address: lower, network, kind}
}
/** Decode a legacy base58check address (P2PKH / P2SH). */
const decodeBase58Address = (address: string): BitcoinAddressInfo | undefined => {
let decoded: Uint8Array
try {
decoded = base58Check.decode(address)
} catch {
return undefined
}
// version byte + 20-byte hash (the 4-byte checksum is consumed by the decoder)
if (decoded.length !== 21) return undefined
const version = BASE58_VERSIONS[decoded[0]]
if (!version) return undefined
return {address, network: version.network, kind: version.kind}
}
/**
* Decode a bare Bitcoin address, verifying its checksum.
*
* Returns undefined rather than throwing, so it can be used as a predicate while
* sniffing unknown input.
*/
export const decodeBitcoinAddress = (
address: string,
): BitcoinAddressInfo | undefined => {
const trimmed = address.trim()
if (trimmed.length === 0) return undefined
return decodeSegwitAddress(trimmed) ?? decodeBase58Address(trimmed)
}
export const isBitcoinAddress = (address: string): boolean =>
decodeBitcoinAddress(address) !== undefined
/**
* Are non-mainnet addresses payable in this build?
*
* Debug builds only. Development is the one situation where paying a regtest address is
* the POINT rather than a mistake: the CDK fakewallet backend settles onchain melts
* against a regtest chain, so a release-only guard would make the whole rail
* untestable end to end.
*
* A release build always refuses. The flag cannot be turned on by a user, and there is
* no setting for it — the only way to pay a testnet address is to be running a debug
* bundle you built yourself.
*
* `typeof` guarded because this module is also loaded by tests outside the React Native
* environment, where `__DEV__` is not defined.
*/
export const ALLOW_NON_MAINNET_PAY =
typeof __DEV__ !== 'undefined' && __DEV__ === true
/**
* Is this an address Minibits is allowed to pay?
*
* Mainnet only, in any build a user can install. The wallet holds mainnet-backed ecash
* and the mints melt to the real chain, so a testnet or regtest address is not a
* payment — it is a mistake, and an irreversible one if a mint broadcasts against it.
*
* This is not hypothetical. The CDK fakewallet backend hands out REGTEST deposit
* addresses (`bcrt1q…`) for onchain topup quotes, so anyone testing this wallet ends
* up with one in their clipboard. Pasting it back into Pay must fail loudly, not
* quietly reach the mint.
*
* `allowNonMainnet` defaults to `ALLOW_NON_MAINNET_PAY` (debug builds) and is an
* explicit parameter rather than a global read so that the decision is testable and so
* that callers cannot accidentally widen it — passing `true` from production code would
* be a visible thing to review.
*
* Kept separate from `decodeBitcoinAddress` on purpose: decoding tells you WHAT an
* address is (and needs to recognise testnet in order to say so), while this decides
* whether we are willing to send money to it. Collapsing the two would leave us unable
* to tell "that is not an address" apart from "that is not OUR network", and the second
* deserves its own error message.
*/
export const isPayableBitcoinAddress = (
address: string,
options?: {allowNonMainnet?: boolean},
): boolean => {
const decoded = decodeBitcoinAddress(address)
if (!decoded) return false
if (decoded.network === 'mainnet') return true
return (options?.allowNonMainnet ?? ALLOW_NON_MAINNET_PAY) === true
}
export type Bip21Data = {
address: string
/** Amount in SATS, converted from the BIP21 `amount` (which is in BTC). */
amountSat?: number
label?: string
message?: string
/** A BOLT11 invoice carried alongside the address in a unified QR. */
lightning?: string
}
/**
* Parse a BIP21 `bitcoin:` URI.
*
* Only the address is required; everything else is a hint the wallet may use or
* ignore. Returns undefined if the URI is not BIP21 or the address does not check
* out — a `bitcoin:` URI with an address we cannot verify is not something to pass
* along half-understood.
*
* The scheme is case-insensitive (BIP21 allows `BITCOIN:`, which is what QR encoders
* emit to stay in the alphanumeric mode).
*/
export const parseBip21 = (uri: string): Bip21Data | undefined => {
const trimmed = uri.trim()
if (!/^bitcoin:/i.test(trimmed)) return undefined
const body = trimmed.slice('bitcoin:'.length)
const [addressPart, queryPart] = body.split('?', 2)
// `bitcoin:?lightning=...` (no address) is a legal BOLT11-only unified URI, but
// it is not something an onchain melt can use — the caller wants an address.
const decoded = decodeBitcoinAddress(addressPart)
if (!decoded) return undefined
// Take the DECODED address, not the raw text: QR encoders uppercase bech32 to stay
// in alphanumeric mode, and this string is what we hand to the mint.
const result: Bip21Data = {address: decoded.address}
if (!queryPart) return result
const params = new URLSearchParams(queryPart)
const amount = params.get('amount')
if (amount) {
const btc = Number(amount)
// BIP21 amounts are decimal BTC. Round rather than truncate: 0.0001 parses to
// 9999.999999999999 sats in binary floating point, and a truncating conversion
// would quietly under-request by one sat.
if (Number.isFinite(btc) && btc > 0) result.amountSat = Math.round(btc * 100_000_000)
}
const label = params.get('label')
if (label) result.label = label
const message = params.get('message')
if (message) result.message = message
const lightning = params.get('lightning')
if (lightning) result.lightning = lightning.toLowerCase()
return result
}
/**
* Find a Bitcoin address or BIP21 URI inside arbitrary pasted text.
*
* Mirrors `LightningUtils.findEncodedLightningInvoice` — clipboards carry surrounding
* prose, and QR payloads sometimes carry a URI inside a larger string.
*/
export const findBitcoinAddress = (text: string): string | undefined => {
const trimmed = text.trim()
const uriMatch = trimmed.match(/bitcoin:[^\s]+/i)
if (uriMatch && parseBip21(uriMatch[0])) return uriMatch[0]
if (decodeBitcoinAddress(trimmed)) return trimmed
// Bare address embedded in text. The candidate pattern is deliberately loose —
// `decodeBitcoinAddress` is the actual filter, so a false candidate costs a failed
// checksum, not a false positive.
const candidates = trimmed.match(/\b(bc1|tb1|bcrt1)[a-z0-9]{6,87}\b|\b[13mn2][a-km-zA-HJ-NP-Z1-9]{25,39}\b/gi)
if (!candidates) return undefined
for (const candidate of candidates) {
if (decodeBitcoinAddress(candidate)) return candidate
}
return undefined
}
export const BitcoinUtils = {
ALLOW_NON_MAINNET_PAY,
decodeBitcoinAddress,
isBitcoinAddress,
isPayableBitcoinAddress,
parseBip21,
findBitcoinAddress,
}

View File

@@ -440,6 +440,21 @@ function getKeysetIdInt(keysetId: string): bigint {
}
}
/**
* Whether a keyset id derives via the deprecated BIP-32 path (NUT-13).
*
* Mirrors cashu-ts `getDerivationKind`: legacy base64 ids and hex ids carrying the
* `00` version byte derive at `m/129372'/0'/{keysetIdInt}'/{counter}'`, where
* `keysetIdInt` is the id reduced mod 2^31-1 to fit a hardened BIP-32 index.
* NUT-02 v2 ids (`01`) instead derive by HMAC-SHA256 over the FULL id, so no
* integer is ever computed from them and that reduction cannot alias.
*/
function usesBip32Derivation(keysetId: string): boolean {
const isHex = /^[0-9a-fA-F]+$/.test(keysetId)
if (!isHex) return true // legacy base64 keyset id
return keysetId.startsWith('00')
}
const exportProofs = (proofs: Proof[]): CashuProof[] => {
@@ -457,11 +472,37 @@ const exportProofs = (proofs: Proof[]): CashuProof[] => {
}
/**
* Reject a keyset whose id collides with one the wallet already holds, per NUT-02:
* "Wallet implementations should reject any attempt at importing new keysets which
* IDs collide with any of the previously added keysets."
*
* `storedKeysetIds` is wallet-wide (every keyset of every mint), and upholding this
* across mints is what makes a keyset id a sound primary key for a derivation
* counter — see MINT_COUNTERS_COLUMNS.
*
* Two checks, guarding different things:
*
* - Exact id equality — always applies. One id means one NUT-13 derivation
* sequence, so two mints sharing an id would share (and burn through) one
* counter.
*
* - keysetIdInt equality — ONLY between ids that both derive via the deprecated
* BIP-32 path, where the id is reduced mod 2^31-1 to fit a hardened index and
* two distinct ids that are congruent therefore land on the SAME derivation
* path. NUT-02 v2 (`01`) ids derive by HMAC over the full 32-byte id and never
* compute that integer, so applying the check to them would reject a legitimate
* mint over a number nothing consumes — and would re-impose the ~2^31 birthday
* bound on ids whose whole point is full-width SHA-256 collision resistance.
* Ids of different derivation kinds can never share a path, so they are skipped.
*/
function isCollidingKeysetId(
newKeysetId: string,
storedKeysetIds: string[],
) {
const newKeysetIdInt = getKeysetIdInt(newKeysetId)
const newUsesBip32 = usesBip32Derivation(newKeysetId)
const newKeysetIdInt = newUsesBip32 ? getKeysetIdInt(newKeysetId) : undefined
return storedKeysetIds.some((storedId) => {
if (storedId === newKeysetId) {
@@ -473,6 +514,10 @@ function isCollidingKeysetId(
return true
}
if (!newUsesBip32 || !usesBip32Derivation(storedId)) {
return false
}
const storedKeysetIdInt = getKeysetIdInt(storedId)
if (storedKeysetIdInt === newKeysetIdInt) {
@@ -480,7 +525,7 @@ function isCollidingKeysetId(
log.error('[isCollidingKeysetId] Colliding keyset ID integer', {
newKeysetId,
storedId,
newKeysetIdInt: newKeysetIdInt.toString(),
newKeysetIdInt: newKeysetIdInt!.toString(),
storedKeysetIdInt: storedKeysetIdInt.toString(),
})

View File

@@ -0,0 +1,85 @@
import AppError, {Err} from '../../utils/AppError'
/**
* Mint URL normalization and validation — the single definition of what a mint
* url may look like.
*
* Extracted because the two entry points had drifted: `MintsStore.addMint`
* stripped the trailing slash and demanded https, while `Mint.setMintUrl` did
* neither (it only checked `new URL()` parsed). A RENAME could therefore install
* a url that ADDING the same mint would have rejected — most damagingly a
* trailing-slash twin of a mint already held, since the duplicate check compares
* urls literally: two Mint nodes for one real mint, each accumulating its own
* state.
*
* Kept free of model imports so both callers can use it without a cycle.
*/
/**
* Whether the url points at a Tor hidden service, which is exempt from the https
* requirement (onion routing already authenticates the endpoint).
*
* Tests the parsed HOSTNAME, not the raw string. A substring test for '.onion'
* (what addMint used to do) also matches a path or query — `http://evil.com/.onion`
* would earn a plain-http exemption for an ordinary host.
*/
export const isOnionMintUrl = function (mintUrl: string): boolean {
try {
return new URL(mintUrl).hostname.endsWith('.onion')
} catch {
return false
}
}
/**
* Normalize a mint url to its canonical form, or throw AppError(VALIDATION_ERROR).
*
* Two rules, from different authorities:
*
* 1. NUT-00 requires the trailing slash be gone. On the v3 token: "The mint URL
* must be stripped of any trailing slashes (/)"; on v4: "The mint URL MUST be
* normalized by stripping any trailing slashes (/)". That is the whole of what
* the spec mandates — it says nothing about case or any other form.
*
* 2. cashu-ts canonicalizes further, and we MUST match it. `new CashuMint(url)`
* stores `normalizeUrl(url)` = `parsed.href` with trailing slashes stripped,
* which also lowercases scheme and host and drops a default port. WalletStore
* compares our stored string against that value directly (`m.mintUrl ===
* mintUrl`, `w.mint.mintUrl === mintUrl`) to find cached CashuMint/CashuWallet
* instances. Normalizing the raw input instead would let `https://Mint.Example`
* be stored while cashu-ts holds `https://mint.example`: every cache lookup
* misses, and the wallet would treat the two spellings as two different mints.
* cashu-ts's normalizeUrl is @internal (not exported), hence the reimplementation
* here — it must be kept in step with it.
*
* The https requirement is ours alone and stricter than cashu-ts, which permits
* http for any host.
*
* cashu-ts additionally rejects credentials, query strings, fragments and
* percent-encoded paths. Those are deliberately NOT re-checked here: both callers
* construct a CashuMint against the url before anything is stored, so cashu-ts
* raises them itself — duplicating the rules would only invite drift.
*/
export const normalizeMintUrl = function (mintUrl: string): string {
if (!mintUrl || !mintUrl.trim()) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL is required.')
}
let parsed: URL
try {
parsed = new URL(mintUrl.trim())
} catch {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid Mint URL.', {mintUrl})
}
// Protocol equality, not `startsWith('https')` — the latter also accepts a
// scheme merely PREFIXED with https (`https-evil://host` parses fine).
if (parsed.protocol !== 'https:' && !isOnionMintUrl(parsed.href)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL needs to start with https.', {mintUrl})
}
// `href` first (canonical), THEN strip: the parser appends a trailing slash to
// an origin-only url, so stripping last removes both that and any the caller
// typed. Identical to cashu-ts normalizeUrl.
return parsed.href.replace(/\/+$/, '')
}

103
src/services/cashu/nut20.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* NUT-20 quote-locking keys.
*
* A NUT-20 mint quote is locked to a public key: the mint will only issue ecash
* against it after seeing a signature from the matching private key. Onchain
* (NUT-30) makes this MANDATORY — the mint refuses to issue an onchain mint quote
* without a `pubkey` (error 20009).
*
* Keys are derived deterministically from the wallet seed, so the key needed to
* sign for a quote can always be re-derived — including days later, after an
* onchain deposit finally confirms, and (in future) after a seed restore.
*
* NUT-20 derivation path:
*
* m/129373'/20'/0'/0'/{counter}
*
* where 129373' is the Cashu namespace, 20' the NUT-20 index, and {counter} an
* incrementing NON-hardened child index. The path has no keyset component, so the
* counter is wallet-global — see walletCountersRepo, which owns it (mint_counters
* is for the keyset-scoped NUT-13 counters and is unrelated).
*
* The spec asks for a UNIQUE key per quote, so the mint cannot link a wallet's
* quotes to each other. `allocateQuoteKeypair` is the only thing call sites
* should use: it burns the index before handing it back, so an index can never
* be issued twice.
*
* Signing itself is cashu-ts's job (`signMintQuote`), which implements the
* `Cashu_MintQuoteSig_v1` message aggregation and BIP340 Schnorr signature. We
* only supply the key.
*/
import {HDKey} from '@scure/bip32'
// `.js` suffix: the bare '@noble/hashes/utils' specifier used elsewhere in this
// codebase does not resolve under tsc (the package's exports map only names the
// suffixed path). Both forms work at runtime; this one also typechecks.
import {bytesToHex} from '@noble/hashes/utils.js'
import {allocateNextCounter, NUT20_COUNTER} from '../db/walletCountersRepo'
import AppError, {Err} from '../../utils/AppError'
export type QuoteKeypair = {
/** 32-byte private key, hex. Never persisted — re-derived from the seed. */
privkey: string
/** 33-byte compressed secp256k1 public key, hex. Sent to the mint. */
pubkey: string
}
/** BIP32 path for the NUT-20 quote-locking key at `index`. */
export const quoteKeyDerivationPath = (index: number): string =>
`m/129373'/20'/0'/0'/${index}`
/**
* Derive the NUT-20 quote-locking keypair at `index` from the wallet seed.
*
* Pure: no database, no MST, no keychain — the caller supplies the seed. That
* keeps it trivially testable and usable from the off-MST background paths that
* will need to sign mint requests.
*/
export const deriveQuoteKeypair = function (
seed: Uint8Array,
index: number,
): QuoteKeypair {
if (!Number.isInteger(index) || index < 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'NUT-20 quote key index must be a non-negative integer',
{index, caller: 'deriveQuoteKeypair'},
)
}
const derived = HDKey.fromMasterSeed(seed).derive(quoteKeyDerivationPath(index))
if (!derived.privateKey || !derived.publicKey) {
throw new AppError(
Err.VALIDATION_ERROR,
'NUT-20 quote key derivation produced no key material',
{index, caller: 'deriveQuoteKeypair'},
)
}
return {
privkey: bytesToHex(derived.privateKey),
pubkey: bytesToHex(derived.publicKey),
}
}
/**
* Allocate a fresh index and derive its keypair — the entry point call sites use.
*
* The index is COMMITTED to the database before this returns, so if the caller's
* mint-quote request then fails (or the app dies mid-request), the index is
* simply skipped and never handed out again. Reuse is the thing we cannot allow:
* two quotes sharing a pubkey would let the mint link them and would make the
* NUT-20 signature ambiguous. Skipped indices are harmless.
*
* The returned `index` MUST be persisted alongside the quote — it is the only
* thing that lets the wallet re-derive the private key to sign the mint request
* later.
*/
export const allocateQuoteKeypair = function (
seed: Uint8Array,
): QuoteKeypair & {index: number} {
const index = allocateNextCounter(NUT20_COUNTER)
return {index, ...deriveQuoteKeypair(seed, index)}
}

View File

@@ -6,31 +6,32 @@ import {log} from '../logService'
// ─────────────────────────────────────────────────────────────────────────────
// Per-keyset deterministic-derivation counters.
//
// The `counter` is the BIP32 derivation high-water mark for a (mint, keyset)
// pair. It was previously held only in the MST `MintProofsCounter` model and
// persisted to MMKV via the whole-tree snapshot — a separate persistence engine
// from the proofs the counter derives, committed at a different moment. That
// cross-engine gap meant a crash between "counter advanced" (MMKV) and "proofs
// written" (SQLite) could desync them and risk blinded-secret reuse.
// The `counter` is the NUT-13 derivation high-water mark for a keyset. It was
// previously held only in the MST `MintProofsCounter` model and persisted to
// MMKV via the whole-tree snapshot — a separate persistence engine from the
// proofs the counter derives, committed at a different moment. That cross-engine
// gap meant a crash between "counter advanced" (MMKV) and "proofs written"
// (SQLite) could desync them and risk blinded-secret reuse.
//
// This repo makes SQLite the authority for the counter so the advance can later
// be folded into the SAME transaction as the proof writes. Every write here is
// MONOTONIC: a counter can never move backward. That single invariant is what
// makes the MMKV→SQLite migration safe — a stale or racing writer can only ever
// be a no-op, never a regression.
//
// Rows are keyed by keysetId ALONE — the mint url is not an input to NUT-13
// derivation, so it was never part of this key space. See MINT_COUNTERS_COLUMNS.
// ─────────────────────────────────────────────────────────────────────────────
export type CounterRecord = {
mintUrl: string
keysetId: string
unit: string | null
counter: number
updatedAt: string | null
}
/** A single (mint, keyset, value) tuple for the one-time seed from MST/MMKV. */
/** A single (keyset, value) tuple for the one-time seed from MST/MMKV. */
export type CounterSeed = {
mintUrl: string
keysetId: string
unit?: string
counter: number
@@ -43,20 +44,19 @@ export type CounterSeed = {
* only ever rises to MAX(existing, value); a lower value is a no-op.
*/
export const buildCounterUpsert = function (
mintUrl: string,
keysetId: string,
unit: string | undefined,
value: number,
now: string = new Date().toISOString(),
): SQLBatchTuple {
return [
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(keysetId) DO UPDATE SET
counter = MAX(counter, excluded.counter),
unit = excluded.unit,
updatedAt = excluded.updatedAt`,
[mintUrl, keysetId, unit ?? null, value, now],
[keysetId, unit ?? null, value, now],
]
}
@@ -64,7 +64,7 @@ export const buildCounterUpsert = function (
export const getCounters = function (): CounterRecord[] {
try {
const db = getInstance()
const {rows} = db.execute(`SELECT mintUrl, keysetId, unit, counter, updatedAt FROM mint_counters`)
const {rows} = db.execute(`SELECT keysetId, unit, counter, updatedAt FROM mint_counters`)
return (rows?._array ?? []) as CounterRecord[]
} catch (e: any) {
throw dbError('Counters could not be retrieved from the database', e)
@@ -72,15 +72,12 @@ export const getCounters = function (): CounterRecord[] {
}
/** Read a single counter, or undefined when no row exists yet. */
export const getCounter = function (
mintUrl: string,
keysetId: string,
): CounterRecord | undefined {
export const getCounter = function (keysetId: string): CounterRecord | undefined {
try {
const db = getInstance()
const {rows} = db.execute(
`SELECT mintUrl, keysetId, unit, counter, updatedAt FROM mint_counters WHERE mintUrl = ? AND keysetId = ?`,
[mintUrl, keysetId],
`SELECT keysetId, unit, counter, updatedAt FROM mint_counters WHERE keysetId = ?`,
[keysetId],
)
return rows?.item(0) as CounterRecord | undefined
} catch (e: any) {
@@ -95,13 +92,12 @@ export const getCounter = function (
* built on. A lower `value` (stale cache, replayed op) is silently ignored.
*/
export const setCounter = function (
mintUrl: string,
keysetId: string,
unit: string | undefined,
value: number,
): void {
try {
const [sql, params] = buildCounterUpsert(mintUrl, keysetId, unit, value)
const [sql, params] = buildCounterUpsert(keysetId, unit, value)
getInstance().execute(sql, params)
} catch (e: any) {
throw dbError('Counter could not be saved to the database', e)
@@ -114,7 +110,6 @@ export const setCounter = function (
* from 0 and becomes `delta`.
*/
export const bumpCounter = function (
mintUrl: string,
keysetId: string,
unit: string | undefined,
delta: number,
@@ -123,12 +118,12 @@ export const bumpCounter = function (
try {
const db = getInstance()
db.execute(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(keysetId) DO UPDATE SET
counter = counter + ?,
updatedAt = excluded.updatedAt`,
[mintUrl, keysetId, unit ?? null, delta, new Date().toISOString(), delta],
[keysetId, unit ?? null, delta, new Date().toISOString(), delta],
)
} catch (e: any) {
throw dbError('Counter could not be advanced in the database', e)
@@ -150,7 +145,7 @@ export const seedCounters = function (seeds: CounterSeed[]): {seeded: number} {
try {
const now = new Date().toISOString()
const batch: SQLBatchTuple[] = seeds.map(s =>
buildCounterUpsert(s.mintUrl, s.keysetId, s.unit, s.counter, now),
buildCounterUpsert(s.keysetId, s.unit, s.counter, now),
)
const db = getInstance()

View File

@@ -13,12 +13,16 @@ import {log} from '../logService'
//
// A row exists only while a request is in-flight; it is deleted on success or
// terminal failure. Keyed by transactionId.
//
// A CHILD of the transaction: the primary key IS the parent's id, so the parent
// owns the mint reference and this table stores none. It used to duplicate
// `mintUrl` and `keysetId` — keysetId had no reader at all, and mintUrl served one
// query, which now joins through the parent. One owner of the fact means nothing
// here can go stale when a mint moves.
// ─────────────────────────────────────────────────────────────────────────────
export type InFlightRequestRecord = {
transactionId: number
mintUrl: string | null
keysetId: string | null
request: any
createdAt: string | null
}
@@ -26,15 +30,11 @@ export type InFlightRequestRecord = {
/** A single in-flight entry for the one-time seed from the MST/MMKV snapshot. */
export type InFlightRequestSeed = {
transactionId: number
mintUrl?: string
keysetId?: string
request: any
}
const rowToRecord = (row: any): InFlightRequestRecord => ({
transactionId: row.transactionId,
mintUrl: row.mintUrl,
keysetId: row.keysetId,
request: JSON.parse(row.request),
createdAt: row.createdAt,
})
@@ -43,17 +43,12 @@ const rowToRecord = (row: any): InFlightRequestRecord => ({
* Store (or replace) the in-flight request for a transaction. Overwrites an
* existing row — matching the previous addInFlightRequest set() semantics.
*/
export const addInFlightRequest = function (
transactionId: number,
mintUrl: string | undefined,
keysetId: string | undefined,
request: any,
): void {
export const addInFlightRequest = function (transactionId: number, request: any): void {
try {
getInstance().execute(
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)`,
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(request), new Date().toISOString()],
`INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?)`,
[transactionId, JSON.stringify(request), new Date().toISOString()],
)
} catch (e: any) {
throw dbError('In-flight request could not be saved to the database', e)
@@ -64,7 +59,7 @@ export const addInFlightRequest = function (
export const getInFlightRequest = function (transactionId: number): InFlightRequestRecord | undefined {
try {
const {rows} = getInstance().execute(
`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`,
`SELECT transactionId, request, createdAt FROM inflight_requests WHERE transactionId = ?`,
[transactionId],
)
const row = rows?.item(0)
@@ -74,12 +69,25 @@ export const getInFlightRequest = function (transactionId: number): InFlightRequ
}
}
/** All in-flight requests for a mint (drives the per-mint recovery sweep). */
export const getInFlightRequestsByMint = function (mintUrl: string): InFlightRequestRecord[] {
/**
* All in-flight requests of a mint (drives the per-mint recovery sweep).
*
* Joins through the owning transaction rather than storing a mint reference here:
* `transactions.mintId` is the single owner of that fact, and being an id it
* survives a mint-url edit — the url copy this table used to keep did not.
*
* A request whose transaction has been deleted is correctly invisible: the retry
* exists to settle proofs onto that transaction, so without it there is nothing to
* apply the result to.
*/
export const getInFlightRequestsByMintId = function (mintId: string): InFlightRequestRecord[] {
try {
const {rows} = getInstance().execute(
`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`,
[mintUrl],
`SELECT r.transactionId, r.request, r.createdAt
FROM inflight_requests r
JOIN transactions t ON t.id = r.transactionId
WHERE t.mintId = ?`,
[mintId],
)
return (rows?._array ?? []).map(rowToRecord)
} catch (e: any) {
@@ -106,10 +114,10 @@ export const seedInFlightRequests = function (seeds: InFlightRequestSeed[]): {se
const now = new Date().toISOString()
getInstance().executeBatch(
seeds.map(s => [
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
[s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.request), now],
[s.transactionId, JSON.stringify(s.request), now],
]),
)
log.info('[seedInFlightRequests]', 'Seeded in-flight requests into SQLite', {count: seeds.length})

View File

@@ -19,8 +19,10 @@ import {
getPendingTopupsCount,
getPendingTransfers,
getPendingTransfersCount,
getPendingOnchainTransfers,
addTransactionAsync,
updateTransaction,
backfillTransactionMintIds,
expireAllAfterRecovery,
updateStatusesAsync,
deleteTransactionsByStatus,
@@ -32,7 +34,6 @@ import {
import {
addOrUpdateProof,
addOrUpdateProofs,
updateProofsMintUrl,
removeAllProofs,
getProofById,
getProofs,
@@ -44,6 +45,7 @@ import {
commitReservation,
rollbackReservation,
getOpenReservations,
backfillReservationMintIds,
} from './reservationsRepo'
import {
getCounters,
@@ -61,10 +63,31 @@ import {
import {
addInFlightRequest,
getInFlightRequest,
getInFlightRequestsByMint,
getInFlightRequestsByMintId,
removeInFlightRequest,
seedInFlightRequests,
} from './inFlightRepo'
import {
allocateNextCounter,
getWalletCounter,
setWalletCounter,
} from './walletCountersRepo'
import {
addOnchainMintQuote,
getOnchainMintQuote,
getOnchainMintQuotesByMintId,
backfillOnchainMintQuoteMintIds,
getWatchedOnchainMintQuotes,
updateOnchainMintQuoteAmounts,
extendOnchainMintQuoteWatch,
} from './onchainQuotesRepo'
import {
upsertMint,
getMints,
removeMintById,
updateMintUrl as updateMintUrlWithProofs,
seedMints,
} from './mintsRepo'
export type {TransactionSearchFilters} from './transactionsRepo'
export type {
@@ -73,11 +96,20 @@ export type {
ReservationTransactionUpdate,
} from './reservationsRepo'
export type {CounterRecord, CounterSeed} from './countersRepo'
export {NUT20_COUNTER} from './walletCountersRepo'
export type {OnchainMintQuoteRecord} from './onchainQuotesRepo'
export {ONCHAIN_QUOTE_WATCH_DAYS} from './onchainQuotesRepo'
export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo'
export type {InFlightRequestRecord, InFlightRequestSeed} from './inFlightRepo'
export type {MintRecord} from './mintsRepo'
export const Database = {
getInstance,
upsertMint,
getMints,
removeMintById,
updateMintUrlWithProofs,
seedMints,
getDatabaseVersion,
cleanAll,
getTransactionsCount,
@@ -91,8 +123,10 @@ export const Database = {
getPendingTopupsCount,
getPendingTransfers,
getPendingTransfersCount,
getPendingOnchainTransfers,
addTransactionAsync,
updateTransaction,
backfillTransactionMintIds,
expireAllAfterRecovery,
updateStatusesAsync,
deleteTransactionsByStatus,
@@ -102,7 +136,6 @@ export const Database = {
getPendingAmount,
addOrUpdateProof,
addOrUpdateProofs,
updateProofsMintUrl,
removeAllProofs,
getProofById,
getProofs,
@@ -112,6 +145,7 @@ export const Database = {
commitReservation,
rollbackReservation,
getOpenReservations,
backfillReservationMintIds,
getCounters,
getCounter,
setCounter,
@@ -123,7 +157,17 @@ export const Database = {
seedMeltRecoveries,
addInFlightRequest,
getInFlightRequest,
getInFlightRequestsByMint,
getInFlightRequestsByMintId,
removeInFlightRequest,
seedInFlightRequests,
allocateNextCounter,
getWalletCounter,
setWalletCounter,
addOnchainMintQuote,
getOnchainMintQuote,
getOnchainMintQuotesByMintId,
backfillOnchainMintQuoteMintIds,
getWatchedOnchainMintQuotes,
updateOnchainMintQuoteAmounts,
extendOnchainMintQuoteWatch,
}

View File

@@ -1,5 +1,5 @@
import {DbConnection, open, SQLBatchTuple} from './connection'
import {createSchemaQueries} from './schema'
import {createSchemaQueries, createTable, DBVERSION_COLUMNS} from './schema'
import {_dbVersion, readDatabaseVersion, seedDatabaseVersion, runMigrations} from './migrations'
import {dbError} from './errors'
import {log} from '../logService'
@@ -27,25 +27,44 @@ const _createDatabaseInstance = function () {
}
}
/**
* Bring the database to the current schema — by BUILDING it (fresh install) or by
* MIGRATING it (everything else), never both.
*
* That either/or is load-bearing. `createSchemaQueries` describes today's shape, so
* running it on an existing database was actively harmful: `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 old enough to predate a table therefore got
* it fully-formed, and the migration that adds a column to that table then died on
* `duplicate column name`, rolling back the entire batch and leaving the database
* unmigrated. Shipped exactly that: a v29 wallet, which predates onchain_mint_quotes
* (v31), got it built WITH the mintId that v33 exists to add, and came up with a
* zero balance.
*
* So: the version row decides. Only `dbversion` itself is created unconditionally,
* because reading the version requires it.
*/
const _createOrUpdateSchema = function (db: DbConnection) {
try {
const {rowsAffected} = db.executeBatch(createSchemaQueries)
// The one table that must exist before anything can be decided.
db.execute(createTable('dbversion', DBVERSION_COLUMNS))
if (rowsAffected && rowsAffected > 0) {
log.info('[_createOrUpdateSchema] New database schema created')
}
const version = readDatabaseVersion(db)
let version = readDatabaseVersion(db)
if (version === null) {
// Fresh database: the schema was just created at the latest shape, so
// seed the version row and skip migrations.
// Fresh install: build at the latest shape and record it, so the migrations
// that produced that shape are correctly skipped.
db.executeBatch(createSchemaQueries)
seedDatabaseVersion(db)
version = _dbVersion
log.info('[_createOrUpdateSchema]', `New database created at version ${_dbVersion}`)
return
}
log.info('[_createOrUpdateSchema]', `Device database version: ${version}`)
// Trigger migrations if there is versions mismatch
// Existing database: migrations own every shape change from here. Each table a
// later version introduced is created by ITS migration, at the shape that
// version had — which is what keeps the subsequent ALTERs valid.
if (version < _dbVersion) {
runMigrations(db)
}
@@ -54,26 +73,39 @@ const _createOrUpdateSchema = function (db: DbConnection) {
}
}
/**
* Drop EVERY table — the factory reset (see DeveloperScreen).
*
* The list is read from the database itself rather than written out here. A
* hand-maintained list drifts the moment a table is added, and this one had:
* it named seven tables while the schema had eleven, so a factory reset silently
* left wallet_counters, onchain_mint_quotes, mints and mint_keysets behind —
* i.e. the user's mints and their onchain deposit addresses SURVIVED a wipe.
*
* It also caused real damage during development: a leftover onchain_mint_quotes
* from a bad build outlived the reset, and because a create-migration uses
* `IF NOT EXISTS`, the migration that should have built it fresh silently skipped
* it — and the ALTER that follows died on "duplicate column name", taking every
* migration down with it.
*
* Asking sqlite_master cannot drift, and it clears artifacts from any past bug too.
*/
export const cleanAll = function () {
const dropQueries = [
['DROP TABLE transactions'],
['DROP TABLE proofs'],
['DROP TABLE dbversion'],
// IF EXISTS: these tables were added by later migrations, so a very old DB
// may lack them; without the guard a missing table aborts the atomic batch.
['DROP TABLE IF EXISTS reservations'],
['DROP TABLE IF EXISTS mint_counters'],
['DROP TABLE IF EXISTS melt_recovery'],
['DROP TABLE IF EXISTS inflight_requests'],
] as SQLBatchTuple[]
try {
const db = getInstance()
const {rowsAffected} = db.executeBatch(dropQueries)
if (rowsAffected && rowsAffected > 0) {
log.info('[cleanAll]', 'Database tables were deleted')
}
const {rows} = db.execute(
// sqlite_% is SQLite's own bookkeeping (sqlite_sequence and friends) and is
// not ours to drop.
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
)
const tables: string[] = (rows?._array ?? []).map((r: any) => r.name)
if (tables.length === 0) return
db.executeBatch(tables.map(name => [`DROP TABLE IF EXISTS "${name}"`]) as SQLBatchTuple[])
log.info('[cleanAll]', 'Database tables were deleted', {tables})
} catch (e: any) {
throw dbError('Could not delete database schema', e)
}

View File

@@ -15,12 +15,17 @@ import {StoredMeltPreview} from '../cashu/cashuUtils'
//
// A row exists only while a melt is in-flight; it is deleted on terminal
// success/failure. Keyed by transactionId.
//
// A CHILD of the transaction: the primary key IS the parent's id, so the parent
// owns the mint reference and this table stores none. It used to duplicate
// `mintUrl` and `keysetId`; neither had a single reader. The keyset that IS used
// comes from inside `meltPreview`, and every caller arrives here already holding
// the transaction. One owner of each fact, so nothing here goes stale on a
// mint-url edit.
// ─────────────────────────────────────────────────────────────────────────────
export type MeltRecoveryRecord = {
transactionId: number
mintUrl: string | null
keysetId: string | null
meltPreview: StoredMeltPreview
createdAt: string | null
}
@@ -28,8 +33,6 @@ export type MeltRecoveryRecord = {
/** A single melt-recovery entry for the one-time seed from the MST/MMKV snapshot. */
export type MeltRecoverySeed = {
transactionId: number
mintUrl?: string
keysetId?: string
meltPreview: StoredMeltPreview
}
@@ -40,16 +43,14 @@ export type MeltRecoverySeed = {
*/
export const addMeltRecovery = function (
transactionId: number,
mintUrl: string | undefined,
keysetId: string | undefined,
meltPreview: StoredMeltPreview,
): void {
try {
getInstance().execute(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(meltPreview), new Date().toISOString()],
[transactionId, JSON.stringify(meltPreview), new Date().toISOString()],
)
} catch (e: any) {
throw dbError('Melt recovery could not be saved to the database', e)
@@ -60,15 +61,13 @@ export const addMeltRecovery = function (
export const getMeltRecovery = function (transactionId: number): MeltRecoveryRecord | undefined {
try {
const {rows} = getInstance().execute(
`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`,
`SELECT transactionId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`,
[transactionId],
)
const row = rows?.item(0)
if (!row) return undefined
return {
transactionId: row.transactionId,
mintUrl: row.mintUrl,
keysetId: row.keysetId,
meltPreview: JSON.parse(row.meltPreview) as StoredMeltPreview,
createdAt: row.createdAt,
}
@@ -97,10 +96,10 @@ export const seedMeltRecoveries = function (seeds: MeltRecoverySeed[]): {seeded:
const db = getInstance()
db.executeBatch(
seeds.map(s => [
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
[s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.meltPreview), now],
[s.transactionId, JSON.stringify(s.meltPreview), now],
]),
)
log.info('[seedMeltRecoveries]', 'Seeded melt recovery entries into SQLite', {count: seeds.length})

View File

@@ -1,13 +1,70 @@
import {DbConnection, SQLBatchTuple} from './connection'
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS} from './schema'
// RESERVATIONS_COLUMNS / ONCHAIN_MINT_QUOTES_COLUMNS are deliberately NOT imported:
// v26 and v31 build those tables from the frozen historical shapes below, not from
// the live schema. See the note there.
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS, MINTS_COLUMNS, MINT_KEYSETS_COLUMNS} from './schema'
import {dbError} from './errors'
import {log} from '../logService'
/** Bump this when a schema change requires a migration, then add an entry below. */
export const _dbVersion = 29
type Migration = {version: number; queries: SQLBatchTuple[]}
// ─────────────────────────────────────────────────────────────────────────────
// Historical column shapes — FROZEN. Do not edit to match schema.ts.
//
// A migration must create a table as it existed AT ITS OWN VERSION. These used to
// read the live constants from schema.ts, which is unsound the moment a column is
// added: a device replaying the old migration would get TODAY's shape, and the
// later ALTER that adds that column would fail with "duplicate column name" —
// breaking upgrades from exactly the older versions the migration exists to serve.
// (v33 adds mintId to both of these, so that is no longer hypothetical.)
// ─────────────────────────────────────────────────────────────────────────────
/** `reservations` as created by v26, before v33 added mintId. */
const RESERVATIONS_COLUMNS_V26 = `
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
createdAt TEXT NOT NULL
`
/** `melt_recovery` as created by v28, before v34 dropped mintUrl/keysetId. */
const MELT_RECOVERY_COLUMNS_V28 = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
`
/** `inflight_requests` as created by v29, before v34 dropped mintUrl/keysetId. */
const INFLIGHT_REQUESTS_COLUMNS_V29 = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
`
/** `onchain_mint_quotes` as created by v31, before v33 added mintId. */
const ONCHAIN_MINT_QUOTES_COLUMNS_V31 = `
quote TEXT PRIMARY KEY NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
counterIndex INTEGER NOT NULL,
pubkey TEXT NOT NULL,
amountRequested INTEGER,
amountPaid INTEGER NOT NULL DEFAULT 0,
amountIssued INTEGER NOT NULL DEFAULT 0,
expiry INTEGER,
watchUntil TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT
`
/**
* Ordered migration registry. On startup every migration whose `version` is
* greater than the device's current version is applied, in order, inside a
@@ -16,7 +73,7 @@ type Migration = {version: number; queries: SQLBatchTuple[]}
* To add a migration: append an entry with the next version number. No runner
* logic changes are needed.
*/
const MIGRATIONS: Migration[] = [
export const MIGRATIONS: Migration[] = [
// IF EXISTS: on devices that never had a usersettings table this DROP used to
// error (and the error was swallowed, leaving the migration stuck). Making it
// defensive lets these devices migrate forward cleanly and lets us treat any
@@ -67,7 +124,7 @@ const MIGRATIONS: Migration[] = [
{
// Add reservations table for atomic proof reservations (Phase 5).
version: 26,
queries: [[createTable('reservations', RESERVATIONS_COLUMNS)]],
queries: [[createTable('reservations', RESERVATIONS_COLUMNS_V26)]],
},
{
// Add per-keyset derivation counters table. The table is created empty here;
@@ -84,17 +141,154 @@ const MIGRATIONS: Migration[] = [
// meltCounterValues from the MST/MMKV snapshot are copied by a one-time JS
// seed (see setupRootStore._runMigrations).
version: 28,
queries: [[createTable('melt_recovery', MELT_RECOVERY_COLUMNS)]],
queries: [[createTable('melt_recovery', MELT_RECOVERY_COLUMNS_V28)]],
},
{
// Add per-transaction in-flight request table. Empty on creation; any
// in-flight requests from the MST/MMKV snapshot are copied by a one-time JS
// seed (see setupRootStore._runMigrations).
version: 29,
queries: [[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)]],
queries: [[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS_V29)]],
},
{
// Add wallet-global derivation counters (NUT-20 quote-locking keys).
// No seed: no NUT-20 quote has ever been created, so an absent row (== 0,
// the first free index) is correct for both new and upgrading wallets.
version: 30,
queries: [[createTable('wallet_counters', WALLET_COUNTERS_COLUMNS)]],
},
{
// Onchain (NUT-30): the mint-quote table, plus `outpoint` on transactions for
// the melt side's txid:vout (melt quotes are one-shot, so they need no table).
// Both empty on creation — no onchain transaction can predate this.
version: 31,
queries: [
[createTable('onchain_mint_quotes', ONCHAIN_MINT_QUOTES_COLUMNS_V31)],
[`ALTER TABLE transactions ADD COLUMN outpoint TEXT`],
],
},
{
// Re-key mint_counters on keysetId alone, dropping mintUrl from the primary
// key. NUT-13 derives from (seed, keysetId, counter) with no mint component,
// so (mintUrl, keysetId) described a key space that does not exist: it let two
// rows track ONE derivation path independently, and left a counter
// unaddressable after a mint-url edit (hydration matched on url, found no row,
// and silently restarted the counter at 0 — reusing blinded secrets).
//
// Any such split is healed here rather than merely prevented: duplicates
// collapse to MAX(counter), the conservative direction, which can skip indices
// but never reuse them. SQLite's bare-column rule for a single-aggregate query
// takes `unit`/`updatedAt` from the same row that supplied MAX(counter), so
// the surviving row is internally consistent.
//
// No DROP COLUMN (unsupported on older SQLite), so the table is rebuilt from
// the canonical column definition.
version: 32,
queries: [
[createTable('mint_counters_v32', MINT_COUNTERS_COLUMNS, false)],
[
`INSERT INTO mint_counters_v32 (${MINT_COUNTERS_COLUMN_NAMES})
SELECT keysetId, unit, MAX(counter), updatedAt
FROM mint_counters
GROUP BY keysetId`,
],
[`DROP TABLE mint_counters`],
[`ALTER TABLE mint_counters_v32 RENAME TO mint_counters`],
],
},
{
// Reference the owning mint by its stable id rather than its url.
//
// A mint url is a network locator; mints move. Rows that outlive the move —
// an onchain quote's address stays creditable for as long as the mint exists —
// cannot be addressed by one, and following a stale url just polls a dead host
// forever, silently. Mint.id never changes, so it survives.
//
// Added nullable and left NULL here: mints live in the MST/MMKV snapshot, not
// SQLite, so nothing in SQL can map url -> id. The v38 seed in setupRootStore
// backfills it once the store is hydrated. A row still NULL after that belongs
// to a mint no longer in the wallet, and is dead either way.
//
// `mintUrl` stays on both tables as the historical record of where the row was
// created; it is no longer followed.
version: 33,
queries: [
[`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`],
[`ALTER TABLE reservations ADD COLUMN mintId TEXT`],
],
},
{
// Split the two jobs `transactions.mint` was doing, and stop the child tables
// duplicating the answer.
//
// `transactions.mint` meant two things at once, switched by status: a
// historical record of where a finished payment happened, AND a live pointer
// the wallet dialled for an open one. A mint-url edit therefore had to rewrite
// the in-flight rows — rewriting the same column that records the past. `mintId`
// takes over the identity job, so `mint` can be frozen as pure history.
//
// inflight_requests and melt_recovery are CHILD rows of a transaction (their
// primary key IS transactionId), so the parent already owns "which mint". Their
// own mintUrl/keysetId copies had no readers at all — dead denormalization of
// exactly the kind that goes stale. They are rebuilt without them; the one
// mint-scoped query joins through the parent instead.
//
// No DROP COLUMN (unsupported on older SQLite), hence the rebuilds. Both tables
// hold at most a handful of rows, and only while an operation is in flight.
version: 34,
queries: [
[`ALTER TABLE transactions ADD COLUMN mintId TEXT`],
[createTable('inflight_requests_v34', INFLIGHT_REQUESTS_COLUMNS, false)],
[
`INSERT INTO inflight_requests_v34 (transactionId, request, createdAt)
SELECT transactionId, request, createdAt FROM inflight_requests`,
],
[`DROP TABLE inflight_requests`],
[`ALTER TABLE inflight_requests_v34 RENAME TO inflight_requests`],
[createTable('melt_recovery_v34', MELT_RECOVERY_COLUMNS, false)],
[
`INSERT INTO melt_recovery_v34 (transactionId, meltPreview, createdAt)
SELECT transactionId, meltPreview, createdAt FROM melt_recovery`,
],
[`DROP TABLE melt_recovery`],
[`ALTER TABLE melt_recovery_v34 RENAME TO melt_recovery`],
],
},
{
// Master the mints here instead of in the MST/MMKV snapshot.
//
// Mints were the last core entity persisted by serializing the whole tree.
// Since postProcessSnapshot already strips proofs and transactions, mints —
// with every keyset's `keys` map — had become the largest thing left in it, and
// `JSON.stringify(snapshot)` runs on EVERY MST action, including every proof
// mutation during a send. It also puts mints in the same engine as proofs, so a
// mint-url edit can finally commit atomically with the proofs it renames.
//
// Created EMPTY: mints live in the MMKV snapshot, so nothing in SQL can read
// them. The v39 seed in setupRootStore copies them once the store is hydrated —
// the same shape as the counters/melt/inflight seeds before it.
version: 35,
queries: [
[createTable('mints', MINTS_COLUMNS)],
[createTable('mint_keysets', MINT_KEYSETS_COLUMNS)],
],
},
]
/**
* The schema version this build expects: whatever the newest migration produces.
*
* DERIVED, never hand-set. It used to be a literal that had to be bumped in step
* with the list, and forgetting was silent in the worst way: a migration below
* `_dbVersion` simply never runs (the runner only applies `currentVersion <
* migration.version` and then stores `_dbVersion`), so devices upgrade to a schema
* the code does not have, and the failure surfaces later as a missing column. A
* fresh install would be fine, which is exactly what makes it easy to miss.
*/
export const _dbVersion = MIGRATIONS.reduce((max, m) => Math.max(max, m.version), 0)
/**
* Pure read of the stored schema version. Returns null when the version row has
* not been seeded yet (a fresh database). Never mutates.

View File

@@ -0,0 +1,228 @@
import {SQLBatchTuple} from './connection'
import {getInstance} from './instance'
import {dbError} from './errors'
import {log} from '../logService'
// ─────────────────────────────────────────────────────────────────────────────
// Mints and their keysets.
//
// The authority for the wallet's mints. They were the last core entity persisted
// only by serializing the whole MST tree to MMKV — and since proofs and
// transactions are stripped from that snapshot, mints (with every keyset's `keys`
// map) had become the largest thing left in it, re-serialized on EVERY MST action
// anywhere in the tree.
//
// As with proofs and transactions: SQLite is the authority, the MST model is an
// in-memory cache. Reads go through the model (MobX cannot observe a table);
// writes land here.
//
// See MINTS_COLUMNS / MINT_KEYSETS_COLUMNS for why the keyset and keys payloads are
// stored as whole JSON objects rather than exploded into columns.
// ─────────────────────────────────────────────────────────────────────────────
/** A mint row plus its keysets, shaped to drop straight into MintModel.create. */
export type MintRecord = {
id: string
mintUrl: string
hostname?: string
shortname?: string
units: string[]
keysets: any[]
keys: any[]
mintInfo?: any
color?: string
status?: string
createdAt?: string
}
const parseJson = <T>(value: string | null | undefined, fallback: T): T => {
if (!value) return fallback
try {
return JSON.parse(value) as T
} catch {
return fallback
}
}
/**
* Build the full write for one mint: the mint row plus a row per keyset.
*
* Returned as batch tuples so the same write can run standalone (upsertMint) or be
* folded into a larger transaction — which is what lets a mint-url edit commit
* atomically with the proofs it renames.
*
* Keysets are upserted, never cleared-and-reinserted: a keyset is only ever added
* (refreshKeysets does not prune, and removeKeyset is unused), and a delete/insert
* pair would briefly leave a mint with no keys inside the transaction.
*/
export const buildMintUpsert = function (mint: MintRecord): SQLBatchTuple[] {
const batch: SQLBatchTuple[] = [
[
`INSERT INTO mints (id, mintUrl, hostname, shortname, color, status, units, mintInfo, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
mintUrl = excluded.mintUrl,
hostname = excluded.hostname,
shortname = excluded.shortname,
color = excluded.color,
status = excluded.status,
units = excluded.units,
mintInfo = excluded.mintInfo`,
[
mint.id,
mint.mintUrl,
mint.hostname ?? null,
mint.shortname ?? null,
mint.color ?? null,
mint.status ?? null,
JSON.stringify(mint.units ?? []),
mint.mintInfo ? JSON.stringify(mint.mintInfo) : null,
mint.createdAt ?? new Date().toISOString(),
],
],
]
const keysById = new Map<string, any>((mint.keys ?? []).map(k => [k.id, k]))
for (const keyset of mint.keysets ?? []) {
const keys = keysById.get(keyset.id)
batch.push([
`INSERT INTO mint_keysets (keysetId, mintId, unit, keyset, keys)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(keysetId) DO UPDATE SET
mintId = excluded.mintId,
unit = excluded.unit,
keyset = excluded.keyset,
-- Never overwrite stored keys with null: a keyset's keys are fetched
-- separately from its metadata, so a metadata-only refresh must not erase
-- them (without keys the wallet cannot verify or spend that keyset).
keys = COALESCE(excluded.keys, mint_keysets.keys)`,
[keyset.id, mint.id, keyset.unit ?? null, JSON.stringify(keyset), keys ? JSON.stringify(keys) : null],
])
}
return batch
}
/** Write one mint (and its keysets) atomically. */
export const upsertMint = function (mint: MintRecord): void {
try {
getInstance().executeBatch(buildMintUpsert(mint))
} catch (e: any) {
throw dbError('Mint could not be saved to the database', e)
}
}
/** Every mint with its keysets, for the startup hydrate. */
export const getMints = function (): MintRecord[] {
try {
const db = getInstance()
const {rows: mintRows} = db.execute(
`SELECT id, mintUrl, hostname, shortname, color, status, units, mintInfo, createdAt
FROM mints ORDER BY createdAt`,
)
const {rows: keysetRows} = db.execute(`SELECT keysetId, mintId, unit, keyset, keys FROM mint_keysets`)
const keysetsByMint = new Map<string, {keysets: any[]; keys: any[]}>()
for (const row of keysetRows?._array ?? []) {
const entry = keysetsByMint.get(row.mintId) ?? {keysets: [], keys: []}
entry.keysets.push(parseJson(row.keyset, null))
// A keyset whose keys were never fetched contributes nothing to `keys`,
// matching the model, where the two arrays are independent.
const keys = parseJson<any>(row.keys, null)
if (keys) entry.keys.push(keys)
keysetsByMint.set(row.mintId, entry)
}
return (mintRows?._array ?? []).map((row: any) => {
const owned = keysetsByMint.get(row.id) ?? {keysets: [], keys: []}
return {
id: row.id,
mintUrl: row.mintUrl,
hostname: row.hostname ?? undefined,
shortname: row.shortname ?? undefined,
units: parseJson<string[]>(row.units, []),
keysets: owned.keysets.filter(Boolean),
keys: owned.keys,
mintInfo: parseJson<any>(row.mintInfo, undefined),
color: row.color ?? undefined,
status: row.status ?? undefined,
createdAt: row.createdAt ?? undefined,
}
})
} catch (e: any) {
throw dbError('Mints could not be retrieved from the database', e)
}
}
/**
* Remove a mint and its keysets.
*
* Its mint_counters rows are deliberately left behind: they are keyed by keysetId
* and retained across removal, so re-adding the mint recovers its real derivation
* counter instead of restarting at 0 (see MINT_COUNTERS_COLUMNS).
*/
export const removeMintById = function (mintId: string): void {
try {
getInstance().executeBatch([
[`DELETE FROM mint_keysets WHERE mintId = ?`, [mintId]],
[`DELETE FROM mints WHERE id = ?`, [mintId]],
])
log.debug('[removeMintById]', 'Mint removed from the database', {mintId})
} catch (e: any) {
throw dbError('Mint could not be removed from the database', e)
}
}
/**
* Point a mint at a new url, together with the proofs that reference it — in ONE
* transaction.
*
* This is the whole reason mints belong in SQLite. Before, the mint's url lived in
* the MMKV snapshot and its proofs in SQLite, so a rename spanned two engines with
* no transaction between them. A crash in that window left the proofs pointing at a
* url no mint owned: the money vanished from every per-mint balance while still
* counting toward the total, and was unspendable, because send and melt select
* proofs by mint.
*
* `proofs.mintUrl` stays denormalized, but it can no longer drift — it is now
* maintained inside the same transaction as its source of truth.
*/
export const updateMintUrl = function (
mintId: string,
currentMintUrl: string,
updatedMintUrl: string,
hostname: string | null,
): void {
try {
getInstance().executeBatch([
[`UPDATE mints SET mintUrl = ?, hostname = ? WHERE id = ?`, [updatedMintUrl, hostname, mintId]],
[`UPDATE proofs SET mintUrl = ? WHERE mintUrl = ?`, [updatedMintUrl, currentMintUrl]],
])
log.debug('[updateMintUrl]', 'Mint url updated with its proofs', {
mintId,
currentMintUrl,
updatedMintUrl,
})
} catch (e: any) {
throw dbError('Mint url could not be updated in the database', e)
}
}
/**
* One-time copy of the MST/MMKV-resident mints into SQLite (the v39 seed).
*
* Idempotent by construction: it upserts by the mint's own id, so re-running it
* cannot duplicate a mint. Runs in a single batch.
*/
export const seedMints = function (mints: MintRecord[]): {seeded: number} {
if (!mints || mints.length === 0) return {seeded: 0}
try {
const batch = mints.flatMap(buildMintUpsert)
getInstance().executeBatch(batch)
log.info('[seedMints]', 'Seeded mints into SQLite', {count: mints.length})
return {seeded: mints.length}
} catch (e: any) {
throw dbError('Mints could not be seeded into the database', e)
}
}

View File

@@ -0,0 +1,270 @@
import {getInstance} from './instance'
import {dbError} from './errors'
import {log} from '../logService'
// ─────────────────────────────────────────────────────────────────────────────
// Onchain (NUT-30) MINT quotes.
//
// A NUT-30 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; the wallet mints the difference. That state cannot live on a transaction
// row — a transaction has one fixed amount — so it lives here, and transactions
// reference it through `transactions.quote` (N transactions : 1 quote, one per
// mint operation).
//
// Two properties of the spec drive everything below:
//
// 1. The mint returns `expiry: null` — the address NEVER dies. Funds sent to a
// long-abandoned address stay creditable forever. So rows are kept forever
// (never deleted), and `counterIndex` in particular must survive: it is the
// NUT-20 derivation index, and the only way to re-derive the key needed to
// sign a mint request for this quote. Delete it and the money is unspendable.
//
// 2. Because nothing expires server-side, nothing bounds polling of a quote that
// was never paid. `watchUntil` is the wallet's own deadline for that, and
// mirrors the 24h fallback bolt11 topup applies to an invoice with no expiry
// tag.
//
// MELT quotes deliberately have no table: they are one-shot and terminal, so
// their durable state (quote id, outpoint, fee) fits on the transaction row.
// ─────────────────────────────────────────────────────────────────────────────
/**
* How long the wallet keeps checking a quote nobody has paid yet.
*
* Longer than bolt11's 24h invoice fallback: an onchain sender may reasonably pay
* the next day, and confirmations add more on top. Expiring the WATCH does not
* expire the FUNDS — the address still works, the row is still here, and the user
* can re-check and mint a late deposit from the transaction detail.
*/
export const ONCHAIN_QUOTE_WATCH_DAYS = 7
export type OnchainMintQuoteRecord = {
quote: string
/**
* Stable id (Mint.id) of the owning mint — the ONLY reference to resolve for
* processing. Null when the mint is no longer in the wallet (or on a pre-v38 row
* the backfill has not reached yet), in which case the quote is unusable.
*/
mintId: string | null
/**
* Where the quote was created. A historical record — do NOT follow it: after a
* mint-url edit it points at a dead host, and these rows outlive the move.
*/
mintUrl: string
unit: string
address: string
/** NUT-20 derivation index. Never lose this — see the note above. */
counterIndex: number
pubkey: string
/** What the user asked for. A BIP21 hint the sender is free to ignore. */
amountRequested: number | null
/** Confirmed and eligible, per the mint. */
amountPaid: number
/** Already minted into ecash. */
amountIssued: number
expiry: number | null
watchUntil: string
createdAt: string
updatedAt: string | null
}
const COLS = `quote, mintId, mintUrl, unit, address, counterIndex, pubkey, amountRequested,
amountPaid, amountIssued, expiry, watchUntil, createdAt, updatedAt`
/**
* Persist a freshly created quote.
*
* Called immediately after the mint returns the address, and BEFORE it is shown
* to the user: if the app dies between the two, the row (and with it the burned
* NUT-20 index) is already safe, so a deposit to that address remains mintable.
*/
export const addOnchainMintQuote = function (
q: Omit<
OnchainMintQuoteRecord,
'mintId' | 'amountPaid' | 'amountIssued' | 'createdAt' | 'updatedAt' | 'watchUntil'
> & {
mintId?: string | null
amountPaid?: number
amountIssued?: number
watchUntil?: string
},
): void {
try {
const now = new Date()
const watchUntil =
q.watchUntil ??
new Date(now.getTime() + ONCHAIN_QUOTE_WATCH_DAYS * 24 * 60 * 60 * 1000).toISOString()
getInstance().execute(
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
q.quote,
q.mintId ?? null,
q.mintUrl,
q.unit,
q.address,
q.counterIndex,
q.pubkey,
q.amountRequested ?? null,
q.amountPaid ?? 0,
q.amountIssued ?? 0,
q.expiry ?? null,
watchUntil,
now.toISOString(),
now.toISOString(),
],
)
log.debug('[addOnchainMintQuote]', {quote: q.quote, mintId: q.mintId, mintUrl: q.mintUrl})
} catch (e: any) {
throw dbError('Onchain mint quote could not be saved to the database', e)
}
}
/** One quote by id, or undefined. */
export const getOnchainMintQuote = function (quote: string): OnchainMintQuoteRecord | undefined {
try {
const {rows} = getInstance().execute(
`SELECT ${COLS} FROM onchain_mint_quotes WHERE quote = ?`,
[quote],
)
return rows?.item(0) as OnchainMintQuoteRecord | undefined
} catch (e: any) {
throw dbError('Onchain mint quote could not be retrieved from the database', e)
}
}
/**
* Record what the mint currently reports for a quote.
*
* MONOTONIC, like the derivation counters: `amount_paid` and `amount_issued` only
* ever rise at the mint, so a stale or out-of-order response can only be a no-op,
* never a regression. Without this, a slow reply arriving after a fresh one could
* walk `amountIssued` backwards and make the wallet think there is unminted money
* where there is none — and mint it twice.
*/
export const updateOnchainMintQuoteAmounts = function (
quote: string,
amountPaid: number,
amountIssued: number,
): void {
try {
getInstance().execute(
`UPDATE onchain_mint_quotes
SET amountPaid = MAX(amountPaid, ?),
amountIssued = MAX(amountIssued, ?),
updatedAt = ?
WHERE quote = ?`,
[amountPaid, amountIssued, new Date().toISOString(), quote],
)
} catch (e: any) {
throw dbError('Onchain mint quote amounts could not be updated in the database', e)
}
}
/**
* Quotes the wallet should still be checking.
*
* amountPaid > amountIssued -> money is credited but not yet
* minted. Watch regardless of any
* deadline: we must never walk away
* from funds the mint is holding.
* amountIssued = 0 AND watchUntil > now -> nothing has arrived yet and the
* window is still open.
*
* Everything else is archived: fully drained (the "stop after the first mint" case),
* or never paid within the window. Archived rows are NOT deleted — a late deposit is
* still creditable, and the user can re-check from the transaction detail.
*
* Note the first clause is about the unminted BALANCE, not about "has ever minted".
* Keying on `amountIssued > 0` would abandon a partially-drained quote with real
* money still credited to it.
*/
export const getWatchedOnchainMintQuotes = function (): OnchainMintQuoteRecord[] {
try {
const {rows} = getInstance().execute(
`SELECT ${COLS} FROM onchain_mint_quotes
WHERE amountPaid > amountIssued
OR (amountIssued = 0 AND watchUntil > ?)
ORDER BY createdAt DESC`,
[new Date().toISOString()],
)
return (rows?._array ?? []) as OnchainMintQuoteRecord[]
} catch (e: any) {
throw dbError('Onchain mint quotes could not be retrieved from the database', e)
}
}
/**
* Every quote of a mint, newest first.
*
* Keyed by mintId, not url: the mint may have moved since the quote was created,
* and a url match would then silently return nothing.
*/
export const getOnchainMintQuotesByMintId = function (mintId: string): OnchainMintQuoteRecord[] {
try {
const {rows} = getInstance().execute(
`SELECT ${COLS} FROM onchain_mint_quotes WHERE mintId = ? ORDER BY createdAt DESC`,
[mintId],
)
return (rows?._array ?? []) as OnchainMintQuoteRecord[]
} catch (e: any) {
throw dbError('Onchain mint quotes could not be retrieved from the database', e)
}
}
/**
* One-time backfill of `mintId` from the url a row was created at (the v38 seed).
*
* Only touches rows that have no mintId yet, so it is idempotent and can never
* overwrite a resolved id with a guess from a url that has since moved on.
*
* Matching on url is sound ONLY here, at the moment of upgrade: before this
* migration nothing could edit a mint's url without also rewriting these rows'
* mintUrl, so the two still agree. That is exactly why the join is spent once, at
* rest, rather than on every rename.
*/
export const backfillOnchainMintQuoteMintIds = function (
mints: Array<{id: string; mintUrl: string}>,
): {updated: number} {
if (mints.length === 0) return {updated: 0}
try {
const db = getInstance()
let updated = 0
for (const mint of mints) {
const {rowsAffected} = db.execute(
`UPDATE onchain_mint_quotes SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`,
[mint.id, mint.mintUrl],
)
updated += rowsAffected ?? 0
}
log.info('[backfillOnchainMintQuoteMintIds]', 'Backfilled onchain quote mint ids', {updated})
return {updated}
} catch (e: any) {
throw dbError('Onchain mint quote mintIds could not be backfilled', e)
}
}
/**
* Reopen the watch window on a quote — the user asking "did anything else arrive?"
* from the transaction detail, after the quote had been archived.
*/
export const extendOnchainMintQuoteWatch = function (
quote: string,
days: number = ONCHAIN_QUOTE_WATCH_DAYS,
): void {
try {
const watchUntil = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
getInstance().execute(
`UPDATE onchain_mint_quotes SET watchUntil = ?, updatedAt = ? WHERE quote = ?`,
[watchUntil, new Date().toISOString(), quote],
)
log.debug('[extendOnchainMintQuoteWatch]', {quote, watchUntil})
} catch (e: any) {
throw dbError('Onchain mint quote watch could not be extended in the database', e)
}
}

View File

@@ -98,25 +98,11 @@ export const addOrUpdateProofs = function (
}
export const updateProofsMintUrl = function (currentMintUrl: string, updatedMintUrl: string) {
try {
const query = `
UPDATE proofs
SET mintUrl = ?
WHERE mintUrl = ?
`
const params = [updatedMintUrl, currentMintUrl]
const db = getInstance()
db.execute(query, params)
log.debug('[updateMintUrl]', 'Proof mintUrl updated', {currentMintUrl, updatedMintUrl})
} catch (e: any) {
throw dbError('Could not update proof mintUrl in database', e)
}
}
// A standalone updateProofsMintUrl used to live here. It is gone on purpose: a
// mint-url edit must move the mint row and its proofs in ONE transaction, so that
// write belongs with the mint's own — see mintsRepo.updateMintUrl. Repointing
// proofs by themselves is what left them owned by no mint when the two writes were
// separable.
export const removeAllProofs = async function () {
try {

View File

@@ -37,8 +37,14 @@ export type LockedProofSnapshot = {
export type ReservationRow = {
id: string
transactionId: number
/**
* Stable id (Mint.id) of the owning mint — resolve this, not `mintUrl`, to reach
* the mint. Null only on a row opened before v33.
*/
mintId: string | null
/** Where the operation started. Historical/debug; not followed. */
mintUrl: string
transactionId: number
unit: string
operationType: string
lockedProofs: LockedProofSnapshot[]
@@ -56,6 +62,7 @@ export const openReservation = function (
reservation: {
id: string
transactionId: number
mintId?: string
mintUrl: string
unit: string
operationType: string
@@ -68,11 +75,12 @@ export const openReservation = function (
const batch: SQLBatchTuple[] = []
batch.push([
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO reservations (id, transactionId, mintId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
reservation.id,
reservation.transactionId,
reservation.mintId ?? null,
reservation.mintUrl,
reservation.unit,
reservation.operationType,
@@ -173,7 +181,6 @@ export const commitReservation = function (
* next derivation reuse a blinded secret. Each upsert is monotonic.
*/
counterUpdate?: Array<{
mintUrl: string
keysetId: string
unit?: string
counter: number
@@ -262,7 +269,7 @@ export const commitReservation = function (
}
for (const cu of changes.counterUpdate ?? []) {
batch.push(buildCounterUpsert(cu.mintUrl, cu.keysetId, cu.unit, cu.counter, now))
batch.push(buildCounterUpsert(cu.keysetId, cu.unit, cu.counter, now))
}
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
@@ -321,6 +328,38 @@ export const rollbackReservation = function (
* Return all reservations currently in the DB. Used at startup to roll back
* orphans (operations whose process died before they could commit or rollback).
*/
/**
* One-time backfill of `mintId` from the url a reservation was opened at (the v38
* seed). See backfillOnchainMintQuoteMintIds — same reasoning, and only fills
* NULLs, so it is idempotent.
*
* Almost always a no-op in practice: a reservation lives only for the duration of
* an operation, so an upgrade rarely catches one open. It exists for the upgrade
* that lands right after a crash left an orphan behind.
*/
export const backfillReservationMintIds = function (
mints: Array<{id: string; mintUrl: string}>,
): {updated: number} {
if (mints.length === 0) return {updated: 0}
try {
const db = getInstance()
let updated = 0
for (const mint of mints) {
const {rowsAffected} = db.execute(
`UPDATE reservations SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`,
[mint.id, mint.mintUrl],
)
updated += rowsAffected ?? 0
}
log.info('[backfillReservationMintIds]', 'Backfilled reservation mint ids', {updated})
return {updated}
} catch (e: any) {
throw dbError('Reservation mintIds could not be backfilled', e)
}
}
export const getOpenReservations = function (): ReservationRow[] {
try {
const db = getInstance()
@@ -339,6 +378,7 @@ export const getOpenReservations = function (): ReservationRow[] {
result.push({
id: row.id,
transactionId: row.transactionId,
mintId: row.mintId ?? null,
mintUrl: row.mintUrl,
unit: row.unit,
operationType: row.operationType,

View File

@@ -1,14 +1,44 @@
import {SQLBatchTuple} from './connection'
/**
* Schema definitions — the single source of truth for table shapes.
* Schema definitions — the source of truth for the CURRENT table shapes, used to
* build a fresh database (see createSchemaQueries).
*
* The `proofs` and `reservations` column lists are referenced from more than
* one place (first-run creation here, plus the v25 proofs rebuild and the v26
* reservations add in migrations.ts). Defining the columns once and generating
* every `CREATE TABLE` from them guarantees the definitions can never drift.
* These constants track the latest shape and therefore CHANGE over time. A
* migration must never build a table from one: a migration has to create the table
* as it existed AT ITS OWN VERSION, or a device replaying it gets today's shape and
* the later ALTER that adds the column fails with "duplicate column name". Old
* migrations freeze their own copy of the shape locally — see the historical
* constants at the top of migrations.ts.
*
* A table rebuild in a migration may reference a live constant only when the
* migration IS the change that produced that shape (as with the v25 proofs rebuild
* and the v32 mint_counters re-key), and even then it stops being true the next
* time the shape moves.
*/
/**
* Wallet transactions.
*
* `mint` and `mintId` are BOTH mint references, and the split is deliberate:
*
* - `mint` is the url the payment actually happened at — a historical fact, frozen
* once written. It is what history displays, and rewriting it would be a lie
* about the past.
* - `mintId` (Mint.id) is the identity, and the ONLY reference to resolve when
* doing something: reaching the mint over the network, or asking "is this
* transaction's mint still in the wallet?". It survives a mint-url edit.
*
* They used to be one column doing both jobs, switched by `status` — live pointer
* while open, history once terminal. That conflation is why a mint-url edit had to
* rewrite in-flight rows: the same column that recorded the past was also being
* dialled. With mintId carrying the identity, `mint` can simply stop moving.
*
* `mintId` is nullable: it cannot be backfilled in SQL (mints live in the MST/MMKV
* snapshot — see the v38 seed in setupRootStore), and history legitimately contains
* transactions of mints since removed, which have no id to point at. A null means
* "no mint in this wallet"; `mint` still records where it happened.
*/
export const TRANSACTIONS_COLUMNS = `
id INTEGER PRIMARY KEY NOT NULL,
paymentId TEXT,
@@ -22,6 +52,7 @@ export const TRANSACTIONS_COLUMNS = `
sentTo TEXT,
profile TEXT,
memo TEXT,
mintId TEXT,
mint TEXT,
quote TEXT,
paymentRequest TEXT,
@@ -34,7 +65,8 @@ export const TRANSACTIONS_COLUMNS = `
tags TEXT,
status TEXT,
expiresAt TEXT,
createdAt TEXT
createdAt TEXT,
outpoint TEXT
`
export const PROOFS_COLUMNS = `
@@ -58,9 +90,23 @@ export const DBVERSION_COLUMNS = `
createdAt TEXT
`
/**
* Open outgoing-operation reservations (a row exists only between reserve() and
* commit()/rollback()).
*
* `mintId` (Mint.id) is the owning-mint reference; `mintUrl` is a debug record of
* where the operation started. Short-lived as these rows are, the url still cannot
* be trusted at commit time: a mint-url edit RACING an open send would commit the
* new proofs under the pre-edit url, stranding them at a url no mint owns —
* an invisible balance. Resolving mintId at commit always yields the live url.
*
* Nullable for the same reason as onchain_mint_quotes: ALTER TABLE cannot backfill
* from MST/MMKV. See the v38 seed in setupRootStore.
*/
export const RESERVATIONS_COLUMNS = `
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
@@ -69,7 +115,7 @@ export const RESERVATIONS_COLUMNS = `
`
/**
* Per-keyset deterministic-derivation counter (the BIP32 high-water mark).
* Per-keyset deterministic-derivation counter (the NUT-13 high-water mark).
*
* Authoritative store for the counter previously held only in the MST
* `MintProofsCounter` model and persisted to MMKV via the whole-tree snapshot.
@@ -77,16 +123,26 @@ export const RESERVATIONS_COLUMNS = `
* derives (same SQLite transaction) and makes SQLite the single source of truth,
* closing the cross-engine non-atomicity that risked blinded-secret reuse.
*
* Keyed by (mintUrl, keysetId): a keyset id is mint-scoped, and keying on the
* url keeps the row addressable across mint-url edits.
* Keyed by keysetId ALONE, because that is the real key space: NUT-13 derives
* from (seed, keysetId, counter) and the mint url is not an input. A `00` id
* derives at `m/129372'/0'/{keysetIdInt}'/{counter}'`, a v2 `01` id by HMAC-SHA256
* over the id — neither path has a mint component, so one keyset id means exactly
* one derivation sequence regardless of which url served it.
*
* This was `PRIMARY KEY (mintUrl, keysetId)`, which asserted a key space that does
* not exist. Two rows could track one derivation path independently, and a
* mint-url edit left the counter unaddressable — hydration found no row, silently
* restarted the counter at 0, and risked blinded-secret reuse.
*
* Sound because global keyset-id uniqueness is enforced at the door by
* CashuUtils.isCollidingKeysetId, per NUT-02's requirement that wallets reject
* keysets colliding with any already held.
*/
export const MINT_COUNTERS_COLUMNS = `
mintUrl TEXT NOT NULL,
keysetId TEXT NOT NULL,
keysetId TEXT PRIMARY KEY NOT NULL,
unit TEXT,
counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT,
PRIMARY KEY (mintUrl, keysetId)
updatedAt TEXT
`
/**
@@ -100,11 +156,16 @@ export const MINT_COUNTERS_COLUMNS = `
*
* Keyed by transactionId (globally unique). A row exists only while a melt is
* in-flight; it is deleted on terminal success/failure.
*
* A CHILD of the transaction — the primary key IS the parent's id — so it carries
* no mint reference of its own: the transaction already owns that fact, and every
* reader arrives here holding the transaction. It used to duplicate `mintUrl` and
* `keysetId`; neither had a single reader (the keyset that IS used comes from
* inside `meltPreview`), so they were dead denormalization of precisely the kind
* that goes stale on a mint-url edit.
*/
export const MELT_RECOVERY_COLUMNS = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
`
@@ -119,15 +180,153 @@ export const MELT_RECOVERY_COLUMNS = `
*
* Keyed by transactionId. A row exists only while a request is in-flight; it is
* deleted on success or terminal failure.
*
* A CHILD of the transaction — the primary key IS the parent's id — so it carries
* no mint reference of its own. It used to duplicate `mintUrl` and `keysetId`;
* `keysetId` had no reader at all, and `mintUrl` served exactly one query ("every
* request of this mint"), which now joins through the parent's `mintId` instead.
* That keeps ONE owner of the fact, so there is no second copy to go stale when a
* mint moves.
*
* Reaching the mint through the parent is not merely equivalent, it is more
* correct: a request whose transaction is gone cannot be applied anyway (the retry
* settles proofs onto that transaction, and the handler branches on `tx.type`), so
* a join finding nothing is the right answer rather than a lost row.
*/
export const INFLIGHT_REQUESTS_COLUMNS = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
`
/**
* Wallet-global deterministic-derivation counters, keyed by purpose name.
*
* Distinct from `mint_counters`, which is keyed by keysetId because a NUT-13
* counter is keyset-scoped. The counters here belong to derivation paths that
* have NO keyset component either, so a single value serves the whole wallet. The
* first is NUT-20 quote-locking (`m/129373'/20'/0'/0'/{counter}`); the table is
* keyed by name so future wallet-global counters need no migration.
*
* `counter` is the NEXT FREE index (a high-water mark), matching the semantics of
* `mint_counters` (which stores cashu-ts's `next`). Allocation increments and
* returns the previous value; see walletCountersRepo.
*/
export const WALLET_COUNTERS_COLUMNS = `
name TEXT PRIMARY KEY NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT
`
/**
* Onchain (NUT-30) MINT quotes.
*
* An onchain mint quote is not a one-shot request like a bolt11 invoice — it is a
* Bitcoin address that can receive several deposits, and the mint tracks
* `amount_paid` / `amount_issued` against it. The wallet mints the difference.
* That state does not fit on a transaction row (one transaction has one fixed
* amount), so it lives here and transactions point at it via `transactions.quote`
* — N transactions to 1 quote, one per mint operation.
*
* `counterIndex` is the load-bearing column: it is the NUT-20 derivation index,
* and the ONLY way to re-derive the private key needed to sign a mint request for
* this quote. Rows are therefore kept forever, even once archived — the mint never
* expires the address (`expiry` comes back null), so a late deposit stays
* creditable and the user can still mint it.
*
* `watchUntil` is wallet-imposed for exactly that reason: with no mint-side expiry
* there is nothing to bound polling of a quote nobody ever paid. It mirrors the
* 24h fallback bolt11 topup applies when an invoice carries no expiry tag.
*
* The owning mint is referenced by `mintId` (Mint.id), NOT by `mintUrl`. Because a
* row here outlives everything — the address stays creditable for as long as the
* mint exists — it has to survive the mint moving to a new url. `mintUrl` is kept
* as the historical record of where the quote was created and is never followed;
* following it after a url edit polls a dead host forever, silently, and the
* deposit can never be minted.
*
* `mintId` is nullable only because ALTER TABLE cannot backfill it: mints live in
* the MST/MMKV snapshot, not SQLite, so the url->id mapping is resolvable only from
* JS (see the v38 seed in setupRootStore). A null after that seed means the owning
* mint is no longer in the wallet, and the quote is dead regardless.
*
* MELT quotes get no table: they are one-shot and terminal, so their durable state
* (quote id, outpoint, fee) fits on the transaction row.
*/
export const ONCHAIN_MINT_QUOTES_COLUMNS = `
quote TEXT PRIMARY KEY NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
counterIndex INTEGER NOT NULL,
pubkey TEXT NOT NULL,
amountRequested INTEGER,
amountPaid INTEGER NOT NULL DEFAULT 0,
amountIssued INTEGER NOT NULL DEFAULT 0,
expiry INTEGER,
watchUntil TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT
`
/**
* Mints — the wallet's mints, mastered here rather than in the MST/MMKV snapshot.
*
* Mints were the last core entity still persisted by serializing the whole MST tree
* to MMKV. That cost more than it looks: postProcessSnapshot already strips proofs
* and transactions, so mints (with every keyset's `keys` map) had become BY FAR the
* largest thing left in the tree — and while an equality guard skips redundant
* writes, `JSON.stringify(snapshot)` still ran on EVERY MST action anywhere,
* including every proof mutation during a send. Moving them here takes that cost off
* the hot path, and lets a mint-url edit commit atomically with the proofs it
* renames (previously two engines, no transaction).
*
* MST keeps a full in-memory copy — reads and MobX reactivity are unchanged. SQLite
* is the authority; the model is the cache. Same split as proofs and transactions.
*
* `mintInfo` and `units` are JSON: nothing queries inside them. `units` is
* derivable from the keysets, but is stored so a load round-trips the model exactly
* rather than reconstructing it.
*/
export const MINTS_COLUMNS = `
id TEXT PRIMARY KEY NOT NULL,
mintUrl TEXT NOT NULL,
hostname TEXT,
shortname TEXT,
color TEXT,
status TEXT,
units TEXT,
mintInfo TEXT,
createdAt TEXT
`
/**
* A mint's keysets, one row each, and the keys that belong to them.
*
* Keyed by keysetId ALONE, matching mint_counters — the two now agree on what a
* keyset is, and a keyset id is unique wallet-wide (enforced at the door by
* CashuUtils.isCollidingKeysetId, per NUT-02). `mintId` is what makes "which mint
* owns this keyset" answerable in SQL for the first time, which is also what would
* let proofs derive their mint from `proofs.id` (a proof's id IS its keyset id)
* instead of carrying a denormalized url. That is deliberately NOT done yet.
*
* `keyset` and `keys` are stored as whole JSON objects rather than exploded into
* columns: MintKeyset carries fields like `final_expiry` that feed NUT-02 v2 id
* derivation, and enumerating columns would silently drop whatever cashu-ts adds
* next. keysetId/mintId/unit are the queryable projection; the blobs are the payload.
*
* `keys` is nullable — a keyset the mint advertised but whose keys were not fetched
* (or were skipped as an unsupported unit) has none.
*/
export const MINT_KEYSETS_COLUMNS = `
keysetId TEXT PRIMARY KEY NOT NULL,
mintId TEXT NOT NULL,
unit TEXT,
keyset TEXT NOT NULL,
keys TEXT
`
/** Build a CREATE TABLE statement from a column block. */
export const createTable = (
name: string,
@@ -139,6 +338,9 @@ export const createTable = (
export const PROOFS_COLUMN_NAMES =
'id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt'
/** Ordered column names for mint_counters (drives the v32 re-key rebuild). */
export const MINT_COUNTERS_COLUMN_NAMES = 'keysetId, unit, counter, updatedAt'
/** First-run schema creation, run inside a single batch transaction. */
export const createSchemaQueries: SQLBatchTuple[] = [
[createTable('transactions', TRANSACTIONS_COLUMNS)],
@@ -157,4 +359,15 @@ export const createSchemaQueries: SQLBatchTuple[] = [
// Per-transaction in-flight mint/swap request data for idempotent retry
// (see inFlightRepo). A row exists only while a request is in-flight.
[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)],
// Wallet-global derivation counters keyed by purpose (see walletCountersRepo).
// First user: NUT-20 quote-locking keys.
[createTable('wallet_counters', WALLET_COUNTERS_COLUMNS)],
// Onchain (NUT-30) mint quotes — the long-lived deposit addresses transactions
// point at via transactions.quote (see onchainQuotesRepo).
[createTable('onchain_mint_quotes', ONCHAIN_MINT_QUOTES_COLUMNS)],
// The wallet's mints and their keysets, mastered here rather than in the MMKV
// snapshot (see mintsRepo). Seeded from the pre-upgrade snapshot on first run
// after the migration.
[createTable('mints', MINTS_COLUMNS)],
[createTable('mint_keysets', MINT_KEYSETS_COLUMNS)],
]

View File

@@ -7,7 +7,7 @@ import {normalizeTransactionRecord, normalizeTransactionRows} from './mappers'
export const updateTransaction = function (id: number, fields: Partial<Transaction>): Transaction {
const allowedColumns = ['amount','fee','unit','data', 'keysetId', 'sentFrom','sentTo','profile','memo','paymentId','quote','paymentRequest','zapRequest','inputToken','outputToken','proof','balanceAfter','noteToSelf','tags','status','expiresAt'];
const allowedColumns = ['amount','fee','unit','data', 'keysetId', 'sentFrom','sentTo','profile','memo','paymentId','quote','paymentRequest','zapRequest','inputToken','outputToken','proof','balanceAfter','noteToSelf','tags','status','expiresAt','outpoint'];
try {
// Filter keys against allowed columns
@@ -48,6 +48,42 @@ export const updateTransaction = function (id: number, fields: Partial<Transacti
}
}
/**
* One-time backfill of `mintId` from the url a transaction happened at (the v38
* seed).
*
* Only fills NULLs, so it is idempotent and can never re-point a resolved row at
* whichever mint now answers a url. Rows left NULL belong to mints no longer in the
* wallet — legitimate history, which keeps its `mint` url and simply has no mint to
* act on.
*
* Matching on url is sound ONLY here, at the moment of upgrade: until now a mint's
* url could not change without in-flight rows being rewritten to match, so the two
* still agree. This spends that join once, at rest, instead of on every rename.
*/
export const backfillTransactionMintIds = function (
mints: Array<{id: string; mintUrl: string}>,
): {updated: number} {
if (mints.length === 0) return {updated: 0}
try {
const db = getInstance()
let updated = 0
for (const mint of mints) {
const {rowsAffected} = db.execute(
`UPDATE transactions SET mintId = ? WHERE mint = ? AND mintId IS NULL`,
[mint.id, mint.mintUrl],
)
updated += rowsAffected ?? 0
}
log.info('[backfillTransactionMintIds]', 'Backfilled transaction mint ids', {updated})
return {updated}
} catch (e: any) {
throw dbError('Transaction mintIds could not be backfilled', e)
}
}
export const getTransactionsAsync = async function (limit: number, offset: number, onlyPending: boolean = false) {
let query: string = ''
@@ -219,6 +255,38 @@ export const getPendingTransfers = function () {
}
/**
* PENDING onchain melts — payments the mint has taken but the chain has not confirmed.
*
* Separate from `getPendingTransfers` (which filters `type = 'TRANSFER'`) rather than
* folded into it, because the two are watched for different reasons and on different
* clocks: a bolt11 transfer is watched to catch a stuck payment and can be EXPIRED,
* while an onchain transfer is waiting on blocks and must never be expired — the melt
* quote's expiry bounds executing the quote, not confirming the payment.
*/
export const getPendingOnchainTransfers = function () {
try {
const query = `
SELECT *
FROM transactions
WHERE status = 'PENDING'
AND type = 'TRANSFER_ONCHAIN'
ORDER BY id DESC
`
const db = getInstance()
const {rows} = db.execute(query)
log.trace(`[getPendingOnchainTransfers], Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export const getPendingTopupsCount = function () {
let query: string = ''
try {
@@ -457,19 +525,19 @@ export const getLastTransactionBy = function (
export const addTransactionAsync = async function (tx: Partial<Transaction>): Promise<Transaction> {
try {
const {type, amount, fee, unit, data, memo, mint, status} = tx
const {type, amount, fee, unit, data, memo, mint, mintId, status} = tx
const now = new Date()
const query = `
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, mintId, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
const params = [type, amount, fee, unit, data, memo, mint, status, now.toISOString()]
const params = [type, amount, fee, unit, data, memo, mint, mintId ?? null, status, now.toISOString()]
const db = getInstance()
const result = await db.executeAsync(query, params)
log.debug('[addTransactionAsync] New transaction added to the database', {id: result.insertId, type, mint, status})
log.debug('[addTransactionAsync] New transaction added to the database', {id: result.insertId, type, mint, mintId, status})
return getTransactionById(result.insertId as number) // already normalized

View File

@@ -0,0 +1,94 @@
import {getInstance} from './instance'
import {dbError} from './errors'
// ─────────────────────────────────────────────────────────────────────────────
// Wallet-global deterministic-derivation counters, keyed by purpose name.
//
// Sibling of countersRepo, which owns the per-keyset NUT-13 counters. The
// counters here belong to derivation paths with NO keyset component, so one value
// serves the whole wallet. First user: NUT-20 quote-locking keys
// (`m/129373'/20'/0'/0'/{counter}`).
//
// The stored `counter` is the NEXT FREE index. Allocation is BURN-FORWARD: the
// increment is committed BEFORE the caller uses the index, so a failed mint call
// or a crash can only ever SKIP an index, never hand the same one out twice.
// That direction matters — two NUT-20 quotes sharing a pubkey would let the mint
// link them (defeating the point of a per-quote key) and make signatures
// ambiguous. Gaps are harmless; a future recovery scan handles them with a gap
// limit.
// ─────────────────────────────────────────────────────────────────────────────
/** Purpose name for the NUT-20 quote-locking counter. */
export const NUT20_COUNTER = 'nut20'
/**
* Allocate the next free index for `name` and COMMIT it before returning.
*
* One atomic statement, so concurrent allocators (foreground and the NWC
* background task) can never receive the same index. `RETURNING` needs SQLite
* >= 3.35; op-sqlite 16.x bundles 3.51.
*
* The row starts at counter=1 on first insert and the statement returns
* `counter - 1`, so the first index handed out is 0 and the stored value is
* always the next free one.
*/
export const allocateNextCounter = function (name: string): number {
try {
const db = getInstance()
const {rows} = db.execute(
`INSERT INTO wallet_counters (name, counter, updatedAt)
VALUES (?, 1, ?)
ON CONFLICT(name) DO UPDATE SET
counter = counter + 1,
updatedAt = excluded.updatedAt
RETURNING counter - 1 AS allocated`,
[name, new Date().toISOString()],
)
const allocated = (rows?.item(0) as {allocated: number} | undefined)?.allocated
if (typeof allocated !== 'number') {
throw new Error('Allocation returned no index')
}
return allocated
} catch (e: any) {
throw dbError('Derivation counter could not be allocated in the database', e)
}
}
/** Next free index for `name`; 0 when no row exists yet. */
export const getWalletCounter = function (name: string): number {
try {
const db = getInstance()
const {rows} = db.execute(`SELECT counter FROM wallet_counters WHERE name = ?`, [name])
return (rows?.item(0) as {counter: number} | undefined)?.counter ?? 0
} catch (e: any) {
throw dbError('Derivation counter could not be retrieved from the database', e)
}
}
/**
* Set a counter to an absolute value, MONOTONICALLY: the stored value only ever
* rises to `MAX(existing, value)`, mirroring countersRepo.setCounter. A lower
* value (stale writer, replayed op) is silently ignored, so this can never walk
* the wallet back onto an index it has already handed out.
*
* For healing and for a future recovery scan that discovers used indices beyond
* the local high-water mark.
*/
export const setWalletCounter = function (name: string, value: number): void {
try {
const db = getInstance()
db.execute(
`INSERT INTO wallet_counters (name, counter, updatedAt)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
counter = MAX(counter, excluded.counter),
updatedAt = excluded.updatedAt`,
[name, value, new Date().toISOString()],
)
} catch (e: any) {
throw dbError('Derivation counter could not be saved to the database', e)
}
}

View File

@@ -1,6 +1,7 @@
import { ReceiveOption, SendOption, TransferOption } from '../screens'
import AppError, { Err } from '../utils/AppError'
import { log } from './logService'
import { BitcoinUtils } from './bitcoin/bitcoinUtils'
import { CashuUtils } from './cashu/cashuUtils'
import { LightningUtils } from './lightning/lightningUtils'
import { LnurlUtils } from './lnurl/lnurlUtils'
@@ -18,6 +19,69 @@ export enum IncomingDataType {
LNURL_ADDRESS = 'LNURL_ADDRESS',
MINT_URL = 'MINT_URL',
NPUB_OR_HEX = 'NPUB_OR_HEX',
/** A Bitcoin address or BIP21 URI, to be paid by an onchain melt (NUT-30). */
BTC_ADDRESS = 'BTC_ADDRESS',
}
/**
* What a scanned/pasted Bitcoin destination resolved to.
*
* The address is always normalized (lowercased bech32), because QR encoders uppercase
* bech32 to stay in alphanumeric mode and this string goes to the mint.
*/
export type BtcAddressData = {
address: string
/** BIP21 `amount`, in sats. A hint from the payee — the user may change it. */
amountSat?: number
/**
* BIP21 `label`/`message`: the PAYEE's description of what this payment is for.
*
* Not something the user writes — an onchain melt has no field that could carry a
* message out. This is the payee's own text arriving with the request, so the wallet
* honours it: shown read-only and stored on the transaction, exactly as a bolt11
* invoice's description is.
*/
memo?: string
}
/**
* Refuse a Bitcoin address we are not willing to pay.
*
* Checksum-verified, and mainnet-only in any build a user can install. Both failures get
* their own message, because "that is not a Bitcoin address" and "that is a Bitcoin
* address on the wrong network" are completely different mistakes — and the second is
* the likely one: the CDK fakewallet hands out REGTEST addresses (`bcrt1q…`) for onchain
* topup quotes, so anyone testing this wallet has one sitting in their clipboard.
*
* Debug builds accept non-mainnet, because settling an onchain melt against the
* fakewallet's regtest chain is the only way to exercise this rail end to end. See
* `BitcoinUtils.ALLOW_NON_MAINNET_PAY`.
*/
const assertPayableBtcAddress = function (address: string): string {
const decoded = BitcoinUtils.decodeBitcoinAddress(address)
if (!decoded) {
throw new AppError(Err.VALIDATION_ERROR, 'This is not a valid Bitcoin address.', {
caller: 'assertPayableBtcAddress',
address,
})
}
if (!BitcoinUtils.isPayableBitcoinAddress(decoded.address)) {
throw new AppError(
Err.VALIDATION_ERROR,
`This is a ${decoded.network} Bitcoin address. Minibits can only pay to mainnet addresses.`,
{caller: 'assertPayableBtcAddress', address, network: decoded.network},
)
}
if (decoded.network !== 'mainnet') {
log.warn('[assertPayableBtcAddress] Paying a NON-MAINNET address (debug build)', {
network: decoded.network,
})
}
return decoded.address
}
const findAndExtract = function (
@@ -67,6 +131,27 @@ const findAndExtract = function (
type: expectedType,
encoded
}
case IncomingDataType.BTC_ADDRESS: {
const bip21 = BitcoinUtils.parseBip21(incomingData)
if (bip21) {
return {
type: expectedType,
encoded: {
address: assertPayableBtcAddress(bip21.address), // throws
amountSat: bip21.amountSat,
memo: bip21.label || bip21.message,
} as BtcAddressData
}
}
return {
type: expectedType,
encoded: {
address: assertPayableBtcAddress(incomingData), // throws
} as BtcAddressData
}
}
case IncomingDataType.MINT_URL:
const url = new URL(incomingData) // throws
@@ -172,6 +257,22 @@ const findAndExtract = function (
}
}
// Bitcoin address / BIP21, checked AFTER lightning.
//
// LIGHTNING WINS on a unified QR. A `bitcoin:` URI carrying a `lightning=` invoice
// offers the payee's preference in both rails, and lightning is the cheaper and
// faster of the two for everyone involved — so if the payload contains an invoice
// we take it, and the onchain address is simply not used. `findEncodedLightningInvoice`
// above already matched it out of the URI, which is why this ordering is the whole
// implementation of the rule.
const maybeBtcAddress = BitcoinUtils.findBitcoinAddress(incomingData)
if(maybeBtcAddress) {
log.trace('[findAndExtract] Got maybeBtcAddress')
return findAndExtract(maybeBtcAddress, IncomingDataType.BTC_ADDRESS) // throws
}
const maybeMintUrl = new URL(incomingData) // throws
if(incomingData.startsWith('http')) {
@@ -296,6 +397,24 @@ const navigateWithIncomingData = async function (
})
}
case IncomingDataType.BTC_ADDRESS: {
const btc = incoming.encoded as BtcAddressData
//@ts-ignore
return navigation.navigate('WalletNavigator', {
screen: 'TransferOnchain',
params: {
address: btc.address,
// The BIP21 amount is a HINT: it pre-fills the screen, the user stays free
// to change it, and the mint prices the payment from what they confirm.
amountSat: btc.amountSat,
// The payee's own description, if the URI carried one. Read-only.
memo: btc.memo,
unit,
mintUrl
}
})
}
case IncomingDataType.MINT_URL:
//@ts-ignore
return navigation.navigate('WalletNavigator', {

View File

@@ -12,13 +12,31 @@ import {
} from '@env'
import { lightFormat } from 'date-fns'
import * as Sentry from '@sentry/react-native'
import { rootStoreInstance } from '../models'
import { LogLevel } from './log/logTypes'
import { Platform } from "react-native"
import AppError, { Err } from "../utils/AppError"
const { userSettingsStore } = rootStoreInstance
//
/**
* The user's log settings, resolved on USE via a deferred require.
*
* Deliberately NOT a top-level `import { rootStoreInstance } from '../models'`.
* That import inverted the dependency graph — logService is a leaf that nearly
* everything imports, the model layer included (Mint -> theme -> services ->
* logService) — and `models/index` EAGERLY instantiates the root store at module
* scope (useStores.ts). So importing models from here re-entered that
* instantiation while RootStore.ts was still evaluating, read `RootStoreModel` as
* undefined, and threw during import. Whether that happened came down purely to
* which module the process loaded first: the app has an entry order that gets away
* with it, a test importing a model directly does not. That is why the model layer
* could not be instantiated in a test at all.
*
* `require` is memoized by the module system, so this costs nothing after the
* first call, and by the time anything logs the graph is fully built.
*
* The tidier end state is inversion — have startup hand the logger a settings
* provider, so a leaf service stops reaching for the root store.
*/
const userSettings = () => require('../models').rootStoreInstance.userSettingsStore
if (!__DEV__) {
Sentry.init({
@@ -133,7 +151,7 @@ const extractFromArray = (arr: any[]): { message: string; params: Record<string,
const customSentryTransport: transportFunctionType<TransportOptions> = async (props) => {
if (!userSettingsStore.isLoggerOn) return true
if (!userSettings().isLoggerOn) return true
const level = props.level.text as LogLevel
const rawMessage = props.rawMsg
@@ -197,7 +215,7 @@ const customSentryTransport: transportFunctionType<TransportOptions> = async (pr
// === Non-error levels: structured breadcrumbs via logger (or addBreadcrumb) ===
const currentPriority = levelPriority[level]
const minPriority = levelPriority[userSettingsStore.logLevel] ?? levelPriority[LogLevel.WARN]
const minPriority = levelPriority[userSettings().logLevel as LogLevel] ?? levelPriority[LogLevel.WARN]
if (currentPriority < minPriority) return true
switch (level) {

View File

@@ -1,5 +1,13 @@
import numbro from 'numbro'
import { BtcIcon, EurIcon, UsdIcon, CadIcon, GbpIcon } from '../../components'
// Each icon from its own module, NOT the '../../components' barrel. The barrel
// pulls in every component (AmountInput and friends), so a barrel import here made
// this module — which the Mint model imports for MintUnit — transitively depend on
// the entire UI. ChfIcon below was already imported this way.
import { BtcIcon } from '../../components/BtcIcon'
import { EurIcon } from '../../components/EurIcon'
import { UsdIcon } from '../../components/UsdIcon'
import { CadIcon } from '../../components/CadIcon'
import { GbpIcon } from '../../components/GbpIcon'
import AppError, { Err } from '../../utils/AppError'
import { ExchangeRate } from '../../models/WalletStore'
import { ChfIcon } from '../../components/ChfIcon'

View File

@@ -31,7 +31,9 @@ const {
*/
const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => {
const mintUrl = mint.mintUrl
const inFlightRequests = Database.getInFlightRequestsByMint(mintUrl)
// By id, not url: the requests are found through their transaction's mintId, so
// a mint that has changed url still finds its own in-flight work.
const inFlightRequests = Database.getInFlightRequestsByMintId(mint.id!)
const totalRequests = inFlightRequests.length
log.trace('[handleInFlightByMintTask] start', {mintUrl, totalRequests})
@@ -244,11 +246,95 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
break
}
// TRANSFER (melt / lightning out retry)
// COMMENTED OUT — solved by syncStateWithMintTask which recovers change
// from pending-yet-paid transfers. Request params (meltPreview) is stored
// in proofsCounter.meltCounterValues, not inFlightRequests.
case TransactionType.TRANSFER: {
// TOPUP_ONCHAIN (mint from an onchain deposit, retry)
//
// Same hazard as TOPUP, and worse to leave unhandled: if the mint
// processed the request but we never saw the response, it has already
// counted the ecash as issued. The quote then reads as drained
// (amount_paid == amount_issued), the watcher stops looking at it, and
// the proofs are stranded. Replaying the identical request hits the
// mint's NUT-19 cache and returns the same signatures.
case TransactionType.TOPUP_ONCHAIN: {
const quoteRow = tx.quote
? Database.getOnchainMintQuote(tx.quote)
: undefined
if (!quoteRow) {
log.error('[handleInFlightByMintTask] No onchain quote for tx', {
tId: tx.id,
quote: tx.quote,
})
break
}
// The signing key was never persisted — re-derive it from the seed
// and the quote's NUT-20 index.
const {deriveQuoteKeypair} = await import('../../cashu/nut20')
const seed: Uint8Array = await walletStore.getCachedSeed()
const {privkey} = deriveQuoteKeypair(seed, quoteRow.counterIndex)
const quoteResponse = await walletStore.checkOnchainMintQuote(
mintUrl,
quoteRow.quote,
)
const proofs = await walletStore.mintOnchainProofs(
mintUrl,
inFlight.request.amount,
unit,
quoteResponse,
privkey,
tx.id,
{inFlightRequest: inFlight},
)
const recoveredAmount = CashuUtils.getProofsAmount(proofs)
const currentSpendable =
proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + recoveredAmount
txData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()})
const reservation = proofsStore.reserve([], {
transactionId: tx.id,
mintUrl,
unit,
operationType: 'onchain-topup-retry',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.COMPLETED,
amount: recoveredAmount,
data: JSON.stringify(txData),
balanceAfter,
},
})
Database.updateOnchainMintQuoteAmounts(
quoteRow.quote,
Number(quoteResponse.amount_paid ?? 0),
Number(quoteResponse.amount_issued ?? 0),
)
break
}
// TRANSFER / TRANSFER_ONCHAIN (melt retry)
// NO-OP — solved by syncStateWithMintTask which recovers change from
// pending-yet-paid transfers. Request params (meltPreview) is stored in
// proofsCounter.meltCounterValues, not inFlightRequests.
//
// Melts need no replay for the reason mints do. A lost mint RESPONSE
// strands issued ecash (the mint counts it as issued, we never see it),
// so TOPUP replays the request against the mint's NUT-19 cache. A lost
// melt response strands nothing: the money is either gone (mint paid, and
// sync recovers the change) or still ours (mint did not, and sync returns
// the proofs). Replaying a melt would risk paying twice to fix nothing.
case TransactionType.TRANSFER:
case TransactionType.TRANSFER_ONCHAIN: {
break
}
@@ -286,7 +372,7 @@ const handleInFlightQueue = async function (): Promise<void> {
for (const mint of mintsStore.allMints) {
if (Database.getInFlightRequestsByMint(mint.mintUrl).length === 0) {
if (Database.getInFlightRequestsByMintId(mint.id!).length === 0) {
log.trace('No inFlight requests for mint, skipping...')
continue
}

View File

@@ -1,6 +1,7 @@
import {isBefore} from 'date-fns'
import {
MeltQuoteBolt11Response,
MeltQuoteOnchainResponse,
MeltQuoteState,
getEncodedToken,
} from '@cashu/cashu-ts'
@@ -14,13 +15,15 @@ import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {MintBalance} from '../../../models/Mint'
import {Proof} from '../../../models/Proof'
import {CashuUtils} from '../../cashu/cashuUtils'
import {NostrEvent} from '../../nostrService'
import {MintUnit, formatCurrency, getCurrency} from '../currency'
import {transferTask} from '../transferTask'
import {transferOnchainTask, transferTask} from '../transferTask'
import {SyncQueue} from '../../syncQueueService'
import {WalletUtils} from '../utils'
import {createQueueAwaitable} from '../queueHelper'
import {TransactionTaskResult} from '../types'
@@ -61,12 +64,60 @@ const transferQueueAwaitable = (
})
/**
* Recover change from a paid melt quote (lightning out)
* Onchain melt, run through the SyncQueue.
*
* The queue is not a nicety. Melting derives its NUT-08 change outputs from the keyset
* counter, exactly as minting derives its blinded secrets from it: two melts running
* concurrently on one keyset both advance to the SAME counter and derive the SAME
* blinded outputs. SyncQueue runs at concurrency 1, and going through it is what makes
* that impossible. Every melting path must.
*/
const transferOnchainQueueAwaitable = (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteOnchainResponse,
feeIndex: number,
memo: string,
quoteExpiry: Date,
address: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number,
): Promise<TransactionTaskResult> =>
createQueueAwaitable<TransactionTaskResult>({
taskFunction: 'transferOnchainTask',
timeoutMessage: 'transferOnchainQueue timed out',
task: () =>
transferOnchainTask(
mintBalanceToTransferFrom,
amountToTransfer,
unit,
meltQuote,
feeIndex,
memo,
quoteExpiry,
address,
nwcEvent,
draftTransactionId,
),
})
/**
* Recover change from a paid melt quote (lightning or onchain out).
*
* The recovery itself is rail-agnostic: it reconstructs the change from the
* meltPreview we persisted before submitting, using the blind signatures the mint
* reports on the resolved quote. Neither of those is bolt11-specific.
*
* Only the STRING form is: given just a quote id we have to ask the mint about it,
* and there is no id to tell us which endpoint to ask. That form is reached from the
* manual recovery screen, which is lightning-only. Callers with an onchain quote pass
* the resolved object.
*/
const recoverMeltQuoteChange = async (
params: {
mintUrl: string
meltQuote: string | MeltQuoteBolt11Response
meltQuote: string | MeltQuoteBolt11Response | MeltQuoteOnchainResponse
},
): Promise<{recoveredAmount: number}> => {
const {mintUrl, meltQuote} = params
@@ -79,7 +130,7 @@ const recoverMeltQuoteChange = async (
log.trace('[recoverMeltQuoteChange] start', {mintUrl, meltQuote})
const meltQuoteResponse: MeltQuoteBolt11Response =
const meltQuoteResponse: MeltQuoteBolt11Response | MeltQuoteOnchainResponse =
typeof meltQuote === 'string'
? await walletStore.checkLightningMeltQuote(mintUrl, meltQuote)
: meltQuote
@@ -396,11 +447,77 @@ const handlePendingMeltTask = async (params: {
// MeltQuoteState.PENDING: no-op, ws/poller will call again
}
/**
* Re-check every PENDING onchain transfer with its mint.
*
* The onchain equivalent of the bolt11 websocket + poller, and deliberately not either
* of those. An onchain melt settles when the transaction is mined, so the wait is
* measured in blocks: the existing ~60s pending-check cadence (app start, foreground,
* WalletScreen focus) is already far finer-grained than the thing it waits for, and a
* 15-second poller would only burn requests to learn nothing.
*
* Each check goes through SyncQueue for the counter-serialisation reason above —
* `refresh` can reconstruct NUT-08 change, and change reconstruction reads the keyset
* counter. Queueing per-transaction also means one unreachable mint cannot stall the
* others.
*/
const handlePendingOnchainTransferQueue = async (): Promise<void> => {
const pending = transactionsStore.getPendingOnchainTransfers()
if (pending.length === 0) {
log.trace('[handlePendingOnchainTransferQueue] No pending onchain transfers')
return
}
log.trace('[handlePendingOnchainTransferQueue] start', {pending: pending.length})
for (const tx of pending) {
enqueuePendingOnchainTransferCheck(tx.id)
}
}
/**
* Queue a single onchain transfer re-check. THE ONLY WAY one may be started.
*
* Duplicate tasks for the same transaction are harmless: they run in sequence, and
* `refresh` no-ops on anything that is no longer PENDING.
*/
const enqueuePendingOnchainTransferCheck = (transactionId: number) => {
const taskId = `handlePendingOnchainTransferTask-${transactionId}-${Date.now()}`
return SyncQueue.addTask(taskId, () => handlePendingOnchainTransferTask(transactionId))
}
/**
* Re-check one onchain transfer. Errors are swallowed and logged: an offline mint, or
* one transfer failing, must not abort the sweep — the next tick simply tries again.
*/
const handlePendingOnchainTransferTask = async (transactionId: number) => {
try {
const {TransferOperationApi} = await import('./transferOperationApi')
return await TransferOperationApi.refresh(transactionId)
} catch (e: any) {
log.warn('[handlePendingOnchainTransferTask]', {transactionId, error: e.message})
return undefined
}
}
/**
* Expire lightning transfers whose invoices have passed. Used by handlePendingQueue.
*
* Lightning only, and that is load-bearing rather than incidental. An onchain melt quote
* also carries an expiry, but it bounds EXECUTING the quote, not SETTLING the payment:
* once the mint has broadcast, the transaction confirms on the chain's schedule and can
* easily outlive the quote it came from. Expiring a transfer on that basis would mark a
* real, in-flight, irreversible payment dead and hide it from the user.
*
* The caller passes only bolt11 transfers (`getPendingTransfers` filters on
* `type = 'TRANSFER'`), so onchain never reaches here — but the guarantee is stated
* here because this is where it would be violated.
*/
const expirePendingTransfers = (pendingTransfers: Transaction[]): void => {
for (const tx of pendingTransfers) {
if (tx.type !== TransactionType.TRANSFER) continue
if (tx.expiresAt && isBefore(tx.expiresAt, new Date())) {
log.debug('[MeltOperationService] Expiring transfer', {paymentId: tx.paymentId})
@@ -428,7 +545,10 @@ const expirePendingTransfers = (pendingTransfers: Transaction[]): void => {
export const MeltOperationService = {
transferQueueAwaitable,
transferOnchainQueueAwaitable,
recoverMeltQuoteChange,
handlePendingMeltTask,
handlePendingOnchainTransferQueue,
enqueuePendingOnchainTransferCheck,
expirePendingTransfers,
}

View File

@@ -0,0 +1,268 @@
/**
* Pure arithmetic for onchain (NUT-30) minting and melting.
*
* Kept free of stores, database and cashu-ts on purpose: these functions decide how
* much money to mint, how much to spend on miner fees, and whether an amount is even
* worth sending, so they are worth being able to test in isolation. Most of them
* exist to refuse a mint response we did not expect, rather than passing it through
* into a request.
*/
/**
* How much of a quote is credited but not yet minted.
*
* Clamped at zero. `amount_issued` should never exceed `amount_paid`, but an
* unclamped negative would reach a mint request as a negative amount — better to
* refuse it explicitly than to end up with "nothing to do" by accident.
*/
export const mintableAmount = (amountPaid: number, amountIssued: number): number =>
Math.max(0, amountPaid - amountIssued)
/**
* Cap a mint request at the mint's advertised per-operation maximum.
*
* A deposit can legitimately exceed `max_amount` — the sender ignores our BIP21
* hint, or pays a stale address. Asking for more than the mint allows fails the
* whole request, stranding money we could have taken in instalments. Minting up to
* the cap leaves the remainder credited on the quote, where the watch rule
* (`amountPaid > amountIssued`) keeps it visible and the next sweep collects it.
*/
export const capMintAmount = (mintable: number, maxAmount?: number | null): number =>
maxAmount && maxAmount > 0 ? Math.min(mintable, Number(maxAmount)) : mintable
/**
* Minibits' own minimum for offering an onchain topup, in sats.
*
* NUT-30 has a `min_amount`, but we cannot rely on it. It is evaluated PER UTXO by
* the mint — a deposit below it never increases `amount_paid`, cannot be aggregated
* with others to clear the bar, and per the spec is "not recoverable through the
* mint quote protocol". In other words, dust sent to a quote address is simply
* gone.
*
* And a mint's advertised value cannot be trusted to protect anyone: the CDK test
* mint advertised `min_amount: 1` — below Bitcoin's own 546-sat dust limit, and far
* below the fee needed to spend such an output. We watched it be wrong in two
* independent ways (frozen in its database, then inherited from the wrong config
* section). So the wallet keeps a floor of its own and takes whichever is higher.
*/
export const MINIBITS_ONCHAIN_FLOOR_SAT = 10000
/**
* The smallest amount for which onchain topup is worth offering.
*
* `max(our floor, the mint's minimum)`. The floor is denominated in sats, so it is
* only applied to sat-denominated topups; for any other unit we defer to the mint
* (in practice mints only advertise onchain for sat, and the capability check
* already hides it elsewhere).
*/
export const onchainTopupFloor = (
unit: string,
mintMinAmount?: number | null,
): number => {
const mintFloor = mintMinAmount && mintMinAmount > 0 ? Number(mintMinAmount) : 0
if (unit !== 'sat') return mintFloor
return Math.max(MINIBITS_ONCHAIN_FLOOR_SAT, mintFloor)
}
/**
* BIP21 payment URI: `bitcoin:<address>?amount=<btc>`.
*
* The amount is a HINT — the sender's wallet pre-fills it, and the sender is free
* to change or ignore it. NUT-30 does not price the quote, so under- and
* overpayment are both normal and handled downstream.
*
* BIP21 amounts are in BTC, not sats, so this converts. Formatted with toFixed(8)
* and trimmed rather than by dividing and stringifying, because `1e-8` and
* `0.000010000000000000001` are both things JS will happily hand you for small
* satoshi values, and neither is a valid BIP21 amount.
*/
export const buildBip21Uri = (address: string, amountSat?: number): string => {
if (!amountSat || amountSat <= 0) return `bitcoin:${address}`
const btc = (amountSat / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')
return `bitcoin:${address}?amount=${btc}`
}
// ─────────────────────────────────────────────────────────────────────────────
// Melt (paying out onchain)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Minibits' own minimum for paying out onchain, in sats.
*
* Lower than the topup floor, and for a different reason. The topup floor is high
* because the mint credits deposits PER UTXO and dust below its minimum is
* unrecoverable — money can actually be lost. Nothing like that happens on the way
* out: the mint either accepts the melt or refuses it.
*
* What this floor protects against is creating an output nobody can afford to spend.
* 1000 sat clears every script type's dust limit (546 for P2PKH, 330 for P2WSH) with
* enough margin that the recipient's output is still economically spendable. The
* mint's own `min_amount` wins whenever it is higher — but as with topup, it is not
* trusted to be sane on its own.
*/
export const MINIBITS_ONCHAIN_MELT_FLOOR_SAT = 1000
/**
* The smallest amount worth paying out onchain: `max(our floor, the mint's minimum)`.
*
* Denominated in sats, so applied only to sat payouts; any other unit defers to the
* mint. Mirrors `onchainTopupFloor`.
*/
export const onchainMeltFloor = (
unit: string,
mintMinAmount?: number | null,
): number => {
const mintFloor = mintMinAmount && mintMinAmount > 0 ? Number(mintMinAmount) : 0
if (unit !== 'sat') return mintFloor
return Math.max(MINIBITS_ONCHAIN_MELT_FLOOR_SAT, mintFloor)
}
/**
* A NUT-30 melt fee tier, normalized to plain numbers.
*
* cashu-ts hands us `fee_reserve` as an `Amount` object. Everything here works in
* numbers so the selection logic stays testable without pulling cashu-ts (and the
* store graph behind it) into the test.
*/
export type OnchainFeeOption = {
feeIndex: number
feeReserve: number
estimatedBlocks: number
/**
* True for a tier this wallet invented in a debug build (see `mockOnchainFeeTiers`).
* Never set on anything a mint sent.
*/
isMock?: boolean
}
/** Shape of a `fee_options` entry as it arrives from cashu-ts. */
type RawFeeOption = {
fee_index: number
fee_reserve: number | {toNumber: () => number}
estimated_blocks: number
}
/**
* Normalize a quote's `fee_options` into plain numbers, sorted cheapest first.
*
* Sorting is not cosmetic — `selectDefaultFeeOption` picks by position, and the mint
* is under no obligation to return the tiers in any particular order. `fee_index` is
* the mint's identifier for a tier, NOT its rank, so it must never be used as one.
*/
export const normalizeFeeOptions = (options: RawFeeOption[]): OnchainFeeOption[] =>
options
.map(o => ({
feeIndex: o.fee_index,
feeReserve:
typeof o.fee_reserve === 'number' ? o.fee_reserve : o.fee_reserve.toNumber(),
estimatedBlocks: o.estimated_blocks,
}))
.sort((a, b) => a.feeReserve - b.feeReserve)
/**
* Which fee tier to pre-select: the middle one, rounding to the cheaper side.
*
* The "normal" choice — fast enough not to strand the payment, cheap enough not to
* quietly overspend. With an even number of tiers there is no true middle, so we
* take the cheaper of the two: the user is always free to pay more, and a wallet
* should never round a fee UP on the user's behalf without being asked.
*
* Expects the sorted output of `normalizeFeeOptions`. Returns undefined only when the
* mint returned no tiers at all, which the spec forbids ("The mint MUST return at
* least one fee_options item") — callers treat that as a broken quote rather than
* inventing a fee.
*/
export const selectDefaultFeeOption = (
options: OnchainFeeOption[],
): OnchainFeeOption | undefined => {
if (options.length === 0) return undefined
return options[Math.floor((options.length - 1) / 2)]
}
/** Look up a tier by the mint's `fee_index`. Undefined if the mint never offered it. */
export const findFeeOption = (
options: OnchainFeeOption[],
feeIndex: number,
): OnchainFeeOption | undefined => options.find(o => o.feeIndex === feeIndex)
/**
* Invent extra fee tiers so the picker can be exercised. DEBUG BUILDS ONLY.
*
* The CDK fakewallet backend returns exactly ONE fee option, so against the only mint we
* can test with, the picker always collapses to its read-only single-tier form and the
* multi-tier path never runs. This fabricates a slower/cheaper and a faster/dearer tier
* around whatever the mint actually sent.
*
* **The mint's real tier is kept, untouched, with its real `fee_index`.** That matters:
* NUT-30 says the mint MUST reject a melt whose `fee_index` it never offered, so a
* fabricated index cannot be paid with. The caller submits the REAL index whichever tier
* the user picks (see `isMock`) — so what is mocked is the CHOICE, and the payment that
* follows is a real one at the mint's real price. A mocked tier's `feeReserve` is a
* fiction for display; nothing downstream may bill against it.
*
* A no-op when the mint already returned more than one tier — there is nothing to
* simulate, and overwriting real tiers with invented ones would be actively misleading.
*/
export const mockOnchainFeeTiers = (
options: OnchainFeeOption[],
): OnchainFeeOption[] => {
if (options.length !== 1) return options
const real = options[0]
// Indices that cannot collide with the mint's own. They are never sent to it.
const cheaper: OnchainFeeOption = {
feeIndex: real.feeIndex + 1001,
feeReserve: Math.max(1, Math.round(real.feeReserve * 0.4)),
estimatedBlocks: Math.max(real.estimatedBlocks * 4, 12),
isMock: true,
}
const faster: OnchainFeeOption = {
feeIndex: real.feeIndex + 1002,
feeReserve: Math.round(real.feeReserve * 2.5),
estimatedBlocks: 1,
isMock: true,
}
return normalizeFeeOptions(
[cheaper, real, faster].map(o => ({
fee_index: o.feeIndex,
fee_reserve: o.feeReserve,
estimated_blocks: o.estimatedBlocks,
})),
).map(o => ({
...o,
isMock: o.feeIndex !== real.feeIndex,
}))
}
/**
* The `fee_index` that may actually be sent to the mint.
*
* Identity for a real tier. For a mocked one it resolves back to the mint's own tier —
* the only index the mint will accept — so a debug build can exercise the picker without
* the melt being rejected. Returns undefined if there is no real tier to fall back to,
* which cannot happen with a quote from a mint.
*/
export const payableFeeIndex = (
options: OnchainFeeOption[],
selected: OnchainFeeOption,
): number | undefined => {
if (!selected.isMock) return selected.feeIndex
return options.find(o => !o.isMock)?.feeIndex
}
/**
* Total that must be covered by the inputs of an onchain melt.
*
* `amount + fee_reserve + input_fee`, per NUT-30. The mint may keep the whole
* `fee_reserve` ("the mint is entitled to claim the full selected_fee_reserve as the
* actual fee") — anything it does not spend comes back as NUT-08 change.
*/
export const onchainMeltTotal = (
amount: number,
feeReserve: number,
inputFee: number = 0,
): number => amount + feeReserve + inputFee

View File

@@ -0,0 +1,96 @@
/**
* Onchain (NUT-30) deposit watcher.
*
* Runs off the existing pending-check cadence (handlePendingQueue, which
* performChecks drives on app start / foreground / WalletScreen focus, debounced).
* There is no separate poller and no websocket: onchain settlement is bounded by
* block times, so a ~60s check is already far finer-grained than the thing it is
* waiting for.
*
* The watch set is QUOTE-driven, not transaction-driven — the one structural
* difference from the bolt11 watcher, which walks PENDING transactions. An onchain
* address can be paid again after its transaction has COMPLETED, so a
* transaction-driven sweep would miss exactly the deposits that need catching. The
* quote rows know whether money is outstanding; the transactions do not.
*
* See onchainQuotesRepo for the watch rule itself.
*/
import {log} from '../../logService'
import {Database} from '../../../services'
import {SyncQueue} from '../../syncQueueService'
import {OnchainTopupOperationApi} from './onchainTopupOperationApi'
/**
* Check every quote the wallet is still watching, minting anything that has been
* credited but not yet issued.
*
* Each quote is queued as its own task so one unreachable mint cannot stall the
* others, and so a mint that lands mid-sweep does not block the rest.
*/
const handleOnchainQuoteQueue = async (): Promise<void> => {
const watched = Database.getWatchedOnchainMintQuotes()
if (watched.length === 0) {
log.trace('[handleOnchainQuoteQueue] No watched onchain quotes')
return
}
log.trace('[handleOnchainQuoteQueue] start', {watched: watched.length})
for (const quote of watched) {
enqueueOnchainQuoteCheck(quote.quote)
}
}
/**
* Queue a single quote check. THE ONLY WAY a quote check may be started.
*
* Minting derives blinded secrets from the keyset counter: mintOnchainProofs
* advances the wallet's counter to our stored value, mints, then writes back the
* value the library consumed. Two mints running concurrently on the same keyset
* would both advance to the SAME starting counter and derive the SAME blinded
* secrets — the mint would reject the second, and worse, a reused secret is exactly
* the hazard the counters-in-SQLite work exists to prevent.
*
* SyncQueue runs at concurrency 1, so routing every check through it serialises
* them. That matters because checks arrive from two independent places: the watcher
* sweep, and the user tapping "check for deposits" in the transaction detail. Call
* refreshQuote directly from either and they can overlap.
*
* Duplicate tasks for the same 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.
*/
const enqueueOnchainQuoteCheck = (quote: string) => {
const taskId = `handleOnchainQuoteTask-${quote}-${Date.now()}`
return SyncQueue.addTask(taskId, () => handleOnchainQuoteTask(quote))
}
/**
* Re-check one quote. Errors are swallowed and logged: an offline mint, or one
* quote failing, must not abort the sweep — the next tick simply tries again.
*/
const handleOnchainQuoteTask = async (quote: string) => {
try {
const result = await OnchainTopupOperationApi.refreshQuote(quote)
if (result.minted > 0) {
log.info('[handleOnchainQuoteTask] Minted an onchain deposit', {
quote,
minted: result.minted,
transactionId: result.transactionId,
})
}
return result
} catch (e: any) {
log.warn('[handleOnchainQuoteTask]', {quote, error: e.message})
return {quote, error: e.message}
}
}
export const OnchainOperationService = {
handleOnchainQuoteQueue,
enqueueOnchainQuoteCheck,
handleOnchainQuoteTask,
}

View File

@@ -0,0 +1,541 @@
/**
* Onchain (NUT-30) topup operation API.
*
* The bolt11 sibling (topupOperationApi) has a linear lifecycle: one invoice, one
* payment, one mint, done. Onchain does not, and the differences are the whole
* reason this is a separate module:
*
* - A quote is an ADDRESS, not an invoice. It has no amount (the mint has
* nothing to price) and it can be paid any number of times. The mint tracks
* `amount_paid` / `amount_issued`; the wallet mints the difference.
*
* - The amount the user typed is a HINT. It goes into the BIP21 URI, and the
* sender is free to ignore it. Under- and overpayment are normal, so a
* transaction's amount is provisional until a deposit actually confirms, and
* then settles to what was really minted.
*
* - Consequently one quote maps to N transactions (one per mint), not one. The
* quote's own state lives in `onchain_mint_quotes`; transactions point at it
* via `transactions.quote`.
*
* - The mint returns `expiry: null` — the address never dies. Nothing
* server-side bounds the wait, so the wallet imposes its own watch window
* (see onchainQuotesRepo).
*
* Lifecycle:
*
* createQuote() → DRAFT → PREPARED → PENDING, plus the persisted quote whose
* address the user is shown.
* refreshQuote() → re-check the mint; if anything is credited but not yet
* minted, mint it and settle the transaction to COMPLETED.
* Called by the watcher (onchainOperations) and by the user's
* manual "check for deposits".
*
* The states mean what they mean elsewhere in the wallet:
*
* DRAFT — row exists, mint not yet contacted. Carries the failure if the
* quote request is refused, so a burned NUT-20 index is never left
* unexplained.
* PREPARED — the mint answered and the quote is persisted. Transient, exactly
* as for bolt11 (topupTask calls prepare() and execute() back to
* back): a crash marker, not a state a user sits in.
* PENDING — the address is live and we are waiting to be paid. This has to be
* PENDING and not PREPARED, because pendingHistory filters on
* PENDING alone — anything else drops the topup out of the pending
* list, which is precisely where the user looks for it.
* COMPLETED — a deposit confirmed and was minted.
*
* Unlike bolt11 there is no "arm the watcher" step between PREPARED and PENDING:
* the watcher is driven by the QUOTE row, so persisting the quote IS the
* registration. And there is no cancel-to-reclaim — nothing local is locked while
* we wait, because the "lock" is a mint-side address.
*/
import {log} from '../../logService'
import {MintError, ValidationError} from '../../../utils/AppError'
import {rootStoreInstance} from '../../../models'
import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {Database} from '../../../services'
import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo'
import type {Mint} from '../../../models/Mint'
import {allocateQuoteKeypair, deriveQuoteKeypair} from '../../cashu/nut20'
import {capMintAmount, mintableAmount} from './onchainAmounts'
import {CashuProof} from '../../cashu/cashuUtils'
import {MintUnit} from '../currency'
import {WalletUtils} from '../utils'
import {sendTopupNotification} from '../notifications'
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
export type CreatedOnchainQuote = {
transactionId: number
tx: Transaction
/** The Bitcoin address the sender pays. */
address: string
/** Mint's quote id. */
quote: string
/** What the user asked for — a hint carried in the BIP21 URI. */
amountRequested: number
watchUntil: Date
}
// ─────────────────────────────────────────────────────────────────────────────
// createQuote()
// ─────────────────────────────────────────────────────────────────────────────
async function createQuote(input: {
mintUrl: string
unit: MintUnit
amountRequested: number
memo?: string
}): Promise<CreatedOnchainQuote> {
const {mintUrl, unit, amountRequested, memo} = input
if (amountRequested <= 0) {
throw new ValidationError('Amount to topup must be above zero.')
}
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new ValidationError('Could not find mint', {mintUrl})
}
if (!mintInstance.supportsMint!('onchain', unit)) {
throw new ValidationError('This mint does not support onchain topup for this unit', {
mintUrl,
unit,
})
}
// ── DRAFT: a record BEFORE the mint is contacted ─────────────────────
//
// This exists so a failure below leaves a trace. Without it, a mint that refuses
// the quote produced no transaction at all — and a NUT-20 index had already been
// burned with nothing to explain the gap. Same reason bolt11's prepare() opens
// with a DRAFT row.
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToTopup: amountRequested,
unit,
method: 'onchain',
createdAt: new Date(),
},
]
const transaction = await transactionsStore.addTransaction({
type: TransactionType.TOPUP_ONCHAIN,
amount: amountRequested,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
})
if (!transaction) {
throw new ValidationError('Failed to create onchain topup transaction')
}
try {
// Burn a NUT-20 index and derive the quote-locking key. The index is committed
// to the database before it is used, so a failure below can only SKIP an index,
// never reuse one. The privkey is NOT persisted — only the index is, and the key
// is re-derived from the seed when it is time to sign.
const seed: Uint8Array = await walletStore.getCachedSeed()
const {index: counterIndex, pubkey} = allocateQuoteKeypair(seed)
const quoteResponse = await walletStore.createOnchainMintQuote(mintUrl, unit, pubkey)
const address = quoteResponse.request
// Persist the quote BEFORE showing the address. If the app dies between the two,
// the row (with its counterIndex) is already safe, so a deposit to that address
// stays mintable. Losing it would make the money unspendable.
Database.addOnchainMintQuote({
// The stable reference. mintUrl is stored alongside it purely as the
// record of where this quote was created — the mint may move, and this
// row outlives the move.
mintId: mintsStore.findByUrl(mintUrl)?.id,
quote: quoteResponse.quote,
mintUrl,
unit,
address,
counterIndex,
pubkey,
amountRequested,
expiry: quoteResponse.expiry ?? null,
})
const persisted = Database.getOnchainMintQuote(quoteResponse.quote)
if (!persisted) {
throw new MintError('Onchain quote could not be persisted', {
quote: quoteResponse.quote,
})
}
const watchUntil = new Date(persisted.watchUntil)
// ── PREPARED: the mint has answered and the quote is safely stored ───
//
// Transient, exactly as it is for bolt11 (topupTask calls prepare() and
// execute() back to back). It is a crash marker, not a state a user sits in:
// it says the address exists and is persisted, but the transaction is not yet
// the one the watcher will settle onto.
//
// Onchain has no "arm the watcher" step to separate PREPARED from PENDING the
// way bolt11 does — the watcher is driven by the quote row, so persisting the
// quote IS the registration. The state is kept anyway so the lifecycle reads
// the same across every operation, and so a crash here is legible.
transactionData.push({
status: TransactionStatus.PREPARED,
quote: quoteResponse.quote,
address,
counterIndex,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED,
quote: quoteResponse.quote,
paymentRequest: address,
expiresAt: watchUntil,
data: JSON.stringify(transactionData),
})
// ── PENDING: the address is live and we are waiting to be paid ───────
//
// PENDING, not PREPARED, is what "waiting for a deposit" must be: pendingHistory
// filters on PENDING alone, so anything else would drop this topup out of the
// pending list — the exact place a user goes looking for it.
transactionData.push({
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
})
log.debug('[OnchainTopupOperationApi.createQuote]', {
transactionId: transaction.id,
quote: quoteResponse.quote,
address,
amountRequested,
})
return {
transactionId: transaction.id,
tx: transaction,
address,
quote: quoteResponse.quote,
amountRequested,
watchUntil,
}
} catch (e: any) {
// Leave the failure on the record rather than dropping it. Mirrors how
// topupTask marks a failed bolt11 prepare/execute.
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData),
})
throw e
}
}
// ─────────────────────────────────────────────────────────────────────────────
// refreshQuote()
// ─────────────────────────────────────────────────────────────────────────────
export type RefreshOnchainQuoteResult = {
quote: string
amountPaid: number
amountIssued: number
/** Ecash actually minted during THIS refresh. 0 when nothing new arrived. */
minted: number
transactionId?: number
}
/**
* Re-check a quote against the mint and mint anything credited but not yet issued.
*
* Safe to call repeatedly and concurrently-ish: amounts are written monotonically,
* and the mintable balance is recomputed from the mint's own numbers each time, so
* a duplicate run finds nothing to do rather than minting twice.
*/
async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult> {
const row = Database.getOnchainMintQuote(quoteId)
if (!row) {
throw new ValidationError('Unknown onchain mint quote', {quote: quoteId})
}
const mint = _resolveQuoteMint(row)
const quoteResponse = await walletStore.checkOnchainMintQuote(mint.mintUrl, quoteId)
const amountPaid = Number(quoteResponse.amount_paid ?? 0)
const amountIssued = Number(quoteResponse.amount_issued ?? 0)
Database.updateOnchainMintQuoteAmounts(quoteId, amountPaid, amountIssued)
const mintable = mintableAmount(amountPaid, amountIssued)
log.trace('[OnchainTopupOperationApi.refreshQuote]', {
quote: quoteId,
amountPaid,
amountIssued,
mintable,
})
if (mintable <= 0) {
return {quote: quoteId, amountPaid, amountIssued, minted: 0}
}
const transactionId = await _mintAvailable(row, mint, quoteResponse, mintable)
return {
quote: quoteId,
amountPaid,
amountIssued: amountIssued + mintable,
minted: mintable,
transactionId,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Private
// ─────────────────────────────────────────────────────────────────────────────
/**
* Resolve a quote's owning mint through its stable id.
*
* `row.mintUrl` is deliberately NOT consulted. It records where the quote was
* created, and a mint that has since moved would leave it pointing at a host that
* no longer answers — while this quote's address stays creditable for as long as
* the mint exists (rows here are never deleted), so a late deposit would be
* unmintable forever, and silently, because the watcher swallows errors by design.
*
* A missing mint means it was removed from the wallet (or, on a row from before
* v33 that the v38 backfill could not match, that its url had already moved on).
* Either way there is nothing to talk to, so this throws rather than guessing.
*/
function _resolveQuoteMint(row: OnchainMintQuoteRecord): Mint {
const mint = row.mintId ? mintsStore.findById(row.mintId) : undefined
if (!mint) {
throw new ValidationError('Onchain quote has no mint in this wallet', {
quote: row.quote,
mintId: row.mintId,
createdAtUrl: row.mintUrl,
})
}
return mint
}
/**
* Mint the available balance on a quote and settle it onto a transaction.
*
* Reuses the quote's PENDING transaction if there is one (the usual case: the user
* created the quote, the deposit landed). Otherwise creates a fresh one — that is
* the second-deposit case, where the original transaction has already COMPLETED and
* this money is genuinely a new receipt.
*/
async function _mintAvailable(
row: OnchainMintQuoteRecord,
mintInstance: Mint,
quoteResponse: any,
mintable: number,
): Promise<number> {
const {quote, counterIndex} = row
const unit = row.unit as MintUnit
// The mint resolved from row.mintId by the caller — NOT row.mintUrl, which is
// only where the quote was created and may since have moved.
const mintUrl = mintInstance.mintUrl
// The mint may cap a single mint operation; never ask for more than it allows.
// Any remainder stays credited on the quote, where the watch rule keeps it
// visible and the next sweep takes the rest.
const maxAmount = mintInstance.mintMethodSetting!('onchain', unit)?.max_amount
const amount = capMintAmount(mintable, maxAmount ? Number(maxAmount) : null)
// Re-derive the NUT-20 signing key from the seed. Only the index was persisted.
const seed: Uint8Array = await walletStore.getCachedSeed()
const {privkey} = deriveQuoteKeypair(seed, counterIndex)
const tx = await _findOrCreateTransaction(row, mintUrl, amount, unit)
const transactionId = tx.id
let proofs: CashuProof[] = []
try {
proofs = await walletStore.mintOnchainProofs(
mintUrl,
amount,
unit,
quoteResponse,
privkey,
transactionId,
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error('[_mintAvailable] Healing outdated proofsCounter and repeating mint.')
proofs = await walletStore.mintOnchainProofs(
mintUrl,
amount,
unit,
quoteResponse,
privkey,
transactionId,
{increaseCounterBy: 10},
)
} else {
throw e
}
}
if (proofs.length === 0) {
throw new MintError('Mint returned no proofs for a confirmed onchain deposit', {
transactionId,
quote,
})
}
const mintedAmount = proofs.reduce((acc, p) => acc + Number(p.amount), 0)
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + mintedAmount
const txData: TransactionData[] = _parseData(tx)
txData.push({
status: TransactionStatus.COMPLETED,
amountMinted: mintedAmount,
createdAt: new Date(),
})
// Empty reservation used purely as the atomic-commit primitive: proofs INSERT and
// tx UPDATE land in one SQLite transaction. Nothing local is locked for a topup.
const reservation = proofsStore.reserve([], {
transactionId,
mintUrl,
unit,
operationType: 'onchain-topup-mint',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: transactionId}],
transactionUpdate: {
id: transactionId,
status: TransactionStatus.COMPLETED,
// The settled amount, NOT the amount the user asked for. The sender may
// have under- or overpaid, and history has to show what actually arrived.
amount: mintedAmount,
data: JSON.stringify(txData),
balanceAfter,
},
})
Database.updateOnchainMintQuoteAmounts(
quote,
Number(quoteResponse.amount_paid ?? 0),
Number(quoteResponse.amount_issued ?? 0) + mintedAmount,
)
sendTopupNotification(mintedAmount, unit)
log.debug('[OnchainTopupOperationApi._mintAvailable] Minted', {
transactionId,
quote,
mintedAmount,
})
return transactionId
}
/**
* The PENDING transaction waiting on this quote, or a new one.
*
* A second deposit to an address whose transaction already COMPLETED is a genuinely
* new receipt and gets its own transaction — an amount that mutates after
* completion would make `balanceAfter` meaningless and read as a rewrite of history.
*/
async function _findOrCreateTransaction(
row: OnchainMintQuoteRecord,
/** The mint's url NOW (resolved from row.mintId), not row.mintUrl. */
mintUrl: string,
amount: number,
unit: MintUnit,
): Promise<Transaction> {
const last = transactionsStore.findLastBy({quote: row.quote})
// PREPARED counts as reusable, not just PENDING. createQuote passes through it on
// the way to PENDING, so a crash in that window leaves a PREPARED row for this
// quote — and settling onto a NEW transaction instead would leave the user with two
// rows for one deposit.
if (
last &&
last.type === TransactionType.TOPUP_ONCHAIN &&
(last.status === TransactionStatus.PENDING ||
last.status === TransactionStatus.PREPARED)
) {
return last
}
const transactionData: TransactionData[] = [
{
status: TransactionStatus.PENDING,
amountToTopup: amount,
unit,
quote: row.quote,
address: row.address,
method: 'onchain',
note: 'Additional deposit to an existing onchain address',
createdAt: new Date(),
},
]
const tx = await transactionsStore.addTransaction({
type: TransactionType.TOPUP_ONCHAIN,
amount,
fee: 0,
unit,
data: JSON.stringify(transactionData),
// Where this deposit is being settled NOW. A second deposit onto an old
// address is still a payment to the mint at its current url, and this row is
// brand new — it has no history to preserve.
mint: mintUrl,
status: TransactionStatus.PENDING,
})
if (!tx) {
throw new ValidationError('Failed to create onchain topup transaction', {
quote: row.quote,
})
}
tx.update({quote: row.quote, paymentRequest: row.address})
return tx
}
function _parseData(tx: Transaction): TransactionData[] {
try {
return JSON.parse(tx.data) as TransactionData[]
} catch {
return []
}
}
export const OnchainTopupOperationApi = {
createQuote,
refreshQuote,
}

View File

@@ -2,14 +2,29 @@ import {log} from '../../logService'
import {rootStoreInstance} from '../../../models'
import {MintOperationService} from './mintOperations'
import {MeltOperationService} from './meltOperations'
import {OnchainOperationService} from './onchainOperations'
const {transactionsStore} = rootStoreInstance
/**
* Process all pending topups and expired lightning transfers.
* Process all pending topups and expired lightning transfers, and check on both
* directions of onchain money: deposits coming in, and melts going out.
*
* Topup polling is delegated to MintOperationService (mint quote lifecycle).
* Transfer expiry is delegated to MeltOperationService (lightning out lifecycle).
* Transfer expiry and the onchain melt sweep are delegated to MeltOperationService.
* Onchain deposits are delegated to OnchainOperationService.
*
* The two onchain sweeps are driven differently, and the asymmetry is deliberate:
*
* - DEPOSITS are QUOTE-driven. An onchain address can be paid again after its
* transaction has COMPLETED, so walking pending transactions (as the bolt11 path
* does) would miss precisely the deposits that need catching.
* - MELTS are TRANSACTION-driven. A melt quote is one-shot and terminal, so the
* pending transaction IS the outstanding work, and there is nothing to find that a
* transaction does not already point at.
*
* Neither uses a websocket or a poller: onchain settlement is bounded by block times,
* so this ~60s cadence is already far finer-grained than what it waits for.
*/
const handlePendingQueue = async (): Promise<void> => {
const pendingTopups = transactionsStore.getPendingTopups()
@@ -29,6 +44,9 @@ const handlePendingQueue = async (): Promise<void> => {
if (pendingTopups.length === 0) {
log.trace('[handlePendingQueue] No pending topups')
}
await OnchainOperationService.handleOnchainQuoteQueue()
await MeltOperationService.handlePendingOnchainTransferQueue()
}
export const PendingOperationService = {

View File

@@ -490,15 +490,25 @@ function _reloadPrepared(transactionId: number): PreparedReceiveData {
if (!tx.inputToken) {
throw new ValidationError('Transaction is missing input token', {transactionId})
}
const mintInstance = mintsStore.findByUrl(tx.mint)
const token = getDecodedToken(tx.inputToken, mintInstance?.keysetIds ?? [])
// By identity: tx.mint is where the receive was prepared and is frozen, so it
// stops finding the mint once it moves.
const mintInstance = mintsStore.findByTransaction(tx)
if (!mintInstance) {
throw new ValidationError('Transaction mint is no longer in this wallet', {
transactionId,
mintId: tx.mintId,
preparedAtUrl: tx.mint,
})
}
const token = getDecodedToken(tx.inputToken, mintInstance.keysetIds ?? [])
const isOffline = tx.status === TransactionStatus.PREPARED_OFFLINE
return {
transactionId,
tx,
mintUrl: tx.mint,
// The mint's url NOW — this is dialled to complete the receive.
mintUrl: mintInstance.mintUrl,
unit: tx.unit,
amountToReceive: tx.amount,
memo: tx.memo ?? '',

View File

@@ -57,6 +57,26 @@ import AppError, {Err} from '../../../utils/AppError'
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
/**
* The live url of a transaction's mint.
*
* Resolved through `tx.mintId`, never `tx.mint`: the latter records where the send
* happened and is deliberately frozen, so after a mint-url edit it points at a host
* that no longer answers.
*/
function _txMintUrl(tx: Transaction): string {
const mint = mintsStore.findByTransaction(tx)
if (!mint) {
throw new ValidationError('Transaction mint is no longer in this wallet', {
transactionId: tx.id,
mintId: tx.mintId,
happenedAtUrl: tx.mint,
})
}
return mint.mintUrl
}
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
@@ -598,7 +618,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
const result = await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs,
mintUrl: tx.mint,
mintUrl: _txMintUrl(tx),
proofState: 'PENDING',
})
if (result.completedTransactionIds.includes(transactionId)) {
@@ -631,7 +651,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
}
await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs,
mintUrl: tx.mint,
mintUrl: _txMintUrl(tx),
proofState: 'PENDING',
})
return transactionsStore.findById(transactionId) ?? tx
@@ -657,6 +677,7 @@ function _rowToReservation(row: ReservationRow): ProofReservation {
return {
id: row.id,
transactionId: row.transactionId,
mintId: row.mintId,
mintUrl: row.mintUrl,
unit: row.unit as MintUnit,
operationType: row.operationType,

Some files were not shown because too many files have changed in this diff Show More