From 5417b8b7b866f1c4f4d99d6ec9ad001af67822b6 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 3 Apr 2024 12:58:01 +0200 Subject: wallet-core: preparations for deferred coin selection --- packages/taler-wallet-core/src/coinSelection.ts | 400 +++++++++++++++++------- 1 file changed, 288 insertions(+), 112 deletions(-) (limited to 'packages/taler-wallet-core/src/coinSelection.ts') 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 { - 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 { -- cgit v1.2.3