Files
minibits_wallet/src/models/WalletStore.ts
T
minibits-cashandClaude Opus 4.8 d62ac12dfd Fix mintToCacheDTO call for cashu-ts v4 signature
cashu-ts v4 dropped the leading `unit` parameter from
CashuKeyChain.mintToCacheDTO; the signature is now
(mintUrl, allKeysets, allKeys). Both call sites still passed `unit` first,
shifting mintUrl into the allKeysets slot. At runtime mintToCacheDTO does
allKeysets.map(...) on what is actually the mintUrl string, throwing
"TypeError: undefined is not a function" — which surfaced as a failed topup
mint right after getCachedWalletKeys, before mintProofs logged its counter.

Verified against the installed runtime (cashu-ts 4.2.1, cashu-ts.es.js): the
impl takes 3 args and cashu-ts calls it internally as
mintToCacheDTO(this.mintUrl, keysets, keys). Sibling of the earlier
loadMintFromCache v4 signature fix (c61e73a); unrelated to the op-sqlite
migration (the DB layer raises AppError, never a bare TypeError).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:42:53 +02:00

1152 lines
43 KiB
TypeScript

import {Instance, SnapshotOut, types, flow, getRoot, getSnapshot} from 'mobx-state-tree'
import {
Mint as CashuMint,
Wallet as CashuWallet,
KeyChain as CashuKeyChain,
MeltQuoteBolt11Response,
setGlobalRequestOptions,
type MintKeys,
type MintKeyset,
Token,
MeltProofsResponse,
CheckStateEnum,
GetKeysetsResponse,
GetKeysResponse,
GetInfoResponse,
MintQuoteBolt11Response,
type ProofState,
type OperationCounters,
type MeltPreview,
MeltQuoteState,
} from '@cashu/cashu-ts'
import { JS_BUNDLE_VERSION } from '@env'
import {KeyChain, MinibitsClient, WalletKeys} from '../services'
import {log} from '../services/logService'
import AppError, { Err, MintError, NetworkError } from '../utils/AppError'
import { Currencies, CurrencyCode, MintUnit } from '../services/wallet/currency'
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
import { Proof } from './Proof'
import { InFlightRequest, Mint } from './Mint'
import { getRootStore } from './helpers/getRootStore'
import { Transaction } from './Transaction'
// r
/*
Not persisted, in-memory only model of the cashu-ts wallet instances and wallet keys persisted in the device secure store.
It is instantiated on first use so that wallet retrieves fresh mint keysets, then cached,
so that new cashu-ts instances are re-used over app lifecycle.
*/
export type ExchangeRate = {
currency: CurrencyCode, // 1 EUR, USD, ...
rate: number // in satoshis
}
export type ReceiveParams = {
token: string | Token,
options?: {
keysetId?: string;
proofsWeHave?: Array<CashuProof>;
counter?: number;
pubkey?: string;
privkey?: string;
requireDleq?: boolean;
}
}
export type SendParams = {
amount: number,
proofs: Array<CashuProof>,
options?: {
proofsWeHave?: Array<CashuProof>;
counter?: number;
pubkey?: string;
privkey?: string;
keysetId?: string;
offline?: boolean;
includeFees?: boolean;
includeDleq?: boolean;
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> }
}
}
export type MintParams = {
amount: number,
quote: string,
options?: {
keysetId?: string;
proofsWeHave?: Array<CashuProof>;
counter?: number;
pubkey?: string;
}
}
export type MeltParams = {
meltQuote: MeltQuoteBolt11Response,
proofsToSend: Array<CashuProof>,
options?: {
keysetId?: string;
counter?: number;
privkey?: string;
}
}
export const WalletStoreModel = types
.model('WalletStore', {
mints: types.array(types.frozen<CashuMint>()),
wallets: types.array(types.frozen<CashuWallet>()),
seedWallets: types.array(types.frozen<CashuWallet>()),
walletKeys: types.maybe(types.frozen<WalletKeys>()),
exchangeRate: types.maybe(types.frozen<ExchangeRate>()),
})
.views(self => ({
getMintModelInstance(mintUrl: string) : Mint | undefined {
const mintsStore = getRootStore(self).mintsStore
return mintsStore.findByUrl(mintUrl) as Mint
},
getOptimalKeyset(mintInstance: Mint, unit: MintUnit) {
const optimalKeyset: MintKeyset =mintInstance.keysets!
.filter((k: MintKeyset) => k.unit === unit && k.active)
.sort((a: MintKeyset, b: MintKeyset) => {
// Prioritize keysets that start with '00'
const aStartsWith00 = a.id.startsWith('00') ? 1 : 0;
const bStartsWith00 = b.id.startsWith('00') ? 1 : 0;
if (aStartsWith00 !== bStartsWith00) {
return bStartsWith00 - aStartsWith00;
}
// If both start with '00' or neither do, sort by input_fee_ppk
return (a.input_fee_ppk ?? 0) - (b.input_fee_ppk ?? 0);
})[0]
if(!optimalKeyset) {
throw new AppError(Err.VALIDATION_ERROR, 'Wallet has not any active keyset for the selected unit.', {
mintUrl: mintInstance.mintUrl,
unit
})
}
return optimalKeyset
},
}))
.actions(self => ({
getExchangeRate: flow(function* getExchangeRate(currencyCode: CurrencyCode) {
if (self.exchangeRate && self.exchangeRate.currency === currencyCode) {
return self.exchangeRate
}
const {rate, currency} = yield MinibitsClient.getExchangeRate(currencyCode)
const precision = Currencies[currencyCode]?.precision
if(!precision) {
throw new AppError(Err.VALIDATION_ERROR, `Currency code ${currency} is not yet supported by Minibits. Submit request to add it on our Github.`)
}
return {
currency: currency,
rate: rate / precision
}
}),
refreshExchangeRate: flow(function* refreshExchangeRate(currencyCode: CurrencyCode) {
const {rate, currency} = yield MinibitsClient.getExchangeRate(currencyCode)
const precision = Currencies[currencyCode]?.precision
if(!precision) {
throw new AppError(Err.VALIDATION_ERROR, `Currency code ${currency} is not yet supported by Minibits. Submit request to add it on our Github.`)
}
self.exchangeRate = {
currency: currency,
rate: rate / precision
}
}),
resetExchangeRate () {
self.exchangeRate = undefined
}
}))
.actions(self => ({
getCachedWalletKeys: flow(function* getWalletKeys() {
if (self.walletKeys) {
log.trace('[getCachedWalletKeys]', 'Returning cached walletKeys')
return self.walletKeys
}
const keys: WalletKeys | undefined = yield KeyChain.getWalletKeys()
if (!keys) {
throw new AppError(
Err.NOTFOUND_ERROR,
'Device secure storage could not return wallet keys, please reinstall and use your seed phrase to recover wallet.'
)
}
self.walletKeys = keys
return keys
}),
cleanCachedWalletKeys() {
self.walletKeys = undefined
},
}))
.actions(self => ({
getCachedSeed: flow(function* getCachedSeed() {
const keys: WalletKeys = yield self.getCachedWalletKeys()
return new Uint8Array(Buffer.from(keys.SEED.seed, 'base64'))
}),
getCachedMnenomic: flow(function* getCachedMnenomic() {
const keys: WalletKeys = yield self.getCachedWalletKeys()
return keys.SEED.mnemonic
}),
getCachedSeedHash: flow(function* getCachedSeedHash() {
const keys: WalletKeys = yield self.getCachedWalletKeys()
return keys.SEED.seedHash
}),
getMint: flow(function* getMint(mintUrl: string) {
const mint = self.mints.find(m => m.mintUrl === mintUrl)
log.trace('[WalletStore.getMint]', {cachedMint: !!mint})
if (mint) {
return mint as CashuMint
}
setGlobalRequestOptions({
headers: {'User-Agent': `Minibits/${JS_BUNDLE_VERSION}`}
})
// create cashu-ts mint instance
const newMint = new CashuMint(mintUrl)
// get fresh keysets - returns all keysets, both active and inactive
const {keysets} = yield newMint.getKeySets()
// get persisted mint model from wallet state
const mintInstance = self.getMintModelInstance(mintUrl)
// skip checks if this is new mint being added
if(mintInstance) {
const newKeysets = keysets.filter((freshKeyset: MintKeyset) => {
return !mintInstance.keysets!.some((keyset: MintKeyset) => keyset.id === freshKeyset.id)
})
if(newKeysets.length > 0) {
// if we have new keysets, get and sync new keys
// this, for perf reasons, returns ONLY active keys so
// mintInstance can not be directly used to restore from inactive keysets
const {keysets: keys} = yield newMint.getKeys() as Promise<GetKeysResponse>
mintInstance.refreshKeys!(keys)
}
// 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) {
const info: GetInfoResponse = yield newMint.getInfo()
mintInstance.setMintInfo!(info as GetInfoResponse & {time: number})
}
}
// store cashu-ts mint instance in memory
self.mints.push(newMint)
return newMint
})
}))
.actions(self => ({
getWallet: flow(function* getWallet(
mintUrl: string,
unit: MintUnit,
options?: {
keysetId?: string
withSeed: boolean
}
) {
// syncs mint model in wallet state and returns cashu-ts mint class instance
const cashuMint: CashuMint = yield self.getMint(mintUrl)
// get uptodate mint model from wallet state
const mintInstance = self.getMintModelInstance(mintUrl)
if(!mintInstance) {
throw new AppError(Err.NOTFOUND_ERROR, 'Mint not found in the wallet state.', {
mintUrl
})
}
// select keys to be used to find or create new cashu-ts wallet instance
let walletKeys: MintKeys
if(options && options.keysetId) {
//log.warn(mintInstance.keys)
const requestedKeys = mintInstance.keys!.find((k: MintKeys) => k.id === options.keysetId)
if(!requestedKeys) {
throw new AppError(Err.NOTFOUND_ERROR, 'Mint has no active keys with provided keyset id, refresh mint settings.', {
mintUrl,
keysetId: options.keysetId
})
}
if(requestedKeys.unit !== unit) {
throw new AppError(Err.VALIDATION_ERROR, 'Wallet unit and keys mismatch.', {
mintUrl,
keysetId: options.keysetId,
unit
})
}
walletKeys = requestedKeys
} else {
// if not we find active keyset with lowest fees and related keys
const activeKeyset: MintKeyset = self.getOptimalKeyset(mintInstance, unit) // throws
log.trace('[WalletStore.getWallet] Optimal keyset for this unit', {activeKeyset, unit, mintUrl})
const activeKeys = mintInstance.keys!.find((k: MintKeys) => k.id === activeKeyset.id)
if(!activeKeys) {
throw new AppError(Err.VALIDATION_ERROR, 'Wallet has no active keys for the selected unit, refresh mint settings.', {
mintUrl,
unit,
activeKeysetId: activeKeyset.id
})
}
walletKeys = activeKeys
}
if (options && options.withSeed) {
const seedWallet: CashuWallet | undefined = self.seedWallets.find(
w => w.mint.mintUrl === mintUrl &&
w.keysetId === walletKeys.id
)
if (seedWallet) {
log.trace('[WalletStore.getWallet]', 'Returning CACHED cashuWallet instance with seed', {mintUrl})
return seedWallet as CashuWallet
}
const seed = yield self.getCachedSeed()
const newSeedWallet = new CashuWallet(cashuMint, {
unit,
keysetId: walletKeys.id,
bip39seed: seed
})
if (mintInstance.mintInfo && mintInstance.keysets?.length && mintInstance.keys?.length) {
const keychainCache = CashuKeyChain.mintToCacheDTO(mintUrl, [...mintInstance.keysets], [...mintInstance.keys])
newSeedWallet.loadMintFromCache(mintInstance.mintInfo, keychainCache)
} else {
yield newSeedWallet.loadMint()
}
self.seedWallets.push(newSeedWallet)
log.trace('[WalletStore.getWallet]', 'Returning NEW cashuWallet instance with seed', {mintUrl})
return newSeedWallet
}
const wallet: CashuWallet | undefined = self.wallets.find(
w => w.mint.mintUrl === mintUrl &&
w.keysetId === walletKeys.id
)
if (wallet) {
log.trace('[WalletStore.getWallet]', 'Returning CACHED cashuWallet instance', {mintUrl})
return wallet
}
const newWallet = new CashuWallet(cashuMint, {
unit,
keysetId: walletKeys.id,
})
if (mintInstance.mintInfo && mintInstance.keysets?.length && mintInstance.keys?.length) {
const keychainCache = CashuKeyChain.mintToCacheDTO(mintUrl, [...mintInstance.keysets], [...mintInstance.keys])
newWallet.loadMintFromCache(mintInstance.mintInfo, keychainCache)
} else {
yield newWallet.loadMint()
}
self.wallets.push(newWallet)
log.trace('[WalletStore.getWallet]', 'Returning NEW cashuWallet instance', {mintUrl})
return newWallet
}),
getMintKeysets: flow(function* getMintKeysets(mintUrl: string) {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
try {
const {keysets} = yield cashuMint.getKeySets() as Promise<GetKeysetsResponse> // all keysets
return keysets as MintKeyset[]
} catch (e: any) {
let message = 'Could not connect to the selected mint.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(Err.CONNECTION_ERROR, message, { message: e.message, mintUrl })
}
}),
getMintKeys: flow(function* getMintKeys(mintUrl: string) {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
try {
const {keysets: keys} = yield cashuMint.getKeys() as Promise<GetKeysResponse> // all active keys
return keys as MintKeys[]
} catch (e: any) {
let message = 'Could not connect to the selected mint.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(Err.CONNECTION_ERROR, message, { message: e.message, mintUrl })
}
}),
resetWallets() {
self.seedWallets.clear()
self.wallets.clear()
}
}))
.actions(self => ({
receive: flow(function* receive(
mintUrl: string,
unit: MintUnit,
decodedToken: Token,
transactionId: number,
options?: {
increaseCounterBy?: number,
inFlightRequest?: InFlightRequest<ReceiveParams>
}
) {
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 && options.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy)
}
log.debug('[WalletStore.receive] counter', currentCounter.counter)
// Sync wallet's internal counter with our stored counter (v3.x)
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
// P2PK locked tokens can be received only by the wallet they are locked to (if lock has not expired)
const isLocked = CashuUtils.isTokenP2PKLocked(decodedToken)
let isLockedToWallet = false
const walletKeys = yield self.getCachedWalletKeys()
if(isLocked) {
const lockedToPK = CashuUtils.getP2PKPubkeySecret(decodedToken.proofs[0].secret)
const locktime = CashuUtils.getP2PKLocktime(decodedToken.proofs[0].secret)
const walletP2PK = '02' + walletKeys.NOSTR.publicKey
isLockedToWallet = lockedToPK === walletP2PK
if(!isLockedToWallet) {
throw new AppError(Err.VALIDATION_ERROR, 'Ecash token is locked to another wallet, can not receive it.', {lockedToPK, walletP2PK, locktime})
}
}
const receiveParams: ReceiveParams = options?.inFlightRequest?.request || {
token: decodedToken,
options: {
keysetId: cashuWallet.keysetId,
// Note: counter is no longer passed in v3.x, wallet manages it internally
privkey: isLockedToWallet ? walletKeys.NOSTR.privateKey : undefined,
}
}
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, receiveParams)
}
let reservedCounters: OperationCounters | undefined
try {
const proofs = yield cashuWallet.receive(
receiveParams.token,
{
...receiveParams.options,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[receive] Counters reserved', info)
}
}
)
log.trace('[WalletStore.receive]', {proofs})
currentCounter.removeInFlightRequest(transactionId)
// Update our counter to match what the wallet used (v3.x)
if (reservedCounters) {
currentCounter.setProofsCounter(reservedCounters.next)
log.debug('[receive] Updated counter', {
keysetId: reservedCounters.keysetId,
start: reservedCounters.start,
count: reservedCounters.count,
next: reservedCounters.next
})
}
const receivedAmount: number = CashuUtils.getProofsAmount(proofs as Proof[])
const amountToReceive: number = CashuUtils.getProofsAmount(decodedToken.proofs)
const swapFeePaid = amountToReceive - receivedAmount
return {
proofs,
swapFeePaid
} as {
proofs: CashuProof[],
swapFeePaid: number
}
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove in-flight request only if it was not a timeout or network error
currentCounter.removeInFlightRequest(transactionId)
}
throw new AppError(
Err.MINT_ERROR,
e.message,
{
caller: 'WalletStore.receive',
code: e.code || undefined,
message: e.message,
}
)
}
}),
send: flow(function* send(
mintUrl: string,
amountToSend: number,
unit: MintUnit,
proofsToSendFrom: Proof[],
transactionId: number,
options?: {
increaseCounterBy?: number,
inFlightRequest?: InFlightRequest<SendParams>
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> }
}
) {
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 && options.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy)
}
log.debug('[WalletStore.send] counter', currentCounter.counter)
// Sync wallet's internal counter with our stored counter (v3.x)
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
const p2pk = options?.p2pk
const sendParams: SendParams = options?.inFlightRequest?.request || {
amount: amountToSend,
proofs: CashuUtils.exportProofs(proofsToSendFrom),
options: {
keysetId: cashuWallet.keysetId,
// Note: counter is no longer passed in v3.x, wallet manages it internally
includeFees: false, // fee reserve needs to be already in proofsToSendFrom
p2pk
}
}
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, sendParams)
}
let reservedCounters: OperationCounters | undefined
try {
const {keep, send} = yield cashuWallet.send(
sendParams.amount,
sendParams.proofs,
{
...sendParams.options,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[send] Counters reserved', info)
}
}
)
currentCounter.removeInFlightRequest(transactionId)
// Update our counter to match what the wallet used (v3.x)
if (reservedCounters) {
currentCounter.setProofsCounter(reservedCounters.next)
log.debug('[send] Updated counter', {
keysetId: reservedCounters.keysetId,
start: reservedCounters.start,
count: reservedCounters.count,
next: reservedCounters.next
})
}
log.trace(`[WalletStore.send] ${keep.length} returnedProofs`, {keep})
log.trace(`[WalletStore.send] ${send.length} proofsToSend`, {send})
const proofsToSendFromAmount: number = CashuUtils.getProofsAmount(proofsToSendFrom)
const returnedAmount: number = CashuUtils.getProofsAmount(keep)
const swapFeePaid = proofsToSendFromAmount - amountToSend - returnedAmount
log.debug('[WalletStore.send] Amounts after swap', {proofsToSendFromAmount, amountToSend, returnedAmount, swapFeePaid})
return {
returnedProofs: keep as CashuProof[],
proofsToSend: send as CashuProof[],
swapFeePaid
}
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove in-flight request only if it was not a timeout or network error
currentCounter.removeInFlightRequest(transactionId)
}
let message = 'Swap to prepare ecash to send has failed.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
mintUrl,
caller: 'WalletStore.send',
code: e.code || undefined,
}
)
}
}),
getProofsStatesFromMint: flow(function* getProofsStatesFromMint(
mintUrl: string,
unit: MintUnit,
proofs: Proof[]
) {
try {
log.trace('[WalletStore.getProofsStatesFromMint] start', {mintUrl, unit})
const cashuWallet: CashuWallet = yield self.getWallet(mintUrl, unit, {withSeed: false})
// v3.x returns ProofState[] array, not grouped by state
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
const proofsByState: {[key in CheckStateEnum]: CashuProof[]} = {
SPENT: [],
PENDING: [],
UNSPENT: []
}
// proofStatesArray is returned in the same order as input proofs
for (let i = 0; i < proofStatesArray.length; i++) {
const proofState = proofStatesArray[i]
const originalProof = proofs[i]
if (originalProof) {
proofsByState[proofState.state].push(originalProof as CashuProof)
}
}
log.trace('[WalletStore.getProofsStatesFromMint]', {mintUrl, proofsByState})
return proofsByState
} catch (e: any) {
let message = 'Could not get response from the mint.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
// Heuristic: if the inner cashu-ts cause looks like a transport
// failure, surface a NetworkError so callers (e.g. syncStateWithMintTask)
// can mark the mint OFFLINE via `instanceof NetworkError`.
const innerMessage: string = e?.message ?? ''
const isNetwork = /network|fetch|timeout|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(innerMessage)
const params = {
message: innerMessage,
caller: 'WalletStore.getProofsStatesFromMint',
mintUrl,
}
throw isNetwork
? new NetworkError(message, params)
: new MintError(message, params)
}
}),
createLightningMintQuote: flow(function* createLightningMintQuote(
mintUrl: string,
unit: MintUnit,
amount: number,
description?: string,
) {
try {
const cashuMint = yield self.getMint(mintUrl)
const mintQuoteResponse: MintQuoteBolt11Response = yield cashuMint.createMintQuoteBolt11({
unit,
amount,
description
})
log.info('[createLightningMintQuote]', {mintQuoteResponse})
return {
encodedInvoice: mintQuoteResponse.request,
mintQuote: mintQuoteResponse.quote,
}
} catch (e: any) {
let message = 'The mint could not return a mint quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'createLightningMintQuote',
mintUrl,
}
)
}
}),
checkLightningMintQuote: flow(function* checkLightningMintQuote(
mintUrl: string,
quote: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const quoteResponse: MintQuoteBolt11Response = yield cashuMint.checkMintQuoteBolt11(
quote
)
log.info('[checkLightningMintQuote]', {quoteResponse})
return {
encodedInvoice: quoteResponse.request,
mintQuote: quoteResponse.quote,
state: quoteResponse.state
}
} catch (e: any) {
let message = 'The mint could not return the state of a mint quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'checkLightningMintQuote',
mintUrl,
}
)
}
}),
mintProofs: flow(function* mintProofs(
mintUrl: string,
amount: number,
unit: MintUnit,
mintQuote: 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 && options.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy)
}
log.debug('[WalletStore.mintProofs] counter', currentCounter.counter)
// Sync wallet's internal counter with our stored counter (v3.x)
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
const mintParams: MintParams = options?.inFlightRequest?.request || {
amount,
quote: mintQuote,
options: {
keysetId: cashuWallet.keysetId
// Note: counter is no longer passed in v3.x, wallet manages it internally
}
}
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, mintParams)
}
let reservedCounters: OperationCounters | undefined
try {
const proofs = yield cashuWallet.mintProofsBolt11(
mintParams.amount,
mintParams.quote,
{
keysetId: mintParams.options?.keysetId,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[mintProofsBolt11] Counters reserved', info)
}
}
)
currentCounter.removeInFlightRequest(transactionId)
// Update our counter to match what the wallet used (v3.x)
if (reservedCounters) {
currentCounter.setProofsCounter(reservedCounters.next)
log.debug('[mintProofs] Updated counter', {
keysetId: reservedCounters.keysetId,
start: reservedCounters.start,
count: reservedCounters.count,
next: reservedCounters.next
})
}
log.debug('[mintProofs]', {amount: mintParams.amount, quote: mintParams.quote, proofs})
return proofs
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove in-flight request only if it was not a timeout or network error
currentCounter.removeInFlightRequest(transactionId)
}
let message = 'Error on request to mint new ecash.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'mintProofs',
mintUrl,
}
)
}
}),
createLightningMeltQuote: flow(function* createLightningMeltQuote(
mintUrl: string,
unit: MintUnit,
encodedInvoice: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const lightningQuote: MeltQuoteBolt11Response = yield cashuMint.createMeltQuoteBolt11({
unit,
request: encodedInvoice
})
log.info('[createLightningMeltQuote]', {mintUrl, unit, encodedInvoice}, {lightningQuote})
return lightningQuote
} catch (e: any) {
let message = 'The mint could not return the lightning quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'createLightningMeltQuote',
request: {mintUrl, unit, encodedInvoice},
}
)
}
}),
payLightningMelt: flow(function* payLightningMelt(
mintUrl: string,
unit: MintUnit,
meltQuote: MeltQuoteBolt11Response, // invoice is stored by mint by quote
proofsToMeltFrom: Proof[],
transactionId: number,
options?: {
increaseCounterBy?: number,
inFlightRequest?: InFlightRequest<MeltParams>,
preferAsync?: boolean,
}
) {
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)
}
// Sync wallet's internal counter with our stored counter (v3.x)
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
log.trace('[WalletStore.payLightningMelt] Preparing melt', {
localCounter: currentCounter.counter,
proofsCount: proofsToMeltFrom.length
})
let reservedCounters: OperationCounters | undefined
// Step 1: Prepare the melt (creates change outputs deterministically)
const meltPreview: MeltPreview = yield cashuWallet.prepareMelt(
'bolt11',
meltQuote,
CashuUtils.exportProofs(proofsToMeltFrom),
{
keysetId: cashuWallet.keysetId,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[prepareMelt] Counters reserved', info)
}
}
)
// Store the MeltPreview for potential recovery
currentCounter.addMeltCounterValue(transactionId, meltPreview)
// Update our counter to match what the wallet used (v3.x)
if (reservedCounters) {
currentCounter.setProofsCounter(reservedCounters.next)
log.debug('[prepareMelt] Updated counter', {
keysetId: reservedCounters.keysetId,
start: reservedCounters.start,
count: reservedCounters.count,
next: reservedCounters.next
})
}
try {
// Step 2: Complete the melt (sends to mint and constructs change proofs)
const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview, undefined, options?.preferAsync)
// Keep the preview for PENDING async melts — handlePendingMeltTask needs it to unbind change later
if (meltResponse.quote.state !== MeltQuoteState.PENDING) {
currentCounter.removeMeltCounterValue(transactionId)
}
log.trace('[payLightningMelt]', {meltResponse})
return meltResponse
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove only if it was not a timeout or network error
currentCounter.removeMeltCounterValue(transactionId)
}
let message = 'Lightning payment failed.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'payLightningMelt',
mintUrl,
code: e.code || undefined,
}
)
}
}),
checkLightningMeltQuote: flow(function* checkLightningMeltQuote(
mintUrl: string,
quote: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const quoteResponse: MeltQuoteBolt11Response = yield cashuMint.checkMeltQuoteBolt11(
quote
)
log.info('[checkLightningMeltQuote]', {quoteResponse})
return quoteResponse
} catch (e: any) {
let message = 'The mint could not return the state of a melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'checkLightningMeltQuote',
mintUrl,
}
)
}
}),
restore: flow(function* restore(
mintUrl: string,
seed: Uint8Array,
options: {
indexFrom: number,
indexTo: number,
keysetId: string
unit: MintUnit
}
) {
try {
// PERF: Start restore timing
const perfTotal = performance.now()
log.info('[PERF][WalletStore.restore] ====== START ======')
log.trace('[restore]', {mintUrl, options})
const {indexFrom, indexTo, keysetId, unit} = options
// PERF: Time wallet creation
const perfWalletCreate = performance.now()
// Create separate CashuMint and CashuWallet instances for restore operation
// to avoid polluting the main wallet state with inactive keyset data.
// cashu-ts 3.4.1+ supports restore from inactive keysets natively.
const cashuMint = new CashuMint(mintUrl)
const cashuWallet = new CashuWallet(cashuMint, {
unit,
keysetId,
bip39seed: seed
})
yield cashuWallet.loadMint()
log.info('[PERF][WalletStore.restore] CashuWallet created:', { ms: (performance.now() - perfWalletCreate).toFixed(2) })
const count = Math.abs(indexTo - indexFrom)
log.info('[PERF][WalletStore.restore] About to restore', { indexFrom, count, keysetId })
// PERF: Time the actual restore call (this is the main operation)
const perfRestore = performance.now()
const {proofs} = yield cashuWallet.restore(
indexFrom,
count,
{keysetId}
)
log.info('[PERF][WalletStore.restore] seedWallet.restore() (cashu-ts):', { ms: (performance.now() - perfRestore).toFixed(2), proofsFound: proofs.length })
log.info('[PERF][WalletStore.restore] ====== END ======')
log.info('[PERF][WalletStore.restore] Total:', { ms: (performance.now() - perfTotal).toFixed(2) })
log.info('[restore]', 'Number of recovered proofs', {proofs: proofs.length})
return {
proofs: proofs || [] as Proof[]
}
} catch (e: any) {
throw new AppError(Err.MINT_ERROR, CashuUtils.isObj(e.message) ? JSON.stringify(e.message) : e.message, {mintUrl})
}
}),
getMintInfo: flow(function* getMintInfo(mintUrl: string) {
try {
const cashuMint = yield self.getMint(mintUrl)
const info = yield cashuMint.getInfo()
log.trace('[getMintInfo]', {info})
return info
} catch (e: any) {
let message = 'The mint could not return mint information.';
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'getMintInfo',
mintUrl
}
)
}
})
}))
.postProcessSnapshot((snapshot) => { // NOT persisted to mmkv storage except last exchangeRate!
return {
mints: [],
wallets: [],
seedWallets: [],
walletKeys: undefined,
exchangeRate: snapshot.exchangeRate
}
})
function isOnionMint(mintUrl: string) {
return new URL(mintUrl).hostname.endsWith('.onion')
}
const TorVPNSetupInstructions = `
Is your Tor VPN running?
Mints on Tor require a Tor VPN application like Orbot.`
export interface WalletStore extends Instance<typeof WalletStoreModel> {}
export interface WalletStoreSnapshot
extends SnapshotOut<typeof WalletStoreModel> {}