wallet-core: towards faster coin selection

This commit is contained in:
Florian Dold 2022-09-15 20:16:42 +02:00
parent 5d08379139
commit b7f7b95602
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
7 changed files with 239 additions and 49 deletions

View File

@ -694,6 +694,7 @@ const codecForDenominationInfo = (): Codec<DenominationInfo> =>
.property("stampExpireWithdraw", codecForTimestamp) .property("stampExpireWithdraw", codecForTimestamp)
.property("stampExpireLegal", codecForTimestamp) .property("stampExpireLegal", codecForTimestamp)
.property("stampExpireDeposit", codecForTimestamp) .property("stampExpireDeposit", codecForTimestamp)
.property("exchangeBaseUrl", codecForString())
.build("codecForDenominationInfo"); .build("codecForDenominationInfo");
export interface DenominationInfo { export interface DenominationInfo {
@ -747,6 +748,8 @@ export interface DenominationInfo {
* Data after which coins of this denomination can't be deposited anymore. * Data after which coins of this denomination can't be deposited anymore.
*/ */
stampExpireDeposit: TalerProtocolTimestamp; stampExpireDeposit: TalerProtocolTimestamp;
exchangeBaseUrl: string;
} }
export type Operation = "deposit" | "withdraw" | "refresh" | "refund"; export type Operation = "deposit" | "withdraw" | "refresh" | "refund";

View File

@ -348,6 +348,7 @@ export namespace DenominationRecord {
stampExpireWithdraw: d.stampExpireWithdraw, stampExpireWithdraw: d.stampExpireWithdraw,
stampStart: d.stampStart, stampStart: d.stampStart,
value: DenominationRecord.getValue(d), value: DenominationRecord.getValue(d),
exchangeBaseUrl: d.exchangeBaseUrl,
}; };
} }
} }
@ -1778,6 +1779,10 @@ export const WalletStoresV1 = {
{ {
byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"), byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"),
byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"), byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"),
byDenomPubHashAndStatus: describeIndex("byDenomPubHashAndStatus", [
"denomPubHash",
"status",
]),
byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"), byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
}, },
), ),

View File

