From fb3da3a28d6ed6a16ca7d0fa8ec775de51c7df6b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 11 Mar 2021 13:08:41 +0100 Subject: [PATCH] towards recovering from accidental double spends --- .../test-wallet-backup-doublespend.ts | 140 ++++++++++++++++++ .../src/integrationtests/testrunner.ts | 2 + .../taler-wallet-core/src/operations/pay.ts | 44 ++++++ .../src/types/backupTypes.ts | 2 +- .../taler-wallet-core/src/types/dbTypes.ts | 35 ++++- 5 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts new file mode 100644 index 000000000..94cad7510 --- /dev/null +++ b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts @@ -0,0 +1,140 @@ +/* + 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 + */ + +/** + * Imports. + */ +import { PreparePayResultType } from "@gnu-taler/taler-wallet-core"; +import { testPay } from "@gnu-taler/taler-wallet-core/src/operations/testing"; +import { + GlobalTestState, + BankApi, + BankAccessApi, + WalletCli, + MerchantPrivateApi, +} from "./harness"; +import { + createSimpleTestkudosEnvironment, + makeTestPayment, + withdrawViaBank, +} from "./helpers"; +import { SyncService } from "./sync"; + +/** + * Run test for basic, bank-integrated withdrawal. + */ +export async function runWalletBackupDoublespendTest(t: GlobalTestState) { + // Set up test environment + + const { + commonDb, + merchant, + wallet, + bank, + exchange, + } = await createSimpleTestkudosEnvironment(t); + + const sync = await SyncService.create(t, { + currency: "TESTKUDOS", + annualFee: "TESTKUDOS:0.5", + database: commonDb.connStr, + fulfillmentUrl: "taler://fulfillment-success", + httpPort: 8089, + name: "sync1", + paymentBackendUrl: merchant.makeInstanceBaseUrl(), + uploadLimitMb: 10, + }); + + await sync.start(); + await sync.pingUntilAvailable(); + + await wallet.addBackupProvider({ + backupProviderBaseUrl: sync.baseUrl, + activate: true, + }); + + await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:10" }); + + await wallet.runBackupCycle(); + await wallet.runUntilDone(); + await wallet.runBackupCycle(); + + const backupRecovery = await wallet.exportBackupRecovery(); + + const wallet2 = new WalletCli(t, "wallet2"); + + await wallet2.importBackupRecovery({ recovery: backupRecovery }); + + await wallet2.runBackupCycle(); + + console.log("wallet1 balance before spend:", await wallet.getBalances()); + + await makeTestPayment(t, { + merchant, + wallet, + order: { + summary: "foo", + amount: "TESTKUDOS:7", + }, + }); + + await wallet.runUntilDone(); + + console.log("wallet1 balance after spend:", await wallet.getBalances()); + + { + console.log("wallet2 balance:", await wallet2.getBalances()); + } + + // Now we double-spend with the second wallet + + { + const instance = "default"; + + const orderResp = await MerchantPrivateApi.createOrder(merchant, instance, { + order: { + amount: "TESTKUDOS:8", + summary: "bla", + fulfillment_url: "taler://fulfillment-success", + }, + }); + + let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus( + merchant, + { + orderId: orderResp.order_id, + }, + ); + + t.assertTrue(orderStatus.order_status === "unpaid"); + + // Make wallet pay for the order + + const preparePayResult = await wallet2.preparePay({ + talerPayUri: orderStatus.taler_pay_uri, + }); + + t.assertTrue( + preparePayResult.status === PreparePayResultType.PaymentPossible, + ); + + const res = await wallet2.confirmPay({ + proposalId: preparePayResult.proposalId, + }); + + console.log(res); + } +} diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts index 50850d6df..9f1edbd62 100644 --- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts +++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts @@ -63,6 +63,7 @@ import { runMerchantInstancesTest } from "./test-merchant-instances"; import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls"; import { runWalletBackupBasicTest } from "./test-wallet-backup-basic"; import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete"; +import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend"; /** * Test runner. @@ -111,6 +112,7 @@ const allTests: TestMainFunction[] = [ runTimetravelWithdrawTest, runTippingTest, runWalletBackupBasicTest, + runWalletBackupDoublespendTest, runWallettestingTest, runWithdrawalAbortBankTest, runWithdrawalBankIntegratedTest, diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index 03bf9e119..3add9bbbf 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -84,6 +84,8 @@ import { throwUnexpectedRequestError, getHttpResponseErrorDetails, readSuccessResponseJsonOrErrorCode, + HttpResponseStatus, + readTalerErrorResponse, } from "../util/http"; import { TalerErrorCode } from "../TalerErrorCode"; import { URL } from "../util/url"; @@ -1001,6 +1003,22 @@ async function storePayReplaySuccess( }); } +/** + * Handle a 409 Conflict response from the merchant. + * + * We do this by going through the coin history provided by the exchange and + * (1) verifying the signatures from the exchange + * (2) adjusting the remaining coin value + * (3) re-do coin selection. + */ +async function handleInsufficientFunds( + ws: InternalWalletState, + proposalId: string, + err: TalerErrorDetails, +): Promise { + throw Error("payment re-denomination not implemented yet"); +} + /** * Submit a payment to the merchant. * @@ -1078,6 +1096,32 @@ async function submitPay( }; } + if (resp.status === HttpResponseStatus.Conflict) { + const err = await readTalerErrorResponse(resp); + if ( + err.code === + TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS + ) { + // Do this in the background, as it might take some time + handleInsufficientFunds(ws, proposalId, err).catch(async (e) => { + await incrementProposalRetry(ws, proposalId, { + code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + message: "unexpected exception", + hint: "unexpected exception", + details: { + exception: e, + }, + }); + }); + + return { + type: ConfirmPayResultType.Pending, + // FIXME: should we return something better here? + lastError: err, + }; + } + } + const merchantResp = await readSuccessResponseJsonOrThrow( resp, codecForMerchantPayResponse(), diff --git a/packages/taler-wallet-core/src/types/backupTypes.ts b/packages/taler-wallet-core/src/types/backupTypes.ts index d4b1625f6..7e6ceb04c 100644 --- a/packages/taler-wallet-core/src/types/backupTypes.ts +++ b/packages/taler-wallet-core/src/types/backupTypes.ts @@ -21,7 +21,7 @@ * as the backup schema must remain very stable and should be self-contained. * * Future: - * 1. Ghost spends (coin unexpectedly spend by a wallet with shared data) + * 1. Ghost spends (coin unexpectedly spent by a wallet with shared data) * 2. Ghost withdrawals (reserve unexpectedly emptied by another wallet with shared data) * 3. Track losses through re-denomination of payments/refreshes * 4. (Feature:) Payments to own bank account and P2P-payments need to be backed up diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index 6972744a3..6c37971ad 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -1464,14 +1464,14 @@ export interface BackupProviderRecord { /** * Proposal that we're currently trying to pay for. - * + * * (Also included in paymentProposalIds.) */ currentPaymentProposalId?: string; /** * Proposals that were used to pay (or attempt to pay) the provider. - * + * * Stored to display a history of payments to the provider, and * to make sure that the wallet isn't overpaying. */ @@ -1541,6 +1541,31 @@ export interface DepositGroupRecord { retryInfo: RetryInfo; } +/** + * Record for a deposits that the wallet observed + * as a result of double spending, but which is not + * present in the wallet's own database otherwise. + */ +export interface GhostDepositGroupRecord { + /** + * When multiple deposits for the same contract terms hash + * have a different timestamp, we choose the earliest one. + */ + timestamp: Timestamp; + + contractTermsHash: string; + + deposits: { + coinPub: string; + amount: AmountString; + timestamp: Timestamp; + depositFee: AmountString; + merchantPub: string; + coinSig: string; + wireHash: string; + }[]; +} + class ExchangesStore extends Store<"exchanges", ExchangeRecord> { constructor() { super("exchanges", { keyPath: "baseUrl" }); @@ -1750,6 +1775,12 @@ export const Stores = { bankWithdrawUris: new BankWithdrawUrisStore(), backupProviders: new BackupProvidersStore(), depositGroups: new DepositGroupsStore(), + ghostDepositGroups: new Store<"ghostDepositGroups", GhostDepositGroupRecord>( + "ghostDepositGroups", + { + keyPath: "contractTermsHash", + }, + ), }; export class MetaConfigStore extends Store<"metaConfig", ConfigRecord> {