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 --- .../test-wallet-refresh-blocked.ts | 12 +- packages/taler-util/src/wallet-types.ts | 34 +- packages/taler-wallet-core/src/coinSelection.ts | 400 +++++++++++++++------ packages/taler-wallet-core/src/deposits.ts | 107 +++--- packages/taler-wallet-core/src/pay-merchant.ts | 106 +++--- .../taler-wallet-core/src/pay-peer-pull-debit.ts | 79 ++-- .../taler-wallet-core/src/pay-peer-push-debit.ts | 75 ++-- packages/taler-wallet-core/src/wallet.ts | 4 +- 8 files changed, 562 insertions(+), 255 deletions(-) diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts index 8c568d190..4662c5110 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts @@ -17,7 +17,7 @@ /** * Imports. */ -import { j2s } from "@gnu-taler/taler-util"; +import { AmountString, j2s } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig } from "../harness/denomStructures.js"; import { GlobalTestState } from "../harness/harness.js"; @@ -106,7 +106,15 @@ export async function runWalletRefreshBlockedTest(t: GlobalTestState) { console.log(`balance details: ${j2s(balDet)}`); // FIXME: Now check deposit/p2p/pay - t.assertTrue(false); + + const depositCheckResp = await w1.call(WalletApiOperation.PrepareDeposit, { + amount: "TESTKUDOS:18" as AmountString, + depositPaytoUri: "payto://x-taler-bank/localhost/myuser", + }); + + console.log(`check resp: ${j2s(depositCheckResp)}`); + + // t.assertTrue(false); } runWalletRefreshBlockedTest.suites = ["wallet"]; diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index e5eb618f0..441da7a87 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -633,11 +633,11 @@ export interface CoinDumpJson { withdrawal_reserve_pub: string | undefined; coin_status: CoinStatus; spend_allocation: - | { - id: string; - amount: AmountString; - } - | undefined; + | { + id: string; + amount: AmountString; + } + | undefined; /** * Information about the age restriction */ @@ -836,7 +836,7 @@ export const codecForPreparePayResultPaymentPossible = ) .build("PreparePayResultPaymentPossible"); -export interface BalanceDetails { } +export interface BalanceDetails {} /** * Detailed reason for why the wallet's balance is insufficient. @@ -2662,8 +2662,16 @@ export interface TestPayResult { } export interface SelectedCoin { + denomPubHash: string; coinPub: string; contribution: AmountString; + exchangeBaseUrl: string; +} + +export interface SelectedProspectiveCoin { + denomPubHash: string; + contribution: AmountString; + exchangeBaseUrl: string; } /** @@ -2684,6 +2692,20 @@ export interface PayCoinSelection { customerDepositFees: AmountString; } +export interface ProspectivePayCoinSelection { + prospectiveCoins: SelectedProspectiveCoin[]; + + /** + * How much of the wire fees is the customer paying? + */ + customerWireFees: AmountString; + + /** + * How much of the deposit fees is the customer paying? + */ + customerDepositFees: AmountString; +} + export interface CheckPeerPushDebitRequest { /** * Preferred exchange to use for the p2p payment. 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 { diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index a8612744f..05a5d780a 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -39,10 +39,10 @@ import { Logger, MerchantContractTerms, NotificationType, - PayCoinSelection, PrepareDepositRequest, PrepareDepositResponse, RefreshReason, + SelectedProspectiveCoin, TalerError, TalerErrorCode, TalerPreciseTimestamp, @@ -1155,11 +1155,8 @@ async function trackDeposit( /** * Check if creating a deposit group is possible and calculate * the associated fees. - * - * FIXME: This should be renamed to checkDepositGroup, - * as it doesn't prepare anything */ -export async function prepareDepositGroup( +export async function checkDepositGroup( wex: WalletExecutionContext, req: PrepareDepositRequest, ): Promise { @@ -1168,6 +1165,7 @@ export async function prepareDepositGroup( throw Error("invalid payto URI"); } const amount = Amounts.parseOrThrow(req.amount); + const currency = Amounts.currencyOf(amount); const exchangeInfos: ExchangeHandle[] = []; @@ -1231,28 +1229,39 @@ export async function prepareDepositGroup( prevPayCoins: [], }); - if (payCoinSel.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, - }, - ); + let selCoins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (payCoinSel.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + selCoins = payCoinSel.result.prospectiveCoins; + break; + case "success": + selCoins = payCoinSel.coinSel.coins; + break; + default: + assertUnreachable(payCoinSel); } - const totalDepositCost = await getTotalPaymentCost(wex, payCoinSel.coinSel); + const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins); const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount( wex, p.targetType, - payCoinSel.coinSel, + selCoins, ); const fees = await getTotalFeesForDepositAmount( wex, p.targetType, amount, - payCoinSel.coinSel, + selCoins, ); return { @@ -1280,6 +1289,7 @@ export async function createDepositGroup( } const amount = Amounts.parseOrThrow(req.amount); + const currency = amount.currency; const exchangeInfos: { url: string; master_pub: string }[] = []; @@ -1350,16 +1360,28 @@ export async function createDepositGroup( prevPayCoins: [], }); - if (payCoinSel.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, - }, - ); + switch (payCoinSel.type) { + case "success": + break; + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + // FIXME: Here we need to create the deposit group without a full coin selection! + throw Error("insufficient balance (pending refresh)"); + default: + assertUnreachable(payCoinSel); } - const totalDepositCost = await getTotalPaymentCost(wex, payCoinSel.coinSel); + const totalDepositCost = await getTotalPaymentCost( + wex, + currency, + payCoinSel.coinSel.coins, + ); let depositGroupId: string; if (req.transactionId) { @@ -1400,7 +1422,7 @@ export async function createDepositGroup( await getCounterpartyEffectiveDepositAmount( wex, p.targetType, - payCoinSel.coinSel, + payCoinSel.coinSel.coins, ); const depositGroup: DepositGroupRecord = { @@ -1500,7 +1522,7 @@ export async function createDepositGroup( export async function getCounterpartyEffectiveDepositAmount( wex: WalletExecutionContext, wireType: string, - pcs: PayCoinSelection, + pcs: SelectedProspectiveCoin[], ): Promise { const amt: AmountJson[] = []; const fees: AmountJson[] = []; @@ -1509,23 +1531,19 @@ export async function getCounterpartyEffectiveDepositAmount( await wex.db.runReadOnlyTx( ["coins", "denominations", "exchangeDetails", "exchanges"], async (tx) => { - for (let i = 0; i < pcs.coins.length; i++) { - const coin = await tx.coins.get(pcs.coins[i].coinPub); - if (!coin) { - throw Error("can't calculate deposit amount, coin not found"); - } + for (let i = 0; i < pcs.length; i++) { const denom = await getDenomInfo( wex, tx, - coin.exchangeBaseUrl, - coin.denomPubHash, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, ); if (!denom) { throw Error("can't find denomination to calculate deposit amount"); } - amt.push(Amounts.parseOrThrow(pcs.coins[i].contribution)); + amt.push(Amounts.parseOrThrow(pcs[i].contribution)); fees.push(Amounts.parseOrThrow(denom.feeDeposit)); - exchangeSet.add(coin.exchangeBaseUrl); + exchangeSet.add(pcs[i].exchangeBaseUrl); } for (const exchangeUrl of exchangeSet.values()) { @@ -1564,7 +1582,7 @@ async function getTotalFeesForDepositAmount( wex: WalletExecutionContext, wireType: string, total: AmountJson, - pcs: PayCoinSelection, + pcs: SelectedProspectiveCoin[], ): Promise { const wireFee: AmountJson[] = []; const coinFee: AmountJson[] = []; @@ -1575,33 +1593,26 @@ async function getTotalFeesForDepositAmount( await wex.db.runReadOnlyTx( ["coins", "denominations", "exchanges", "exchangeDetails"], async (tx) => { - for (let i = 0; i < pcs.coins.length; i++) { - const coin = await tx.coins.get(pcs.coins[i].coinPub); - if (!coin) { - throw Error("can't calculate deposit amount, coin not found"); - } + for (let i = 0; i < pcs.length; i++) { const denom = await getDenomInfo( wex, tx, - coin.exchangeBaseUrl, - coin.denomPubHash, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, ); if (!denom) { throw Error("can't find denomination to calculate deposit amount"); } coinFee.push(Amounts.parseOrThrow(denom.feeDeposit)); - exchangeSet.add(coin.exchangeBaseUrl); + exchangeSet.add(pcs[i].exchangeBaseUrl); const allDenoms = await getCandidateWithdrawalDenomsTx( wex, tx, - coin.exchangeBaseUrl, + pcs[i].exchangeBaseUrl, currency, ); - const amountLeft = Amounts.sub( - denom.value, - pcs.coins[i].contribution, - ).amount; + const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; const refreshCost = getTotalRefreshCost( allDenoms, denom, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index 3b58c1e0a..25725052c 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -63,12 +63,12 @@ import { parsePayTemplateUri, parsePayUri, parseTalerUri, - PayCoinSelection, PreparePayResult, PreparePayResultType, PreparePayTemplateRequest, randomBytes, RefreshReason, + SelectedProspectiveCoin, SharePaymentResult, StartRefundQueryForUriResponse, stringifyPayUri, @@ -453,19 +453,15 @@ export class RefundTransactionContext implements TransactionContext { */ export async function getTotalPaymentCost( wex: WalletExecutionContext, - pcs: PayCoinSelection, + currency: string, + pcs: SelectedProspectiveCoin[], ): Promise { - const currency = Amounts.currencyOf(pcs.customerDepositFees); return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { const costs: AmountJson[] = []; - for (let i = 0; i < pcs.coins.length; i++) { - const coin = await tx.coins.get(pcs.coins[i].coinPub); - if (!coin) { - throw Error("can't calculate payment cost, coin not found"); - } + for (let i = 0; i < pcs.length; i++) { const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, ]); if (!denom) { throw Error( @@ -475,23 +471,20 @@ export async function getTotalPaymentCost( const allDenoms = await getCandidateWithdrawalDenomsTx( wex, tx, - coin.exchangeBaseUrl, + pcs[i].exchangeBaseUrl, currency, ); - const amountLeft = Amounts.sub( - denom.value, - pcs.coins[i].contribution, - ).amount; + const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; const refreshCost = getTotalRefreshCost( allDenoms, DenominationRecord.toDenomInfo(denom), amountLeft, wex.ws.config.testing.denomselAllowLate, ); - costs.push(Amounts.parseOrThrow(pcs.coins[i].contribution)); + costs.push(Amounts.parseOrThrow(pcs[i].contribution)); costs.push(refreshCost); } - const zero = Amounts.zeroOfAmount(pcs.customerDepositFees); + const zero = Amounts.zeroOfCurrency(currency); return Amounts.sum([zero, ...costs]).amount; }); } @@ -1256,6 +1249,8 @@ async function checkPaymentByProposalId( proposalId = proposal.proposalId; + const currency = Amounts.currencyOf(contractData.amount); + const ctx = new PayMerchantTransactionContext(wex, proposalId); const transactionId = ctx.transactionId; @@ -1293,23 +1288,37 @@ async function checkPaymentByProposalId( restrictWireMethod: contractData.wireMethod, }); - if (res.type !== "success") { - logger.info("not allowing payment, insufficient coins"); - logger.info( - `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`, - ); - return { - status: PreparePayResultType.InsufficientBalance, - contractTerms: d.contractTermsRaw, - proposalId: proposal.proposalId, - transactionId, - amountRaw: Amounts.stringify(d.contractData.amount), - talerUri, - balanceDetails: res.insufficientBalanceDetails, - }; + switch (res.type) { + case "failure": { + logger.info("not allowing payment, insufficient coins"); + logger.info( + `insufficient balance details: ${j2s( + res.insufficientBalanceDetails, + )}`, + ); + return { + status: PreparePayResultType.InsufficientBalance, + contractTerms: d.contractTermsRaw, + proposalId: proposal.proposalId, + transactionId, + amountRaw: Amounts.stringify(d.contractData.amount), + talerUri, + balanceDetails: res.insufficientBalanceDetails, + }; + } + case "prospective": + throw Error("insufficient balance (waiting on refresh)"); + case "success": + break; + default: + assertUnreachable(res); } - const totalCost = await getTotalPaymentCost(wex, res.coinSel); + const totalCost = await getTotalPaymentCost( + wex, + currency, + res.coinSel.coins, + ); logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); @@ -1813,6 +1822,8 @@ export async function confirmPay( const contractData = d.contractData; + const currency = Amounts.currencyOf(contractData.amount); + const selectCoinsResult = await selectPayCoins(wex, { restrictExchanges: { auditors: [], @@ -1827,18 +1838,31 @@ export async function confirmPay( forcedSelection: forcedCoinSel, }); - logger.trace("coin selection result", selectCoinsResult); - - if (selectCoinsResult.type === "failure") { - // 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"); + switch (selectCoinsResult.type) { + case "failure": { + // 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"); + } + case "prospective": { + throw Error("insufficient balance (waiting on refresh)"); + } + case "success": + break; + default: + assertUnreachable(selectCoinsResult); } + logger.trace("coin selection result", selectCoinsResult); + const coinSelection = selectCoinsResult.coinSel; - const payCostInfo = await getTotalPaymentCost(wex, coinSelection); + const payCostInfo = await getTotalPaymentCost( + wex, + currency, + coinSelection.coins, + ); let sessionId: string | undefined; if (sessionIdOverride) { diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts index da68d7839..2cc241187 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -370,13 +370,26 @@ async function handlePurseCreationConflict( } } - const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); + const coinSelRes = await selectPeerCoins(ws, { + instructedAmount, + repair, + includePendingCoins: false, + }); - if (coinSelRes.type == "failure") { - // FIXME: Details! - throw Error( - "insufficient balance to re-select coins to repair double spending", - ); + switch (coinSelRes.type) { + case "failure": + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + case "prospective": + throw Error( + "insufficient balance to re-select coins to repair double spending (blocked on refresh)", + ); + case "success": + break; + default: + assertUnreachable(coinSelRes); } const totalAmount = await getTotalPeerPaymentCost( @@ -583,18 +596,30 @@ export async function confirmPeerPullDebit( const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); - const coinSelRes = await selectPeerCoins(wex, { instructedAmount }); + // FIXME: Select coins once with pending coins, once without. + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + includePendingCoins: false, + }); if (logger.shouldLogTrace()) { logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); } - if (coinSelRes.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("insufficient balance (blocked on refresh)"); + case "success": + break; + default: + assertUnreachable(coinSelRes); } const sel = coinSelRes.result; @@ -758,18 +783,28 @@ export async function preparePeerPullDebit( const instructedAmount = Amounts.parseOrThrow(contractTerms.amount); - const coinSelRes = await selectPeerCoins(wex, { instructedAmount }); + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + includePendingCoins: true, + }); if (logger.shouldLogTrace()) { logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); } - if (coinSelRes.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("insufficient balance (waiting on refresh)"); + case "success": + break; + default: + assertUnreachable(coinSelRes); } const totalAmount = await getTotalPeerPaymentCost( diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts index 20001e040..51b865b99 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -343,14 +343,24 @@ export async function checkPeerPushDebit( logger.trace( `checking peer push debit for ${Amounts.stringify(instructedAmount)}`, ); - const coinSelRes = await selectPeerCoins(wex, { instructedAmount }); - if (coinSelRes.type === "failure") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + includePendingCoins: true, + }); + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("not supported"); + case "success": + break; + default: + assertUnreachable(coinSelRes); } logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`); const totalAmount = await getTotalPeerPaymentCost( @@ -402,13 +412,23 @@ async function handlePurseCreationConflict( } } - const coinSelRes = await selectPeerCoins(wex, { instructedAmount, repair }); + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + repair, + includePendingCoins: false, + }); - if (coinSelRes.type == "failure") { - // FIXME: Details! - throw Error( - "insufficient balance to re-select coins to repair double spending", - ); + switch (coinSelRes.type) { + case "failure": + case "prospective": + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + case "success": + break; + default: + assertUnreachable(coinSelRes); } await wex.db.runReadWriteTx(["peerPushDebit"], async (tx) => { @@ -934,15 +954,26 @@ export async function initiatePeerPushDebit( const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({}); - const coinSelRes = await selectPeerCoins(wex, { instructedAmount }); + // FIXME: Check first if possible with pending coins, in that case defer coin selection + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + includePendingCoins: false, + }); - if (coinSelRes.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("blocked on pending refresh"); + case "success": + break; + default: + assertUnreachable(coinSelRes); } const sel = coinSelRes.result; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index f531c32a3..eb981e79c 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -188,7 +188,7 @@ import { import { createDepositGroup, generateDepositGroupTxId, - prepareDepositGroup, + checkDepositGroup, } from "./deposits.js"; import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { @@ -1191,7 +1191,7 @@ async function dispatchRequestInternal( } case WalletApiOperation.PrepareDeposit: { const req = codecForPrepareDepositRequest().decode(payload); - return await prepareDepositGroup(wex, req); + return await checkDepositGroup(wex, req); } case WalletApiOperation.GenerateDepositGroupTxId: return { -- cgit v1.2.3