@ -51,7 +51,7 @@ import {
OperationStatus, OperationStatus,
} from "../db.js"; } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { selectPayCoins } from "../util/coinSelection.js"; import { selectPayCoinsLegacy } from "../util/coinSelection.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { spendCoins } from "../wallet.js"; import { spendCoins } from "../wallet.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
@ -271,7 +271,7 @@ export async function getFeeForDeposit(
const candidates = await getCandidatePayCoins(ws, csr); const candidates = await getCandidatePayCoins(ws, csr);
const payCoinSel = selectPayCoins({ const payCoinSel = selectPayCoinsLegacy({
candidates, candidates,
contractTermsAmount: csr.amount, contractTermsAmount: csr.amount,
depositFeeLimit: csr.maxDepositFee, depositFeeLimit: csr.maxDepositFee,
@ -367,7 +367,7 @@ export async function prepareDepositGroup(
wireMethod: contractData.wireMethod, wireMethod: contractData.wireMethod,
}); });
const payCoinSel = selectPayCoins({ const payCoinSel = selectPayCoinsLegacy({
candidates, candidates,
contractTermsAmount: contractData.amount, contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee, depositFeeLimit: contractData.maxDepositFee,
@ -470,7 +470,7 @@ export async function createDepositGroup(
wireMethod: contractData.wireMethod, wireMethod: contractData.wireMethod,
}); });
const payCoinSel = selectPayCoins({ const payCoinSel = selectPayCoinsLegacy({
candidates, candidates,
contractTermsAmount: contractData.amount, contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee, depositFeeLimit: contractData.maxDepositFee,

View File

@ -26,6 +26,7 @@
*/ */
import { import {
AbsoluteTime, AbsoluteTime,
AgeRestriction,
AmountJson, AmountJson,
Amounts, Amounts,
codecForContractTerms, codecForContractTerms,
@ -36,6 +37,8 @@ import {
ConfirmPayResultType, ConfirmPayResultType,
ContractTerms, ContractTerms,
ContractTermsUtil, ContractTermsUtil,
DenominationInfo,
DenominationPubKey,
Duration, Duration,
encodeCrock, encodeCrock,
ForcedCoinSel, ForcedCoinSel,
@ -50,6 +53,7 @@ import {
PreparePayResult, PreparePayResult,
PreparePayResultType, PreparePayResultType,
RefreshReason, RefreshReason,
strcmp,
TalerErrorCode, TalerErrorCode,
TalerErrorDetail, TalerErrorDetail,
TalerProtocolTimestamp, TalerProtocolTimestamp,
@ -86,9 +90,11 @@ import { assertUnreachable } from "../util/assertUnreachable.js";
import { import {
AvailableCoinInfo, AvailableCoinInfo,
CoinCandidateSelection, CoinCandidateSelection,
CoinSelectionTally,
PreviousPayCoins, PreviousPayCoins,
selectForcedPayCoins, selectForcedPayCoins,
selectPayCoins, selectPayCoinsLegacy,
tallyFees,
} from "../util/coinSelection.js"; } from "../util/coinSelection.js";
import { import {
getHttpResponseErrorDetails, getHttpResponseErrorDetails,
@ -962,7 +968,7 @@ async function handleInsufficientFunds(
} }
}); });
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates, candidates,
contractTermsAmount: contractData.amount, contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee, depositFeeLimit: contractData.maxDepositFee,
@ -1019,6 +1025,205 @@ async function unblockBackup(
}); });
} }
export interface SelectPayCoinRequestNg {
exchanges: string[];
auditors: string[];
wireMethod: string;
contractTermsAmount: AmountJson;
depositFeeLimit: AmountJson;
wireFeeLimit: AmountJson;
wireFeeAmortization: number;
prevPayCoins?: PreviousPayCoins;
requiredMinimumAge?: number;
}
export type AvailableDenom = DenominationInfo & {
numAvailable: number;
};
/**
* 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 async function selectPayCoinsNew(
ws: InternalWalletState,
req: SelectPayCoinRequestNg,
): Promise<PayCoinSelection | undefined> {
const {
contractTermsAmount,
depositFeeLimit,
wireFeeLimit,
wireFeeAmortization,
} = req;
const [candidateDenoms, wireFeesPerExchange] = await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
.runReadOnly(async (tx) => {
const denoms: AvailableDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray();
const wfPerExchange: Record<string, AmountJson> = {};
for (const exchange of exchanges) {
const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
if (exchangeDetails?.currency !== contractTermsAmount.currency) {
continue;
}
let accepted = false;
if (req.exchanges.includes(exchange.baseUrl)) {
accepted = true;
} else {
for (const auditor of exchangeDetails.auditors) {
if (req.auditors.includes(auditor.auditor_url)) {
accepted = true;
}
}
}
if (!accepted) {
continue;
}
// FIXME: Do this query more efficiently via indexing
const exchangeDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(exchangeDetails.exchangeBaseUrl)
.filter((x) => x.freshCoinCount != null && x.freshCoinCount > 0);
// FIXME: Should we exclude denominations that are
// not spendable anymore?
for (const denom of exchangeDenoms) {
denoms.push({
...DenominationRecord.toDenomInfo(denom),
numAvailable: denom.freshCoinCount ?? 0,
});
}
}
return [denoms, wfPerExchange];
});
const coinPubs: string[] = [];
const coinContributions: AmountJson[] = [];
const currency = contractTermsAmount.currency;
let tally: CoinSelectionTally = {
amountPayRemaining: contractTermsAmount,
amountWireFeeLimitRemaining: wireFeeLimit,
amountDepositFeeLimitRemaining: depositFeeLimit,
customerDepositFees: Amounts.getZero(currency),
customerWireFees: Amounts.getZero(currency),
wireFeeCoveredForExchange: new Set(),
};
const prevPayCoins = req.prevPayCoins ?? [];
// Look at existing pay coin selection and tally up
for (const prev of prevPayCoins) {
tally = tallyFees(
tally,
wireFeesPerExchange,
wireFeeAmortization,
prev.exchangeBaseUrl,
prev.feeDeposit,
);
tally.amountPayRemaining = Amounts.sub(
tally.amountPayRemaining,
prev.contribution,
).amount;
coinPubs.push(prev.coinPub);
coinContributions.push(prev.contribution);
}
// Sort by available amount (descending), deposit fee (ascending) and
// denomPub (ascending) if deposit fee is the same
// (to guarantee deterministic results)
candidateDenoms.sort(
(o1, o2) =>
-Amounts.cmp(o1.value, o2.value) ||
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
strcmp(o1.denomPubHash, o2.denomPubHash),
);
// 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.
const selectedDenom: {
[dph: string]: AmountJson[];
} = {};
for (const aci of candidateDenoms) {
const contributions: AmountJson[] = [];
for (let i = 0; i < aci.numAvailable; i++) {
// 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.value) > 0) {
continue;
}
if (Amounts.isZero(tally.amountPayRemaining)) {
// We have spent enough!
break;
}
tally = tallyFees(
tally,
wireFeesPerExchange,
wireFeeAmortization,
aci.exchangeBaseUrl,
aci.feeDeposit,
);
let coinSpend = Amounts.max(
Amounts.min(tally.amountPayRemaining, aci.value),
aci.feeDeposit,
);
tally.amountPayRemaining = Amounts.sub(
tally.amountPayRemaining,
coinSpend,
).amount;
contributions.push(coinSpend);
}
if (contributions.length) {
selectedDenom[aci.denomPubHash] = contributions;
}
if (Amounts.isZero(tally.amountPayRemaining)) {
await ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
for (const dph of Object.keys(selectedDenom)) {
const contributions = selectedDenom[dph];
const coins = await tx.coins.indexes.byDenomPubHashAndStatus.getAll(
[dph, CoinStatus.Fresh],
contributions.length,
);
if (coins.length != contributions.length) {
throw Error(
`coin selection failed (not available anymore, got only ${coins.length}/${contributions.length})`,
);
}
coinPubs.push(...coins.map((x) => x.coinPub));
coinContributions.push(...contributions);
}
});
return {
paymentAmount: contractTermsAmount,
coinContributions,
coinPubs,
customerDepositFees: tally.customerDepositFees,
customerWireFees: tally.customerWireFees,
};
}
}
return undefined;
}
export async function checkPaymentByProposalId( export async function checkPaymentByProposalId(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -1079,7 +1284,7 @@ export async function checkPaymentByProposalId(
wireFeeAmortization: contractData.wireFeeAmortization, wireFeeAmortization: contractData.wireFeeAmortization,
wireMethod: contractData.wireMethod, wireMethod: contractData.wireMethod,
}); });
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates, candidates,
contractTermsAmount: contractData.amount, contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee, depositFeeLimit: contractData.maxDepositFee,
@ -1408,7 +1613,7 @@ export async function confirmPay(
requiredMinimumAge: contractData.minimumAge, requiredMinimumAge: contractData.minimumAge,
}); });
} else { } else {
res = selectPayCoins({ res = selectPayCoinsLegacy({
candidates, candidates,
contractTermsAmount: contractData.amount, contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee, depositFeeLimit: contractData.maxDepositFee,

View File

@ -19,7 +19,7 @@
*/ */
import test from "ava"; import test from "ava";
import { AmountJson, Amounts, DenomKeyType } from "@gnu-taler/taler-util"; import { AmountJson, Amounts, DenomKeyType } from "@gnu-taler/taler-util";
import { AvailableCoinInfo, selectPayCoins } from "./coinSelection.js"; import { AvailableCoinInfo, selectPayCoinsLegacy } from "./coinSelection.js";
function a(x: string): AmountJson { function a(x: string): AmountJson {
const amt = Amounts.parse(x); const amt = Amounts.parse(x);
@ -66,7 +66,7 @@ test("it should be able to pay if merchant takes the fees", (t) => {
]; ];
acis.forEach((x, i) => (x.coinPub = String(i))); acis.forEach((x, i) => (x.coinPub = String(i)));
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -94,7 +94,7 @@ test("it should take the last two coins if it pays less fees", (t) => {
]; ];
acis.forEach((x, i) => (x.coinPub = String(i))); acis.forEach((x, i) => (x.coinPub = String(i)));
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -122,7 +122,7 @@ test("it should take the last coins if the merchant doest not take all the fee",
]; ];
acis.forEach((x, i) => (x.coinPub = String(i))); acis.forEach((x, i) => (x.coinPub = String(i)));
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -148,7 +148,7 @@ test("it should use 3 coins to cover fees and payment", (t) => {
fakeAci("EUR:1.0", "EUR:0.5"), //contributed value .5 fakeAci("EUR:1.0", "EUR:0.5"), //contributed value .5
]; ];
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -174,7 +174,7 @@ test("it should return undefined if there is not enough coins", (t) => {
fakeAci("EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
]; ];
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -194,7 +194,7 @@ test("it should return undefined if there is not enough coins (taking into accou
fakeAci("EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeAci("EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
]; ];
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -213,7 +213,7 @@ test("it should not count into customer fee if merchant can afford it", (t) => {
fakeAci("EUR:1.0", "EUR:0.1"), fakeAci("EUR:1.0", "EUR:0.1"),
fakeAci("EUR:1.0", "EUR:0.1"), fakeAci("EUR:1.0", "EUR:0.1"),
]; ];
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -240,7 +240,7 @@ test("it should use the coins that spent less relative fee", (t) => {
]; ];
acis.forEach((x, i) => (x.coinPub = String(i))); acis.forEach((x, i) => (x.coinPub = String(i)));
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -263,7 +263,7 @@ test("coin selection 9", (t) => {
fakeAci("EUR:1.0", "EUR:0.2"), fakeAci("EUR:1.0", "EUR:0.2"),
fakeAci("EUR:0.2", "EUR:0.2"), fakeAci("EUR:0.2", "EUR:0.2"),
]; ];
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},
@ -290,7 +290,7 @@ test("it should be able to use unrestricted coins for age restricted contract",
fakeAciWithAgeRestriction("EUR:1.0", "EUR:0.2"), fakeAciWithAgeRestriction("EUR:1.0", "EUR:0.2"),
fakeAciWithAgeRestriction("EUR:0.2", "EUR:0.2"), fakeAciWithAgeRestriction("EUR:0.2", "EUR:0.2"),
]; ];
const res = selectPayCoins({ const res = selectPayCoinsLegacy({
candidates: { candidates: {
candidateCoins: acis, candidateCoins: acis,
wireFeesPerExchange: {}, wireFeesPerExchange: {},

View File

@ -97,7 +97,7 @@ export interface SelectPayCoinRequest {
requiredMinimumAge?: number; requiredMinimumAge?: number;
} }
interface CoinSelectionTally { export interface CoinSelectionTally {
/** /**
* Amount that still needs to be paid. * Amount that still needs to be paid.
* May increase during the computation when fees need to be covered. * May increase during the computation when fees need to be covered.
@ -125,7 +125,7 @@ interface CoinSelectionTally {
/** /**
* Account for the fees of spending a coin. * Account for the fees of spending a coin.
*/ */
function tallyFees( export function tallyFees(
tally: CoinSelectionTally, tally: CoinSelectionTally,
wireFeesPerExchange: Record<string, AmountJson>, wireFeesPerExchange: Record<string, AmountJson>,
wireFeeAmortization: number, wireFeeAmortization: number,
@ -204,7 +204,7 @@ function tallyFees(
* *
* This function is only exported for the sake of unit tests. * This function is only exported for the sake of unit tests.
*/ */
export function selectPayCoins( export function selectPayCoinsLegacy(
req: SelectPayCoinRequest, req: SelectPayCoinRequest,
): PayCoinSelection | undefined { ): PayCoinSelection | undefined {
const { const {

View File

@ -743,19 +743,9 @@ async function getExchangeDetailedInfo(
return; return;
} }
const denominations: DenomInfo[] = denominationRecords.map((x) => ({ const denominations: DenomInfo[] = denominationRecords.map((x) =>
denomPub: x.denomPub, DenominationRecord.toDenomInfo(x),
denomPubHash: x.denomPubHash, );
feeDeposit: x.fees.feeDeposit,
feeRefresh: x.fees.feeRefresh,
feeRefund: x.fees.feeRefund,
feeWithdraw: x.fees.feeWithdraw,
stampExpireDeposit: x.stampExpireDeposit,
stampExpireLegal: x.stampExpireLegal,
stampExpireWithdraw: x.stampExpireWithdraw,
stampStart: x.stampStart,
value: DenominationRecord.getValue(x),
}));
return { return {
info: { info: {
@ -1591,20 +1581,7 @@ class InternalWalletStateImpl implements InternalWalletState {
} }
const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
if (d) { if (d) {
const denomInfo = { return DenominationRecord.toDenomInfo(d);
denomPub: d.denomPub,
denomPubHash: d.denomPubHash,
feeDeposit: d.fees.feeDeposit,
feeRefresh: d.fees.feeRefresh,
feeRefund: d.fees.feeRefund,
feeWithdraw: d.fees.feeWithdraw,
stampExpireDeposit: d.stampExpireDeposit,
stampExpireLegal: d.stampExpireLegal,
stampExpireWithdraw: d.stampExpireWithdraw,
stampStart: d.stampStart,
value: DenominationRecord.getValue(d),
};
return denomInfo;
} }
return undefined; return undefined;
} }