diff --git a/package.json b/package.json index 0126313b..9acf0aa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.1.9-beta.34", + "version": "0.1.9-beta.35", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", @@ -19,7 +19,7 @@ "postinstall": "patch-package --exclude 'nothing'" }, "dependencies": { - "@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=1a60b7859627829dc29293d78a945095c335de63", + "@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57", "@exodus/borc": "^2.1.1", "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-brands-svg-icons": "^6.6.0", diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 59b0c7a5..86d87602 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -379,7 +379,7 @@ export const MintModel = types // Calculate the fees const feeReserve = Math.max(Math.floor((totalInputFees + 999) / 1000), 0) - log.debug('[getMintFeeReserve]', {feeReserve}) + log.trace('[getMintFeeReserve]', {feeReserve}) return feeReserve } })) diff --git a/src/models/NwcStore.ts b/src/models/NwcStore.ts index 49ffa7c0..ac86bd65 100644 --- a/src/models/NwcStore.ts +++ b/src/models/NwcStore.ts @@ -34,6 +34,8 @@ import { roundUp } from '../utils/number' import { PaymentRequest } from './PaymentRequest' import { PaymentRequests } from './PaymentRequestsStore' import { transaction } from 'mobx' +import { MINIBITS_MINT_URL } from '@env' +import { MintBalance } from './Mint' type NwcError = { result_type: string, @@ -75,6 +77,9 @@ const getConnectionRelays = function () { return minibitsRelays } +const MIN_LIGHTNING_FEE = 2 // sats +const LIGHTNING_FEE_PERCENT = 1 + const getSupportedMethods = function () { return [ 'pay_invoice', @@ -209,7 +214,7 @@ export const NwcConnectionModel = types.model('NwcConnection', { log.debug('[handleTransferTaskResult] Got transfer task result', { connection: self.name, meltQuote: result.meltQuote?.quote, - txId: result.transaction?.id + txId: result.transaction?.id, }) if(result.meltQuote?.quote === self.lastMeltQuoteId) { @@ -453,12 +458,18 @@ export const NwcConnectionModel = types.model('NwcConnection', { } as NwcResponse }, handlePayInvoice: flow(function* handlePayInvoice(nwcRequest: NwcRequest, requestEvent: NostrEvent) { - log.trace('[Nwc.handlePayInvoice] start') + log.debug('[Nwc.handlePayInvoice] start') try { const encoded = nwcRequest.params.invoice const walletStore = self.getWalletStore() - const proofsStore = self.getProofsStore() + const proofsStore = self.getProofsStore() + + // reset daily limit if day changed while keeping live connection + if(!isSameDay(self.currentDay, new Date())) { + self.setRemainingDailyLimit(self.dailyLimit) + self.setCurrentDay() + } const invoice = LightningUtils.decodeInvoice(encoded) @@ -470,34 +481,25 @@ export const NwcConnectionModel = types.model('NwcConnection', { } = LightningUtils.getInvoiceData(invoice) const invoiceExpiry = addSeconds(new Date(timestamp as number * 1000), expiry as number) + + // Calculated on device to avoid mintQuote call for minibits mint + const feeReserve = Math.max(MIN_LIGHTNING_FEE, amountToPay * LIGHTNING_FEE_PERCENT / 100) + const totalAmountToPay = amountToPay + feeReserve - const mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat') + let mintBalance: MintBalance | undefined = undefined + let isMinibitsMintSelected: boolean = false + const minibitsBalance = proofsStore.getMintBalance(MINIBITS_MINT_URL) + + if(minibitsBalance && minibitsBalance.balances.sat! >= totalAmountToPay) { + mintBalance = minibitsBalance + isMinibitsMintSelected = true + } else { + mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat') + } + const availableBalanceSat = mintBalance?.balances.sat || 0 - if(!mintBalance || availableBalanceSat < amountToPay) { // decoded amount is in sat - const message = `Insufficient balance to pay this invoice` - return { - result_type: nwcRequest.method, - error: { code: 'INSUFFICIENT_BALANCE', message} - } as NwcError - } - - // melt quote - const meltQuote: MeltQuoteResponse = yield walletStore.createLightningMeltQuote( - mintBalance.mintUrl, - 'sat', - encoded, - ) - - const totalAmountToPay = meltQuote.amount + meltQuote.fee_reserve - - // reset daily limit if day changed while keeping live connection - if(!isSameDay(self.currentDay, new Date())) { - self.setRemainingDailyLimit(self.dailyLimit) - self.setCurrentDay() - } - - if(availableBalanceSat < totalAmountToPay) { + if(!mintBalance || availableBalanceSat < totalAmountToPay) { const message = `Insufficient balance to pay this invoice.` return { result_type: nwcRequest.method, @@ -512,18 +514,38 @@ export const NwcConnectionModel = types.model('NwcConnection', { error: { code: 'QUOTA_EXCEEDED', message} } as NwcError } - - // Jachyme, hod ho do stroje! - WalletTask.transfer( - mintBalance, - amountToPay, - 'sat', - meltQuote, - description || '', - invoiceExpiry as Date, - encoded, - requestEvent - ) + + if(isMinibitsMintSelected) { + // Process the payment partly on the server side to avoid android background processing failures + WalletTask.nwcTransfer( + mintBalance, + amountToPay, + feeReserve, + 'sat', + description || '', + invoiceExpiry as Date, + encoded, + requestEvent + ) + } else { + // Full process on device for other mints so that minibits server does not touch foreign ecash + const meltQuote: MeltQuoteResponse = yield walletStore.createLightningMeltQuote( + mintBalance.mintUrl, + 'sat', + encoded, + ) + + WalletTask.transfer( + mintBalance, + amountToPay, + 'sat', + meltQuote, + description || '', + invoiceExpiry as Date, + encoded, + requestEvent + ) + } } catch (e: any) { log.error(`[NwcConnection.handlePayInvoice] ${e.message}`) @@ -773,6 +795,7 @@ export const NwcStoreModel = types }) EventEmitter.on('ev_transferTask_result', self.handleTransferResult) + EventEmitter.on('ev_nwcTransferTask_result', self.handleTransferResult) EventEmitter.on('ev_topupTask_result', self.handleTopupResult) } catch (e: any) { @@ -794,6 +817,7 @@ export const NwcStoreModel = types yield targetConnection.handleRequest(event) EventEmitter.on('ev_transferTask_result', self.handleTransferResult) + EventEmitter.on('ev_nwcTransferTask_result', self.handleTransferResult) EventEmitter.on('ev_topupTask_result', self.handleTopupResult) }) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 51083427..1ca48502 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -174,7 +174,7 @@ export const ProofsStoreModel = types proofsCounter.increaseProofsCounter(keysetProofs.length) } - log.info('[addProofs]', `Added new ${addedProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`) + log.trace('[addProofs]', `Added new ${addedProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`) if (addedProofs.length > 0) { Database.addOrUpdateProofs(addedProofs, isPending) // isSpent = false @@ -210,7 +210,7 @@ export const ProofsStoreModel = types proofs.replace(proofs.filter(proof => !proofsToRemove.some(removed => removed.secret === proof.secret))) - log.debug('[removeProofs]', `${count} ${(isPending) ? 'pending' : ''} proofs removed from ProofsStore`) + log.trace('[removeProofs]', `${count} ${(isPending) ? 'pending' : ''} proofs removed from ProofsStore`) } catch (e: any) { throw new AppError(Err.STORAGE_ERROR, e.message.toString()) @@ -411,7 +411,7 @@ export const ProofsStoreModel = types }, getMintBalanceWithMaxBalance: (unit: MintUnit) => { const balances = self.getBalances().mintBalances - let maxBalance = null; + let maxBalance = undefined; let maxAmount = -Infinity; for (const balance of balances) { diff --git a/src/models/Transaction.ts b/src/models/Transaction.ts index 9cb0119c..1d7d2f5a 100644 --- a/src/models/Transaction.ts +++ b/src/models/Transaction.ts @@ -39,6 +39,7 @@ export enum TransactionType { RECEIVE_NOSTR = 'RECEIVE_NOSTR', // not used TOPUP = 'TOPUP', TRANSFER = 'TRANSFER', + NWC_TRANSFER = 'NWC_TRANSFER', } export enum TransactionStatus { @@ -140,12 +141,12 @@ export const TransactionModel = types setInputToken(inputToken: string) { Database.updateInputToken(self.id!, inputToken) self.inputToken = inputToken - log.debug('[setInputToken]', 'Transaction inputToken updated', {id: self.id, inputToken}) + log.trace('[setInputToken]', 'Transaction inputToken updated', {id: self.id, inputToken}) }, setOutputToken(outputToken: string) { Database.updateOutputToken(self.id!, outputToken) self.outputToken = outputToken - log.trace('[setOutputToken]', 'Transaction outputToken updated', {id: self.id, outputToken}) + log.trace('[setOutputToken]', 'Transaction outputToken updated', {id: self.id}) }, setProof(proof: string) { Database.updateProof(self.id!, proof) diff --git a/src/models/TransactionsStore.ts b/src/models/TransactionsStore.ts index 1462da43..1a399885 100644 --- a/src/models/TransactionsStore.ts +++ b/src/models/TransactionsStore.ts @@ -189,7 +189,7 @@ export const TransactionsStoreModel = types self.history.unshift(reference!) self.recentByUnit.unshift(reference!) - log.debug('[addTransaction]', 'New transaction added to the TransactionsStore', {id}) + // log.trace('[addTransaction]', 'New transaction added to the TransactionsStore', {id}) // Purge the oldest references from cache, but keep some for each mint self.pruneRecentByUnit(newTransaction.unit) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index bedf7f7d..2959b1c7 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -214,8 +214,7 @@ export const WalletStoreModel = types keysetId?: string withSeed: boolean } - ) { - log.trace('[WalletStore.getWallet] start', {mintUrl}) + ) { // syncs mint model in wallet state and returns cashu-ts mint class instance const cashuMint = yield self.getMint(mintUrl) @@ -315,8 +314,7 @@ export const WalletStoreModel = types log.trace('[WalletStore.getWallet]', 'Returning NEW cashuWallet instance', {mintUrl}) return newWallet }), - getMintKeysets: flow(function* getMintKeysets(mintUrl: string) { - log.trace('[getMintKeysets] start') + getMintKeysets: flow(function* getMintKeysets(mintUrl: string) { const cashuMint: CashuMint = yield self.getMint(mintUrl) try { @@ -350,7 +348,7 @@ export const WalletStoreModel = types mintUrl: string, unit: MintUnit, decodedToken: TokenV3, - mintFeeReserve: number, + swapFeeReserve: number, options: { preference: AmountPreference[], counter: number @@ -368,7 +366,7 @@ export const WalletStoreModel = types const proofs = yield cashuWallet.receive( decodedToken, - mintFeeReserve, + swapFeeReserve, { keysetId: cashuWallet.keys.id, preference: options.preference, @@ -378,9 +376,9 @@ export const WalletStoreModel = types }) const receivedAmount: number = CashuUtils.getProofsAmount(proofs as Proof[]) - const mintFeePaid = amountToReceive - receivedAmount + const swapFeePaid = amountToReceive - receivedAmount - return {proofs, mintFeePaid} + return {proofs, swapFeePaid} } catch (e: any) { throw new AppError( Err.MINT_ERROR, @@ -391,7 +389,7 @@ export const WalletStoreModel = types }), send: flow(function* send(mintUrl: string, amountToSend: number, - mintFeeReserve: number, + swapFeeReserve: number, unit: MintUnit, proofsToSendFrom: Proof[], options: { @@ -410,7 +408,7 @@ export const WalletStoreModel = types const {returnChange, send} = yield cashuWallet.send( amountToSend, - mintFeeReserve, + swapFeeReserve, proofsToSendFrom, { keysetId: cashuWallet.keys.id, @@ -436,13 +434,13 @@ export const WalletStoreModel = types ) } - const mintFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount + const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount // we normalize naming of returned parameters return { returnedProofs: returnChange as Proof[], proofsToSend: send as Proof[], - mintFeePaid + swapFeePaid } } catch (e: any) { let message = 'The mint could not return signatures necessary for this transaction' diff --git a/src/screens/TranDetailScreen.tsx b/src/screens/TranDetailScreen.tsx index bea37f79..68dfc7e3 100644 --- a/src/screens/TranDetailScreen.tsx +++ b/src/screens/TranDetailScreen.tsx @@ -185,7 +185,7 @@ export const TranDetailScreen: FC> = return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}` case TransactionType.TOPUP: return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}` - case TransactionType.TRANSFER: + case TransactionType.TRANSFER || TransactionType.NWC_TRANSFER: return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}` default: return `${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}` @@ -305,6 +305,14 @@ export const TranDetailScreen: FC> = colorScheme={colorScheme} /> )} + {transaction.type === TransactionType.NWC_TRANSFER && ( + + )} {isDataParsable && ( @@ -1124,6 +1134,7 @@ const SendInfoBlock = function (props: { return ( <> @@ -1338,6 +1349,7 @@ const TopupInfoBlock = function (props: { return ( <> @@ -1610,12 +1622,14 @@ const TransferInfoBlock = function (props: { label="tranDetailScreen.type" value={transaction.type as string} /> - + {transaction.status === TransactionStatus.COMPLETED && ( + + )} {transaction.status === TransactionStatus.PREPARED ? ( )} - {([TransactionType.SEND, TransactionType.TRANSFER].includes(tx.type)) && ( + {([TransactionType.SEND, TransactionType.TRANSFER, TransactionType.NWC_TRANSFER].includes(tx.type)) && ( b.amount - a.amount); +const findMinExcess = function (requestedAmount: number, proofs: Proof[], preference: 'SMALL' | 'BIG' = 'SMALL'): Proof[] { + if(preference === 'SMALL') { + proofs.sort((a, b) => a.amount - b.amount); + } else { + proofs.sort((a, b) => b.amount - a.amount); + } + const selectedProofs: Proof[] = []; let currentAmount = 0; diff --git a/src/services/minibitsService.ts b/src/services/minibitsService.ts index 597add5a..62683dd2 100644 --- a/src/services/minibitsService.ts +++ b/src/services/minibitsService.ts @@ -7,6 +7,9 @@ import { } from '@env' import { WalletProfile, WalletProfileRecord } from "../models/WalletProfileStore" import { CurrencyCode } from "./wallet/currency" +import { ProofV3, TokenV3 } from "./cashu/cashuUtils" +import { NostrEvent } from "./nostrService" +import { MeltQuoteResponse, MeltQuoteState } from "@cashu/cashu-ts" type MinibitsRequestArgs = { @@ -255,6 +258,29 @@ const checkDonationPaid = async function (paymentHash: string, pubkey: string) { } +const payNwcTransfer = async function (encodedInvoice: string, tokenToPayFrom: TokenV3) { + const url = MINIBITS_SERVER_API_HOST + '/payment/payInvoice' + const method = 'POST' + + const body = { + encodedInvoice, + tokenToPayFrom, + } + + const response: { + meltQuote: MeltQuoteResponse, + tokenToReturn?: TokenV3 + } = await fetchApi(url, { + method, + body + }) + + log.debug(`[minibitsClient.payNwcTransfer] Got response`, {response}) + + return response +} + + const createClaim = async function (walletId: string, seedHash: string, pubkey: string, batchFrom?: number) { const url = MINIBITS_SERVER_API_HOST + '/claim' const method = 'POST' @@ -362,6 +388,7 @@ export const MinibitsClient = { createDonation, checkDonationPaid, createClaim, + payNwcTransfer, getExchangeRate, getPublicHeaders, fetchApi, diff --git a/src/services/wallet/nwcTransferTask.ts b/src/services/wallet/nwcTransferTask.ts new file mode 100644 index 00000000..a63b28e8 --- /dev/null +++ b/src/services/wallet/nwcTransferTask.ts @@ -0,0 +1,425 @@ +import {CashuUtils, ProofV3, TokenEntryV3} from '../cashu/cashuUtils' +import AppError, {Err} from '../../utils/AppError' +import {MeltQuoteResponse, MeltQuoteState} from '@cashu/cashu-ts' +import {rootStoreInstance} from '../../models' +import { TransactionTaskResult, WalletTask } from '../walletService' +import { MintBalance, MintProofsCounter } from '../../models/Mint' +import { Proof } from '../../models/Proof' +import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction' +import { log } from '../logService' +import { WalletUtils } from './utils' +import {isBefore} from 'date-fns' +import { MintUnit, formatCurrency, getCurrency } from './currency' +import { NostrEvent } from '../nostrService' +import { MinibitsClient } from '../minibitsService' +import { getSnapshot } from 'mobx-state-tree' +import { MINIBITS_MINT_URL } from '@env' +import { receiveSync } from './receiveTask' + +const { + transactionsStore, + mintsStore, + proofsStore, +} = rootStoreInstance + +const NWC_TRANSFER = 'nwcTransferTask' + +export const nwcTransferTask = async function ( + mintBalanceToTransferFrom: MintBalance, + amountToTransfer: number, + feeReserve: number, + unit: MintUnit, + memo: string, + invoiceExpiry: Date, + encodedInvoice: string, + nwcEvent: NostrEvent +) : Promise { + + const mintUrl = mintBalanceToTransferFrom.mintUrl + const mintInstance = mintsStore.findByUrl(mintUrl) + + if(mintUrl !== MINIBITS_MINT_URL) { + throw new AppError( + Err.VALIDATION_ERROR, + 'Payment of NWC invoice on the Minibits server is supported only for ecash issued by Minibits mint.', + {mintBalanceToTransferFrom} + ) + } + + // TODO refresh - balance might be outdated if it waits in queue before other txs + log.debug('[nwcTransfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom}) + log.debug('[nwcTransfer]', 'amountToTransfer', {amountToTransfer, feeReserve}) + + // create draft transaction + const transactionData: TransactionData[] = [ + { + status: TransactionStatus.DRAFT, + mintBalanceToTransferFrom, + amountToTransfer, + feeReserve, + unit, + encodedInvoice, + isNwc: nwcEvent ? true : false, + createdAt: new Date(), + } + ] + + let transaction: Transaction | undefined = undefined + let meltQuote: MeltQuoteResponse | undefined = undefined + let proofsToMeltFrom: Proof[] = [] + let proofsToMeltFromAmount: number = 0 + + try { + const newTransaction = { + type: TransactionType.NWC_TRANSFER, + amount: amountToTransfer, + fee: feeReserve, + unit, + data: JSON.stringify(transactionData), + memo, + mint: mintBalanceToTransferFrom.mintUrl, + status: TransactionStatus.DRAFT, + } + + // store tx in db and in the model + transaction = await transactionsStore.addTransaction(newTransaction) + const transactionId = transaction.id + + if (amountToTransfer + feeReserve > mintBalanceToTransferFrom.balances[unit]!) { + throw new AppError( + Err.VALIDATION_ERROR, + 'Mint balance is insufficient to cover the amount to transfer with the expected Lightning fees.', + {transactionId} + ) + } + + if(isBefore(invoiceExpiry, new Date())) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This invoice has already expired and can not be paid.', + {invoiceExpiry, transactionId} + ) + } + + if (!mintInstance) { + throw new AppError( + Err.VALIDATION_ERROR, + 'Could not find mint', {mintUrl, transactionId} + ) + } + + // calculate fees charged by mint for melt transaction to prepare enough proofs + const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit}) + const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint) + + let proofsToMelt = CashuUtils.getProofsToSend( + amountToTransfer + feeReserve, + proofsFromMint + ) + + let meltFeeReserve = mintInstance.getMintFeeReserve(proofsToMelt) + const amountWithFees = amountToTransfer + feeReserve + meltFeeReserve + + if (totalAmountFromMint < amountWithFees) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount.', + {totalAmountFromMint, amountWithFees, transactionId, caller: 'nwcTransferTask'}, + ) + } + + // exact match or min number of proofs that matches the amount + proofsToMeltFrom = CashuUtils.getProofsToSend( + amountWithFees, + proofsFromMint + ) + + proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom) + + proofsStore.removeProofs(proofsToMeltFrom) + WalletUtils.addCashuProofs( + mintUrl, + proofsToMeltFrom, + { + unit, + transactionId: transaction.id, + isPending: true + } + ) + + log.trace('[nwcTransfer]', 'Prepared proofsToMeltFrom proofs', { + proofsToMeltFromAmount, + transactionId, + unit, + }) + + // Update transaction status + transactionData.push({ + status: TransactionStatus.PREPARED, + proofsToMeltFromAmount, + lightningFeeReserve: feeReserve, + meltFeeReserve, + createdAt: new Date(), + }) + + transaction.setStatus( + TransactionStatus.PREPARED, + JSON.stringify(transactionData), + ) + + const cleanedproofsToMeltFrom = proofsToMeltFrom.map(proof => { + const {mintUrl, unit, tId, ...rest} = getSnapshot(proof) + return rest + }) + + const inputTokenEntry: TokenEntryV3 = { + mint: mintUrl, + proofs: cleanedproofsToMeltFrom, + } + + const tokenToPayFrom = { + token: [inputTokenEntry], + unit + } + + const inputToken = CashuUtils.encodeToken(tokenToPayFrom) + + transaction.setInputToken(inputToken) + + const result = await MinibitsClient.payNwcTransfer( + encodedInvoice, + { + token: [inputTokenEntry], + unit + } + ) + + meltQuote = result.meltQuote + const tokenToReturn = result.tokenToReturn // this is server wallet ecash, needs swap + + if (meltQuote.state === MeltQuoteState.PAID) { + + log.debug('[nwcTransfer] Invoice PAID', { + transactionId + }) + + // Save preimage asap + if(meltQuote.payment_preimage) { + transaction.setProof(meltQuote.payment_preimage) + } + + // If noting was returned, all reserves were spent on fees + let totalFeePaid = proofsToMeltFromAmount - amountToTransfer + let lightningFeePaid = totalFeePaid - meltFeeReserve + + let returnedAmount = 0 + + if (tokenToReturn) { + // Save in case receive fails + const outputToken = CashuUtils.encodeToken(tokenToReturn) + transaction.setOutputToken(outputToken) + + // Swap received feeSaved proofs as they were issued to the server wallet thus are not linked to wallet seed + const { + receivedProofs: returnedProofs + } = await receiveSync( + mintUrl, + tokenToReturn, + memo, + transactionId + ) + + // Spend pending proofs that were used to settle the lightning invoice + proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false) + returnedAmount = CashuUtils.getProofsAmount(returnedProofs) + + const returnedTokenEntry: TokenEntryV3 = { + mint: mintUrl, + proofs: returnedProofs, + } + + const swappedOutputToken = CashuUtils.encodeToken({ + token: [returnedTokenEntry], + unit, + }) + + transaction.setOutputToken(swappedOutputToken) + + totalFeePaid = totalFeePaid - returnedAmount + lightningFeePaid = totalFeePaid - meltFeeReserve + } else { + // Spend pending proofs that were used to settle the lightning invoice + proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false) + } + + // Save final fee in db + if(totalFeePaid !== transaction.fee) { + transaction.setFee(totalFeePaid) + } + + // Update transaction status + transactionData.push({ + status: TransactionStatus.COMPLETED, + lightningFeeReserve: meltQuote.fee_reserve, + lightningFeePaid, + mintFeesPaid: totalFeePaid - lightningFeePaid, + preimage: meltQuote.payment_preimage, + createdAt: new Date(), + }) + + transaction.setStatus( + TransactionStatus.COMPLETED, + JSON.stringify(transactionData), + ) + + const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! + transaction.setBalanceAfter(balanceAfter) + + return { + taskFunction: NWC_TRANSFER, + mintUrl, + transaction, + message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`, + lightningFeePaid, + mintFees: totalFeePaid - lightningFeePaid, + totalFeePaid, + meltQuote, + preimage: meltQuote.payment_preimage, + nwcEvent + } as TransactionTaskResult + + } else if(meltQuote.state === MeltQuoteState.PENDING) { + + log.debug('[transfer] Invoice PENDING', { + meltQuote, + transactionId + }) + + return { + taskFunction: NWC_TRANSFER, + mintUrl, + transaction, + message: `Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.`, + meltQuote, + nwcEvent + } as TransactionTaskResult + + } else { + // throw so that proper state of proofs is synced inside the catch block + throw new AppError(Err.MINT_ERROR, 'Lightning payment has not been paid.', { + state: meltQuote.state, + transactionId + }) + } + + } catch (e: any) { + if (transaction) { + + let message = e.message + + if (proofsToMeltFrom.length > 0) { + // check with the mint the real status of the proofs involved in transaction + await WalletTask.syncStateWithMintSync( + { + proofsToSync: proofsToMeltFrom, + mintUrl, + isPending: true + } + ) + + // force refresh just in case above method did not update the model? + const refreshed = transactionsStore.findById(transaction.id) + + if(refreshed?.status === TransactionStatus.PENDING) { + log.warn('[transfer]', 'proofsToPay from transfer with error are pending by mint', { + proofsToMeltFromAmount, + unit, + transactionId: transaction.id + }) + + message = 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.' + + return { + taskFunction: NWC_TRANSFER, + mintUrl, + transaction, + message, + meltQuote, + nwcEvent + } as TransactionTaskResult + + } else if(refreshed?.status === TransactionStatus.COMPLETED) { + // Likely receiving of change failed due to wallet error. We keep completed as status. + log.error('[nwcTransfer]', 'NWC Transfer throwed error but the payment suceeded', { + error: e.message, + proofsToMeltFromAmount, + unit, + transactionId: transaction.id + }) + + if(meltQuote?.payment_preimage) { + refreshed.setProof(meltQuote?.payment_preimage) + } + + const message = `Lightning invoice has been successfully paid, however some error occured: ${e.message}` + + return { + taskFunction: NWC_TRANSFER, + mintUrl, + transaction, + message, + meltQuote, + preimage: meltQuote?.payment_preimage, + nwcEvent + } as TransactionTaskResult + + } else { + // syncStateWithMintSync returns to spendable only proofs that were pending by mint before + // so we need to take care of our transfer here. + + log.warn('[transfer]', 'proofsToPay from transfer with error to be returned to spendable wallet', { + proofsToMeltFromAmount, + unit, + transactionId: transaction.id + }) + + // remove it from pending proofs in the wallet + proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, true) + // add proofs back to the spendable wallet with internal references + WalletUtils.addCashuProofs( + mintUrl, + proofsToMeltFrom, + { + unit, + transactionId: transaction.id, + isPending: false + } + ) + } + } + + transactionData.push({ + status: TransactionStatus.ERROR, + error: WalletUtils.formatError(e), + createdAt: new Date() + }) + + transaction.setStatus( + TransactionStatus.ERROR, + JSON.stringify(transactionData), + ) + } + + log.error('[transfer]', e.name, e.message, e.params) + + return { + taskFunction: NWC_TRANSFER, + mintUrl, + transaction, + message: e.message, + error: WalletUtils.formatError(e), + meltQuote, + nwcEvent + } as TransactionTaskResult + } +} \ No newline at end of file diff --git a/src/services/wallet/receiveTask.ts b/src/services/wallet/receiveTask.ts index 37246641..30695cc1 100644 --- a/src/services/wallet/receiveTask.ts +++ b/src/services/wallet/receiveTask.ts @@ -37,8 +37,7 @@ export const receiveTask = async function ( const transactionData: TransactionData[] = [] let transaction: Transaction | undefined = undefined let mintToReceive: string | undefined = undefined - const unit = token.unit as MintUnit || 'sat' - let lockedProofsCounter: MintProofsCounter | undefined = undefined + const unit = token.unit as MintUnit || 'sat' try { const tokenMints: string[] = CashuUtils.getMintsFromToken(token) @@ -103,94 +102,19 @@ export const receiveTask = async function ( } as TransactionTaskResult } - // Handle missing mint, we add it automatically - const alreadyExists = mintsStore.alreadyExists(mintToReceive) - - if (!alreadyExists) { - await mintsStore.addMint(mintToReceive) - } - - const mintInstance = mintsStore.findByUrl(mintToReceive) - - if(!mintInstance) { - throw new AppError(Err.VALIDATION_ERROR, 'Missing mint', {mintToReceive}) - } - - // 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 - const amountPreferences = getDefaultAmountPreference(amountToReceive) - const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences) - const proofsToReceive = token.token.flatMap(entry => entry.proofs) - const mintFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive) - - log.trace('[receiveTask]', 'amountPreferences', {amountPreferences, transactionId: transaction.id}) - log.trace('[receiveTask]', 'countOfInFlightProofs', {countOfInFlightProofs, transactionId: transaction.id}) - // log.trace('[receiveTask]', {tokenEntries}) - - // temp increase the counter + acquire lock and set inFlight values - lockedProofsCounter = await WalletUtils.lockAndSetInFlight( - mintInstance, - unit, - countOfInFlightProofs, - transaction.id! - ) - - const receivedResult = await walletStore.receive( + const { + receivedAmount, + outputToken, + swapFeeReserve, + swapFeePaid, + counter + } = await receiveSync( mintToReceive, - unit as MintUnit, token, - mintFeeReserve, - { - preference: amountPreferences, - counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase - } + token.memo || '', + transaction.id ) - const receivedProofs = receivedResult.proofs - const mintFeePaid = receivedResult.mintFeePaid - - // log.trace('[receiveTask]', {receivedProofs}) - - // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs - lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs) - - // store swapped proofs as encoded token in tx data - const receivedTokenEntry = { - mint: mintToReceive, - proofs: receivedProofs, - } - - const outputToken = CashuUtils.encodeToken({ - token: [receivedTokenEntry], - unit, - memo, - }) - - // Update transaction status - transactionData.push({ - status: TransactionStatus.PREPARED, - mintFeeReserve, - mintFeePaid, - createdAt: new Date(), - }) - - transaction.setStatus( - TransactionStatus.PREPARED, - JSON.stringify(transactionData), - ) - - const { addedAmount: receivedAmount } = WalletUtils.addCashuProofs( - mintToReceive, - receivedProofs, - { - unit, - transactionId: transaction.id!, - isPending: false - } - ) - - // release lock - lockedProofsCounter.resetInFlight(transaction.id!) // Update tx amount if full amount was not received if (receivedAmount !== amountToReceive) { @@ -199,10 +123,12 @@ export const receiveTask = async function ( // Finally, update completed transaction transactionData.push({ - status: TransactionStatus.COMPLETED, + status: TransactionStatus.COMPLETED, + swapFeeReserve, + swapFeePaid, receivedAmount, unit, - counter: lockedProofsCounter.counter, + counter, createdAt: new Date(), }) @@ -216,13 +142,13 @@ export const receiveTask = async function ( const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! transaction.setBalanceAfter(balanceAfter) - if(mintFeePaid > 0) { - transaction.setFee(mintFeePaid) + if(swapFeePaid > 0) { + transaction.setFee(swapFeePaid) } return { taskFunction: RECEIVE, - mintUrl: mintInstance.mintUrl, + mintUrl: mintToReceive, transaction, message: `You've received ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.`, receivedAmount, @@ -230,9 +156,6 @@ export const receiveTask = async function ( } catch (e: any) { if (transaction) { - if(lockedProofsCounter) { - lockedProofsCounter.resetInFlight(transaction.id!) - } transactionData.push({ status: TransactionStatus.ERROR, @@ -250,7 +173,7 @@ export const receiveTask = async function ( return { taskFunction: RECEIVE, - mintUrl: mintToReceive || '', + mintUrl: mintToReceive, transaction, message: e.message, error: WalletUtils.formatError(e), @@ -355,7 +278,7 @@ export const receiveOfflinePrepareTask = async function ( error: WalletUtils.formatError(e), }) - await transaction.setStatus( + transaction.setStatus( TransactionStatus.ERROR, JSON.stringify(transactionData), ) @@ -377,8 +300,7 @@ export const receiveOfflinePrepareTask = async function ( export const receiveOfflineCompleteTask = async function ( transactionId: number ) { - let mintToReceive = '' - let lockedProofsCounter: MintProofsCounter | undefined = undefined + let mintToReceive = '' const transaction = transactionsStore.findById(transactionId) try { @@ -388,7 +310,6 @@ export const receiveOfflineCompleteTask = async function ( const transactionData = JSON.parse(transaction.data) - if (!transaction.inputToken) { throw new AppError(Err.VALIDATION_ERROR, 'Could not find ecash token to redeem', {caller: 'receiveOfflineComplete'}) } @@ -420,6 +341,95 @@ export const receiveOfflineCompleteTask = async function ( } as TransactionTaskResult } + const { + receivedAmount, + outputToken, + swapFeeReserve, + swapFeePaid, + counter + } = await receiveSync( + mintToReceive, + token, + token.memo || '', + transaction.id + ) + + // Update tx amount if full amount was not received + if (receivedAmount !== transaction.amount) { + transaction.setReceivedAmount(receivedAmount) + } + + // Finally, update completed transaction + transactionData.push({ + status: TransactionStatus.COMPLETED, + receivedAmount, + swapFeeReserve, + swapFeePaid, + unit, + counter, + createdAt: new Date(), + }) + + transaction.setStatus( + TransactionStatus.COMPLETED, + JSON.stringify(transactionData), + ) + + transaction.setOutputToken(outputToken) + + const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! + transaction.setBalanceAfter(balanceAfter) + + if(swapFeePaid > 0) { + transaction.setFee(swapFeePaid) + } + + return { + taskFunction: RECEIVE_OFFLINE_COMPLETE, + mintUrl: mintToReceive, + transaction, + message: `You've received ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.`, + receivedAmount, + } as TransactionTaskResult + + } catch (e: any) { + // release lock + if(transaction) { + + const transactionData = JSON.parse(transaction.data) + transactionData.push({ + status: TransactionStatus.ERROR, + error: WalletUtils.formatError(e), + }) + + transaction.setStatus( + TransactionStatus.ERROR, + JSON.stringify(transactionData), + ) + } + + return { + taskFunction: RECEIVE_OFFLINE_COMPLETE, + mintUrl: mintToReceive, + transaction, + message: '', + error: WalletUtils.formatError(e), + } as TransactionTaskResult + } +} + + +export const receiveSync = async function ( + mintToReceive: string, + token: TokenV3, + memo: string, + transactionId: number +) { + + const unit = token.unit as MintUnit || 'sat' + let lockedProofsCounter: MintProofsCounter | undefined = undefined + + try { // Handle missing mint, we add it automatically const alreadyExists = mintsStore.alreadyExists(mintToReceive) @@ -434,31 +444,29 @@ export const receiveOfflineCompleteTask = async function ( } // 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 tokenEntries: TokenEntryV3[] = token.token - const proofsToReceive = tokenEntries[0].proofs as Proof[] - const mintFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive) - // We will receive amount less fees paid for a swap for fresh ecash - const amountPreferences = getDefaultAmountPreference(transaction.amount - mintFeeReserve) - const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences) - - log.trace('[receiveOfflineCompleteTask]', 'amountPreferences', amountPreferences) - log.trace('[receiveOfflineCompleteTask]', 'countOfInFlightProofs', countOfInFlightProofs) + // is not received our recovery index counts for sigs the mint has already issued + const amountToReceive = CashuUtils.getTokenAmounts(token).totalAmount + const amountPreferences = getDefaultAmountPreference(amountToReceive) + const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences) + const proofsToReceive = token.token.flatMap(entry => entry.proofs) + const swapFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive) + log.trace('[receiveSync]', 'amountPreferences', {amountPreferences, transactionId}) + log.trace('[receiveSync]', 'countOfInFlightProofs', {countOfInFlightProofs, transactionId}) + // temp increase the counter + acquire lock and set inFlight values lockedProofsCounter = await WalletUtils.lockAndSetInFlight( mintInstance, unit, countOfInFlightProofs, - transaction.id as number + transactionId ) const receivedResult = await walletStore.receive( mintToReceive, unit as MintUnit, token, - mintFeeReserve, + swapFeeReserve, { preference: amountPreferences, counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase @@ -466,110 +474,58 @@ export const receiveOfflineCompleteTask = async function ( ) const receivedProofs = receivedResult.proofs - const mintFeePaid = receivedResult.mintFeePaid + const swapFeePaid = receivedResult.swapFeePaid + + // log.trace('[receiveTask]', {receivedProofs}) // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs) - - // store swapped proofs as encoded token in tx data - const receivedTokenEntry: TokenEntryV3 = { - mint: mintToReceive, - proofs: receivedProofs, - } - - const outputToken = CashuUtils.encodeToken({ - token: [receivedTokenEntry], - unit, - }) - - // Update transaction status - transactionData.push({ - status: TransactionStatus.PREPARED, - mintFeeReserve, - mintFeePaid, - createdAt: new Date(), - }) - - transaction.setStatus( - TransactionStatus.PREPARED, - JSON.stringify(transactionData), - ) const { addedAmount: receivedAmount } = WalletUtils.addCashuProofs( mintToReceive, receivedProofs, { unit, - transactionId: transaction.id as number, + transactionId: transactionId, isPending: false } ) // release lock - lockedProofsCounter.resetInFlight(transaction.id as number) - - // Update tx amount if full amount was not received - if (receivedAmount !== transaction.amount) { - transaction.setReceivedAmount(receivedAmount) + lockedProofsCounter.resetInFlight(transactionId) + + // store swapped proofs as encoded token in tx data + const receivedTokenEntry = { + mint: mintToReceive, + proofs: receivedProofs, } - // Finally, update completed transaction - transactionData.push({ - status: TransactionStatus.COMPLETED, + const outputToken = CashuUtils.encodeToken({ + token: [receivedTokenEntry], + unit, + memo, + }) + + return { receivedAmount, - unit, - createdAt: new Date(), - }) - - transaction.setStatus( - TransactionStatus.COMPLETED, - JSON.stringify(transactionData), - ) - - transaction.setOutputToken(outputToken) - - const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! - transaction.setBalanceAfter(balanceAfter) - - if(mintFeePaid > 0) { - transaction.setFee(mintFeePaid) + receivedProofs, + outputToken, + swapFeeReserve, + swapFeePaid, + counter: lockedProofsCounter?.counter } - - return { - taskFunction: RECEIVE_OFFLINE_COMPLETE, - mintUrl: mintInstance.mintUrl, - transaction, - message: `You've received ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.`, - receivedAmount, - } as TransactionTaskResult - + } catch (e: any) { - // release lock - if(transaction) { - if(lockedProofsCounter) { - lockedProofsCounter.resetInFlight(transaction.id as number) + if (transactionId) { + if(lockedProofsCounter) { + lockedProofsCounter.resetInFlight(transactionId) } - - const transactionData = JSON.parse(transaction.data) - transactionData.push({ - status: TransactionStatus.ERROR, - error: WalletUtils.formatError(e), - }) - - transaction.setStatus( - TransactionStatus.ERROR, - JSON.stringify(transactionData), - ) } - log.error(e.name, e.message) - - return { - taskFunction: RECEIVE_OFFLINE_COMPLETE, - mintUrl: mintToReceive, - transaction, - message: '', - error: WalletUtils.formatError(e), - } as TransactionTaskResult + if (e instanceof AppError) { + throw e + } else { + throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 200)) + } } } \ No newline at end of file diff --git a/src/services/wallet/sendTask.ts b/src/services/wallet/sendTask.ts index 3813a74f..f6da0dce 100644 --- a/src/services/wallet/sendTask.ts +++ b/src/services/wallet/sendTask.ts @@ -11,7 +11,7 @@ import AppError, {Err} from '../../utils/AppError' import { MintKeyset, } from '@cashu/cashu-ts' -import { getDefaultAmountPreference, isObj } from '@cashu/cashu-ts/src/utils' +import { getDefaultAmountPreference } from '@cashu/cashu-ts/src/utils' import { MAX_SWAP_INPUT_SIZE, TransactionTaskResult, WalletTask } from '../walletService' import { Mint, MintBalance, MintProofsCounter } from '../../models/Mint' import { Proof } from '../../models/Proof' @@ -76,8 +76,8 @@ export const sendTask = async function ( // get ready proofs to send and update proofs and pending proofs storage const { proofs: proofsToSend, - mintFeePaid, - mintFeeReserve, + swapFeePaid, + swapFeeReserve, isSwapNeeded, counter } = await sendFromMintSync( @@ -91,8 +91,8 @@ export const sendTask = async function ( // Update transaction status transactionData.push({ status: TransactionStatus.PREPARED, - mintFeeReserve, - mintFeePaid, + swapFeeReserve, + swapFeePaid, isSwapNeeded, createdAt: new Date(), }) @@ -134,8 +134,8 @@ export const sendTask = async function ( const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! transaction.setBalanceAfter(balanceAfter) - if(mintFeePaid > 0) { - transaction.setFee(mintFeePaid) + if(swapFeePaid > 0) { + transaction.setFee(swapFeePaid) } log.trace('[send] totalBalance after', balanceAfter) @@ -165,7 +165,7 @@ export const sendTask = async function ( transaction, message: '', encodedTokenToSend: outputToken, - mintFeePaid + swapFeePaid } as TransactionTaskResult } catch (e: any) { @@ -263,13 +263,17 @@ export const sendFromMintSync = async function ( ) } - for (const proof of selectedProofs) { - proof.setTransactionId(transactionId) // update txId - } - - // move sent proofs to pending - proofsStore.removeProofs(selectedProofs) - proofsStore.addProofs(selectedProofs, true) // pending true + // move sent proofs to pending and add tx references + proofsStore.removeProofs(selectedProofs) + WalletUtils.addCashuProofs( + mintUrl, + selectedProofs, + { + unit, + transactionId: transactionId, + isPending: true + } + ) // Clean private properties to not to send them out. This returns plain js array, not model objects. const cleanedProofsToSend = selectedProofs.map(proof => { @@ -280,8 +284,8 @@ export const sendFromMintSync = async function ( // We return cleaned proofs to be encoded as a sendable token return { proofs: cleanedProofsToSend, - mintFeeReserve: 0, - mintFeePaid: 0 + swapFeeReserve: 0, + swapFeePaid: 0 } } @@ -312,9 +316,9 @@ export const sendFromMintSync = async function ( let proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) // swap will happen if we could not select proofs equal to amountToSend - let mintFeeReserve: number = 0 + let swapFeeReserve: number = 0 let returnedAmount = 0 - let mintFeePaid: number = 0 + let swapFeePaid: number = 0 let proofsToSend: Proof[] = [] let returnedProofs: Proof[] = [] @@ -327,26 +331,30 @@ export const sendFromMintSync = async function ( */ if(isSwapNeeded) { // Calculate feeReserve from mint fee rate - mintFeeReserve = mintInstance.getMintFeeReserve(proofsToSendFrom) - // This is expected to get back from mint as a split remainder - we deduct fee that a mint will keep - returnedAmount = proofsToSendFromAmount - amountToSend - mintFeeReserve + swapFeeReserve = mintInstance.getMintFeeReserve(proofsToSendFrom) + const amountWithFees = amountToSend + swapFeeReserve - log.debug('[sendFromMintSync] Swap is needed.', {mintFeeReserve, returnedAmount, transactionId}) - - // if we did not selected enough proofs to cover the fees we need some more - if(returnedAmount < 0) { - - const proofsToPayFees = getProofsToPayFees( - Math.abs(returnedAmount), - proofsFromMint, - proofsToSendFrom + if (totalAmountFromMint < amountWithFees) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount.', + {totalAmountFromMint, amountWithFees, transactionId, caller: 'transferTask'}, ) - // add more proofs into inputs and recalculate amounts - proofsToSendFrom.push(...proofsToPayFees) - proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) - returnedAmount = proofsToSendFromAmount - amountToSend - mintFeeReserve } + // exact match or min number of proofs that matches the amount + proofsToSendFrom = CashuUtils.getProofsToSend( + amountWithFees, + proofsFromMint + ) + + proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) + + // This is expected to get back from mint as a split remainder - we deduct fee that a mint will keep + returnedAmount = proofsToSendFromAmount - (amountToSend + swapFeeReserve) + + log.debug('[sendFromMintSync] Swap is needed.', {swapFeeReserve, returnedAmount, transactionId}) + // Output denominations we ask for to get back const amountPreferences = getDefaultAmountPreference(amountToSend) // Output denominations we are about to get as a split remainder @@ -355,8 +363,7 @@ export const sendFromMintSync = async function ( const countOfProofsToSend = CashuUtils.getAmountPreferencesCount(amountPreferences) const countOfReturnedProofs = CashuUtils.getAmountPreferencesCount(returnedAmountPreferences) const countOfInFlightProofs = countOfProofsToSend + countOfReturnedProofs - - log.trace('[sendFromMintSync]', 'amountPreferences', {amountPreferences, returnedAmountPreferences}) + log.trace('[sendFromMintSync]', 'countOfInFlightProofs', countOfInFlightProofs) // Increase the proofs counter before the mint call so that in case the response @@ -374,7 +381,7 @@ export const sendFromMintSync = async function ( const sendResult = await walletStore.send( mintUrl, amountToSend, - mintFeeReserve, + swapFeeReserve, unit, proofsToSendFrom, { @@ -385,13 +392,14 @@ export const sendFromMintSync = async function ( returnedProofs = sendResult.returnedProofs // TODO types - these are ProofsV3 indeed proofsToSend = sendResult.proofsToSend // TODO types - these are ProofsV3 indeed - mintFeePaid = sendResult.mintFeePaid + swapFeePaid = sendResult.swapFeePaid // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs) // add proofs returned by the mint after the split log.trace('[sendFromMintSync] add returned proofs to spendable') + WalletUtils.addCashuProofs( mintUrl, returnedProofs, @@ -400,8 +408,7 @@ export const sendFromMintSync = async function ( transactionId, isPending: false } - ) - + ) // release lock lockedProofsCounter.resetInFlight(transactionId) @@ -410,7 +417,7 @@ export const sendFromMintSync = async function ( * SWAP is NOT needed, we've found denominations that match exact amount * */ - log.debug('[sendFromMintSync] Swap is not necessary, all proofsToSendFrom will be sent.', {transactionId}) + log.debug('[sendFromMintSync] Swap is not necessary.', {transactionId}) // If we selected whole balance, check if it is not above limit acceptable by wallet and mints. if(proofsToSendFrom.length > MAX_SWAP_INPUT_SIZE) { @@ -452,8 +459,8 @@ export const sendFromMintSync = async function ( // We return cleaned proofs to be encoded as a sendable token + fees return { proofs: cleanedProofsToSend as ProofV3[], - mintFeeReserve, - mintFeePaid, + swapFeeReserve, + swapFeePaid, isSwapNeeded, counter: lockedProofsCounter?.counter } @@ -562,29 +569,4 @@ const getInactiveKeysetIds = function(mint: Mint) { } return [] -} - - -const getProofsToPayFees = function ( - feeAmount: number, - proofsFromMint: Proof[], - proofsToSendFrom: Proof[] -) { - - const remainingProofs = proofsStore.getProofsSubset(proofsFromMint, proofsToSendFrom) - const remainingProofsAmount = CashuUtils.getProofsAmount(remainingProofs) - - if(feeAmount > remainingProofsAmount) { - throw new AppError( - Err.VALIDATION_ERROR, - 'There is not enough funds to cover the expected fee for this payment.', - {feeAmount, remainingProofsAmount}, - ) - } - - return CashuUtils.getProofsToSend( - feeAmount, - remainingProofs - ) -} - +} \ No newline at end of file diff --git a/src/services/wallet/transferTask.ts b/src/services/wallet/transferTask.ts index b03f24b1..d8867178 100644 --- a/src/services/wallet/transferTask.ts +++ b/src/services/wallet/transferTask.ts @@ -1,4 +1,4 @@ -import {CashuUtils, ProofV3, TokenEntryV3} from '../cashu/cashuUtils' +import {CashuUtils, TokenEntryV3} from '../cashu/cashuUtils' import AppError, {Err} from '../../utils/AppError' import {MeltQuoteResponse, MeltQuoteState} from '@cashu/cashu-ts' import {rootStoreInstance} from '../../models' @@ -9,7 +9,6 @@ import { Transaction, TransactionData, TransactionRecord, TransactionStatus, Tra import { log } from '../logService' import { WalletUtils } from './utils' import {isBefore} from 'date-fns' -import { sendFromMintSync } from './sendTask' import { MintUnit, formatCurrency, getCurrency } from './currency' import { NostrEvent } from '../nostrService' @@ -38,7 +37,8 @@ export const transferTask = async function ( const mintInstance = mintsStore.findByUrl(mintUrl) let lockedProofsCounter: MintProofsCounter | undefined = undefined - log.debug('[transfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom}) + // TODO refresh - balance might be outdated if it waits in queue before other txs + log.debug('[transfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom}) log.debug('[transfer]', 'amountToTransfer', {amountToTransfer}) log.debug('[transfer]', 'meltQuote', {meltQuote}) @@ -57,8 +57,8 @@ export const transferTask = async function ( ] let transaction: Transaction | undefined = undefined - let proofsToPay: ProofV3[] = [] - let proofsToPayAmount: number = 0 + let proofsToMeltFrom: Proof[] = [] + let proofsToMeltFromAmount: number = 0 try { const newTransaction = { @@ -98,101 +98,58 @@ export const transferTask = async function ( 'Could not find mint', {mintUrl, transactionId} ) } - - // if previous transaction got interrupted by android background processing, resync their proofs state with the mint - // unfinished transactions cause outputs already signed error - const previousTransaction = transactionsStore.findById(transactionId - 1) - - // if zap remained stuck in DRAFT - if(previousTransaction - && previousTransaction.status === TransactionStatus.DRAFT - && previousTransaction.type === TransactionType.TRANSFER - ) { - log.error(`[transfer] Previous TRANSFER transaction ${previousTransaction.id} is stuck in DRAFT status.`) - // we check if proofsCounter is locked so we very likely fired swap request - const proofsCounter = mintInstance.findInFlightProofsCounterByTId(previousTransaction.id) - - if(proofsCounter && proofsCounter.inFlightTid === previousTransaction.id) { - log.error(`[transfer] Found previous DARFT TRANSFER locked proofsCounter.`, {proofsCounter, prevId: previousTransaction.id}) - await WalletTask.handleInFlightSync(mintInstance) - } - } - - // if zap remained stuck in PREPARED we sync pending proofs so that we set proofsCounters to correct values - if(previousTransaction - && previousTransaction.status === TransactionStatus.PREPARED - && previousTransaction.type === TransactionType.TRANSFER - ) { - log.error(`[transfer] Previous TRANSFER transaction ${previousTransaction.id} is stuck in PREPARED status.`) - const proofsCounter = mintInstance.findInFlightProofsCounterByTId(previousTransaction.id) - - if(proofsCounter && proofsCounter.inFlightTid === previousTransaction.id) { - log.error(`[transfer] Found previous PREPARED TRANSFER locked proofsCounter.`, {proofsCounter, prevId: previousTransaction.id}) - await WalletTask.handleInFlightSync(mintInstance) - } - - const proofsToSync = proofsStore.getByTransactionId(previousTransaction.id, true) // pending - log.error(`[transfer] Syncing PREPARED TRANSFER pending proofs with the mint.`, {prevId: previousTransaction.id, proofsToSyncCount: proofsToSync.length}) - - if(proofsToSync.length > 0) { - await WalletTask.syncStateWithMintSync({proofsToSync, mintUrl, isPending: true}) - } - } - + // calculate fees charged by mint for melt transaction to prepare enough proofs const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit}) + const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint) - let proofsToMelt = CashuUtils.getProofsToSend( + const proofsToMelt = CashuUtils.getProofsToSend( amountToTransfer + meltQuote.fee_reserve, proofsFromMint ) - let meltFeeReserve = mintInstance.getMintFeeReserve(proofsToMelt) + let meltFeeReserve = mintInstance.getMintFeeReserve(proofsToMelt) + const amountWithFees = amountToTransfer + meltQuote.fee_reserve + meltFeeReserve - log.trace('[transfer]', { - meltFeeReserve, - lightningFeeReserve: meltQuote.fee_reserve, - amountWithFees: amountToTransfer + meltQuote.fee_reserve + meltFeeReserve, - }) + if (totalAmountFromMint < amountWithFees) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount.', + {totalAmountFromMint, amountWithFees, transactionId, caller: 'transferTask'}, + ) + } - // get proofs ready to be paid to the mint - const swapResult = await sendFromMintSync( - mintBalanceToTransferFrom, - amountToTransfer + meltQuote.fee_reserve + meltFeeReserve, - unit, - [], - transactionId, + // exact match or min number of proofs that matches the amount + proofsToMeltFrom = CashuUtils.getProofsToSend( + amountWithFees, + proofsFromMint ) - - const { - proofs: proofsToPay, - mintFeePaid, - mintFeeReserve, - isSwapNeeded, - counter - } = swapResult - proofsToPayAmount = CashuUtils.getProofsAmount(proofsToPay) + proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom) - // TODO in case of swap from inactive keysets, different meltFees might apply than above calculated meltFeeReserve - // In such case, we might need to add / substract the fee difference to / from proofsToPay + proofsStore.removeProofs(proofsToMeltFrom) + WalletUtils.addCashuProofs( + mintUrl, + proofsToMeltFrom, + { + unit, + transactionId: transaction.id, + isPending: true + } + ) - log.info('[transfer]', 'Prepared poofsToPay proofs', { - proofsToPayAmount, + log.trace('[transfer]', 'Prepared proofsToMeltFrom proofs', { + proofsToMeltFromAmount, + transactionId, unit, - transactionId, - isSwapNeeded, - counter }) // Update transaction status transactionData.push({ status: TransactionStatus.PREPARED, - mintFeeReserve, - mintFeePaid, - proofsToPayAmount, - isSwapNeeded, - counter, + proofsToMeltFromAmount, + lightningFeeReserve: meltQuote.fee_reserve, + meltFeeReserve, createdAt: new Date(), }) @@ -203,7 +160,7 @@ export const transferTask = async function ( const inputTokenEntry: TokenEntryV3 = { mint: mintUrl, - proofs: proofsToPay, + proofs: proofsToMeltFrom, } const inputToken = CashuUtils.encodeToken({ @@ -213,10 +170,10 @@ export const transferTask = async function ( transaction.setInputToken(inputToken) - // number of outputs we can get back with returned lightning fees + // number of outputs we can get back as a change let countOfInFlightProofs = 1 - if(meltQuote.fee_reserve > 1) { - countOfInFlightProofs = Math.ceil(Math.log2(meltQuote.fee_reserve)) + if(proofsToMeltFromAmount - amountToTransfer > 1) { + countOfInFlightProofs = Math.ceil(Math.log2(proofsToMeltFromAmount - amountToTransfer)) } // temp increase the counter + acquire lock and set inFlight values @@ -227,11 +184,11 @@ export const transferTask = async function ( transaction.id ) - const {state, preimage, change: feeSavedProofs} = await walletStore.payLightningMelt( + const {state, preimage, change: returnedProofs} = await walletStore.payLightningMelt( mintUrl, unit, meltQuote, - proofsToPay, + proofsToMeltFrom, { counter: lockedProofsCounter.inFlightFrom as number } @@ -241,22 +198,26 @@ export const transferTask = async function ( if (state === MeltQuoteState.PAID) { - log.debug('[transfer] Invoice PAID', { - state, - meltQuote, - preimage, + log.debug('[transfer] Invoice PAID', { transactionId }) + // Save preimage asap + if(preimage) { + transaction.setProof(preimage) + } + // Spend pending proofs that were used to settle the lightning invoice - proofsStore.removeProofs(proofsToPay as Proof[], true, false) + proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false) + + let totalFeePaid = proofsToMeltFromAmount - amountToTransfer + let lightningFeePaid = totalFeePaid - meltFeeReserve + let returnedAmount = CashuUtils.getProofsAmount(returnedProofs) - let lightningFeePaid = meltQuote.fee_reserve - - if (feeSavedProofs.length > 0) { - const {addedAmount: feeSaved} = WalletUtils.addCashuProofs( + if(returnedProofs.length > 0) { + WalletUtils.addCashuProofs( mintUrl, - feeSavedProofs, + returnedProofs, { unit, transactionId: transaction.id, @@ -264,45 +225,44 @@ export const transferTask = async function ( } ) - const feeSavedTokenEntry: TokenEntryV3 = { + const returnedTokenEntry: TokenEntryV3 = { mint: mintUrl, - proofs: feeSavedProofs, + proofs: returnedProofs, } const outputToken = CashuUtils.encodeToken({ - token: [feeSavedTokenEntry], + token: [returnedTokenEntry], unit, }) - transaction.setOutputToken(outputToken) - - lightningFeePaid = meltQuote.fee_reserve - feeSaved + transaction.setOutputToken(outputToken) + + totalFeePaid = totalFeePaid - returnedAmount + lightningFeePaid = totalFeePaid - meltFeeReserve + } else { + // Spend pending proofs that were used to settle the lightning invoice + proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false) } // release lock lockedProofsCounter.resetInFlight(transactionId) - // Save preimage - if(preimage) { - transaction.setProof(preimage) - } - // Save final fee in db - if(lightningFeePaid + mintFeePaid !== meltQuote.fee_reserve) { - transaction.setFee(lightningFeePaid + mintFeePaid) + if(totalFeePaid !== transaction.fee) { + transaction.setFee(totalFeePaid) } // Update transaction status transactionData.push({ - status: TransactionStatus.COMPLETED, - lightningFeeReserve: meltQuote.fee_reserve, + status: TransactionStatus.COMPLETED, lightningFeePaid, + mintFeesPaid: totalFeePaid - lightningFeePaid, + returnedAmount, preimage, counter: lockedProofsCounter.counter, createdAt: new Date(), - }) - - // this overwrites transactionData and COMPLETED status already set by syncStateWithMintSync + }) + transaction.setStatus( TransactionStatus.COMPLETED, JSON.stringify(transactionData), @@ -315,9 +275,10 @@ export const transferTask = async function ( taskFunction: TRANSFER, mintUrl, transaction, - message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(lightningFeePaid + mintFeePaid, getCurrency(unit).code)} ${getCurrency(unit).code}.`, - lightningFeePaid, - mintFeePaid, + message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`, + lightningFeePaid, + mintFees: totalFeePaid - lightningFeePaid, + totalFeePaid, meltQuote, preimage, nwcEvent @@ -337,9 +298,7 @@ export const transferTask = async function ( mintUrl, transaction, message: `Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.`, - mintFeePaid, - meltQuote, - preimage, + meltQuote, nwcEvent } as TransactionTaskResult @@ -359,16 +318,12 @@ export const transferTask = async function ( } let message = e.message - - // If Exception was trigerred most likely by walletStore.payLightningMelt() - if (proofsToPay.length > 0) { - // check with the mint if proofs are not pending by mint, if yes, - // sync sets transaction status as PENDING (timeout-ed/hodled lightning payments) - const proofsToSync = proofsStore.getByMint(mintUrl, {isPending: true}) - + + if (proofsToMeltFrom.length > 0) { + // check with the mint the real status of the proofs involved in transaction await WalletTask.syncStateWithMintSync( { - proofsToSync, + proofsToSync: proofsToMeltFrom, mintUrl, isPending: true } @@ -378,27 +333,49 @@ export const transferTask = async function ( const refreshed = transactionsStore.findById(transaction.id) if(refreshed?.status === TransactionStatus.PENDING) { - log.warn('[transfer]', 'proofsToPay from transfer with error are pending by mint', { - proofsToPayAmount, + log.warn('[transfer]', 'proofsToMeltFrom from transfer with error are pending by mint', { + proofsToMeltFromAmount, unit, transactionId: transaction.id }) message = 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.' + } else if(refreshed?.status === TransactionStatus.COMPLETED) { + // Likely receiving of change failed due to wallet error. We keep completed as status. + log.error('[transfer]', 'Transfer throwed error but the payment suceeded', { + error: e.message, + proofsToMeltFromAmount, + unit, + transactionId: transaction.id + }) + + const message = `Lightning invoice has been successfully paid, however some error occured: ${e.message}` + + return { + taskFunction: TRANSFER, + mintUrl, + transaction, + message, + meltQuote, + preimage: meltQuote?.payment_preimage, + nwcEvent + } as TransactionTaskResult + } else { + // syncStateWithMintSync returns to spendable only proofs that were pending by mint before so we need to take care of our transfer here. log.warn('[transfer]', 'proofsToPay from transfer with error to be returned to spendable wallet', { - proofsToPayAmount, + proofsToMeltFromAmount, unit, transactionId: transaction.id }) // remove it from pending proofs in the wallet - proofsStore.removeProofs(proofsToPay as Proof[], true, true) + proofsStore.removeProofs(proofsToMeltFrom, true, true) // add proofs back to the spendable wallet with internal references WalletUtils.addCashuProofs( mintUrl, - proofsToPay, + proofsToMeltFrom, { unit, transactionId: transaction.id, diff --git a/src/services/wallet/utils.ts b/src/services/wallet/utils.ts index f12a14ef..3f948d57 100644 --- a/src/services/wallet/utils.ts +++ b/src/services/wallet/utils.ts @@ -29,7 +29,11 @@ const lockAndSetInFlight = async function ( const walletInstance = await walletStore.getWallet(mint.mintUrl, unit, {withSeed: true}) const currentCounter = mint.getProofsCounterByKeysetId!(walletInstance.keys.id) - log.info('[lockAndSetInFlight] Before lock', {transactionId, counter: currentCounter.counter}) + log.info('[lockAndSetInFlight] Before lock', { + transactionId, + counter: currentCounter.counter, + countOfInFlightProofs + }) if(!currentCounter) { throw new AppError(Err.VALIDATION_ERROR, 'Missing ProofsCounter.') diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 5d2d6d18..43646a31 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -26,11 +26,13 @@ import { receiveTask, receiveOfflinePrepareTask, receiveOfflineCompleteTask} fro import { sendTask } from './wallet/sendTask' import { topupTask } from './wallet/topupTask' import { transferTask } from './wallet/transferTask' +import { nwcTransferTask } from './wallet/nwcTransferTask' +import { revertTask } from './wallet/revertTask' import { WalletUtils } from './wallet/utils' import { NotificationService } from './notificationService' import { MintUnit, formatCurrency, getCurrency } from './wallet/currency' import { MinibitsClient } from './minibitsService' -import { revertTask } from './wallet/revertTask' + export const MAX_SWAP_INPUT_SIZE = 100 @@ -71,6 +73,16 @@ type WalletTaskService = { encodedInvoice: string, nwcEvent?: NostrEvent ) => Promise + nwcTransfer: ( + mintBalanceToTransferFrom: MintBalance, + amountToTransfer: number, + feeReserve: number, + unit: MintUnit, + memo: string, + invoiceExpiry: Date, + encodedInvoice: string, + nwcEvent: NostrEvent + ) => Promise receive: ( token: TokenV3, amountToReceive: number, @@ -123,8 +135,9 @@ export interface WalletTaskResult { export interface TransactionTaskResult extends WalletTaskResult { transaction?: Transaction + swapFeePaid?: number lightningFeePaid?: number - mintFeePaid?: number + meltFeePaid?: number meltQuote?: MeltQuoteResponse nwcEvent?: NostrEvent } @@ -199,6 +212,34 @@ const transfer = async function ( } +const nwcTransfer = async function ( + mintBalanceToTransferFrom: MintBalance, + amountToTransfer: number, + feeReserve: number, + unit: MintUnit, + memo: string, + invoiceExpiry: Date, + encodedInvoice: string, + nwcEvent: NostrEvent +): Promise { + const now = new Date().getTime() + SyncQueue.addPrioritizedTask( + `nwcTransferTask-${now}`, + async () => await nwcTransferTask( + mintBalanceToTransferFrom, + amountToTransfer, + feeReserve, + unit, + memo, + invoiceExpiry, + encodedInvoice, + nwcEvent + ) + ) + return +} + + const receive = async function ( token: TokenV3, amountToReceive: number, @@ -1806,7 +1847,8 @@ export const WalletTask: WalletTaskService = { receiveOfflineComplete, send, sendAll, - transfer, + transfer, + nwcTransfer, topup, revert } diff --git a/yarn.lock b/yarn.lock index ee1c2b62..0ed83388 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1648,9 +1648,9 @@ __metadata: languageName: node linkType: hard -"@cashu/cashu-ts@minibits-cash/cashu-ts#commit=1a60b7859627829dc29293d78a945095c335de63": +"@cashu/cashu-ts@minibits-cash/cashu-ts#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57": version: 1.0.0-minibits.6 - resolution: "@cashu/cashu-ts@https://github.com/minibits-cash/cashu-ts.git#commit=1a60b7859627829dc29293d78a945095c335de63" + resolution: "@cashu/cashu-ts@https://github.com/minibits-cash/cashu-ts.git#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57" dependencies: "@cashu/crypto": ^0.2.6 "@noble/curves": ^1.3.0 @@ -1658,7 +1658,7 @@ __metadata: "@scure/bip32": ^1.3.3 "@scure/bip39": ^1.2.2 buffer: ^6.0.3 - checksum: 79a83bca6644836268903d45ca5e43e863dbc48fc8ac6073fb218cf1c4ad05d9b70fcfa9e83a1e5edbe55bbc6128c281a6a769c490a36e7bc3d3ebcb5c48be4d + checksum: dfeebbfe10b99429da21848257eb8a320cb9936909487686d2847e2cdcc61390140ca4fd8da87227c66e3e1c972dc0c1e5689f7d197e55b873f202138a28189c languageName: node linkType: hard @@ -9426,7 +9426,7 @@ __metadata: "@babel/core": ^7.20.0 "@babel/preset-env": ^7.20.0 "@babel/runtime": ^7.20.0 - "@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=1a60b7859627829dc29293d78a945095c335de63" + "@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57" "@exodus/borc": ^2.1.1 "@fortawesome/fontawesome-svg-core": ^6.6.0 "@fortawesome/free-brands-svg-icons": ^6.6.0