Files
minibits_wallet/src/models/Mint.ts
T

690 lines
29 KiB
TypeScript

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'
// 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/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
balances: {
[key in MintUnit]?: number
}
}
export type UnitBalance = {
unitBalance: number
unit: MintUnit
}
export type Balances = {
mintBalances: MintBalance[]
unitBalances: UnitBalance[]
}
export enum MintStatus {
ONLINE = 'ONLINE',
OFFLINE = 'OFFLINE'
}
export type InFlightRequest<TRequest = any> = {
transactionId: number
request: TRequest
}
// === Migration function ===
// inFlightRequests and meltCounterValues moved to SQLite (inflight_requests /
// melt_recovery tables). Strip both from any old snapshot so applySnapshot does
// not choke on the removed fields. A MintProofsCounter snapshot is now just
// {keyset, unit, counter}.
const migrateSnapshot = (snapshot: any): any => {
if (!snapshot) return snapshot
// `counter` is now VOLATILE (mastered in SQLite, see model below) — drop it
// from any incoming snapshot so applySnapshot never carries a stale value.
const {inFlightRequests, meltCounterValues, counter, ...rest} = snapshot
return rest
}
/**
* Write a counter mutation through to the SQLite authority — the PRIMARY
* counter persistence ("W1").
*
* `mode: 'set'` persists an absolute value monotonically (never lowers);
* `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
* have seen those outputs — covering a crash before commit AND an explicit
* rollback. Those indices are consumed at the mint and must never be reused even
* if the operation aborts, which is exactly why rollback does NOT rewind the
* counter.
*
* Errors are logged (→ Sentry in prod) but never rethrown: a counter write must
* 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.
*/
const persistCounter = (self: any, mode: 'set' | 'bump', value: number): void => {
try {
if (mode === 'set') {
Database.setCounter(self.keyset, self.unit, value)
} else {
Database.bumpCounter(self.keyset, self.unit, value)
}
} catch (e: any) {
log.error('[persistCounter]', 'Counter write-through failed', {
error: e?.message,
keyset: self.keyset,
})
}
}
export const MintProofsCounterModel = types
.model('MintProofsCounter', {
keyset: types.string,
unit: types.optional(types.frozen<MintUnit>(), 'sat'),
})
// The derivation counter is mastered in SQLite (mint_counters) and kept here
// only as an in-memory cache, hydrated from the authority on startup/resume.
// It is VOLATILE — deliberately NOT part of the MST snapshot — so the many
// per-derivation bumps during a wallet transaction never invalidate the root
// snapshot, and therefore never trigger a serialize + MMKV write. It used to
// be a persisted prop stripped to 0 in postProcessSnapshot, but MST still
// fired onSnapshot (and thus a redundant whole-tree write) on every bump.
// Persistence of the real value happens through the SQLite write-through in
// the counter actions below; this field is a cache only.
.volatile(() => ({
counter: 0,
}))
.preProcessSnapshot(migrateSnapshot)
.actions(self => ({
// === Counter mutations (write through to the SQLite authority) ===
increaseProofsCounter(numberOfProofs: number) {
self.counter += numberOfProofs
persistCounter(self, 'bump', numberOfProofs)
log.info('[increaseProofsCounter]', 'Increased proofsCounter', {
numberOfProofs,
counter: self.counter,
})
},
setProofsCounter(newCounter: number) {
self.counter = newCounter
persistCounter(self, 'set', newCounter)
log.debug('[setProofsCounter]', 'Set proofsCounter', {
counter: self.counter,
})
},
/**
* Load the authoritative value from SQLite into the in-memory cache on
* startup/resume. Monotonic (only raises) and does NOT write back, so it
* can't loop with the write-through above.
*/
hydrateCounterFromDb(value: number) {
if (value > self.counter) {
self.counter = value
}
},
}))
export type MintProofsCounter = Instance<typeof MintProofsCounterModel>
/**
* This represents a Cashu mint
*/
export const MintModel = types
.model('Mint', {
id: types.optional(types.identifier, () => generateId(8)),
mintUrl: types.string,
hostname: types.maybe(types.string),
shortname: types.maybe(types.string),
units: types.array(types.frozen<MintUnit>()),
keysets: types.array(types.frozen<CashuMintKeyset>()),
keys: types.array(types.frozen<CashuMintKeys>()),
mintInfo: types.maybe(types.frozen<GetInfoResponse & {time: number}>()),
proofsCounters: types.array(MintProofsCounterModel),
color: types.optional(types.string, colors.palette.iconBlue200),
status: types.optional(types.frozen<MintStatus>(), MintStatus.ONLINE),
createdAt: types.optional(types.Date, new Date()),
})
.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.
*
* Each NUT carries a `disabled` flag beside its method list, and a mint that
* advertises methods while disabling the NUT is telling us that direction is
* off — nutshell sets it when its backend is unavailable. Ignoring the flag
* means offering a flow the mint then refuses. Same rule as `getMethods` in
* screens/Mints/mintInfoSummary.ts, which drives MintInfoScreen: the two must
* stay in step or the mint's detail screen contradicts the topup menu.
*/
mintMethodSetting(method: PaymentMethod, unit: MintUnit): SwapMethod | undefined {
const nut4 = self.mintInfo?.nuts?.['4']
if (nut4?.disabled === true) return undefined
return nut4?.methods?.find(m => m.method === method && m.unit === unit)
},
meltMethodSetting(method: PaymentMethod, unit: MintUnit): SwapMethod | undefined {
const nut5 = self.mintInfo?.nuts?.['5']
if (nut5?.disabled === true) return undefined
return nut5?.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) {
const alreadyExists = self.keysets.some(k => k.id === keyset.id)
if(!alreadyExists) {
self.keysets.push(keyset)
self.keysets = cast(self.keysets)
}
},
removeKeyset(keyset: CashuMintKeyset) {
const index = self.keysets.findIndex(k => k.id === keyset.id)
if(index !== -1) {
self.keysets.splice(index, 1)
self.keysets = cast(self.keysets)
}
},
setIsActive(freshKeyset: CashuMintKeyset) {
const index = self.keysets.findIndex(k => k.id === freshKeyset.id)
if(index !== -1) {
// Since keysets is a frozen array, we need to replace the entire keyset object
const updatedKeyset = {
...self.keysets[index],
active: freshKeyset.active
}
self.keysets[index] = updatedKeyset
self.keysets = cast(self.keysets)
}
// Keep the parallel `keys` entry's active flag in lockstep. MintKeys carries
// its own `active`, and getKeys() only ever returns ACTIVE keysets — so a
// keyset going inactive is never re-fetched, and its keys entry would keep a
// stale active:true forever (visible after a mint migration flips the old
// active keyset inactive). The keyset metadata is the authority; mirror it.
const keysIndex = self.keys.findIndex(k => k.id === freshKeyset.id)
if(keysIndex !== -1) {
const updatedKeys = {
...self.keys[keysIndex],
active: freshKeyset.active
}
self.keys[keysIndex] = updatedKeys
self.keys = cast(self.keys)
}
},
setInputFeePpk(keysetId: string, inputFeePpk: number) {
const index = self.keysets.findIndex(k => k.id === keysetId)
if(index !== -1) {
// Since keysets is a frozen array, we need to replace the entire keyset object
const updatedKeyset = {
...self.keysets[index],
input_fee_ppk: inputFeePpk
}
self.keysets[index] = updatedKeyset
self.keysets = cast(self.keysets)
}
},
addKeys(keys: CashuMintKeys) {
const alreadyExists = self.keys.some(k => k.id === keys.id)
if(!alreadyExists) {
self.keys.push(keys)
self.keys = cast(self.keys)
}
},
removeKeys(keys: CashuMintKeys) {
const index = self.keys.findIndex(k => k.id === keys.id)
if(index !== -1) {
self.keys.splice(index, 1)
self.keys = cast(self.keys)
}
},
addUnit(unit: MintUnit) {
const alreadyExists = self.units.some(u => u === unit)
if(!alreadyExists) {
self.units.push(unit)
self.units = cast(self.units)
}
},
removeUnit(unit: MintUnit) {
const index = self.units.findIndex(u => u === unit)
if(index !== -1) {
self.units.splice(index, 1)
self.units = cast(self.units)
}
},
addProofsCounter(counter: MintProofsCounter) {
const alreadyExists = self.proofsCounters.some(p => p.keyset === counter.keyset)
if(!alreadyExists) {
log.trace('[addProofsCounter]', {counter})
self.proofsCounters.push(counter)
self.proofsCounters = cast(self.proofsCounters)
}
},
removeProofsCounter(counter: MintProofsCounter) {
const index = self.proofsCounters.findIndex(p => p.keyset === counter.keyset)
if(index !== -1) {
self.proofsCounters.splice(index, 1)
self.proofsCounters = cast(self.proofsCounters)
}
},
getProofsCounter(keysetId: string) {
const counter = self.proofsCounters.find(c => c.keyset === keysetId)
// Make sure we did not lost counter, breaks the wallet
if(counter && isNaN(counter?.counter)) {
counter.counter = 0
self.proofsCounters = cast(self.proofsCounters)
}
return counter
},
isUnitSupported(unit: MintUnit): boolean {
return MintUnits.includes(unit) ? true : false
},
keysetExists(keyset: CashuMintKeyset): boolean {
return self.keysets.some(k => k.id === keyset.id)
},
keysExist(keysetId: string): boolean {
return self.keys.some(k => k.id === keysetId)
}
}))
.actions(self => ({
createProofsCounter(keyset: CashuMintKeyset) {
const existing = self.getProofsCounter(keyset.id)
if(!existing) {
const newCounter = MintProofsCounterModel.create({
keyset: keyset.id,
unit: keyset.unit as MintUnit,
counter: 0,
})
self.addProofsCounter(newCounter)
return newCounter
}
return existing
}
}))
.actions(self => ({
initKeyset(keyset: CashuMintKeyset, allKeysetIds: string[]) {
// ATTN: const mintsStore = getRootStore(self).mintsStore does not work here (may be because it is being called from within loop)
// Do not add unit the wallet does not have configured
if(!self.isUnitSupported(keyset.unit as MintUnit)) {
throw new AppError(
Err.VALIDATION_ERROR,
`Unsupported unit provided by the mint`,
{caller: 'initKeyset', unit: keyset.unit}
)
}
const existing = self.keysets.find(k => k.id === keyset.id)
if(existing) {
if (existing.unit !== keyset.unit) {
throw new AppError(
Err.VALIDATION_ERROR,
`Keyset unit mismatch.`,
{caller: 'initKeyset', existingUnit: existing.unit, keysetUnit: keyset.unit}
)
}
if(keyset.input_fee_ppk && existing.input_fee_ppk !== keyset.input_fee_ppk) {
self.setInputFeePpk(existing.id, keyset.input_fee_ppk)
}
if(existing.active !== keyset.active) {
self.setIsActive(keyset)
}
return existing
}
// Prevent keysetId collision with other mints
if(CashuUtils.isCollidingKeysetId(keyset.id, allKeysetIds)) {
throw new AppError(
Err.VALIDATION_ERROR,
`KeysetId validation failed, collision detected.`,
{caller: 'initKeyset', keysetId: keyset.id}
)
}
if(!keyset.input_fee_ppk) {
keyset.input_fee_ppk = 0
}
self.addKeyset(keyset)
self.addUnit(keyset.unit as MintUnit)
self.createProofsCounter(keyset)
log.trace('[initKeyset]', {newKeyset: keyset})
},
initKeys(key: CashuMintKeys) {
// Do not add unit the wallet does not have configured
if(!self.isUnitSupported(key.unit as MintUnit)) {
throw new AppError(Err.VALIDATION_ERROR, `Unsupported unit provided by the mint: ${key.unit}`)
}
const existing = self.keys.find(k => k.id === key.id)
if(existing) {
if (existing.unit !== key.unit) {
throw new AppError(Err.VALIDATION_ERROR, `Keyset unit mismatch, got ${key.unit}, expected ${existing.unit}`)
}
return existing
}
self.addKeys(key)
log.trace('[initKeys]', {newKeys: key.id})
},
}))
.actions(self => ({
refreshKeysets(freshKeysets: CashuMintKeyset[]) {
const mintsStore = getRootStore(self).mintsStore
const allKeysetIds = mintsStore.allKeysetIds
log.trace('[refreshKeysets]', {freshKeysets, allKeysetIds})
// add new keyset if not exists
for (const keyset of freshKeysets) {
// initKeyset now handles active status updates internally
self.initKeyset(keyset, allKeysetIds)
}
},
refreshKeys(freshKeys: CashuMintKeys[]) {
for (const key of freshKeys) {
self.initKeys(key)
}
},
/**
* 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) {
const counter = self.proofsCounters.find(p => p.keyset === keysetId)
if(!counter) {
const keyset = self.keysets.find(k => k.id === keysetId)
if(!keyset) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing keyset.')
}
return self.createProofsCounter(keyset)
}
return counter
},
setHostname() {
try {
self.hostname = new URL(self.mintUrl).hostname
} catch (e) {
return false
}
},
setId() { // migration
self.id = generateId(8)
},
setShortname: flow(function* setShortname() {
// get name from URL as a fallback
const lastSlashIndex = self.mintUrl.lastIndexOf('/')
let shortname = self.mintUrl.substring(lastSlashIndex + 1).slice(0, 25)
try {
const cashuMint = new CashuMint(self.mintUrl)
const info: GetInfoResponse = yield cashuMint.getInfo()
if(info.name.length > 0) {
shortname = info.name
}
} catch (e: any) {
log.warn('[setShortname]', {error: e.message})
}
// Mint may have been removed while the network call was in flight
if (!isAlive(self)) return
self.shortname = shortname
}),
setColor(color: string) {
self.color = color
},
setStatus(status: MintStatus) {
self.status = status
},
resetCounters() {
for(const counter of self.proofsCounters) {
log.warn('Resetting counter', counter.keyset)
counter.counter = 0
}
self.proofsCounters = cast(self.proofsCounters)
},
getMintFeeReserve(proofs: CashuProof[] | Proof[]): number {
// Find the corresponding keyset for each proof and sum the input fees
const totalInputFees: number = proofs.reduce((sum: number, proof) => {
const keyset = self.keysets.find(k => k.id === proof.id)
return keyset && keyset.input_fee_ppk ? sum + (keyset?.input_fee_ppk ?? 0) : sum
}, 0)
// Calculate the fees
const feeReserve = Math.max(Math.floor((totalInputFees + 999) / 1000), 0)
log.trace('[getMintFeeReserve]', {feeReserve})
return feeReserve
},
}))
.views(self => ({
get balances(): MintBalance | undefined {
const mintBalance: MintBalance | undefined = getRootStore(self).proofsStore.getMintBalance(self.mintUrl)
return mintBalance
},
get keysetIds(): string[] {
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
},
}))
export type Mint = {
mintUrl: string
} & Partial<Instance<typeof MintModel>>
export interface MintSnapshotOut extends SnapshotOut<typeof MintModel> {}
export interface MintSnapshotIn extends SnapshotIn<typeof MintModel> {}