Reimplement v3 counters

This commit is contained in:
minibits-cash
2026-01-09 23:40:49 +01:00
parent e126fd3a8c
commit 5b4b96c490
2 changed files with 175 additions and 117 deletions
+12 -9
View File
@@ -1,10 +1,11 @@
import {cast, detach, flow, getParent, getRoot, getSnapshot, IAnyStateTreeNode, Instance, isAlive, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree' import {cast, detach, flow, getParent, getRoot, getSnapshot, IAnyStateTreeNode, Instance, isAlive, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
import {withSetPropAction} from './helpers/withSetPropAction' import {withSetPropAction} from './helpers/withSetPropAction'
import { import {
type GetInfoResponse, type GetInfoResponse,
type MintKeys as CashuMintKeys, type MintKeys as CashuMintKeys,
type MintKeyset as CashuMintKeyset, type MintKeyset as CashuMintKeyset,
Mint as CashuMint, Mint as CashuMint,
type MeltPreview,
} from '@cashu/cashu-ts' } from '@cashu/cashu-ts'
import {colors, getRandomIconColor} from '../theme' import {colors, getRandomIconColor} from '../theme'
import { log } from '../services' import { log } from '../services'
@@ -16,7 +17,6 @@ import { generateId } from '../utils/utils'
import { Proof } from './Proof' import { Proof } from './Proof'
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils' import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
ß
export type MintBalance = { export type MintBalance = {
mintUrl: string mintUrl: string
balances: { balances: {
@@ -49,10 +49,11 @@ const InFlightRequestModel = types.model('InFlightRequest', {
request: types.frozen<any>(), // or replace `any` with your actual request type request: types.frozen<any>(), // or replace `any` with your actual request type
}) })
// Sub-model for melt counter snapshots // Sub-model for melt previews (v3.x uses MeltPreview instead of just counter)
const MeltCounterValueModel = types.model('MeltCounterValue', { const MeltCounterValueModel = types.model('MeltCounterValue', {
transactionId: types.number, transactionId: types.number,
counterAtMelt: types.number, // the counter value when melt started counterAtMelt: types.number, // the counter value when melt started (kept for backward compatibility)
meltPreview: types.maybe(types.frozen<MeltPreview>()), // v3.x MeltPreview object for recovery
createdAt: types.optional(types.Date, () => new Date()), // optional: when it was added createdAt: types.optional(types.Date, () => new Date()), // optional: when it was added
}) })
@@ -140,7 +141,7 @@ export const MintProofsCounterModel = types
}, },
// === Melt counter tracking === // === Melt counter tracking ===
addMeltCounterValue(transactionId: number): number { addMeltCounterValue(transactionId: number, meltPreview?: MeltPreview): number {
const key = transactionId.toString() const key = transactionId.toString()
if (self.meltCounterValues.has(key)) { if (self.meltCounterValues.has(key)) {
log.warn('[addMeltCounterValue]', 'Melt already tracked', { transactionId }) log.warn('[addMeltCounterValue]', 'Melt already tracked', { transactionId })
@@ -150,12 +151,14 @@ export const MintProofsCounterModel = types
self.meltCounterValues.set(key, { self.meltCounterValues.set(key, {
transactionId, transactionId,
counterAtMelt: self.counter, counterAtMelt: self.counter,
meltPreview,
createdAt: new Date(), createdAt: new Date(),
}) })
log.trace('[addMeltCounterValue]', { log.trace('[addMeltCounterValue]', {
transactionId, transactionId,
counterAtMelt: self.counter, counterAtMelt: self.counter,
hasMeltPreview: !!meltPreview,
}) })
return self.counter return self.counter
+163 -108
View File
@@ -14,6 +14,7 @@ import {
MintQuoteBolt11Response, MintQuoteBolt11Response,
type ProofState, type ProofState,
type OperationCounters, type OperationCounters,
type MeltPreview,
} from '@cashu/cashu-ts' } from '@cashu/cashu-ts'
import { JS_BUNDLE_VERSION } from '@env' import { JS_BUNDLE_VERSION } from '@env'
import {KeyChain, MinibitsClient, WalletKeys} from '../services' import {KeyChain, MinibitsClient, WalletKeys} from '../services'
@@ -411,63 +412,86 @@ export const WalletStoreModel = types
) )
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId) const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
// outputs error healing // outputs error healing
if(options && options.increaseCounterBy) { if(options && options.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy) currentCounter.increaseProofsCounter(options.increaseCounterBy)
} }
log.debug('[WalletStore.receive] counter', currentCounter.counter) 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) // P2PK locked tokens can be received only by the wallet they are locked to (if lock has not expired)
const isLocked = CashuUtils.isTokenP2PKLocked(decodedToken) const isLocked = CashuUtils.isTokenP2PKLocked(decodedToken)
let isLockedToWallet = false let isLockedToWallet = false
const walletKeys = yield self.getCachedWalletKeys() const walletKeys = yield self.getCachedWalletKeys()
if(isLocked) { if(isLocked) {
const lockedToPK = CashuUtils.getP2PKPubkeySecret(decodedToken.proofs[0].secret) const lockedToPK = CashuUtils.getP2PKPubkeySecret(decodedToken.proofs[0].secret)
const locktime = CashuUtils.getP2PKLocktime(decodedToken.proofs[0].secret) const locktime = CashuUtils.getP2PKLocktime(decodedToken.proofs[0].secret)
const walletP2PK = '02' + walletKeys.NOSTR.publicKey const walletP2PK = '02' + walletKeys.NOSTR.publicKey
isLockedToWallet = lockedToPK === walletP2PK isLockedToWallet = lockedToPK === walletP2PK
if(!isLockedToWallet) { if(!isLockedToWallet) {
throw new AppError(Err.VALIDATION_ERROR, 'Ecash token is locked to another wallet, can not receive it.', {lockedToPK, walletP2PK, locktime}) 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 || { const receiveParams: ReceiveParams = options?.inFlightRequest?.request || {
token: decodedToken, token: decodedToken,
options: { options: {
keysetId: cashuWallet.keysetId, keysetId: cashuWallet.keysetId,
counter: currentCounter.counter, // Note: counter is no longer passed in v3.x, wallet manages it internally
privkey: isLockedToWallet ? walletKeys.NOSTR.privateKey : undefined, privkey: isLockedToWallet ? walletKeys.NOSTR.privateKey : undefined,
} }
} }
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, receiveParams) currentCounter.addInFlightRequest(transactionId, receiveParams)
} }
let reservedCounters: OperationCounters | undefined
try { try {
const proofs = yield cashuWallet.receive( const proofs = yield cashuWallet.receive(
receiveParams.token, receiveParams.token,
receiveParams.options {
...receiveParams.options,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[receive] Counters reserved', info)
}
}
) )
log.trace('[WalletStore.receive]', {proofs}) log.trace('[WalletStore.receive]', {proofs})
currentCounter.removeInFlightRequest(transactionId) currentCounter.removeInFlightRequest(transactionId)
// Update our counter to match what the wallet used (v3.x)
if (reservedCounters) {
currentCounter.counter = 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 receivedAmount: number = CashuUtils.getProofsAmount(proofs as Proof[])
const amountToReceive: number = CashuUtils.getProofsAmount(decodedToken.proofs) const amountToReceive: number = CashuUtils.getProofsAmount(decodedToken.proofs)
const swapFeePaid = amountToReceive - receivedAmount const swapFeePaid = amountToReceive - receivedAmount
return { return {
proofs, proofs,
swapFeePaid swapFeePaid
} as { } as {
proofs: CashuProof[], proofs: CashuProof[],
swapFeePaid: number swapFeePaid: number
} }
@@ -516,50 +540,74 @@ export const WalletStoreModel = types
) )
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId) const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
// outputs error healing // outputs error healing
if(options && options.increaseCounterBy) { if(options && options.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy) currentCounter.increaseProofsCounter(options.increaseCounterBy)
} }
log.debug('[WalletStore.send] counter', currentCounter.counter) 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 p2pk = options?.p2pk
const sendParams: SendParams = options?.inFlightRequest?.request || { const sendParams: SendParams = options?.inFlightRequest?.request || {
amount: amountToSend, amount: amountToSend,
proofs: CashuUtils.exportProofs(proofsToSendFrom), proofs: CashuUtils.exportProofs(proofsToSendFrom),
options: { options: {
keysetId: cashuWallet.keysetId, keysetId: cashuWallet.keysetId,
counter: p2pk && p2pk.pubkey ? undefined : currentCounter.counter, // p2pk is not deterministic // Note: counter is no longer passed in v3.x, wallet manages it internally
includeFees: false, // fee reserve needs to be already in proofsToSendFrom includeFees: false, // fee reserve needs to be already in proofsToSendFrom
p2pk p2pk
} }
} }
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, sendParams) currentCounter.addInFlightRequest(transactionId, sendParams)
} }
let reservedCounters: OperationCounters | undefined
try { try {
const {keep, send} = yield cashuWallet.swap( const {keep, send} = yield cashuWallet.swap(
sendParams.amount, sendParams.amount,
sendParams.proofs, sendParams.proofs,
sendParams.options {
...sendParams.options,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[send] Counters reserved', info)
}
}
) )
currentCounter.removeInFlightRequest(transactionId) currentCounter.removeInFlightRequest(transactionId)
// Update our counter to match what the wallet used (v3.x)
if (reservedCounters) {
currentCounter.counter = 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] ${keep.length} returnedProofs`, {keep})
log.trace(`[WalletStore.send] ${send.length} proofsToSend`, {send}) log.trace(`[WalletStore.send] ${send.length} proofsToSend`, {send})
const totalAmountToSendFrom: number = CashuUtils.getProofsAmount(proofsToSendFrom) const totalAmountToSendFrom: number = CashuUtils.getProofsAmount(proofsToSendFrom)
const returnedAmount: number = CashuUtils.getProofsAmount(keep) const returnedAmount: number = CashuUtils.getProofsAmount(keep)
const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount
return { return {
returnedProofs: keep as CashuProof[], returnedProofs: keep as CashuProof[],
proofsToSend: send as CashuProof[], proofsToSend: send as CashuProof[],
swapFeePaid swapFeePaid
} }
@@ -745,11 +793,12 @@ export const WalletStoreModel = types
} }
} }
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, mintParams) currentCounter.addInFlightRequest(transactionId, mintParams)
} }
let reservedCounters: OperationCounters | undefined = undefined let reservedCounters: OperationCounters | undefined
try { try {
@@ -758,8 +807,8 @@ export const WalletStoreModel = types
mintParams.quote, mintParams.quote,
{ {
keysetId: mintParams.options?.keysetId, keysetId: mintParams.options?.keysetId,
onCountersReserved: (info) => { onCountersReserved: (info: OperationCounters) => {
reservedCounters = info as OperationCounters reservedCounters = info
log.debug('[mintProofsBolt11] Counters reserved', info) log.debug('[mintProofsBolt11] Counters reserved', info)
} }
} }
@@ -864,66 +913,71 @@ export const WalletStoreModel = types
currentCounter.increaseProofsCounter(options.increaseCounterBy) currentCounter.increaseProofsCounter(options.increaseCounterBy)
} }
// store local counter value to be able to recover the change if needed // Sync wallet's internal counter with our stored counter (v3.x)
const counterValueForMelt = currentCounter.addMeltCounterValue(transactionId) yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
// Preemptively increase local counter in case we miss response with change from mint log.trace('[WalletStore.payLightningMelt] Preparing melt', {
currentCounter.increaseProofsCounter(proofsToMeltFrom.length) // ? localCounter: currentCounter.counter,
proofsCount: proofsToMeltFrom.length
let countChangeOutputs = 0 })
if (meltQuote.fee_reserve > 0) {
countChangeOutputs = Math.ceil(Math.log2(meltQuote.fee_reserve)) || 1 let reservedCounters: OperationCounters | undefined
currentCounter.increaseProofsCounter(countChangeOutputs)
} // Step 1: Prepare the melt (creates change outputs deterministically)
const meltPreview: MeltPreview = yield cashuWallet.prepareMelt(
log.trace('[WalletStore.payLightningMelt] Counter state before melt', {localCounter: currentCounter.counter, counterValueForMelt}) 'bolt11',
meltQuote,
const meltParams: MeltParams = options?.inFlightRequest?.request || { CashuUtils.exportProofs(proofsToMeltFrom),
meltQuote, {
proofsToSend: CashuUtils.exportProofs(proofsToMeltFrom), keysetId: cashuWallet.keysetId,
options: { onCountersReserved: (info: OperationCounters) => {
keysetId: cashuWallet.keysetId, reservedCounters = info
counter: counterValueForMelt log.debug('[prepareMelt] Counters reserved', info)
}
} }
} )
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { // Store the MeltPreview for potential recovery
currentCounter.addInFlightRequest(transactionId, meltParams) currentCounter.addMeltCounterValue(transactionId, meltPreview)
}
// Update our counter to match what the wallet used (v3.x)
if (reservedCounters) {
currentCounter.counter = reservedCounters.next
log.debug('[prepareMelt] Updated counter', {
keysetId: reservedCounters.keysetId,
start: reservedCounters.start,
count: reservedCounters.count,
next: reservedCounters.next
})
}
try { try {
// Step 2: Complete the melt (sends to mint and constructs change proofs)
const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview)
const meltResponse = yield cashuWallet.meltProofsBolt11( // Remove the stored preview on success
meltParams.meltQuote, currentCounter.removeMeltCounterValue(transactionId)
meltParams.proofsToSend,
meltParams.options
)
currentCounter.removeInFlightRequest(transactionId)
log.trace('[payLightningMelt]', {meltResponse}) log.trace('[payLightningMelt]', {meltResponse})
// we normalize naming of returned parameters return meltResponse
return meltResponse as MeltProofsResponse
} catch (e: any) { } catch (e: any) {
if(!e.message.toLowerCase().includes('timeout') && // On error, keep the MeltPreview stored so we can attempt recovery later
!e.message.toLowerCase().includes('network request failed')) { log.error('[payLightningMelt] Melt failed, MeltPreview stored for recovery', {
// remove in-flight request only if it was not a timeout or network error transactionId,
currentCounter.removeInFlightRequest(transactionId) error: e.message
// we might roll back preemptive counter increase })
// currentCounter.decreaseProofsCounter(countChangeOutputs)
}
let message = 'Lightning payment failed.' let message = 'Lightning payment failed.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError( throw new AppError(
Err.MINT_ERROR, Err.MINT_ERROR,
message, message,
{ {
message: e.message, message: e.message,
caller: 'payLightningMelt', caller: 'payLightningMelt',
mintUrl, mintUrl,
code: e.code || undefined, code: e.code || undefined,
} }
) )
} }
@@ -957,56 +1011,57 @@ export const WalletStoreModel = types
} }
}), }),
recoverMeltQuoteChange: flow(function* recoverMeltQuoteChange( recoverMeltQuoteChange: flow(function* recoverMeltQuoteChange(
mintUrl: string, mintUrl: string,
meltQuote: MeltQuoteBolt11Response, meltQuote: MeltQuoteBolt11Response,
transactionId: number transactionId: number
) { ) {
try { try {
const mintInstance = self.getMintModelInstance(mintUrl) const mintInstance = self.getMintModelInstance(mintUrl)
if(!mintInstance) { if(!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl}) throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl})
} }
const cashuWallet = yield self.getWallet( const cashuWallet = yield self.getWallet(
mintUrl, mintUrl,
'sat', 'sat',
{ {
withSeed: true, withSeed: true,
} }
) )
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId) const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
const counterValueForMelt = currentCounter.getMeltCounterValue(transactionId)?.counterAtMelt const meltCounterValue = currentCounter.getMeltCounterValue(transactionId)
if(counterValueForMelt === undefined) { if(!meltCounterValue) {
throw new AppError(Err.VALIDATION_ERROR, 'Change already claimed - counterAtMelt not available for this transaction', {mintUrl, transactionId}) throw new AppError(Err.VALIDATION_ERROR, 'Change already claimed - melt data not available for this transaction', {mintUrl, transactionId})
} }
const {change}: MeltProofsResponse = yield cashuWallet.recoverMeltQuoteChange( const meltPreview = meltCounterValue.meltPreview
meltQuote,
{ if(!meltPreview) {
keysetId: cashuWallet.keysetId, throw new AppError(Err.VALIDATION_ERROR, 'MeltPreview not found - this transaction may be from an older version', {mintUrl, transactionId})
counter: counterValueForMelt }
}
) // Use completeMelt with the stored MeltPreview to recover change
const {change}: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview)
currentCounter.removeMeltCounterValue(transactionId) currentCounter.removeMeltCounterValue(transactionId)
log.info('[recoverMeltQuoteChange]', {change}) log.info('[recoverMeltQuoteChange]', {change})
return change return change
} catch (e: any) { } catch (e: any) {
let message = 'The mint could not return change from a melt quote.' let message = 'The mint could not return change from a melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError( throw new AppError(
Err.MINT_ERROR, Err.MINT_ERROR,
message, message,
{ {
message: e.message, message: e.message,
caller: 'recoverMeltQuoteChange', caller: 'recoverMeltQuoteChange',
mintUrl, mintUrl,
} }
) )
} }
@@ -1094,4 +1149,4 @@ export const WalletStoreModel = types
export interface WalletStore extends Instance<typeof WalletStoreModel> {} export interface WalletStore extends Instance<typeof WalletStoreModel> {}
export interface WalletStoreSnapshot export interface WalletStoreSnapshot
extends SnapshotOut<typeof WalletStoreModel> {} extends SnapshotOut<typeof WalletStoreModel> {}