summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-04-04 20:48:19 +0200
committerFlorian Dold <florian@dold.me>2024-04-04 20:48:26 +0200
commitab724bdbd2059484335211662b63a9ae415a270c (patch)
treedd96150dae760dd4cfc2068c7ba990277137200e /packages
parent58323fc496d0fe2a34884b289860264ffd1310e8 (diff)
downloadwallet-core-ab724bdbd2059484335211662b63a9ae415a270c.tar.gz
wallet-core-ab724bdbd2059484335211662b63a9ae415a270c.tar.bz2
wallet-core-ab724bdbd2059484335211662b63a9ae415a270c.zip
wallet-core: allow peer-pull with coins locked behind refresh
Diffstat (limited to 'packages')
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts177
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts2
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts145
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"],