Implement fees for swap (send)

This commit is contained in:
minibits-cash
2024-07-20 01:00:06 +02:00
parent 88f73678cf
commit fdf2ca7ea7
6 changed files with 239 additions and 120 deletions
+95 -43
View File
@@ -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 {
+10 -14
View File
@@ -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
}
}
+13 -34
View File
@@ -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<typeof ProofsStoreModel> {}
export interface ProofsStoreSnapshot
+47 -2
View File
@@ -193,7 +193,7 @@ const getProofsFromTokenEntries = (tokenEntries: TokenEntry[]) => {
* @param tokens
* @returns
*/
const getProofsToSend = function (amount: number, proofs: Array<Proof>) {
/* const getProofsToSend = function (amount: number, proofs: Array<Proof>) {
let proofsAmount = 0
const proofSubset = proofs.filter(proof => {
if (proofsAmount < amount) {
@@ -202,7 +202,52 @@ const getProofsToSend = function (amount: number, proofs: Array<Proof>) {
}
})
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
}
/**
+15 -9
View File
@@ -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)
+59 -18
View File
@@ -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))
}
}
}
}