improved pay coin selection
support for multiple exchanges and healing a previous selection
This commit is contained in:
parent
fb3da3a28d
commit
44b1896b9e
@ -27,7 +27,6 @@ import {
|
||||
ExchangeUpdateStatus,
|
||||
ExchangeWireInfo,
|
||||
getTimestampNow,
|
||||
PayCoinSelection,
|
||||
ProposalDownload,
|
||||
ProposalStatus,
|
||||
RefreshReason,
|
||||
@ -49,6 +48,7 @@ import {
|
||||
BackupRefundState,
|
||||
WalletBackupContentV1,
|
||||
} from "../../types/backupTypes";
|
||||
import { PayCoinSelection } from "../../util/coinSelection";
|
||||
import { j2s } from "../../util/helpers";
|
||||
import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants";
|
||||
import { Logger } from "../../util/logging";
|
||||
|
@ -37,6 +37,7 @@ import {
|
||||
codecForString,
|
||||
codecOptional,
|
||||
} from "../util/codec";
|
||||
import { selectPayCoins } from "../util/coinSelection";
|
||||
import { canonicalJson } from "../util/helpers";
|
||||
import { readSuccessResponseJsonOrThrow } from "../util/http";
|
||||
import { parsePaytoUri } from "../util/payto";
|
||||
@ -54,7 +55,7 @@ import {
|
||||
applyCoinSpend,
|
||||
extractContractData,
|
||||
generateDepositPermissions,
|
||||
getCoinsForPayment,
|
||||
getCandidatePayCoins,
|
||||
getEffectiveDepositAmount,
|
||||
getTotalPaymentCost,
|
||||
} from "./pay";
|
||||
@ -363,7 +364,26 @@ export async function createDepositGroup(
|
||||
"",
|
||||
);
|
||||
|
||||
const payCoinSel = await getCoinsForPayment(ws, contractData);
|
||||
const candidates = await getCandidatePayCoins(ws, {
|
||||
allowedAuditors: contractData.allowedAuditors,
|
||||
allowedExchanges: contractData.allowedExchanges,
|
||||
amount: contractData.amount,
|
||||
maxDepositFee: contractData.maxDepositFee,
|
||||
maxWireFee: contractData.maxWireFee,
|
||||
timestamp: contractData.timestamp,
|
||||
wireFeeAmortization: contractData.wireFeeAmortization,
|
||||
wireMethod: contractData.wireMethod,
|
||||
});
|
||||
|
||||
const payCoinSel = selectPayCoins({
|
||||
candidates,
|
||||
contractTermsAmount: contractData.amount,
|
||||
depositFeeLimit: contractData.maxDepositFee,
|
||||
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
||||
wireFeeLimit: contractData.maxWireFee,
|
||||
prevPayCoins: [],
|
||||
});
|
||||
|
||||
|
||||
if (!payCoinSel) {
|
||||
throw Error("insufficient funds");
|
||||
|
@ -34,7 +34,6 @@ import {
|
||||
WalletContractData,
|
||||
CoinRecord,
|
||||
DenominationRecord,
|
||||
PayCoinSelection,
|
||||
AbortStatus,
|
||||
AllowedExchangeInfo,
|
||||
AllowedAuditorInfo,
|
||||
@ -55,7 +54,7 @@ import {
|
||||
PreparePayResultType,
|
||||
ConfirmPayResultType,
|
||||
} from "../types/walletTypes";
|
||||
import * as Amounts from "../util/amounts";
|
||||
import { Amounts } from "../util/amounts";
|
||||
import { AmountJson } from "../util/amounts";
|
||||
import { Logger } from "../util/logging";
|
||||
import { parsePayUri } from "../util/taleruri";
|
||||
@ -95,38 +94,13 @@ import {
|
||||
getRetryDuration,
|
||||
} from "../util/retries";
|
||||
import { TransactionHandle } from "../util/query";
|
||||
import { PayCoinSelection, CoinCandidateSelection, AvailableCoinInfo, selectPayCoins } from "../util/coinSelection";
|
||||
|
||||
/**
|
||||
* Logger.
|
||||
*/
|
||||
const logger = new Logger("pay.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.
|
||||
*/
|
||||
denomPub: string;
|
||||
|
||||
/**
|
||||
* Amount still remaining (typically the full amount,
|
||||
* as coins are always refreshed after use.)
|
||||
*/
|
||||
availableAmount: AmountJson;
|
||||
|
||||
/**
|
||||
* Deposit fee for the coin.
|
||||
*/
|
||||
feeDeposit: AmountJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the total cost of a payment to the customer.
|
||||
@ -212,104 +186,6 @@ export async function getEffectiveDepositAmount(
|
||||
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of available coins, select coins to spend under the merchant's
|
||||
* constraints.
|
||||
*
|
||||
* This function is only exported for the sake of unit tests.
|
||||
*/
|
||||
export function selectPayCoins(
|
||||
acis: AvailableCoinInfo[],
|
||||
contractTermsAmount: AmountJson,
|
||||
customerWireFees: AmountJson,
|
||||
depositFeeLimit: AmountJson,
|
||||
): PayCoinSelection | undefined {
|
||||
if (acis.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const coinPubs: string[] = [];
|
||||
const coinContributions: AmountJson[] = [];
|
||||
// Sort by available amount (descending), deposit fee (ascending) and
|
||||
// denomPub (ascending) if deposit fee is the same
|
||||
// (to guarantee deterministic results)
|
||||
acis.sort(
|
||||
(o1, o2) =>
|
||||
-Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
|
||||
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
|
||||
strcmp(o1.denomPub, o2.denomPub),
|
||||
);
|
||||
const paymentAmount = Amounts.add(contractTermsAmount, customerWireFees)
|
||||
.amount;
|
||||
const currency = paymentAmount.currency;
|
||||
let amountPayRemaining = paymentAmount;
|
||||
let amountDepositFeeLimitRemaining = depositFeeLimit;
|
||||
const customerDepositFees = Amounts.getZero(currency);
|
||||
for (const aci of acis) {
|
||||
// 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 (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
|
||||
// We have spent enough!
|
||||
break;
|
||||
}
|
||||
|
||||
// How much does the user spend on deposit fees for this coin?
|
||||
const depositFeeSpend = Amounts.sub(
|
||||
aci.feeDeposit,
|
||||
amountDepositFeeLimitRemaining,
|
||||
).amount;
|
||||
|
||||
if (Amounts.isZero(depositFeeSpend)) {
|
||||
// Fees are still covered by the merchant.
|
||||
amountDepositFeeLimitRemaining = Amounts.sub(
|
||||
amountDepositFeeLimitRemaining,
|
||||
aci.feeDeposit,
|
||||
).amount;
|
||||
} else {
|
||||
amountDepositFeeLimitRemaining = Amounts.getZero(currency);
|
||||
}
|
||||
|
||||
let coinSpend: AmountJson;
|
||||
const amountActualAvailable = Amounts.sub(
|
||||
aci.availableAmount,
|
||||
depositFeeSpend,
|
||||
).amount;
|
||||
|
||||
if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
|
||||
// Partial spending, as the coin is worth more than the remaining
|
||||
// amount to pay.
|
||||
coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount;
|
||||
// Make sure we contribute at least the deposit fee, otherwise
|
||||
// contributing this coin would cause a loss for the merchant.
|
||||
if (Amounts.cmp(coinSpend, aci.feeDeposit) < 0) {
|
||||
coinSpend = aci.feeDeposit;
|
||||
}
|
||||
amountPayRemaining = Amounts.getZero(currency);
|
||||
} else {
|
||||
// Spend the full remaining amount on the coin
|
||||
coinSpend = aci.availableAmount;
|
||||
amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend)
|
||||
.amount;
|
||||
amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount)
|
||||
.amount;
|
||||
}
|
||||
|
||||
coinPubs.push(aci.coinPub);
|
||||
coinContributions.push(coinSpend);
|
||||
}
|
||||
if (Amounts.isZero(amountPayRemaining)) {
|
||||
return {
|
||||
paymentAmount: contractTermsAmount,
|
||||
coinContributions,
|
||||
coinPubs,
|
||||
customerDepositFees,
|
||||
customerWireFees,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isSpendableCoin(
|
||||
coin: CoinRecord,
|
||||
@ -329,6 +205,7 @@ export function isSpendableCoin(
|
||||
|
||||
export interface CoinSelectionRequest {
|
||||
amount: AmountJson;
|
||||
|
||||
allowedAuditors: AllowedAuditorInfo[];
|
||||
allowedExchanges: AllowedExchangeInfo[];
|
||||
|
||||
@ -347,19 +224,23 @@ export interface CoinSelectionRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select coins from the wallet's database that can be used
|
||||
* to pay for the given contract.
|
||||
* Get candidate coins. From these candidate coins,
|
||||
* the actual contributions will be computed later.
|
||||
*
|
||||
* If payment is impossible, undefined is returned.
|
||||
* The resulting candidate coin list is sorted deterministically.
|
||||
*
|
||||
* TODO: Exclude more coins:
|
||||
* - when we already have a coin with more remaining amount than
|
||||
* the payment amount, coins with even higher amounts can be skipped.
|
||||
*/
|
||||
export async function getCoinsForPayment(
|
||||
export async function getCandidatePayCoins(
|
||||
ws: InternalWalletState,
|
||||
req: CoinSelectionRequest,
|
||||
): Promise<PayCoinSelection | undefined> {
|
||||
const remainingAmount = req.amount;
|
||||
): Promise<CoinCandidateSelection> {
|
||||
const candidateCoins: AvailableCoinInfo[] = [];
|
||||
const wireFeesPerExchange: Record<string, AmountJson> = {};
|
||||
|
||||
const exchanges = await ws.db.iter(Stores.exchanges).toArray();
|
||||
|
||||
for (const exchange of exchanges) {
|
||||
let isOkay = false;
|
||||
const exchangeDetails = exchange.details;
|
||||
@ -416,7 +297,6 @@ export async function getCoinsForPayment(
|
||||
throw Error("db inconsistent");
|
||||
}
|
||||
const currency = firstDenom.value.currency;
|
||||
const acis: AvailableCoinInfo[] = [];
|
||||
for (const coin of coins) {
|
||||
const denom = await ws.db.get(Stores.denominations, [
|
||||
exchange.baseUrl,
|
||||
@ -434,11 +314,12 @@ export async function getCoinsForPayment(
|
||||
if (!isSpendableCoin(coin, denom)) {
|
||||
continue;
|
||||
}
|
||||
acis.push({
|
||||
candidateCoins.push({
|
||||
availableAmount: coin.currentAmount,
|
||||
coinPub: coin.coinPub,
|
||||
denomPub: coin.denomPub,
|
||||
feeDeposit: denom.feeDeposit,
|
||||
exchangeBaseUrl: denom.exchangeBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@ -449,32 +330,25 @@ export async function getCoinsForPayment(
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let customerWireFee: AmountJson;
|
||||
|
||||
if (wireFee && req.wireFeeAmortization) {
|
||||
const amortizedWireFee = Amounts.divide(wireFee, req.wireFeeAmortization);
|
||||
if (Amounts.cmp(req.maxWireFee, amortizedWireFee) < 0) {
|
||||
customerWireFee = amortizedWireFee;
|
||||
} else {
|
||||
customerWireFee = Amounts.getZero(currency);
|
||||
if (wireFee) {
|
||||
wireFeesPerExchange[exchange.baseUrl] = wireFee;
|
||||
}
|
||||
} else {
|
||||
customerWireFee = Amounts.getZero(currency);
|
||||
}
|
||||
|
||||
// Try if paying using this exchange works
|
||||
const res = selectPayCoins(
|
||||
acis,
|
||||
remainingAmount,
|
||||
customerWireFee,
|
||||
req.maxDepositFee,
|
||||
// Sort by available amount (descending), deposit fee (ascending) and
|
||||
// denomPub (ascending) if deposit fee is the same
|
||||
// (to guarantee deterministic results)
|
||||
candidateCoins.sort(
|
||||
(o1, o2) =>
|
||||
-Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
|
||||
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
|
||||
strcmp(o1.denomPub, o2.denomPub),
|
||||
);
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
return {
|
||||
candidateCoins,
|
||||
wireFeesPerExchange,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyCoinSpend(
|
||||
@ -1009,13 +883,18 @@ async function storePayReplaySuccess(
|
||||
* We do this by going through the coin history provided by the exchange and
|
||||
* (1) verifying the signatures from the exchange
|
||||
* (2) adjusting the remaining coin value
|
||||
* (3) re-do coin selection.
|
||||
* (3) re-do coin selection with the bad coin removed
|
||||
*/
|
||||
async function handleInsufficientFunds(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
err: TalerErrorDetails,
|
||||
): Promise<void> {
|
||||
const proposal = await ws.db.get(Stores.purchases, proposalId);
|
||||
if (!proposal) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw Error("payment re-denomination not implemented yet");
|
||||
}
|
||||
|
||||
@ -1232,7 +1111,24 @@ export async function checkPaymentByProposalId(
|
||||
|
||||
if (!purchase) {
|
||||
// If not already paid, check if we could pay for it.
|
||||
const res = await getCoinsForPayment(ws, contractData);
|
||||
const candidates = await getCandidatePayCoins(ws, {
|
||||
allowedAuditors: contractData.allowedAuditors,
|
||||
allowedExchanges: contractData.allowedExchanges,
|
||||
amount: contractData.amount,
|
||||
maxDepositFee: contractData.maxDepositFee,
|
||||
maxWireFee: contractData.maxWireFee,
|
||||
timestamp: contractData.timestamp,
|
||||
wireFeeAmortization: contractData.wireFeeAmortization,
|
||||
wireMethod: contractData.wireMethod,
|
||||
});
|
||||
const res = selectPayCoins({
|
||||
candidates,
|
||||
contractTermsAmount: contractData.amount,
|
||||
depositFeeLimit: contractData.maxDepositFee,
|
||||
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
||||
wireFeeLimit: contractData.maxWireFee,
|
||||
prevPayCoins: [],
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
logger.info("not confirming payment, insufficient coins");
|
||||
@ -1434,12 +1330,34 @@ export async function confirmPay(
|
||||
|
||||
logger.trace("confirmPay: purchase record does not exist yet");
|
||||
|
||||
const res = await getCoinsForPayment(ws, d.contractData);
|
||||
const contractData = d.contractData;
|
||||
|
||||
const candidates = await getCandidatePayCoins(ws, {
|
||||
allowedAuditors: contractData.allowedAuditors,
|
||||
allowedExchanges: contractData.allowedExchanges,
|
||||
amount: contractData.amount,
|
||||
maxDepositFee: contractData.maxDepositFee,
|
||||
maxWireFee: contractData.maxWireFee,
|
||||
timestamp: contractData.timestamp,
|
||||
wireFeeAmortization: contractData.wireFeeAmortization,
|
||||
wireMethod: contractData.wireMethod,
|
||||
});
|
||||
|
||||
const res = selectPayCoins({
|
||||
candidates,
|
||||
contractTermsAmount: contractData.amount,
|
||||
depositFeeLimit: contractData.maxDepositFee,
|
||||
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
||||
wireFeeLimit: contractData.maxWireFee,
|
||||
prevPayCoins: [],
|
||||
});
|
||||
|
||||
logger.trace("coin selection result", res);
|
||||
|
||||
if (!res) {
|
||||
// Should not happen, since checkPay should be called first
|
||||
// FIXME: Actually, this should be handled gracefully,
|
||||
// and the status should be stored in the DB.
|
||||
logger.warn("not confirming payment, insufficient coins");
|
||||
throw Error("insufficient balance");
|
||||
}
|
||||
|
@ -41,6 +41,7 @@ import { ReserveTransaction } from "./ReserveTransaction";
|
||||
import { Timestamp, Duration } from "../util/time";
|
||||
import { IDBKeyPath } from "@gnu-taler/idb-bridge";
|
||||
import { RetryInfo } from "../util/retries";
|
||||
import { PayCoinSelection } from "../util/coinSelection";
|
||||
|
||||
export enum ReserveRecordStatus {
|
||||
/**
|
||||
@ -1138,36 +1139,6 @@ export interface WalletContractData {
|
||||
maxDepositFee: AmountJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of selecting coins, contains the exchange, and selected
|
||||
* coins with their denomination.
|
||||
*/
|
||||
export interface PayCoinSelection {
|
||||
/**
|
||||
* Amount requested by the merchant.
|
||||
*/
|
||||
paymentAmount: AmountJson;
|
||||
|
||||
/**
|
||||
* Public keys of the coins that were selected.
|
||||
*/
|
||||
coinPubs: string[];
|
||||
|
||||
/**
|
||||
* Amount that each coin contributes.
|
||||
*/
|
||||
coinContributions: AmountJson[];
|
||||
|
||||
/**
|
||||
* How much of the wire fees is the customer paying?
|
||||
*/
|
||||
customerWireFees: AmountJson;
|
||||
|
||||
/**
|
||||
* How much of the deposit fees is the customer paying?
|
||||
*/
|
||||
customerDepositFees: AmountJson;
|
||||
}
|
||||
|
||||
export enum AbortStatus {
|
||||
None = "none",
|
||||
@ -1210,6 +1181,16 @@ export interface PurchaseRecord {
|
||||
|
||||
payCoinSelection: PayCoinSelection;
|
||||
|
||||
/**
|
||||
* Pending removals from pay coin selection.
|
||||
*
|
||||
* Used when a the pay coin selection needs to be changed
|
||||
* because a coin became known as double-spent or invalid,
|
||||
* but a new coin selection can't immediately be done, as
|
||||
* there is not enough balance (e.g. when waiting for a refresh).
|
||||
*/
|
||||
pendingRemovedCoinPubs?: string[];
|
||||
|
||||
totalPayCost: AmountJson;
|
||||
|
||||
/**
|
||||
|
@ -381,6 +381,25 @@ function mult(a: AmountJson, n: number): Result {
|
||||
return add(acc, x);
|
||||
}
|
||||
|
||||
function max(a: AmountLike, b: AmountLike): AmountJson {
|
||||
const cr = Amounts.cmp(a, b);
|
||||
if (cr >= 0) {
|
||||
return jsonifyAmount(a);
|
||||
} else {
|
||||
return jsonifyAmount(b);
|
||||
}
|
||||
}
|
||||
|
||||
function min(a: AmountLike, b: AmountLike): AmountJson {
|
||||
const cr = Amounts.cmp(a, b);
|
||||
if (cr >= 0) {
|
||||
return jsonifyAmount(b);
|
||||
} else {
|
||||
return jsonifyAmount(a);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Export all amount-related functions here for better IDE experience.
|
||||
export const Amounts = {
|
||||
stringify: stringify,
|
||||
@ -391,6 +410,8 @@ export const Amounts = {
|
||||
sum: sum,
|
||||
sub: sub,
|
||||
mult: mult,
|
||||
max: max,
|
||||
min: min,
|
||||
check: check,
|
||||
getZero: getZero,
|
||||
isZero: isZero,
|
||||
|
@ -1,24 +1,25 @@
|
||||
/*
|
||||
This file is part of TALER
|
||||
(C) 2017 Inria and GNUnet e.V.
|
||||
This file is part of GNU Taler
|
||||
(C) 2021 Taler Systems S.A.
|
||||
|
||||
TALER is free software; you can redistribute it and/or modify it under the
|
||||
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.
|
||||
|
||||
TALER is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
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
|
||||
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Imports.
|
||||
*/
|
||||
import test from "ava";
|
||||
|
||||
import { AmountJson } from "./util/amounts";
|
||||
import * as Amounts from "./util/amounts";
|
||||
import { selectPayCoins, AvailableCoinInfo } from "./operations/pay";
|
||||
import { AmountJson, Amounts } from "..";
|
||||
import { AvailableCoinInfo, selectPayCoins } from "./coinSelection";
|
||||
|
||||
function a(x: string): AmountJson {
|
||||
const amt = Amounts.parse(x);
|
||||
@ -34,6 +35,7 @@ function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo {
|
||||
coinPub: "foobar",
|
||||
denomPub: "foobar",
|
||||
feeDeposit: a(feeDeposit),
|
||||
exchangeBaseUrl: "https://example.com/",
|
||||
};
|
||||
}
|
||||
|
||||
@ -43,7 +45,17 @@ test("coin selection 1", (t) => {
|
||||
fakeAci("EUR:1.0", "EUR:0.0"),
|
||||
];
|
||||
|
||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.1"));
|
||||
const res = selectPayCoins({
|
||||
candidates: {
|
||||
candidateCoins: acis,
|
||||
wireFeesPerExchange: {},
|
||||
},
|
||||
contractTermsAmount: a("EUR:2.0"),
|
||||
depositFeeLimit: a("EUR:0.1"),
|
||||
wireFeeLimit: a("EUR:0"),
|
||||
wireFeeAmortization: 1,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
t.fail();
|
||||
return;
|
||||
@ -59,7 +71,18 @@ test("coin selection 2", (t) => {
|
||||
// Merchant covers the fee, this one shouldn't be used
|
||||
fakeAci("EUR:1.0", "EUR:0.0"),
|
||||
];
|
||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.5"));
|
||||
|
||||
const res = selectPayCoins({
|
||||
candidates: {
|
||||
candidateCoins: acis,
|
||||
wireFeesPerExchange: {},
|
||||
},
|
||||
contractTermsAmount: a("EUR:2.0"),
|
||||
depositFeeLimit: a("EUR:0.5"),
|
||||
wireFeeLimit: a("EUR:0"),
|
||||
wireFeeAmortization: 1,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
t.fail();
|
||||
return;
|
||||
@ -75,7 +98,18 @@ test("coin selection 3", (t) => {
|
||||
// this coin should be selected instead of previous one with fee
|
||||
fakeAci("EUR:1.0", "EUR:0.0"),
|
||||
];
|
||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.5"));
|
||||
|
||||
const res = selectPayCoins({
|
||||
candidates: {
|
||||
candidateCoins: acis,
|
||||
wireFeesPerExchange: {},
|
||||
},
|
||||
contractTermsAmount: a("EUR:2.0"),
|
||||
depositFeeLimit: a("EUR:0.5"),
|
||||
wireFeeLimit: a("EUR:0"),
|
||||
wireFeeAmortization: 1,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
t.fail();
|
||||
return;
|
||||
@ -90,7 +124,18 @@ test("coin selection 4", (t) => {
|
||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||
];
|
||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.5"));
|
||||
|
||||
const res = selectPayCoins({
|
||||
candidates: {
|
||||
candidateCoins: acis,
|
||||
wireFeesPerExchange: {},
|
||||
},
|
||||
contractTermsAmount: a("EUR:2.0"),
|
||||
depositFeeLimit: a("EUR:0.5"),
|
||||
wireFeeLimit: a("EUR:0"),
|
||||
wireFeeAmortization: 1,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
t.fail();
|
||||
return;
|
||||
@ -105,7 +150,18 @@ test("coin selection 5", (t) => {
|
||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||
];
|
||||
const res = selectPayCoins(acis, a("EUR:4.0"), a("EUR:0"), a("EUR:0.2"));
|
||||
|
||||
const res = selectPayCoins({
|
||||
candidates: {
|
||||
candidateCoins: acis,
|
||||
wireFeesPerExchange: {},
|
||||
},
|
||||
contractTermsAmount: a("EUR:4.0"),
|
||||
depositFeeLimit: a("EUR:0.2"),
|
||||
wireFeeLimit: a("EUR:0"),
|
||||
wireFeeAmortization: 1,
|
||||
});
|
||||
|
||||
t.true(!res);
|
||||
t.pass();
|
||||
});
|
||||
@ -115,7 +171,16 @@ test("coin selection 6", (t) => {
|
||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||
fakeAci("EUR:1.0", "EUR:0.5"),
|
||||
];
|
||||
const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0"), a("EUR:0.2"));
|
||||
const res = selectPayCoins({
|
||||
candidates: {
|
||||
candidateCoins: acis,
|
||||
wireFeesPerExchange: {},
|
||||
},
|
||||
contractTermsAmount: a("EUR:2.0"),
|
||||
depositFeeLimit: a("EUR:0.2"),
|
||||
wireFeeLimit: a("EUR:0"),
|
||||
wireFeeAmortization: 1,
|
||||
});
|
||||
t.true(!res);
|
||||
t.pass();
|
||||
});
|
263
packages/taler-wallet-core/src/util/coinSelection.ts
Normal file
263
packages/taler-wallet-core/src/util/coinSelection.ts
Normal file
@ -0,0 +1,263 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Selection of coins for payments.
|
||||
*
|
||||
* @author Florian Dold
|
||||
*/
|
||||
|
||||
/**
|
||||
* Imports.
|
||||
*/
|
||||
import { AmountJson, Amounts } from "./amounts";
|
||||
import { strcmp } from "./helpers";
|
||||
|
||||
/**
|
||||
* Result of selecting coins, contains the exchange, and selected
|
||||
* coins with their denomination.
|
||||
*/
|
||||
export interface PayCoinSelection {
|
||||
/**
|
||||
* Amount requested by the merchant.
|
||||
*/
|
||||
paymentAmount: AmountJson;
|
||||
|
||||
/**
|
||||
* Public keys of the coins that were selected.
|
||||
*/
|
||||
coinPubs: string[];
|
||||
|
||||
/**
|
||||
* Amount that each coin contributes.
|
||||
*/
|
||||
coinContributions: AmountJson[];
|
||||
|
||||
/**
|
||||
* How much of the wire fees is the customer paying?
|
||||
*/
|
||||
customerWireFees: AmountJson;
|
||||
|
||||
/**
|
||||
* How much of the deposit fees is the customer paying?
|
||||
*/
|
||||
customerDepositFees: AmountJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
denomPub: string;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
type PreviousPayCoins = {
|
||||
coinPub: string;
|
||||
contribution: AmountJson;
|
||||
feeDeposit: AmountJson;
|
||||
exchangeBaseUrl: string;
|
||||
}[];
|
||||
|
||||
export interface CoinCandidateSelection {
|
||||
candidateCoins: AvailableCoinInfo[];
|
||||
wireFeesPerExchange: Record<string, AmountJson>;
|
||||
}
|
||||
|
||||
export interface SelectPayCoinRequest {
|
||||
candidates: CoinCandidateSelection;
|
||||
contractTermsAmount: AmountJson;
|
||||
depositFeeLimit: AmountJson;
|
||||
wireFeeLimit: AmountJson;
|
||||
wireFeeAmortization: number;
|
||||
prevPayCoins?: PreviousPayCoins;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 selectPayCoins(
|
||||
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;
|
||||
// Amount that still needs to be paid.
|
||||
// May increase during the computation when fees need to be covered.
|
||||
let amountPayRemaining = contractTermsAmount;
|
||||
// Allowance given by the merchant towards wire fees
|
||||
let amountWireFeeLimitRemaining = wireFeeLimit;
|
||||
// Allowance given by the merchant towards deposit fees
|
||||
// (and wire fees after wire fee limit is exhausted)
|
||||
let amountDepositFeeLimitRemaining = depositFeeLimit;
|
||||
let customerDepositFees = Amounts.getZero(currency);
|
||||
let customerWireFees = Amounts.getZero(currency);
|
||||
|
||||
const wireFeeCoveredForExchange: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Account for the fees of spending a coin.
|
||||
*/
|
||||
function tallyFees(exchangeBaseUrl: string, feeDeposit: AmountJson): void {
|
||||
if (!wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
|
||||
const wf =
|
||||
candidates.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;
|
||||
}
|
||||
|
||||
const prevPayCoins = req.prevPayCoins ?? [];
|
||||
|
||||
// Look at existing pay coin selection and tally up
|
||||
for (const prev of prevPayCoins) {
|
||||
tallyFees(prev.exchangeBaseUrl, prev.feeDeposit);
|
||||
amountPayRemaining = Amounts.sub(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) ||
|
||||
strcmp(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(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;
|
||||
}
|
||||
|
||||
tallyFees(aci.exchangeBaseUrl, aci.feeDeposit);
|
||||
|
||||
const coinSpend = Amounts.min(amountPayRemaining, aci.availableAmount);
|
||||
amountPayRemaining = Amounts.sub(amountPayRemaining, coinSpend).amount;
|
||||
coinPubs.push(aci.coinPub);
|
||||
coinContributions.push(coinSpend);
|
||||
}
|
||||
|
||||
if (Amounts.isZero(amountPayRemaining)) {
|
||||
return {
|
||||
paymentAmount: contractTermsAmount,
|
||||
coinContributions,
|
||||
coinPubs,
|
||||
customerDepositFees,
|
||||
customerWireFees,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
Loading…
Reference in New Issue
Block a user