diff options
author | Florian Dold <florian@dold.me> | 2024-04-04 20:48:19 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-04-04 20:48:26 +0200 |
commit | ab724bdbd2059484335211662b63a9ae415a270c (patch) | |
tree | dd96150dae760dd4cfc2068c7ba990277137200e | |
parent | 58323fc496d0fe2a34884b289860264ffd1310e8 (diff) | |
download | wallet-core-ab724bdbd2059484335211662b63a9ae415a270c.tar.xz |
wallet-core: allow peer-pull with coins locked behind refresh
3 files changed, 293 insertions, 31 deletions
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts new file mode 100644 index 000000000..36a6fea05 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts @@ -0,0 +1,177 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AmountString, + Duration, + NotificationType, + TransactionMajorState, + TransactionMinorState, + TransactionType, + 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"; +import { + createSimpleTestkudosEnvironmentV2, + createWalletDaemonWithClient, + makeTestPaymentV2, + withdrawViaBankV2, +} from "../harness/helpers.js"; + +const coinCommon = { + cipher: "RSA" as const, + durationLegal: "3 years", + durationSpend: "2 years", + durationWithdraw: "7 days", + feeDeposit: "TESTKUDOS:0", + feeRefresh: "TESTKUDOS:0", + feeRefund: "TESTKUDOS:0", + feeWithdraw: "TESTKUDOS:0", + rsaKeySize: 1024, +}; + +/** + * Run test for a peer push payment with balance locked behind a pending refresh. + */ +export async function runWalletBlockedPayPeerPullTest(t: GlobalTestState) { + // Set up test environment + + const coinConfigList: CoinConfig[] = [ + { + ...coinCommon, + name: "n1", + value: "TESTKUDOS:1", + }, + { + ...coinCommon, + name: "n5", + value: "TESTKUDOS:5", + }, + ]; + + const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2( + t, + coinConfigList, + ); + + // Withdraw digital cash into the wallet. + + const { walletClient: w1 } = await createWalletDaemonWithClient(t, { + name: "w1", + persistent: true, + config: { + testing: { + devModeActive: true, + }, + }, + }); + + const { walletClient: w2 } = await createWalletDaemonWithClient(t, { + name: "w2", + persistent: true, + config: { + testing: { + devModeActive: true, + }, + }, + }); + + await withdrawViaBankV2(t, { + walletClient: w1, + bank, + exchange, + amount: "TESTKUDOS:20", + }); + + await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + // Prevent the wallet from doing refreshes by injecting a 5xx + // status for all refresh requests. + await w1.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/start-block-refresh", + }); + + // Do a payment that causes a refresh. + await makeTestPaymentV2(t, { + merchant, + walletClient: w1, + order: { + summary: "test", + amount: "TESTKUDOS:2", + }, + }); + + await w2.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: exchange.baseUrl, + }); + + const pullCreditReadyCond = w2.waitForNotificationCond((n) => { + return ( + n.type === NotificationType.TransactionStateTransition && + n.transactionId.startsWith("txn:peer-pull-credit:") && + n.newTxState.major === TransactionMajorState.Pending && + n.newTxState.minor === TransactionMinorState.Ready + ); + }); + + const initResp = await w2.call(WalletApiOperation.InitiatePeerPullCredit, { + partialContractTerms: { + summary: "hi!", + amount: "TESTKUDOS:18" as AmountString, + purse_expiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ hours: 1 }), + ), + ), + }, + }); + + await pullCreditReadyCond; + + const initTx = await w2.call(WalletApiOperation.GetTransactionById, { + transactionId: initResp.transactionId, + }); + + t.assertDeepEqual(initTx.type, TransactionType.PeerPullCredit); + t.assertTrue(!!initTx.talerUri); + + const checkResp = await w1.call(WalletApiOperation.PreparePeerPullDebit, { + talerUri: initTx.talerUri, + }); + + console.log(`check resp ${j2s(checkResp)}`); + + const confirmResp = await w1.call(WalletApiOperation.ConfirmPeerPullDebit, { + transactionId: checkResp.transactionId, + }); + + console.log(`confirm resp ${j2s(confirmResp)}`); + + await w1.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/stop-block-refresh", + }); + + await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); +} + +runWalletBlockedPayPeerPullTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index acc9f5e29..9841cb37b 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -92,6 +92,7 @@ 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 { runWalletBlockedPayPeerPullTest } from "./test-wallet-blocked-pay-peer-pull.js"; import { runWalletBlockedPayPeerPushTest } from "./test-wallet-blocked-pay-peer-push.js"; import { runWalletCliTerminationTest } from "./test-wallet-cli-termination.js"; import { runWalletConfigTest } from "./test-wallet-config.js"; @@ -218,6 +219,7 @@ const allTests: TestMainFunction[] = [ runWalletBlockedDepositTest, runWalletBlockedPayMerchantTest, runWalletBlockedPayPeerPushTest, + runWalletBlockedPayPeerPullTest, ]; export interface TestRunSpec { 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 9bfa14ca2..705317eb6 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -37,6 +37,7 @@ import { PreparePeerPullDebitRequest, PreparePeerPullDebitResponse, RefreshReason, + SelectedProspectiveCoin, TalerError, TalerErrorCode, TalerPreciseTimestamp, @@ -427,8 +428,88 @@ async function processPeerPullDebitPendingDeposit( const pursePub = peerPullInc.pursePub; const coinSel = peerPullInc.coinSel; + if (!coinSel) { - throw Error("invalid state, no coins selected"); + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("insufficient balance (locked behind refresh)"); + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const peerPullDebitId = peerPullInc.peerPullDebitId; + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + // FIXME: Missing notification here! + + const transitionDone = await wex.db.runReadWriteTx( + [ + "exchanges", + "coins", + "denominations", + "refreshGroups", + "refreshSessions", + "peerPullDebit", + "coinAvailability", + ], + async (tx) => { + const pi = await tx.peerPullDebit.get(peerPullDebitId); + if (!pi) { + return false; + } + if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return false; + } + if (pi.coinSel) { + return false; + } + await spendCoins(wex, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + pi.coinSel = { + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + await tx.peerPullDebit.put(pi); + return true; + }, + ); + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } } const coins = await queryCoinInfosForSelection(wex, coinSel); @@ -595,8 +676,6 @@ export async function confirmPeerPullDebit( const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); - // FIXME: Select coins once with pending coins, once without. - const coinSelRes = await selectPeerCoins(wex, { instructedAmount, }); @@ -604,6 +683,8 @@ export async function confirmPeerPullDebit( logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); } + let coins: SelectedProspectiveCoin[] | undefined = undefined; + switch (coinSelRes.type) { case "failure": throw TalerError.fromDetail( @@ -613,19 +694,18 @@ export async function confirmPeerPullDebit( }, ); case "prospective": - throw Error("insufficient balance (blocked on refresh)"); + coins = coinSelRes.result.prospectiveCoins; + break; case "success": + coins = coinSelRes.result.coins; break; default: assertUnreachable(coinSelRes); } - const sel = coinSelRes.result; + const totalAmount = await getTotalPeerPaymentCost(wex, coins); - const totalAmount = await getTotalPeerPaymentCost( - wex, - coinSelRes.result.coins, - ); + // FIXME: Missing notification here! await wex.db.runReadWriteTx( [ @@ -638,31 +718,33 @@ export async function confirmPeerPullDebit( "coinAvailability", ], async (tx) => { - await spendCoins(wex, tx, { - // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, - allocationId: constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }), - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayPeerPull, - }); - const pi = await tx.peerPullDebit.get(peerPullDebitId); if (!pi) { throw Error(); } - if (pi.status === PeerPullDebitRecordStatus.DialogProposed) { - pi.status = PeerPullDebitRecordStatus.PendingDeposit; + if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) { + return; + } + if (coinSelRes.type == "success") { + await spendCoins(wex, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); pi.coinSel = { - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => x.contribution), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), totalCost: Amounts.stringify(totalAmount), }; } + pi.status = PeerPullDebitRecordStatus.PendingDeposit; await tx.peerPullDebit.put(pi); }, ); @@ -788,6 +870,8 @@ export async function preparePeerPullDebit( logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); } + let coins: SelectedProspectiveCoin[] | undefined = undefined; + switch (coinSelRes.type) { case "failure": throw TalerError.fromDetail( @@ -797,17 +881,16 @@ export async function preparePeerPullDebit( }, ); case "prospective": - throw Error("insufficient balance (waiting on refresh)"); + coins = coinSelRes.result.prospectiveCoins; + break; case "success": + coins = coinSelRes.result.coins; break; default: assertUnreachable(coinSelRes); } - const totalAmount = await getTotalPeerPaymentCost( - wex, - coinSelRes.result.coins, - ); + const totalAmount = await getTotalPeerPaymentCost(wex, coins); await wex.db.runReadWriteTx( ["peerPullDebit", "contractTerms"], |