summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/coinSelection.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/coinSelection.ts')
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts1258
1 files changed, 1258 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
new file mode 100644
index 000000000..a60e41ecd
--- /dev/null
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -0,0 +1,1258 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 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/>
+ */
+
+/**
+ * Selection of coins for payments.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AccountRestriction,
+ AgeRestriction,
+ AllowedAuditorInfo,
+ AllowedExchangeInfo,
+ AmountJson,
+ Amounts,
+ checkDbInvariant,
+ checkLogicInvariant,
+ CoinStatus,
+ DenominationInfo,
+ ExchangeGlobalFees,
+ ForcedCoinSel,
+ InternationalizedString,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ PayCoinSelection,
+ PaymentInsufficientBalanceDetails,
+ ProspectivePayCoinSelection,
+ SelectedCoin,
+ SelectedProspectiveCoin,
+ strcmp,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { getPaymentBalanceDetailsInTx } from "./balance.js";
+import { getAutoRefreshExecuteThreshold } from "./common.js";
+import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js";
+import {
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("coinSelection.ts");
+
+export type PreviousPayCoins = {
+ coinPub: string;
+ contribution: AmountJson;
+}[];
+
+export interface ExchangeRestrictionSpec {
+ exchanges: AllowedExchangeInfo[];
+ auditors: AllowedAuditorInfo[];
+}
+
+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 deposit fees
+ * (and wire fees after wire fee limit is exhausted)
+ */
+ amountDepositFeeLimitRemaining: AmountJson;
+
+ customerDepositFees: AmountJson;
+
+ customerWireFees: AmountJson;
+
+ wireFeeCoveredForExchange: Set<string>;
+
+ lastDepositFee: AmountJson;
+}
+
+/**
+ * Account for the fees of spending a coin.
+ */
+function tallyFees(
+ tally: CoinSelectionTally,
+ wireFeesPerExchange: Record<string, AmountJson>,
+ exchangeBaseUrl: string,
+ feeDeposit: AmountJson,
+): void {
+ const currency = tally.amountPayRemaining.currency;
+
+ if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
+ const wf =
+ wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency);
+ // The remaining, amortized amount needs to be paid by the
+ // wallet or covered by the deposit fee allowance.
+ let wfRemaining = wf;
+ // This is the amount forgiven via the deposit fee allowance.
+ const wfDepositForgiven = Amounts.min(
+ tally.amountDepositFeeLimitRemaining,
+ wfRemaining,
+ );
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ wfDepositForgiven,
+ ).amount;
+ wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
+ tally.customerWireFees = Amounts.add(
+ tally.customerWireFees,
+ wfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ wfRemaining,
+ ).amount;
+ tally.wireFeeCoveredForExchange.add(exchangeBaseUrl);
+ }
+
+ const dfForgiven = Amounts.min(
+ feeDeposit,
+ tally.amountDepositFeeLimitRemaining,
+ );
+
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ dfForgiven,
+ ).amount;
+
+ // How much does the user spend on deposit fees for this coin?
+ const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
+ tally.customerDepositFees = Amounts.add(
+ tally.customerDepositFees,
+ dfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ dfRemaining,
+ ).amount;
+ tally.lastDepositFee = feeDeposit;
+}
+
+export type SelectPayCoinsResult =
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ }
+ | { type: "prospective"; result: ProspectivePayCoinSelection }
+ | { type: "success"; coinSel: PayCoinSelection };
+
+async function internalSelectPayCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ]
+ >,
+ req: SelectPayCoinRequestNg,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
+ | undefined
+> {
+ const { contractTermsAmount, depositFeeLimit } = req;
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ includePendingCoins,
+ },
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ );
+ logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
+ logger.trace(`candidates: ${j2s(candidateDenoms)}`);
+ }
+
+ const coinRes: SelectedCoin[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.zeroOfCurrency(currency),
+ customerWireFees: Amounts.zeroOfCurrency(currency),
+ wireFeeCoveredForExchange: new Set(),
+ lastDepositFee: Amounts.zeroOfCurrency(currency),
+ };
+
+ await maybeRepairCoinSelection(
+ wex,
+ tx,
+ req.prevPayCoins ?? [],
+ coinRes,
+ tally,
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ );
+
+ 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(
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ candidateDenoms,
+ tally,
+ );
+ }
+
+ if (!selectedDenom) {
+ return undefined;
+ }
+ return {
+ sel: selectedDenom,
+ coinRes,
+ tally,
+ };
+}
+
+/**
+ * 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.
+ */
+export async function selectPayCoins(
+ wex: WalletExecutionContext,
+ req: SelectPayCoinRequestNg,
+): Promise<SelectPayCoinsResult> {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selecting coins for ${j2s(req)}`);
+ }
+
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const materialAvSel = await internalSelectPayCoins(wex, tx, req, false);
+
+ if (!materialAvSel) {
+ const prospectiveAvSel = await internalSelectPayCoins(
+ wex,
+ tx,
+ req,
+ true,
+ );
+
+ if (prospectiveAvSel) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvSel.sel)) {
+ const mySel = prospectiveAvSel.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ customerDepositFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerDepositFees,
+ ),
+ customerWireFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerWireFees,
+ ),
+ },
+ } satisfies SelectPayCoinsResult;
+ }
+
+ return {
+ type: "failure",
+ insufficientBalanceDetails: await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ requiredMinimumAge: req.requiredMinimumAge,
+ wireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ },
+ ),
+ } satisfies SelectPayCoinsResult;
+ }
+
+ const coinSel = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ materialAvSel.sel,
+ materialAvSel.coinRes,
+ materialAvSel.tally,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`coin selection: ${j2s(coinSel)}`);
+ }
+
+ return {
+ type: "success",
+ coinSel,
+ };
+ },
+ );
+}
+
+async function maybeRepairCoinSelection(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ prevPayCoins: PreviousPayCoins,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+ feeInfo: {
+ wireFeesPerExchange: Record<string, AmountJson>;
+ },
+): Promise<void> {
+ // Look at existing pay coin selection and tally up
+ for (const prev of prevPayCoins) {
+ const coin = await tx.coins.get(prev.coinPub);
+ if (!coin) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ tallyFees(
+ tally,
+ feeInfo.wireFeesPerExchange,
+ coin.exchangeBaseUrl,
+ Amounts.parseOrThrow(denom.feeDeposit),
+ );
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ prev.contribution,
+ ).amount;
+
+ coinRes.push({
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ denomPubHash: coin.denomPubHash,
+ coinPub: prev.coinPub,
+ contribution: Amounts.stringify(prev.contribution),
+ });
+ }
+}
+
+/**
+ * Returns undefined if the success response could not be assembled,
+ * as not enough coins are actually available.
+ */
+async function assembleSelectPayCoinsSuccessResult(
+ tx: WalletDbReadOnlyTransaction<["coins"]>,
+ finalSel: SelResult,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+): Promise<PayCoinSelection> {
+ 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.trace(`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})`,
+ );
+ }
+
+ for (let i = 0; i < selInfo.contributions.length; i++) {
+ coinRes.push({
+ denomPubHash: coins[i].denomPubHash,
+ coinPub: coins[i].coinPub,
+ contribution: Amounts.stringify(selInfo.contributions[i]),
+ exchangeBaseUrl: coins[i].exchangeBaseUrl,
+ });
+ }
+ }
+
+ return {
+ coins: coinRes,
+ customerDepositFees: Amounts.stringify(tally.customerDepositFees),
+ customerWireFees: Amounts.stringify(tally.customerWireFees),
+ };
+}
+
+interface ReportInsufficientBalanceRequest {
+ instructedAmount: AmountJson;
+ requiredMinimumAge: number | undefined;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ wireMethod: string | undefined;
+ depositPaytoUri: string | undefined;
+}
+
+export async function reportInsufficientBalanceDetails(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "exchanges",
+ "exchangeDetails",
+ "refreshGroups",
+ "denominations",
+ ]
+ >,
+ req: ReportInsufficientBalanceRequest,
+): Promise<PaymentInsufficientBalanceDetails> {
+ const details = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: req.restrictExchanges,
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ const perExchange: PaymentInsufficientBalanceDetails["perExchange"] = {};
+ const exchanges = await tx.exchanges.getAll();
+
+ for (const exch of exchanges) {
+ if (!exch.detailsPointer) {
+ continue;
+ }
+ let missingGlobalFees = false;
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ missingGlobalFees = true;
+ } else {
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ missingGlobalFees = true;
+ }
+ }
+ const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: {
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.baseUrl,
+ exchangePub: exch.detailsPointer?.masterPublicKey,
+ },
+ ],
+ auditors: [],
+ },
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : [],
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ perExchange[exch.baseUrl] = {
+ balanceAvailable: Amounts.stringify(exchDet.balanceAvailable),
+ balanceMaterial: Amounts.stringify(exchDet.balanceMaterial),
+ balanceExchangeDepositable: Amounts.stringify(
+ exchDet.balanceExchangeDepositable,
+ ),
+ balanceAgeAcceptable: Amounts.stringify(exchDet.balanceAgeAcceptable),
+ balanceReceiverAcceptable: Amounts.stringify(
+ exchDet.balanceReceiverAcceptable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ exchDet.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(
+ exchDet.maxEffectiveSpendAmount,
+ ),
+ missingGlobalFees,
+ };
+ }
+
+ return {
+ amountRequested: Amounts.stringify(req.instructedAmount),
+ balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
+ balanceAvailable: Amounts.stringify(details.balanceAvailable),
+ balanceMaterial: Amounts.stringify(details.balanceMaterial),
+ balanceReceiverAcceptable: Amounts.stringify(
+ details.balanceReceiverAcceptable,
+ ),
+ balanceExchangeDepositable: Amounts.stringify(
+ details.balanceExchangeDepositable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ details.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount),
+ perExchange,
+ };
+}
+
+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[];
+ };
+}
+
+export function testing_selectGreedy(
+ ...args: Parameters<typeof selectGreedy>
+): ReturnType<typeof selectGreedy> {
+ return selectGreedy(...args);
+}
+
+export interface SelectGreedyRequest {
+ wireFeesPerExchange: Record<string, AmountJson>;
+}
+
+function selectGreedy(
+ req: SelectGreedyRequest,
+ candidateDenoms: AvailableDenom[],
+ tally: CoinSelectionTally,
+): SelResult | undefined {
+ 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++
+ ) {
+ tallyFees(
+ tally,
+ req.wireFeesPerExchange,
+ 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 function checkAccountRestriction(
+ paytoUri: string,
+ restrictions: AccountRestriction[],
+): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } {
+ for (const myRestriction of restrictions) {
+ switch (myRestriction.type) {
+ case "deny":
+ return { ok: false };
+ case "regex":
+ const regex = new RegExp(myRestriction.payto_regex);
+ if (!regex.test(paytoUri)) {
+ return {
+ ok: false,
+ hint: myRestriction.human_hint,
+ hintI18n: myRestriction.human_hint_i18n,
+ };
+ }
+ }
+ }
+ return {
+ ok: true,
+ };
+}
+
+export interface SelectPayCoinRequestNg {
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethod: string;
+ contractTermsAmount: AmountJson;
+ depositFeeLimit: AmountJson;
+ prevPayCoins?: PreviousPayCoins;
+ requiredMinimumAge?: number;
+ forcedSelection?: ForcedCoinSel;
+
+ /**
+ * Deposit payto URI, in case we already know the account that
+ * will be deposited into.
+ *
+ * That is typically the case when the wallet does a deposit to
+ * return funds to the user's own bank account.
+ */
+ depositPaytoUri?: string;
+}
+
+export type AvailableDenom = DenominationInfo & {
+ maxAge: number;
+ numAvailable: number;
+};
+
+export function findMatchingWire(
+ wireMethod: string,
+ depositPaytoUri: string | undefined,
+ exchangeWireDetails: ExchangeWireDetails,
+): { wireFee: AmountJson } | undefined {
+ for (const acc of exchangeWireDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ if (pp.targetType !== wireMethod) {
+ continue;
+ }
+ const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[
+ wireMethod
+ ]?.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+
+ if (!wireFeeStr) {
+ continue;
+ }
+
+ let debitAccountCheckOk = false;
+ if (depositPaytoUri) {
+ // FIXME: We should somehow propagate the hint here!
+ const checkResult = checkAccountRestriction(
+ depositPaytoUri,
+ acc.debit_restrictions,
+ );
+ if (checkResult.ok) {
+ debitAccountCheckOk = true;
+ }
+ } else {
+ debitAccountCheckOk = true;
+ }
+
+ if (!debitAccountCheckOk) {
+ continue;
+ }
+
+ return {
+ wireFee: Amounts.parseOrThrow(wireFeeStr),
+ };
+ }
+ return undefined;
+}
+
+function checkExchangeAccepted(
+ exchangeDetails: ExchangeWireDetails,
+ exchangeRestrictions: ExchangeRestrictionSpec | undefined,
+): boolean {
+ if (!exchangeRestrictions) {
+ return true;
+ }
+ let accepted = false;
+ for (const allowedExchange of exchangeRestrictions.exchanges) {
+ if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ accepted = true;
+ break;
+ }
+ }
+ for (const allowedAuditor of exchangeRestrictions.auditors) {
+ for (const providedAuditor of exchangeDetails.auditors) {
+ if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
+ accepted = true;
+ break;
+ }
+ }
+ }
+ return accepted;
+}
+
+interface SelectPayCandidatesRequest {
+ instructedAmount: AmountJson;
+ restrictWireMethod: string | undefined;
+ depositPaytoUri?: string;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ requiredMinimumAge?: number;
+
+ /**
+ * If set to true, the coin selection will also use coins that are not
+ * materially available yet, but that are expected to become available
+ * as the output of a refresh operation.
+ */
+ includePendingCoins: boolean;
+}
+
+async function selectPayCandidates(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
+ >,
+ req: SelectPayCandidatesRequest,
+): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+ // FIXME: Use the existing helper (from balance.ts) to
+ // get acceptable exchanges.
+ logger.shouldLogTrace() &&
+ logger.trace(`selecting available coin candidates for ${j2s(req)}`);
+ const denoms: AvailableDenom[] = [];
+ const exchanges = await tx.exchanges.iter().toArray();
+ const wfPerExchange: Record<string, AmountJson> = {};
+ for (const exchange of exchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchange.baseUrl,
+ );
+ // 1. exchange has same currency
+ if (exchangeDetails?.currency !== req.instructedAmount.currency) {
+ logger.shouldLogTrace() &&
+ logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`);
+ continue;
+ }
+
+ // 2. Exchange supports wire method (only for pay/deposit)
+ if (req.restrictWireMethod) {
+ const wire = findMatchingWire(
+ req.restrictWireMethod,
+ req.depositPaytoUri,
+ exchangeDetails,
+ );
+ if (!wire) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `skipping ${exchange.baseUrl} due to missing wire info mismatch`,
+ );
+ }
+ continue;
+ }
+ wfPerExchange[exchange.baseUrl] = wire.wireFee;
+ }
+
+ // 3. exchange is trusted in the exchange list or auditor list
+ let accepted = checkExchangeAccepted(
+ exchangeDetails,
+ req.restrictExchanges,
+ );
+ if (!accepted) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`skipping ${exchange.baseUrl} due to unacceptability`);
+ }
+ 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],
+ ),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${myExchangeCoins.length} candidate records`,
+ );
+ }
+
+ let numUsable = 0;
+
+ // 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) {
+ logger.trace("denom is revoked");
+ continue;
+ }
+ if (!denom.isOffered) {
+ logger.trace("denom is unoffered");
+ continue;
+ }
+ numUsable++;
+ let numAvailable = coinAvail.freshCoinCount ?? 0;
+ if (req.includePendingCoins) {
+ numAvailable += coinAvail.pendingRefreshOutputCount ?? 0;
+ }
+ denoms.push({
+ ...DenominationRecord.toDenomInfo(denom),
+ numAvailable,
+ maxAge: coinAvail.maxAge,
+ });
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${numUsable} candidate records with usable denominations`,
+ );
+ }
+ }
+ // 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];
+}
+
+export interface PeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ /**
+ * Info of Coins that were selected.
+ */
+ coins: SelectedCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export interface ProspectivePeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export type SelectPeerCoinsResult =
+ | { type: "success"; result: PeerCoinSelectionDetails }
+ // Successful, but using coins that are not materially available yet.
+ | { type: "prospective"; result: ProspectivePeerCoinSelectionDetails }
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ };
+
+export interface PeerCoinSelectionRequest {
+ instructedAmount: AmountJson;
+
+ /**
+ * Instruct the coin selection to repair this coin
+ * selection instead of selecting completely new coins.
+ */
+ repair?: PreviousPayCoins;
+}
+
+export async function computeCoinSelMaxExpirationDate(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ selectedDenom: SelResult,
+): Promise<TalerProtocolTimestamp> {
+ let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never();
+ for (const dph of Object.keys(selectedDenom)) {
+ const selInfo = selectedDenom[dph];
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ // Compute earliest time that a selected denom
+ // would have its coins auto-refreshed.
+ minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min(
+ minAutorefreshExecuteThreshold,
+ AbsoluteTime.toProtocolTimestamp(
+ getAutoRefreshExecuteThreshold({
+ stampExpireDeposit: denom.stampExpireDeposit,
+ stampExpireWithdraw: denom.stampExpireWithdraw,
+ }),
+ ),
+ );
+ }
+ return minAutorefreshExecuteThreshold;
+}
+
+export function emptyTallyForPeerPayment(
+ instructedAmount: AmountJson,
+): CoinSelectionTally {
+ const currency = instructedAmount.currency;
+ const zero = Amounts.zeroOfCurrency(currency);
+ return {
+ amountPayRemaining: instructedAmount,
+ customerDepositFees: zero,
+ lastDepositFee: zero,
+ amountDepositFeeLimitRemaining: zero,
+ customerWireFees: zero,
+ wireFeeCoveredForExchange: new Set(),
+ };
+}
+
+function getGlobalFees(
+ wireDetails: ExchangeWireDetails,
+): ExchangeGlobalFees | undefined {
+ const now = AbsoluteTime.now();
+ for (let gf of wireDetails.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.startDate),
+ AbsoluteTime.fromProtocolTimestamp(gf.endDate),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return gf;
+ }
+ return undefined;
+}
+
+async function internalSelectPeerCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ]
+ >,
+ req: PeerCoinSelectionRequest,
+ exch: ExchangeWireDetails,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; tally: CoinSelectionTally; resCoins: SelectedCoin[] }
+ | undefined
+> {
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ instructedAmount: req.instructedAmount,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ exchangePub: exch.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ includePendingCoins,
+ });
+ const candidates = candidatesRes[0];
+ if (logger.shouldLogTrace()) {
+ logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
+ }
+ const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const resCoins: SelectedCoin[] = [];
+
+ await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
+ wireFeesPerExchange: {},
+ });
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`candidates: ${j2s(candidates)}`);
+ logger.trace(`instructedAmount: ${j2s(req.instructedAmount)}`);
+ logger.trace(`tally: ${j2s(tally)}`);
+ }
+
+ const selRes = selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
+ if (!selRes) {
+ return undefined;
+ }
+
+ return {
+ sel: selRes,
+ tally,
+ resCoins,
+ };
+}
+
+export async function selectPeerCoins(
+ wex: WalletExecutionContext,
+ req: PeerCoinSelectionRequest,
+): Promise<SelectPeerCoinsResult> {
+ const instructedAmount = req.instructedAmount;
+ if (Amounts.isZero(instructedAmount)) {
+ // Other parts of the code assume that we have at least
+ // one coin to spend.
+ throw new Error("amount of zero not allowed");
+ }
+
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ],
+ },
+ async (tx): Promise<SelectPeerCoinsResult> => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ const currency = Amounts.currencyOf(instructedAmount);
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ continue;
+ }
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ continue;
+ }
+
+ const avRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ false,
+ );
+
+ if (!avRes) {
+ // Try to see if we can do a prospective selection
+ const prospectiveAvRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ true,
+ );
+ if (prospectiveAvRes) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvRes.sel)) {
+ const mySel = prospectiveAvRes.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ prospectiveAvRes.sel,
+ );
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ depositFees: prospectiveAvRes.tally.customerDepositFees,
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ } else if (avRes) {
+ const r = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ avRes.sel,
+ avRes.resCoins,
+ avRes.tally,
+ );
+
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ avRes.sel,
+ );
+
+ return {
+ type: "success",
+ result: {
+ coins: r.coins,
+ depositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ }
+ const insufficientBalanceDetails = await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: undefined,
+ instructedAmount: req.instructedAmount,
+ requiredMinimumAge: undefined,
+ wireMethod: undefined,
+ depositPaytoUri: undefined,
+ },
+ );
+ return {
+ type: "failure",
+ insufficientBalanceDetails,
+ };
+ },
+ );
+}