/* This file is part of GNU Taler (C) 2021 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 */ /** * Selection of coins for payments. * * @author Florian Dold */ /** * Imports. */ import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, AgeCommitmentProof, AgeRestriction, AmountJson, Amounts, AmountString, CoinStatus, DenominationInfo, DenominationPubKey, DenomSelectionState, Duration, ForcedCoinSel, ForcedDenomSel, GetPlanForOperationRequest, GetPlanForOperationResponse, j2s, Logger, parsePaytoUri, PayCoinSelection, PayMerchantInsufficientBalanceDetails, strcmp, TransactionType, } from "@gnu-taler/taler-util"; import { AllowedAuditorInfo, AllowedExchangeInfo, DenominationRecord, } from "../db.js"; import { CoinAvailabilityRecord, getExchangeDetails, isWithdrawableDenom, } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { getMerchantPaymentBalanceDetails } from "../operations/balance.js"; import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; const logger = new Logger("coinSelection.ts"); /** * Structure to describe a coin that is available to be * used in a payment. */ export interface AvailableCoinInfo { /** * Public key of the coin. */ coinPub: string; /** * Coin's denomination public key. * * FIXME: We should only need the denomPubHash here, if at all. */ denomPub: DenominationPubKey; /** * Full value of the coin. */ value: AmountJson; /** * Amount still remaining (typically the full amount, * as coins are always refreshed after use.) */ availableAmount: AmountJson; /** * Deposit fee for the coin. */ feeDeposit: AmountJson; exchangeBaseUrl: string; maxAge: number; ageCommitmentProof?: AgeCommitmentProof; } export type PreviousPayCoins = { coinPub: string; contribution: AmountJson; feeDeposit: AmountJson; exchangeBaseUrl: string; }[]; export interface CoinCandidateSelection { candidateCoins: AvailableCoinInfo[]; wireFeesPerExchange: Record; } export interface SelectPayCoinRequest { candidates: CoinCandidateSelection; contractTermsAmount: AmountJson; depositFeeLimit: AmountJson; wireFeeLimit: AmountJson; wireFeeAmortization: number; prevPayCoins?: PreviousPayCoins; requiredMinimumAge?: number; } export interface CoinSelectionTally { /** * Amount that still needs to be paid. * May increase during the computation when fees need to be covered. */ amountPayRemaining: AmountJson; /** * Allowance given by the merchant towards wire fees */ amountWireFeeLimitRemaining: AmountJson; /** * Allowance given by the merchant towards deposit fees * (and wire fees after wire fee limit is exhausted) */ amountDepositFeeLimitRemaining: AmountJson; customerDepositFees: AmountJson; customerWireFees: AmountJson; wireFeeCoveredForExchange: Set; lastDepositFee: AmountJson; } /** * Account for the fees of spending a coin. */ function tallyFees( tally: Readonly, wireFeesPerExchange: Record, wireFeeAmortization: number, exchangeBaseUrl: string, feeDeposit: AmountJson, ): CoinSelectionTally { const currency = tally.amountPayRemaining.currency; let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining; let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining; let customerDepositFees = tally.customerDepositFees; let customerWireFees = tally.customerWireFees; let amountPayRemaining = tally.amountPayRemaining; const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange); if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) { const wf = wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency); const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf); amountWireFeeLimitRemaining = Amounts.sub( amountWireFeeLimitRemaining, wfForgiven, ).amount; // The remaining, amortized amount needs to be paid by the // wallet or covered by the deposit fee allowance. let wfRemaining = Amounts.divide( Amounts.sub(wf, wfForgiven).amount, wireFeeAmortization, ); // This is the amount forgiven via the deposit fee allowance. const wfDepositForgiven = Amounts.min( amountDepositFeeLimitRemaining, wfRemaining, ); amountDepositFeeLimitRemaining = Amounts.sub( amountDepositFeeLimitRemaining, wfDepositForgiven, ).amount; wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount; customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount; amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount; wireFeeCoveredForExchange.add(exchangeBaseUrl); } const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining); amountDepositFeeLimitRemaining = Amounts.sub( amountDepositFeeLimitRemaining, dfForgiven, ).amount; // How much does the user spend on deposit fees for this coin? const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount; customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount; amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount; return { amountDepositFeeLimitRemaining, amountPayRemaining, amountWireFeeLimitRemaining, customerDepositFees, customerWireFees, wireFeeCoveredForExchange, lastDepositFee: feeDeposit, }; } export type SelectPayCoinsResult = | { type: "failure"; insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; } | { type: "success"; coinSel: PayCoinSelection }; /** * Given a list of candidate coins, select coins to spend under the merchant's * constraints. * * The prevPayCoins can be specified to "repair" a coin selection * by adding additional coins, after a broken (e.g. double-spent) coin * has been removed from the selection. * * This function is only exported for the sake of unit tests. */ export async function selectPayCoinsNew( ws: InternalWalletState, req: SelectPayCoinRequestNg, ): Promise { const { contractTermsAmount, depositFeeLimit, wireFeeLimit, wireFeeAmortization, } = req; const [candidateDenoms, wireFeesPerExchange] = await selectCandidates( ws, req, ); const coinPubs: string[] = []; const coinContributions: AmountJson[] = []; const currency = contractTermsAmount.currency; let tally: CoinSelectionTally = { amountPayRemaining: contractTermsAmount, amountWireFeeLimitRemaining: wireFeeLimit, amountDepositFeeLimitRemaining: depositFeeLimit, customerDepositFees: Amounts.zeroOfCurrency(currency), customerWireFees: Amounts.zeroOfCurrency(currency), wireFeeCoveredForExchange: new Set(), lastDepositFee: Amounts.zeroOfCurrency(currency), }; const prevPayCoins = req.prevPayCoins ?? []; // Look at existing pay coin selection and tally up for (const prev of prevPayCoins) { tally = tallyFees( tally, wireFeesPerExchange, wireFeeAmortization, prev.exchangeBaseUrl, prev.feeDeposit, ); tally.amountPayRemaining = Amounts.sub( tally.amountPayRemaining, prev.contribution, ).amount; coinPubs.push(prev.coinPub); coinContributions.push(prev.contribution); } let selectedDenom: SelResult | undefined; if (req.forcedSelection) { selectedDenom = selectForced(req, candidateDenoms); } else { // FIXME: Here, we should select coins in a smarter way. // Instead of always spending the next-largest coin, // we should try to find the smallest coin that covers the // amount. selectedDenom = selectGreedy( req, candidateDenoms, wireFeesPerExchange, tally, ); } if (!selectedDenom) { const details = await getMerchantPaymentBalanceDetails(ws, { acceptedAuditors: req.auditors, acceptedExchanges: req.exchanges, acceptedWireMethods: [req.wireMethod], currency: Amounts.currencyOf(req.contractTermsAmount), minAge: req.requiredMinimumAge ?? 0, }); let feeGapEstimate: AmountJson; if ( Amounts.cmp( details.balanceMerchantDepositable, req.contractTermsAmount, ) >= 0 ) { // FIXME: We can probably give a better estimate. feeGapEstimate = Amounts.add( tally.amountPayRemaining, tally.lastDepositFee, ).amount; } else { feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount); } return { type: "failure", insufficientBalanceDetails: { amountRequested: Amounts.stringify(req.contractTermsAmount), balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable), balanceAvailable: Amounts.stringify(details.balanceAvailable), balanceMaterial: Amounts.stringify(details.balanceMaterial), balanceMerchantAcceptable: Amounts.stringify( details.balanceMerchantAcceptable, ), balanceMerchantDepositable: Amounts.stringify( details.balanceMerchantDepositable, ), feeGapEstimate: Amounts.stringify(feeGapEstimate), }, }; } const finalSel = selectedDenom; logger.trace(`coin selection request ${j2s(req)}`); logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); await ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { for (const dph of Object.keys(finalSel)) { const selInfo = finalSel[dph]; const numRequested = selInfo.contributions.length; const query = [ selInfo.exchangeBaseUrl, selInfo.denomPubHash, selInfo.maxAge, CoinStatus.Fresh, ]; logger.info(`query: ${j2s(query)}`); const coins = await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( query, numRequested, ); if (coins.length != numRequested) { throw Error( `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, ); } coinPubs.push(...coins.map((x) => x.coinPub)); coinContributions.push(...selInfo.contributions); } }); return { type: "success", coinSel: { paymentAmount: Amounts.stringify(contractTermsAmount), coinContributions: coinContributions.map((x) => Amounts.stringify(x)), coinPubs, customerDepositFees: Amounts.stringify(tally.customerDepositFees), customerWireFees: Amounts.stringify(tally.customerWireFees), }, }; } function makeAvailabilityKey( exchangeBaseUrl: string, denomPubHash: string, maxAge: number, ): string { return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; } /** * Selection result. */ interface SelResult { /** * Map from an availability key * to an array of contributions. */ [avKey: string]: { exchangeBaseUrl: string; denomPubHash: string; maxAge: number; contributions: AmountJson[]; }; } function selectGreedy( req: SelectPayCoinRequestNg, candidateDenoms: AvailableDenom[], wireFeesPerExchange: Record, tally: CoinSelectionTally, ): SelResult | undefined { const { wireFeeAmortization } = req; const selectedDenom: SelResult = {}; for (const denom of candidateDenoms) { const contributions: AmountJson[] = []; // Don't use this coin if depositing it is more expensive than // the amount it would give the merchant. if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) { tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit); continue; } for ( let i = 0; i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); i++ ) { tally = tallyFees( tally, wireFeesPerExchange, wireFeeAmortization, denom.exchangeBaseUrl, Amounts.parseOrThrow(denom.feeDeposit), ); const coinSpend = Amounts.max( Amounts.min(tally.amountPayRemaining, denom.value), denom.feeDeposit, ); tally.amountPayRemaining = Amounts.sub( tally.amountPayRemaining, coinSpend, ).amount; contributions.push(coinSpend); } if (contributions.length) { const avKey = makeAvailabilityKey( denom.exchangeBaseUrl, denom.denomPubHash, denom.maxAge, ); let sd = selectedDenom[avKey]; if (!sd) { sd = { contributions: [], denomPubHash: denom.denomPubHash, exchangeBaseUrl: denom.exchangeBaseUrl, maxAge: denom.maxAge, }; } sd.contributions.push(...contributions); selectedDenom[avKey] = sd; } } return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined; } function selectForced( req: SelectPayCoinRequestNg, candidateDenoms: AvailableDenom[], ): SelResult | undefined { const selectedDenom: SelResult = {}; const forcedSelection = req.forcedSelection; checkLogicInvariant(!!forcedSelection); for (const forcedCoin of forcedSelection.coins) { let found = false; for (const aci of candidateDenoms) { if (aci.numAvailable <= 0) { continue; } if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { aci.numAvailable--; const avKey = makeAvailabilityKey( aci.exchangeBaseUrl, aci.denomPubHash, aci.maxAge, ); let sd = selectedDenom[avKey]; if (!sd) { sd = { contributions: [], denomPubHash: aci.denomPubHash, exchangeBaseUrl: aci.exchangeBaseUrl, maxAge: aci.maxAge, }; } sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); selectedDenom[avKey] = sd; found = true; break; } } if (!found) { throw Error("can't find coin for forced coin selection"); } } return selectedDenom; } export interface SelectPayCoinRequestNg { exchanges: AllowedExchangeInfo[]; auditors: AllowedAuditorInfo[]; wireMethod: string; contractTermsAmount: AmountJson; depositFeeLimit: AmountJson; wireFeeLimit: AmountJson; wireFeeAmortization: number; prevPayCoins?: PreviousPayCoins; requiredMinimumAge?: number; forcedSelection?: ForcedCoinSel; } export type AvailableDenom = DenominationInfo & { maxAge: number; numAvailable: number; }; async function selectCandidates( ws: InternalWalletState, req: SelectPayCoinRequestNg, ): Promise<[AvailableDenom[], Record]> { return await ws.db .mktx((x) => [ x.exchanges, x.exchangeDetails, x.denominations, x.coinAvailability, ]) .runReadOnly(async (tx) => { // FIXME: Use the existing helper (from balance.ts) to // get acceptable exchanges. const denoms: AvailableDenom[] = []; const exchanges = await tx.exchanges.iter().toArray(); const wfPerExchange: Record = {}; for (const exchange of exchanges) { const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl); // 1.- exchange has same currency if (exchangeDetails?.currency !== req.contractTermsAmount.currency) { continue; } let wireMethodFee: string | undefined; // 2.- exchange supports wire method for (const acc of exchangeDetails.wireInfo.accounts) { const pp = parsePaytoUri(acc.payto_uri); checkLogicInvariant(!!pp); if (pp.targetType === req.wireMethod) { // also check that wire method is supported now const wireFeeStr = exchangeDetails.wireInfo.feesForType[ req.wireMethod ]?.find((x) => { return AbsoluteTime.isBetween( AbsoluteTime.now(), AbsoluteTime.fromProtocolTimestamp(x.startStamp), AbsoluteTime.fromProtocolTimestamp(x.endStamp), ); })?.wireFee; if (wireFeeStr) { wireMethodFee = wireFeeStr; } break; } } if (!wireMethodFee) { break; } wfPerExchange[exchange.baseUrl] = Amounts.parseOrThrow(wireMethodFee); // 3.- exchange is trusted in the exchange list or auditor list let accepted = false; for (const allowedExchange of req.exchanges) { if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { accepted = true; break; } } for (const allowedAuditor of req.auditors) { for (const providedAuditor of exchangeDetails.auditors) { if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { accepted = true; break; } } } if (!accepted) { continue; } //4.- filter coins restricted by age let ageLower = 0; let ageUpper = AgeRestriction.AGE_UNRESTRICTED; if (req.requiredMinimumAge) { ageLower = req.requiredMinimumAge; } 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; } denoms.push({ ...DenominationRecord.toDenomInfo(denom), numAvailable: coinAvail.freshCoinCount ?? 0, maxAge: coinAvail.maxAge, }); } } // Sort by available amount (descending), deposit fee (ascending) and // denomPub (ascending) if deposit fee is the same // (to guarantee deterministic results) denoms.sort( (o1, o2) => -Amounts.cmp(o1.value, o2.value) || Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || strcmp(o1.denomPubHash, o2.denomPubHash), ); return [denoms, wfPerExchange]; }); } /** * Get a list of denominations (with repetitions possible) * whose total value is as close as possible to the available * amount, but never larger. */ export function selectWithdrawalDenominations( amountAvailable: AmountJson, denoms: DenominationRecord[], denomselAllowLate: boolean = false, ): DenomSelectionState { let remaining = Amounts.copy(amountAvailable); const selectedDenoms: { count: number; denomPubHash: string; }[] = []; let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); denoms.sort((d1, d2) => Amounts.cmp( DenominationRecord.getValue(d2), DenominationRecord.getValue(d1), ), ); for (const d of denoms) { const cost = Amounts.add( DenominationRecord.getValue(d), d.fees.feeWithdraw, ).amount; const res = Amounts.divmod(remaining, cost); const count = res.quotient; remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount; if (count > 0) { totalCoinValue = Amounts.add( totalCoinValue, Amounts.mult(DenominationRecord.getValue(d), count).amount, ).amount; totalWithdrawCost = Amounts.add( totalWithdrawCost, Amounts.mult(cost, count).amount, ).amount; selectedDenoms.push({ count, denomPubHash: d.denomPubHash, }); } if (Amounts.isZero(remaining)) { break; } } if (logger.shouldLogTrace()) { logger.trace( `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`, ); for (const sd of selectedDenoms) { logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`); } logger.trace("(end of withdrawal denom list)"); } return { selectedDenoms, totalCoinValue: Amounts.stringify(totalCoinValue), totalWithdrawCost: Amounts.stringify(totalWithdrawCost), }; } export function selectForcedWithdrawalDenominations( amountAvailable: AmountJson, denoms: DenominationRecord[], forcedDenomSel: ForcedDenomSel, denomselAllowLate: boolean, ): DenomSelectionState { const selectedDenoms: { count: number; denomPubHash: string; }[] = []; let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); denoms.sort((d1, d2) => Amounts.cmp( DenominationRecord.getValue(d2), DenominationRecord.getValue(d1), ), ); for (const fds of forcedDenomSel.denoms) { const count = fds.count; const denom = denoms.find((x) => { return Amounts.cmp(DenominationRecord.getValue(x), fds.value) == 0; }); if (!denom) { throw Error( `unable to find denom for forced selection (value ${fds.value})`, ); } const cost = Amounts.add( DenominationRecord.getValue(denom), denom.fees.feeWithdraw, ).amount; totalCoinValue = Amounts.add( totalCoinValue, Amounts.mult(DenominationRecord.getValue(denom), count).amount, ).amount; totalWithdrawCost = Amounts.add( totalWithdrawCost, Amounts.mult(cost, count).amount, ).amount; selectedDenoms.push({ count, denomPubHash: denom.denomPubHash, }); } return { selectedDenoms, totalCoinValue: Amounts.stringify(totalCoinValue), totalWithdrawCost: Amounts.stringify(totalWithdrawCost), }; } 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, }; } } } export function calculatePlanFormAvailableCoins( transactionType: TransactionType, amount: AmountJson, mode: "effective" | "raw", availableCoins: AvailableCoins, ) { const operationType = getOperationType(transactionType); let usableCoins; switch (transactionType) { case TransactionType.Withdrawal: { usableCoins = selectCoinForOperation( operationType, amount, mode === "effective" ? "net" : "gross", availableCoins, ); break; } case TransactionType.Deposit: { //FIXME: just doing for 1 exchange now //assuming that the wallet has one exchange and all the coins available //are from that exchange const wireFee = Object.values(availableCoins.exchanges)[0].wireFee!; if (mode === "effective") { usableCoins = selectCoinForOperation( operationType, amount, "gross", availableCoins, ); usableCoins.totalContribution = Amounts.sub( usableCoins.totalContribution, wireFee, ).amount; } else { const adjustedAmount = Amounts.add(amount, wireFee).amount; usableCoins = selectCoinForOperation( operationType, adjustedAmount, "net", availableCoins, ); usableCoins.totalContribution = Amounts.sub( usableCoins.totalContribution, wireFee, ).amount; } break; } default: { throw Error("operation not supported"); } } return getAmountsWithFee( operationType, usableCoins!.totalValue, usableCoins!.totalContribution, usableCoins, ); } /** * simulate a coin selection and return the amount * that will effectively change the wallet balance and * the raw amount of the operation * * @param ws * @param br * @returns */ export async function getPlanForOperation( ws: InternalWalletState, req: GetPlanForOperationRequest, ): Promise { const amount = Amounts.parseOrThrow(req.instructedAmount); const operationType = getOperationType(req.type); const filter = getCoinsFilter(req); const availableCoins = await getAvailableCoins( ws, operationType, amount.currency, filter, ); return calculatePlanFormAvailableCoins( req.type, amount, req.mode, availableCoins, ); } /** * * @param op defined which fee are we taking into consideration: deposits or withdraw * @param limit the total amount limit of the operation * @param mode if the total amount is includes the fees or just the contribution * @param denoms list of available denomination for the operation * @returns */ export function selectCoinForOperation( op: "debit" | "credit", limit: AmountJson, mode: "net" | "gross", coins: AvailableCoins, ): SelectedCoins { const result: SelectedCoins = { totalValue: Amounts.zeroOfCurrency(limit.currency), totalWithdrawalFee: Amounts.zeroOfCurrency(limit.currency), totalDepositFee: Amounts.zeroOfCurrency(limit.currency), totalContribution: Amounts.zeroOfCurrency(limit.currency), coins: [], }; if (!coins.list.length) return result; /** * We can make this faster. We should prevent sorting and * keep the information ready for multiple calls since this * function is expected to work on embedded devices and * create a response on key press */ //rank coins coins.list.sort(buildRankingForCoins(op)); //take coins in order until amount let selectedCoinsAreEnough = false; let denomIdx = 0; iterateDenoms: while (denomIdx < coins.list.length) { const denom = coins.list[denomIdx]; let total = op === "credit" ? Number.MAX_SAFE_INTEGER : denom.totalAvailable ?? 0; const opFee = op === "credit" ? denom.denomWithdraw : denom.denomDeposit; const contribution = Amounts.sub(denom.value, opFee).amount; if (Amounts.isZero(contribution)) { // 0 contribution denoms should be the last break iterateDenoms; } //use Amounts.divmod instead of iterate iterateCoins: while (total > 0) { const nextValue = Amounts.add(result.totalValue, denom.value).amount; const nextContribution = Amounts.add( result.totalContribution, contribution, ).amount; const progress = mode === "gross" ? nextValue : nextContribution; if (Amounts.cmp(progress, limit) === 1) { //the current coin is more than we need, try next denom break iterateCoins; } result.totalValue = nextValue; result.totalContribution = nextContribution; result.totalDepositFee = Amounts.add( result.totalDepositFee, denom.denomDeposit, ).amount; result.totalWithdrawalFee = Amounts.add( result.totalWithdrawalFee, denom.denomWithdraw, ).amount; result.coins.push(denom.id); if (Amounts.cmp(progress, limit) === 0) { selectedCoinsAreEnough = true; // we have just enough coins, complete break iterateDenoms; } //go next coin total--; } //go next denom denomIdx++; } if (selectedCoinsAreEnough) { // we made it return result; } if (op === "credit") { //doing withdraw there is no way to cover the gap return result; } //tried all the coins but there is a gap //doing deposit we can try refreshing coins const total = mode === "gross" ? result.totalValue : result.totalContribution; const gap = Amounts.sub(limit, total).amount; //about recursive calls //the only way to get here is by doing a deposit (that will do a refresh) //and now we are calculating fee for credit (which does not need to calculate refresh) let refreshIdx = 0; let choice: RefreshChoice | undefined = undefined; refreshIteration: while (refreshIdx < coins.list.length) { const d = coins.list[refreshIdx]; const denomContribution = mode === "gross" ? Amounts.sub(d.value, d.denomRefresh).amount : Amounts.sub(d.value, d.denomDeposit, d.denomRefresh).amount; const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount; if (Amounts.isZero(changeAfterDeposit)) { //the rest of the coins are very small break refreshIteration; } const changeCost = selectCoinForOperation( "credit", changeAfterDeposit, mode, coins, ); const totalFee = Amounts.add( d.denomDeposit, d.denomRefresh, changeCost.totalWithdrawalFee, ).amount; if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { //found cheaper change choice = { gap: gap, totalFee: totalFee, selected: d.id, totalValue: d.value, totalRefreshFee: d.denomRefresh, totalDepositFee: d.denomDeposit, totalChangeValue: changeCost.totalValue, totalChangeContribution: changeCost.totalContribution, totalChangeWithdrawalFee: changeCost.totalWithdrawalFee, change: changeCost.coins, }; } refreshIdx++; } if (choice) { if (mode === "gross") { result.totalValue = Amounts.add(result.totalValue, gap).amount; result.totalContribution = Amounts.add( result.totalContribution, gap, ).amount; result.totalContribution = Amounts.sub( result.totalContribution, choice.totalFee, ).amount; } else { result.totalContribution = Amounts.add( result.totalContribution, gap, ).amount; result.totalValue = Amounts.add( result.totalValue, gap, choice.totalFee, ).amount; } } // console.log("gap", Amounts.stringify(limit), Amounts.stringify(gap), choice); result.refresh = choice; return result; } type CompareCoinsFunction = (d1: CoinInfo, d2: CoinInfo) => -1 | 0 | 1; function buildRankingForCoins(op: "debit" | "credit"): CompareCoinsFunction { function getFee(d: CoinInfo) { return op === "credit" ? d.denomWithdraw : d.denomDeposit; } //different exchanges may have different wireFee //ranking should take the relative contribution in the exchange //which is (value - denomFee / fixedFee) // where denomFee is withdraw or deposit // and fixedFee can be purse or wire return function rank(d1: CoinInfo, d2: CoinInfo) { const contrib1 = Amounts.sub(d1.value, getFee(d1)).amount; const contrib2 = Amounts.sub(d2.value, getFee(d2)).amount; return ( Amounts.cmp(contrib2, contrib1) || Duration.cmp(d1.duration, d2.duration) || strcmp(d1.id, d2.id) ); }; } function getOperationType(txType: TransactionType): "credit" | "debit" { const operationType = txType === TransactionType.Withdrawal ? ("credit" as const) : txType === TransactionType.Deposit ? ("debit" as const) : undefined; if (!operationType) { throw Error(`operation type ${txType} not supported`); } return operationType; } function getAmountsWithFee( op: "debit" | "credit", value: AmountJson, contribution: AmountJson, details: any, ): GetPlanForOperationResponse { return { rawAmount: Amounts.stringify(op === "credit" ? value : contribution), effectiveAmount: Amounts.stringify(op === "credit" ? contribution : value), details, }; } interface RefreshChoice { gap: AmountJson; totalFee: AmountJson; selected: string; totalValue: AmountJson; totalDepositFee: AmountJson; totalRefreshFee: AmountJson; totalChangeValue: AmountJson; totalChangeContribution: AmountJson; totalChangeWithdrawalFee: AmountJson; change: string[]; } interface SelectedCoins { totalValue: AmountJson; totalContribution: AmountJson; totalWithdrawalFee: AmountJson; totalDepositFee: AmountJson; coins: string[]; refresh?: RefreshChoice; } interface AvailableCoins { list: CoinInfo[]; exchanges: Record; } interface CoinInfo { id: string; value: AmountJson; denomDeposit: AmountJson; denomWithdraw: AmountJson; denomRefresh: AmountJson; totalAvailable: number | undefined; exchangeWire: AmountJson | undefined; exchangePurse: AmountJson | undefined; duration: Duration; maxAge: number; } interface ExchangeInfo { wireFee: AmountJson | undefined; purseFee: AmountJson | undefined; creditDeadline: AbsoluteTime; debitDeadline: AbsoluteTime; } interface CoinsFilter { shouldCalculatePurseFee?: boolean; exchanges?: string[]; wireMethod?: string; ageRestricted?: number; } /** * 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( ws: InternalWalletState, op: "credit" | "debit", currency: string, filters: CoinsFilter = {}, ): Promise { return await ws.db .mktx((x) => [ x.exchanges, x.exchangeDetails, x.denominations, x.coinAvailability, ]) .runReadOnly(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 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 (op === "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, duration: AbsoluteTime.difference( AbsoluteTime.now(), AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit), ), totalAvailable: total, value: DenominationRecord.getValue(denom), maxAge, }; }