aboutsummaryrefslogtreecommitdiff
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.ts370
1 files changed, 306 insertions, 64 deletions
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
index 51316a21f..bc9d51ec7 100644
--- a/packages/taler-wallet-core/src/coinSelection.ts
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -26,25 +26,30 @@
import { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
- AccountRestriction,
AgeRestriction,
AllowedAuditorInfo,
AllowedExchangeInfo,
AmountJson,
Amounts,
+ checkAccountRestriction,
checkDbInvariant,
checkLogicInvariant,
CoinStatus,
DenominationInfo,
ExchangeGlobalFees,
ForcedCoinSel,
- InternationalizedString,
+ GetMaxDepositAmountRequest,
+ GetMaxDepositAmountResponse,
+ GetMaxPeerPushDebitAmountRequest,
+ GetMaxPeerPushDebitAmountResponse,
j2s,
Logger,
parsePaytoUri,
PayCoinSelection,
PaymentInsufficientBalanceDetails,
ProspectivePayCoinSelection,
+ ScopeInfo,
+ ScopeType,
SelectedCoin,
SelectedProspectiveCoin,
strcmp,
@@ -54,6 +59,7 @@ import { getPaymentBalanceDetailsInTx } from "./balance.js";
import { getAutoRefreshExecuteThreshold } from "./common.js";
import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js";
import {
+ checkExchangeInScope,
ExchangeWireDetails,
getExchangeWireDetailsInTx,
} from "./exchanges.js";
@@ -86,6 +92,8 @@ export interface CoinSelectionTally {
customerDepositFees: AmountJson;
+ totalDepositFees: AmountJson;
+
customerWireFees: AmountJson;
wireFeeCoveredForExchange: Set<string>;
@@ -152,6 +160,10 @@ function tallyFees(
dfRemaining,
).amount;
tally.lastDepositFee = feeDeposit;
+ tally.totalDepositFees = Amounts.add(
+ tally.totalDepositFees,
+ feeDeposit,
+ ).amount;
}
export type SelectPayCoinsResult =
@@ -180,19 +192,32 @@ async function internalSelectPayCoins(
| { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
| undefined
> {
+ let restrictWireMethod;
+ if (req.depositPaytoUri) {
+ const parsedPayto = parsePaytoUri(req.depositPaytoUri);
+ if (!parsedPayto) {
+ throw Error("invalid payto URI");
+ }
+ restrictWireMethod = parsedPayto.targetType;
+ if (restrictWireMethod !== req.restrictWireMethod) {
+ logger.warn(`conflicting payto URI and wire method restriction`);
+ }
+ } else {
+ restrictWireMethod = req.restrictWireMethod;
+ }
+
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,
- },
- );
+ 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(
@@ -210,6 +235,7 @@ async function internalSelectPayCoins(
amountDepositFeeLimitRemaining: depositFeeLimit,
customerDepositFees: Amounts.zeroOfCurrency(currency),
customerWireFees: Amounts.zeroOfCurrency(currency),
+ totalDepositFees: Amounts.zeroOfCurrency(currency),
wireFeeCoveredForExchange: new Set(),
lastDepositFee: Amounts.zeroOfCurrency(currency),
};
@@ -452,6 +478,7 @@ async function assembleSelectPayCoinsSuccessResult(
coins: coinRes,
customerDepositFees: Amounts.stringify(tally.customerDepositFees),
customerWireFees: Amounts.stringify(tally.customerWireFees),
+ totalDepositFees: Amounts.stringify(tally.totalDepositFees),
};
}
@@ -493,12 +520,16 @@ export async function reportInsufficientBalanceDetails(
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;
- } else {
- 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: {
@@ -510,7 +541,7 @@ export async function reportInsufficientBalanceDetails(
],
auditors: [],
},
- restrictWireMethods: req.wireMethod ? [req.wireMethod] : [],
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
currency: Amounts.currencyOf(req.instructedAmount),
minAge: req.requiredMinimumAge ?? 0,
depositPaytoUri: req.depositPaytoUri,
@@ -529,7 +560,7 @@ export async function reportInsufficientBalanceDetails(
exchDet.balanceReceiverDepositable,
),
maxEffectiveSpendAmount: Amounts.stringify(
- exchDet.maxEffectiveSpendAmount,
+ exchDet.maxMerchantEffectiveDepositAmount,
),
missingGlobalFees,
};
@@ -549,7 +580,9 @@ export async function reportInsufficientBalanceDetails(
balanceReceiverDepositable: Amounts.stringify(
details.balanceReceiverDepositable,
),
- maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount),
+ maxEffectiveSpendAmount: Amounts.stringify(
+ details.maxMerchantEffectiveDepositAmount,
+ ),
perExchange,
};
}
@@ -590,7 +623,7 @@ export interface SelectGreedyRequest {
function selectGreedy(
req: SelectGreedyRequest,
- candidateDenoms: AvailableDenom[],
+ candidateDenoms: AvailableCoinsOfDenom[],
tally: CoinSelectionTally,
): SelResult | undefined {
const selectedDenom: SelResult = {};
@@ -653,7 +686,7 @@ function selectGreedy(
function selectForced(
req: SelectPayCoinRequestNg,
- candidateDenoms: AvailableDenom[],
+ candidateDenoms: AvailableCoinsOfDenom[],
): SelResult | undefined {
const selectedDenom: SelResult = {};
@@ -695,31 +728,6 @@ function selectForced(
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;
@@ -739,7 +747,7 @@ export interface SelectPayCoinRequestNg {
depositPaytoUri?: string;
}
-export type AvailableDenom = DenominationInfo & {
+export type AvailableCoinsOfDenom = DenominationInfo & {
maxAge: number;
numAvailable: number;
};
@@ -820,7 +828,7 @@ function checkExchangeAccepted(
}
interface SelectPayCandidatesRequest {
- instructedAmount: AmountJson;
+ currency: string;
restrictWireMethod: string | undefined;
depositPaytoUri?: string;
restrictExchanges: ExchangeRestrictionSpec | undefined;
@@ -834,18 +842,23 @@ interface SelectPayCandidatesRequest {
includePendingCoins: boolean;
}
+export interface PayCoinCandidates {
+ coinAvailability: AvailableCoinsOfDenom[];
+ currentWireFeePerExchange: Record<string, AmountJson>;
+}
+
async function selectPayCandidates(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<
["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
>,
req: SelectPayCandidatesRequest,
-): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+): Promise<PayCoinCandidates> {
// 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 denoms: AvailableCoinsOfDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray();
const wfPerExchange: Record<string, AmountJson> = {};
for (const exchange of exchanges) {
@@ -854,7 +867,7 @@ async function selectPayCandidates(
exchange.baseUrl,
);
// 1. exchange has same currency
- if (exchangeDetails?.currency !== req.instructedAmount.currency) {
+ if (exchangeDetails?.currency !== req.currency) {
logger.shouldLogTrace() &&
logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`);
continue;
@@ -961,7 +974,10 @@ async function selectPayCandidates(
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
strcmp(o1.denomPubHash, o2.denomPubHash),
);
- return [denoms, wfPerExchange];
+ return {
+ coinAvailability: denoms,
+ currentWireFeePerExchange: wfPerExchange,
+ };
}
export interface PeerCoinSelectionDetails {
@@ -975,7 +991,9 @@ export interface PeerCoinSelectionDetails {
/**
* How much of the deposit fees is the customer paying?
*/
- depositFees: AmountJson;
+ customerDepositFees: AmountJson;
+
+ totalDepositFees: AmountJson;
maxExpirationDate: TalerProtocolTimestamp;
}
@@ -988,7 +1006,9 @@ export interface ProspectivePeerCoinSelectionDetails {
/**
* How much of the deposit fees is the customer paying?
*/
- depositFees: AmountJson;
+ customerDepositFees: AmountJson;
+
+ totalDepositFees: AmountJson;
maxExpirationDate: TalerProtocolTimestamp;
}
@@ -1006,6 +1026,19 @@ 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.
*/
@@ -1045,17 +1078,21 @@ export async function computeCoinSelMaxExpirationDate(
}
export function emptyTallyForPeerPayment(
- instructedAmount: AmountJson,
+ req: PeerCoinSelectionRequest,
): CoinSelectionTally {
+ const instructedAmount = req.instructedAmount;
const currency = instructedAmount.currency;
const zero = Amounts.zeroOfCurrency(currency);
return {
amountPayRemaining: instructedAmount,
customerDepositFees: zero,
lastDepositFee: zero,
- amountDepositFeeLimitRemaining: zero,
+ amountDepositFeeLimitRemaining: req.feesCoveredByCounterparty
+ ? instructedAmount
+ : zero,
customerWireFees: zero,
wireFeeCoveredForExchange: new Set(),
+ totalDepositFees: zero,
};
}
@@ -1098,7 +1135,7 @@ async function internalSelectPeerCoins(
| undefined
> {
const candidatesRes = await selectPayCandidates(wex, tx, {
- instructedAmount: req.instructedAmount,
+ currency: Amounts.currencyOf(req.instructedAmount),
restrictExchanges: {
auditors: [],
exchanges: [
@@ -1111,11 +1148,11 @@ async function internalSelectPeerCoins(
restrictWireMethod: undefined,
includePendingCoins,
});
- const candidates = candidatesRes[0];
+ const candidates = candidatesRes.coinAvailability;
if (logger.shouldLogTrace()) {
logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
}
- const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const tally = emptyTallyForPeerPayment(req);
const resCoins: SelectedCoin[] = [];
await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
@@ -1178,6 +1215,19 @@ export async function selectPeerCoinsInTx(
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;
@@ -1215,7 +1265,8 @@ export async function selectPeerCoinsInTx(
type: "prospective",
result: {
prospectiveCoins,
- depositFees: prospectiveAvRes.tally.customerDepositFees,
+ customerDepositFees: prospectiveAvRes.tally.customerDepositFees,
+ totalDepositFees: prospectiveAvRes.tally.totalDepositFees,
exchangeBaseUrl: exch.baseUrl,
maxExpirationDate,
},
@@ -1239,7 +1290,8 @@ export async function selectPeerCoinsInTx(
type: "success",
result: {
coins: r.coins,
- depositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ customerDepositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ totalDepositFees: Amounts.parseOrThrow(r.totalDepositFees),
exchangeBaseUrl: exch.baseUrl,
maxExpirationDate,
},
@@ -1284,3 +1336,193 @@ export async function selectPeerCoins(
},
);
}
+
+function getMaxDepositAmountForAvailableCoins(
+ req: GetMaxDepositAmountRequest,
+ candidateRes: PayCoinCandidates,
+): GetMaxDepositAmountResponse {
+ const wireFeeCoveredForExchange = new Set<string>();
+
+ 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<GetMaxDepositAmountResponse> {
+ logger.trace(`getting max deposit amount for: ${j2s(req)}`);
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coinAvailability",
+ "denominations",
+ "exchangeDetails",
+ ],
+ },
+ async (tx): Promise<GetMaxDepositAmountResponse> => {
+ 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<GetMaxPeerPushDebitAmountResponse> {
+ logger.trace(`getting max deposit amount for: ${j2s(req)}`);
+
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coinAvailability",
+ "denominations",
+ "exchangeDetails",
+ ],
+ },
+ async (tx): Promise<GetMaxPeerPushDebitAmountResponse> => {
+ 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;
+ },
+ );
+}