Reimplement v3 counters

This commit is contained in:
minibits-cash
2026-01-10 00:39:30 +01:00
parent 658e62cd75
commit 9b606741b3
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 {withSetPropAction} from './helpers/withSetPropAction'
import {
type GetInfoResponse,
type MintKeys as CashuMintKeys,
type MintKeyset as CashuMintKeyset,
Mint as CashuMint,
import {
type GetInfoResponse,
type MintKeys as CashuMintKeys,
type MintKeyset as CashuMintKeyset,
Mint as CashuMint,
type MeltPreview,
} from '@cashu/cashu-ts'
import {colors, getRandomIconColor} from '../theme'
import { log } from '../services'
@@ -16,7 +17,6 @@ import { generateId } from '../utils/utils'
import { Proof } from './Proof'
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
ß
export type MintBalance = {
mintUrl: string
balances: {
@@ -49,10 +49,11 @@ const InFlightRequestModel = types.model('InFlightRequest', {
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', {
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
})
@@ -140,7 +141,7 @@ export const MintProofsCounterModel = types
},
// === Melt counter tracking ===
addMeltCounterValue(transactionId: number): number {
addMeltCounterValue(transactionId: number, meltPreview?: MeltPreview): number {
const key = transactionId.toString()
if (self.meltCounterValues.has(key)) {
log.warn('[addMeltCounterValue]', 'Melt already tracked', { transactionId })
@@ -150,12 +151,14 @@ export const MintProofsCounterModel = types
self.meltCounterValues.set(key, {
transactionId,
counterAtMelt: self.counter,
meltPreview,
createdAt: new Date(),
})
log.trace('[addMeltCounterValue]', {
transactionId,
counterAtMelt: self.counter,
hasMeltPreview: !!meltPreview,
})
return self.counter
+163 -108
View File
@@ -14,6 +14,7 @@ import {
MintQuoteBolt11Response,
type ProofState,
type OperationCounters,
type MeltPreview,
} from '@cashu/cashu-ts'
import { JS_BUNDLE_VERSION } from '@env'
import {KeyChain, MinibitsClient, WalletKeys} from '../services'
@@ -411,63 +412,86 @@ export const WalletStoreModel = types
)
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
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,
counter: currentCounter.counter,
privkey: isLockedToWallet ? walletKeys.NOSTR.privateKey : undefined,
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
{
...receiveParams.options,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[receive] Counters reserved', info)
}
}
)
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 amountToReceive: number = CashuUtils.getProofsAmount(decodedToken.proofs)
const swapFeePaid = amountToReceive - receivedAmount
return {
proofs,
proofs,
swapFeePaid
} as {
proofs: CashuProof[],
proofs: CashuProof[],
swapFeePaid: number
}
@@ -516,50 +540,74 @@ export const WalletStoreModel = types
)
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,
amount: amountToSend,
proofs: CashuUtils.exportProofs(proofsToSendFrom),
options: {
keysetId: cashuWallet.keysetId,
counter: p2pk && p2pk.pubkey ? undefined : currentCounter.counter, // p2pk is not deterministic
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.swap(
sendParams.amount,
sendParams.proofs,
sendParams.options
{
...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.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] ${send.length} proofsToSend`, {send})
log.trace(`[WalletStore.send] ${send.length} proofsToSend`, {send})
const totalAmountToSendFrom: number = CashuUtils.getProofsAmount(proofsToSendFrom)
const returnedAmount: number = CashuUtils.getProofsAmount(keep)
const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount
const returnedAmount: number = CashuUtils.getProofsAmount(keep)
const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount
return {
returnedProofs: keep as CashuProof[],
proofsToSend: send as CashuProof[],
proofsToSend: send as CashuProof[],
swapFeePaid
}
@@ -745,11 +793,12 @@ export const WalletStoreModel = types
}
}
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, mintParams)
}
let reservedCounters: OperationCounters | undefined = undefined
let reservedCounters: OperationCounters | undefined
try {
@@ -758,8 +807,8 @@ export const WalletStoreModel = types
mintParams.quote,
{
keysetId: mintParams.options?.keysetId,
onCountersReserved: (info) => {
reservedCounters = info as OperationCounters
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[mintProofsBolt11] Counters reserved', info)
}
}
@@ -864,66 +913,71 @@ export const WalletStoreModel = types
currentCounter.increaseProofsCounter(options.increaseCounterBy)
}
// store local counter value to be able to recover the change if needed
const counterValueForMelt = currentCounter.addMeltCounterValue(transactionId)
// Sync wallet's internal counter with our stored counter (v3.x)
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
// Preemptively increase local counter in case we miss response with change from mint
currentCounter.increaseProofsCounter(proofsToMeltFrom.length) // ?
let countChangeOutputs = 0
if (meltQuote.fee_reserve > 0) {
countChangeOutputs = Math.ceil(Math.log2(meltQuote.fee_reserve)) || 1
currentCounter.increaseProofsCounter(countChangeOutputs)
}
log.trace('[WalletStore.payLightningMelt] Counter state before melt', {localCounter: currentCounter.counter, counterValueForMelt})
const meltParams: MeltParams = options?.inFlightRequest?.request || {
meltQuote,
proofsToSend: CashuUtils.exportProofs(proofsToMeltFrom),
options: {
keysetId: cashuWallet.keysetId,
counter: counterValueForMelt
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)
}
}
}
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
currentCounter.addInFlightRequest(transactionId, meltParams)
}
)
// Store the MeltPreview for potential recovery
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 {
// Step 2: Complete the melt (sends to mint and constructs change proofs)
const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview)
const meltResponse = yield cashuWallet.meltProofsBolt11(
meltParams.meltQuote,
meltParams.proofsToSend,
meltParams.options
)
// Remove the stored preview on success
currentCounter.removeMeltCounterValue(transactionId)
currentCounter.removeInFlightRequest(transactionId)
log.trace('[payLightningMelt]', {meltResponse})
// we normalize naming of returned parameters
return meltResponse as MeltProofsResponse
return meltResponse
} 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)
// we might roll back preemptive counter increase
// currentCounter.decreaseProofsCounter(countChangeOutputs)
}
// On error, keep the MeltPreview stored so we can attempt recovery later
log.error('[payLightningMelt] Melt failed, MeltPreview stored for recovery', {
transactionId,
error: e.message
})
let message = 'Lightning payment failed.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'payLightningMelt',
caller: 'payLightningMelt',
mintUrl,
code: e.code || undefined,
code: e.code || undefined,
}
)
}
@@ -957,56 +1011,57 @@ export const WalletStoreModel = types
}
}),
recoverMeltQuoteChange: flow(function* recoverMeltQuoteChange(
mintUrl: string,
mintUrl: string,
meltQuote: MeltQuoteBolt11Response,
transactionId: number
transactionId: number
) {
try {
const mintInstance = self.getMintModelInstance(mintUrl)
if(!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl})
}
const cashuWallet = yield self.getWallet(
mintUrl,
'sat',
mintUrl,
'sat',
{
withSeed: true,
withSeed: true,
}
)
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
const counterValueForMelt = currentCounter.getMeltCounterValue(transactionId)?.counterAtMelt
const meltCounterValue = currentCounter.getMeltCounterValue(transactionId)
if(counterValueForMelt === undefined) {
throw new AppError(Err.VALIDATION_ERROR, 'Change already claimed - counterAtMelt not available for this transaction', {mintUrl, transactionId})
if(!meltCounterValue) {
throw new AppError(Err.VALIDATION_ERROR, 'Change already claimed - melt data not available for this transaction', {mintUrl, transactionId})
}
const {change}: MeltProofsResponse = yield cashuWallet.recoverMeltQuoteChange(
meltQuote,
{
keysetId: cashuWallet.keysetId,
counter: counterValueForMelt
}
)
const meltPreview = meltCounterValue.meltPreview
if(!meltPreview) {
throw new AppError(Err.VALIDATION_ERROR, 'MeltPreview not found - this transaction may be from an older version', {mintUrl, transactionId})
}
// Use completeMelt with the stored MeltPreview to recover change
const {change}: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview)
currentCounter.removeMeltCounterValue(transactionId)
log.info('[recoverMeltQuoteChange]', {change})
return change
} catch (e: any) {
let message = 'The mint could not return change from a melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'recoverMeltQuoteChange',
mintUrl,
caller: 'recoverMeltQuoteChange',
mintUrl,
}
)
}
@@ -1094,4 +1149,4 @@ export const WalletStoreModel = types
export interface WalletStore extends Instance<typeof WalletStoreModel> {}
export interface WalletStoreSnapshot
extends SnapshotOut<typeof WalletStoreModel> {}
extends SnapshotOut<typeof WalletStoreModel> {}