/*
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
*/
/**
* Selection of coins for payments.
*
* @author Florian Dold
*/
/**
* Imports.
*/
import { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
AgeRestriction,
AllowedAuditorInfo,
AllowedExchangeInfo,
AmountJson,
Amounts,
checkAccountRestriction,
checkDbInvariant,
checkLogicInvariant,
CoinStatus,
DenominationInfo,
ExchangeGlobalFees,
ForcedCoinSel,
GetMaxDepositAmountRequest,
GetMaxDepositAmountResponse,
GetMaxPeerPushDebitAmountRequest,
GetMaxPeerPushDebitAmountResponse,
j2s,
Logger,
parsePaytoUri,
PayCoinSelection,
PaymentInsufficientBalanceDetails,
ProspectivePayCoinSelection,
ScopeInfo,
ScopeType,
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 {
checkExchangeInScope,
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;
totalDepositFees: AmountJson;
customerWireFees: AmountJson;
wireFeeCoveredForExchange: Set;
lastDepositFee: AmountJson;
}
/**
* Account for the fees of spending a coin.
*/
function tallyFees(
tally: CoinSelectionTally,
wireFeesPerExchange: Record,
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;
tally.totalDepositFees = Amounts.add(
tally.totalDepositFees,
feeDeposit,
).amount;
}
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 candidateRes = await selectPayCandidates(wex, tx, {
currency: Amounts.currencyOf(req.contractTermsAmount),
restrictExchanges: req.restrictExchanges,
restrictWireMethod: req.restrictWireMethod,
depositPaytoUri: req.depositPaytoUri,
requiredMinimumAge: req.requiredMinimumAge,
includePendingCoins,
});
const wireFeesPerExchange = candidateRes.currentWireFeePerExchange;
const candidateDenoms = candidateRes.coinAvailability;
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),
totalDepositFees: 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,
};
}
export async function selectPayCoinsInTx(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<
[
"coinAvailability",
"denominations",
"refreshGroups",
"exchanges",
"exchangeDetails",
"coins",
]
>,
req: SelectPayCoinRequestNg,
): Promise {
if (logger.shouldLogTrace()) {
logger.trace(`selecting coins for ${j2s(req)}`);
}
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,
};
}
/**
* 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 {
return await wex.db.runReadOnlyTx(
{
storeNames: [
"coinAvailability",
"denominations",
"refreshGroups",
"exchanges",
"exchangeDetails",
"coins",
],
},
async (tx) => {
return selectPayCoinsInTx(wex, tx, req);
},
);
}
async function maybeRepairCoinSelection(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
prevPayCoins: PreviousPayCoins,
coinRes: SelectedCoin[],
tally: CoinSelectionTally,
feeInfo: {
wireFeesPerExchange: Record;
},
): Promise {
// 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 {
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),
totalDepositFees: Amounts.stringify(tally.totalDepositFees),
};
}
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 {
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) {
// No wire details about the exchange known, skip!
continue;
}
const globalFees = getGlobalFees(exchWire);
if (!globalFees) {
missingGlobalFees = true;
}
if (exchWire.currency !== Amounts.currencyOf(req.instructedAmount)) {
// Do not report anything for an exchange with a different currency.
continue;
}
const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
restrictExchanges: {
exchanges: [
{
exchangeBaseUrl: exch.baseUrl,
exchangePub: exch.detailsPointer?.masterPublicKey,
},
],
auditors: [],
},
restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
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.maxMerchantEffectiveDepositAmount,
),
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.maxMerchantEffectiveDepositAmount,
),
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
): ReturnType {
return selectGreedy(...args);
}
export interface SelectGreedyRequest {
wireFeesPerExchange: Record;
}
function selectGreedy(
req: SelectGreedyRequest,
candidateDenoms: AvailableCoinsOfDenom[],
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: AvailableCoinsOfDenom[],
): 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 {
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 AvailableCoinsOfDenom = 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 {
currency: string;
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;
}
export interface PayCoinCandidates {
coinAvailability: AvailableCoinsOfDenom[];
currentWireFeePerExchange: Record;
}
async function selectPayCandidates(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<
["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
>,
req: SelectPayCandidatesRequest,
): Promise {
// 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: AvailableCoinsOfDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray();
const wfPerExchange: Record = {};
for (const exchange of exchanges) {
const exchangeDetails = await getExchangeWireDetailsInTx(
tx,
exchange.baseUrl,
);
// 1. exchange has same currency
if (exchangeDetails?.currency !== req.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,
`denomination of a coin is missing hash: ${coinAvail.denomPubHash}`,
);
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 {
coinAvailability: denoms,
currentWireFeePerExchange: wfPerExchange,
};
}
export interface PeerCoinSelectionDetails {
exchangeBaseUrl: string;
/**
* Info of Coins that were selected.
*/
coins: SelectedCoin[];
/**
* How much of the deposit fees is the customer paying?
*/
customerDepositFees: AmountJson;
totalDepositFees: AmountJson;
maxExpirationDate: TalerProtocolTimestamp;
}
export interface ProspectivePeerCoinSelectionDetails {
exchangeBaseUrl: string;
prospectiveCoins: SelectedProspectiveCoin[];
/**
* How much of the deposit fees is the customer paying?
*/
customerDepositFees: AmountJson;
totalDepositFees: 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;
/**
* Are deposit fees covered by the counterparty?
*
* Defaults to false.
*/
feesCoveredByCounterparty?: boolean;
/**
* Restrict the scope of funds that can be spent via the given
* scope info.
*/
restrictScope?: ScopeInfo;
/**
* 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 {
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(
req: PeerCoinSelectionRequest,
): CoinSelectionTally {
const instructedAmount = req.instructedAmount;
const currency = instructedAmount.currency;
const zero = Amounts.zeroOfCurrency(currency);
return {
amountPayRemaining: instructedAmount,
customerDepositFees: zero,
lastDepositFee: zero,
amountDepositFeeLimitRemaining: req.feesCoveredByCounterparty
? instructedAmount
: zero,
customerWireFees: zero,
wireFeeCoveredForExchange: new Set(),
totalDepositFees: zero,
};
}
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, {
currency: Amounts.currencyOf(req.instructedAmount),
restrictExchanges: {
auditors: [],
exchanges: [
{
exchangeBaseUrl: exch.exchangeBaseUrl,
exchangePub: exch.masterPublicKey,
},
],
},
restrictWireMethod: undefined,
includePendingCoins,
});
const candidates = candidatesRes.coinAvailability;
if (logger.shouldLogTrace()) {
logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
}
const tally = emptyTallyForPeerPayment(req);
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 selectPeerCoinsInTx(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<
[
"exchanges",
"contractTerms",
"coins",
"coinAvailability",
"denominations",
"refreshGroups",
"exchangeDetails",
]
>,
req: PeerCoinSelectionRequest,
): Promise {
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("peer-to-peer payment with amount of zero not supported");
}
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 isInScope = req.restrictScope
? await checkExchangeInScope(wex, exch.baseUrl, req.restrictScope)
: true;
if (!isInScope) {
continue;
}
if (
req.restrictScope &&
req.restrictScope.type === ScopeType.Exchange &&
req.restrictScope.url !== exch.baseUrl
) {
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,
customerDepositFees: prospectiveAvRes.tally.customerDepositFees,
totalDepositFees: prospectiveAvRes.tally.totalDepositFees,
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,
customerDepositFees: Amounts.parseOrThrow(r.customerDepositFees),
totalDepositFees: Amounts.parseOrThrow(r.totalDepositFees),
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,
};
}
export async function selectPeerCoins(
wex: WalletExecutionContext,
req: PeerCoinSelectionRequest,
): Promise {
return await wex.db.runReadWriteTx(
{
storeNames: [
"exchanges",
"contractTerms",
"coins",
"coinAvailability",
"denominations",
"refreshGroups",
"exchangeDetails",
],
},
async (tx): Promise => {
return selectPeerCoinsInTx(wex, tx, req);
},
);
}
function getMaxDepositAmountForAvailableCoins(
req: GetMaxDepositAmountRequest,
candidateRes: PayCoinCandidates,
): GetMaxDepositAmountResponse {
const wireFeeCoveredForExchange = new Set();
let amountEffective = Amounts.zeroOfCurrency(req.currency);
let fees = Amounts.zeroOfCurrency(req.currency);
for (const cc of candidateRes.coinAvailability) {
if (!wireFeeCoveredForExchange.has(cc.exchangeBaseUrl)) {
const wireFee =
candidateRes.currentWireFeePerExchange[cc.exchangeBaseUrl];
// Wire fee can be null if max deposit amount is computed
// without restricting the wire method.
if (wireFee != null) {
fees = Amounts.add(fees, wireFee).amount;
}
wireFeeCoveredForExchange.add(cc.exchangeBaseUrl);
}
amountEffective = Amounts.add(
amountEffective,
Amounts.mult(cc.value, cc.numAvailable).amount,
).amount;
fees = Amounts.add(
fees,
Amounts.mult(cc.feeDeposit, cc.numAvailable).amount,
).amount;
}
return {
effectiveAmount: Amounts.stringify(amountEffective),
rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount),
};
}
/**
* Only used for unit testing getMaxDepositAmountForAvailableCoins.
*/
export const testing_getMaxDepositAmountForAvailableCoins =
getMaxDepositAmountForAvailableCoins;
export async function getMaxDepositAmount(
wex: WalletExecutionContext,
req: GetMaxDepositAmountRequest,
): Promise {
logger.trace(`getting max deposit amount for: ${j2s(req)}`);
return await wex.db.runReadOnlyTx(
{
storeNames: [
"exchanges",
"coinAvailability",
"denominations",
"exchangeDetails",
],
},
async (tx): Promise => {
let restrictWireMethod: string | undefined = undefined;
if (req.depositPaytoUri) {
const p = parsePaytoUri(req.depositPaytoUri);
if (!p) {
throw Error("invalid payto URI");
}
restrictWireMethod = p.targetType;
}
const candidateRes = await selectPayCandidates(wex, tx, {
currency: req.currency,
restrictExchanges: undefined,
restrictWireMethod,
depositPaytoUri: req.depositPaytoUri,
requiredMinimumAge: undefined,
includePendingCoins: true,
});
return getMaxDepositAmountForAvailableCoins(req, candidateRes);
},
);
}
function getMaxPeerPushDebitAmountForAvailableCoins(
req: GetMaxDepositAmountRequest,
exchangeBaseUrl: string,
candidateRes: PayCoinCandidates,
): GetMaxPeerPushDebitAmountResponse {
let amountEffective = Amounts.zeroOfCurrency(req.currency);
let fees = Amounts.zeroOfCurrency(req.currency);
for (const cc of candidateRes.coinAvailability) {
amountEffective = Amounts.add(
amountEffective,
Amounts.mult(cc.value, cc.numAvailable).amount,
).amount;
fees = Amounts.add(
fees,
Amounts.mult(cc.feeDeposit, cc.numAvailable).amount,
).amount;
}
return {
exchangeBaseUrl,
effectiveAmount: Amounts.stringify(amountEffective),
rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount),
};
}
export async function getMaxPeerPushDebitAmount(
wex: WalletExecutionContext,
req: GetMaxPeerPushDebitAmountRequest,
): Promise {
logger.trace(`getting max deposit amount for: ${j2s(req)}`);
return await wex.db.runReadOnlyTx(
{
storeNames: [
"exchanges",
"coinAvailability",
"denominations",
"exchangeDetails",
],
},
async (tx): Promise => {
let result: GetMaxDepositAmountResponse | undefined = undefined;
const currency = req.currency;
const exchanges = await tx.exchanges.iter().toArray();
for (const exch of exchanges) {
if (exch.detailsPointer?.currency !== currency) {
continue;
}
const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
if (!exchWire) {
continue;
}
const isInScope = req.restrictScope
? await checkExchangeInScope(wex, exch.baseUrl, req.restrictScope)
: true;
if (!isInScope) {
continue;
}
if (
req.restrictScope &&
req.restrictScope.type === ScopeType.Exchange &&
req.restrictScope.url !== exch.baseUrl
) {
continue;
}
const globalFees = getGlobalFees(exchWire);
if (!globalFees) {
continue;
}
const candidatesRes = await selectPayCandidates(wex, tx, {
currency,
restrictExchanges: {
auditors: [],
exchanges: [
{
exchangeBaseUrl: exchWire.exchangeBaseUrl,
exchangePub: exchWire.masterPublicKey,
},
],
},
restrictWireMethod: undefined,
includePendingCoins: true,
});
const myExchangeRes = getMaxPeerPushDebitAmountForAvailableCoins(
req,
exchWire.exchangeBaseUrl,
candidatesRes,
);
if (!result) {
result = myExchangeRes;
} else if (Amounts.cmp(result.rawAmount, myExchangeRes.rawAmount) < 0) {
result = myExchangeRes;
}
}
if (!result) {
return {
effectiveAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
rawAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
};
}
return result;
},
);
}