diff options
Diffstat (limited to 'packages/taler-wallet-core/src/util/instructedAmountConversion.ts')
-rw-r--r-- | packages/taler-wallet-core/src/util/instructedAmountConversion.ts | 849 |
1 files changed, 849 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts new file mode 100644 index 000000000..bd02e7b22 --- /dev/null +++ b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts @@ -0,0 +1,849 @@ +/* + This file is part of GNU Taler + (C) 2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AgeRestriction, + AmountJson, + AmountResponse, + Amounts, + ConvertAmountRequest, + Duration, + GetAmountRequest, + GetPlanForOperationRequest, + TransactionAmountMode, + TransactionType, + parsePaytoUri, + strcmp, +} from "@gnu-taler/taler-util"; +import { checkDbInvariant } from "./invariants.js"; +import { + DenominationRecord, + InternalWalletState, + getExchangeDetails, +} from "../index.js"; +import { CoinInfo } from "./coinSelection.js"; +import { GlobalIDB } from "@gnu-taler/idb-bridge"; + +/** + * If the operation going to be plan subtracts + * or adds amount in the wallet db + */ +export enum OperationType { + Credit = "credit", + Debit = "debit", +} + +// FIXME: Name conflict ... +interface ExchangeInfo { + wireFee: AmountJson | undefined; + purseFee: AmountJson | undefined; + creditDeadline: AbsoluteTime; + debitDeadline: AbsoluteTime; +} + +function getOperationType(txType: TransactionType): OperationType { + const operationType = + txType === TransactionType.Withdrawal + ? OperationType.Credit + : txType === TransactionType.Deposit + ? OperationType.Debit + : undefined; + if (!operationType) { + throw Error(`operation type ${txType} not yet supported`); + } + return operationType; +} + +interface SelectedCoins { + totalValue: AmountJson; + coins: { info: CoinInfo; size: number }[]; + refresh?: RefreshChoice; +} + +function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter { + switch (req.type) { + case TransactionType.Withdrawal: { + return { + exchanges: + req.exchangeUrl === undefined ? undefined : [req.exchangeUrl], + }; + } + case TransactionType.Deposit: { + const payto = parsePaytoUri(req.account); + if (!payto) { + throw Error(`wrong payto ${req.account}`); + } + return { + wireMethod: payto.targetType, + }; + } + } +} + +interface RefreshChoice { + /** + * Amount that need to be covered + */ + gap: AmountJson; + totalFee: AmountJson; + selected: CoinInfo; + totalChangeValue: AmountJson; + refreshEffective: AmountJson; + coins: { info: CoinInfo; size: number }[]; + + // totalValue: AmountJson; + // totalDepositFee: AmountJson; + // totalRefreshFee: AmountJson; + // totalChangeContribution: AmountJson; + // totalChangeWithdrawalFee: AmountJson; +} + +interface CoinsFilter { + shouldCalculatePurseFee?: boolean; + exchanges?: string[]; + wireMethod?: string; + ageRestricted?: number; +} + +interface AvailableCoins { + list: CoinInfo[]; + exchanges: Record<string, ExchangeInfo>; +} + +/** + * Get all the denoms that can be used for a operation that is limited + * by the following restrictions. + * This function is costly (by the database access) but with high chances + * of being cached + */ +async function getAvailableDenoms( + ws: InternalWalletState, + op: TransactionType, + currency: string, + filters: CoinsFilter = {}, +): Promise<AvailableCoins> { + const operationType = getOperationType(TransactionType.Deposit); + + return await ws.db + .mktx((x) => [ + x.exchanges, + x.exchangeDetails, + x.denominations, + x.coinAvailability, + ]) + .runReadOnly(async (tx) => { + const list: CoinInfo[] = []; + const exchanges: Record<string, ExchangeInfo> = {}; + + const databaseExchanges = await tx.exchanges.iter().toArray(); + const filteredExchanges = + filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl); + + for (const exchangeBaseUrl of filteredExchanges) { + const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl); + // 1.- exchange has same currency + if (exchangeDetails?.currency !== currency) { + continue; + } + + let deadline = AbsoluteTime.never(); + // 2.- exchange supports wire method + let wireFee: AmountJson | undefined; + if (filters.wireMethod) { + const wireMethodWithDates = + exchangeDetails.wireInfo.feesForType[filters.wireMethod]; + + if (!wireMethodWithDates) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`, + ); + } + const wireMethodFee = wireMethodWithDates.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startStamp), + AbsoluteTime.fromProtocolTimestamp(x.endStamp), + ); + }); + + if (!wireMethodFee) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`, + ); + } + wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee); + deadline = AbsoluteTime.min( + deadline, + AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp), + ); + } + // exchanges[exchangeBaseUrl].wireFee = wireMethodFee; + + // 3.- exchange supports wire method + let purseFee: AmountJson | undefined; + if (filters.shouldCalculatePurseFee) { + const purseFeeFound = exchangeDetails.globalFees.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startDate), + AbsoluteTime.fromProtocolTimestamp(x.endDate), + ); + }); + if (!purseFeeFound) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`, + ); + } + purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee); + deadline = AbsoluteTime.min( + deadline, + AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate), + ); + } + + let creditDeadline = AbsoluteTime.never(); + let debitDeadline = AbsoluteTime.never(); + //4.- filter coins restricted by age + if (operationType === OperationType.Credit) { + const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const denom of ds) { + const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireWithdraw, + ); + const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireDeposit, + ); + creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); + debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); + list.push( + buildCoinInfoFromDenom( + denom, + purseFee, + wireFee, + AgeRestriction.AGE_UNRESTRICTED, + Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom + ), + ); + } + } else { + const ageLower = filters.ageRestricted ?? 0; + const ageUpper = AgeRestriction.AGE_UNRESTRICTED; + + const myExchangeCoins = + await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( + GlobalIDB.KeyRange.bound( + [exchangeDetails.exchangeBaseUrl, ageLower, 1], + [ + exchangeDetails.exchangeBaseUrl, + ageUpper, + Number.MAX_SAFE_INTEGER, + ], + ), + ); + //5.- save denoms with how many coins are available + // FIXME: Check that the individual denomination is audited! + // FIXME: Should we exclude denominations that are + // not spendable anymore? + for (const coinAvail of myExchangeCoins) { + const denom = await tx.denominations.get([ + coinAvail.exchangeBaseUrl, + coinAvail.denomPubHash, + ]); + checkDbInvariant(!!denom); + if (denom.isRevoked || !denom.isOffered) { + continue; + } + const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireWithdraw, + ); + const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireDeposit, + ); + creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); + debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); + list.push( + buildCoinInfoFromDenom( + denom, + purseFee, + wireFee, + coinAvail.maxAge, + coinAvail.freshCoinCount, + ), + ); + } + } + + exchanges[exchangeBaseUrl] = { + purseFee, + wireFee, + debitDeadline, + creditDeadline, + }; + } + + return { list, exchanges }; + }); +} + +function buildCoinInfoFromDenom( + denom: DenominationRecord, + purseFee: AmountJson | undefined, + wireFee: AmountJson | undefined, + maxAge: number, + total: number, +): CoinInfo { + return { + id: denom.denomPubHash, + denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw), + denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), + denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh), + exchangePurse: purseFee, + exchangeWire: wireFee, + exchangeBaseUrl: denom.exchangeBaseUrl, + duration: AbsoluteTime.difference( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit), + ), + totalAvailable: total, + value: DenominationRecord.getValue(denom), + maxAge, + }; +} + +export async function convertDepositAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise<AmountResponse> { + const amount = Amounts.parseOrThrow(req.amount); + // const filter = getCoinsFilter(req); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Deposit, + amount.currency, + {}, + ); + const result = convertDepositAmountForAvailableCoins( + denoms, + amount, + req.type, + ); + + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +const LOG_REFRESH = false; +const LOG_DEPOSIT = false; +export function convertDepositAmountForAvailableCoins( + denoms: AvailableCoins, + amount: AmountJson, + mode: TransactionAmountMode, +): AmountAndRefresh { + const zero = Amounts.zeroOfCurrency(amount.currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + const depositDenoms = rankDenominationForDeposit(denoms.list, mode); + + //FIXME: we are not taking into account + // * exchanges with multiple accounts + // * wallet with multiple exchanges + const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; + const adjustedAmount = Amounts.add(amount, wireFee).amount; + + const selected = selectGreedyCoins(depositDenoms, adjustedAmount); + + const gap = Amounts.sub(amount, selected.totalValue).amount; + + const result = getTotalEffectiveAndRawForDeposit( + selected.coins, + amount.currency, + ); + result.raw = Amounts.sub(result.raw, wireFee).amount; + + if (Amounts.isZero(gap)) { + // exact amount founds + return result; + } + + if (LOG_DEPOSIT) { + const logInfo = selected.coins.map((c) => { + return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; + }); + console.log( + "deposit used:", + logInfo.join(", "), + "gap:", + Amounts.stringifyValue(gap), + ); + } + + const refreshDenoms = rankDenominationForRefresh(denoms.list); + /** + * FIXME: looking for refresh AFTER selecting greedy is not optimal + */ + const refreshCoin = searchBestRefreshCoin( + depositDenoms, + refreshDenoms, + gap, + mode, + ); + + if (refreshCoin) { + const fee = Amounts.sub(result.effective, result.raw).amount; + const effective = Amounts.add( + result.effective, + refreshCoin.refreshEffective, + ).amount; + const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount; + //found with change + return { + effective, + raw, + refresh: refreshCoin, + }; + } + + // there is a gap, but no refresh coin was found + return result; +} + +export async function getMaxDepositAmount( + ws: InternalWalletState, + req: GetAmountRequest, +): Promise<AmountResponse> { + // const filter = getCoinsFilter(req); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Deposit, + req.currency, + {}, + ); + + const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency); + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +export function getMaxDepositAmountForAvailableCoins( + denoms: AvailableCoins, + currency: string, +) { + const zero = Amounts.zeroOfCurrency(currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + + const result = getTotalEffectiveAndRawForDeposit( + denoms.list.map((info) => { + return { info, size: info.totalAvailable ?? 0 }; + }), + currency, + ); + + const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; + result.raw = Amounts.sub(result.raw, wireFee).amount; + + return result; +} + +export async function convertPeerPushAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise<AmountResponse> { + throw Error("to be implemented after 1.0"); +} +export async function getMaxPeerPushAmount( + ws: InternalWalletState, + req: GetAmountRequest, +): Promise<AmountResponse> { + throw Error("to be implemented after 1.0"); +} +export async function convertWithdrawalAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise<AmountResponse> { + const amount = Amounts.parseOrThrow(req.amount); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Withdrawal, + amount.currency, + {}, + ); + + const result = convertWithdrawalAmountFromAvailableCoins( + denoms, + amount, + req.type, + ); + + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +export function convertWithdrawalAmountFromAvailableCoins( + denoms: AvailableCoins, + amount: AmountJson, + mode: TransactionAmountMode, +) { + const zero = Amounts.zeroOfCurrency(amount.currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode); + + const selected = selectGreedyCoins(withdrawDenoms, amount); + + return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency); +} + +/** ***************************************************** + * HELPERS + * ***************************************************** + */ + +/** + * + * @param depositDenoms + * @param refreshDenoms + * @param amount + * @param mode + * @returns + */ +function searchBestRefreshCoin( + depositDenoms: SelectableElement[], + refreshDenoms: Record<string, SelectableElement[]>, + amount: AmountJson, + mode: TransactionAmountMode, +): RefreshChoice | undefined { + let choice: RefreshChoice | undefined = undefined; + let refreshIdx = 0; + refreshIteration: while (refreshIdx < depositDenoms.length) { + const d = depositDenoms[refreshIdx]; + + const denomContribution = + mode === TransactionAmountMode.Effective + ? d.value + : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount; + + const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount; + if (Amounts.isZero(changeAfterDeposit)) { + //this coin is not big enough to use for refresh + //since the list is sorted, we can break here + break refreshIteration; + } + + const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl]; + const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit); + + const zero = Amounts.zeroOfCurrency(amount.currency); + const withdrawChangeFee = change.coins.reduce((cur, prev) => { + return Amounts.add( + cur, + Amounts.mult(prev.info.denomWithdraw, prev.size).amount, + ).amount; + }, zero); + + const withdrawChangeValue = change.coins.reduce((cur, prev) => { + return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount) + .amount; + }, zero); + + const totalFee = Amounts.add( + d.info.denomDeposit, + d.info.denomRefresh, + withdrawChangeFee, + ).amount; + + if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { + //found cheaper change + choice = { + gap: amount, + totalFee: totalFee, + totalChangeValue: change.totalValue, //change after refresh + refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered + selected: d.info, + coins: change.coins, + }; + } + refreshIdx++; + } + if (choice) { + if (LOG_REFRESH) { + const logInfo = choice.coins.map((c) => { + return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; + }); + console.log( + "refresh used:", + Amounts.stringifyValue(choice.selected.value), + "change:", + logInfo.join(", "), + "fee:", + Amounts.stringifyValue(choice.totalFee), + "refreshEffective:", + Amounts.stringifyValue(choice.refreshEffective), + "totalChangeValue:", + Amounts.stringifyValue(choice.totalChangeValue), + ); + } + } + return choice; +} + +/** + * Returns a copy of the list sorted for the best denom to withdraw first + * + * @param denoms + * @returns + */ +function rankDenominationForWithdrawals( + denoms: CoinInfo[], + mode: TransactionAmountMode, +): SelectableElement[] { + const copyList = [...denoms]; + /** + * Rank coins + */ + copyList.sort((d1, d2) => { + // the best coin to use is + // 1.- the one that contrib more and pay less fee + // 2.- it takes more time before expires + + //different exchanges may have different wireFee + //ranking should take the relative contribution in the exchange + //which is (value - denomFee / fixedFee) + const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; + const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; + return ( + contribCmp || + Duration.cmp(d1.duration, d2.duration) || + strcmp(d1.id, d2.id) + ); + }); + + return copyList.map((info) => { + switch (mode) { + case TransactionAmountMode.Effective: { + //if the user instructed "effective" then we need to selected + //greedy total coin value + return { + info, + value: info.value, + total: Number.MAX_SAFE_INTEGER, + }; + } + case TransactionAmountMode.Raw: { + //if the user instructed "raw" then we need to selected + //greedy total coin raw amount (without fee) + return { + info, + value: Amounts.add(info.value, info.denomWithdraw).amount, + total: Number.MAX_SAFE_INTEGER, + }; + } + } + }); +} + +/** + * Returns a copy of the list sorted for the best denom to deposit first + * + * @param denoms + * @returns + */ +function rankDenominationForDeposit( + denoms: CoinInfo[], + mode: TransactionAmountMode, +): SelectableElement[] { + const copyList = [...denoms]; + /** + * Rank coins + */ + copyList.sort((d1, d2) => { + // the best coin to use is + // 1.- the one that contrib more and pay less fee + // 2.- it takes more time before expires + + //different exchanges may have different wireFee + //ranking should take the relative contribution in the exchange + //which is (value - denomFee / fixedFee) + const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; + const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; + return ( + contribCmp || + Duration.cmp(d1.duration, d2.duration) || + strcmp(d1.id, d2.id) + ); + }); + + return copyList.map((info) => { + switch (mode) { + case TransactionAmountMode.Effective: { + //if the user instructed "effective" then we need to selected + //greedy total coin value + return { + info, + value: info.value, + total: info.totalAvailable ?? 0, + }; + } + case TransactionAmountMode.Raw: { + //if the user instructed "raw" then we need to selected + //greedy total coin raw amount (without fee) + return { + info, + value: Amounts.sub(info.value, info.denomDeposit).amount, + total: info.totalAvailable ?? 0, + }; + } + } + }); +} + +/** + * Returns a copy of the list sorted for the best denom to withdraw first + * + * @param denoms + * @returns + */ +function rankDenominationForRefresh( + denoms: CoinInfo[], +): Record<string, SelectableElement[]> { + const groupByExchange: Record<string, CoinInfo[]> = {}; + for (const d of denoms) { + if (!groupByExchange[d.exchangeBaseUrl]) { + groupByExchange[d.exchangeBaseUrl] = []; + } + groupByExchange[d.exchangeBaseUrl].push(d); + } + + const result: Record<string, SelectableElement[]> = {}; + for (const d of denoms) { + result[d.exchangeBaseUrl] = rankDenominationForWithdrawals( + groupByExchange[d.exchangeBaseUrl], + TransactionAmountMode.Raw, + ); + } + return result; +} + +interface SelectableElement { + total: number; + value: AmountJson; + info: CoinInfo; +} + +function selectGreedyCoins( + coins: SelectableElement[], + limit: AmountJson, +): SelectedCoins { + const result: SelectedCoins = { + totalValue: Amounts.zeroOfCurrency(limit.currency), + coins: [], + }; + if (!coins.length) return result; + + let denomIdx = 0; + iterateDenoms: while (denomIdx < coins.length) { + const denom = coins[denomIdx]; + // let total = denom.total; + const left = Amounts.sub(limit, result.totalValue).amount; + + if (Amounts.isZero(denom.value)) { + // 0 contribution denoms should be the last + break iterateDenoms; + } + + //use Amounts.divmod instead of iterate + const div = Amounts.divmod(left, denom.value); + const size = Math.min(div.quotient, denom.total); + if (size > 0) { + const mul = Amounts.mult(denom.value, size).amount; + const progress = Amounts.add(result.totalValue, mul).amount; + + result.totalValue = progress; + result.coins.push({ info: denom.info, size }); + denom.total = denom.total - size; + } + + //go next denom + denomIdx++; + } + + return result; +} + +type AmountWithFee = { raw: AmountJson; effective: AmountJson }; +type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice }; + +export function getTotalEffectiveAndRawForDeposit( + list: { info: CoinInfo; size: number }[], + currency: string, +): AmountWithFee { + const init = { + raw: Amounts.zeroOfCurrency(currency), + effective: Amounts.zeroOfCurrency(currency), + }; + return list.reduce((prev, cur) => { + const ef = Amounts.mult(cur.info.value, cur.size).amount; + const rw = Amounts.mult( + Amounts.sub(cur.info.value, cur.info.denomDeposit).amount, + cur.size, + ).amount; + + prev.effective = Amounts.add(prev.effective, ef).amount; + prev.raw = Amounts.add(prev.raw, rw).amount; + return prev; + }, init); +} + +function getTotalEffectiveAndRawForWithdrawal( + list: { info: CoinInfo; size: number }[], + currency: string, +): AmountWithFee { + const init = { + raw: Amounts.zeroOfCurrency(currency), + effective: Amounts.zeroOfCurrency(currency), + }; + return list.reduce((prev, cur) => { + const ef = Amounts.mult(cur.info.value, cur.size).amount; + const rw = Amounts.mult( + Amounts.add(cur.info.value, cur.info.denomWithdraw).amount, + cur.size, + ).amount; + + prev.effective = Amounts.add(prev.effective, ef).amount; + prev.raw = Amounts.add(prev.raw, rw).amount; + return prev; + }, init); +} |