/* 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 { AgeCommitmentProof, AgeRestriction, AmountJson, Amounts, DenominationPubKey, ForcedCoinSel, Logger, PayCoinSelection, } from "@gnu-taler/taler-util"; import { 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; 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; } /** * Account for the fees of spending a coin. */ export function tallyFees( tally: CoinSelectionTally, 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.getZero(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, }; } /** * 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 function selectPayCoinsLegacy( req: SelectPayCoinRequest, ): PayCoinSelection | undefined { const { candidates, contractTermsAmount, depositFeeLimit, wireFeeLimit, wireFeeAmortization, } = req; if (candidates.candidateCoins.length === 0) { return undefined; } const coinPubs: string[] = []; const coinContributions: AmountJson[] = []; const currency = contractTermsAmount.currency; let tally: CoinSelectionTally = { amountPayRemaining: contractTermsAmount, amountWireFeeLimitRemaining: wireFeeLimit, amountDepositFeeLimitRemaining: depositFeeLimit, customerDepositFees: Amounts.getZero(currency), customerWireFees: Amounts.getZero(currency), wireFeeCoveredForExchange: new Set(), }; const prevPayCoins = req.prevPayCoins ?? []; // Look at existing pay coin selection and tally up for (const prev of prevPayCoins) { tally = tallyFees( tally, candidates.wireFeesPerExchange, wireFeeAmortization, prev.exchangeBaseUrl, prev.feeDeposit, ); tally.amountPayRemaining = Amounts.sub( tally.amountPayRemaining, prev.contribution, ).amount; coinPubs.push(prev.coinPub); coinContributions.push(prev.contribution); } const prevCoinPubs = new Set(prevPayCoins.map((x) => x.coinPub)); // Sort by available amount (descending), deposit fee (ascending) and // denomPub (ascending) if deposit fee is the same // (to guarantee deterministic results) const candidateCoins = [...candidates.candidateCoins].sort( (o1, o2) => -Amounts.cmp(o1.availableAmount, o2.availableAmount) || Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || DenominationPubKey.cmp(o1.denomPub, o2.denomPub), ); // 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. for (const aci of candidateCoins) { // Don't use this coin if depositing it is more expensive than // the amount it would give the merchant. if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) > 0) { continue; } if (Amounts.isZero(tally.amountPayRemaining)) { // We have spent enough! break; } // The same coin can't contribute twice to the same payment, // by a fundamental, intentional limitation of the protocol. if (prevCoinPubs.has(aci.coinPub)) { continue; } if (req.requiredMinimumAge != null) { const index = AgeRestriction.getAgeGroupIndex( aci.denomPub.age_mask, req.requiredMinimumAge, ); // if (!aci.ageCommitmentProof) { // // No age restriction, can't use for this payment // continue; // } if ( aci.ageCommitmentProof && aci.ageCommitmentProof.proof.privateKeys.length < index ) { // Available age proofs to low, can't use for this payment continue; } } tally = tallyFees( tally, candidates.wireFeesPerExchange, wireFeeAmortization, aci.exchangeBaseUrl, aci.feeDeposit, ); let coinSpend = Amounts.max( Amounts.min(tally.amountPayRemaining, aci.availableAmount), aci.feeDeposit, ); tally.amountPayRemaining = Amounts.sub( tally.amountPayRemaining, coinSpend, ).amount; coinPubs.push(aci.coinPub); coinContributions.push(coinSpend); } if (Amounts.isZero(tally.amountPayRemaining)) { return { paymentAmount: contractTermsAmount, coinContributions, coinPubs, customerDepositFees: tally.customerDepositFees, customerWireFees: tally.customerWireFees, }; } return undefined; } export function selectForcedPayCoins( forcedCoinSel: ForcedCoinSel, req: SelectPayCoinRequest, ): PayCoinSelection | undefined { const { candidates, contractTermsAmount, depositFeeLimit, wireFeeLimit, wireFeeAmortization, } = req; if (candidates.candidateCoins.length === 0) { return undefined; } const coinPubs: string[] = []; const coinContributions: AmountJson[] = []; const currency = contractTermsAmount.currency; let tally: CoinSelectionTally = { amountPayRemaining: contractTermsAmount, amountWireFeeLimitRemaining: wireFeeLimit, amountDepositFeeLimitRemaining: depositFeeLimit, customerDepositFees: Amounts.getZero(currency), customerWireFees: Amounts.getZero(currency), wireFeeCoveredForExchange: new Set(), }; // Not supported by forced coin selection checkLogicInvariant(!req.prevPayCoins); // Sort by available amount (descending), deposit fee (ascending) and // denomPub (ascending) if deposit fee is the same // (to guarantee deterministic results) const candidateCoins = [...candidates.candidateCoins].sort( (o1, o2) => -Amounts.cmp(o1.availableAmount, o2.availableAmount) || Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || DenominationPubKey.cmp(o1.denomPub, o2.denomPub), ); // 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. // Set of spent coin indices from candidate coins const spentSet: Set = new Set(); for (const forcedCoin of forcedCoinSel.coins) { let aci: AvailableCoinInfo | undefined = undefined; for (let i = 0; i < candidateCoins.length; i++) { if (spentSet.has(i)) { continue; } if ( Amounts.cmp(forcedCoin.value, candidateCoins[i].availableAmount) != 0 ) { continue; } spentSet.add(i); aci = candidateCoins[i]; break; } if (!aci) { throw Error("can't find coin for forced coin selection"); } tally = tallyFees( tally, candidates.wireFeesPerExchange, wireFeeAmortization, aci.exchangeBaseUrl, aci.feeDeposit, ); let coinSpend = Amounts.parseOrThrow(forcedCoin.contribution); tally.amountPayRemaining = Amounts.sub( tally.amountPayRemaining, coinSpend, ).amount; coinPubs.push(aci.coinPub); coinContributions.push(coinSpend); } if (Amounts.isZero(tally.amountPayRemaining)) { return { paymentAmount: contractTermsAmount, coinContributions, coinPubs, customerDepositFees: tally.customerDepositFees, customerWireFees: tally.customerWireFees, }; } return undefined; }