aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/coinSelection.ts
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-04-03 12:58:01 +0200
committerFlorian Dold <florian@dold.me>2024-04-03 12:58:01 +0200
commit5417b8b7b866f1c4f4d99d6ec9ad001af67822b6 (patch)
tree8e14f48ca356621343ca949d1ff700dc41d08776 /packages/taler-wallet-core/src/coinSelection.ts
parentbc3e40310ef37c90ef16d562440fffe9793f1099 (diff)
downloadwallet-core-5417b8b7b866f1c4f4d99d6ec9ad001af67822b6.tar.xz
wallet-core: preparations for deferred coin selection
Diffstat (limited to 'packages/taler-wallet-core/src/coinSelection.ts')
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts400
1 files changed, 288 insertions, 112 deletions
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
index 6e3ef5917..bce51fd91 100644
--- a/packages/taler-wallet-core/src/coinSelection.ts
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -44,7 +44,9 @@ import {
parsePaytoUri,
PayCoinSelection,
PaymentInsufficientBalanceDetails,
+ ProspectivePayCoinSelection,
SelectedCoin,
+ SelectedProspectiveCoin,
strcmp,
TalerProtocolTimestamp,
} from "@gnu-taler/taler-util";
@@ -158,8 +160,101 @@ export type SelectPayCoinsResult =
type: "failure";
insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
}
+ | { type: "prospective"; result: ProspectivePayCoinSelection }
| { type: "success"; coinSel: PayCoinSelection };
+async function internalSelectPayCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ]
+ >,
+ req: SelectPayCoinRequestNg,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
+ | undefined
+> {
+ const { contractTermsAmount, depositFeeLimit } = req;
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ includePendingCoins,
+ },
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ );
+ logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
+ logger.trace(`candidates: ${j2s(candidateDenoms)}`);
+ }
+
+ const coinRes: SelectedCoin[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.zeroOfCurrency(currency),
+ customerWireFees: Amounts.zeroOfCurrency(currency),
+ wireFeeCoveredForExchange: new Set(),
+ lastDepositFee: Amounts.zeroOfCurrency(currency),
+ };
+
+ await maybeRepairCoinSelection(
+ wex,
+ tx,
+ req.prevPayCoins ?? [],
+ coinRes,
+ tally,
+ {
+ wireFeeAmortization: req.wireFeeAmortization,
+ 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(
+ {
+ wireFeeAmortization: req.wireFeeAmortization,
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ candidateDenoms,
+ tally,
+ );
+ }
+
+ if (!selectedDenom) {
+ return undefined;
+ }
+ return {
+ sel: selectedDenom,
+ coinRes,
+ tally,
+ };
+}
+
/**
* Select coins to spend under the merchant's constraints.
*
@@ -171,8 +266,6 @@ export async function selectPayCoins(
wex: WalletExecutionContext,
req: SelectPayCoinRequestNg,
): Promise<SelectPayCoinsResult> {
- const { contractTermsAmount, depositFeeLimit } = req;
-
if (logger.shouldLogTrace()) {
logger.trace(`selecting coins for ${j2s(req)}`);
}
@@ -187,69 +280,42 @@ export async function selectPayCoins(
"coins",
],
async (tx) => {
- const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
- wex,
- tx,
- {
- restrictExchanges: req.restrictExchanges,
- instructedAmount: req.contractTermsAmount,
- restrictWireMethod: req.restrictWireMethod,
- depositPaytoUri: req.depositPaytoUri,
- requiredMinimumAge: req.requiredMinimumAge,
- },
- );
+ const materialAvSel = await internalSelectPayCoins(wex, tx, req, false);
- if (logger.shouldLogTrace()) {
- logger.trace(
- `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ if (!materialAvSel) {
+ const prospectiveAvSel = await internalSelectPayCoins(
+ wex,
+ tx,
+ req,
+ true,
);
- logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
- logger.trace(`candidates: ${j2s(candidateDenoms)}`);
- }
- const coinRes: SelectedCoin[] = [];
- const currency = contractTermsAmount.currency;
-
- let tally: CoinSelectionTally = {
- amountPayRemaining: contractTermsAmount,
- amountDepositFeeLimitRemaining: depositFeeLimit,
- customerDepositFees: Amounts.zeroOfCurrency(currency),
- customerWireFees: Amounts.zeroOfCurrency(currency),
- wireFeeCoveredForExchange: new Set(),
- lastDepositFee: Amounts.zeroOfCurrency(currency),
- };
-
- await maybeRepairCoinSelection(
- wex,
- tx,
- req.prevPayCoins ?? [],
- coinRes,
- tally,
- {
- wireFeeAmortization: req.wireFeeAmortization,
- 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(
- {
- wireFeeAmortization: req.wireFeeAmortization,
- wireFeesPerExchange: wireFeesPerExchange,
- },
- candidateDenoms,
- tally,
- );
- }
+ 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;
+ }
- if (!selectedDenom) {
return {
type: "failure",
insufficientBalanceDetails: await reportInsufficientBalanceDetails(
@@ -268,9 +334,9 @@ export async function selectPayCoins(
const coinSel = await assembleSelectPayCoinsSuccessResult(
tx,
- selectedDenom,
- coinRes,
- tally,
+ materialAvSel.sel,
+ materialAvSel.coinRes,
+ materialAvSel.tally,
);
if (logger.shouldLogTrace()) {
@@ -324,12 +390,18 @@ async function maybeRepairCoinSelection(
).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,
@@ -359,8 +431,10 @@ async function assembleSelectPayCoinsSuccessResult(
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,
});
}
}
@@ -745,6 +819,13 @@ interface SelectPayCandidatesRequest {
depositPaytoUri?: string;
restrictExchanges: ExchangeRestrictionSpec | undefined;
requiredMinimumAge?: number;
+
+ /**
+ * If set to true, the coin selection will also use coins that are not
+ * materially available yet, but that are expected to become available
+ * as the output of a refresh operation.
+ */
+ includePendingCoins: boolean;
}
async function selectPayCandidates(
@@ -845,9 +926,13 @@ async function selectPayCandidates(
continue;
}
numUsable++;
+ let numAvailable = coinAvail.freshCoinCount ?? 0;
+ if (req.includePendingCoins) {
+ numAvailable += coinAvail.pendingRefreshOutputCount ?? 0;
+ }
denoms.push({
...DenominationRecord.toDenomInfo(denom),
- numAvailable: coinAvail.freshCoinCount ?? 0,
+ numAvailable,
maxAge: coinAvail.maxAge,
});
}
@@ -886,8 +971,23 @@ export interface PeerCoinSelectionDetails {
maxExpirationDate: TalerProtocolTimestamp;
}
+export interface ProspectivePeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
export type SelectPeerCoinsResult =
| { type: "success"; result: PeerCoinSelectionDetails }
+ // Successful, but using coins that are not materially available yet.
+ | { type: "prospective"; result: ProspectivePeerCoinSelectionDetails }
| {
type: "failure";
insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
@@ -901,6 +1001,13 @@ export interface PeerCoinSelectionRequest {
* selection instead of selecting completely new coins.
*/
repair?: PreviousPayCoins;
+
+ /**
+ * 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 async function computeCoinSelMaxExpirationDate(
@@ -968,6 +1075,77 @@ function getGlobalFees(
return undefined;
}
+async function internalSelectPeerCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ]
+ >,
+ req: PeerCoinSelectionRequest,
+ exch: ExchangeWireDetails,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; tally: CoinSelectionTally; resCoins: SelectedCoin[] }
+ | undefined
+> {
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ instructedAmount: req.instructedAmount,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ exchangePub: exch.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ includePendingCoins,
+ });
+ const candidates = candidatesRes[0];
+ if (logger.shouldLogTrace()) {
+ logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
+ }
+ const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const resCoins: SelectedCoin[] = [];
+
+ await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
+ wireFeeAmortization: 1,
+ wireFeesPerExchange: {},
+ });
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`candidates: ${j2s(candidates)}`);
+ logger.trace(`instructedAmount: ${j2s(req.instructedAmount)}`);
+ logger.trace(`tally: ${j2s(tally)}`);
+ }
+
+ const selRes = selectGreedy(
+ {
+ wireFeeAmortization: 1,
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
+ if (!selRes) {
+ return undefined;
+ }
+
+ return {
+ sel: selRes,
+ tally,
+ resCoins,
+ };
+}
+
export async function selectPeerCoins(
wex: WalletExecutionContext,
req: PeerCoinSelectionRequest,
@@ -1004,65 +1182,63 @@ export async function selectPeerCoins(
if (!globalFees) {
continue;
}
- const candidatesRes = await selectPayCandidates(wex, tx, {
- instructedAmount,
- restrictExchanges: {
- auditors: [],
- exchanges: [
- {
- exchangeBaseUrl: exch.baseUrl,
- exchangePub: exch.detailsPointer.masterPublicKey,
- },
- ],
- },
- restrictWireMethod: undefined,
- });
- const candidates = candidatesRes[0];
- if (logger.shouldLogTrace()) {
- logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
- }
- const tally = emptyTallyForPeerPayment(req.instructedAmount);
- const resCoins: SelectedCoin[] = [];
- await maybeRepairCoinSelection(
+ const avRes = await internalSelectPeerCoins(
wex,
tx,
- req.repair ?? [],
- resCoins,
- tally,
- {
- wireFeeAmortization: 1,
- wireFeesPerExchange: {},
- },
+ req,
+ exchWire,
+ false,
);
- if (logger.shouldLogTrace()) {
- logger.trace(`candidates: ${j2s(candidates)}`);
- logger.trace(`instructedAmount: ${j2s(instructedAmount)}`);
- logger.trace(`tally: ${j2s(tally)}`);
- }
-
- const selectedDenom = selectGreedy(
- {
- wireFeeAmortization: 1,
- wireFeesPerExchange: {},
- },
- candidates,
- tally,
- );
-
- if (selectedDenom) {
+ if (!avRes && req.includePendingCoins) {
+ // Try to see if we can do a prospective selection
+ const prospectiveAvRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ true,
+ );
+ if (prospectiveAvRes) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvRes.sel)) {
+ const mySel = prospectiveAvRes.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ prospectiveAvRes.sel,
+ );
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ depositFees: prospectiveAvRes.tally.customerDepositFees,
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ } else if (avRes) {
const r = await assembleSelectPayCoinsSuccessResult(
tx,
- selectedDenom,
- resCoins,
- tally,
+ avRes.sel,
+ avRes.resCoins,
+ avRes.tally,
);
const maxExpirationDate = await computeCoinSelMaxExpirationDate(
wex,
tx,
- selectedDenom,
+ avRes.sel,
);
return {