wallet-core: make basic backup work again

This commit is contained in:
Florian Dold 2022-09-20 21:44:21 +02:00
parent 52ec740c82
commit 16a5bb4083
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
14 changed files with 533 additions and 213 deletions

View File

@ -47,6 +47,15 @@
* 3. Derived information is never backed up (hashed values, public keys * 3. Derived information is never backed up (hashed values, public keys
* when we know the private key). * 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 <dold@taler.net> * @author Florian Dold <dold@taler.net>
*/ */
@ -56,6 +65,23 @@
import { DenominationPubKey, UnblindedSignature } from "./talerTypes.js"; import { DenominationPubKey, UnblindedSignature } from "./talerTypes.js";
import { TalerProtocolDuration, TalerProtocolTimestamp } from "./time.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. * 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. * 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. * 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 * Root public key of the wallet. This field is present as
@ -131,6 +159,13 @@ export interface WalletBackupContentV1 {
exchange_details: BackupExchangeDetails[]; exchange_details: BackupExchangeDetails[];
/**
* Withdrawal groups.
*
* Sorted by the withdrawal group ID.
*/
withdrawal_groups: BackupWithdrawalGroup[];
/** /**
* Grouped refresh sessions. * Grouped refresh sessions.
* *
@ -208,6 +243,118 @@ export interface WalletBackupContentV1 {
tombstones: Tombstone[]; 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 "<type>:<key>" * Tombstone in the format "<type>:<key>"
*/ */
@ -619,46 +766,6 @@ export interface BackupRefreshGroup {
finish_is_failure?: boolean; 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 { export enum BackupRefundState {
Failed = "failed", Failed = "failed",
Applied = "applied", Applied = "applied",
@ -914,101 +1021,6 @@ export type BackupDenomSel = {
count: number; 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 * Wire fee for one wire payment target type as stored in the
* wallet's database. * wallet's database.
@ -1148,11 +1160,6 @@ export interface BackupExchangeDetails {
*/ */
denominations: BackupDenomination[]; denominations: BackupDenomination[];
/**
* Reserves at the exchange.
*/
reserves: BackupReserve[];
/** /**
* Last observed protocol version. * Last observed protocol version.
*/ */

View File

@ -87,10 +87,14 @@ export interface TransactionCommon {
*/ */
frozen: boolean; 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; 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; amountEffective: AmountString;
error?: TalerErrorDetail; error?: TalerErrorDetail;
@ -509,10 +513,11 @@ export interface TransactionByIdRequest {
transactionId: string; transactionId: string;
} }
export const codecForTransactionByIdRequest = (): Codec<TransactionByIdRequest> => export const codecForTransactionByIdRequest =
buildCodecForObject<TransactionByIdRequest>() (): Codec<TransactionByIdRequest> =>
.property("transactionId", codecForString()) buildCodecForObject<TransactionByIdRequest>()
.build("TransactionByIdRequest"); .property("transactionId", codecForString())
.build("TransactionByIdRequest");
export const codecForTransactionsRequest = (): Codec<TransactionsRequest> => export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>
buildCodecForObject<TransactionsRequest>() buildCodecForObject<TransactionsRequest>()

View File

@ -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 advancedCli
.subcommand("payPrepare", "pay-prepare", { .subcommand("payPrepare", "pay-prepare", {
help: "Claim an order but don't pay yet.", help: "Claim an order but don't pay yet.",

View File

@ -17,9 +17,13 @@
/** /**
* Imports. * Imports.
*/ */
import { j2s } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState, WalletCli } from "../harness/harness.js"; 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"; import { SyncService } from "../harness/sync";
/** /**
@ -28,13 +32,8 @@ import { SyncService } from "../harness/sync";
export async function runWalletBackupBasicTest(t: GlobalTestState) { export async function runWalletBackupBasicTest(t: GlobalTestState) {
// Set up test environment // Set up test environment
const { const { commonDb, merchant, wallet, bank, exchange } =
commonDb, await createSimpleTestkudosEnvironment(t);
merchant,
wallet,
bank,
exchange,
} = await createSimpleTestkudosEnvironment(t);
const sync = await SyncService.create(t, { const sync = await SyncService.create(t, {
currency: "TESTKUDOS", 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"); const wallet2 = new WalletCli(t, "wallet2");
// Check that the second wallet is a fresh wallet. // 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 // 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, {}); const bal1 = await wallet2.client.call(WalletApiOperation.GetBalances, {});
t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:14.1"); t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:14.1");
@ -140,8 +147,16 @@ export async function runWalletBackupBasicTest(t: GlobalTestState) {
amount: "TESTKUDOS:10", amount: "TESTKUDOS:10",
}); });
await exchange.runWirewatchOnce();
await wallet2.runUntilDone(); 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, {}); const bal2 = await wallet2.client.call(WalletApiOperation.GetBalances, {});
t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:23.82"); t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:23.82");

View File

@ -19,7 +19,11 @@
*/ */
import { PreparePayResultType } from "@gnu-taler/taler-util"; import { PreparePayResultType } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; 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 { import {
createSimpleTestkudosEnvironment, createSimpleTestkudosEnvironment,
makeTestPayment, makeTestPayment,
@ -33,13 +37,8 @@ import { SyncService } from "../harness/sync";
export async function runWalletBackupDoublespendTest(t: GlobalTestState) { export async function runWalletBackupDoublespendTest(t: GlobalTestState) {
// Set up test environment // Set up test environment
const { const { commonDb, merchant, wallet, bank, exchange } =
commonDb, await createSimpleTestkudosEnvironment(t);
merchant,
wallet,
bank,
exchange,
} = await createSimpleTestkudosEnvironment(t);
const sync = await SyncService.create(t, { const sync = await SyncService.create(t, {
currency: "TESTKUDOS", currency: "TESTKUDOS",
@ -139,8 +138,9 @@ export async function runWalletBackupDoublespendTest(t: GlobalTestState) {
}, },
); );
t.assertTrue( t.assertDeepEqual(
preparePayResult.status === PreparePayResultType.PaymentPossible, preparePayResult.status,
PreparePayResultType.PaymentPossible,
); );
const res = await wallet2.client.call(WalletApiOperation.ConfirmPay, { const res = await wallet2.client.call(WalletApiOperation.ConfirmPay, {

View File

@ -72,6 +72,33 @@ function upgradeFromStoreMap(
throw Error("upgrade not supported"); throw Error("upgrade not supported");
} }
function promiseFromTransaction(transaction: IDBTransaction): Promise<void> {
return new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject();
};
});
}
/**
* Purge all data in the given database.
*/
export function clearDatabase(db: IDBDatabase): Promise<void> {
// 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( function onTalerDbUpgradeNeeded(
db: IDBDatabase, db: IDBDatabase,
oldVersion: number, oldVersion: number,

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler 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 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 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 { RetryInfo, RetryTags } from "./util/retries.js";
import { Event, IDBDatabase } from "@gnu-taler/idb-bridge"; 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 "<Name>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 <dold@taler.net>
*/
/** /**
* Name of the Taler database. This is effectively the major * Name of the Taler database. This is effectively the major
* version of the DB schema. Whenever it changes, custom import logic * 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; export const WALLET_DB_MINOR_VERSION = 1;
/**
* Status of a withdrawal.
*/
export enum ReserveRecordStatus { export enum ReserveRecordStatus {
/** /**
* Reserve must be registered with the bank. * 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 * Was this denomination still offered by the exchange the last time
* we checked? * we checked?
* Only false when the exchange redacts a previously published denomination. * Only false when the exchange redacts a previously published denomination.
* *
* FIXME: Consider rolling this and isRevoked into some bitfield? * FIXME: Consider rolling this and isRevoked into some bitfield?
*/ */
isOffered: boolean; isOffered: boolean;
@ -520,6 +541,9 @@ export interface PlanchetRecord {
*/ */
coinIdx: number; coinIdx: number;
/**
* FIXME: make this an enum!
*/
withdrawalDone: boolean; withdrawalDone: boolean;
lastError: TalerErrorDetail | undefined; lastError: TalerErrorDetail | undefined;
@ -639,6 +663,9 @@ export interface CoinRecord {
/** /**
* Amount that's left on the coin. * Amount that's left on the coin.
*
* FIXME: This is pretty redundant with "allocation" and "status".
* Do we really need this?
*/ */
currentAmount: AmountJson; currentAmount: AmountJson;
@ -716,6 +743,9 @@ export interface ProposalDownload {
*/ */
contractTermsRaw: any; contractTermsRaw: any;
/**
* Extracted / parsed data from the contract terms.
*/
contractData: WalletContractData; contractData: WalletContractData;
} }
@ -780,6 +810,9 @@ export interface TipRecord {
*/ */
tipAmountRaw: AmountJson; tipAmountRaw: AmountJson;
/**
* Effect on the balance (including fees etc).
*/
tipAmountEffective: AmountJson; tipAmountEffective: AmountJson;
/** /**
@ -800,6 +833,9 @@ export interface TipRecord {
/** /**
* Denomination selection made by the wallet for picking up * Denomination selection made by the wallet for picking up
* this tip. * this tip.
*
* FIXME: Put this into some DenomSelectionCacheRecord instead of
* storing it here!
*/ */
denomsSel: DenomSelectionState; denomsSel: DenomSelectionState;
@ -889,6 +925,8 @@ export interface RefreshGroupRecord {
/** /**
* No coins are pending, but at least one is frozen. * No coins are pending, but at least one is frozen.
*
* FIXME: What does this mean?
*/ */
frozen?: boolean; frozen?: boolean;
} }
@ -1319,11 +1357,15 @@ export interface WithdrawalGroupRecord {
/** /**
* Operation status of the withdrawal group. * Operation status of the withdrawal group.
* Used for indexing in the database. * Used for indexing in the database.
*
* FIXME: Redundant with reserveStatus
*/ */
operationStatus: OperationStatus; operationStatus: OperationStatus;
/** /**
* Current status of the reserve. * Current status of the reserve.
*
* FIXME: Wrong name!
*/ */
reserveStatus: ReserveRecordStatus; reserveStatus: ReserveRecordStatus;
@ -1756,6 +1798,10 @@ export interface CoinAvailabilityRecord {
freshCoinCount: number; freshCoinCount: number;
} }
/**
* Schema definition for the IndexedDB
* wallet database.
*/
export const WalletStoresV1 = { export const WalletStoresV1 = {
coinAvailability: describeStore( coinAvailability: describeStore(
"coinAvailability", "coinAvailability",

View File

@ -25,6 +25,7 @@
* Imports. * Imports.
*/ */
import { import {
AbsoluteTime,
Amounts, Amounts,
BackupBackupProvider, BackupBackupProvider,
BackupBackupProviderTerms, BackupBackupProviderTerms,
@ -35,6 +36,7 @@ import {
BackupExchange, BackupExchange,
BackupExchangeDetails, BackupExchangeDetails,
BackupExchangeWireFee, BackupExchangeWireFee,
BackupOperationStatus,
BackupProposal, BackupProposal,
BackupProposalStatus, BackupProposalStatus,
BackupPurchase, BackupPurchase,
@ -44,30 +46,35 @@ import {
BackupRefreshSession, BackupRefreshSession,
BackupRefundItem, BackupRefundItem,
BackupRefundState, BackupRefundState,
BackupReserve,
BackupTip, BackupTip,
BackupWgInfo,
BackupWgType,
BackupWithdrawalGroup, BackupWithdrawalGroup,
BACKUP_VERSION_MAJOR,
BACKUP_VERSION_MINOR,
canonicalizeBaseUrl, canonicalizeBaseUrl,
canonicalJson, canonicalJson,
Logger,
WalletBackupContentV1,
hash,
encodeCrock, encodeCrock,
getRandomBytes, getRandomBytes,
hash,
Logger,
stringToBytes, stringToBytes,
AbsoluteTime, WalletBackupContentV1,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { import {
AbortStatus, AbortStatus,
CoinSourceType, CoinSourceType,
CoinStatus, CoinStatus,
DenominationRecord, DenominationRecord,
OperationStatus,
ProposalStatus, ProposalStatus,
RefreshCoinStatus, RefreshCoinStatus,
RefundState, RefundState,
WALLET_BACKUP_STATE_KEY, WALLET_BACKUP_STATE_KEY,
WithdrawalRecordType,
} from "../../db.js"; } from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
import { getWalletBackupState, provideBackupState } from "./state.js"; import { getWalletBackupState, provideBackupState } from "./state.js";
const logger = new Logger("backup/export.ts"); const logger = new Logger("backup/export.ts");
@ -100,31 +107,75 @@ export async function exportBackup(
const backupDenominationsByExchange: { const backupDenominationsByExchange: {
[url: string]: BackupDenomination[]; [url: string]: BackupDenomination[];
} = {}; } = {};
const backupReservesByExchange: { [url: string]: BackupReserve[] } = {};
const backupPurchases: BackupPurchase[] = []; const backupPurchases: BackupPurchase[] = [];
const backupProposals: BackupProposal[] = []; const backupProposals: BackupProposal[] = [];
const backupRefreshGroups: BackupRefreshGroup[] = []; const backupRefreshGroups: BackupRefreshGroup[] = [];
const backupBackupProviders: BackupBackupProvider[] = []; const backupBackupProviders: BackupBackupProvider[] = [];
const backupTips: BackupTip[] = []; const backupTips: BackupTip[] = [];
const backupRecoupGroups: BackupRecoupGroup[] = []; const backupRecoupGroups: BackupRecoupGroup[] = [];
const withdrawalGroupsByReserve: { const backupWithdrawalGroups: BackupWithdrawalGroup[] = [];
[reservePub: string]: BackupWithdrawalGroup[];
} = {};
await tx.withdrawalGroups.iter().forEachAsync(async (wg) => { await tx.withdrawalGroups.iter().forEachAsync(async (wg) => {
const withdrawalGroups = (withdrawalGroupsByReserve[wg.reservePub] ??= let info: BackupWgInfo;
[]); switch (wg.wgInfo.withdrawalType) {
withdrawalGroups.push({ 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), raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount),
selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({ info,
count: x.count,
denom_pub_hash: x.denomPubHash,
})),
timestamp_created: wg.timestampStart, timestamp_created: wg.timestampStart,
timestamp_finish: wg.timestampFinish, timestamp_finish: wg.timestampFinish,
withdrawal_group_id: wg.withdrawalGroupId, withdrawal_group_id: wg.withdrawalGroupId,
secret_seed: wg.secretSeed, 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, tos_accepted_timestamp: ex.termsOfServiceAcceptedTimestamp,
denominations: denominations:
backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [], backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [],
reserves: backupReservesByExchange[ex.exchangeBaseUrl] ?? [],
}); });
}); });
@ -439,7 +489,8 @@ export async function exportBackup(
const backupBlob: WalletBackupContentV1 = { const backupBlob: WalletBackupContentV1 = {
schema_id: "gnu-taler-wallet-backup-content", schema_id: "gnu-taler-wallet-backup-content",
schema_version: 1, schema_version: BACKUP_VERSION_MAJOR,
minor_version: BACKUP_VERSION_MINOR,
exchanges: backupExchanges, exchanges: backupExchanges,
exchange_details: backupExchangeDetails, exchange_details: backupExchangeDetails,
wallet_root_pub: bs.walletRootPub, wallet_root_pub: bs.walletRootPub,
@ -456,6 +507,8 @@ export async function exportBackup(
intern_table: {}, intern_table: {},
error_reports: [], error_reports: [],
tombstones: [], tombstones: [],
// FIXME!
withdrawal_groups: backupWithdrawalGroups,
}; };
// If the backup changed, we change our nonce and timestamp. // If the backup changed, we change our nonce and timestamp.

View File

@ -24,6 +24,7 @@ import {
BackupPurchase, BackupPurchase,
BackupRefreshReason, BackupRefreshReason,
BackupRefundState, BackupRefundState,
BackupWgType,
codecForContractTerms, codecForContractTerms,
DenomKeyType, DenomKeyType,
j2s, j2s,
@ -53,8 +54,11 @@ import {
WalletContractData, WalletContractData,
WalletRefundItem, WalletRefundItem,
WalletStoresV1, WalletStoresV1,
WgInfo,
WithdrawalRecordType,
} from "../../db.js"; } from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js"; import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
import { import {
checkDbInvariant, checkDbInvariant,
checkLogicInvariant, 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 // FIXME: import reserves with new schema
// for (const backupReserve of backupExchangeDetails.reserves) { // for (const backupReserve of backupExchangeDetails.reserves) {

View File

@ -187,11 +187,11 @@ async function computeBackupCryptoData(
cryptoData.rsaDenomPubToHash[backupDenom.denom_pub.rsa_public_key] = cryptoData.rsaDenomPubToHash[backupDenom.denom_pub.rsa_public_key] =
encodeCrock(hashDenomPub(backupDenom.denom_pub)); encodeCrock(hashDenomPub(backupDenom.denom_pub));
} }
for (const backupReserve of backupExchangeDetails.reserves) { }
cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock( for (const backupWg of backupContent.withdrawal_groups) {
eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)), cryptoData.reservePrivToPub[backupWg.reserve_priv] = encodeCrock(
); eddsaGetPublic(decodeCrock(backupWg.reserve_priv)),
} );
} }
for (const prop of backupContent.proposals) { for (const prop of backupContent.proposals) {
const { h: contractTermsHash } = await cryptoApi.hashString({ const { h: contractTermsHash } = await cryptoApi.hashString({

View File

@ -96,12 +96,13 @@ import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
import { import {
OperationAttemptResult, OperationAttemptResult,
OperationAttemptResultType, OperationAttemptResultType,
RetryTags,
} from "../util/retries.js"; } from "../util/retries.js";
import { import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js"; } from "../versions.js";
import { makeCoinAvailable } from "../wallet.js"; import { makeCoinAvailable, storeOperationPending } from "../wallet.js";
import { import {
getExchangeDetails, getExchangeDetails,
getExchangePaytoUri, getExchangePaytoUri,
@ -1099,6 +1100,7 @@ export async function processWithdrawalGroup(
); );
if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
logger.warn("Finishing empty withdrawal group (no denoms)");
await ws.db await ws.db
.mktx((x) => [x.withdrawalGroups]) .mktx((x) => [x.withdrawalGroups])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
@ -1107,6 +1109,7 @@ export async function processWithdrawalGroup(
return; return;
} }
wg.operationStatus = OperationStatus.Finished; wg.operationStatus = OperationStatus.Finished;
wg.timestampFinish = TalerProtocolTimestamp.now();
await tx.withdrawalGroups.put(wg); await tx.withdrawalGroups.put(wg);
}); });
return { return {
@ -1185,7 +1188,7 @@ export async function processWithdrawalGroup(
errorsPerCoin[x.coinIdx] = x.lastError; 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) { if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
finishedForFirstTime = true; finishedForFirstTime = true;
wg.timestampFinish = TalerProtocolTimestamp.now(); wg.timestampFinish = TalerProtocolTimestamp.now();

View File

@ -409,10 +409,12 @@ export type GetReadWriteAccess<BoundStores> = {
type ReadOnlyTransactionFunction<BoundStores, T> = ( type ReadOnlyTransactionFunction<BoundStores, T> = (
t: GetReadOnlyAccess<BoundStores>, t: GetReadOnlyAccess<BoundStores>,
rawTx: IDBTransaction,
) => Promise<T>; ) => Promise<T>;
type ReadWriteTransactionFunction<BoundStores, T> = ( type ReadWriteTransactionFunction<BoundStores, T> = (
t: GetReadWriteAccess<BoundStores>, t: GetReadWriteAccess<BoundStores>,
rawTx: IDBTransaction,
) => Promise<T>; ) => Promise<T>;
export interface TransactionContext<BoundStores> { export interface TransactionContext<BoundStores> {
@ -420,22 +422,10 @@ export interface TransactionContext<BoundStores> {
runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>; runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>;
} }
type CheckDescriptor<T> = T extends StoreWithIndexes<
infer SN,
infer SD,
infer IM
>
? StoreWithIndexes<SN, SD, IM>
: unknown;
type GetPickerType<F, SM> = F extends (x: SM) => infer Out
? { [P in keyof Out]: CheckDescriptor<Out[P]> }
: unknown;
function runTx<Arg, Res>( function runTx<Arg, Res>(
tx: IDBTransaction, tx: IDBTransaction,
arg: Arg, arg: Arg,
f: (t: Arg) => Promise<Res>, f: (t: Arg, t2: IDBTransaction) => Promise<Res>,
): Promise<Res> { ): Promise<Res> {
const stack = Error("Failed transaction was started here."); const stack = Error("Failed transaction was started here.");
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -474,7 +464,7 @@ function runTx<Arg, Res>(
logger.error(msg); logger.error(msg);
reject(new TransactionAbortedError(msg)); reject(new TransactionAbortedError(msg));
}; };
const resP = Promise.resolve().then(() => f(arg)); const resP = Promise.resolve().then(() => f(arg, tx));
resP resP
.then((result) => { .then((result) => {
gotFunResult = true; gotFunResult = true;
@ -624,6 +614,46 @@ export class DbAccess<StoreMap> {
return this.db; return this.db;
} }
/**
* Run a transaction with all object stores.
*/
mktxAll(): TransactionContext<StoreMap> {
const storeNames: string[] = [];
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
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<any, any, any>;
if (!swi) {
throw Error(`store metadata not available (${sn})`);
}
storeNames.push(sn);
accessibleStores[sn] = swi;
}
const runReadOnly = <T>(
txf: ReadOnlyTransactionFunction<StoreMap, T>,
): Promise<T> => {
const tx = this.db.transaction(storeNames, "readonly");
const readContext = makeReadContext(tx, accessibleStores);
return runTx(tx, readContext, txf);
};
const runReadWrite = <T>(
txf: ReadWriteTransactionFunction<StoreMap, T>,
): Promise<T> => {
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. * Run a transaction with selected object stores.
* *
@ -638,13 +668,14 @@ export class DbAccess<StoreMap> {
[X in StoreNamesOf<StoreList>]: StoreList[number] & { storeName: X }; [X in StoreNamesOf<StoreList>]: StoreList[number] & { storeName: X };
}, },
>(namePicker: (x: StoreMap) => StoreList): TransactionContext<BoundStores> { >(namePicker: (x: StoreMap) => StoreList): TransactionContext<BoundStores> {
const storeNames: string[] = [];
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
const storePick = namePicker(this.stores) as any; const storePick = namePicker(this.stores) as any;
if (typeof storePick !== "object" || storePick === null) { if (typeof storePick !== "object" || storePick === null) {
throw Error(); throw Error();
} }
const storeNames: string[] = [];
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
for (const swiPicked of storePick) { for (const swiPicked of storePick) {
const swi = swiPicked as StoreWithIndexes<any, any, any>; const swi = swiPicked as StoreWithIndexes<any, any, any>;
if (swi.mark !== storeWithIndexesSymbol) { if (swi.mark !== storeWithIndexesSymbol) {

View File

@ -134,6 +134,8 @@ export enum WalletApiOperation {
InitiatePeerPullPayment = "initiatePeerPullPayment", InitiatePeerPullPayment = "initiatePeerPullPayment",
CheckPeerPullPayment = "checkPeerPullPayment", CheckPeerPullPayment = "checkPeerPullPayment",
AcceptPeerPullPayment = "acceptPeerPullPayment", AcceptPeerPullPayment = "acceptPeerPullPayment",
ClearDb = "clearDb",
Recycle = "recycle",
} }
export type WalletOperations = { export type WalletOperations = {
@ -317,6 +319,14 @@ export type WalletOperations = {
request: AcceptPeerPullPaymentRequest; request: AcceptPeerPullPaymentRequest;
response: {}; response: {};
}; };
[WalletApiOperation.ClearDb]: {
request: {};
response: {};
};
[WalletApiOperation.Recycle]: {
request: {};
response: {};
};
}; };
export type RequestType< export type RequestType<

View File

@ -99,6 +99,7 @@ import {
CryptoDispatcher, CryptoDispatcher,
CryptoWorkerFactory, CryptoWorkerFactory,
} from "./crypto/workers/cryptoDispatcher.js"; } from "./crypto/workers/cryptoDispatcher.js";
import { clearDatabase } from "./db-utils.js";
import { import {
AuditorTrustRecord, AuditorTrustRecord,
CoinRecord, CoinRecord,
@ -114,7 +115,6 @@ import {
makeErrorDetail, makeErrorDetail,
TalerError, TalerError,
} from "./errors.js"; } from "./errors.js";
import { createDenominationTimeline } from "./index.browser.js";
import { import {
ExchangeOperations, ExchangeOperations,
InternalWalletState, InternalWalletState,
@ -131,6 +131,7 @@ import {
codecForRunBackupCycle, codecForRunBackupCycle,
getBackupInfo, getBackupInfo,
getBackupRecovery, getBackupRecovery,
importBackupPlain,
loadBackupRecovery, loadBackupRecovery,
processBackupForProvider, processBackupForProvider,
removeBackupProvider, removeBackupProvider,
@ -215,6 +216,7 @@ import {
} from "./pending-types.js"; } from "./pending-types.js";
import { assertUnreachable } from "./util/assertUnreachable.js"; import { assertUnreachable } from "./util/assertUnreachable.js";
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js"; import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
import { createDenominationTimeline } from "./util/denominations.js";
import { import {
HttpRequestLibrary, HttpRequestLibrary,
readSuccessResponseJsonOrThrow, readSuccessResponseJsonOrThrow,
@ -1060,8 +1062,11 @@ async function dispatchRequestInternal(
`wallet must be initialized before running operation ${operation}`, `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) { switch (operation) {
case "initWallet": { case "initWallet": {
logger.info("initializing wallet");
ws.initCalled = true; ws.initCalled = true;
if (typeof payload === "object" && (payload as any).skipDefaults) { if (typeof payload === "object" && (payload as any).skipDefaults) {
logger.info("skipping defaults"); logger.info("skipping defaults");
@ -1371,6 +1376,15 @@ async function dispatchRequestInternal(
logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`); logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`);
return {}; 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": { case "exportDb": {
const dbDump = await exportDb(ws.db.idbHandle()); const dbDump = await exportDb(ws.db.idbHandle());
return dbDump; return dbDump;