From 16a5bb40834c01e50e84144bb644517e67a66187 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 20 Sep 2022 21:44:21 +0200 Subject: [PATCH] wallet-core: make basic backup work again --- packages/taler-util/src/backupTypes.ts | 291 +++++++++--------- packages/taler-util/src/transactionsTypes.ts | 17 +- packages/taler-wallet-cli/src/index.ts | 20 ++ .../test-wallet-backup-basic.ts | 31 +- .../test-wallet-backup-doublespend.ts | 20 +- packages/taler-wallet-core/src/db-utils.ts | 27 ++ packages/taler-wallet-core/src/db.ts | 50 ++- .../src/operations/backup/export.ts | 93 ++++-- .../src/operations/backup/import.ts | 89 ++++++ .../src/operations/backup/index.ts | 10 +- .../src/operations/withdraw.ts | 7 +- packages/taler-wallet-core/src/util/query.ts | 65 +++- .../taler-wallet-core/src/wallet-api-types.ts | 10 + packages/taler-wallet-core/src/wallet.ts | 16 +- 14 files changed, 533 insertions(+), 213 deletions(-) diff --git a/packages/taler-util/src/backupTypes.ts b/packages/taler-util/src/backupTypes.ts index b31a83831..90f95ce9d 100644 --- a/packages/taler-util/src/backupTypes.ts +++ b/packages/taler-util/src/backupTypes.ts @@ -47,6 +47,15 @@ * 3. Derived information is never backed up (hashed values, public keys * when we know the private key). * + * Problems: + * + * Withdrawal group fork/merging loses money: + * - Before the withdrawal happens, wallet forks into two backups. + * - Both wallets need to re-denominate the withdrawal (unlikely but possible). + * - Because the backup doesn't store planchets where a withdrawal was attempted, + * after merging some money will be list. + * - Fix: backup withdrawal objects also store planchets where withdrawal has been attempted + * * @author Florian Dold */ @@ -56,6 +65,23 @@ import { DenominationPubKey, UnblindedSignature } from "./talerTypes.js"; import { TalerProtocolDuration, TalerProtocolTimestamp } from "./time.js"; +export const BACKUP_TAG = "gnu-taler-wallet-backup-content" as const; +/** + * Major version. Each increment means a backwards-incompatible change. + * Typically this means that a custom converter needs to be written. + */ +export const BACKUP_VERSION_MAJOR = 1 as const; + +/** + * Minor version. Each increment means that information is added to the backup + * in a backwards-compatible way. + * + * Wallets can always import a smaller minor version than their own backup code version. + * When importing a bigger version, data loss is possible and the user should be urged to + * upgrade their wallet first. + */ +export const BACKUP_VERSION_MINOR = 1 as const; + /** * Type alias for strings that are to be treated like amounts. */ @@ -93,12 +119,14 @@ export interface WalletBackupContentV1 { /** * Magic constant to identify that this is a backup content JSON. */ - schema_id: "gnu-taler-wallet-backup-content"; + schema_id: typeof BACKUP_TAG; /** * Version of the schema. */ - schema_version: 1; + schema_version: typeof BACKUP_VERSION_MAJOR; + + minor_version: number; /** * Root public key of the wallet. This field is present as @@ -131,6 +159,13 @@ export interface WalletBackupContentV1 { exchange_details: BackupExchangeDetails[]; + /** + * Withdrawal groups. + * + * Sorted by the withdrawal group ID. + */ + withdrawal_groups: BackupWithdrawalGroup[]; + /** * Grouped refresh sessions. * @@ -208,6 +243,118 @@ export interface WalletBackupContentV1 { tombstones: Tombstone[]; } +export enum BackupOperationStatus { + Cancelled = "cancelled", + Finished = "finished", + Pending = "pending", +} + +export enum BackupWgType { + BankManual = "bank-manual", + BankIntegrated = "bank-integrated", + PeerPullCredit = "peer-pull-credit", + PeerPushCredit = "peer-push-credit", + Recoup = "recoup", +} + +export type BackupWgInfo = + | { + type: BackupWgType.BankManual; + } + | { + type: BackupWgType.BankIntegrated; + taler_withdraw_uri: string; + + /** + * URL that the user can be redirected to, and allows + * them to confirm (or abort) the bank-integrated withdrawal. + */ + confirm_url?: string; + + /** + * Exchange payto URI that the bank will use to fund the reserve. + */ + exchange_payto_uri: string; + + /** + * Time when the information about this reserve was posted to the bank. + * + * Only applies if bankWithdrawStatusUrl is defined. + * + * Set to undefined if that hasn't happened yet. + */ + timestamp_reserve_info_posted?: TalerProtocolTimestamp; + + /** + * Time when the reserve was confirmed by the bank. + * + * Set to undefined if not confirmed yet. + */ + timestamp_bank_confirmed?: TalerProtocolTimestamp; + } + | { + type: BackupWgType.PeerPullCredit; + contract_terms: any; + contract_priv: string; + } + | { + type: BackupWgType.PeerPushCredit; + contract_terms: any; + } + | { + type: BackupWgType.Recoup; + }; + +/** + * FIXME: Open questions: + * - Do we have to store the denomination selection? Why? + * (If deterministic, amount shouldn't change. Not storing it is simpler.) + */ +export interface BackupWithdrawalGroup { + withdrawal_group_id: string; + + /** + * Detailled info based on the type of withdrawal group. + */ + info: BackupWgInfo; + + secret_seed: string; + + reserve_priv: string; + + exchange_base_url: string; + + timestamp_created: TalerProtocolTimestamp; + + timestamp_finish?: TalerProtocolTimestamp; + + operation_status: BackupOperationStatus; + + instructed_amount: BackupAmountString; + + /** + * Amount including fees (i.e. the amount subtracted from the + * reserve to withdraw all coins in this withdrawal session). + * + * Note that this *includes* the amount remaining in the reserve + * that is too small to be withdrawn, and thus can't be derived + * from selectedDenoms. + */ + raw_withdrawal_amount: BackupAmountString; + + /** + * Restrict withdrawals from this reserve to this age. + */ + restrict_age?: number; + + /** + * Multiset of denominations selected for withdrawal. + */ + selected_denoms: BackupDenomSel; + + selected_denoms_uid: OperationUid; +} + /** * Tombstone in the format ":" */ @@ -619,46 +766,6 @@ export interface BackupRefreshGroup { finish_is_failure?: boolean; } -/** - * Backup information for a withdrawal group. - * - * Always part of a BackupReserve. - */ -export interface BackupWithdrawalGroup { - withdrawal_group_id: string; - - /** - * Secret seed to derive the planchets. - */ - secret_seed: string; - - /** - * When was the withdrawal operation started started? - * Timestamp in milliseconds. - */ - timestamp_created: TalerProtocolTimestamp; - - timestamp_finish?: TalerProtocolTimestamp; - finish_is_failure?: boolean; - - /** - * Amount including fees (i.e. the amount subtracted from the - * reserve to withdraw all coins in this withdrawal session). - * - * Note that this *includes* the amount remaining in the reserve - * that is too small to be withdrawn, and thus can't be derived - * from selectedDenoms. - */ - raw_withdrawal_amount: BackupAmountString; - - /** - * Multiset of denominations selected for withdrawal. - */ - selected_denoms: BackupDenomSel; - - selected_denoms_id: OperationUid; -} - export enum BackupRefundState { Failed = "failed", Applied = "applied", @@ -914,101 +1021,6 @@ export type BackupDenomSel = { count: number; }[]; -export interface BackupReserve { - /** - * The reserve private key. - */ - reserve_priv: string; - - /** - * Time when the reserve was created. - */ - timestamp_created: TalerProtocolTimestamp; - - /** - * Timestamp of the last observed activity. - * - * Used to compute when to give up querying the exchange. - */ - timestamp_last_activity: TalerProtocolTimestamp; - - /** - * Timestamp of when the reserve closed. - * - * Note that the last activity can be after the closing time - * due to recouping. - */ - timestamp_closed?: TalerProtocolTimestamp; - - /** - * Wire information (as payto URI) for the bank account that - * transferred funds for this reserve. - */ - sender_wire?: string; - - /** - * Amount that was sent by the user to fund the reserve. - */ - instructed_amount: BackupAmountString; - - /** - * Extra state for when this is a withdrawal involving - * a Taler-integrated bank. - */ - bank_info?: { - /** - * Status URL that the wallet will use to query the status - * of the Taler withdrawal operation on the bank's side. - */ - status_url: string; - - /** - * URL that the user should be instructed to navigate to - * in order to confirm the transfer (or show instructions/help - * on how to do that at a PoS terminal). - */ - confirm_url?: string; - - /** - * Exchange payto URI that the bank will use to fund the reserve. - */ - exchange_payto_uri: string; - - /** - * Time when the information about this reserve was posted to the bank. - */ - timestamp_reserve_info_posted: TalerProtocolTimestamp | undefined; - - /** - * Time when the reserve was confirmed by the bank. - * - * Set to undefined if not confirmed yet. - */ - timestamp_bank_confirmed: TalerProtocolTimestamp | undefined; - }; - - /** - * Pre-allocated withdrawal group ID that will be - * used for the first withdrawal. - * - * (Already created so it can be referenced in the transactions list - * before it really exists, as there'll be an entry for the withdrawal - * even before the withdrawal group really has been created). - */ - initial_withdrawal_group_id: string; - - /** - * Denominations selected for the initial withdrawal. - * Stored here to show costs before withdrawal has begun. - */ - initial_selected_denoms: BackupDenomSel; - - /** - * Groups of withdrawal operations for this reserve. Typically just one. - */ - withdrawal_groups: BackupWithdrawalGroup[]; -} - /** * Wire fee for one wire payment target type as stored in the * wallet's database. @@ -1148,11 +1160,6 @@ export interface BackupExchangeDetails { */ denominations: BackupDenomination[]; - /** - * Reserves at the exchange. - */ - reserves: BackupReserve[]; - /** * Last observed protocol version. */ diff --git a/packages/taler-util/src/transactionsTypes.ts b/packages/taler-util/src/transactionsTypes.ts index e5b0695f8..3dc4a93d7 100644 --- a/packages/taler-util/src/transactionsTypes.ts +++ b/packages/taler-util/src/transactionsTypes.ts @@ -87,10 +87,14 @@ export interface TransactionCommon { */ frozen: boolean; - // Raw amount of the transaction (exclusive of fees or other extra costs) + /** + * Raw amount of the transaction (exclusive of fees or other extra costs). + */ amountRaw: AmountString; - // Amount added or removed from the wallet's balance (including all fees and other costs) + /** + * Amount added or removed from the wallet's balance (including all fees and other costs). + */ amountEffective: AmountString; error?: TalerErrorDetail; @@ -509,10 +513,11 @@ export interface TransactionByIdRequest { transactionId: string; } -export const codecForTransactionByIdRequest = (): Codec => - buildCodecForObject() - .property("transactionId", codecForString()) - .build("TransactionByIdRequest"); +export const codecForTransactionByIdRequest = + (): Codec => + buildCodecForObject() + .property("transactionId", codecForString()) + .build("TransactionByIdRequest"); export const codecForTransactionsRequest = (): Codec => buildCodecForObject() diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 31e0b0f65..8fd0de642 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -886,6 +886,26 @@ currenciesCli }); }); +advancedCli + .subcommand("clearDatabase", "clear-database", { + help: "Clear the database, irrevocable deleting all data in the wallet.", + }) + .action(async (args) => { + await withWallet(args, async (wallet) => { + await wallet.client.call(WalletApiOperation.ClearDb, {}); + }); + }); + +advancedCli + .subcommand("recycle", "recycle", { + help: "Export, clear and re-import the database via the backup mechamism.", + }) + .action(async (args) => { + await withWallet(args, async (wallet) => { + await wallet.client.call(WalletApiOperation.Recycle, {}); + }); + }); + advancedCli .subcommand("payPrepare", "pay-prepare", { help: "Claim an order but don't pay yet.", diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts index 23e01e5e1..c82d1e650 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts @@ -17,9 +17,13 @@ /** * Imports. */ +import { j2s } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { GlobalTestState, WalletCli } from "../harness/harness.js"; -import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js"; +import { + createSimpleTestkudosEnvironment, + withdrawViaBank, +} from "../harness/helpers.js"; import { SyncService } from "../harness/sync"; /** @@ -28,13 +32,8 @@ import { SyncService } from "../harness/sync"; export async function runWalletBackupBasicTest(t: GlobalTestState) { // Set up test environment - const { - commonDb, - merchant, - wallet, - bank, - exchange, - } = await createSimpleTestkudosEnvironment(t); + const { commonDb, merchant, wallet, bank, exchange } = + await createSimpleTestkudosEnvironment(t); const sync = await SyncService.create(t, { currency: "TESTKUDOS", @@ -106,6 +105,9 @@ export async function runWalletBackupBasicTest(t: GlobalTestState) { {}, ); + const txs = await wallet.client.call(WalletApiOperation.GetTransactions, {}); + console.log(`backed up transactions ${j2s(txs)}`); + const wallet2 = new WalletCli(t, "wallet2"); // Check that the second wallet is a fresh wallet. @@ -129,6 +131,11 @@ export async function runWalletBackupBasicTest(t: GlobalTestState) { // Now do some basic checks that the restored wallet is still functional { + const txs = await wallet2.client.call( + WalletApiOperation.GetTransactions, + {}, + ); + console.log(`restored transactions ${j2s(txs)}`); const bal1 = await wallet2.client.call(WalletApiOperation.GetBalances, {}); t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:14.1"); @@ -140,8 +147,16 @@ export async function runWalletBackupBasicTest(t: GlobalTestState) { amount: "TESTKUDOS:10", }); + await exchange.runWirewatchOnce(); + await wallet2.runUntilDone(); + const txs2 = await wallet2.client.call( + WalletApiOperation.GetTransactions, + {}, + ); + console.log(`tx after withdraw after restore ${j2s(txs2)}`); + const bal2 = await wallet2.client.call(WalletApiOperation.GetBalances, {}); t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:23.82"); 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 index 8c20dcc2b..ec1d6417b 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts @@ -19,7 +19,11 @@ */ import { PreparePayResultType } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { GlobalTestState, WalletCli, MerchantPrivateApi } from "../harness/harness.js"; +import { + GlobalTestState, + WalletCli, + MerchantPrivateApi, +} from "../harness/harness.js"; import { createSimpleTestkudosEnvironment, makeTestPayment, @@ -33,13 +37,8 @@ import { SyncService } from "../harness/sync"; export async function runWalletBackupDoublespendTest(t: GlobalTestState) { // Set up test environment - const { - commonDb, - merchant, - wallet, - bank, - exchange, - } = await createSimpleTestkudosEnvironment(t); + const { commonDb, merchant, wallet, bank, exchange } = + await createSimpleTestkudosEnvironment(t); const sync = await SyncService.create(t, { currency: "TESTKUDOS", @@ -139,8 +138,9 @@ export async function runWalletBackupDoublespendTest(t: GlobalTestState) { }, ); - t.assertTrue( - preparePayResult.status === PreparePayResultType.PaymentPossible, + t.assertDeepEqual( + preparePayResult.status, + PreparePayResultType.PaymentPossible, ); const res = await wallet2.client.call(WalletApiOperation.ConfirmPay, { diff --git a/packages/taler-wallet-core/src/db-utils.ts b/packages/taler-wallet-core/src/db-utils.ts index de54719c9..b32b3d585 100644 --- a/packages/taler-wallet-core/src/db-utils.ts +++ b/packages/taler-wallet-core/src/db-utils.ts @@ -72,6 +72,33 @@ function upgradeFromStoreMap( throw Error("upgrade not supported"); } +function promiseFromTransaction(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = () => { + reject(); + }; + }); +} + +/** + * Purge all data in the given database. + */ +export function clearDatabase(db: IDBDatabase): Promise { + // db.objectStoreNames is a DOMStringList, so we need to convert + let stores: string[] = []; + for (let i = 0; i < db.objectStoreNames.length; i++) { + stores.push(db.objectStoreNames[i]); + } + const tx = db.transaction(stores, "readwrite"); + for (const store of stores) { + tx.objectStore(store).clear(); + } + return promiseFromTransaction(tx); +} + function onTalerDbUpgradeNeeded( db: IDBDatabase, oldVersion: number, diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 1275b0cf2..078060297 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (C) 2021-2022 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 @@ -49,6 +49,24 @@ import { import { RetryInfo, RetryTags } from "./util/retries.js"; import { Event, IDBDatabase } from "@gnu-taler/idb-bridge"; +/** + * This file contains the database schema of the Taler wallet together + * with some helper functions. + * + * Some design considerations: + * - By convention, each object store must have a corresponding "Record" + * interface defined for it. + * - For records that represent operations, there should be exactly + * one top-level enum field that indicates the status of the operation. + * This field should be present even if redundant, because the field + * will have an index. + * - Amounts are stored as strings, except when they are needed for + * indexing. + * - Optional fields should be avoided, use "T | undefined" instead. + * + * @author Florian Dold + */ + /** * Name of the Taler database. This is effectively the major * version of the DB schema. Whenever it changes, custom import logic @@ -76,6 +94,9 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; */ export const WALLET_DB_MINOR_VERSION = 1; +/** + * Status of a withdrawal. + */ export enum ReserveRecordStatus { /** * Reserve must be registered with the bank. @@ -293,7 +314,7 @@ export interface DenominationRecord { * Was this denomination still offered by the exchange the last time * we checked? * Only false when the exchange redacts a previously published denomination. - * + * * FIXME: Consider rolling this and isRevoked into some bitfield? */ isOffered: boolean; @@ -520,6 +541,9 @@ export interface PlanchetRecord { */ coinIdx: number; + /** + * FIXME: make this an enum! + */ withdrawalDone: boolean; lastError: TalerErrorDetail | undefined; @@ -639,6 +663,9 @@ export interface CoinRecord { /** * Amount that's left on the coin. + * + * FIXME: This is pretty redundant with "allocation" and "status". + * Do we really need this? */ currentAmount: AmountJson; @@ -716,6 +743,9 @@ export interface ProposalDownload { */ contractTermsRaw: any; + /** + * Extracted / parsed data from the contract terms. + */ contractData: WalletContractData; } @@ -780,6 +810,9 @@ export interface TipRecord { */ tipAmountRaw: AmountJson; + /** + * Effect on the balance (including fees etc). + */ tipAmountEffective: AmountJson; /** @@ -800,6 +833,9 @@ export interface TipRecord { /** * Denomination selection made by the wallet for picking up * this tip. + * + * FIXME: Put this into some DenomSelectionCacheRecord instead of + * storing it here! */ denomsSel: DenomSelectionState; @@ -889,6 +925,8 @@ export interface RefreshGroupRecord { /** * No coins are pending, but at least one is frozen. + * + * FIXME: What does this mean? */ frozen?: boolean; } @@ -1319,11 +1357,15 @@ export interface WithdrawalGroupRecord { /** * Operation status of the withdrawal group. * Used for indexing in the database. + * + * FIXME: Redundant with reserveStatus */ operationStatus: OperationStatus; /** * Current status of the reserve. + * + * FIXME: Wrong name! */ reserveStatus: ReserveRecordStatus; @@ -1756,6 +1798,10 @@ export interface CoinAvailabilityRecord { freshCoinCount: number; } +/** + * Schema definition for the IndexedDB + * wallet database. + */ export const WalletStoresV1 = { coinAvailability: describeStore( "coinAvailability", diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index 35d5e6ef7..b39e6dc27 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -25,6 +25,7 @@ * Imports. */ import { + AbsoluteTime, Amounts, BackupBackupProvider, BackupBackupProviderTerms, @@ -35,6 +36,7 @@ import { BackupExchange, BackupExchangeDetails, BackupExchangeWireFee, + BackupOperationStatus, BackupProposal, BackupProposalStatus, BackupPurchase, @@ -44,30 +46,35 @@ import { BackupRefreshSession, BackupRefundItem, BackupRefundState, - BackupReserve, BackupTip, + BackupWgInfo, + BackupWgType, BackupWithdrawalGroup, + BACKUP_VERSION_MAJOR, + BACKUP_VERSION_MINOR, canonicalizeBaseUrl, canonicalJson, - Logger, - WalletBackupContentV1, - hash, encodeCrock, getRandomBytes, + hash, + Logger, stringToBytes, - AbsoluteTime, + WalletBackupContentV1, } from "@gnu-taler/taler-util"; -import { InternalWalletState } from "../../internal-wallet-state.js"; import { AbortStatus, CoinSourceType, CoinStatus, DenominationRecord, + OperationStatus, ProposalStatus, RefreshCoinStatus, RefundState, WALLET_BACKUP_STATE_KEY, + WithdrawalRecordType, } from "../../db.js"; +import { InternalWalletState } from "../../internal-wallet-state.js"; +import { assertUnreachable } from "../../util/assertUnreachable.js"; import { getWalletBackupState, provideBackupState } from "./state.js"; const logger = new Logger("backup/export.ts"); @@ -100,31 +107,75 @@ export async function exportBackup( const backupDenominationsByExchange: { [url: string]: BackupDenomination[]; } = {}; - const backupReservesByExchange: { [url: string]: BackupReserve[] } = {}; const backupPurchases: BackupPurchase[] = []; const backupProposals: BackupProposal[] = []; const backupRefreshGroups: BackupRefreshGroup[] = []; const backupBackupProviders: BackupBackupProvider[] = []; const backupTips: BackupTip[] = []; const backupRecoupGroups: BackupRecoupGroup[] = []; - const withdrawalGroupsByReserve: { - [reservePub: string]: BackupWithdrawalGroup[]; - } = {}; + const backupWithdrawalGroups: BackupWithdrawalGroup[] = []; await tx.withdrawalGroups.iter().forEachAsync(async (wg) => { - const withdrawalGroups = (withdrawalGroupsByReserve[wg.reservePub] ??= - []); - withdrawalGroups.push({ + let info: BackupWgInfo; + switch (wg.wgInfo.withdrawalType) { + case WithdrawalRecordType.BankIntegrated: + info = { + type: BackupWgType.BankIntegrated, + exchange_payto_uri: wg.wgInfo.bankInfo.exchangePaytoUri, + taler_withdraw_uri: wg.wgInfo.bankInfo.talerWithdrawUri, + confirm_url: wg.wgInfo.bankInfo.confirmUrl, + timestamp_bank_confirmed: + wg.wgInfo.bankInfo.timestampBankConfirmed, + timestamp_reserve_info_posted: + wg.wgInfo.bankInfo.timestampReserveInfoPosted, + }; + break; + case WithdrawalRecordType.BankManual: + info = { + type: BackupWgType.BankManual, + }; + break; + case WithdrawalRecordType.PeerPullCredit: + info = { + type: BackupWgType.PeerPullCredit, + contract_priv: wg.wgInfo.contractPriv, + contract_terms: wg.wgInfo.contractTerms, + }; + break; + case WithdrawalRecordType.PeerPushCredit: + info = { + type: BackupWgType.PeerPushCredit, + contract_terms: wg.wgInfo.contractTerms, + }; + break; + case WithdrawalRecordType.Recoup: + info = { + type: BackupWgType.Recoup, + }; + break; + default: + assertUnreachable(wg.wgInfo); + } + backupWithdrawalGroups.push({ raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount), - selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({ - count: x.count, - denom_pub_hash: x.denomPubHash, - })), + info, timestamp_created: wg.timestampStart, timestamp_finish: wg.timestampFinish, withdrawal_group_id: wg.withdrawalGroupId, secret_seed: wg.secretSeed, - selected_denoms_id: wg.denomSelUid, + exchange_base_url: wg.exchangeBaseUrl, + instructed_amount: Amounts.stringify(wg.instructedAmount), + reserve_priv: wg.reservePriv, + restrict_age: wg.restrictAge, + operation_status: + wg.operationStatus == OperationStatus.Finished + ? BackupOperationStatus.Finished + : BackupOperationStatus.Pending, + selected_denoms_uid: wg.denomSelUid, + selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({ + count: x.count, + denom_pub_hash: x.denomPubHash, + })), }); }); @@ -299,7 +350,6 @@ export async function exportBackup( tos_accepted_timestamp: ex.termsOfServiceAcceptedTimestamp, denominations: backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [], - reserves: backupReservesByExchange[ex.exchangeBaseUrl] ?? [], }); }); @@ -439,7 +489,8 @@ export async function exportBackup( const backupBlob: WalletBackupContentV1 = { schema_id: "gnu-taler-wallet-backup-content", - schema_version: 1, + schema_version: BACKUP_VERSION_MAJOR, + minor_version: BACKUP_VERSION_MINOR, exchanges: backupExchanges, exchange_details: backupExchangeDetails, wallet_root_pub: bs.walletRootPub, @@ -456,6 +507,8 @@ export async function exportBackup( intern_table: {}, error_reports: [], tombstones: [], + // FIXME! + withdrawal_groups: backupWithdrawalGroups, }; // If the backup changed, we change our nonce and timestamp. diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index be09952cd..507a6cf10 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -24,6 +24,7 @@ import { BackupPurchase, BackupRefreshReason, BackupRefundState, + BackupWgType, codecForContractTerms, DenomKeyType, j2s, @@ -53,8 +54,11 @@ import { WalletContractData, WalletRefundItem, WalletStoresV1, + WgInfo, + WithdrawalRecordType, } from "../../db.js"; import { InternalWalletState } from "../../internal-wallet-state.js"; +import { assertUnreachable } from "../../util/assertUnreachable.js"; import { checkDbInvariant, checkLogicInvariant, @@ -444,6 +448,91 @@ export async function importBackup( } } + for (const backupWg of backupBlob.withdrawal_groups) { + const reservePub = cryptoComp.reservePrivToPub[backupWg.reserve_priv]; + checkLogicInvariant(!!reservePub); + const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub); + if (tombstoneSet.has(ts)) { + continue; + } + const existingWg = await tx.withdrawalGroups.get( + backupWg.withdrawal_group_id, + ); + if (existingWg) { + continue; + } + let wgInfo: WgInfo; + switch (backupWg.info.type) { + case BackupWgType.BankIntegrated: + wgInfo = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { + exchangePaytoUri: backupWg.info.exchange_payto_uri, + talerWithdrawUri: backupWg.info.taler_withdraw_uri, + confirmUrl: backupWg.info.confirm_url, + timestampBankConfirmed: + backupWg.info.timestamp_bank_confirmed, + timestampReserveInfoPosted: + backupWg.info.timestamp_reserve_info_posted, + }, + }; + break; + case BackupWgType.BankManual: + wgInfo = { + withdrawalType: WithdrawalRecordType.BankManual, + }; + break; + case BackupWgType.PeerPullCredit: + wgInfo = { + withdrawalType: WithdrawalRecordType.PeerPullCredit, + contractTerms: backupWg.info.contract_terms, + contractPriv: backupWg.info.contract_priv, + }; + break; + case BackupWgType.PeerPushCredit: + wgInfo = { + withdrawalType: WithdrawalRecordType.PeerPushCredit, + contractTerms: backupWg.info.contract_terms, + }; + break; + case BackupWgType.Recoup: + wgInfo = { + withdrawalType: WithdrawalRecordType.Recoup, + }; + break; + default: + assertUnreachable(backupWg.info); + } + await tx.withdrawalGroups.put({ + withdrawalGroupId: backupWg.withdrawal_group_id, + exchangeBaseUrl: backupWg.exchange_base_url, + instructedAmount: Amounts.parseOrThrow(backupWg.instructed_amount), + secretSeed: backupWg.secret_seed, + operationStatus: backupWg.timestamp_finish + ? OperationStatus.Finished + : OperationStatus.Pending, + denomsSel: await getDenomSelStateFromBackup( + tx, + backupWg.exchange_base_url, + backupWg.selected_denoms, + ), + denomSelUid: backupWg.selected_denoms_uid, + rawWithdrawalAmount: Amounts.parseOrThrow( + backupWg.raw_withdrawal_amount, + ), + reservePriv: backupWg.reserve_priv, + reservePub, + reserveStatus: backupWg.timestamp_finish + ? ReserveRecordStatus.Dormant + : ReserveRecordStatus.QueryingStatus, // FIXME! + timestampStart: backupWg.timestamp_created, + wgInfo, + restrictAge: backupWg.restrict_age, + senderWire: undefined, // FIXME! + timestampFinish: backupWg.timestamp_finish, + }); + } + // FIXME: import reserves with new schema // for (const backupReserve of backupExchangeDetails.reserves) { diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index c7c93e909..b69c0b7b7 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -187,11 +187,11 @@ async function computeBackupCryptoData( cryptoData.rsaDenomPubToHash[backupDenom.denom_pub.rsa_public_key] = encodeCrock(hashDenomPub(backupDenom.denom_pub)); } - for (const backupReserve of backupExchangeDetails.reserves) { - cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock( - eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)), - ); - } + } + for (const backupWg of backupContent.withdrawal_groups) { + cryptoData.reservePrivToPub[backupWg.reserve_priv] = encodeCrock( + eddsaGetPublic(decodeCrock(backupWg.reserve_priv)), + ); } for (const prop of backupContent.proposals) { const { h: contractTermsHash } = await cryptoApi.hashString({ diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index de9721f3d..7dd874f49 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -96,12 +96,13 @@ import { DbAccess, GetReadOnlyAccess } from "../util/query.js"; import { OperationAttemptResult, OperationAttemptResultType, + RetryTags, } from "../util/retries.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, } from "../versions.js"; -import { makeCoinAvailable } from "../wallet.js"; +import { makeCoinAvailable, storeOperationPending } from "../wallet.js"; import { getExchangeDetails, getExchangePaytoUri, @@ -1099,6 +1100,7 @@ export async function processWithdrawalGroup( ); if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { + logger.warn("Finishing empty withdrawal group (no denoms)"); await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { @@ -1107,6 +1109,7 @@ export async function processWithdrawalGroup( return; } wg.operationStatus = OperationStatus.Finished; + wg.timestampFinish = TalerProtocolTimestamp.now(); await tx.withdrawalGroups.put(wg); }); return { @@ -1185,7 +1188,7 @@ export async function processWithdrawalGroup( errorsPerCoin[x.coinIdx] = x.lastError; } }); - logger.trace(`now withdrawn ${numFinished} of ${numTotalCoins} coins`); + logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`); if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { finishedForFirstTime = true; wg.timestampFinish = TalerProtocolTimestamp.now(); diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts index 8b8c30f35..d1aae6fd6 100644 --- a/packages/taler-wallet-core/src/util/query.ts +++ b/packages/taler-wallet-core/src/util/query.ts @@ -409,10 +409,12 @@ export type GetReadWriteAccess = { type ReadOnlyTransactionFunction = ( t: GetReadOnlyAccess, + rawTx: IDBTransaction, ) => Promise; type ReadWriteTransactionFunction = ( t: GetReadWriteAccess, + rawTx: IDBTransaction, ) => Promise; export interface TransactionContext { @@ -420,22 +422,10 @@ export interface TransactionContext { runReadOnly(f: ReadOnlyTransactionFunction): Promise; } -type CheckDescriptor = T extends StoreWithIndexes< - infer SN, - infer SD, - infer IM -> - ? StoreWithIndexes - : unknown; - -type GetPickerType = F extends (x: SM) => infer Out - ? { [P in keyof Out]: CheckDescriptor } - : unknown; - function runTx( tx: IDBTransaction, arg: Arg, - f: (t: Arg) => Promise, + f: (t: Arg, t2: IDBTransaction) => Promise, ): Promise { const stack = Error("Failed transaction was started here."); return new Promise((resolve, reject) => { @@ -474,7 +464,7 @@ function runTx( logger.error(msg); reject(new TransactionAbortedError(msg)); }; - const resP = Promise.resolve().then(() => f(arg)); + const resP = Promise.resolve().then(() => f(arg, tx)); resP .then((result) => { gotFunResult = true; @@ -624,6 +614,46 @@ export class DbAccess { return this.db; } + /** + * Run a transaction with all object stores. + */ + mktxAll(): TransactionContext { + const storeNames: string[] = []; + const accessibleStores: { [x: string]: StoreWithIndexes } = + {}; + + for (let i = 0; i < this.db.objectStoreNames.length; i++) { + const sn = this.db.objectStoreNames[i]; + const swi = (this.stores as any)[sn] as StoreWithIndexes; + if (!swi) { + throw Error(`store metadata not available (${sn})`); + } + storeNames.push(sn); + accessibleStores[sn] = swi; + } + + const runReadOnly = ( + txf: ReadOnlyTransactionFunction, + ): Promise => { + const tx = this.db.transaction(storeNames, "readonly"); + const readContext = makeReadContext(tx, accessibleStores); + return runTx(tx, readContext, txf); + }; + + const runReadWrite = ( + txf: ReadWriteTransactionFunction, + ): Promise => { + const tx = this.db.transaction(storeNames, "readwrite"); + const writeContext = makeWriteContext(tx, accessibleStores); + return runTx(tx, writeContext, txf); + }; + + return { + runReadOnly, + runReadWrite, + }; + } + /** * Run a transaction with selected object stores. * @@ -638,13 +668,14 @@ export class DbAccess { [X in StoreNamesOf]: StoreList[number] & { storeName: X }; }, >(namePicker: (x: StoreMap) => StoreList): TransactionContext { + const storeNames: string[] = []; + const accessibleStores: { [x: string]: StoreWithIndexes } = + {}; + const storePick = namePicker(this.stores) as any; if (typeof storePick !== "object" || storePick === null) { throw Error(); } - const storeNames: string[] = []; - const accessibleStores: { [x: string]: StoreWithIndexes } = - {}; for (const swiPicked of storePick) { const swi = swiPicked as StoreWithIndexes; if (swi.mark !== storeWithIndexesSymbol) { diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 665be80fb..f2c76731b 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -134,6 +134,8 @@ export enum WalletApiOperation { InitiatePeerPullPayment = "initiatePeerPullPayment", CheckPeerPullPayment = "checkPeerPullPayment", AcceptPeerPullPayment = "acceptPeerPullPayment", + ClearDb = "clearDb", + Recycle = "recycle", } export type WalletOperations = { @@ -317,6 +319,14 @@ export type WalletOperations = { request: AcceptPeerPullPaymentRequest; response: {}; }; + [WalletApiOperation.ClearDb]: { + request: {}; + response: {}; + }; + [WalletApiOperation.Recycle]: { + request: {}; + response: {}; + }; }; export type RequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 1b74f2025..2e362da6e 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -99,6 +99,7 @@ import { CryptoDispatcher, CryptoWorkerFactory, } from "./crypto/workers/cryptoDispatcher.js"; +import { clearDatabase } from "./db-utils.js"; import { AuditorTrustRecord, CoinRecord, @@ -114,7 +115,6 @@ import { makeErrorDetail, TalerError, } from "./errors.js"; -import { createDenominationTimeline } from "./index.browser.js"; import { ExchangeOperations, InternalWalletState, @@ -131,6 +131,7 @@ import { codecForRunBackupCycle, getBackupInfo, getBackupRecovery, + importBackupPlain, loadBackupRecovery, processBackupForProvider, removeBackupProvider, @@ -215,6 +216,7 @@ import { } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js"; +import { createDenominationTimeline } from "./util/denominations.js"; import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, @@ -1060,8 +1062,11 @@ async function dispatchRequestInternal( `wallet must be initialized before running operation ${operation}`, ); } + // FIXME: Can we make this more type-safe by using the request/response type + // definitions we already have? switch (operation) { case "initWallet": { + logger.info("initializing wallet"); ws.initCalled = true; if (typeof payload === "object" && (payload as any).skipDefaults) { logger.info("skipping defaults"); @@ -1371,6 +1376,15 @@ async function dispatchRequestInternal( logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`); return {}; } + case "clearDb": + await clearDatabase(ws.db.idbHandle()); + return {}; + case "recycle": { + const backup = await exportBackup(ws); + await clearDatabase(ws.db.idbHandle()); + await importBackupPlain(ws, backup); + return {}; + } case "exportDb": { const dbDump = await exportDb(ws.db.idbHandle()); return dbDump;