From cf31b263ae249979cea8d64ee524cfff4b50f04b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 4 Apr 2024 14:48:29 +0200 Subject: wallet-core: allow payments to merchant with coins locked behind refresh --- .../test-wallet-blocked-deposit.ts | 6 +- .../src/integrationtests/testrunner.ts | 6 +- packages/taler-wallet-core/src/db.ts | 10 +- packages/taler-wallet-core/src/pay-merchant.ts | 193 +++++++++++++++++---- packages/taler-wallet-core/src/testing.ts | 2 +- packages/taler-wallet-core/src/transactions.ts | 7 +- 6 files changed, 178 insertions(+), 46 deletions(-) diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts index cb9c54f1d..69b721789 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts @@ -49,7 +49,7 @@ const coinCommon = { /** * Run test for refreshe after a payment. */ -export async function runWalletBlockedDeposit(t: GlobalTestState) { +export async function runWalletBlockedDepositTest(t: GlobalTestState) { // Set up test environment const coinConfigList: CoinConfig[] = [ @@ -114,8 +114,6 @@ export async function runWalletBlockedDeposit(t: GlobalTestState) { }); console.log(`balance details: ${j2s(balDet)}`); - // FIXME: Now check deposit/p2p/pay - const depositCheckResp = await w1.call(WalletApiOperation.PrepareDeposit, { amount: "TESTKUDOS:18" as AmountString, depositPaytoUri: userPayto, @@ -149,4 +147,4 @@ export async function runWalletBlockedDeposit(t: GlobalTestState) { await depositTrackCond; } -runWalletBlockedDeposit.suites = ["wallet"]; +runWalletBlockedDepositTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index 063aefa43..2934e36e3 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -90,6 +90,8 @@ import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend import { runWalletBalanceNotificationsTest } from "./test-wallet-balance-notifications.js"; import { runWalletBalanceZeroTest } from "./test-wallet-balance-zero.js"; import { runWalletBalanceTest } from "./test-wallet-balance.js"; +import { runWalletBlockedDepositTest } from "./test-wallet-blocked-deposit.js"; +import { runWalletBlockedPayMerchantTest } from "./test-wallet-blocked-pay-merchant.js"; import { runWalletCliTerminationTest } from "./test-wallet-cli-termination.js"; import { runWalletConfigTest } from "./test-wallet-config.js"; import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js"; @@ -101,7 +103,6 @@ import { runWalletGenDbTest } from "./test-wallet-gendb.js"; import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js"; import { runWalletNotificationsTest } from "./test-wallet-notifications.js"; import { runWalletObservabilityTest } from "./test-wallet-observability.js"; -import { runWalletBlockedDeposit } from "./test-wallet-blocked-deposit.js"; import { runWalletRefreshTest } from "./test-wallet-refresh.js"; import { runWalletWirefeesTest } from "./test-wallet-wirefees.js"; import { runWallettestingTest } from "./test-wallettesting.js"; @@ -213,7 +214,8 @@ const allTests: TestMainFunction[] = [ runWalletWirefeesTest, runDenomLostTest, runWalletDenomExpireTest, - runWalletBlockedDeposit, + runWalletBlockedDepositTest, + runWalletBlockedPayMerchantTest, ]; export interface TestRunSpec { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 7b9dfa2a2..a675fa8dd 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1226,9 +1226,15 @@ export interface DbCoinSelection { } export interface PurchasePayInfo { - payCoinSelection: DbCoinSelection; + /** + * Undefined if payment is blocked by a pending refund. + */ + payCoinSelection?: DbCoinSelection; + /** + * Undefined if payment is blocked by a pending refund. + */ + payCoinSelectionUid?: string; totalPayCost: AmountString; - payCoinSelectionUid: string; } /** diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index 25725052c..a2089d843 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -291,7 +291,7 @@ export class PayMerchantTransactionContext implements TransactionContext { case PurchaseStatus.PendingPaying: case PurchaseStatus.SuspendedPaying: { purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; - if (purchase.payInfo) { + if (purchase.payInfo && purchase.payInfo.payCoinSelection) { const coinSel = purchase.payInfo.payCoinSelection; const currency = Amounts.currencyOf( purchase.payInfo.totalPayCost, @@ -519,7 +519,7 @@ async function failProposalPermanently( function getPayRequestTimeout(purchase: PurchaseRecord): Duration { return Duration.multiply( { d_ms: 15000 }, - 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5, + 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5, ); } @@ -1130,6 +1130,9 @@ async function handleInsufficientFunds( } const payCoinSelection = payInfo.payCoinSelection; + if (!payCoinSelection) { + return; + } await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { @@ -1155,9 +1158,16 @@ async function handleInsufficientFunds( requiredMinimumAge: contractData.minimumAge, }); - if (res.type !== "success") { - logger.trace("insufficient funds for coin re-selection"); - return; + switch (res.type) { + case "failure": + logger.trace("insufficient funds for coin re-selection"); + return; + case "prospective": + return; + case "success": + break; + default: + assertUnreachable(res); } logger.trace("re-selected coins"); @@ -1288,6 +1298,8 @@ async function checkPaymentByProposalId( restrictWireMethod: contractData.wireMethod, }); + let coins: SelectedProspectiveCoin[] | undefined = undefined; + switch (res.type) { case "failure": { logger.info("not allowing payment, insufficient coins"); @@ -1307,18 +1319,16 @@ async function checkPaymentByProposalId( }; } case "prospective": - throw Error("insufficient balance (waiting on refresh)"); + coins = res.result.prospectiveCoins; + break; case "success": + coins = res.coinSel.coins; break; default: assertUnreachable(res); } - const totalCost = await getTotalPaymentCost( - wex, - currency, - res.coinSel.coins, - ); + const totalCost = await getTotalPaymentCost(wex, currency, coins); logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); @@ -1838,6 +1848,8 @@ export async function confirmPay( forcedSelection: forcedCoinSel, }); + let coins: SelectedProspectiveCoin[] | undefined = undefined; + switch (selectCoinsResult.type) { case "failure": { // Should not happen, since checkPay should be called first @@ -1847,9 +1859,11 @@ export async function confirmPay( throw Error("insufficient balance"); } case "prospective": { - throw Error("insufficient balance (waiting on refresh)"); + coins = selectCoinsResult.result.prospectiveCoins; + break; } case "success": + coins = selectCoinsResult.coinSel.coins; break; default: assertUnreachable(selectCoinsResult); @@ -1857,12 +1871,7 @@ export async function confirmPay( logger.trace("coin selection result", selectCoinsResult); - const coinSelection = selectCoinsResult.coinSel; - const payCostInfo = await getTotalPaymentCost( - wex, - currency, - coinSelection.coins, - ); + const payCostInfo = await getTotalPaymentCost(wex, currency, coins); let sessionId: string | undefined; if (sessionIdOverride) { @@ -1894,29 +1903,37 @@ export async function confirmPay( case PurchaseStatus.DialogShared: case PurchaseStatus.DialogProposed: p.payInfo = { - payCoinSelection: { - coinContributions: coinSelection.coins.map((x) => x.contribution), - coinPubs: coinSelection.coins.map((x) => x.coinPub), - }, - payCoinSelectionUid: encodeCrock(getRandomBytes(16)), totalPayCost: Amounts.stringify(payCostInfo), }; + if (selectCoinsResult.type === "success") { + p.payInfo.payCoinSelection = { + coinContributions: selectCoinsResult.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + }; + p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16)); + } p.lastSessionId = sessionId; p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); p.purchaseStatus = PurchaseStatus.PendingPaying; await tx.purchases.put(p); - await spendCoins(wex, tx, { - //`txn:proposal:${p.proposalId}` - allocationId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: proposalId, - }), - coinPubs: coinSelection.coins.map((x) => x.coinPub), - contributions: coinSelection.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayMerchant, - }); + if (p.payInfo.payCoinSelection) { + const sel = p.payInfo.payCoinSelection; + await spendCoins(wex, tx, { + //`txn:proposal:${p.proposalId}` + allocationId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: proposalId, + }), + coinPubs: sel.coinPubs, + contributions: sel.coinContributions.map((x) => + Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayMerchant, + }); + } + break; case PurchaseStatus.Done: case PurchaseStatus.PendingPaying: @@ -2030,6 +2047,8 @@ async function processPurchasePay( } logger.trace(`processing purchase pay ${proposalId}`); + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const sessionId = purchase.lastSessionId; logger.trace(`paying with session ID ${sessionId}`); @@ -2078,6 +2097,109 @@ async function processPurchasePay( } } + const contractData = download.contractData; + const currency = Amounts.currencyOf(download.contractData.amount); + + if (!payInfo.payCoinSelection) { + const selectCoinsResult = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + wireFeeAmortization: 1, // FIXME #8653 + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + }); + 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 (pending refresh)"); + } + case "success": + break; + default: + assertUnreachable(selectCoinsResult); + } + + logger.trace("coin selection result", selectCoinsResult); + + const payCostInfo = await getTotalPaymentCost( + wex, + currency, + selectCoinsResult.coinSel.coins, + ); + + const transitionDone = await wex.db.runReadWriteTx( + [ + "purchases", + "coins", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + ], + async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return false; + } + if (p.payInfo?.payCoinSelection) { + return false; + } + switch (p.purchaseStatus) { + case PurchaseStatus.DialogShared: + case PurchaseStatus.DialogProposed: + p.payInfo = { + totalPayCost: Amounts.stringify(payCostInfo), + payCoinSelection: { + coinContributions: selectCoinsResult.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + }, + }; + p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16)); + p.purchaseStatus = PurchaseStatus.PendingPaying; + await tx.purchases.put(p); + const sel = p.payInfo.payCoinSelection; + await spendCoins(wex, tx, { + //`txn:proposal:${p.proposalId}` + allocationId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: proposalId, + }), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + contributions: selectCoinsResult.coinSel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayMerchant, + }); + return true; + case PurchaseStatus.Done: + case PurchaseStatus.PendingPaying: + default: + break; + } + return false; + }, + ); + + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } + } + if (!purchase.merchantPaySig) { const payUrl = new URL( `orders/${download.contractData.orderId}/pay`, @@ -2132,6 +2254,7 @@ async function processPurchasePay( TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS ) { // Do this in the background, as it might take some time + // FIXME: Why? We're already in a (background) task! handleInsufficientFunds(wex, proposalId, err).catch(async (e) => { logger.error("handling insufficient funds failed"); logger.error(`${e.toString()}`); diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts index b192e7b70..32c0765b4 100644 --- a/packages/taler-wallet-core/src/testing.ts +++ b/packages/taler-wallet-core/src/testing.ts @@ -912,6 +912,6 @@ export async function testPay( }); checkLogicInvariant(!!purchase); return { - numCoins: purchase.payInfo?.payCoinSelection.coinContributions.length ?? 0, + numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0, }; } diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index 463aa97ba..536f0de4b 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -1264,7 +1264,10 @@ export async function getTransactions( const exchangesInTx: string[] = []; const p = await tx.purchases.get(refundGroup.proposalId); - if (!p || !p.payInfo) return; //refund with no payment + if (!p || !p.payInfo || !p.payInfo.payCoinSelection) { + //refund with no payment + return; + } // FIXME: This is very slow, should become obsolete with materialized transactions. for (const cp of p.payInfo.payCoinSelection.coinPubs) { @@ -1410,7 +1413,7 @@ export async function getTransactions( } const exchangesInTx: string[] = []; - for (const cp of purchase.payInfo.payCoinSelection.coinPubs) { + for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) { const c = await tx.coins.get(cp); if (c?.exchangeBaseUrl) { exchangesInTx.push(c.exchangeBaseUrl); -- cgit v1.2.3