From 7a749816a412224c43598feaadff0ba735d8abdd Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 22 Dec 2023 22:34:30 +0100 Subject: [PATCH] Recover send and receive from intermittent fails --- android/app/build.gradle | 2 +- package.json | 2 +- src/models/Mint.ts | 81 +++++-- src/models/MintsStore.ts | 24 +- src/models/ProofsStore.ts | 4 +- src/models/RelaysStore.ts | 4 +- src/models/TransactionsStore.ts | 4 +- src/navigation/TabsNavigator.tsx | 2 +- src/screens/ReceiveOptionsScreen.tsx | 2 +- src/screens/RemoteRecoveryScreen.tsx | 2 +- src/screens/ScanScreen.tsx | 20 +- src/screens/SendOptionsScreen.tsx | 2 +- src/screens/WalletScreen.tsx | 8 +- src/services/cashu/cashuUtils.ts | 9 +- src/services/cashuMintClient.ts | 18 +- src/services/walletService.ts | 331 ++++++++++++++++++++++----- 16 files changed, 381 insertions(+), 134 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 93b79f8f..125ba5e0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -102,7 +102,7 @@ android { applicationId "com.minibits_wallet" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 31 + versionCode 32 versionName "0.1.5" ndk { abiFilters 'arm64-v8a', 'x86_64', 'x86', 'armeabi-v7a' diff --git a/package.json b/package.json index 7df11b61..038626bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.1.5-beta.18", + "version": "0.1.5-beta.19", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", diff --git a/src/models/Mint.ts b/src/models/Mint.ts index e69a1b91..5c73b3e2 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -19,6 +19,9 @@ export enum MintStatus { export type MintProofsCounter = { keyset: string counter: number + inFlightFrom?: number // starting counter index for pending split request sent to mint (for recovery from failure to receive proofs) + inFlightTo?: number // last counter index for pending split request sent to mint + inFlightTid?: number // related tx id } /** @@ -35,17 +38,35 @@ export const MintModel = types types.model('MintProofsCounter', { keyset: types.string, counter: types.number, + inFlightFrom: types.maybe(types.number), + inFlightTo: types.maybe(types.number), + inFlightTid: types.maybe(types.number) }) ), color: types.optional(types.string, colors.palette.iconBlue200), status: types.optional(types.frozen(), MintStatus.ONLINE), createdAt: types.optional(types.Date, new Date()), }) - .actions(withSetPropAction) - .views(self => ({ - get currentProofsCounter() { + .actions(withSetPropAction) // TODO start to use across app to avoid pure setter methods, e.g. mint.setProp('color', '#ccc') + .actions(self => ({ + getOrCreateProofsCounter() { const currentKeyset = deriveKeysetId(self.keys) - return self.proofsCounters.find(c => c.keyset === currentKeyset) + const currentCounter = self.proofsCounters.find(c => c.keyset === currentKeyset) + + if(!currentCounter) { + const newCounter = { + keyset: currentKeyset, + counter: 0, + } + + self.proofsCounters.push(newCounter) + const instance = self.proofsCounters.find(c => c.keyset === currentKeyset) as MintProofsCounter + + log.trace('[getOrCreateProofsCounter] new', {newCounter: instance}) + return instance + } + + return currentCounter }, })) .actions(self => ({ @@ -85,29 +106,49 @@ export const MintModel = types self.keys = keys self.keysets = cast(self.keysets) }, - increaseProofsCounter(numberOfProofs: number) { - const currentCounter = self.currentProofsCounter + setProofsInFLightFrom(inFlightFrom: number) { + const currentCounter = self.getOrCreateProofsCounter() + currentCounter.inFlightFrom = inFlightFrom - if (currentCounter) { - log.trace('[increaseProofsCounter]', 'Before update', {currentCounter}) - currentCounter.counter += numberOfProofs - log.trace('[increaseProofsCounter]', 'Updated proofsCounter', {numberOfProofs, currentCounter}) - } else { - // If the counter doesn't exist, create a new one - const currentKeyset = deriveKeysetId(self.keys) + self.proofsCounters = cast(self.proofsCounters) + }, + setProofsInFlightTo(inFlightTo: number) { + const currentCounter = self.getOrCreateProofsCounter() + currentCounter.inFlightTo = inFlightTo - const newCounter = { - keyset: currentKeyset, - counter: numberOfProofs, - } + self.proofsCounters = cast(self.proofsCounters) + }, + setInFlightTid(inFlightTid: number) { + const currentCounter = self.getOrCreateProofsCounter() + currentCounter.inFlightTid = inFlightTid - self.proofsCounters.push(newCounter) + self.proofsCounters = cast(self.proofsCounters) + }, + resetInFlight() { + const currentCounter = self.getOrCreateProofsCounter() + currentCounter.inFlightFrom = undefined + currentCounter.inFlightTo = undefined + currentCounter.inFlightTid = undefined + + log.trace('[resetInFlight]', 'Reset proofsCounter') + self.proofsCounters = cast(self.proofsCounters) + }, + increaseProofsCounter(numberOfProofs: number) { + const currentCounter = self.getOrCreateProofsCounter() + currentCounter.counter += numberOfProofs + log.trace('[increaseProofsCounter]', 'Increased proofsCounter', {numberOfProofs, currentCounter}) - log.trace('[increaseProofsCounter]', 'Adding new proofsCounter', {newCounter}) - } // Make sure to cast the frozen array back to a mutable array self.proofsCounters = cast(self.proofsCounters) }, + decreaseProofsCounter(numberOfProofs: number) { + const currentCounter = self.getOrCreateProofsCounter() + currentCounter.counter -= numberOfProofs + Math.max(0, currentCounter.counter) + log.trace('[decreaseProofsCounter]', 'Decreased proofsCounter', {numberOfProofs, currentCounter}) + + self.proofsCounters = cast(self.proofsCounters) + }, })) diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 660b368a..3e526dc9 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -76,19 +76,7 @@ export const MintsStoreModel = types unblockMint(blockedMint: Mint) { self.blockedMintUrls.remove(blockedMint.mintUrl) log.debug('[unblockMint]', 'Mint unblocked in MintsStore') - }, - increaseProofsCounter(mintUrl: string, numberOfProofs: number) { - const mintInstance = self.findByUrl(mintUrl) - - if(mintInstance) { - mintInstance.increaseProofsCounter(numberOfProofs) - return mintInstance.currentProofsCounter - } - - log.warn('[increaseProofsCounter]', 'Could not find mint', {mintUrl}) - return 0 - - }, + } })) .views(self => ({ get mintCount() { @@ -135,16 +123,6 @@ export const MintsStoreModel = types } return missingMints }, - currentProofsCounterValue(mintUrl: string) { - const mintInstance = self.findByUrl(mintUrl) - - if (mintInstance) { - return mintInstance.currentProofsCounter?.counter || 0 - } - - log.warn('[currentProofsCounter]', 'Could not find mint', {mintUrl}) - return 0 - }, })) export interface MintsStore extends Instance {} diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index ff7661ff..c78cf6ce 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -87,7 +87,9 @@ export const ProofsStoreModel = types // Handle counter increment const mintsStore = getRootStore(self).mintsStore - mintsStore.increaseProofsCounter(newProofs[0].mintUrl as string, addedProofs.length) + const mintInstance = mintsStore.findByUrl(newProofs[0].mintUrl as string) + + mintInstance?.increaseProofsCounter(addedProofs.length) log.debug('[addProofs]', `Added new ${addedProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`,) diff --git a/src/models/RelaysStore.ts b/src/models/RelaysStore.ts index 2d5ea1b5..6c5c8d41 100644 --- a/src/models/RelaysStore.ts +++ b/src/models/RelaysStore.ts @@ -46,11 +46,11 @@ export const RelaysStoreModel = types relayInstance?.setError(error) } - log.trace('[addOrUpdateRelay]', 'Relay updated in the RelaysStore', {relay}) + // log.trace('[addOrUpdateRelay]', 'Relay updated in the RelaysStore', {relay}) } else { const normalized = NostrClient.getNormalizedRelayUrl(relay.url) - log.trace('[addOrUpdateRelay]', 'Normalized URL', normalized) + // log.trace('[addOrUpdateRelay]', 'Normalized URL', normalized) relay.url = normalized const relayInstance = RelayModel.create(relay) diff --git a/src/models/TransactionsStore.ts b/src/models/TransactionsStore.ts index 922f60b3..9b585b1b 100644 --- a/src/models/TransactionsStore.ts +++ b/src/models/TransactionsStore.ts @@ -98,7 +98,7 @@ export const TransactionsStoreModel = types log.debug('[addTransactionsToModel]', `${inStoreTransactions.length} new transactions added to TransactionsStore`, ) }, - updateStatus: flow(function* updateStatus( + updateStatus: flow(function* updateStatus( // TODO append, not replace status to align behavior with updateStatuses id: number, status: TransactionStatus, data: string, @@ -110,7 +110,7 @@ export const TransactionsStoreModel = types // Update in the model if (transactionInstance) { transactionInstance.status = status - transactionInstance.data = data + transactionInstance.data = data log.debug('[updateStatus]', 'Transaction status and data updated in TransactionsStore', {id, status}) } diff --git a/src/navigation/TabsNavigator.tsx b/src/navigation/TabsNavigator.tsx index ddaec61e..415848b4 100644 --- a/src/navigation/TabsNavigator.tsx +++ b/src/navigation/TabsNavigator.tsx @@ -121,7 +121,7 @@ export type WalletStackParamList = { Receive: {encodedToken? : string} SendOptions: undefined Send: {contact?: Contact, relays?: string[], paymentOption?: SendOption} - Scan: {expectedType?: IncomingDataType} + Scan: undefined TranDetail: {id: number} TranHistory: undefined PaymentRequests: undefined diff --git a/src/screens/ReceiveOptionsScreen.tsx b/src/screens/ReceiveOptionsScreen.tsx index 60d7f8ab..b5cecdf7 100644 --- a/src/screens/ReceiveOptionsScreen.tsx +++ b/src/screens/ReceiveOptionsScreen.tsx @@ -55,7 +55,7 @@ export const ReceiveOptionsScreen: FC> const onScan = async function () { - navigation.navigate('Scan', {}) + navigation.navigate('Scan') } diff --git a/src/screens/RemoteRecoveryScreen.tsx b/src/screens/RemoteRecoveryScreen.tsx index 5b851a75..ce285072 100644 --- a/src/screens/RemoteRecoveryScreen.tsx +++ b/src/screens/RemoteRecoveryScreen.tsx @@ -195,7 +195,7 @@ export const RemoteRecoveryScreen: FC> = o } // need to move counter by whole interval to avoid duplicate _B!!! - mintsStore.increaseProofsCounter(mint.mintUrl, Math.abs(endIndex - startIndex)) + mint.increaseProofsCounter(Math.abs(endIndex - startIndex)) if(newKeys) {updateMintKeys(mint.mintUrl as string, newKeys)} diff --git a/src/screens/ScanScreen.tsx b/src/screens/ScanScreen.tsx index 40634287..b106c63e 100644 --- a/src/screens/ScanScreen.tsx +++ b/src/screens/ScanScreen.tsx @@ -25,7 +25,7 @@ const hasAndroidCameraPermission = async () => { export const ScanScreen: FC> = function ScanScreen(_props) { - const {navigation, route} = _props + const {navigation} = _props useHeader({ title: 'Scan QR code', titleStyle: {fontFamily: typography.primary?.medium}, @@ -33,8 +33,7 @@ export const ScanScreen: FC> = function ScanScree onLeftPress: () => navigation.goBack(), }) - const [shouldLoad, setShouldLoad] = useState(false) - const [expected, setExpected] = useState() + const [shouldLoad, setShouldLoad] = useState(false) const [isScanned, setIsScanned] = useState(false) const [error, setError] = useState() @@ -44,21 +43,6 @@ export const ScanScreen: FC> = function ScanScree })() }, []) - - useEffect(() => { - const setExpectedType = async () => { - const {expectedType} = route.params - - if(expectedType) { - log.trace('Got expectedType', expectedType) - setExpected(expectedType) - } - } - - setExpectedType() - }, [route.params?.expectedType]) - - const onReadCode = async function(event: any) { setIsScanned(true) const scanned = event.nativeEvent.codeStringValue diff --git a/src/screens/SendOptionsScreen.tsx b/src/screens/SendOptionsScreen.tsx index 99ab858c..50dadfa4 100644 --- a/src/screens/SendOptionsScreen.tsx +++ b/src/screens/SendOptionsScreen.tsx @@ -54,7 +54,7 @@ export const SendOptionsScreen: FC> = obse const onScan = async function () { - navigation.navigate('Scan', {expectedType: IncomingDataType.INVOICE}) + navigation.navigate('Scan') } diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index 3e977a89..b6e59384 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -213,7 +213,8 @@ export const WalletScreen: FC = observer( } Wallet.checkPendingSpent().catch(e => false) - Wallet.checkPendingTopups().catch(e => false) + Wallet.checkPendingTopups().catch(e => false) + Wallet.checkInFlight().catch(e => false) }, 100) }, []) @@ -245,7 +246,8 @@ export const WalletScreen: FC = observer( } Wallet.checkPendingSpent().catch(e => false) - Wallet.checkPendingTopups().catch(e => false) + Wallet.checkPendingTopups().catch(e => false) + Wallet.checkInFlight().catch(e => false) }, 100) } @@ -352,7 +354,7 @@ export const WalletScreen: FC = observer( } const gotoScan = function () { - navigation.navigate('Scan', {}) + navigation.navigate('Scan') } const gotoTranHistory = function () { diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index bbfaac06..af393cf5 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -1,6 +1,7 @@ import {Mint} from '../../models/Mint' import { - getDecodedToken, + AmountPreference, + getDecodedToken, } from '@cashu/cashu-ts' import cloneDeep from 'lodash.clonedeep' import AppError, {Err} from '../../utils/AppError' @@ -131,6 +132,11 @@ const getProofsAmount = function (proofs: Array): number { } +const getAmountPreferencesCount = function (amountPreferences: AmountPreference[]): number { + return amountPreferences.reduce((total, preference) => total + preference.count, 0); +} + + const getMintsFromToken = function (token: Token): string[] { const mints = token.token.map(item => item.mint) return Array.from(new Set(mints)) // make sure the mints are not duplicated @@ -266,6 +272,7 @@ export const CashuUtils = { getTokenAmounts, getTokenEntryAmount, getProofsAmount, + getAmountPreferencesCount, getMintsFromToken, updateMintProofs, getProofsFromTokenEntries, diff --git a/src/services/cashuMintClient.ts b/src/services/cashuMintClient.ts index 2f50f087..bcb91dee 100644 --- a/src/services/cashuMintClient.ts +++ b/src/services/cashuMintClient.ts @@ -1,8 +1,9 @@ import { - CashuMint, - CashuWallet, - deriveKeysetId, - PayLnInvoiceResponse, + AmountPreference, + CashuMint, + CashuWallet, + deriveKeysetId, + PayLnInvoiceResponse, type Proof as CashuProof, } from '@cashu/cashu-ts' import {rootStoreInstance} from '../models' @@ -187,6 +188,7 @@ const getMintKeys = async function (mintUrl: string) { const receiveFromMint = async function ( mintUrl: string, encodedToken: string, + amountPreferences: AmountPreference[], counter: number ) { try { @@ -197,7 +199,7 @@ const receiveFromMint = async function ( // this method returns quite a mess, we normalize naming of returned parameters const {token, tokensWithErrors, newKeys, errors} = await cashuWallet.receive( encodedToken, - undefined, + amountPreferences, counter ) @@ -223,21 +225,25 @@ const sendFromMint = async function ( mintUrl: string, amountToSend: number, proofsToSendFrom: Proof[], + amountPreferences: AmountPreference[], counter: number ) { try { const cashuWallet = await getWallet(mintUrl, true) // with seed + log.debug('[MintClient.sendFromMint] counter', counter) + const {returnChange, send, newKeys} = await cashuWallet.send( amountToSend, proofsToSendFrom, - undefined, + amountPreferences, counter ) log.debug('[MintClient.sendFromMint] returnedProofs', returnChange) log.debug('[MintClient.sendFromMint] sentProofs', send) log.debug('[MintClient.sendFromMint] newKeys', newKeys) + // do some basic validations that proof amounts from mints match const totalAmountToSendFrom = CashuUtils.getProofsAmount(proofsToSendFrom) diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 08782bf0..ace68adc 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -30,14 +30,22 @@ import { PaymentRequest, PaymentRequestStatus, PaymentRequestType } from '../mod import { IncomingDataType, IncomingParser } from './incomingParser' import { Contact, ContactType } from '../models/Contact' import { MinibitsClient } from './minibitsService' +import { getDefaultAmountPreference } from '@cashu/cashu-ts/src/utils' +import { KeyChain } from './keyChain' +import { delay } from '../utils/utils' type WalletService = { checkPendingSpent: () => Promise checkPendingReceived: () => Promise - checkSpent: () => Promise< - {spentCount: number; spentAmount: number} | undefined - > + checkSpent: () => Promise<{ + spentCount: number; + spentAmount: number + } | undefined> checkPendingTopups: () => Promise + checkInFlight: () => Promise<{ + recoveredCount: number, + recoveredAmount: number + } | undefined> transfer: ( mintBalanceToTransferFrom: MintBalance, amountToTransfer: number, @@ -105,7 +113,7 @@ const { /* * Checks with all mints whether their pending proofs have been spent. */ -async function checkPendingSpent() { +const checkPendingSpent = async function () { if (mintsStore.mintCount === 0) { return } @@ -368,13 +376,13 @@ const checkPendingReceived = async function () { } -function getTagValue(tagsArray: [string, string][], tagName: string): string | undefined { +const getTagValue = function (tagsArray: [string, string][], tagName: string): string | undefined { const tag = tagsArray.find(([name]) => name === tagName) return tag ? tag[1] : undefined } -function findMemo(message: string): string | undefined { +const findMemo = function (message: string): string | undefined { // Find the last occurrence of "memo: " const lastIndex = message.lastIndexOf("memo: ") @@ -440,7 +448,7 @@ const receiveFromNostrEvent = async function (encoded: string, event: NostrEvent /* * Recover stuck wallet if tx error caused spent proof to remain in wallet. */ -async function checkSpent() { +const checkSpent = async function () { if (mintsStore.mintCount === 0) { return } @@ -465,7 +473,7 @@ async function checkSpent() { * This situation occurs as a result of error during SEND or TRANSFER and causes failure of * subsequent transactions because mint returns "Tokens already spent" if any spent proof is used as an input. */ -async function _checkSpentByMint(mintUrl: string, isPending: boolean = false) { +const _checkSpentByMint = async function (mintUrl: string, isPending: boolean = false) { try { const proofsFromMint = proofsStore.getByMint(mintUrl, isPending) as Proof[] @@ -605,8 +613,7 @@ async function _checkSpentByMint(mintUrl: string, isPending: boolean = false) { log.trace('[_checkSpentByMint]', `No moved proofs from pending`, mintUrl) } - } - + } return { mintUrl, @@ -624,6 +631,116 @@ async function _checkSpentByMint(mintUrl: string, isPending: boolean = false) { } } + +/* + * Recover proofs that were issued by mint, but wallet failed to receive them if split did not complete. + */ +const checkInFlight = async function () { + if (mintsStore.mintCount === 0) { + return + } + + let recoveredCount: number = 0 + let recoveredAmount: number = 0 + const seed = await MintClient.getSeed() + + if(!seed) { + return + } + + for (const mint of mintsStore.allMints) { + try { + const result = await _checkInFlightByMint(mint, seed) + + if(result) { + log.info('[checkInFlight] result', {result}) + } + } catch (e) { + continue + } + } + + return {recoveredCount, recoveredAmount} +} + + +const _checkInFlightByMint = async function (mint: Mint, seed: Uint8Array) { + + const mintUrl = mint.mintUrl + const proofsCounter = mint.getOrCreateProofsCounter?.() + + if(!proofsCounter?.inFlightFrom || !proofsCounter?.inFlightTo) { + log.trace('[_checkInFlightByMint]', 'No inFlight proofs to restore', {mintUrl}) + return + } + + try { + log.info('[_checkInFlightByMint]', `Restoring from ${mint.hostname}...`) + + const { proofs, newKeys } = await MintClient.restore( + mint.mintUrl, + proofsCounter.inFlightFrom, + proofsCounter.inFlightTo, + seed as Uint8Array + ) + + const proofsAmount = CashuUtils.getProofsAmount(proofs as Proof[]) + log.debug('[_checkInFlightByMint]', `Restored proofs`, {count: proofs.length, proofsAmount}) + + if (proofs.length === 0) { + return + } + + if(newKeys) {_updateMintKeys(mint.mintUrl as string, newKeys)} + + const { addedAmount, addedProofs } = _addCashuProofs( + proofs, + mint.mintUrl, + proofsCounter.inFlightTid as number + ) + + + // Clean any spent proofs from spendable wallet + const spentResult = await _checkSpentByMint(mintUrl, false) // recovery mode, pending false + + const txRecoveryResult = { + mintUrl, + recoveredCount: addedProofs.length, + recoveredAmount: addedAmount, + spentCount: spentResult?.spentCount || 0, + spentAmount: spentResult?.spentAmount || 0 + } + + const transactionDataUpdate = { + status: TransactionStatus.ERROR, + txRecoveryResult, + message: 'This transaction failed to receive expected funds from the mint, but the wallet suceeded to recover them.', + createdAt: new Date(), + } + + transactionsStore.updateStatuses( + [proofsCounter.inFlightTid as number], + TransactionStatus.ERROR, // has been most likely DRAFT + JSON.stringify(transactionDataUpdate), + ) + + mint.resetInFlight?.() + + log.debug('[_checkInFlightByMint]', `Completed`, {txRecoveryResult}) + + return txRecoveryResult + + } catch (e: any) { + // silent + log.error('[_checkInFlightByMint]', e.name, {message: e.message, mintUrl}) + return { + mintUrl, + error: e, + } + } +} + + const receive = async function ( token: Token, amountToReceive: number, @@ -706,18 +823,47 @@ const receive = async function ( mintsStore.addMint(newMint) } - const proofsCounter = mintsStore.currentProofsCounterValue(mintToReceive) + const mintInstance = mintsStore.findByUrl(mintToReceive) + + if(!mintInstance) { + throw new AppError(Err.VALIDATION_ERROR, 'Missing mint', {mintToReceive}) + } + + const proofsCounter = mintInstance.getOrCreateProofsCounter() + log.trace('[receive]', 'initial proofsCounter', proofsCounter) + + mintInstance.setInFlightTid(transactionId as number) + mintInstance.setProofsInFLightFrom(proofsCounter.counter) + + // Increase the proofs counter before the mint call so that in case the response + // is not received our recovery index counts for sigs the mint has already issued (prevents duplicate b_b bug) + const amountPreferences = getDefaultAmountPreference(amountToReceive) + const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences) + + log.trace('[receive]', 'amountPreferences', amountPreferences) + log.trace('[receive]', 'countOfInFlightProofs', countOfInFlightProofs) + + mintInstance.setProofsInFlightTo(proofsCounter.counter + countOfInFlightProofs) + mintInstance.increaseProofsCounter(countOfInFlightProofs) + + log.trace('[receive]', 'inFlight proofsCounter', proofsCounter) // Now we ask all mints to get fresh outputs for their tokenEntries, and create from them new proofs // 0.8.0-rc3 implements multimints receive however CashuMint constructor still expects single mintUrl const {updatedToken, errorToken, newKeys, errors} = await MintClient.receiveFromMint( mintToReceive, encodedToken as string, - proofsCounter + amountPreferences, + proofsCounter.inFlightFrom as number // MUST be counter value before increase ) if(newKeys) {_updateMintKeys(mintToReceive, newKeys)} + // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs + // As well null inFlight indexes + mintInstance.decreaseProofsCounter(countOfInFlightProofs) + mintInstance.resetInFlight() + // Update transaction status transactionData.push({ status: TransactionStatus.PREPARED, @@ -1002,16 +1148,47 @@ const receiveOfflineComplete = async function ( mintsStore.addMint(newMint) } - const proofsCounter = mintsStore.currentProofsCounterValue(mintToReceive) + const mintInstance = mintsStore.findByUrl(mintToReceive) + + if(!mintInstance) { + throw new AppError(Err.VALIDATION_ERROR, 'Missing mint', {mintToReceive}) + } + + const proofsCounter = mintInstance.getOrCreateProofsCounter() + + log.trace('[receiveOfflineComplete]', 'initial proofsCounter', proofsCounter) + + mintInstance.setInFlightTid(transaction.id as number) + mintInstance.setProofsInFLightFrom(proofsCounter.counter) + + // Increase the proofs counter before the mint call so that in case the response + // is not received our recovery index counts for sigs the mint has already issued (prevents duplicate b_b bug) + const amountPreferences = getDefaultAmountPreference(transaction.amount) + const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences) + + log.trace('[receiveOfflineComplete]', 'amountPreferences', amountPreferences) + log.trace('[receiveOfflineComplete]', 'countOfInFlightProofs', countOfInFlightProofs) + + mintInstance.setProofsInFlightTo(proofsCounter.counter + countOfInFlightProofs) + mintInstance.increaseProofsCounter(countOfInFlightProofs) + + log.trace('[receiveOfflineComplete]', 'inFlight proofsCounter', proofsCounter) // Now we ask all mints to get fresh outputs for their tokenEntries, and create from them new proofs // 0.8.0-rc3 implements multimints receive however CashuMint constructor still expects single mintUrl const {updatedToken, errorToken, newKeys, errors} = await MintClient.receiveFromMint( tokenMints[0], encodedToken as string, - proofsCounter + amountPreferences, + proofsCounter.inFlightFrom as number // MUST be counter value before increase ) + if (newKeys) {_updateMintKeys(mintInstance.mintUrl, newKeys)} + // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs + // As well null inFlight indexes + mintInstance.decreaseProofsCounter(countOfInFlightProofs) + mintInstance.resetInFlight() + let amountWithErrors = 0 if (errorToken && errorToken.token.length > 0) { @@ -1025,8 +1202,8 @@ const receiveOfflineComplete = async function ( 'Ecash could not be redeemed.', {caller: 'receiveOfflineComplete', message: errors?.length ? errors[0]?.message : undefined} ) - } - + } + let receivedAmount = 0 for (const entry of updatedToken.token) { @@ -1039,7 +1216,7 @@ const receiveOfflineComplete = async function ( receivedAmount += addedAmount } - + // const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount log.debug('[receiveOfflineComplete]', 'Received amount', receivedAmount) @@ -1114,8 +1291,16 @@ const _sendFromMint = async function ( transactionId: number, ) { const mintUrl = mintBalance.mint + const mintInstance = mintsStore.findByUrl(mintUrl) try { + if (!mintInstance) { + throw new AppError( + Err.VALIDATION_ERROR, + 'Could not find mint', {mintUrl} + ) + } + const proofsFromMint = proofsStore.getByMint(mintUrl) as Proof[] log.debug('[_sendFromMint]', 'proofsFromMint count', proofsFromMint.length) @@ -1137,16 +1322,17 @@ const _sendFromMint = async function ( ) } + /* + * OFFLINE SEND + * if we have selected ecash to send in offline mode, we do not interact with the mint + */ + const selectedProofsAmount = CashuUtils.getProofsAmount(selectedProofs) if(selectedProofsAmount > 0 && (amountToSend !== selectedProofsAmount)) { // failsafe for some unknown ecash selection UX error throw new AppError(Err.VALIDATION_ERROR, 'Requested amount to send does not equal sum of ecash denominations provided.') } - /* - * if we have selected ecash to send in offline mode, we do not interact with the mint - */ - if(selectedProofsAmount > 0) { for (const proof of selectedProofs) { proof.setTransactionId(transactionId) // update txId @@ -1168,31 +1354,58 @@ const _sendFromMint = async function ( /* - * if we do not have selected ecash and we might need a split of ecash by the mint to match exact amount + * if we did not selected ecash but amount and we might need a split of ecash by the mint to match exact amount */ const proofsToSendFrom = proofsStore.getProofsToSend( amountToSend, proofsFromMint, ) + + const proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) + const proofsCounter = mintInstance.getOrCreateProofsCounter() - log.debug('[_sendFromMint]', 'proofsToSendFrom count', proofsToSendFrom.length) + mintInstance.setInFlightTid(transactionId) + mintInstance.setProofsInFLightFrom(proofsCounter.counter) + + log.debug('[_sendFromMint]', 'proofsToSendFrom count', {proofsToSendFromCount: proofsToSendFrom.length}) - const proofsCounter = mintsStore.currentProofsCounterValue(mintUrl) + // Increase the proofs counter before the mint call so that in case the response + // is not received our recovery index counts for sigs the mint has already issued (prevents duplicate b_b bug) + const amountPreferences = getDefaultAmountPreference(amountToSend) + const returnedAmountPreferences = getDefaultAmountPreference(proofsToSendFromAmount - amountToSend) + const countOfProofsToSend = CashuUtils.getAmountPreferencesCount(amountPreferences) + const countOfReturnedProofs = CashuUtils.getAmountPreferencesCount(returnedAmountPreferences) + const countOfInFlightProofs = countOfProofsToSend + countOfReturnedProofs + + log.trace('[_sendFromMint]', 'amountPreferences', {amountPreferences, returnedAmountPreferences}) + log.trace('[_sendFromMint]', 'countOfInFlightProofs', countOfInFlightProofs) + + mintInstance.setProofsInFlightTo(proofsCounter.counter + countOfInFlightProofs) + mintInstance.increaseProofsCounter(countOfInFlightProofs) + + log.trace('[_sendFromMint]', 'inFlight proofsCounter', proofsCounter) + // if split to required denominations was necessary, this gets it done with the mint and we get the return const {returnedProofs, proofsToSend, newKeys} = await MintClient.sendFromMint( mintUrl, amountToSend, proofsToSendFrom, - proofsCounter + amountPreferences, + proofsCounter.inFlightFrom as number // MUST be counter value before increase ) - + // log.debug('[_sendFromMint]', 'returnedProofs', returnedProofs) // log.debug('[_sendFromMint]', 'proofsToSend', proofsToSend) if (newKeys) {_updateMintKeys(mintUrl, newKeys)} + // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs + // As well null inFlight indexes + mintInstance.decreaseProofsCounter(countOfInFlightProofs) + mintInstance.resetInFlight() + // add proofs returned by the mint after the split if (returnedProofs.length > 0) { const { addedProofs, addedAmount } = _addCashuProofs( @@ -1222,7 +1435,7 @@ const _sendFromMint = async function ( const {mintUrl, tId, ...rest} = proof as Proof return rest } - }) + }) // We return cleaned proofs to be encoded as a sendable token return cleanedProofsToSend @@ -1230,7 +1443,7 @@ const _sendFromMint = async function ( if (e instanceof AppError) { throw e } else { - throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 100)) + throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 200)) } } } @@ -1355,7 +1568,7 @@ const send = async function ( } as TransactionResult } catch (e: any) { // Update transaction status if we have any - let errorTransaction: TransactionRecord | undefined = undefined + let errorTransaction: TransactionRecord | undefined = undefined if (transactionId > 0) { transactionData.push({ @@ -1388,18 +1601,11 @@ const transfer = async function ( encodedInvoice: string, ) { const mintUrl = mintBalanceToTransferFrom.mint + const mintInstance = mintsStore.findByUrl(mintUrl) log.debug('[transfer]', 'mintBalanceToTransferFrom', mintBalanceToTransferFrom) log.debug('[transfer]', 'amountToTransfer', amountToTransfer) log.debug('[transfer]', 'estimatedFee', estimatedFee) - - if (amountToTransfer + estimatedFee > mintBalanceToTransferFrom.balance) { - throw new AppError(Err.VALIDATION_ERROR, 'Mint balance is insufficient to cover the amount to transfer with expected Lightning fees.') - } - - if(isBefore(invoiceExpiry, new Date())) { - throw new AppError(Err.VALIDATION_ERROR, 'This invoice has already expired and can not be paid.', {invoiceExpiry}) - } // create draft transaction const transactionData: TransactionData[] = [ @@ -1418,15 +1624,30 @@ const transfer = async function ( let proofsToPay: CashuProof[] = [] try { - const newTransaction: Transaction = { - type: TransactionType.TRANSFER, - amount: amountToTransfer, - fee: estimatedFee, - data: JSON.stringify(transactionData), - memo, - mint: mintBalanceToTransferFrom.mint, - status: TransactionStatus.DRAFT, - } + if (amountToTransfer + estimatedFee > mintBalanceToTransferFrom.balance) { + throw new AppError(Err.VALIDATION_ERROR, 'Mint balance is insufficient to cover the amount to transfer with expected Lightning fees.') + } + + if(isBefore(invoiceExpiry, new Date())) { + throw new AppError(Err.VALIDATION_ERROR, 'This invoice has already expired and can not be paid.', {invoiceExpiry}) + } + + if (!mintInstance) { + throw new AppError( + Err.VALIDATION_ERROR, + 'Could not find mint', {mintUrl} + ) + } + + const newTransaction: Transaction = { + type: TransactionType.TRANSFER, + amount: amountToTransfer, + fee: estimatedFee, + data: JSON.stringify(transactionData), + memo, + mint: mintBalanceToTransferFrom.mint, + status: TransactionStatus.DRAFT, + } // store tx in db and in the model const storedTransaction: TransactionRecord = @@ -1457,7 +1678,7 @@ const transfer = async function ( JSON.stringify(transactionData), ) - const proofsCounter = mintsStore.currentProofsCounterValue(mintUrl as string) + const proofsCounter = mintInstance.getOrCreateProofsCounter() // Use prepared proofs to settle with the mint the payment of the invoice on wallet behalf const {feeSavedProofs, isPaid, preimage, newKeys} = @@ -1466,7 +1687,7 @@ const transfer = async function ( encodedInvoice, proofsToPay, estimatedFee, - proofsCounter + proofsCounter.counter ) if (newKeys) {_updateMintKeys(mintUrl, newKeys)} @@ -1870,14 +2091,19 @@ const checkPendingTopups = async function () { try { for (const pr of paymentRequests) { // claim tokens if invoice is paid - - const proofsCounter = mintsStore.currentProofsCounterValue(pr.mint as string) + const mintInstance = mintsStore.findByUrl(pr.mint as string) + + if(!mintInstance) { + throw new AppError(Err.VALIDATION_ERROR, 'Missing mint', {mintUrl: pr.mint}) + } + + const proofsCounter = mintInstance.getOrCreateProofsCounter() const {proofs, newKeys} = (await MintClient.requestProofs( pr.mint as string, pr.amount, pr.paymentHash, - proofsCounter + proofsCounter.counter )) as {proofs: Proof[], newKeys: MintKeys} if (!proofs || proofs.length === 0) { @@ -1976,7 +2202,7 @@ const _updateMintKeys = function (mintUrl: string, newKeys: MintKeys) { const _formatError = function (e: AppError) { return { name: e.name, - message: e.message.slice(0, 200), + message: e.message.slice(0, 300), params: e.params || {}, } as AppError } @@ -1986,6 +2212,7 @@ export const Wallet: WalletService = { checkPendingReceived, checkSpent, checkPendingTopups, + checkInFlight, transfer, receive, receiveOfflinePrepare,