diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index 9f153bbf..e4e22467 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -468,6 +468,7 @@ "invoice": "Lightning invoice to pay", "isOffline": "Redeem online", "lightningFee": "Lightning network fee", + "fee": "Fee", "memoFromInvoice": "Memo from invoice", "memoFromSender": "Memo from sender", "receiveOfflineComplete": "Redeem to wallet", diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 10d3d0ff..c26c7646 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -15,6 +15,7 @@ import { getRootStore } from './helpers/getRootStore' import { generateId } from '../utils/utils' import { Proof } from './Proof' + // used as a helper type across app /* export type Balance = { balance: number @@ -52,7 +53,7 @@ export type MintProofsCounter = { inFlightTid?: number // related tx id } -export const MintProofsCounterModel = types +/* export const MintProofsCounterModel = types .model('MintProofsCounter', { keyset: types.string, unit: types.optional(types.frozen(), 'sat'), @@ -60,7 +61,7 @@ export const MintProofsCounterModel = types inFlightFrom: types.maybe(types.number), inFlightTo: types.maybe(types.number), inFlightTid: types.maybe(types.number) - }) + })*/ /** * This represents a Cashu mint */ @@ -73,7 +74,16 @@ export const MintModel = types units: types.array(types.frozen()), keysets: types.array(types.frozen()), keys: types.array(types.frozen()), - proofsCounters: types.array(MintProofsCounterModel), + proofsCounters: types.array( + types.model('MintProofsCounter', { + keyset: types.string, + unit: types.optional(types.frozen(), 'sat'), + 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()), @@ -147,6 +157,7 @@ export const MintModel = types const alreadyExists = self.proofsCounters.some(p => p.keyset === counter.keyset) if(!alreadyExists) { + log.trace('[addProofsCounter]', {counter}) self.proofsCounters.push(counter) } @@ -180,43 +191,44 @@ export const MintModel = types counter: 0, } - const proofsCounterInstance = MintProofsCounterModel.create(newCounter) self.addProofsCounter(newCounter) - - log.trace('[ceateProofsCounter]', {newCounter: getSnapshot(proofsCounterInstance)}) - return proofsCounterInstance + return self.getProofsCounter(keyset.id) } })) .actions(self => ({ initKeyset(keyset: CashuMintKeyset) { // Do not add unit the wallet does not have configured - if(!self.isUnitSupported(keyset.unit as MintUnit)) { - throw new AppError(Err.VALIDATION_ERROR, `Unsupported unit provided by the mint: ${keyset.unit}`) - } - - const existing = self.keysets.find(k => k.id === keyset.id) - - if(existing) { - if (existing.unit !== keyset.unit) { - throw new AppError(Err.VALIDATION_ERROR, `Keyset unit mismatch, got ${keyset.unit}, expected ${existing.unit}`) - } + try { + if(!self.isUnitSupported(keyset.unit as MintUnit)) { + throw new AppError(Err.VALIDATION_ERROR, `Unsupported unit provided by the mint: ${keyset.unit}`) + } - return existing - } + const existing = self.keysets.find(k => k.id === keyset.id) - if(!keyset.input_fee_ppk) { - keyset.input_fee_ppk = 0 - } + if(existing) { + if (existing.unit !== keyset.unit) { + throw new AppError(Err.VALIDATION_ERROR, `Keyset unit mismatch, got ${keyset.unit}, expected ${existing.unit}`) + } + + return existing + } - if(!keyset.unit) { - keyset.unit = 'sat' - } + if(!keyset.input_fee_ppk) { + keyset.input_fee_ppk = 0 + } - self.addKeyset(keyset) - self.addUnit(keyset.unit as MintUnit) - self.createProofsCounter(keyset) + if(!keyset.unit) { + keyset.unit = 'sat' + } - log.trace('[initKeyset]', {newKeyset: keyset}) + self.addKeyset(keyset) + self.addUnit(keyset.unit as MintUnit) + self.createProofsCounter(keyset) + + log.trace('[initKeyset]', {newKeyset: keyset}) + } catch (e: any) { + throw new AppError(Err.WALLET_ERROR, '[initKeyset] ' + e.message) + } }, initKeys(key: CashuMintKeys) { // Do not add unit the wallet does not have configured @@ -409,7 +421,7 @@ export const MintModel = types self.proofsCounters = cast(self.proofsCounters) }, - getFeesForProofs(proofs: Proof[]): number { + getMintFeeReserve(proofs: Proof[]): number { // Find the corresponding keyset for each proof and sum the input fees const totalInputFees = proofs.reduce((sum, proof) => { const keyset = self.keysets.find(k => k.id === proof.id) @@ -417,10 +429,10 @@ export const MintModel = types }, 0) // Calculate the fees - const fees = Math.max(Math.floor((totalInputFees + 999) / 1000), 0) + const feeReserve = Math.max(Math.floor((totalInputFees + 999) / 1000), 0) - log.debug('*** [getFeesForProofs]', {fees}) - return fees + log.debug('[getMintFeeReserve]', {feeReserve}) + return feeReserve } })) .views(self => ({ diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 05c80110..228c9f71 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -45,15 +45,25 @@ export const ProofsStoreModel = types getByMint( mintUrl: string, options: { - unit?: MintUnit, isPending: boolean, + unit?: MintUnit, + keysetIds?: string[] } - ): Proof[] | undefined { - const proofs = options.isPending ? self.pendingProofs : self.proofs - + let proofs: Proof[] = [] + if(options.keysetIds && options.keysetIds.length > 0) { + proofs = options.isPending ? + self.pendingProofs.filter(p => options.keysetIds?.includes(p.id)) : + self.proofs.filter(p => options.keysetIds?.includes(p.id)) + } else { + proofs = options.isPending ? self.pendingProofs : self.proofs + } + if (options.unit) { - return proofs.filter(proof => proof.mintUrl === mintUrl && proof.unit === options.unit) + return proofs.filter( + proof => proof.mintUrl === mintUrl && + proof.unit === options.unit + ) .slice() .sort((a, b) => b.amount - a.amount) } @@ -62,7 +72,7 @@ export const ProofsStoreModel = types .slice() .sort((a, b) => b.amount - a.amount) - }, + }, getProofInstance(proof: Proof, isPending: boolean = false) { let proofInstance: Proof | undefined if (isStateTreeNode(proof)) { diff --git a/src/screens/ReceiveScreen.tsx b/src/screens/ReceiveScreen.tsx index a9b6f665..0c3c998e 100644 --- a/src/screens/ReceiveScreen.tsx +++ b/src/screens/ReceiveScreen.tsx @@ -389,9 +389,6 @@ export const ReceiveScreen: FC> = observer( )} )} - {isLoading && } - {error && } - {info && } > = observer( onBackButtonPress={toggleResultModal} onBackdropPress={toggleResultModal} /> + {isLoading && } + {error && } + {info && } ) }, diff --git a/src/screens/SendScreen.tsx b/src/screens/SendScreen.tsx index da384ae0..d5860ee8 100644 --- a/src/screens/SendScreen.tsx +++ b/src/screens/SendScreen.tsx @@ -304,7 +304,7 @@ export const SendScreen: FC> = observer( contactToSendTo?.picture ) - return navigation.navigate('Wallet', {}) + // return navigation.navigate('Wallet', {}) } catch(e: any) { log.error(e.name, e.message) // silent } @@ -687,9 +687,6 @@ export const SendScreen: FC> = observer( )} - {isLoading && } - {error && } - {info && } > = observer( onBackButtonPress={toggleResultModal} onBackdropPress={toggleResultModal} /> + {isLoading && } + {error && } + {info && } ) } diff --git a/src/screens/TranDetailScreen.tsx b/src/screens/TranDetailScreen.tsx index 73994dda..6c5a4a8d 100644 --- a/src/screens/TranDetailScreen.tsx +++ b/src/screens/TranDetailScreen.tsx @@ -556,6 +556,12 @@ const ReceiveInfoBlock = function (props: { label="tranDetailScreen.type" value={transaction.type as string} /> + {encodedTokenToRetry ? ( @@ -808,6 +814,12 @@ const ReceiveOfflineInfoBlock = function (props: { label="tranDetailScreen.type" value={transaction.type as string} /> + + {transaction.status === TransactionStatus.PENDING ? ( @@ -1202,6 +1220,12 @@ const TopupInfoBlock = function (props: { label="tranDetailScreen.type" value={transaction.type as string} /> + {paymentRequest && isInternetReachable ? ( @@ -1408,10 +1432,6 @@ const TransferInfoBlock = function (props: { isCurrency={true} isFirst={true} /> - {transaction.memo && ( + mint.mint === mintUrl) - - if (mintIndex === -1) { - throw new AppError( - Err.VALIDATION_ERROR, - `Mint ${mintUrl} not found in token`, - ) - } - - // Clone the token instance - const updatedToken: Token = cloneDeep(token) - - // Update the proofs for the specified mint - updatedToken.token[mintIndex].proofs = updatedProofs - - return updatedToken -} - - const getProofsFromTokenEntries = (tokenEntries: TokenEntry[]) => { const proofs: Proof[] = [] @@ -187,6 +162,63 @@ const getProofsFromTokenEntries = (tokenEntries: TokenEntry[]) => { } +const findExactMatch = function (requestedAmount: number, proofs: Proof[]): Proof[] | null { + const result: Proof[] = []; + function backtrack(start: number, remaining: number): boolean { + if (remaining === 0) { + return true; + } + for (let i = start; i < proofs.length; i++) { + if (proofs[i].amount > remaining) continue; + result.push(proofs[i]); + if (backtrack(i + 1, remaining - proofs[i].amount)) { + return true; + } + result.pop(); + } + return false; + } + + proofs.sort((a, b) => b.amount - a.amount); + if (backtrack(0, requestedAmount)) { + return result; + } + return null; +} + +const findMinExcess = function (requestedAmount: number, proofs: Proof[]): Proof[] { + proofs.sort((a, b) => b.amount - a.amount); + const selectedProofs: Proof[] = []; + let currentAmount = 0; + + for (const proof of proofs) { + if (currentAmount >= requestedAmount) { + break; + } + selectedProofs.push(proof); + currentAmount += proof.amount; + } + + return selectedProofs; +} + +const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Proof[] { + const proofsAmount = getProofsAmount(proofs) + if(requestedAmount > proofsAmount) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount', + {requestedAmount, proofsAmount, caller: 'getProofsToSend'}) + } + const exactMatch = findExactMatch(requestedAmount, proofs); + if (exactMatch) { + return exactMatch; + } + + return findMinExcess(requestedAmount, proofs); +} + + /** * returns a subset of tokens, so that not all tokens are sent to mint for smaller amounts. * @param amount @@ -204,51 +236,6 @@ const getProofsFromTokenEntries = (tokenEntries: TokenEntry[]) => { return proofSubset } */ -export const getProofsToSend = (amount: number, proofs: Proof[]) => { - if (proofs.reduce((s, t) => (s += t.amount), 0) < amount) { - // there are not enough proofs to pay the amount - throw new AppError(Err.VALIDATION_ERROR, 'Not enough proofs to match requested amount', {amount}) - } - - // sort proofs by amount ascending - proofs = proofs.slice().sort((a, b) => a.amount - b.amount); - // remember next bigger proof as a fallback - const nextBigger = proofs.find((p) => p.amount > amount); - - // go through smaller proofs until sum is bigger than amount - const smallerProofs = proofs.filter((p) => p.amount <= amount); - // sort by amount descending - smallerProofs.sort((a, b) => b.amount - a.amount); - - let selectedProofs: Proof[] = []; - - if (smallerProofs.length == 0 && nextBigger) { - // if there are no smaller proofs, take the next bigger proof as a fallback - return [nextBigger]; - } else if (smallerProofs.length == 0 && !nextBigger) { - // no proofs available - return []; - } - - // recursively select the largest proof of smallerProofs, subtract the amount from the remainder - // and call coinSelect again with the remainder and the rest of the smallerProofs (without the largest proof) - let remainder = amount; - selectedProofs = [smallerProofs[0]]; - remainder -= smallerProofs[0].amount; - if (remainder > 0) { - selectedProofs = selectedProofs.concat(getProofsToSend(remainder, smallerProofs.slice(1))); - } - let sum = selectedProofs.reduce((s, t) => (s += t.amount), 0); - - // if sum of selectedProofs is smaller than amount, take next bigger proof instead as a fallback - if (sum < amount && nextBigger) { - selectedProofs = [nextBigger]; - } - - log.trace("[getProofsToSend] ### selected amounts", "sum", selectedProofs.reduce((s, t) => (s += t.amount), 0), selectedProofs.map(p => p.amount)); - return selectedProofs -} - /** * removes a set of tokens from another set of tokens, and returns the remaining. @@ -303,7 +290,7 @@ const getMintFromProof = function ( ): Mint | undefined { let mint: Mint | undefined mints.forEach(m => { - if (m.keysets?.includes(proof.id)) { + if (m.keysetIds?.includes(proof.id)) { mint = m } }) @@ -322,7 +309,8 @@ export const CashuUtils = { getProofsAmount, getAmountPreferencesCount, getMintsFromToken, - updateMintProofs, + findMinExcess, + // updateMintProofs, getProofsFromTokenEntries, getProofsToSend, getProofsSubset, diff --git a/src/services/cashuMintClient.ts b/src/services/cashuMintClient.ts index c2c15633..24b82b34 100644 --- a/src/services/cashuMintClient.ts +++ b/src/services/cashuMintClient.ts @@ -26,6 +26,7 @@ import { isObj } from '@cashu/cashu-ts/src/utils' import { JS_BUNDLE_VERSION } from '@env' import { MintUnit } from './wallet/currency' import { getSnapshot } from 'mobx-state-tree' +import { Token } from '../models/Token' let _mints: CashuMint[] = [] let _wallets: CashuWallet[] = [] // used where seed is not required (perf) @@ -114,22 +115,22 @@ const getMint = async function (mintUrl: string): Promise { // get mint model from wallet state const mintInstance = mintsStore.findByUrl(mintUrl) - if(!mintInstance) { - throw new AppError(Err.NOTFOUND_ERROR, 'Missing mint in the wallet state', {mintUrl}) + // skip checks if this is new mint being added + if(mintInstance) { + const newKeysets = keysets.filter(freshKeyset => { + return !mintInstance.keysets.some(keyset => keyset.id === freshKeyset.id); + }) + + if(newKeysets.length > 0) { + // if we heve new keysets, get and sync new keys + const {keysets} = await newMint.getKeys() + mintInstance.refreshKeys(keysets) + } + + // sync wallet state with fresh keysets, active statuses and keys + mintInstance.refreshKeysets(keysets) } - // check if we've got new keysets not existing in wallet state - const newKeysets = keysets.filter(freshKeyset => { - return !mintInstance.keysets.some(keyset => keyset.id === freshKeyset.id); - }) - - if(newKeysets.length > 0) { - // if we heve new keysets, get and sync new keys - const {keysets} = await newMint.getKeys() - mintInstance.refreshKeys(keysets) - } - - // sync wallet state with fresh keysets, active statuses and keys - mintInstance.refreshKeysets(keysets) + // store cashu-ts mint instance in memory _mints.push(newMint) @@ -142,13 +143,13 @@ const getWallet = async function ( mintUrl: string, unit: MintUnit, options?: { - keysetId?: string // force specific (inactive) keys to swap from + keysetId?: string withSeed: boolean } ): Promise { // syncs mint model in wallet state and returns cashu-ts mint class instance const cashuMint = await getMint(mintUrl) - + // mint model from wallet state const mintInstance = mintsStore.findByUrl(mintUrl) if(!mintInstance) { @@ -156,7 +157,7 @@ const getWallet = async function ( mintUrl }) } - + // select keys to be used to find or create new cashu-ts wallet instance let walletKeys: MintKeys if(options && options.keysetId) { @@ -181,7 +182,9 @@ const getWallet = async function ( walletKeys = requestedKeys } else { // if not we find first active keyset and related keys - const activeKeyset = mintInstance.keysets.find(k => k.unit === unit && k.active === true) + const activeKeyset = mintInstance.keysets.find(k => k.unit === unit && k.active === true) + + if(!activeKeyset) { throw new AppError(Err.VALIDATION_ERROR, 'Wallet has not any active keyset for the selected unit.', { @@ -199,7 +202,7 @@ const getWallet = async function ( }) } - walletKeys = getSnapshot(activeKeys) + walletKeys = activeKeys } if (options && options.withSeed) { @@ -288,6 +291,7 @@ const receive = async function ( mintUrl: string, unit: MintUnit, decodedToken: CashuToken, + mintFeeReserve: number, options: { preference: AmountPreference[], counter: number @@ -301,8 +305,11 @@ const receive = async function ( withSeed: true, }) + const amountToReceive = CashuUtils.getTokenAmounts(decodedToken as Token).totalAmount + const proofs = await cashuWallet.receive( - decodedToken, + decodedToken, + mintFeeReserve, { keysetId: cashuWallet.keys.id, preference: options.preference, @@ -311,7 +318,10 @@ const receive = async function ( privkey: undefined }) - return proofs + const receivedAmount = CashuUtils.getProofsAmount(proofs as Proof[]) + const mintFeePaid = amountToReceive - receivedAmount + + return {proofs, mintFeePaid} } catch (e: any) { throw new AppError(Err.MINT_ERROR, e.message) } @@ -322,6 +332,7 @@ const receive = async function ( const send = async function ( mintUrl: string, amountToSend: number, + mintFeeReserve: number, unit: MintUnit, proofsToSendFrom: Proof[], options: { @@ -337,10 +348,11 @@ const send = async function ( withSeed: true, }) - log.debug('[MintClient.sendFromMint] counter', options.counter) + log.debug('[MintClient.send] counter', options.counter) const {returnChange, send} = await cashuWallet.send( amountToSend, + mintFeeReserve, proofsToSendFrom, { keysetId: cashuWallet.keys.id, @@ -351,8 +363,8 @@ const send = async function ( } ) - log.debug('[MintClient.sendFromMint] returnedProofs', returnChange) - log.debug('[MintClient.sendFromMint] proofsToSend', send) + log.debug('[MintClient.send] returnedProofs', returnChange) + log.debug('[MintClient.send] proofsToSend', send) // do some basic validations that proof amounts from mints match const totalAmountToSendFrom = CashuUtils.getProofsAmount(proofsToSendFrom) @@ -397,10 +409,8 @@ const getSpentOrPendingProofsFromMint = async function ( unit: MintUnit, proofs: Proof[], ) { - try { - - const cashuWallet = await getWallet(mintUrl, unit, {withSeed: true}) - + try { + const cashuWallet = await getWallet(mintUrl, unit, {withSeed: true}) const spentPendingProofs = await cashuWallet.checkProofsSpent(proofs) log.trace('[CashuMintClient.getSpentOrPendingProofsFromMint]', {mintUrl, spentPendingProofs}) diff --git a/src/services/wallet/receiveTask.ts b/src/services/wallet/receiveTask.ts index ff52f2de..f6e18449 100644 --- a/src/services/wallet/receiveTask.ts +++ b/src/services/wallet/receiveTask.ts @@ -12,12 +12,15 @@ import {CashuUtils} from '../cashu/cashuUtils' import AppError, {Err} from '../../utils/AppError' import {Token} from '../../models/Token' import { - type Token as CashuToken + TokenEntry, + type Token as CashuToken, + type Proof as CashuProof } from '@cashu/cashu-ts' import { getDefaultAmountPreference, getEncodedToken } from '@cashu/cashu-ts/src/utils' import { TransactionTaskResult } from '../walletService' import { WalletUtils } from './utils' import { MintUnit, formatCurrency, getCurrency } from './currency' +import { Proof } from '../../models/Proof' const { mintsStore, @@ -120,9 +123,12 @@ export const receiveTask = 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) + // is not received our recovery index counts for sigs the mint has already issued const amountPreferences = getDefaultAmountPreference(amountToReceive) const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences) + const tokenEntries: Array = token.token + const proofsToReceive = tokenEntries[0].proofs as Proof[] + const mintFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive) log.trace('[receiveTask]', 'proofsCounter initial state', {proofsCounter: mintInstance.getProofsCounterByUnit?.(unit)}) log.trace('[receiveTask]', 'amountPreferences', {amountPreferences, transactionId}) @@ -132,17 +138,21 @@ export const receiveTask = async function ( await WalletUtils.lockAndSetInFlight(mintInstance, unit, countOfInFlightProofs, transactionId) // get locked counter values - const lockedProofsCounter = mintInstance.getProofsCounterByUnit?.(unit) + const lockedProofsCounter = mintInstance.getProofsCounterByUnit?.(unit)! - const receivedProofs = await MintClient.receive( + const receivedResult = await MintClient.receive( mintToReceive, unit as MintUnit, token, + mintFeeReserve, { preference: amountPreferences, counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase } - ) + ) + + const receivedProofs = receivedResult.proofs + const mintFeePaid = receivedResult.mintFeePaid // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs mintInstance.decreaseProofsCounter(lockedProofsCounter.keyset, countOfInFlightProofs) @@ -163,6 +173,8 @@ export const receiveTask = async function ( transactionData.push({ status: TransactionStatus.PREPARED, updatedToken, + mintFeeReserve, + mintFeePaid, createdAt: new Date(), }) @@ -210,6 +222,10 @@ export const receiveTask = async function ( const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! await transactionsStore.updateBalanceAfter(transactionId, balanceAfter) + if(mintFeePaid > 0) { + await transactionsStore.updateFee(transactionId, mintFeePaid) + } + return { taskFunction: RECEIVE, mintUrl: mintInstance.mintUrl, @@ -435,7 +451,12 @@ 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 amountPreferences = getDefaultAmountPreference(transaction.amount) + + const tokenEntries: Array = 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) @@ -445,23 +466,27 @@ export const receiveOfflineCompleteTask = async function ( await WalletUtils.lockAndSetInFlight(mintInstance, unit, countOfInFlightProofs, transaction.id as number) // get locked counter values - const lockedProofsCounter = mintInstance.getProofsCounterByUnit?.(unit) + const lockedProofsCounter = mintInstance.getProofsCounterByUnit?.(unit)! - const receivedProofs = await MintClient.receive( + const receivedResult = await MintClient.receive( mintToReceive, unit as MintUnit, token, + mintFeeReserve, { preference: amountPreferences, counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase } - ) + ) + + const receivedProofs = receivedResult.proofs + const mintFeePaid = receivedResult.mintFeePaid // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs mintInstance.decreaseProofsCounter(lockedProofsCounter.keyset, countOfInFlightProofs) // store swapped proofs as encoded token in tx data - const tokenEntryToSend = { + const tokenEntryToSend: TokenEntry = { mint: mintToReceive, proofs: receivedProofs, } @@ -475,6 +500,8 @@ export const receiveOfflineCompleteTask = async function ( transactionData.push({ status: TransactionStatus.PREPARED, updatedToken, + mintFeeReserve, + mintFeePaid, createdAt: new Date(), }) @@ -522,6 +549,10 @@ export const receiveOfflineCompleteTask = async function ( const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! await transactionsStore.updateBalanceAfter(transaction.id as number, balanceAfter) + if(mintFeePaid > 0) { + await transactionsStore.updateFee(transaction.id as number, mintFeePaid) + } + return { taskFunction: RECEIVE_OFFLINE_COMPLETE, mintUrl: mintInstance.mintUrl, diff --git a/src/services/wallet/sendTask.ts b/src/services/wallet/sendTask.ts index 9a056426..382784a5 100644 --- a/src/services/wallet/sendTask.ts +++ b/src/services/wallet/sendTask.ts @@ -18,13 +18,13 @@ import { } from '@cashu/cashu-ts' import { getDefaultAmountPreference, isObj } from '@cashu/cashu-ts/src/utils' import { TransactionTaskResult, WalletTask } from '../walletService' -import { MintBalance } from '../../models/Mint' +import { MintBalance, MintProofsCounter } from '../../models/Mint' import { Proof } from '../../models/Proof' import { poller } from '../../utils/poller' import { WalletUtils } from './utils' import { getSnapshot, isStateTreeNode } from 'mobx-state-tree' import { MintUnit } from './currency' -import { getProofsToSend } from '../../services/cashu/cashuUtils' +import { boolean } from 'mobx-state-tree/dist/internal' const { mintsStore, @@ -129,6 +129,10 @@ export const sendTask = async function ( const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance! await transactionsStore.updateBalanceAfter(transactionId, balanceAfter) + + if(mintFeePaid > 0) { + await transactionsStore.updateFee(transactionId, mintFeePaid) + } log.trace('[send] totalBalance after', balanceAfter) @@ -203,9 +207,11 @@ export const sendFromMint = async function ( ) } - const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit}) as Proof[] - - log.debug('[sendFromMint]', 'proofsFromMint count', {proofsCount: proofsFromMint.length, amountToSend}) + + + const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit}) as Proof[] + + log.debug('[sendFromMint]', 'proofsFromMint count', {mintBalance: mintBalance.balances[unit], amountToSend}) if (proofsFromMint.length < 1) { throw new AppError( @@ -219,8 +225,8 @@ export const sendFromMint = async function ( if (totalAmountFromMint < amountToSend) { throw new AppError( Err.VALIDATION_ERROR, - 'There is not enough funds to send this payment', - {totalAmountFromMint, amountToSend}, + 'There is not enough funds to send this amount', + {totalAmountFromMint, amountToSend, caller: 'sendFromMint'}, ) } @@ -259,25 +265,78 @@ export const sendFromMint = async function ( } /* - * if we did not selected ecash but amount and we might need a swap of ecash by the mint to match exact amount - */ + * SWAP or DIRECT SEND + * if we did not selected ecash but amount and we might need a swap of ecash by the mint to match exact amount + */ + + // Prioritize send from inactive keysets + let proofsToSendFrom: Proof[] = [] + const inactiveKeysetIds = mintInstance?.keysets.filter(k => k.active === false).map(k => k.id) + const activeKeysetIds = mintInstance?.keysets.filter(k => k.active === true).map(k => k.id) - const proofsToSendFrom = getProofsToSend( - amountToSend, - proofsFromMint, - ) + log.trace('[sendFromMint]', {inactiveKeysetIds, activeKeysetIds}) + + if(inactiveKeysetIds.length > 0) { + + const proofsFromInactiveKeysets = proofsStore.getByMint(mintUrl, {isPending: false, unit, keysetIds: inactiveKeysetIds}) + const proofsFromActiveKeysets = proofsStore.getByMint(mintUrl, {isPending: false, unit, keysetIds: activeKeysetIds}) - let proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) + if(proofsFromInactiveKeysets && proofsFromInactiveKeysets.length > 0) { + let proofsFromInactiveKeysetsAmount = CashuUtils.getProofsAmount(proofsFromInactiveKeysets) - // swap will happen if we could not select proofs equal to amountToSend - let returnedAmount = proofsToSendFromAmount - amountToSend + log.trace('[sendFromMint]', {proofsFromInactiveKeysetsAmount}) + + if(proofsFromInactiveKeysetsAmount >= amountToSend) { + proofsToSendFrom = CashuUtils.getProofsToSend( + amountToSend, + proofsFromInactiveKeysets + ) + } else { + const remainingAmount = amountToSend - proofsFromInactiveKeysetsAmount + const remainingProofs = CashuUtils.findMinExcess(remainingAmount, proofsFromActiveKeysets!) + proofsToSendFrom = CashuUtils.getProofsToSend( + amountToSend, + [...proofsFromInactiveKeysets, ...remainingProofs] + ) + } + } else { + proofsToSendFrom = CashuUtils.getProofsToSend( + amountToSend, + proofsFromMint + ) + } + } else { + proofsToSendFrom = CashuUtils.getProofsToSend( + amountToSend, + proofsFromMint + ) + } + + let proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) + // swap will happen if we could not select proofs equal to amountToSend let mintFeeReserve: number = 0 + let mintFeePaid: number = 0 + let proofsToSend: Proof[] = [] + let returnedProofs: Proof[] = [] + let isSwapNeeded: boolean = proofsToSendFromAmount - amountToSend > 0 ? true : false + let returnedAmount = 0 - if(returnedAmount > 0) { - mintFeeReserve = mintInstance.getFeesForProofs(proofsToSendFrom) + log.trace('[sendFromMint]', {proofsToSendFromAmount, amountToSend}) + /* + * SWAP is needed, could involve a fee + * if we did not selected ecash but amount and we might need a swap of ecash by the mint to match exact amount + */ + 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 mint will keep + returnedAmount = proofsToSendFromAmount - amountToSend - mintFeeReserve + + log.debug('[sendFromMint] Swap is needed.', {mintFeeReserve, returnedAmount}) + // if we did not selected enough proofs to cover the fees we need some more - if(mintFeeReserve > returnedAmount) { - const missingFeesAmount = mintFeeReserve - returnedAmount + if(returnedAmount < 0) { + const missingFeesAmount = Math.abs(returnedAmount) const remainingProofs = proofsStore.getProofsSubset(proofsFromMint, proofsToSendFrom) const remainingProofsAmount = CashuUtils.getProofsAmount(remainingProofs) @@ -290,8 +349,8 @@ export const sendFromMint = async function ( {totalAmountFromMint, amountToSend, mintFeeReserve}, ) } - - const proofsToPayFees = getProofsToSend( + // select additional proof(s) to pay fees + const proofsToPayFees = CashuUtils.getProofsToSend( missingFeesAmount, remainingProofs ) @@ -299,51 +358,51 @@ export const sendFromMint = async function ( // add more proofs into inputs and recalculate amounts proofsToSendFrom.push(...proofsToPayFees) proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) - returnedAmount = proofsToSendFromAmount - amountToSend + returnedAmount = proofsToSendFromAmount - amountToSend - mintFeeReserve } - // decrease requested returned outputs by fees so that the mint can charge them - returnedAmount -= mintFeeReserve - } + // Outputs denominations we asked for to get + const amountPreferences = getDefaultAmountPreference(amountToSend) + // Output denominations we are about to get as a split remainder + const returnedAmountPreferences = getDefaultAmountPreference(returnedAmount) - // Inputs we are about to send - const amountPreferences = getDefaultAmountPreference(amountToSend) - // Outputs we are about to get back if swap occurs - const returnedAmountPreferences = getDefaultAmountPreference(returnedAmount) + const countOfProofsToSend = CashuUtils.getAmountPreferencesCount(amountPreferences) + const countOfReturnedProofs = CashuUtils.getAmountPreferencesCount(returnedAmountPreferences) + const countOfInFlightProofs = countOfProofsToSend + countOfReturnedProofs - 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) - - // 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) - // + acquire lock and set inFlight values - await WalletUtils.lockAndSetInFlight(mintInstance, unit, countOfInFlightProofs, transactionId) - - // get locked counter values - const lockedProofsCounter = mintInstance.getProofsCounterByUnit?.(unit) - - // if split to required denominations was necessary, this gets it done with the mint and we get the return - - const {returnedProofs, proofsToSend, mintFeePaid} = await MintClient.send( - mintUrl, - amountToSend, - unit, - proofsToSendFrom, - { - preference: amountPreferences, - counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase - } - ) + log.trace('[sendFromMint]', 'amountPreferences', {amountPreferences, returnedAmountPreferences}) + log.trace('[sendFromMint]', 'countOfInFlightProofs', countOfInFlightProofs) - // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs - mintInstance.decreaseProofsCounter(lockedProofsCounter.keyset, countOfInFlightProofs) + // 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) + // + acquire lock and set inFlight values + await WalletUtils.lockAndSetInFlight(mintInstance, unit, countOfInFlightProofs, transactionId) - // add proofs returned by the mint after the split - if (returnedProofs.length > 0) { + // get locked counter values + const lockedProofsCounter = mintInstance.getProofsCounterByUnit(unit)! + + // if split to required denominations was necessary, this gets it done with the mint and we get the return + + const sendResult = await MintClient.send( + mintUrl, + amountToSend, + mintFeeReserve, + unit, + proofsToSendFrom, + { + preference: amountPreferences, + counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase + } + ) + + returnedProofs = sendResult.returnedProofs + proofsToSend = sendResult.proofsToSend + mintFeePaid = sendResult.mintFeePaid + + // If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs + mintInstance.decreaseProofsCounter(lockedProofsCounter.keyset, countOfInFlightProofs) + + // add proofs returned by the mint after the split log.trace('[sendFromMint] add returned proofs to spendable') WalletUtils.addCashuProofs( mintUrl, @@ -352,16 +411,24 @@ export const sendFromMint = async function ( unit, transactionId, isPending: false - } - ) - } + }) + + } else if (returnedAmount === 0) { + /* + * SWAP not needed, all selected proofs will be sent + */ + log.trace('[sendFromMint] Swap is not necessary, all proofsToSendFrom will be sent.') + proofsToSend = [...proofsToSendFrom] + + } else { + throw new AppError(Err.VALIDATION_ERROR, 'Amount to keep can not be negative') + } // remove used proofs and move sent proofs to pending log.trace('[sendFromMint] remove proofsToSendFrom from spendable') proofsStore.removeProofs(proofsToSendFrom) - // these might be original proofToSendFrom if they matched the exact amount and split was not necessary - log.trace('[sendFromMint] add proofsToSend to pending') + // these might be original proofToSendFrom if they matched the exact amount and split was not necessary WalletUtils.addCashuProofs( mintUrl, proofsToSend, diff --git a/src/services/wallet/transferTask.ts b/src/services/wallet/transferTask.ts index 25997fb0..b7003dab 100644 --- a/src/services/wallet/transferTask.ts +++ b/src/services/wallet/transferTask.ts @@ -86,10 +86,19 @@ export const transferTask = async function ( transactionId = storedTransaction.id as number + // calculate fees charged by mint for melt transaction to prepare enough proofs + const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit}) as Proof[] + let proofsToSendFrom = CashuUtils.getProofsToSend( + amountToTransfer + meltQuote.fee_reserve, + proofsFromMint + ) + let meltFeeReserve = mintInstance.getMintFeeReserve(proofsToSendFrom) + log.trace('[transfer]', {meltFeeReserve, amountWithFees: amountToTransfer + meltQuote.fee_reserve + meltFeeReserve,}) + // get proofs ready to be paid to the mint const {proofs: proofsToPay, mintFeePaid, mintFeeReserve} = await sendFromMint( mintBalanceToTransferFrom, - amountToTransfer + meltQuote.fee_reserve, + amountToTransfer + meltQuote.fee_reserve + meltFeeReserve, unit, [], transactionId, @@ -110,21 +119,21 @@ export const transferTask = async function ( transactionId, TransactionStatus.PREPARED, JSON.stringify(transactionData), - ) + ) - // we do not know how much we will get so use big enough constant to increase and lock - const countOfInFlightProofs = 10 + // number of outputs we can get back with returned lightning fees + const countOfInFlightProofs = Math.ceil(Math.log2(meltQuote.fee_reserve)) || 1 // temp increase the counter + acquire lock and set inFlight values await WalletUtils.lockAndSetInFlight(mintInstance, unit, countOfInFlightProofs, transactionId) // get locked counter values - const lockedProofsCounter = mintInstance.getProofsCounterByUnit?.(unit) + const lockedProofsCounter = mintInstance.getProofsCounterByUnit?.(unit)! const {isPaid, preimage, feeSavedProofs} = await MintClient.payLightningMelt( mintUrl, unit, meltQuote, - proofsToPay, + proofsToPay, { counter: lockedProofsCounter.inFlightFrom as number } @@ -203,7 +212,9 @@ export const transferTask = async function ( mintInstance.resetInFlight(transactionId) // Save final fee in db - await transactionsStore.updateFee(transactionId, lightningFeePaid + mintFeePaid) + if(lightningFeePaid + mintFeePaid !== meltQuote.fee_reserve) { + await transactionsStore.updateFee(transactionId, lightningFeePaid + mintFeePaid) + } // Update transaction status transactionData.push({ @@ -229,7 +240,7 @@ export const transferTask = async function ( taskFunction: TRANSFER, mintUrl, transaction: completedTransaction, - message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Final network fee has been ${formatCurrency(finalFee, getCurrency(unit).code)} ${getCurrency(unit).code}.`, + 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 } as TransactionTaskResult diff --git a/src/services/walletService.ts b/src/services/walletService.ts index d8317c35..9d096278 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -821,7 +821,7 @@ const _handlePendingTopupTask = async function (params: {paymentRequest: Payment ) // get locked counter values - const lockedProofsCounter = mintInstance.getProofsCounterByUnit(unit) + const lockedProofsCounter = mintInstance.getProofsCounterByUnit(unit)! let proofs: CashuProof[] = []