From fdf2ca7ea7a48791de869e7ddb1971447e67c887 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Sat, 20 Jul 2024 01:00:06 +0200 Subject: [PATCH] Implement fees for swap (send) --- src/models/Mint.ts | 138 +++++++++++++++++++++---------- src/models/MintsStore.ts | 24 +++--- src/models/ProofsStore.ts | 47 +++-------- src/services/cashu/cashuUtils.ts | 49 ++++++++++- src/services/cashuMintClient.ts | 24 ++++-- src/services/wallet/sendTask.ts | 77 +++++++++++++---- 6 files changed, 239 insertions(+), 120 deletions(-) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 1fdc4d32..0e818a10 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -1,13 +1,14 @@ import {cast, flow, Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree' import {withSetPropAction} from './helpers/withSetPropAction' -import type {GetInfoResponse, MintKeys, MintKeyset} from '@cashu/cashu-ts' +import type {CashuWallet, GetInfoResponse, MintKeys, MintKeyset} from '@cashu/cashu-ts' import {colors, getRandomIconColor} from '../theme' import { log, MintClient } from '../services' import AppError, { Err } from '../utils/AppError' -import { MintUnit } from '../services/wallet/currency' +import { MintUnit, MintUnits } from '../services/wallet/currency' import { getRootStore } from './helpers/getRootStore' import { generateId } from '../utils/utils' +import { Proof } from './Proof' // used as a helper type across app /* export type Balance = { @@ -42,7 +43,7 @@ export enum MintStatus { export type MintProofsCounter = { keyset: string counter: number - unit: MintUnit + unit: MintUnit input_fee_ppk: number inFlightFrom?: number // starting counter index for pending split request sent to mint (for recovery from failure to receive proofs) inFlightTo?: number // last counter index for pending split request sent to mint @@ -94,37 +95,43 @@ export const MintModel = types self.units = cast(self.units) }, + getProofsCounter(keysetId: string) { + const counter = self.proofsCounters.find(c => c.keyset === keysetId) + return counter + }, })) - .actions(self => ({ - getOrCreateProofsCounter(keysetId: string, unit?: MintUnit, input_fee_ppk?: number) { - log.trace('[getOrCreateProofsCounter]', keysetId, unit) - const counter = self.proofsCounters.find(c => c.keyset === keysetId) - - if(!counter) { - if (!unit) { - throw new AppError(Err.VALIDATION_ERROR, 'Can not create proofs counter: missing unit') - } - - const newCounter = { - keyset: keysetId, - unit, - counter: 0, - input_fee_ppk: input_fee_ppk || 0, - } - - self.proofsCounters.push(newCounter) - self.addUnit(unit) - const instance = self.proofsCounters.find(c => c.keyset === keysetId) as MintProofsCounter - - log.trace('[getOrCreateProofsCounter] new', {newCounter: instance}) - return instance - } - - if(unit && counter.unit !== unit) { - throw new AppError(Err.VALIDATION_ERROR, 'Mismatch of proofsCounter keyset and passed unit', {counter, unit}) + .actions(self => ({ + createProofsCounter(keyset: MintKeyset) { + // Do not add unit the wallet does not have configured + if (!MintUnits.includes(keyset.unit as MintUnit)) { + throw new AppError(Err.VALIDATION_ERROR, `Unsupported unit provided by the mint: ${keyset.unit}`) } - return counter as MintProofsCounter + const existing = self.getProofsCounter(keyset.id) + + if(existing) { // update fees if they can change (?) + if (existing.unit !== keyset.unit) { + throw new AppError(Err.VALIDATION_ERROR, `Keyset unit mismatch, got ${keyset.unit}, expected ${existing.unit}`) + } + + existing.input_fee_ppk = keyset.input_fee_ppk || 0 + self.proofsCounters = cast(self.proofsCounters) + return existing + } + + const newCounter: MintProofsCounter = { + keyset: keyset.id, + unit: keyset.unit as MintUnit, + input_fee_ppk: keyset.input_fee_ppk || 0, + counter: 0, + } + + self.proofsCounters.push(newCounter) + self.addUnit(keyset.unit as MintUnit) + const instance = self.proofsCounters.find(c => c.keyset === keyset.id) as MintProofsCounter + + log.trace('[ceateProofsCounter]', {newCounter: instance}) + return instance }, findInFlightProofsCounter() { const counter = self.proofsCounters.find(c => c.inFlightFrom && c.inFlightTo && c.inFlightTid) @@ -144,13 +151,31 @@ export const MintModel = types }, })) .actions(self => ({ - getProofsCounterByUnit: flow(function* getProofsCounterByUnit(unit: MintUnit) { - // Retrieve current keys for this unit from new or existing cashu-ts wallet instance - const keys: MintKeys = (yield MintClient.getWallet(self.mintUrl, unit)).keys + getProofsCounterByUnit: flow(function* getProofsCounterByUnit(unit: MintUnit) { + try { + // Refresh current keys for this unit from new or existing in-memory wallet instance + const wallet: CashuWallet = yield MintClient.getWallet(self.mintUrl, unit) + const keyset = wallet.keyset - // Get or create new proofs counter for this keyset - const counter = self.getOrCreateProofsCounter(keys.id, unit) - return counter + // Get or create new proofs counter for this keyset + const counter = self.getProofsCounter(keyset.id) + + if(!counter) { + return self.createProofsCounter(keyset) + } + + return counter + } catch(e: any) { + log.warn('[getProofsCounterByUnit] Could not refresh keyset, using mint proofsCounter for unit', {unit}) + + const proofsCounterInstance = self.proofsCounters.find(c => c.unit === unit) + + if(!proofsCounterInstance) { + throw new AppError(Err.NOTFOUND_ERROR, 'Could not get keyset to create new mint proofsCounter for unit', {unit}) + } + + return proofsCounterInstance + } }), setHostname() { try { @@ -207,8 +232,12 @@ export const MintModel = types setStatus(status: MintStatus) { self.status = status }, - setInFlight(keyset: string, options: {inFlightFrom: number, inFlightTo: number, inFlightTid: number}) { - const counter = self.getOrCreateProofsCounter(keyset) + setInFlight(keysetId: string, options: {inFlightFrom: number, inFlightTo: number, inFlightTid: number}) { + const counter = self.getProofsCounter(keysetId) + + if(!counter) { + throw new AppError(Err.NOTFOUND_ERROR, 'Count not get mint proofsCounter for keysetId', {keysetId}) + } counter.inFlightFrom = options.inFlightFrom counter.inFlightTo = options.inFlightTo @@ -235,16 +264,26 @@ export const MintModel = types self.proofsCounters = cast(self.proofsCounters) }, - increaseProofsCounter(keyset: string, numberOfProofs: number) { - const counter = self.getOrCreateProofsCounter(keyset) + increaseProofsCounter(keysetId: string, numberOfProofs: number) { + const counter = self.getProofsCounter(keysetId) + + if(!counter) { + throw new AppError(Err.NOTFOUND_ERROR, 'Count not get mint proofsCounter for keysetId', {keysetId}) + } + counter.counter += numberOfProofs log.trace('[increaseProofsCounter]', 'Increased proofsCounter', {numberOfProofs, counter}) // Make sure to cast the frozen array back to a mutable array self.proofsCounters = cast(self.proofsCounters) }, - decreaseProofsCounter(keyset: string, numberOfProofs: number) { - const counter = self.getOrCreateProofsCounter(keyset) + decreaseProofsCounter(keysetId: string, numberOfProofs: number) { + const counter = self.getProofsCounter(keysetId) + + if(!counter) { + throw new AppError(Err.NOTFOUND_ERROR, 'Count not get mint proofsCounter for keysetId', {keysetId}) + } + counter.counter -= numberOfProofs Math.max(0, counter.counter) log.trace('[decreaseProofsCounter]', 'Decreased proofsCounter', {numberOfProofs, counter}) @@ -259,6 +298,19 @@ export const MintModel = types self.proofsCounters = cast(self.proofsCounters) }, + getFeesForProofs(proofs: Proof[]): number { + // Find the corresponding MintProofsCounter for each proof and sum the input fees + const totalInputFees = proofs.reduce((sum, proof) => { + const counter = self.proofsCounters.find(pc => pc.keyset === proof.id) + return counter ? sum + counter.input_fee_ppk : sum + }, 0) + + // Calculate the fees + const fees = Math.max(Math.floor((totalInputFees + 999) / 1000), 0) + + log.debug('*** [getFeesForProofs]', {fees}) + return fees + } })) .views(self => ({ get balances(): MintBalance | undefined { diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 61ef0fed..8e1b9fb6 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -62,20 +62,16 @@ import { MintUnit, MintUnits } from '../services/wallet/currency' mintInstance.setRandomColor() for(const keyset of activeKeysets) { - if(keyset.active === true) { - // Do not add unit the wallet does not have configured - if (!MintUnits.includes(keyset.unit as MintUnit)) { - log.error(`Unsupported unit provided by the mint: ${keyset.unit}`) - continue + try { + if(keyset.active === true) { + mintInstance.createProofsCounter(keyset) } - - mintInstance.addUnit(keyset.unit as MintUnit) // add supported units by mint - mintInstance.getOrCreateProofsCounter(keyset.id, keyset.unit as MintUnit, keyset.input_fee_ppk || 0) // create proofsCounters + } catch(e: any) { + continue } } - yield mintInstance.setShortname() - + yield mintInstance.setShortname() self.mints.push(mintInstance) }), updateMint: flow(function* updateMint(mintUrl: string) { @@ -86,13 +82,13 @@ import { MintUnit, MintUnits } from '../services/wallet/currency' throw new AppError(Err.VALIDATION_ERROR, 'Could not find mint to update', {mintUrl}) } // refresh up to date mint keys - const activeKeysets: MintKeyset[] = yield MintClient.getMintKeysets(mintUrl) + const allKeysets: MintKeyset[] = yield MintClient.getMintKeysets(mintUrl) - if(!activeKeysets || activeKeysets.length === 0) { + if(!allKeysets || allKeysets.length === 0) { throw new AppError(Err.VALIDATION_ERROR, 'Mint has no active keysets and is not operational', {mintUrl}) } - for(const keyset of activeKeysets) { + for(const keyset of allKeysets) { if(keyset.active === true) { // Do not add unit the wallet does not have configured if (!MintUnits.includes(keyset.unit as MintUnit)) { @@ -101,7 +97,7 @@ import { MintUnit, MintUnits } from '../services/wallet/currency' } mintInstance.addUnit(keyset.unit as MintUnit) // add supported units by mint if not yet exist - mintInstance.getOrCreateProofsCounter(keyset.id, keyset.unit as MintUnit, keyset.input_fee_ppk || 0) // create proofsCounters if not yet exist + mintInstance.createProofsCounter(keyset) // create proofsCounters or update fees if exists } } diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index d7d8e768..05c80110 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -363,30 +363,19 @@ export const ProofsStoreModel = types log.debug('[getMintBalanceWithMaxBalance]', {balances}) - /*const maxBalance = balances.reduce((maxBalance, currentBalance) => { - if(currentBalance.balances[unit] === undefined) { - return maxBalance - } - - if (currentBalance.balances[unit] || 0 > maxBalance.balances[unit]!) { - return currentBalance - } - return maxBalance - })*/ - - let maxBalance = null; - let maxAmount = -Infinity; + let maxBalance = null; + let maxAmount = -Infinity; - for (const balance of balances) { - const amount = balance.balances[unit]; - if (amount !== undefined && amount > maxAmount) { - maxAmount = amount; - maxBalance = balance; - } - } + for (const balance of balances) { + const amount = balance.balances[unit]; + if (amount !== undefined && amount > maxAmount) { + maxAmount = amount; + maxBalance = balance; + } + } - log.debug('[getMintBalanceWithMaxBalance]', {maxBalance}) - return maxBalance; + log.debug('[getMintBalanceWithMaxBalance]', {maxBalance}) + return maxBalance; }, getUnitBalance: (unit: MintUnit) => { const balances = self.getBalances().unitBalances @@ -395,22 +384,12 @@ export const ProofsStoreModel = types .find((balance: UnitBalance) => balance.unit === unit) return unitBalance - }, - getProofsToSend: (amount: number, proofs: Proof[]) => { - let proofsAmount = 0 - const proofSubset = proofs.filter(proof => { - if (proofsAmount < amount) { - proofsAmount += proof.amount - return true - } - }) - return proofSubset - }, + }, getProofsSubset: (proofs: Proof[], proofsToRemove: Proof[]) => { return proofs.filter(proof => !proofsToRemove.includes(proof)) }, })) - + export interface Proofs extends Instance {} export interface ProofsStoreSnapshot diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index fc1ab54a..b02edbb0 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -193,7 +193,7 @@ const getProofsFromTokenEntries = (tokenEntries: TokenEntry[]) => { * @param tokens * @returns */ -const getProofsToSend = function (amount: number, proofs: Array) { +/* const getProofsToSend = function (amount: number, proofs: Array) { let proofsAmount = 0 const proofSubset = proofs.filter(proof => { if (proofsAmount < amount) { @@ -202,7 +202,52 @@ const getProofsToSend = function (amount: number, proofs: Array) { } }) 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 +} /** diff --git a/src/services/cashuMintClient.ts b/src/services/cashuMintClient.ts index 304903f3..5502f68b 100644 --- a/src/services/cashuMintClient.ts +++ b/src/services/cashuMintClient.ts @@ -144,17 +144,23 @@ const getWallet = async function ( mnemonicOrSeed: seed }) - // make sure we have keys for wallet unit cached in wallet instance - const keys = await newSeedWallet.getKeys(undefined, unit) + try { + // make sure we have keys for wallet unit cached in wallet instance + const keys = await newSeedWallet.getKeys(undefined, unit) - if(!keys || keys.unit !== unit) { - throw new AppError(Err.VALIDATION_ERROR, `This mint does not currently support unit ${unit}`) + if(!keys || keys.unit !== unit) { + throw new AppError(Err.VALIDATION_ERROR, `This mint does not currently support unit ${unit}`) + } + + _seedWallets.push(newSeedWallet) + + log.trace('[getWallet]', 'Returning new cashuWallet instance with seed') + return newSeedWallet + } catch (e: any) { + let message = 'Could not connect to the selected mint.' + if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; + throw new AppError(Err.NETWORK_ERROR, message, {message: e.message, caller: 'getWallet'}) } - - _seedWallets.push(newSeedWallet) - - log.trace('[getWallet]', 'Returning new cashuWallet instance with seed') - return newSeedWallet } const wallet = _wallets.find(w => w.mint.mintUrl === mintUrl && w.unit === unit) diff --git a/src/services/wallet/sendTask.ts b/src/services/wallet/sendTask.ts index 2fcb7ffb..92a831c3 100644 --- a/src/services/wallet/sendTask.ts +++ b/src/services/wallet/sendTask.ts @@ -24,6 +24,7 @@ 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' const { mintsStore, @@ -86,7 +87,7 @@ export const sendTask = async function ( // Update transaction status transactionData.push({ status: TransactionStatus.PREPARED, - proofsToSend, + //proofsToSend, createdAt: new Date(), }) @@ -191,7 +192,7 @@ export const sendFromMint = async function ( ) { const mintUrl = mintBalance.mintUrl const mintInstance = mintsStore.findByUrl(mintUrl) - + try { if (!mintInstance) { throw new AppError( @@ -228,11 +229,11 @@ export const sendFromMint = async function ( const selectedProofsAmount = CashuUtils.getProofsAmount(selectedProofs) - if(selectedProofsAmount > 0 && (amountToSend !== selectedProofsAmount)) { // failsafe for some unknown ecash selection UX error - throw new AppError(Err.VALIDATION_ERROR, 'Requested amount to send does not equal sum of ecash denominations provided.') - } + if(selectedProofsAmount > 0) { + if(amountToSend !== selectedProofsAmount) { // failsafe for some unknown ecash selection UX error + throw new AppError(Err.VALIDATION_ERROR, 'Requested amount to send does not equal sum of ecash denominations provided.') + } - if(selectedProofsAmount > 0) { for (const proof of selectedProofs) { proof.setTransactionId(transactionId) // update txId } @@ -250,32 +251,71 @@ export const sendFromMint = async function ( // We return cleaned proofs to be encoded as a sendable token return cleanedProofsToSend } - /* - * if we did not selected ecash but amount and we might need a split of ecash by the mint to match exact amount + * if we did not selected ecash but amount and we might need a swap of ecash by the mint to match exact amount */ - const proofsToSendFrom = proofsStore.getProofsToSend( + const proofsToSendFrom = getProofsToSend( amountToSend, proofsFromMint, ) - const proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) + let proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) + - // Increase the proofs counter before the mint call so that in case the response - // is not received our recovery index counts for sigs the mint has already issued (prevents duplicate b_b bug) - const amountPreferences = getDefaultAmountPreference(amountToSend) - const returnedAmountPreferences = getDefaultAmountPreference(proofsToSendFromAmount - amountToSend) + // swap will happen if we could not select proofs equal to amountToSend + let returnedAmount = proofsToSendFromAmount - amountToSend + let feesAmount: number = 0 + + if(returnedAmount > 0) { + feesAmount = mintInstance.getFeesForProofs(proofsToSendFrom) + // if we did not selected enough proofs to cover the fees we need some more + if(feesAmount > returnedAmount) { + const missingFeesAmount = feesAmount - returnedAmount + const remainingProofs = proofsStore.getProofsSubset(proofsFromMint, proofsToSendFrom) + const remainingProofsAmount = CashuUtils.getProofsAmount(remainingProofs) + + log.warn('[sendFromMint]', 'Not enough proofs to cover feeAmount', {feesAmount, returnedAmount}) + + if(missingFeesAmount > remainingProofsAmount) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this payment with expected fees', + {totalAmountFromMint, amountToSend, feesAmount}, + ) + } + + const proofsToPayFees = getProofsToSend( + missingFeesAmount, + remainingProofs + ) + + // add more proofs into inputs and recalculate amounts + proofsToSendFrom.push(...proofsToPayFees) + proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom) + returnedAmount = proofsToSendFromAmount - amountToSend + } + + // decrease requested returned outputs by fees so mint can charge them + returnedAmount -= feesAmount + } + + // 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 countOfInFlightProofs = countOfProofsToSend + countOfReturnedProofs + log.trace('[sendFromMint]', 'amountPreferences', {amountPreferences, returnedAmountPreferences}) log.trace('[sendFromMint]', 'countOfInFlightProofs', countOfInFlightProofs) - // temp increase the counter + acquire lock and set inFlight values + // 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 @@ -352,4 +392,5 @@ export const sendFromMint = async function ( throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 200)) } } -} \ No newline at end of file +} +