/* 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 */ import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, AgeRestriction, AmountJson, AmountResponse, Amounts, ConvertAmountRequest, Duration, TransactionAmountMode, TransactionType, checkDbInvariant, strcmp, } from "@gnu-taler/taler-util"; import { DenominationRecord, timestampProtocolFromDb } from "./db.js"; import { getExchangeWireDetailsInTx } from "./exchanges.js"; import { WalletExecutionContext } from "./wallet.js"; export interface CoinInfo { id: string; value: AmountJson; denomDeposit: AmountJson; denomWithdraw: AmountJson; denomRefresh: AmountJson; totalAvailable: number | undefined; exchangeWire: AmountJson | undefined; exchangePurse: AmountJson | undefined; duration: Duration; exchangeBaseUrl: string; maxAge: number; } /** * 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; } 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; } /** * 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 getAvailableCoins( wex: WalletExecutionContext, op: TransactionType, currency: string, filters: CoinsFilter = {}, ): Promise { const operationType = getOperationType(op); return await wex.db.runReadOnlyTx( { storeNames: [ "exchanges", "exchangeDetails", "denominations", "coinAvailability", ], }, async (tx) => { const list: CoinInfo[] = []; const exchanges: Record = {}; const databaseExchanges = await tx.exchanges.iter().toArray(); const filteredExchanges = filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl); for (const exchangeBaseUrl of filteredExchanges) { const exchangeDetails = await getExchangeWireDetailsInTx( 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) { // FIXME: Use denom groups instead of querying all denominations! const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( exchangeBaseUrl, ); for (const denom of ds) { const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(denom.stampExpireWithdraw), ); const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(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, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`, ); if (denom.isRevoked || !denom.isOffered) { continue; } const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(denom.stampExpireWithdraw), ); const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(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( timestampProtocolFromDb(denom.stampExpireDeposit), ), ), totalAvailable: total, value: Amounts.parseOrThrow(denom.value), maxAge, }; } export async function convertDepositAmount( wex: WalletExecutionContext, req: ConvertAmountRequest, ): Promise { const amount = Amounts.parseOrThrow(req.amount); // const filter = getCoinsFilter(req); const denoms = await getAvailableCoins( wex, 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 convertWithdrawalAmount( wex: WalletExecutionContext, req: ConvertAmountRequest, ): Promise { const amount = Amounts.parseOrThrow(req.amount); const denoms = await getAvailableCoins( wex, 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 * ***************************************************** */ function searchBestRefreshCoin( depositDenoms: SelectableElement[], refreshDenoms: Record, 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 */ 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.isZero(d1.denomWithdraw) ? Number.MIN_SAFE_INTEGER : Amounts.divmod(d1.value, d1.denomWithdraw).quotient; const rate2 = Amounts.isZero(d2.denomWithdraw) ? Number.MIN_SAFE_INTEGER : 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.isZero(d1.denomDeposit) ? Number.MIN_SAFE_INTEGER : Amounts.divmod(d1.value, d1.denomDeposit).quotient; const rate2 = Amounts.isZero(d2.denomDeposit) ? Number.MIN_SAFE_INTEGER : 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 */ function rankDenominationForRefresh( denoms: CoinInfo[], ): Record { const groupByExchange: Record = {}; for (const d of denoms) { if (!groupByExchange[d.exchangeBaseUrl]) { groupByExchange[d.exchangeBaseUrl] = []; } groupByExchange[d.exchangeBaseUrl].push(d); } const result: Record = {}; 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 }; 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); }