gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated (49b5d006 -> 1392dc47)


From: gnunet
Subject: [taler-wallet-core] branch master updated (49b5d006 -> 1392dc47)
Date: Wed, 10 Mar 2021 17:12:05 +0100

This is an automated email from the git hooks/post-receive script.

dold pushed a change to branch master
in repository wallet-core.

    from 49b5d006 deletion test: expect different status code
     new ac89c3d2 restructure sync, store errors
     new 1392dc47 finish first complete end-to-end backup/sync test

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../src/integrationtests/harness.ts                |   35 +-
 .../taler-wallet-cli/src/integrationtests/sync.ts  |    3 +-
 .../integrationtests/test-wallet-backup-basic.ts   |   69 +-
 .../taler-wallet-core/src/operations/backup.ts     | 1907 --------------------
 .../src/operations/backup/export.ts                |  447 +++++
 .../src/operations/backup/import.ts                |  807 +++++++++
 .../src/operations/backup/index.ts                 |  783 ++++++++
 .../src/operations/backup/state.ts                 |  101 ++
 packages/taler-wallet-core/src/operations/pay.ts   |   68 +-
 packages/taler-wallet-core/src/types/dbTypes.ts    |   11 +
 packages/taler-wallet-core/src/util/helpers.ts     |    2 +-
 packages/taler-wallet-core/src/util/http.ts        |   39 +-
 packages/taler-wallet-core/src/wallet.ts           |   13 +-
 13 files changed, 2321 insertions(+), 1964 deletions(-)
 delete mode 100644 packages/taler-wallet-core/src/operations/backup.ts
 create mode 100644 packages/taler-wallet-core/src/operations/backup/export.ts
 create mode 100644 packages/taler-wallet-core/src/operations/backup/import.ts
 create mode 100644 packages/taler-wallet-core/src/operations/backup/index.ts
 create mode 100644 packages/taler-wallet-core/src/operations/backup/state.ts

diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts 
b/packages/taler-wallet-cli/src/integrationtests/harness.ts
index 2b26ef7f..31f9131a 100644
--- a/packages/taler-wallet-cli/src/integrationtests/harness.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts
@@ -82,6 +82,7 @@ import {
   CreateDepositGroupResponse,
   TrackDepositGroupRequest,
   TrackDepositGroupResponse,
+  RecoveryLoadRequest,
 } from "@gnu-taler/taler-wallet-core";
 import { URL } from "url";
 import axios, { AxiosError } from "axios";
@@ -99,7 +100,11 @@ import {
 import { ApplyRefundResponse } from "@gnu-taler/taler-wallet-core";
 import { PendingOperationsResponse } from "@gnu-taler/taler-wallet-core";
 import { CoinConfig } from "./denomStructures";
-import { AddBackupProviderRequest, BackupInfo } from 
"@gnu-taler/taler-wallet-core/src/operations/backup";
+import {
+  AddBackupProviderRequest,
+  BackupInfo,
+  BackupRecovery,
+} from "@gnu-taler/taler-wallet-core/src/operations/backup";
 
 const exec = util.promisify(require("child_process").exec);
 
@@ -1474,7 +1479,9 @@ export class MerchantService implements 
MerchantServiceInterface {
     config.write(this.configFilename);
   }
 
-  async addInstance(instanceConfig: PartialMerchantInstanceConfig): 
Promise<void> {
+  async addInstance(
+    instanceConfig: PartialMerchantInstanceConfig,
+  ): Promise<void> {
     if (!this.proc) {
       throw Error("merchant must be running to add instance");
     }
@@ -1881,4 +1888,28 @@ export class WalletCli {
     }
     throw new OperationFailedError(resp.error);
   }
+
+  async exportBackupRecovery(): Promise<BackupRecovery> {
+    const resp = await this.apiRequest("exportBackupRecovery", {});
+    if (resp.type === "response") {
+      return resp.result as BackupRecovery;
+    }
+    throw new OperationFailedError(resp.error);
+  }
+
+  async importBackupRecovery(req: RecoveryLoadRequest): Promise<void> {
+    const resp = await this.apiRequest("importBackupRecovery", req);
+    if (resp.type === "response") {
+      return;
+    }
+    throw new OperationFailedError(resp.error);
+  }
+
+  async runBackupCycle(): Promise<void> {
+    const resp = await this.apiRequest("runBackupCycle", {});
+    if (resp.type === "response") {
+      return;
+    }
+    throw new OperationFailedError(resp.error);
+  }
 }
diff --git a/packages/taler-wallet-cli/src/integrationtests/sync.ts 
b/packages/taler-wallet-cli/src/integrationtests/sync.ts
index 7aa4b289..83024ec7 100644
--- a/packages/taler-wallet-cli/src/integrationtests/sync.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/sync.ts
@@ -19,7 +19,6 @@
  */
 import axios from "axios";
 import { Configuration, URL } from "@gnu-taler/taler-wallet-core";
-import { getRandomIban, getRandomString } from "./helpers";
 import * as fs from "fs";
 import * as util from "util";
 import {
@@ -87,6 +86,8 @@ export class SyncService {
     config.setString("sync", "port", `${sc.httpPort}`);
     config.setString("sync", "db", "postgres");
     config.setString("syncdb-postgres", "config", sc.database);
+    config.setString("sync", "payment_backend_url", sc.paymentBackendUrl);
+    config.setString("sync", "upload_limit_mb", `${sc.uploadLimitMb}`);
     config.write(cfgFilename);
 
     return new SyncService(gc, sc, cfgFilename);
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 9201c558..2ed16fe1 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,12 @@
 /**
  * Imports.
  */
-import { GlobalTestState, BankApi, BankAccessApi } from "./harness";
-import { createSimpleTestkudosEnvironment } from "./helpers";
-import { codecForBalancesResponse } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, BankApi, BankAccessApi, WalletCli } from "./harness";
+import {
+  createSimpleTestkudosEnvironment,
+  makeTestPayment,
+  withdrawViaBank,
+} from "./helpers";
 import { SyncService } from "./sync";
 
 /**
@@ -28,7 +31,13 @@ import { SyncService } from "./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",
@@ -56,11 +65,61 @@ export async function runWalletBackupBasicTest(t: 
GlobalTestState) {
 
   await wallet.addBackupProvider({
     backupProviderBaseUrl: sync.baseUrl,
-    activate: false,
+    activate: true,
   });
 
   {
     const bi = await wallet.getBackupInfo();
     t.assertDeepEqual(bi.providers[0].active, true);
   }
+
+  await wallet.runBackupCycle();
+
+  {
+    const bi = await wallet.getBackupInfo();
+    console.log(bi);
+    t.assertDeepEqual(
+      bi.providers[0].paymentStatus.type,
+      "insufficient-balance",
+    );
+  }
+
+  await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:10" });
+
+  await wallet.runBackupCycle();
+
+  {
+    const bi = await wallet.getBackupInfo();
+    console.log(bi);
+  }
+
+  await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:5" });
+
+  await wallet.runBackupCycle();
+
+  {
+    const bi = await wallet.getBackupInfo();
+    console.log(bi);
+  }
+  
+  const backupRecovery = await wallet.exportBackupRecovery();
+
+  const wallet2 = new WalletCli(t, "wallet2");
+
+  // Check that the second wallet is a fresh wallet.
+  {
+    const bal = await wallet2.getBalances();
+    t.assertTrue(bal.balances.length === 0);
+  }
+
+  await wallet2.importBackupRecovery({ recovery: backupRecovery });
+
+  await wallet2.runBackupCycle();
+
+  // Check that now the old balance is available!
+  {
+    const bal = await wallet2.getBalances();
+    t.assertTrue(bal.balances.length === 1);
+    console.log(bal);
+  }
 }
diff --git a/packages/taler-wallet-core/src/operations/backup.ts 
b/packages/taler-wallet-core/src/operations/backup.ts
deleted file mode 100644
index 77f1581a..00000000
--- a/packages/taler-wallet-core/src/operations/backup.ts
+++ /dev/null
@@ -1,1907 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- 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/>
- */
-
-/**
- * Implementation of wallet backups (export/import/upload) and sync
- * server management.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import { InternalWalletState } from "./state";
-import {
-  BackupBackupProvider,
-  BackupBackupProviderTerms,
-  BackupCoin,
-  BackupCoinSource,
-  BackupCoinSourceType,
-  BackupDenomination,
-  BackupDenomSel,
-  BackupExchange,
-  BackupExchangeWireFee,
-  BackupProposal,
-  BackupProposalStatus,
-  BackupPurchase,
-  BackupRecoupGroup,
-  BackupRefreshGroup,
-  BackupRefreshOldCoin,
-  BackupRefreshReason,
-  BackupRefreshSession,
-  BackupRefundItem,
-  BackupRefundState,
-  BackupReserve,
-  BackupTip,
-  BackupWithdrawalGroup,
-  WalletBackupContentV1,
-} from "../types/backupTypes";
-import { TransactionHandle } from "../util/query";
-import {
-  AbortStatus,
-  BackupProviderStatus,
-  CoinSource,
-  CoinSourceType,
-  CoinStatus,
-  ConfigRecord,
-  DenominationStatus,
-  DenomSelectionState,
-  ExchangeUpdateStatus,
-  ExchangeWireInfo,
-  PayCoinSelection,
-  ProposalDownload,
-  ProposalStatus,
-  RefreshSessionRecord,
-  RefundState,
-  ReserveBankInfo,
-  ReserveRecordStatus,
-  Stores,
-  WalletContractData,
-  WalletRefundItem,
-} from "../types/dbTypes";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants";
-import { AmountJson, Amounts, codecForAmountString } from "../util/amounts";
-import {
-  bytesToString,
-  decodeCrock,
-  eddsaGetPublic,
-  EddsaKeyPair,
-  encodeCrock,
-  getRandomBytes,
-  hash,
-  rsaBlind,
-  stringToBytes,
-} from "../crypto/talerCrypto";
-import { canonicalizeBaseUrl, canonicalJson, j2s } from "../util/helpers";
-import { getTimestampNow, Timestamp } from "../util/time";
-import { URL } from "../util/url";
-import {
-  AmountString,
-  codecForContractTerms,
-  ContractTerms,
-} from "../types/talerTypes";
-import {
-  buildCodecForObject,
-  Codec,
-  codecForNumber,
-  codecForString,
-} from "../util/codec";
-import {
-  HttpResponseStatus,
-  readSuccessResponseJsonOrThrow,
-  throwUnexpectedRequestError,
-} from "../util/http";
-import { Logger } from "../util/logging";
-import { gunzipSync, gzipSync } from "fflate";
-import { kdf } from "../crypto/primitives/kdf";
-import { initRetryInfo } from "../util/retries";
-import {
-  ConfirmPayResultType,
-  PreparePayResultType,
-  RecoveryLoadRequest,
-  RecoveryMergeStrategy,
-  RefreshReason,
-} from "../types/walletTypes";
-import { CryptoApi } from "../crypto/workers/cryptoApi";
-import { secretbox, secretbox_open } from "../crypto/primitives/nacl-fast";
-import { str } from "../i18n";
-import { confirmPay, preparePayForUri } from "./pay";
-
-interface WalletBackupConfState {
-  deviceId: string;
-  walletRootPub: string;
-  walletRootPriv: string;
-  clocks: { [device_id: string]: number };
-
-  /**
-   * Last hash of the canonicalized plain-text backup.
-   *
-   * Used to determine whether the wallet's content changed
-   * and we need to bump the clock.
-   */
-  lastBackupPlainHash?: string;
-
-  /**
-   * Timestamp stored in the last backup.
-   */
-  lastBackupTimestamp?: Timestamp;
-
-  /**
-   * Last time we tried to do a backup.
-   */
-  lastBackupCheckTimestamp?: Timestamp;
-  lastBackupNonce?: string;
-}
-
-const WALLET_BACKUP_STATE_KEY = "walletBackupState";
-
-const logger = new Logger("operations/backup.ts");
-
-async function provideBackupState(
-  ws: InternalWalletState,
-): Promise<WalletBackupConfState> {
-  const bs: ConfigRecord<WalletBackupConfState> | undefined = await ws.db.get(
-    Stores.config,
-    WALLET_BACKUP_STATE_KEY,
-  );
-  if (bs) {
-    return bs.value;
-  }
-  // We need to generate the key outside of the transaction
-  // due to how IndexedDB works.
-  const k = await ws.cryptoApi.createEddsaKeypair();
-  const d = getRandomBytes(5);
-  // FIXME: device ID should be configured when wallet is initialized
-  // and be based on hostname
-  const deviceId = `wallet-core-${encodeCrock(d)}`;
-  return await ws.db.runWithWriteTransaction([Stores.config], async (tx) => {
-    let backupStateEntry:
-      | ConfigRecord<WalletBackupConfState>
-      | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
-    if (!backupStateEntry) {
-      backupStateEntry = {
-        key: WALLET_BACKUP_STATE_KEY,
-        value: {
-          deviceId,
-          clocks: { [deviceId]: 1 },
-          walletRootPub: k.pub,
-          walletRootPriv: k.priv,
-          lastBackupPlainHash: undefined,
-        },
-      };
-      await tx.put(Stores.config, backupStateEntry);
-    }
-    return backupStateEntry.value;
-  });
-}
-
-async function getWalletBackupState(
-  ws: InternalWalletState,
-  tx: TransactionHandle<typeof Stores.config>,
-): Promise<WalletBackupConfState> {
-  let bs = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
-  checkDbInvariant(!!bs, "wallet backup state should be in DB");
-  return bs.value;
-}
-
-export async function exportBackup(
-  ws: InternalWalletState,
-): Promise<WalletBackupContentV1> {
-  await provideBackupState(ws);
-  return ws.db.runWithWriteTransaction(
-    [
-      Stores.config,
-      Stores.exchanges,
-      Stores.coins,
-      Stores.denominations,
-      Stores.purchases,
-      Stores.proposals,
-      Stores.refreshGroups,
-      Stores.backupProviders,
-      Stores.tips,
-      Stores.recoupGroups,
-      Stores.reserves,
-      Stores.withdrawalGroups,
-    ],
-    async (tx) => {
-      const bs = await getWalletBackupState(ws, tx);
-
-      const backupExchanges: BackupExchange[] = [];
-      const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {};
-      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[];
-      } = {};
-
-      await tx.iter(Stores.withdrawalGroups).forEachAsync(async (wg) => {
-        const withdrawalGroups = (withdrawalGroupsByReserve[
-          wg.reservePub
-        ] ??= []);
-        withdrawalGroups.push({
-          raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount),
-          selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({
-            count: x.count,
-            denom_pub_hash: x.denomPubHash,
-          })),
-          timestamp_created: wg.timestampStart,
-          timestamp_finish: wg.timestampFinish,
-          withdrawal_group_id: wg.withdrawalGroupId,
-          secret_seed: wg.secretSeed,
-        });
-      });
-
-      await tx.iter(Stores.reserves).forEach((reserve) => {
-        const backupReserve: BackupReserve = {
-          initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map(
-            (x) => ({
-              count: x.count,
-              denom_pub_hash: x.denomPubHash,
-            }),
-          ),
-          initial_withdrawal_group_id: reserve.initialWithdrawalGroupId,
-          instructed_amount: Amounts.stringify(reserve.instructedAmount),
-          reserve_priv: reserve.reservePriv,
-          timestamp_created: reserve.timestampCreated,
-          withdrawal_groups:
-            withdrawalGroupsByReserve[reserve.reservePub] ?? [],
-          // FIXME!
-          timestamp_last_activity: reserve.timestampCreated,
-        };
-        const backupReserves = (backupReservesByExchange[
-          reserve.exchangeBaseUrl
-        ] ??= []);
-        backupReserves.push(backupReserve);
-      });
-
-      await tx.iter(Stores.tips).forEach((tip) => {
-        backupTips.push({
-          exchange_base_url: tip.exchangeBaseUrl,
-          merchant_base_url: tip.merchantBaseUrl,
-          merchant_tip_id: tip.merchantTipId,
-          wallet_tip_id: tip.walletTipId,
-          secret_seed: tip.secretSeed,
-          selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({
-            count: x.count,
-            denom_pub_hash: x.denomPubHash,
-          })),
-          timestamp_finished: tip.pickedUpTimestamp,
-          timestamp_accepted: tip.acceptedTimestamp,
-          timestamp_created: tip.createdTimestamp,
-          timestamp_expiration: tip.tipExpiration,
-          tip_amount_raw: Amounts.stringify(tip.tipAmountRaw),
-        });
-      });
-
-      await tx.iter(Stores.recoupGroups).forEach((recoupGroup) => {
-        backupRecoupGroups.push({
-          recoup_group_id: recoupGroup.recoupGroupId,
-          timestamp_created: recoupGroup.timestampStarted,
-          timestamp_finish: recoupGroup.timestampFinished,
-          coins: recoupGroup.coinPubs.map((x, i) => ({
-            coin_pub: x,
-            recoup_finished: recoupGroup.recoupFinishedPerCoin[i],
-            old_amount: Amounts.stringify(recoupGroup.oldAmountPerCoin[i]),
-          })),
-        });
-      });
-
-      await tx.iter(Stores.backupProviders).forEach((bp) => {
-        let terms: BackupBackupProviderTerms | undefined;
-        if (bp.terms) {
-          terms = {
-            annual_fee: Amounts.stringify(bp.terms.annualFee),
-            storage_limit_in_megabytes: bp.terms.storageLimitInMegabytes,
-            supported_protocol_version: bp.terms.supportedProtocolVersion,
-          };
-        }
-        backupBackupProviders.push({
-          terms,
-          base_url: canonicalizeBaseUrl(bp.baseUrl),
-          pay_proposal_ids: bp.paymentProposalIds,
-        });
-      });
-
-      await tx.iter(Stores.coins).forEach((coin) => {
-        let bcs: BackupCoinSource;
-        switch (coin.coinSource.type) {
-          case CoinSourceType.Refresh:
-            bcs = {
-              type: BackupCoinSourceType.Refresh,
-              old_coin_pub: coin.coinSource.oldCoinPub,
-            };
-            break;
-          case CoinSourceType.Tip:
-            bcs = {
-              type: BackupCoinSourceType.Tip,
-              coin_index: coin.coinSource.coinIndex,
-              wallet_tip_id: coin.coinSource.walletTipId,
-            };
-            break;
-          case CoinSourceType.Withdraw:
-            bcs = {
-              type: BackupCoinSourceType.Withdraw,
-              coin_index: coin.coinSource.coinIndex,
-              reserve_pub: coin.coinSource.reservePub,
-              withdrawal_group_id: coin.coinSource.withdrawalGroupId,
-            };
-            break;
-        }
-
-        const coins = (backupCoinsByDenom[coin.denomPubHash] ??= []);
-        coins.push({
-          blinding_key: coin.blindingKey,
-          coin_priv: coin.coinPriv,
-          coin_source: bcs,
-          current_amount: Amounts.stringify(coin.currentAmount),
-          fresh: coin.status === CoinStatus.Fresh,
-          denom_sig: coin.denomSig,
-        });
-      });
-
-      await tx.iter(Stores.denominations).forEach((denom) => {
-        const backupDenoms = (backupDenominationsByExchange[
-          denom.exchangeBaseUrl
-        ] ??= []);
-        backupDenoms.push({
-          coins: backupCoinsByDenom[denom.denomPubHash] ?? [],
-          denom_pub: denom.denomPub,
-          fee_deposit: Amounts.stringify(denom.feeDeposit),
-          fee_refresh: Amounts.stringify(denom.feeRefresh),
-          fee_refund: Amounts.stringify(denom.feeRefund),
-          fee_withdraw: Amounts.stringify(denom.feeWithdraw),
-          is_offered: denom.isOffered,
-          is_revoked: denom.isRevoked,
-          master_sig: denom.masterSig,
-          stamp_expire_deposit: denom.stampExpireDeposit,
-          stamp_expire_legal: denom.stampExpireLegal,
-          stamp_expire_withdraw: denom.stampExpireWithdraw,
-          stamp_start: denom.stampStart,
-          value: Amounts.stringify(denom.value),
-        });
-      });
-
-      await tx.iter(Stores.exchanges).forEach((ex) => {
-        // Only back up permanently added exchanges.
-
-        if (!ex.details) {
-          return;
-        }
-        if (!ex.wireInfo) {
-          return;
-        }
-        if (!ex.addComplete) {
-          return;
-        }
-        if (!ex.permanent) {
-          return;
-        }
-        const wi = ex.wireInfo;
-        const wireFees: BackupExchangeWireFee[] = [];
-
-        Object.keys(wi.feesForType).forEach((x) => {
-          for (const f of wi.feesForType[x]) {
-            wireFees.push({
-              wire_type: x,
-              closing_fee: Amounts.stringify(f.closingFee),
-              end_stamp: f.endStamp,
-              sig: f.sig,
-              start_stamp: f.startStamp,
-              wire_fee: Amounts.stringify(f.wireFee),
-            });
-          }
-        });
-
-        backupExchanges.push({
-          base_url: ex.baseUrl,
-          reserve_closing_delay: ex.details.reserveClosingDelay,
-          accounts: ex.wireInfo.accounts.map((x) => ({
-            payto_uri: x.payto_uri,
-            master_sig: x.master_sig,
-          })),
-          auditors: ex.details.auditors.map((x) => ({
-            auditor_pub: x.auditor_pub,
-            auditor_url: x.auditor_url,
-            denomination_keys: x.denomination_keys,
-          })),
-          master_public_key: ex.details.masterPublicKey,
-          currency: ex.details.currency,
-          protocol_version: ex.details.protocolVersion,
-          wire_fees: wireFees,
-          signing_keys: ex.details.signingKeys.map((x) => ({
-            key: x.key,
-            master_sig: x.master_sig,
-            stamp_end: x.stamp_end,
-            stamp_expire: x.stamp_expire,
-            stamp_start: x.stamp_start,
-          })),
-          tos_etag_accepted: ex.termsOfServiceAcceptedEtag,
-          tos_etag_last: ex.termsOfServiceLastEtag,
-          denominations: backupDenominationsByExchange[ex.baseUrl] ?? [],
-          reserves: backupReservesByExchange[ex.baseUrl] ?? [],
-        });
-      });
-
-      const purchaseProposalIdSet = new Set<string>();
-
-      await tx.iter(Stores.purchases).forEach((purch) => {
-        const refunds: BackupRefundItem[] = [];
-        purchaseProposalIdSet.add(purch.proposalId);
-        for (const refundKey of Object.keys(purch.refunds)) {
-          const ri = purch.refunds[refundKey];
-          const common = {
-            coin_pub: ri.coinPub,
-            execution_time: ri.executionTime,
-            obtained_time: ri.obtainedTime,
-            refund_amount: Amounts.stringify(ri.refundAmount),
-            rtransaction_id: ri.rtransactionId,
-            total_refresh_cost_bound: Amounts.stringify(
-              ri.totalRefreshCostBound,
-            ),
-          };
-          switch (ri.type) {
-            case RefundState.Applied:
-              refunds.push({ type: BackupRefundState.Applied, ...common });
-              break;
-            case RefundState.Failed:
-              refunds.push({ type: BackupRefundState.Failed, ...common });
-              break;
-            case RefundState.Pending:
-              refunds.push({ type: BackupRefundState.Pending, ...common });
-              break;
-          }
-        }
-
-        backupPurchases.push({
-          contract_terms_raw: purch.download.contractTermsRaw,
-          auto_refund_deadline: purch.autoRefundDeadline,
-          merchant_pay_sig: purch.merchantPaySig,
-          pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
-            coin_pub: x,
-            contribution: Amounts.stringify(
-              purch.payCoinSelection.coinContributions[i],
-            ),
-          })),
-          proposal_id: purch.proposalId,
-          refunds,
-          timestamp_accept: purch.timestampAccept,
-          timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
-          abort_status:
-            purch.abortStatus === AbortStatus.None
-              ? undefined
-              : purch.abortStatus,
-          nonce_priv: purch.noncePriv,
-          merchant_sig: purch.download.contractData.merchantSig,
-          total_pay_cost: Amounts.stringify(purch.totalPayCost),
-        });
-      });
-
-      await tx.iter(Stores.proposals).forEach((prop) => {
-        if (purchaseProposalIdSet.has(prop.proposalId)) {
-          return;
-        }
-        let propStatus: BackupProposalStatus;
-        switch (prop.proposalStatus) {
-          case ProposalStatus.ACCEPTED:
-            return;
-          case ProposalStatus.DOWNLOADING:
-          case ProposalStatus.PROPOSED:
-            propStatus = BackupProposalStatus.Proposed;
-            break;
-          case ProposalStatus.PERMANENTLY_FAILED:
-            propStatus = BackupProposalStatus.PermanentlyFailed;
-            break;
-          case ProposalStatus.REFUSED:
-            propStatus = BackupProposalStatus.Refused;
-            break;
-          case ProposalStatus.REPURCHASE:
-            propStatus = BackupProposalStatus.Repurchase;
-            break;
-        }
-        backupProposals.push({
-          claim_token: prop.claimToken,
-          nonce_priv: prop.noncePriv,
-          proposal_id: prop.noncePriv,
-          proposal_status: propStatus,
-          repurchase_proposal_id: prop.repurchaseProposalId,
-          timestamp: prop.timestamp,
-          contract_terms_raw: prop.download?.contractTermsRaw,
-          download_session_id: prop.downloadSessionId,
-          merchant_base_url: prop.merchantBaseUrl,
-          order_id: prop.orderId,
-          merchant_sig: prop.download?.contractData.merchantSig,
-        });
-      });
-
-      await tx.iter(Stores.refreshGroups).forEach((rg) => {
-        const oldCoins: BackupRefreshOldCoin[] = [];
-
-        for (let i = 0; i < rg.oldCoinPubs.length; i++) {
-          let refreshSession: BackupRefreshSession | undefined;
-          const s = rg.refreshSessionPerCoin[i];
-          if (s) {
-            refreshSession = {
-              new_denoms: s.newDenoms.map((x) => ({
-                count: x.count,
-                denom_pub_hash: x.denomPubHash,
-              })),
-              session_secret_seed: s.sessionSecretSeed,
-              noreveal_index: s.norevealIndex,
-            };
-          }
-          oldCoins.push({
-            coin_pub: rg.oldCoinPubs[i],
-            estimated_output_amount: Amounts.stringify(
-              rg.estimatedOutputPerCoin[i],
-            ),
-            finished: rg.finishedPerCoin[i],
-            input_amount: Amounts.stringify(rg.inputPerCoin[i]),
-            refresh_session: refreshSession,
-          });
-        }
-
-        backupRefreshGroups.push({
-          reason: rg.reason as any,
-          refresh_group_id: rg.refreshGroupId,
-          timestamp_created: rg.timestampCreated,
-          timestamp_finish: rg.timestampFinished,
-          old_coins: oldCoins,
-        });
-      });
-
-      if (!bs.lastBackupTimestamp) {
-        bs.lastBackupTimestamp = getTimestampNow();
-      }
-
-      const backupBlob: WalletBackupContentV1 = {
-        schema_id: "gnu-taler-wallet-backup-content",
-        schema_version: 1,
-        clocks: bs.clocks,
-        exchanges: backupExchanges,
-        wallet_root_pub: bs.walletRootPub,
-        backup_providers: backupBackupProviders,
-        current_device_id: bs.deviceId,
-        proposals: backupProposals,
-        purchase_tombstones: [],
-        purchases: backupPurchases,
-        recoup_groups: backupRecoupGroups,
-        refresh_groups: backupRefreshGroups,
-        tips: backupTips,
-        timestamp: bs.lastBackupTimestamp,
-        trusted_auditors: {},
-        trusted_exchanges: {},
-        intern_table: {},
-        error_reports: [],
-      };
-
-      // If the backup changed, we increment our clock.
-
-      let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
-      if (h != bs.lastBackupPlainHash) {
-        backupBlob.clocks[bs.deviceId] = ++bs.clocks[bs.deviceId];
-        bs.lastBackupPlainHash = encodeCrock(
-          hash(stringToBytes(canonicalJson(backupBlob))),
-        );
-        bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
-        await tx.put(Stores.config, {
-          key: WALLET_BACKUP_STATE_KEY,
-          value: bs,
-        });
-      }
-
-      return backupBlob;
-    },
-  );
-}
-
-function concatArrays(xs: Uint8Array[]): Uint8Array {
-  let len = 0;
-  for (const x of xs) {
-    len += x.byteLength;
-  }
-  const out = new Uint8Array(len);
-  let offset = 0;
-  for (const x of xs) {
-    out.set(x, offset);
-    offset += x.length;
-  }
-  return out;
-}
-
-const magic = "TLRWBK01";
-
-/**
- * Encrypt the backup.
- *
- * Blob format:
- * Magic "TLRWBK01" (8 bytes)
- * Nonce (24 bytes)
- * Compressed JSON blob (rest)
- */
-export async function encryptBackup(
-  config: WalletBackupConfState,
-  blob: WalletBackupContentV1,
-): Promise<Uint8Array> {
-  const chunks: Uint8Array[] = [];
-  chunks.push(stringToBytes(magic));
-  const nonceStr = config.lastBackupNonce;
-  checkLogicInvariant(!!nonceStr);
-  const nonce = decodeCrock(nonceStr).slice(0, 24);
-  chunks.push(nonce);
-  const backupJsonContent = canonicalJson(blob);
-  logger.trace("backup JSON size", backupJsonContent.length);
-  const compressedContent = gzipSync(stringToBytes(backupJsonContent));
-  const secret = deriveBlobSecret(config);
-  const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
-  chunks.push(encrypted);
-  return concatArrays(chunks);
-}
-
-interface CompletedCoin {
-  coinPub: string;
-  coinEvHash: string;
-}
-
-/**
- * Precomputed cryptographic material for a backup import.
- *
- * We separate this data from the backup blob as we want the backup
- * blob to be small, and we can't compute it during the database transaction,
- * as the async crypto worker communication would auto-close the database 
transaction.
- */
-interface BackupCryptoPrecomputedData {
-  denomPubToHash: Record<string, string>;
-  coinPrivToCompletedCoin: Record<string, CompletedCoin>;
-  proposalNoncePrivToPub: { [priv: string]: string };
-  proposalIdToContractTermsHash: { [proposalId: string]: string };
-  reservePrivToPub: Record<string, string>;
-}
-
-/**
- * Compute cryptographic values for a backup blob.
- *
- * FIXME: Take data that we already know from the DB.
- * FIXME: Move computations into crypto worker.
- */
-async function computeBackupCryptoData(
-  cryptoApi: CryptoApi,
-  backupContent: WalletBackupContentV1,
-): Promise<BackupCryptoPrecomputedData> {
-  const cryptoData: BackupCryptoPrecomputedData = {
-    coinPrivToCompletedCoin: {},
-    denomPubToHash: {},
-    proposalIdToContractTermsHash: {},
-    proposalNoncePrivToPub: {},
-    reservePrivToPub: {},
-  };
-  for (const backupExchange of backupContent.exchanges) {
-    for (const backupDenom of backupExchange.denominations) {
-      for (const backupCoin of backupDenom.coins) {
-        const coinPub = encodeCrock(
-          eddsaGetPublic(decodeCrock(backupCoin.coin_priv)),
-        );
-        const blindedCoin = rsaBlind(
-          hash(decodeCrock(backupCoin.coin_priv)),
-          decodeCrock(backupCoin.blinding_key),
-          decodeCrock(backupDenom.denom_pub),
-        );
-        cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = {
-          coinEvHash: encodeCrock(hash(blindedCoin)),
-          coinPub,
-        };
-      }
-      cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock(
-        hash(decodeCrock(backupDenom.denom_pub)),
-      );
-    }
-    for (const backupReserve of backupExchange.reserves) {
-      cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock(
-        eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)),
-      );
-    }
-  }
-  for (const prop of backupContent.proposals) {
-    const contractTermsHash = await cryptoApi.hashString(
-      canonicalJson(prop.contract_terms_raw),
-    );
-    const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
-    cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
-    cryptoData.proposalIdToContractTermsHash[
-      prop.proposal_id
-    ] = contractTermsHash;
-  }
-  for (const purch of backupContent.purchases) {
-    const contractTermsHash = await cryptoApi.hashString(
-      canonicalJson(purch.contract_terms_raw),
-    );
-    const noncePub = 
encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv)));
-    cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub;
-    cryptoData.proposalIdToContractTermsHash[
-      purch.proposal_id
-    ] = contractTermsHash;
-  }
-  return cryptoData;
-}
-
-function checkBackupInvariant(b: boolean, m?: string): asserts b {
-  if (!b) {
-    if (m) {
-      throw Error(`BUG: backup invariant failed (${m})`);
-    } else {
-      throw Error("BUG: backup invariant failed");
-    }
-  }
-}
-
-/**
- * Re-compute information about the coin selection for a payment.
- */
-async function recoverPayCoinSelection(
-  tx: TransactionHandle<
-    typeof Stores.exchanges | typeof Stores.coins | typeof Stores.denominations
-  >,
-  contractData: WalletContractData,
-  backupPurchase: BackupPurchase,
-): Promise<PayCoinSelection> {
-  const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
-  const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
-    Amounts.parseOrThrow(x.contribution),
-  );
-
-  const coveredExchanges: Set<string> = new Set();
-
-  let totalWireFee: AmountJson = Amounts.getZero(contractData.amount.currency);
-  let totalDepositFees: AmountJson = Amounts.getZero(
-    contractData.amount.currency,
-  );
-
-  for (const coinPub of coinPubs) {
-    const coinRecord = await tx.get(Stores.coins, coinPub);
-    checkBackupInvariant(!!coinRecord);
-    const denom = await tx.get(Stores.denominations, [
-      coinRecord.exchangeBaseUrl,
-      coinRecord.denomPubHash,
-    ]);
-    checkBackupInvariant(!!denom);
-    totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount;
-
-    if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) {
-      const exchange = await tx.get(
-        Stores.exchanges,
-        coinRecord.exchangeBaseUrl,
-      );
-      checkBackupInvariant(!!exchange);
-      let wireFee: AmountJson | undefined;
-      const feesForType = exchange.wireInfo?.feesForType;
-      checkBackupInvariant(!!feesForType);
-      for (const fee of feesForType[contractData.wireMethod] || []) {
-        if (
-          fee.startStamp <= contractData.timestamp &&
-          fee.endStamp >= contractData.timestamp
-        ) {
-          wireFee = fee.wireFee;
-          break;
-        }
-      }
-      if (wireFee) {
-        totalWireFee = Amounts.add(totalWireFee, wireFee).amount;
-      }
-    }
-  }
-
-  let customerWireFee: AmountJson;
-
-  const amortizedWireFee = Amounts.divide(
-    totalWireFee,
-    contractData.wireFeeAmortization,
-  );
-  if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
-    customerWireFee = amortizedWireFee;
-  } else {
-    customerWireFee = Amounts.getZero(contractData.amount.currency);
-  }
-
-  const customerDepositFees = Amounts.sub(
-    totalDepositFees,
-    contractData.maxDepositFee,
-  ).amount;
-
-  return {
-    coinPubs,
-    coinContributions,
-    paymentAmount: contractData.amount,
-    customerWireFees: customerWireFee,
-    customerDepositFees,
-  };
-}
-
-async function getDenomSelStateFromBackup(
-  tx: TransactionHandle<typeof Stores.denominations>,
-  exchangeBaseUrl: string,
-  sel: BackupDenomSel,
-): Promise<DenomSelectionState> {
-  const d0 = await tx.get(Stores.denominations, [
-    exchangeBaseUrl,
-    sel[0].denom_pub_hash,
-  ]);
-  checkBackupInvariant(!!d0);
-  const selectedDenoms: {
-    denomPubHash: string;
-    count: number;
-  }[] = [];
-  let totalCoinValue = Amounts.getZero(d0.value.currency);
-  let totalWithdrawCost = Amounts.getZero(d0.value.currency);
-  for (const s of sel) {
-    const d = await tx.get(Stores.denominations, [
-      exchangeBaseUrl,
-      s.denom_pub_hash,
-    ]);
-    checkBackupInvariant(!!d);
-    totalCoinValue = Amounts.add(totalCoinValue, d.value).amount;
-    totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw)
-      .amount;
-  }
-  return {
-    selectedDenoms,
-    totalCoinValue,
-    totalWithdrawCost,
-  };
-}
-
-export async function importBackup(
-  ws: InternalWalletState,
-  backupBlobArg: any,
-  cryptoComp: BackupCryptoPrecomputedData,
-): Promise<void> {
-  await provideBackupState(ws);
-  return ws.db.runWithWriteTransaction(
-    [
-      Stores.config,
-      Stores.exchanges,
-      Stores.coins,
-      Stores.denominations,
-      Stores.purchases,
-      Stores.proposals,
-      Stores.refreshGroups,
-      Stores.backupProviders,
-      Stores.tips,
-      Stores.recoupGroups,
-      Stores.reserves,
-      Stores.withdrawalGroups,
-    ],
-    async (tx) => {
-      // FIXME: validate schema!
-      const backupBlob = backupBlobArg as WalletBackupContentV1;
-
-      // FIXME: validate version
-
-      for (const backupExchange of backupBlob.exchanges) {
-        const existingExchange = await tx.get(
-          Stores.exchanges,
-          backupExchange.base_url,
-        );
-
-        if (!existingExchange) {
-          const wireInfo: ExchangeWireInfo = {
-            accounts: backupExchange.accounts.map((x) => ({
-              master_sig: x.master_sig,
-              payto_uri: x.payto_uri,
-            })),
-            feesForType: {},
-          };
-          for (const fee of backupExchange.wire_fees) {
-            const w = (wireInfo.feesForType[fee.wire_type] ??= []);
-            w.push({
-              closingFee: Amounts.parseOrThrow(fee.closing_fee),
-              endStamp: fee.end_stamp,
-              sig: fee.sig,
-              startStamp: fee.start_stamp,
-              wireFee: Amounts.parseOrThrow(fee.wire_fee),
-            });
-          }
-          await tx.put(Stores.exchanges, {
-            addComplete: true,
-            baseUrl: backupExchange.base_url,
-            builtIn: false,
-            updateReason: undefined,
-            permanent: true,
-            retryInfo: initRetryInfo(),
-            termsOfServiceAcceptedEtag: backupExchange.tos_etag_accepted,
-            termsOfServiceText: undefined,
-            termsOfServiceLastEtag: backupExchange.tos_etag_last,
-            updateStarted: getTimestampNow(),
-            updateStatus: ExchangeUpdateStatus.FetchKeys,
-            wireInfo,
-            details: {
-              currency: backupExchange.currency,
-              reserveClosingDelay: backupExchange.reserve_closing_delay,
-              auditors: backupExchange.auditors.map((x) => ({
-                auditor_pub: x.auditor_pub,
-                auditor_url: x.auditor_url,
-                denomination_keys: x.denomination_keys,
-              })),
-              lastUpdateTime: { t_ms: "never" },
-              masterPublicKey: backupExchange.master_public_key,
-              nextUpdateTime: { t_ms: "never" },
-              protocolVersion: backupExchange.protocol_version,
-              signingKeys: backupExchange.signing_keys.map((x) => ({
-                key: x.key,
-                master_sig: x.master_sig,
-                stamp_end: x.stamp_end,
-                stamp_expire: x.stamp_expire,
-                stamp_start: x.stamp_start,
-              })),
-            },
-          });
-        }
-
-        for (const backupDenomination of backupExchange.denominations) {
-          const denomPubHash =
-            cryptoComp.denomPubToHash[backupDenomination.denom_pub];
-          checkLogicInvariant(!!denomPubHash);
-          const existingDenom = await tx.get(Stores.denominations, [
-            backupExchange.base_url,
-            denomPubHash,
-          ]);
-          if (!existingDenom) {
-            await tx.put(Stores.denominations, {
-              denomPub: backupDenomination.denom_pub,
-              denomPubHash: denomPubHash,
-              exchangeBaseUrl: backupExchange.base_url,
-              feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit),
-              feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh),
-              feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund),
-              feeWithdraw: Amounts.parseOrThrow(
-                backupDenomination.fee_withdraw,
-              ),
-              isOffered: backupDenomination.is_offered,
-              isRevoked: backupDenomination.is_revoked,
-              masterSig: backupDenomination.master_sig,
-              stampExpireDeposit: backupDenomination.stamp_expire_deposit,
-              stampExpireLegal: backupDenomination.stamp_expire_legal,
-              stampExpireWithdraw: backupDenomination.stamp_expire_withdraw,
-              stampStart: backupDenomination.stamp_start,
-              status: DenominationStatus.VerifiedGood,
-              value: Amounts.parseOrThrow(backupDenomination.value),
-            });
-          }
-          for (const backupCoin of backupDenomination.coins) {
-            const compCoin =
-              cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv];
-            checkLogicInvariant(!!compCoin);
-            const existingCoin = await tx.get(Stores.coins, compCoin.coinPub);
-            if (!existingCoin) {
-              let coinSource: CoinSource;
-              switch (backupCoin.coin_source.type) {
-                case BackupCoinSourceType.Refresh:
-                  coinSource = {
-                    type: CoinSourceType.Refresh,
-                    oldCoinPub: backupCoin.coin_source.old_coin_pub,
-                  };
-                  break;
-                case BackupCoinSourceType.Tip:
-                  coinSource = {
-                    type: CoinSourceType.Tip,
-                    coinIndex: backupCoin.coin_source.coin_index,
-                    walletTipId: backupCoin.coin_source.wallet_tip_id,
-                  };
-                  break;
-                case BackupCoinSourceType.Withdraw:
-                  coinSource = {
-                    type: CoinSourceType.Withdraw,
-                    coinIndex: backupCoin.coin_source.coin_index,
-                    reservePub: backupCoin.coin_source.reserve_pub,
-                    withdrawalGroupId:
-                      backupCoin.coin_source.withdrawal_group_id,
-                  };
-                  break;
-              }
-              await tx.put(Stores.coins, {
-                blindingKey: backupCoin.blinding_key,
-                coinEvHash: compCoin.coinEvHash,
-                coinPriv: backupCoin.coin_priv,
-                currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),
-                denomSig: backupCoin.denom_sig,
-                coinPub: compCoin.coinPub,
-                suspended: false,
-                exchangeBaseUrl: backupExchange.base_url,
-                denomPub: backupDenomination.denom_pub,
-                denomPubHash,
-                status: backupCoin.fresh
-                  ? CoinStatus.Fresh
-                  : CoinStatus.Dormant,
-                coinSource,
-              });
-            }
-          }
-        }
-
-        for (const backupReserve of backupExchange.reserves) {
-          const reservePub =
-            cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
-          checkLogicInvariant(!!reservePub);
-          const existingReserve = await tx.get(Stores.reserves, reservePub);
-          const instructedAmount = Amounts.parseOrThrow(
-            backupReserve.instructed_amount,
-          );
-          if (!existingReserve) {
-            let bankInfo: ReserveBankInfo | undefined;
-            if (backupReserve.bank_info) {
-              bankInfo = {
-                exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
-                statusUrl: backupReserve.bank_info.status_url,
-                confirmUrl: backupReserve.bank_info.confirm_url,
-              };
-            }
-            await tx.put(Stores.reserves, {
-              currency: instructedAmount.currency,
-              instructedAmount,
-              exchangeBaseUrl: backupExchange.base_url,
-              reservePub,
-              reservePriv: backupReserve.reserve_priv,
-              requestedQuery: false,
-              bankInfo,
-              timestampCreated: backupReserve.timestamp_created,
-              timestampBankConfirmed:
-                backupReserve.bank_info?.timestamp_bank_confirmed,
-              timestampReserveInfoPosted:
-                backupReserve.bank_info?.timestamp_reserve_info_posted,
-              senderWire: backupReserve.sender_wire,
-              retryInfo: initRetryInfo(false),
-              lastError: undefined,
-              lastSuccessfulStatusQuery: { t_ms: "never" },
-              initialWithdrawalGroupId:
-                backupReserve.initial_withdrawal_group_id,
-              initialWithdrawalStarted:
-                backupReserve.withdrawal_groups.length > 0,
-              // FIXME!
-              reserveStatus: ReserveRecordStatus.QUERYING_STATUS,
-              initialDenomSel: await getDenomSelStateFromBackup(
-                tx,
-                backupExchange.base_url,
-                backupReserve.initial_selected_denoms,
-              ),
-            });
-          }
-          for (const backupWg of backupReserve.withdrawal_groups) {
-            const existingWg = await tx.get(
-              Stores.withdrawalGroups,
-              backupWg.withdrawal_group_id,
-            );
-            if (!existingWg) {
-              await tx.put(Stores.withdrawalGroups, {
-                denomsSel: await getDenomSelStateFromBackup(
-                  tx,
-                  backupExchange.base_url,
-                  backupWg.selected_denoms,
-                ),
-                exchangeBaseUrl: backupExchange.base_url,
-                lastError: undefined,
-                rawWithdrawalAmount: Amounts.parseOrThrow(
-                  backupWg.raw_withdrawal_amount,
-                ),
-                reservePub,
-                retryInfo: initRetryInfo(false),
-                secretSeed: backupWg.secret_seed,
-                timestampStart: backupWg.timestamp_created,
-                timestampFinish: backupWg.timestamp_finish,
-                withdrawalGroupId: backupWg.withdrawal_group_id,
-              });
-            }
-          }
-        }
-      }
-
-      for (const backupProposal of backupBlob.proposals) {
-        const existingProposal = await tx.get(
-          Stores.proposals,
-          backupProposal.proposal_id,
-        );
-        if (!existingProposal) {
-          let download: ProposalDownload | undefined;
-          let proposalStatus: ProposalStatus;
-          switch (backupProposal.proposal_status) {
-            case BackupProposalStatus.Proposed:
-              if (backupProposal.contract_terms_raw) {
-                proposalStatus = ProposalStatus.PROPOSED;
-              } else {
-                proposalStatus = ProposalStatus.DOWNLOADING;
-              }
-              break;
-            case BackupProposalStatus.Refused:
-              proposalStatus = ProposalStatus.REFUSED;
-              break;
-            case BackupProposalStatus.Repurchase:
-              proposalStatus = ProposalStatus.REPURCHASE;
-              break;
-            case BackupProposalStatus.PermanentlyFailed:
-              proposalStatus = ProposalStatus.PERMANENTLY_FAILED;
-              break;
-          }
-          if (backupProposal.contract_terms_raw) {
-            checkDbInvariant(!!backupProposal.merchant_sig);
-            const parsedContractTerms = codecForContractTerms().decode(
-              backupProposal.contract_terms_raw,
-            );
-            const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
-            const contractTermsHash =
-              cryptoComp.proposalIdToContractTermsHash[
-                backupProposal.proposal_id
-              ];
-            let maxWireFee: AmountJson;
-            if (parsedContractTerms.max_wire_fee) {
-              maxWireFee = Amounts.parseOrThrow(
-                parsedContractTerms.max_wire_fee,
-              );
-            } else {
-              maxWireFee = Amounts.getZero(amount.currency);
-            }
-            download = {
-              contractData: {
-                amount,
-                contractTermsHash: contractTermsHash,
-                fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
-                merchantBaseUrl: parsedContractTerms.merchant_base_url,
-                merchantPub: parsedContractTerms.merchant_pub,
-                merchantSig: backupProposal.merchant_sig,
-                orderId: parsedContractTerms.order_id,
-                summary: parsedContractTerms.summary,
-                autoRefund: parsedContractTerms.auto_refund,
-                maxWireFee,
-                payDeadline: parsedContractTerms.pay_deadline,
-                refundDeadline: parsedContractTerms.refund_deadline,
-                wireFeeAmortization:
-                  parsedContractTerms.wire_fee_amortization || 1,
-                allowedAuditors: parsedContractTerms.auditors.map((x) => ({
-                  auditorBaseUrl: x.url,
-                  auditorPub: x.auditor_pub,
-                })),
-                allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
-                  exchangeBaseUrl: x.url,
-                  exchangePub: x.master_pub,
-                })),
-                timestamp: parsedContractTerms.timestamp,
-                wireMethod: parsedContractTerms.wire_method,
-                wireInfoHash: parsedContractTerms.h_wire,
-                maxDepositFee: Amounts.parseOrThrow(
-                  parsedContractTerms.max_fee,
-                ),
-                merchant: parsedContractTerms.merchant,
-                products: parsedContractTerms.products,
-                summaryI18n: parsedContractTerms.summary_i18n,
-              },
-              contractTermsRaw: backupProposal.contract_terms_raw,
-            };
-          }
-          await tx.put(Stores.proposals, {
-            claimToken: backupProposal.claim_token,
-            lastError: undefined,
-            merchantBaseUrl: backupProposal.merchant_base_url,
-            timestamp: backupProposal.timestamp,
-            orderId: backupProposal.order_id,
-            noncePriv: backupProposal.nonce_priv,
-            noncePub:
-              cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
-            proposalId: backupProposal.proposal_id,
-            repurchaseProposalId: backupProposal.repurchase_proposal_id,
-            retryInfo: initRetryInfo(false),
-            download,
-            proposalStatus,
-          });
-        }
-      }
-
-      for (const backupPurchase of backupBlob.purchases) {
-        const existingPurchase = await tx.get(
-          Stores.purchases,
-          backupPurchase.proposal_id,
-        );
-        if (!existingPurchase) {
-          const refunds: { [refundKey: string]: WalletRefundItem } = {};
-          for (const backupRefund of backupPurchase.refunds) {
-            const key = 
`${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
-            const coin = await tx.get(Stores.coins, backupRefund.coin_pub);
-            checkBackupInvariant(!!coin);
-            const denom = await tx.get(Stores.denominations, [
-              coin.exchangeBaseUrl,
-              coin.denomPubHash,
-            ]);
-            checkBackupInvariant(!!denom);
-            const common = {
-              coinPub: backupRefund.coin_pub,
-              executionTime: backupRefund.execution_time,
-              obtainedTime: backupRefund.obtained_time,
-              refundAmount: Amounts.parseOrThrow(backupRefund.refund_amount),
-              refundFee: denom.feeRefund,
-              rtransactionId: backupRefund.rtransaction_id,
-              totalRefreshCostBound: Amounts.parseOrThrow(
-                backupRefund.total_refresh_cost_bound,
-              ),
-            };
-            switch (backupRefund.type) {
-              case BackupRefundState.Applied:
-                refunds[key] = {
-                  type: RefundState.Applied,
-                  ...common,
-                };
-                break;
-              case BackupRefundState.Failed:
-                refunds[key] = {
-                  type: RefundState.Failed,
-                  ...common,
-                };
-                break;
-              case BackupRefundState.Pending:
-                refunds[key] = {
-                  type: RefundState.Pending,
-                  ...common,
-                };
-                break;
-            }
-          }
-          let abortStatus: AbortStatus;
-          switch (backupPurchase.abort_status) {
-            case "abort-finished":
-              abortStatus = AbortStatus.AbortFinished;
-              break;
-            case "abort-refund":
-              abortStatus = AbortStatus.AbortRefund;
-              break;
-            case undefined:
-              abortStatus = AbortStatus.None;
-              break;
-            default:
-              logger.warn(
-                `got backup purchase abort_status ${j2s(
-                  backupPurchase.abort_status,
-                )}`,
-              );
-              throw Error("not reachable");
-          }
-          const parsedContractTerms = codecForContractTerms().decode(
-            backupPurchase.contract_terms_raw,
-          );
-          const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
-          const contractTermsHash =
-            cryptoComp.proposalIdToContractTermsHash[
-              backupPurchase.proposal_id
-            ];
-          let maxWireFee: AmountJson;
-          if (parsedContractTerms.max_wire_fee) {
-            maxWireFee = 
Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
-          } else {
-            maxWireFee = Amounts.getZero(amount.currency);
-          }
-          const download: ProposalDownload = {
-            contractData: {
-              amount,
-              contractTermsHash: contractTermsHash,
-              fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
-              merchantBaseUrl: parsedContractTerms.merchant_base_url,
-              merchantPub: parsedContractTerms.merchant_pub,
-              merchantSig: backupPurchase.merchant_sig,
-              orderId: parsedContractTerms.order_id,
-              summary: parsedContractTerms.summary,
-              autoRefund: parsedContractTerms.auto_refund,
-              maxWireFee,
-              payDeadline: parsedContractTerms.pay_deadline,
-              refundDeadline: parsedContractTerms.refund_deadline,
-              wireFeeAmortization:
-                parsedContractTerms.wire_fee_amortization || 1,
-              allowedAuditors: parsedContractTerms.auditors.map((x) => ({
-                auditorBaseUrl: x.url,
-                auditorPub: x.auditor_pub,
-              })),
-              allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
-                exchangeBaseUrl: x.url,
-                exchangePub: x.master_pub,
-              })),
-              timestamp: parsedContractTerms.timestamp,
-              wireMethod: parsedContractTerms.wire_method,
-              wireInfoHash: parsedContractTerms.h_wire,
-              maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
-              merchant: parsedContractTerms.merchant,
-              products: parsedContractTerms.products,
-              summaryI18n: parsedContractTerms.summary_i18n,
-            },
-            contractTermsRaw: backupPurchase.contract_terms_raw,
-          };
-          await tx.put(Stores.purchases, {
-            proposalId: backupPurchase.proposal_id,
-            noncePriv: backupPurchase.nonce_priv,
-            noncePub:
-              cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
-            lastPayError: undefined,
-            autoRefundDeadline: { t_ms: "never" },
-            refundStatusRetryInfo: initRetryInfo(false),
-            lastRefundStatusError: undefined,
-            timestampAccept: backupPurchase.timestamp_accept,
-            timestampFirstSuccessfulPay:
-              backupPurchase.timestamp_first_successful_pay,
-            timestampLastRefundStatus: undefined,
-            merchantPaySig: backupPurchase.merchant_pay_sig,
-            lastSessionId: undefined,
-            abortStatus,
-            // FIXME!
-            payRetryInfo: initRetryInfo(false),
-            download,
-            paymentSubmitPending: 
!backupPurchase.timestamp_first_successful_pay,
-            refundQueryRequested: false,
-            payCoinSelection: await recoverPayCoinSelection(
-              tx,
-              download.contractData,
-              backupPurchase,
-            ),
-            coinDepositPermissions: undefined,
-            totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
-            refunds,
-          });
-        }
-      }
-
-      for (const backupRefreshGroup of backupBlob.refresh_groups) {
-        const existingRg = await tx.get(
-          Stores.refreshGroups,
-          backupRefreshGroup.refresh_group_id,
-        );
-        if (!existingRg) {
-          let reason: RefreshReason;
-          switch (backupRefreshGroup.reason) {
-            case BackupRefreshReason.AbortPay:
-              reason = RefreshReason.AbortPay;
-              break;
-            case BackupRefreshReason.BackupRestored:
-              reason = RefreshReason.BackupRestored;
-              break;
-            case BackupRefreshReason.Manual:
-              reason = RefreshReason.Manual;
-              break;
-            case BackupRefreshReason.Pay:
-              reason = RefreshReason.Pay;
-              break;
-            case BackupRefreshReason.Recoup:
-              reason = RefreshReason.Recoup;
-              break;
-            case BackupRefreshReason.Refund:
-              reason = RefreshReason.Refund;
-              break;
-            case BackupRefreshReason.Scheduled:
-              reason = RefreshReason.Scheduled;
-              break;
-          }
-          const refreshSessionPerCoin: (
-            | RefreshSessionRecord
-            | undefined
-          )[] = [];
-          for (const oldCoin of backupRefreshGroup.old_coins) {
-            const c = await tx.get(Stores.coins, oldCoin.coin_pub);
-            checkBackupInvariant(!!c);
-            if (oldCoin.refresh_session) {
-              const denomSel = await getDenomSelStateFromBackup(
-                tx,
-                c.exchangeBaseUrl,
-                oldCoin.refresh_session.new_denoms,
-              );
-              refreshSessionPerCoin.push({
-                sessionSecretSeed: oldCoin.refresh_session.session_secret_seed,
-                norevealIndex: oldCoin.refresh_session.noreveal_index,
-                newDenoms: oldCoin.refresh_session.new_denoms.map((x) => ({
-                  count: x.count,
-                  denomPubHash: x.denom_pub_hash,
-                })),
-                amountRefreshOutput: denomSel.totalCoinValue,
-              });
-            } else {
-              refreshSessionPerCoin.push(undefined);
-            }
-          }
-          await tx.put(Stores.refreshGroups, {
-            timestampFinished: backupRefreshGroup.timestamp_finish,
-            timestampCreated: backupRefreshGroup.timestamp_created,
-            refreshGroupId: backupRefreshGroup.refresh_group_id,
-            reason,
-            lastError: undefined,
-            lastErrorPerCoin: {},
-            oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),
-            finishedPerCoin: backupRefreshGroup.old_coins.map(
-              (x) => x.finished,
-            ),
-            inputPerCoin: backupRefreshGroup.old_coins.map((x) =>
-              Amounts.parseOrThrow(x.input_amount),
-            ),
-            estimatedOutputPerCoin: backupRefreshGroup.old_coins.map((x) =>
-              Amounts.parseOrThrow(x.estimated_output_amount),
-            ),
-            refreshSessionPerCoin,
-            retryInfo: initRetryInfo(false),
-          });
-        }
-      }
-
-      for (const backupTip of backupBlob.tips) {
-        const existingTip = await tx.get(Stores.tips, backupTip.wallet_tip_id);
-        if (!existingTip) {
-          const denomsSel = await getDenomSelStateFromBackup(
-            tx,
-            backupTip.exchange_base_url,
-            backupTip.selected_denoms,
-          );
-          await tx.put(Stores.tips, {
-            acceptedTimestamp: backupTip.timestamp_accepted,
-            createdTimestamp: backupTip.timestamp_created,
-            denomsSel,
-            exchangeBaseUrl: backupTip.exchange_base_url,
-            lastError: undefined,
-            merchantBaseUrl: backupTip.exchange_base_url,
-            merchantTipId: backupTip.merchant_tip_id,
-            pickedUpTimestamp: backupTip.timestamp_finished,
-            retryInfo: initRetryInfo(false),
-            secretSeed: backupTip.secret_seed,
-            tipAmountEffective: denomsSel.totalCoinValue,
-            tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw),
-            tipExpiration: backupTip.timestamp_expiration,
-            walletTipId: backupTip.wallet_tip_id,
-          });
-        }
-      }
-    },
-  );
-}
-
-function deriveAccountKeyPair(
-  bc: WalletBackupConfState,
-  providerUrl: string,
-): EddsaKeyPair {
-  const privateKey = kdf(
-    32,
-    decodeCrock(bc.walletRootPriv),
-    stringToBytes("taler-sync-account-key-salt"),
-    stringToBytes(providerUrl),
-  );
-  return {
-    eddsaPriv: privateKey,
-    eddsaPub: eddsaGetPublic(privateKey),
-  };
-}
-
-function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
-  return kdf(
-    32,
-    decodeCrock(bc.walletRootPriv),
-    stringToBytes("taler-sync-blob-secret-salt"),
-    stringToBytes("taler-sync-blob-secret-info"),
-  );
-}
-
-/**
- * Do one backup cycle that consists of:
- * 1. Exporting a backup and try to upload it.
- *    Stop if this step succeeds.
- * 2. Download, verify and import backups from connected sync accounts.
- * 3. Upload the updated backup blob.
- */
-export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
-  const providers = await ws.db.iter(Stores.backupProviders).toArray();
-  logger.trace("got backup providers", providers);
-  const backupJson = await exportBackup(ws);
-  const backupConfig = await provideBackupState(ws);
-  const encBackup = await encryptBackup(backupConfig, backupJson);
-
-  const currentBackupHash = hash(encBackup);
-
-  for (const provider of providers) {
-    const accountKeyPair = deriveAccountKeyPair(backupConfig, 
provider.baseUrl);
-    logger.trace(`trying to upload backup to ${provider.baseUrl}`);
-
-    const syncSig = await ws.cryptoApi.makeSyncSignature({
-      newHash: encodeCrock(currentBackupHash),
-      oldHash: provider.lastBackupHash,
-      accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
-    });
-
-    logger.trace(`sync signature is ${syncSig}`);
-
-    const accountBackupUrl = new URL(
-      `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
-      provider.baseUrl,
-    );
-
-    const resp = await ws.http.fetch(accountBackupUrl.href, {
-      method: "POST",
-      body: encBackup,
-      headers: {
-        "content-type": "application/octet-stream",
-        "sync-signature": syncSig,
-        "if-none-match": encodeCrock(currentBackupHash),
-        ...(provider.lastBackupHash
-          ? {
-              "if-match": provider.lastBackupHash,
-            }
-          : {}),
-      },
-    });
-
-    logger.trace(`response status: ${resp.status}`);
-
-    if (resp.status === HttpResponseStatus.PaymentRequired) {
-      logger.trace("payment required for backup");
-      logger.trace(`headers: ${j2s(resp.headers)}`);
-      const talerUri = resp.headers.get("taler");
-      if (!talerUri) {
-        throw Error("no taler URI available to pay provider");
-      }
-      const res = await preparePayForUri(ws, talerUri);
-      let proposalId: string | undefined;
-      switch (res.status) {
-        case PreparePayResultType.InsufficientBalance:
-          // FIXME: record in provider state!
-          logger.warn("insufficient balance to pay for backup provider");
-          break;
-        case PreparePayResultType.PaymentPossible:
-        case PreparePayResultType.AlreadyConfirmed:
-          proposalId = res.proposalId;
-          break;
-      }
-      if (!proposalId) {
-        continue;
-      }
-      const p = proposalId;
-      await ws.db.runWithWriteTransaction(
-        [Stores.backupProviders],
-        async (tx) => {
-          const provRec = await tx.get(
-            Stores.backupProviders,
-            provider.baseUrl,
-          );
-          checkDbInvariant(!!provRec);
-          const ids = new Set(provRec.paymentProposalIds);
-          ids.add(p);
-          provRec.paymentProposalIds = Array.from(ids);
-          await tx.put(Stores.backupProviders, provRec);
-        },
-      );
-      const confirmRes = await confirmPay(ws, proposalId);
-      switch (confirmRes.type) {
-        case ConfirmPayResultType.Pending:
-          logger.warn("payment not yet finished yet");
-          break;
-      }
-    }
-    if (resp.status === HttpResponseStatus.NoContent) {
-      await ws.db.runWithWriteTransaction(
-        [Stores.backupProviders],
-        async (tx) => {
-          const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
-          if (!prov) {
-            return;
-          }
-          prov.lastBackupHash = encodeCrock(currentBackupHash);
-          prov.lastBackupTimestamp = getTimestampNow();
-          prov.lastBackupClock =
-            backupJson.clocks[backupJson.current_device_id];
-          await tx.put(Stores.backupProviders, prov);
-        },
-      );
-      continue;
-    }
-    if (resp.status === HttpResponseStatus.Conflict) {
-      logger.info("conflicting backup found");
-      const backupEnc = new Uint8Array(await resp.bytes());
-      const backupConfig = await provideBackupState(ws);
-      const blob = await decryptBackup(backupConfig, backupEnc);
-      const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
-      await importBackup(ws, blob, cryptoData);
-      await ws.db.runWithWriteTransaction(
-        [Stores.backupProviders],
-        async (tx) => {
-          const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
-          if (!prov) {
-            return;
-          }
-          prov.lastBackupHash = encodeCrock(hash(backupEnc));
-          prov.lastBackupClock = blob.clocks[blob.current_device_id];
-          prov.lastBackupTimestamp = getTimestampNow();
-          await tx.put(Stores.backupProviders, prov);
-        },
-      );
-      logger.info("processed existing backup");
-    }
-  }
-}
-
-interface SyncTermsOfServiceResponse {
-  // maximum backup size supported
-  storage_limit_in_megabytes: number;
-
-  // Fee for an account, per year.
-  annual_fee: AmountString;
-
-  // protocol version supported by the server,
-  // for now always "0.0".
-  version: string;
-}
-
-const codecForSyncTermsOfServiceResponse = (): Codec<
-  SyncTermsOfServiceResponse
-> =>
-  buildCodecForObject<SyncTermsOfServiceResponse>()
-    .property("storage_limit_in_megabytes", codecForNumber())
-    .property("annual_fee", codecForAmountString())
-    .property("version", codecForString())
-    .build("SyncTermsOfServiceResponse");
-
-export interface AddBackupProviderRequest {
-  backupProviderBaseUrl: string;
-  /**
-   * Activate the provider.  Should only be done after
-   * the user has reviewed the provider.
-   */
-  activate?: boolean;
-}
-
-export const codecForAddBackupProviderRequest = (): Codec<
-  AddBackupProviderRequest
-> =>
-  buildCodecForObject<AddBackupProviderRequest>()
-    .property("backupProviderBaseUrl", codecForString())
-    .build("AddBackupProviderRequest");
-
-export async function addBackupProvider(
-  ws: InternalWalletState,
-  req: AddBackupProviderRequest,
-): Promise<void> {
-  await provideBackupState(ws);
-  const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
-  const oldProv = await ws.db.get(Stores.backupProviders, canonUrl);
-  if (oldProv) {
-    if (req.activate) {
-      oldProv.active = true;
-      await ws.db.put(Stores.backupProviders, oldProv);
-    }
-    return;
-  }
-  const termsUrl = new URL("terms", canonUrl);
-  const resp = await ws.http.get(termsUrl.href);
-  const terms = await readSuccessResponseJsonOrThrow(
-    resp,
-    codecForSyncTermsOfServiceResponse(),
-  );
-  await ws.db.put(Stores.backupProviders, {
-    active: !!req.activate,
-    terms: {
-      annualFee: terms.annual_fee,
-      storageLimitInMegabytes: terms.storage_limit_in_megabytes,
-      supportedProtocolVersion: terms.version,
-    },
-    paymentProposalIds: [],
-    baseUrl: canonUrl,
-    lastError: undefined,
-    retryInfo: initRetryInfo(false),
-  });
-}
-
-export async function removeBackupProvider(
-  syncProviderBaseUrl: string,
-): Promise<void> {}
-
-export async function restoreFromRecoverySecret(): Promise<void> {}
-
-/**
- * Information about one provider.
- *
- * We don't store the account key here,
- * as that's derived from the wallet root key.
- */
-export interface ProviderInfo {
-  active: boolean;
-  syncProviderBaseUrl: string;
-  lastRemoteClock?: number;
-  lastBackupTimestamp?: Timestamp;
-  paymentProposalIds: string[];
-}
-
-export interface BackupInfo {
-  walletRootPub: string;
-  deviceId: string;
-  lastLocalClock: number;
-  providers: ProviderInfo[];
-}
-
-export async function importBackupPlain(
-  ws: InternalWalletState,
-  blob: any,
-): Promise<void> {
-  // FIXME: parse
-  const backup: WalletBackupContentV1 = blob;
-
-  const cryptoData = await computeBackupCryptoData(ws.cryptoApi, backup);
-
-  await importBackup(ws, blob, cryptoData);
-}
-
-/**
- * Get information about the current state of wallet backups.
- */
-export async function getBackupInfo(
-  ws: InternalWalletState,
-): Promise<BackupInfo> {
-  const backupConfig = await provideBackupState(ws);
-  const providers = await ws.db.iter(Stores.backupProviders).toArray();
-  return {
-    deviceId: backupConfig.deviceId,
-    lastLocalClock: backupConfig.clocks[backupConfig.deviceId],
-    walletRootPub: backupConfig.walletRootPub,
-    providers: providers.map((x) => ({
-      active: x.active,
-      lastRemoteClock: x.lastBackupClock,
-      syncProviderBaseUrl: x.baseUrl,
-      lastBackupTimestamp: x.lastBackupTimestamp,
-      paymentProposalIds: x.paymentProposalIds,
-    })),
-  };
-}
-
-export interface BackupRecovery {
-  walletRootPriv: string;
-  providers: {
-    url: string;
-  }[];
-}
-
-/**
- * Get information about the current state of wallet backups.
- */
-export async function getBackupRecovery(
-  ws: InternalWalletState,
-): Promise<BackupRecovery> {
-  const bs = await provideBackupState(ws);
-  const providers = await ws.db.iter(Stores.backupProviders).toArray();
-  return {
-    providers: providers
-      .filter((x) => x.active)
-      .map((x) => {
-        return {
-          url: x.baseUrl,
-        };
-      }),
-    walletRootPriv: bs.walletRootPriv,
-  };
-}
-
-async function backupRecoveryTheirs(
-  ws: InternalWalletState,
-  br: BackupRecovery,
-) {
-  await ws.db.runWithWriteTransaction(
-    [Stores.config, Stores.backupProviders],
-    async (tx) => {
-      let backupStateEntry:
-        | ConfigRecord<WalletBackupConfState>
-        | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
-      checkDbInvariant(!!backupStateEntry);
-      backupStateEntry.value.lastBackupNonce = undefined;
-      backupStateEntry.value.lastBackupTimestamp = undefined;
-      backupStateEntry.value.lastBackupCheckTimestamp = undefined;
-      backupStateEntry.value.lastBackupPlainHash = undefined;
-      backupStateEntry.value.walletRootPriv = br.walletRootPriv;
-      backupStateEntry.value.walletRootPub = encodeCrock(
-        eddsaGetPublic(decodeCrock(br.walletRootPriv)),
-      );
-      await tx.put(Stores.config, backupStateEntry);
-      for (const prov of br.providers) {
-        const existingProv = await tx.get(Stores.backupProviders, prov.url);
-        if (!existingProv) {
-          await tx.put(Stores.backupProviders, {
-            active: true,
-            baseUrl: prov.url,
-            paymentProposalIds: [],
-            retryInfo: initRetryInfo(false),
-            lastError: undefined,
-          });
-        }
-      }
-      const providers = await tx.iter(Stores.backupProviders).toArray();
-      for (const prov of providers) {
-        prov.lastBackupTimestamp = undefined;
-        prov.lastBackupHash = undefined;
-        prov.lastBackupClock = undefined;
-        await tx.put(Stores.backupProviders, prov);
-      }
-    },
-  );
-}
-
-async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) 
{
-  throw Error("not implemented");
-}
-
-export async function loadBackupRecovery(
-  ws: InternalWalletState,
-  br: RecoveryLoadRequest,
-): Promise<void> {
-  const bs = await provideBackupState(ws);
-  const providers = await ws.db.iter(Stores.backupProviders).toArray();
-  let strategy = br.strategy;
-  if (
-    br.recovery.walletRootPriv != bs.walletRootPriv &&
-    providers.length > 0 &&
-    !strategy
-  ) {
-    throw Error(
-      "recovery load strategy must be specified for wallet with existing 
providers",
-    );
-  } else if (!strategy) {
-    // Default to using the new key if we don't have providers yet.
-    strategy = RecoveryMergeStrategy.Theirs;
-  }
-  if (strategy === RecoveryMergeStrategy.Theirs) {
-    return backupRecoveryTheirs(ws, br.recovery);
-  } else {
-    return backupRecoveryOurs(ws, br.recovery);
-  }
-}
-
-export async function exportBackupEncrypted(
-  ws: InternalWalletState,
-): Promise<Uint8Array> {
-  await provideBackupState(ws);
-  const blob = await exportBackup(ws);
-  const bs = await ws.db.runWithWriteTransaction(
-    [Stores.config],
-    async (tx) => {
-      return await getWalletBackupState(ws, tx);
-    },
-  );
-  return encryptBackup(bs, blob);
-}
-
-export async function decryptBackup(
-  backupConfig: WalletBackupConfState,
-  data: Uint8Array,
-): Promise<WalletBackupContentV1> {
-  const rMagic = bytesToString(data.slice(0, 8));
-  if (rMagic != magic) {
-    throw Error("invalid backup file (magic tag mismatch)");
-  }
-
-  const nonce = data.slice(8, 8 + 24);
-  const box = data.slice(8 + 24);
-  const secret = deriveBlobSecret(backupConfig);
-  const dataCompressed = secretbox_open(box, nonce, secret);
-  if (!dataCompressed) {
-    throw Error("decryption failed");
-  }
-  return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
-}
-
-export async function importBackupEncrypted(
-  ws: InternalWalletState,
-  data: Uint8Array,
-): Promise<void> {
-  const backupConfig = await provideBackupState(ws);
-  const blob = await decryptBackup(backupConfig, data);
-  const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
-  await importBackup(ws, blob, cryptoData);
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts 
b/packages/taler-wallet-core/src/operations/backup/export.ts
new file mode 100644
index 00000000..a32aec39
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/backup/export.ts
@@ -0,0 +1,447 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ 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/>
+ */
+
+import { Stores, Amounts, CoinSourceType, CoinStatus, RefundState, 
AbortStatus, ProposalStatus, getTimestampNow, encodeCrock, stringToBytes, 
getRandomBytes } from "../..";
+import { hash } from "../../crypto/primitives/nacl-fast";
+import { WalletBackupContentV1, BackupExchange, BackupCoin, 
BackupDenomination, BackupReserve, BackupPurchase, BackupProposal, 
BackupRefreshGroup, BackupBackupProvider, BackupTip, BackupRecoupGroup, 
BackupWithdrawalGroup, BackupBackupProviderTerms, BackupCoinSource, 
BackupCoinSourceType, BackupExchangeWireFee, BackupRefundItem, 
BackupRefundState, BackupProposalStatus, BackupRefreshOldCoin, 
BackupRefreshSession } from "../../types/backupTypes";
+import { canonicalizeBaseUrl, canonicalJson } from "../../util/helpers";
+import { InternalWalletState } from "../state";
+import { provideBackupState, getWalletBackupState, WALLET_BACKUP_STATE_KEY } 
from "./state";
+
+/**
+ * Implementation of wallet backups (export/import/upload) and sync
+ * server management.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+export async function exportBackup(
+  ws: InternalWalletState,
+): Promise<WalletBackupContentV1> {
+  await provideBackupState(ws);
+  return ws.db.runWithWriteTransaction(
+    [
+      Stores.config,
+      Stores.exchanges,
+      Stores.coins,
+      Stores.denominations,
+      Stores.purchases,
+      Stores.proposals,
+      Stores.refreshGroups,
+      Stores.backupProviders,
+      Stores.tips,
+      Stores.recoupGroups,
+      Stores.reserves,
+      Stores.withdrawalGroups,
+    ],
+    async (tx) => {
+      const bs = await getWalletBackupState(ws, tx);
+
+      const backupExchanges: BackupExchange[] = [];
+      const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {};
+      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[];
+      } = {};
+
+      await tx.iter(Stores.withdrawalGroups).forEachAsync(async (wg) => {
+        const withdrawalGroups = (withdrawalGroupsByReserve[
+          wg.reservePub
+        ] ??= []);
+        withdrawalGroups.push({
+          raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount),
+          selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({
+            count: x.count,
+            denom_pub_hash: x.denomPubHash,
+          })),
+          timestamp_created: wg.timestampStart,
+          timestamp_finish: wg.timestampFinish,
+          withdrawal_group_id: wg.withdrawalGroupId,
+          secret_seed: wg.secretSeed,
+        });
+      });
+
+      await tx.iter(Stores.reserves).forEach((reserve) => {
+        const backupReserve: BackupReserve = {
+          initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map(
+            (x) => ({
+              count: x.count,
+              denom_pub_hash: x.denomPubHash,
+            }),
+          ),
+          initial_withdrawal_group_id: reserve.initialWithdrawalGroupId,
+          instructed_amount: Amounts.stringify(reserve.instructedAmount),
+          reserve_priv: reserve.reservePriv,
+          timestamp_created: reserve.timestampCreated,
+          withdrawal_groups:
+            withdrawalGroupsByReserve[reserve.reservePub] ?? [],
+          // FIXME!
+          timestamp_last_activity: reserve.timestampCreated,
+        };
+        const backupReserves = (backupReservesByExchange[
+          reserve.exchangeBaseUrl
+        ] ??= []);
+        backupReserves.push(backupReserve);
+      });
+
+      await tx.iter(Stores.tips).forEach((tip) => {
+        backupTips.push({
+          exchange_base_url: tip.exchangeBaseUrl,
+          merchant_base_url: tip.merchantBaseUrl,
+          merchant_tip_id: tip.merchantTipId,
+          wallet_tip_id: tip.walletTipId,
+          secret_seed: tip.secretSeed,
+          selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({
+            count: x.count,
+            denom_pub_hash: x.denomPubHash,
+          })),
+          timestamp_finished: tip.pickedUpTimestamp,
+          timestamp_accepted: tip.acceptedTimestamp,
+          timestamp_created: tip.createdTimestamp,
+          timestamp_expiration: tip.tipExpiration,
+          tip_amount_raw: Amounts.stringify(tip.tipAmountRaw),
+        });
+      });
+
+      await tx.iter(Stores.recoupGroups).forEach((recoupGroup) => {
+        backupRecoupGroups.push({
+          recoup_group_id: recoupGroup.recoupGroupId,
+          timestamp_created: recoupGroup.timestampStarted,
+          timestamp_finish: recoupGroup.timestampFinished,
+          coins: recoupGroup.coinPubs.map((x, i) => ({
+            coin_pub: x,
+            recoup_finished: recoupGroup.recoupFinishedPerCoin[i],
+            old_amount: Amounts.stringify(recoupGroup.oldAmountPerCoin[i]),
+          })),
+        });
+      });
+
+      await tx.iter(Stores.backupProviders).forEach((bp) => {
+        let terms: BackupBackupProviderTerms | undefined;
+        if (bp.terms) {
+          terms = {
+            annual_fee: Amounts.stringify(bp.terms.annualFee),
+            storage_limit_in_megabytes: bp.terms.storageLimitInMegabytes,
+            supported_protocol_version: bp.terms.supportedProtocolVersion,
+          };
+        }
+        backupBackupProviders.push({
+          terms,
+          base_url: canonicalizeBaseUrl(bp.baseUrl),
+          pay_proposal_ids: bp.paymentProposalIds,
+        });
+      });
+
+      await tx.iter(Stores.coins).forEach((coin) => {
+        let bcs: BackupCoinSource;
+        switch (coin.coinSource.type) {
+          case CoinSourceType.Refresh:
+            bcs = {
+              type: BackupCoinSourceType.Refresh,
+              old_coin_pub: coin.coinSource.oldCoinPub,
+            };
+            break;
+          case CoinSourceType.Tip:
+            bcs = {
+              type: BackupCoinSourceType.Tip,
+              coin_index: coin.coinSource.coinIndex,
+              wallet_tip_id: coin.coinSource.walletTipId,
+            };
+            break;
+          case CoinSourceType.Withdraw:
+            bcs = {
+              type: BackupCoinSourceType.Withdraw,
+              coin_index: coin.coinSource.coinIndex,
+              reserve_pub: coin.coinSource.reservePub,
+              withdrawal_group_id: coin.coinSource.withdrawalGroupId,
+            };
+            break;
+        }
+
+        const coins = (backupCoinsByDenom[coin.denomPubHash] ??= []);
+        coins.push({
+          blinding_key: coin.blindingKey,
+          coin_priv: coin.coinPriv,
+          coin_source: bcs,
+          current_amount: Amounts.stringify(coin.currentAmount),
+          fresh: coin.status === CoinStatus.Fresh,
+          denom_sig: coin.denomSig,
+        });
+      });
+
+      await tx.iter(Stores.denominations).forEach((denom) => {
+        const backupDenoms = (backupDenominationsByExchange[
+          denom.exchangeBaseUrl
+        ] ??= []);
+        backupDenoms.push({
+          coins: backupCoinsByDenom[denom.denomPubHash] ?? [],
+          denom_pub: denom.denomPub,
+          fee_deposit: Amounts.stringify(denom.feeDeposit),
+          fee_refresh: Amounts.stringify(denom.feeRefresh),
+          fee_refund: Amounts.stringify(denom.feeRefund),
+          fee_withdraw: Amounts.stringify(denom.feeWithdraw),
+          is_offered: denom.isOffered,
+          is_revoked: denom.isRevoked,
+          master_sig: denom.masterSig,
+          stamp_expire_deposit: denom.stampExpireDeposit,
+          stamp_expire_legal: denom.stampExpireLegal,
+          stamp_expire_withdraw: denom.stampExpireWithdraw,
+          stamp_start: denom.stampStart,
+          value: Amounts.stringify(denom.value),
+        });
+      });
+
+      await tx.iter(Stores.exchanges).forEach((ex) => {
+        // Only back up permanently added exchanges.
+
+        if (!ex.details) {
+          return;
+        }
+        if (!ex.wireInfo) {
+          return;
+        }
+        if (!ex.addComplete) {
+          return;
+        }
+        if (!ex.permanent) {
+          return;
+        }
+        const wi = ex.wireInfo;
+        const wireFees: BackupExchangeWireFee[] = [];
+
+        Object.keys(wi.feesForType).forEach((x) => {
+          for (const f of wi.feesForType[x]) {
+            wireFees.push({
+              wire_type: x,
+              closing_fee: Amounts.stringify(f.closingFee),
+              end_stamp: f.endStamp,
+              sig: f.sig,
+              start_stamp: f.startStamp,
+              wire_fee: Amounts.stringify(f.wireFee),
+            });
+          }
+        });
+
+        backupExchanges.push({
+          base_url: ex.baseUrl,
+          reserve_closing_delay: ex.details.reserveClosingDelay,
+          accounts: ex.wireInfo.accounts.map((x) => ({
+            payto_uri: x.payto_uri,
+            master_sig: x.master_sig,
+          })),
+          auditors: ex.details.auditors.map((x) => ({
+            auditor_pub: x.auditor_pub,
+            auditor_url: x.auditor_url,
+            denomination_keys: x.denomination_keys,
+          })),
+          master_public_key: ex.details.masterPublicKey,
+          currency: ex.details.currency,
+          protocol_version: ex.details.protocolVersion,
+          wire_fees: wireFees,
+          signing_keys: ex.details.signingKeys.map((x) => ({
+            key: x.key,
+            master_sig: x.master_sig,
+            stamp_end: x.stamp_end,
+            stamp_expire: x.stamp_expire,
+            stamp_start: x.stamp_start,
+          })),
+          tos_etag_accepted: ex.termsOfServiceAcceptedEtag,
+          tos_etag_last: ex.termsOfServiceLastEtag,
+          denominations: backupDenominationsByExchange[ex.baseUrl] ?? [],
+          reserves: backupReservesByExchange[ex.baseUrl] ?? [],
+        });
+      });
+
+      const purchaseProposalIdSet = new Set<string>();
+
+      await tx.iter(Stores.purchases).forEach((purch) => {
+        const refunds: BackupRefundItem[] = [];
+        purchaseProposalIdSet.add(purch.proposalId);
+        for (const refundKey of Object.keys(purch.refunds)) {
+          const ri = purch.refunds[refundKey];
+          const common = {
+            coin_pub: ri.coinPub,
+            execution_time: ri.executionTime,
+            obtained_time: ri.obtainedTime,
+            refund_amount: Amounts.stringify(ri.refundAmount),
+            rtransaction_id: ri.rtransactionId,
+            total_refresh_cost_bound: Amounts.stringify(
+              ri.totalRefreshCostBound,
+            ),
+          };
+          switch (ri.type) {
+            case RefundState.Applied:
+              refunds.push({ type: BackupRefundState.Applied, ...common });
+              break;
+            case RefundState.Failed:
+              refunds.push({ type: BackupRefundState.Failed, ...common });
+              break;
+            case RefundState.Pending:
+              refunds.push({ type: BackupRefundState.Pending, ...common });
+              break;
+          }
+        }
+
+        backupPurchases.push({
+          contract_terms_raw: purch.download.contractTermsRaw,
+          auto_refund_deadline: purch.autoRefundDeadline,
+          merchant_pay_sig: purch.merchantPaySig,
+          pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
+            coin_pub: x,
+            contribution: Amounts.stringify(
+              purch.payCoinSelection.coinContributions[i],
+            ),
+          })),
+          proposal_id: purch.proposalId,
+          refunds,
+          timestamp_accept: purch.timestampAccept,
+          timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
+          abort_status:
+            purch.abortStatus === AbortStatus.None
+              ? undefined
+              : purch.abortStatus,
+          nonce_priv: purch.noncePriv,
+          merchant_sig: purch.download.contractData.merchantSig,
+          total_pay_cost: Amounts.stringify(purch.totalPayCost),
+        });
+      });
+
+      await tx.iter(Stores.proposals).forEach((prop) => {
+        if (purchaseProposalIdSet.has(prop.proposalId)) {
+          return;
+        }
+        let propStatus: BackupProposalStatus;
+        switch (prop.proposalStatus) {
+          case ProposalStatus.ACCEPTED:
+            return;
+          case ProposalStatus.DOWNLOADING:
+          case ProposalStatus.PROPOSED:
+            propStatus = BackupProposalStatus.Proposed;
+            break;
+          case ProposalStatus.PERMANENTLY_FAILED:
+            propStatus = BackupProposalStatus.PermanentlyFailed;
+            break;
+          case ProposalStatus.REFUSED:
+            propStatus = BackupProposalStatus.Refused;
+            break;
+          case ProposalStatus.REPURCHASE:
+            propStatus = BackupProposalStatus.Repurchase;
+            break;
+        }
+        backupProposals.push({
+          claim_token: prop.claimToken,
+          nonce_priv: prop.noncePriv,
+          proposal_id: prop.noncePriv,
+          proposal_status: propStatus,
+          repurchase_proposal_id: prop.repurchaseProposalId,
+          timestamp: prop.timestamp,
+          contract_terms_raw: prop.download?.contractTermsRaw,
+          download_session_id: prop.downloadSessionId,
+          merchant_base_url: prop.merchantBaseUrl,
+          order_id: prop.orderId,
+          merchant_sig: prop.download?.contractData.merchantSig,
+        });
+      });
+
+      await tx.iter(Stores.refreshGroups).forEach((rg) => {
+        const oldCoins: BackupRefreshOldCoin[] = [];
+
+        for (let i = 0; i < rg.oldCoinPubs.length; i++) {
+          let refreshSession: BackupRefreshSession | undefined;
+          const s = rg.refreshSessionPerCoin[i];
+          if (s) {
+            refreshSession = {
+              new_denoms: s.newDenoms.map((x) => ({
+                count: x.count,
+                denom_pub_hash: x.denomPubHash,
+              })),
+              session_secret_seed: s.sessionSecretSeed,
+              noreveal_index: s.norevealIndex,
+            };
+          }
+          oldCoins.push({
+            coin_pub: rg.oldCoinPubs[i],
+            estimated_output_amount: Amounts.stringify(
+              rg.estimatedOutputPerCoin[i],
+            ),
+            finished: rg.finishedPerCoin[i],
+            input_amount: Amounts.stringify(rg.inputPerCoin[i]),
+            refresh_session: refreshSession,
+          });
+        }
+
+        backupRefreshGroups.push({
+          reason: rg.reason as any,
+          refresh_group_id: rg.refreshGroupId,
+          timestamp_created: rg.timestampCreated,
+          timestamp_finish: rg.timestampFinished,
+          old_coins: oldCoins,
+        });
+      });
+
+      if (!bs.lastBackupTimestamp) {
+        bs.lastBackupTimestamp = getTimestampNow();
+      }
+
+      const backupBlob: WalletBackupContentV1 = {
+        schema_id: "gnu-taler-wallet-backup-content",
+        schema_version: 1,
+        clocks: bs.clocks,
+        exchanges: backupExchanges,
+        wallet_root_pub: bs.walletRootPub,
+        backup_providers: backupBackupProviders,
+        current_device_id: bs.deviceId,
+        proposals: backupProposals,
+        purchase_tombstones: [],
+        purchases: backupPurchases,
+        recoup_groups: backupRecoupGroups,
+        refresh_groups: backupRefreshGroups,
+        tips: backupTips,
+        timestamp: bs.lastBackupTimestamp,
+        trusted_auditors: {},
+        trusted_exchanges: {},
+        intern_table: {},
+        error_reports: [],
+      };
+
+      // If the backup changed, we increment our clock.
+
+      let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
+      if (h != bs.lastBackupPlainHash) {
+        backupBlob.clocks[bs.deviceId] = ++bs.clocks[bs.deviceId];
+        bs.lastBackupPlainHash = encodeCrock(
+          hash(stringToBytes(canonicalJson(backupBlob))),
+        );
+        bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
+        await tx.put(Stores.config, {
+          key: WALLET_BACKUP_STATE_KEY,
+          value: bs,
+        });
+      }
+
+      return backupBlob;
+    },
+  );
+}
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts 
b/packages/taler-wallet-core/src/operations/backup/import.ts
new file mode 100644
index 00000000..416b068e
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -0,0 +1,807 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ 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/>
+ */
+
+import {
+  AbortStatus,
+  AmountJson,
+  Amounts,
+  codecForContractTerms,
+  CoinSource,
+  CoinSourceType,
+  CoinStatus,
+  DenominationStatus,
+  DenomSelectionState,
+  ExchangeUpdateStatus,
+  ExchangeWireInfo,
+  getTimestampNow,
+  PayCoinSelection,
+  ProposalDownload,
+  ProposalStatus,
+  RefreshReason,
+  RefreshSessionRecord,
+  RefundState,
+  ReserveBankInfo,
+  ReserveRecordStatus,
+  Stores,
+  TransactionHandle,
+  WalletContractData,
+  WalletRefundItem,
+} from "../..";
+import {
+  BackupCoinSourceType,
+  BackupDenomSel,
+  BackupProposalStatus,
+  BackupPurchase,
+  BackupRefreshReason,
+  BackupRefundState,
+  WalletBackupContentV1,
+} from "../../types/backupTypes";
+import { j2s } from "../../util/helpers";
+import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants";
+import { Logger } from "../../util/logging";
+import { initRetryInfo } from "../../util/retries";
+import { InternalWalletState } from "../state";
+import { provideBackupState } from "./state";
+
+const logger = new Logger("operations/backup/import.ts");
+
+function checkBackupInvariant(b: boolean, m?: string): asserts b {
+  if (!b) {
+    if (m) {
+      throw Error(`BUG: backup invariant failed (${m})`);
+    } else {
+      throw Error("BUG: backup invariant failed");
+    }
+  }
+}
+
+/**
+ * Re-compute information about the coin selection for a payment.
+ */
+async function recoverPayCoinSelection(
+  tx: TransactionHandle<
+    typeof Stores.exchanges | typeof Stores.coins | typeof Stores.denominations
+  >,
+  contractData: WalletContractData,
+  backupPurchase: BackupPurchase,
+): Promise<PayCoinSelection> {
+  const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
+  const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
+    Amounts.parseOrThrow(x.contribution),
+  );
+
+  const coveredExchanges: Set<string> = new Set();
+
+  let totalWireFee: AmountJson = Amounts.getZero(contractData.amount.currency);
+  let totalDepositFees: AmountJson = Amounts.getZero(
+    contractData.amount.currency,
+  );
+
+  for (const coinPub of coinPubs) {
+    const coinRecord = await tx.get(Stores.coins, coinPub);
+    checkBackupInvariant(!!coinRecord);
+    const denom = await tx.get(Stores.denominations, [
+      coinRecord.exchangeBaseUrl,
+      coinRecord.denomPubHash,
+    ]);
+    checkBackupInvariant(!!denom);
+    totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount;
+
+    if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) {
+      const exchange = await tx.get(
+        Stores.exchanges,
+        coinRecord.exchangeBaseUrl,
+      );
+      checkBackupInvariant(!!exchange);
+      let wireFee: AmountJson | undefined;
+      const feesForType = exchange.wireInfo?.feesForType;
+      checkBackupInvariant(!!feesForType);
+      for (const fee of feesForType[contractData.wireMethod] || []) {
+        if (
+          fee.startStamp <= contractData.timestamp &&
+          fee.endStamp >= contractData.timestamp
+        ) {
+          wireFee = fee.wireFee;
+          break;
+        }
+      }
+      if (wireFee) {
+        totalWireFee = Amounts.add(totalWireFee, wireFee).amount;
+      }
+    }
+  }
+
+  let customerWireFee: AmountJson;
+
+  const amortizedWireFee = Amounts.divide(
+    totalWireFee,
+    contractData.wireFeeAmortization,
+  );
+  if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
+    customerWireFee = amortizedWireFee;
+  } else {
+    customerWireFee = Amounts.getZero(contractData.amount.currency);
+  }
+
+  const customerDepositFees = Amounts.sub(
+    totalDepositFees,
+    contractData.maxDepositFee,
+  ).amount;
+
+  return {
+    coinPubs,
+    coinContributions,
+    paymentAmount: contractData.amount,
+    customerWireFees: customerWireFee,
+    customerDepositFees,
+  };
+}
+
+async function getDenomSelStateFromBackup(
+  tx: TransactionHandle<typeof Stores.denominations>,
+  exchangeBaseUrl: string,
+  sel: BackupDenomSel,
+): Promise<DenomSelectionState> {
+  const d0 = await tx.get(Stores.denominations, [
+    exchangeBaseUrl,
+    sel[0].denom_pub_hash,
+  ]);
+  checkBackupInvariant(!!d0);
+  const selectedDenoms: {
+    denomPubHash: string;
+    count: number;
+  }[] = [];
+  let totalCoinValue = Amounts.getZero(d0.value.currency);
+  let totalWithdrawCost = Amounts.getZero(d0.value.currency);
+  for (const s of sel) {
+    const d = await tx.get(Stores.denominations, [
+      exchangeBaseUrl,
+      s.denom_pub_hash,
+    ]);
+    checkBackupInvariant(!!d);
+    totalCoinValue = Amounts.add(totalCoinValue, d.value).amount;
+    totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw)
+      .amount;
+  }
+  return {
+    selectedDenoms,
+    totalCoinValue,
+    totalWithdrawCost,
+  };
+}
+
+export interface CompletedCoin {
+  coinPub: string;
+  coinEvHash: string;
+}
+
+/**
+ * Precomputed cryptographic material for a backup import.
+ *
+ * We separate this data from the backup blob as we want the backup
+ * blob to be small, and we can't compute it during the database transaction,
+ * as the async crypto worker communication would auto-close the database 
transaction.
+ */
+export interface BackupCryptoPrecomputedData {
+  denomPubToHash: Record<string, string>;
+  coinPrivToCompletedCoin: Record<string, CompletedCoin>;
+  proposalNoncePrivToPub: { [priv: string]: string };
+  proposalIdToContractTermsHash: { [proposalId: string]: string };
+  reservePrivToPub: Record<string, string>;
+}
+
+export async function importBackup(
+  ws: InternalWalletState,
+  backupBlobArg: any,
+  cryptoComp: BackupCryptoPrecomputedData,
+): Promise<void> {
+  await provideBackupState(ws);
+
+  logger.info(`importing backup ${j2s(backupBlobArg)}`);
+
+  return ws.db.runWithWriteTransaction(
+    [
+      Stores.config,
+      Stores.exchanges,
+      Stores.coins,
+      Stores.denominations,
+      Stores.purchases,
+      Stores.proposals,
+      Stores.refreshGroups,
+      Stores.backupProviders,
+      Stores.tips,
+      Stores.recoupGroups,
+      Stores.reserves,
+      Stores.withdrawalGroups,
+    ],
+    async (tx) => {
+      // FIXME: validate schema!
+      const backupBlob = backupBlobArg as WalletBackupContentV1;
+
+      // FIXME: validate version
+
+      for (const backupExchange of backupBlob.exchanges) {
+        const existingExchange = await tx.get(
+          Stores.exchanges,
+          backupExchange.base_url,
+        );
+
+        if (!existingExchange) {
+          const wireInfo: ExchangeWireInfo = {
+            accounts: backupExchange.accounts.map((x) => ({
+              master_sig: x.master_sig,
+              payto_uri: x.payto_uri,
+            })),
+            feesForType: {},
+          };
+          for (const fee of backupExchange.wire_fees) {
+            const w = (wireInfo.feesForType[fee.wire_type] ??= []);
+            w.push({
+              closingFee: Amounts.parseOrThrow(fee.closing_fee),
+              endStamp: fee.end_stamp,
+              sig: fee.sig,
+              startStamp: fee.start_stamp,
+              wireFee: Amounts.parseOrThrow(fee.wire_fee),
+            });
+          }
+          await tx.put(Stores.exchanges, {
+            addComplete: true,
+            baseUrl: backupExchange.base_url,
+            builtIn: false,
+            updateReason: undefined,
+            permanent: true,
+            retryInfo: initRetryInfo(),
+            termsOfServiceAcceptedEtag: backupExchange.tos_etag_accepted,
+            termsOfServiceText: undefined,
+            termsOfServiceLastEtag: backupExchange.tos_etag_last,
+            updateStarted: getTimestampNow(),
+            updateStatus: ExchangeUpdateStatus.FetchKeys,
+            wireInfo,
+            details: {
+              currency: backupExchange.currency,
+              reserveClosingDelay: backupExchange.reserve_closing_delay,
+              auditors: backupExchange.auditors.map((x) => ({
+                auditor_pub: x.auditor_pub,
+                auditor_url: x.auditor_url,
+                denomination_keys: x.denomination_keys,
+              })),
+              lastUpdateTime: { t_ms: "never" },
+              masterPublicKey: backupExchange.master_public_key,
+              nextUpdateTime: { t_ms: "never" },
+              protocolVersion: backupExchange.protocol_version,
+              signingKeys: backupExchange.signing_keys.map((x) => ({
+                key: x.key,
+                master_sig: x.master_sig,
+                stamp_end: x.stamp_end,
+                stamp_expire: x.stamp_expire,
+                stamp_start: x.stamp_start,
+              })),
+            },
+          });
+        }
+
+        for (const backupDenomination of backupExchange.denominations) {
+          const denomPubHash =
+            cryptoComp.denomPubToHash[backupDenomination.denom_pub];
+          checkLogicInvariant(!!denomPubHash);
+          const existingDenom = await tx.get(Stores.denominations, [
+            backupExchange.base_url,
+            denomPubHash,
+          ]);
+          if (!existingDenom) {
+            await tx.put(Stores.denominations, {
+              denomPub: backupDenomination.denom_pub,
+              denomPubHash: denomPubHash,
+              exchangeBaseUrl: backupExchange.base_url,
+              feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit),
+              feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh),
+              feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund),
+              feeWithdraw: Amounts.parseOrThrow(
+                backupDenomination.fee_withdraw,
+              ),
+              isOffered: backupDenomination.is_offered,
+              isRevoked: backupDenomination.is_revoked,
+              masterSig: backupDenomination.master_sig,
+              stampExpireDeposit: backupDenomination.stamp_expire_deposit,
+              stampExpireLegal: backupDenomination.stamp_expire_legal,
+              stampExpireWithdraw: backupDenomination.stamp_expire_withdraw,
+              stampStart: backupDenomination.stamp_start,
+              status: DenominationStatus.VerifiedGood,
+              value: Amounts.parseOrThrow(backupDenomination.value),
+            });
+          }
+          for (const backupCoin of backupDenomination.coins) {
+            const compCoin =
+              cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv];
+            checkLogicInvariant(!!compCoin);
+            const existingCoin = await tx.get(Stores.coins, compCoin.coinPub);
+            if (!existingCoin) {
+              let coinSource: CoinSource;
+              switch (backupCoin.coin_source.type) {
+                case BackupCoinSourceType.Refresh:
+                  coinSource = {
+                    type: CoinSourceType.Refresh,
+                    oldCoinPub: backupCoin.coin_source.old_coin_pub,
+                  };
+                  break;
+                case BackupCoinSourceType.Tip:
+                  coinSource = {
+                    type: CoinSourceType.Tip,
+                    coinIndex: backupCoin.coin_source.coin_index,
+                    walletTipId: backupCoin.coin_source.wallet_tip_id,
+                  };
+                  break;
+                case BackupCoinSourceType.Withdraw:
+                  coinSource = {
+                    type: CoinSourceType.Withdraw,
+                    coinIndex: backupCoin.coin_source.coin_index,
+                    reservePub: backupCoin.coin_source.reserve_pub,
+                    withdrawalGroupId:
+                      backupCoin.coin_source.withdrawal_group_id,
+                  };
+                  break;
+              }
+              await tx.put(Stores.coins, {
+                blindingKey: backupCoin.blinding_key,
+                coinEvHash: compCoin.coinEvHash,
+                coinPriv: backupCoin.coin_priv,
+                currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),
+                denomSig: backupCoin.denom_sig,
+                coinPub: compCoin.coinPub,
+                suspended: false,
+                exchangeBaseUrl: backupExchange.base_url,
+                denomPub: backupDenomination.denom_pub,
+                denomPubHash,
+                status: backupCoin.fresh
+                  ? CoinStatus.Fresh
+                  : CoinStatus.Dormant,
+                coinSource,
+              });
+            }
+          }
+        }
+
+        for (const backupReserve of backupExchange.reserves) {
+          const reservePub =
+            cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
+          checkLogicInvariant(!!reservePub);
+          const existingReserve = await tx.get(Stores.reserves, reservePub);
+          const instructedAmount = Amounts.parseOrThrow(
+            backupReserve.instructed_amount,
+          );
+          if (!existingReserve) {
+            let bankInfo: ReserveBankInfo | undefined;
+            if (backupReserve.bank_info) {
+              bankInfo = {
+                exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
+                statusUrl: backupReserve.bank_info.status_url,
+                confirmUrl: backupReserve.bank_info.confirm_url,
+              };
+            }
+            await tx.put(Stores.reserves, {
+              currency: instructedAmount.currency,
+              instructedAmount,
+              exchangeBaseUrl: backupExchange.base_url,
+              reservePub,
+              reservePriv: backupReserve.reserve_priv,
+              requestedQuery: false,
+              bankInfo,
+              timestampCreated: backupReserve.timestamp_created,
+              timestampBankConfirmed:
+                backupReserve.bank_info?.timestamp_bank_confirmed,
+              timestampReserveInfoPosted:
+                backupReserve.bank_info?.timestamp_reserve_info_posted,
+              senderWire: backupReserve.sender_wire,
+              retryInfo: initRetryInfo(false),
+              lastError: undefined,
+              lastSuccessfulStatusQuery: { t_ms: "never" },
+              initialWithdrawalGroupId:
+                backupReserve.initial_withdrawal_group_id,
+              initialWithdrawalStarted:
+                backupReserve.withdrawal_groups.length > 0,
+              // FIXME!
+              reserveStatus: ReserveRecordStatus.QUERYING_STATUS,
+              initialDenomSel: await getDenomSelStateFromBackup(
+                tx,
+                backupExchange.base_url,
+                backupReserve.initial_selected_denoms,
+              ),
+            });
+          }
+          for (const backupWg of backupReserve.withdrawal_groups) {
+            const existingWg = await tx.get(
+              Stores.withdrawalGroups,
+              backupWg.withdrawal_group_id,
+            );
+            if (!existingWg) {
+              await tx.put(Stores.withdrawalGroups, {
+                denomsSel: await getDenomSelStateFromBackup(
+                  tx,
+                  backupExchange.base_url,
+                  backupWg.selected_denoms,
+                ),
+                exchangeBaseUrl: backupExchange.base_url,
+                lastError: undefined,
+                rawWithdrawalAmount: Amounts.parseOrThrow(
+                  backupWg.raw_withdrawal_amount,
+                ),
+                reservePub,
+                retryInfo: initRetryInfo(false),
+                secretSeed: backupWg.secret_seed,
+                timestampStart: backupWg.timestamp_created,
+                timestampFinish: backupWg.timestamp_finish,
+                withdrawalGroupId: backupWg.withdrawal_group_id,
+              });
+            }
+          }
+        }
+      }
+
+      for (const backupProposal of backupBlob.proposals) {
+        const existingProposal = await tx.get(
+          Stores.proposals,
+          backupProposal.proposal_id,
+        );
+        if (!existingProposal) {
+          let download: ProposalDownload | undefined;
+          let proposalStatus: ProposalStatus;
+          switch (backupProposal.proposal_status) {
+            case BackupProposalStatus.Proposed:
+              if (backupProposal.contract_terms_raw) {
+                proposalStatus = ProposalStatus.PROPOSED;
+              } else {
+                proposalStatus = ProposalStatus.DOWNLOADING;
+              }
+              break;
+            case BackupProposalStatus.Refused:
+              proposalStatus = ProposalStatus.REFUSED;
+              break;
+            case BackupProposalStatus.Repurchase:
+              proposalStatus = ProposalStatus.REPURCHASE;
+              break;
+            case BackupProposalStatus.PermanentlyFailed:
+              proposalStatus = ProposalStatus.PERMANENTLY_FAILED;
+              break;
+          }
+          if (backupProposal.contract_terms_raw) {
+            checkDbInvariant(!!backupProposal.merchant_sig);
+            const parsedContractTerms = codecForContractTerms().decode(
+              backupProposal.contract_terms_raw,
+            );
+            const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+            const contractTermsHash =
+              cryptoComp.proposalIdToContractTermsHash[
+                backupProposal.proposal_id
+              ];
+            let maxWireFee: AmountJson;
+            if (parsedContractTerms.max_wire_fee) {
+              maxWireFee = Amounts.parseOrThrow(
+                parsedContractTerms.max_wire_fee,
+              );
+            } else {
+              maxWireFee = Amounts.getZero(amount.currency);
+            }
+            download = {
+              contractData: {
+                amount,
+                contractTermsHash: contractTermsHash,
+                fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
+                merchantBaseUrl: parsedContractTerms.merchant_base_url,
+                merchantPub: parsedContractTerms.merchant_pub,
+                merchantSig: backupProposal.merchant_sig,
+                orderId: parsedContractTerms.order_id,
+                summary: parsedContractTerms.summary,
+                autoRefund: parsedContractTerms.auto_refund,
+                maxWireFee,
+                payDeadline: parsedContractTerms.pay_deadline,
+                refundDeadline: parsedContractTerms.refund_deadline,
+                wireFeeAmortization:
+                  parsedContractTerms.wire_fee_amortization || 1,
+                allowedAuditors: parsedContractTerms.auditors.map((x) => ({
+                  auditorBaseUrl: x.url,
+                  auditorPub: x.auditor_pub,
+                })),
+                allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+                  exchangeBaseUrl: x.url,
+                  exchangePub: x.master_pub,
+                })),
+                timestamp: parsedContractTerms.timestamp,
+                wireMethod: parsedContractTerms.wire_method,
+                wireInfoHash: parsedContractTerms.h_wire,
+                maxDepositFee: Amounts.parseOrThrow(
+                  parsedContractTerms.max_fee,
+                ),
+                merchant: parsedContractTerms.merchant,
+                products: parsedContractTerms.products,
+                summaryI18n: parsedContractTerms.summary_i18n,
+              },
+              contractTermsRaw: backupProposal.contract_terms_raw,
+            };
+          }
+          await tx.put(Stores.proposals, {
+            claimToken: backupProposal.claim_token,
+            lastError: undefined,
+            merchantBaseUrl: backupProposal.merchant_base_url,
+            timestamp: backupProposal.timestamp,
+            orderId: backupProposal.order_id,
+            noncePriv: backupProposal.nonce_priv,
+            noncePub:
+              cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
+            proposalId: backupProposal.proposal_id,
+            repurchaseProposalId: backupProposal.repurchase_proposal_id,
+            retryInfo: initRetryInfo(false),
+            download,
+            proposalStatus,
+          });
+        }
+      }
+
+      for (const backupPurchase of backupBlob.purchases) {
+        const existingPurchase = await tx.get(
+          Stores.purchases,
+          backupPurchase.proposal_id,
+        );
+        if (!existingPurchase) {
+          const refunds: { [refundKey: string]: WalletRefundItem } = {};
+          for (const backupRefund of backupPurchase.refunds) {
+            const key = 
`${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
+            const coin = await tx.get(Stores.coins, backupRefund.coin_pub);
+            checkBackupInvariant(!!coin);
+            const denom = await tx.get(Stores.denominations, [
+              coin.exchangeBaseUrl,
+              coin.denomPubHash,
+            ]);
+            checkBackupInvariant(!!denom);
+            const common = {
+              coinPub: backupRefund.coin_pub,
+              executionTime: backupRefund.execution_time,
+              obtainedTime: backupRefund.obtained_time,
+              refundAmount: Amounts.parseOrThrow(backupRefund.refund_amount),
+              refundFee: denom.feeRefund,
+              rtransactionId: backupRefund.rtransaction_id,
+              totalRefreshCostBound: Amounts.parseOrThrow(
+                backupRefund.total_refresh_cost_bound,
+              ),
+            };
+            switch (backupRefund.type) {
+              case BackupRefundState.Applied:
+                refunds[key] = {
+                  type: RefundState.Applied,
+                  ...common,
+                };
+                break;
+              case BackupRefundState.Failed:
+                refunds[key] = {
+                  type: RefundState.Failed,
+                  ...common,
+                };
+                break;
+              case BackupRefundState.Pending:
+                refunds[key] = {
+                  type: RefundState.Pending,
+                  ...common,
+                };
+                break;
+            }
+          }
+          let abortStatus: AbortStatus;
+          switch (backupPurchase.abort_status) {
+            case "abort-finished":
+              abortStatus = AbortStatus.AbortFinished;
+              break;
+            case "abort-refund":
+              abortStatus = AbortStatus.AbortRefund;
+              break;
+            case undefined:
+              abortStatus = AbortStatus.None;
+              break;
+            default:
+              logger.warn(
+                `got backup purchase abort_status ${j2s(
+                  backupPurchase.abort_status,
+                )}`,
+              );
+              throw Error("not reachable");
+          }
+          const parsedContractTerms = codecForContractTerms().decode(
+            backupPurchase.contract_terms_raw,
+          );
+          const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+          const contractTermsHash =
+            cryptoComp.proposalIdToContractTermsHash[
+              backupPurchase.proposal_id
+            ];
+          let maxWireFee: AmountJson;
+          if (parsedContractTerms.max_wire_fee) {
+            maxWireFee = 
Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
+          } else {
+            maxWireFee = Amounts.getZero(amount.currency);
+          }
+          const download: ProposalDownload = {
+            contractData: {
+              amount,
+              contractTermsHash: contractTermsHash,
+              fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
+              merchantBaseUrl: parsedContractTerms.merchant_base_url,
+              merchantPub: parsedContractTerms.merchant_pub,
+              merchantSig: backupPurchase.merchant_sig,
+              orderId: parsedContractTerms.order_id,
+              summary: parsedContractTerms.summary,
+              autoRefund: parsedContractTerms.auto_refund,
+              maxWireFee,
+              payDeadline: parsedContractTerms.pay_deadline,
+              refundDeadline: parsedContractTerms.refund_deadline,
+              wireFeeAmortization:
+                parsedContractTerms.wire_fee_amortization || 1,
+              allowedAuditors: parsedContractTerms.auditors.map((x) => ({
+                auditorBaseUrl: x.url,
+                auditorPub: x.auditor_pub,
+              })),
+              allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+                exchangeBaseUrl: x.url,
+                exchangePub: x.master_pub,
+              })),
+              timestamp: parsedContractTerms.timestamp,
+              wireMethod: parsedContractTerms.wire_method,
+              wireInfoHash: parsedContractTerms.h_wire,
+              maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
+              merchant: parsedContractTerms.merchant,
+              products: parsedContractTerms.products,
+              summaryI18n: parsedContractTerms.summary_i18n,
+            },
+            contractTermsRaw: backupPurchase.contract_terms_raw,
+          };
+          await tx.put(Stores.purchases, {
+            proposalId: backupPurchase.proposal_id,
+            noncePriv: backupPurchase.nonce_priv,
+            noncePub:
+              cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
+            lastPayError: undefined,
+            autoRefundDeadline: { t_ms: "never" },
+            refundStatusRetryInfo: initRetryInfo(false),
+            lastRefundStatusError: undefined,
+            timestampAccept: backupPurchase.timestamp_accept,
+            timestampFirstSuccessfulPay:
+              backupPurchase.timestamp_first_successful_pay,
+            timestampLastRefundStatus: undefined,
+            merchantPaySig: backupPurchase.merchant_pay_sig,
+            lastSessionId: undefined,
+            abortStatus,
+            // FIXME!
+            payRetryInfo: initRetryInfo(false),
+            download,
+            paymentSubmitPending: 
!backupPurchase.timestamp_first_successful_pay,
+            refundQueryRequested: false,
+            payCoinSelection: await recoverPayCoinSelection(
+              tx,
+              download.contractData,
+              backupPurchase,
+            ),
+            coinDepositPermissions: undefined,
+            totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
+            refunds,
+          });
+        }
+      }
+
+      for (const backupRefreshGroup of backupBlob.refresh_groups) {
+        const existingRg = await tx.get(
+          Stores.refreshGroups,
+          backupRefreshGroup.refresh_group_id,
+        );
+        if (!existingRg) {
+          let reason: RefreshReason;
+          switch (backupRefreshGroup.reason) {
+            case BackupRefreshReason.AbortPay:
+              reason = RefreshReason.AbortPay;
+              break;
+            case BackupRefreshReason.BackupRestored:
+              reason = RefreshReason.BackupRestored;
+              break;
+            case BackupRefreshReason.Manual:
+              reason = RefreshReason.Manual;
+              break;
+            case BackupRefreshReason.Pay:
+              reason = RefreshReason.Pay;
+              break;
+            case BackupRefreshReason.Recoup:
+              reason = RefreshReason.Recoup;
+              break;
+            case BackupRefreshReason.Refund:
+              reason = RefreshReason.Refund;
+              break;
+            case BackupRefreshReason.Scheduled:
+              reason = RefreshReason.Scheduled;
+              break;
+          }
+          const refreshSessionPerCoin: (
+            | RefreshSessionRecord
+            | undefined
+          )[] = [];
+          for (const oldCoin of backupRefreshGroup.old_coins) {
+            const c = await tx.get(Stores.coins, oldCoin.coin_pub);
+            checkBackupInvariant(!!c);
+            if (oldCoin.refresh_session) {
+              const denomSel = await getDenomSelStateFromBackup(
+                tx,
+                c.exchangeBaseUrl,
+                oldCoin.refresh_session.new_denoms,
+              );
+              refreshSessionPerCoin.push({
+                sessionSecretSeed: oldCoin.refresh_session.session_secret_seed,
+                norevealIndex: oldCoin.refresh_session.noreveal_index,
+                newDenoms: oldCoin.refresh_session.new_denoms.map((x) => ({
+                  count: x.count,
+                  denomPubHash: x.denom_pub_hash,
+                })),
+                amountRefreshOutput: denomSel.totalCoinValue,
+              });
+            } else {
+              refreshSessionPerCoin.push(undefined);
+            }
+          }
+          await tx.put(Stores.refreshGroups, {
+            timestampFinished: backupRefreshGroup.timestamp_finish,
+            timestampCreated: backupRefreshGroup.timestamp_created,
+            refreshGroupId: backupRefreshGroup.refresh_group_id,
+            reason,
+            lastError: undefined,
+            lastErrorPerCoin: {},
+            oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),
+            finishedPerCoin: backupRefreshGroup.old_coins.map(
+              (x) => x.finished,
+            ),
+            inputPerCoin: backupRefreshGroup.old_coins.map((x) =>
+              Amounts.parseOrThrow(x.input_amount),
+            ),
+            estimatedOutputPerCoin: backupRefreshGroup.old_coins.map((x) =>
+              Amounts.parseOrThrow(x.estimated_output_amount),
+            ),
+            refreshSessionPerCoin,
+            retryInfo: initRetryInfo(false),
+          });
+        }
+      }
+
+      for (const backupTip of backupBlob.tips) {
+        const existingTip = await tx.get(Stores.tips, backupTip.wallet_tip_id);
+        if (!existingTip) {
+          const denomsSel = await getDenomSelStateFromBackup(
+            tx,
+            backupTip.exchange_base_url,
+            backupTip.selected_denoms,
+          );
+          await tx.put(Stores.tips, {
+            acceptedTimestamp: backupTip.timestamp_accepted,
+            createdTimestamp: backupTip.timestamp_created,
+            denomsSel,
+            exchangeBaseUrl: backupTip.exchange_base_url,
+            lastError: undefined,
+            merchantBaseUrl: backupTip.exchange_base_url,
+            merchantTipId: backupTip.merchant_tip_id,
+            pickedUpTimestamp: backupTip.timestamp_finished,
+            retryInfo: initRetryInfo(false),
+            secretSeed: backupTip.secret_seed,
+            tipAmountEffective: denomsSel.totalCoinValue,
+            tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw),
+            tipExpiration: backupTip.timestamp_expiration,
+            walletTipId: backupTip.wallet_tip_id,
+          });
+        }
+      }
+    },
+  );
+}
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts 
b/packages/taler-wallet-core/src/operations/backup/index.ts
new file mode 100644
index 00000000..edc5acc1
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/backup/index.ts
@@ -0,0 +1,783 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ 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/>
+ */
+
+/**
+ * Implementation of wallet backups (export/import/upload) and sync
+ * server management.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import { InternalWalletState } from "../state";
+import { WalletBackupContentV1 } from "../../types/backupTypes";
+import { TransactionHandle } from "../../util/query";
+import {
+  BackupProviderRecord,
+  ConfigRecord,
+  Stores,
+} from "../../types/dbTypes";
+import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants";
+import { codecForAmountString } from "../../util/amounts";
+import {
+  bytesToString,
+  decodeCrock,
+  eddsaGetPublic,
+  EddsaKeyPair,
+  encodeCrock,
+  hash,
+  rsaBlind,
+  stringToBytes,
+} from "../../crypto/talerCrypto";
+import { canonicalizeBaseUrl, canonicalJson, j2s } from "../../util/helpers";
+import {
+  durationAdd,
+  durationFromSpec,
+  getTimestampNow,
+  Timestamp,
+  timestampAddDuration,
+} from "../../util/time";
+import { URL } from "../../util/url";
+import { AmountString } from "../../types/talerTypes";
+import {
+  buildCodecForObject,
+  Codec,
+  codecForBoolean,
+  codecForNumber,
+  codecForString,
+  codecOptional,
+} from "../../util/codec";
+import {
+  HttpResponseStatus,
+  readSuccessResponseJsonOrThrow,
+  readTalerErrorResponse,
+} from "../../util/http";
+import { Logger } from "../../util/logging";
+import { gunzipSync, gzipSync } from "fflate";
+import { kdf } from "../../crypto/primitives/kdf";
+import { initRetryInfo } from "../../util/retries";
+import {
+  ConfirmPayResultType,
+  PreparePayResultType,
+  RecoveryLoadRequest,
+  RecoveryMergeStrategy,
+  TalerErrorDetails,
+} from "../../types/walletTypes";
+import { CryptoApi } from "../../crypto/workers/cryptoApi";
+import { secretbox, secretbox_open } from "../../crypto/primitives/nacl-fast";
+import { checkPaymentByProposalId, confirmPay, preparePayForUri } from 
"../pay";
+import { exportBackup } from "./export";
+import { BackupCryptoPrecomputedData, importBackup } from "./import";
+import {
+  provideBackupState,
+  WALLET_BACKUP_STATE_KEY,
+  getWalletBackupState,
+  WalletBackupConfState,
+} from "./state";
+import { PaymentStatus } from "../../types/transactionsTypes";
+
+const logger = new Logger("operations/backup.ts");
+
+function concatArrays(xs: Uint8Array[]): Uint8Array {
+  let len = 0;
+  for (const x of xs) {
+    len += x.byteLength;
+  }
+  const out = new Uint8Array(len);
+  let offset = 0;
+  for (const x of xs) {
+    out.set(x, offset);
+    offset += x.length;
+  }
+  return out;
+}
+
+const magic = "TLRWBK01";
+
+/**
+ * Encrypt the backup.
+ *
+ * Blob format:
+ * Magic "TLRWBK01" (8 bytes)
+ * Nonce (24 bytes)
+ * Compressed JSON blob (rest)
+ */
+export async function encryptBackup(
+  config: WalletBackupConfState,
+  blob: WalletBackupContentV1,
+): Promise<Uint8Array> {
+  const chunks: Uint8Array[] = [];
+  chunks.push(stringToBytes(magic));
+  const nonceStr = config.lastBackupNonce;
+  checkLogicInvariant(!!nonceStr);
+  const nonce = decodeCrock(nonceStr).slice(0, 24);
+  chunks.push(nonce);
+  const backupJsonContent = canonicalJson(blob);
+  logger.trace("backup JSON size", backupJsonContent.length);
+  const compressedContent = gzipSync(stringToBytes(backupJsonContent));
+  const secret = deriveBlobSecret(config);
+  const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
+  chunks.push(encrypted);
+  return concatArrays(chunks);
+}
+
+/**
+ * Compute cryptographic values for a backup blob.
+ *
+ * FIXME: Take data that we already know from the DB.
+ * FIXME: Move computations into crypto worker.
+ */
+async function computeBackupCryptoData(
+  cryptoApi: CryptoApi,
+  backupContent: WalletBackupContentV1,
+): Promise<BackupCryptoPrecomputedData> {
+  const cryptoData: BackupCryptoPrecomputedData = {
+    coinPrivToCompletedCoin: {},
+    denomPubToHash: {},
+    proposalIdToContractTermsHash: {},
+    proposalNoncePrivToPub: {},
+    reservePrivToPub: {},
+  };
+  for (const backupExchange of backupContent.exchanges) {
+    for (const backupDenom of backupExchange.denominations) {
+      for (const backupCoin of backupDenom.coins) {
+        const coinPub = encodeCrock(
+          eddsaGetPublic(decodeCrock(backupCoin.coin_priv)),
+        );
+        const blindedCoin = rsaBlind(
+          hash(decodeCrock(backupCoin.coin_priv)),
+          decodeCrock(backupCoin.blinding_key),
+          decodeCrock(backupDenom.denom_pub),
+        );
+        cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = {
+          coinEvHash: encodeCrock(hash(blindedCoin)),
+          coinPub,
+        };
+      }
+      cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock(
+        hash(decodeCrock(backupDenom.denom_pub)),
+      );
+    }
+    for (const backupReserve of backupExchange.reserves) {
+      cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock(
+        eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)),
+      );
+    }
+  }
+  for (const prop of backupContent.proposals) {
+    const contractTermsHash = await cryptoApi.hashString(
+      canonicalJson(prop.contract_terms_raw),
+    );
+    const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
+    cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
+    cryptoData.proposalIdToContractTermsHash[
+      prop.proposal_id
+    ] = contractTermsHash;
+  }
+  for (const purch of backupContent.purchases) {
+    const contractTermsHash = await cryptoApi.hashString(
+      canonicalJson(purch.contract_terms_raw),
+    );
+    const noncePub = 
encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv)));
+    cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub;
+    cryptoData.proposalIdToContractTermsHash[
+      purch.proposal_id
+    ] = contractTermsHash;
+  }
+  return cryptoData;
+}
+
+function deriveAccountKeyPair(
+  bc: WalletBackupConfState,
+  providerUrl: string,
+): EddsaKeyPair {
+  const privateKey = kdf(
+    32,
+    decodeCrock(bc.walletRootPriv),
+    stringToBytes("taler-sync-account-key-salt"),
+    stringToBytes(providerUrl),
+  );
+  return {
+    eddsaPriv: privateKey,
+    eddsaPub: eddsaGetPublic(privateKey),
+  };
+}
+
+function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
+  return kdf(
+    32,
+    decodeCrock(bc.walletRootPriv),
+    stringToBytes("taler-sync-blob-secret-salt"),
+    stringToBytes("taler-sync-blob-secret-info"),
+  );
+}
+
+interface BackupForProviderArgs {
+  backupConfig: WalletBackupConfState;
+  provider: BackupProviderRecord;
+  currentBackupHash: ArrayBuffer;
+  encBackup: ArrayBuffer;
+  backupJson: WalletBackupContentV1;
+
+  /**
+   * Should we attempt one more upload after trying
+   * to pay?
+   */
+  retryAfterPayment: boolean;
+}
+
+async function runBackupCycleForProvider(
+  ws: InternalWalletState,
+  args: BackupForProviderArgs,
+): Promise<void> {
+  const {
+    backupConfig,
+    provider,
+    currentBackupHash,
+    encBackup,
+    backupJson,
+  } = args;
+  const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
+  logger.trace(`trying to upload backup to ${provider.baseUrl}`);
+
+  const syncSig = await ws.cryptoApi.makeSyncSignature({
+    newHash: encodeCrock(currentBackupHash),
+    oldHash: provider.lastBackupHash,
+    accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
+  });
+
+  logger.trace(`sync signature is ${syncSig}`);
+
+  const accountBackupUrl = new URL(
+    `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
+    provider.baseUrl,
+  );
+
+  const resp = await ws.http.fetch(accountBackupUrl.href, {
+    method: "POST",
+    body: encBackup,
+    headers: {
+      "content-type": "application/octet-stream",
+      "sync-signature": syncSig,
+      "if-none-match": encodeCrock(currentBackupHash),
+      ...(provider.lastBackupHash
+        ? {
+            "if-match": provider.lastBackupHash,
+          }
+        : {}),
+    },
+  });
+
+  logger.trace(`sync response status: ${resp.status}`);
+
+  if (resp.status === HttpResponseStatus.PaymentRequired) {
+    logger.trace("payment required for backup");
+    logger.trace(`headers: ${j2s(resp.headers)}`);
+    const talerUri = resp.headers.get("taler");
+    if (!talerUri) {
+      throw Error("no taler URI available to pay provider");
+    }
+    const res = await preparePayForUri(ws, talerUri);
+    let proposalId = res.proposalId;
+    let doPay: boolean = false;
+    switch (res.status) {
+      case PreparePayResultType.InsufficientBalance:
+        // FIXME: record in provider state!
+        logger.warn("insufficient balance to pay for backup provider");
+        proposalId = res.proposalId;
+        break;
+      case PreparePayResultType.PaymentPossible:
+        doPay = true;
+        break;
+      case PreparePayResultType.AlreadyConfirmed:
+        break;
+    }
+
+    // FIXME: check if the provider is overcharging us!
+
+    await ws.db.runWithWriteTransaction(
+      [Stores.backupProviders],
+      async (tx) => {
+        const provRec = await tx.get(Stores.backupProviders, provider.baseUrl);
+        checkDbInvariant(!!provRec);
+        const ids = new Set(provRec.paymentProposalIds);
+        ids.add(proposalId);
+        provRec.paymentProposalIds = Array.from(ids).sort();
+        provRec.currentPaymentProposalId = proposalId;
+        await tx.put(Stores.backupProviders, provRec);
+      },
+    );
+
+    if (doPay) {
+      const confirmRes = await confirmPay(ws, proposalId);
+      switch (confirmRes.type) {
+        case ConfirmPayResultType.Pending:
+          logger.warn("payment not yet finished yet");
+          break;
+      }
+    }
+
+    if (args.retryAfterPayment) {
+      await runBackupCycleForProvider(ws, {
+        ...args,
+        retryAfterPayment: false,
+      });
+    }
+    return;
+  }
+
+  if (resp.status === HttpResponseStatus.NoContent) {
+    await ws.db.runWithWriteTransaction(
+      [Stores.backupProviders],
+      async (tx) => {
+        const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
+        if (!prov) {
+          return;
+        }
+        prov.lastBackupHash = encodeCrock(currentBackupHash);
+        prov.lastBackupTimestamp = getTimestampNow();
+        prov.lastBackupClock = backupJson.clocks[backupJson.current_device_id];
+        prov.lastError = undefined;
+        await tx.put(Stores.backupProviders, prov);
+      },
+    );
+    return;
+  }
+
+  if (resp.status === HttpResponseStatus.Conflict) {
+    logger.info("conflicting backup found");
+    const backupEnc = new Uint8Array(await resp.bytes());
+    const backupConfig = await provideBackupState(ws);
+    const blob = await decryptBackup(backupConfig, backupEnc);
+    const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
+    await importBackup(ws, blob, cryptoData);
+    await ws.db.runWithWriteTransaction(
+      [Stores.backupProviders],
+      async (tx) => {
+        const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
+        if (!prov) {
+          return;
+        }
+        prov.lastBackupHash = encodeCrock(hash(backupEnc));
+        prov.lastBackupClock = blob.clocks[blob.current_device_id];
+        prov.lastBackupTimestamp = getTimestampNow();
+        prov.lastError = undefined;
+        await tx.put(Stores.backupProviders, prov);
+      },
+    );
+    logger.info("processed existing backup");
+    return;
+  }
+
+  // Some other response that we did not expect!
+
+  logger.error("parsing error response");
+
+  const err = await readTalerErrorResponse(resp);
+  logger.error(`got error response from backup provider: ${j2s(err)}`);
+  await ws.db.runWithWriteTransaction([Stores.backupProviders], async (tx) => {
+    const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
+    if (!prov) {
+      return;
+    }
+    prov.lastError = err;
+    await tx.put(Stores.backupProviders, prov);
+  });
+}
+
+/**
+ * Do one backup cycle that consists of:
+ * 1. Exporting a backup and try to upload it.
+ *    Stop if this step succeeds.
+ * 2. Download, verify and import backups from connected sync accounts.
+ * 3. Upload the updated backup blob.
+ */
+export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
+  const providers = await ws.db.iter(Stores.backupProviders).toArray();
+  logger.trace("got backup providers", providers);
+  const backupJson = await exportBackup(ws);
+  const backupConfig = await provideBackupState(ws);
+  const encBackup = await encryptBackup(backupConfig, backupJson);
+
+  const currentBackupHash = hash(encBackup);
+
+  for (const provider of providers) {
+    await runBackupCycleForProvider(ws, {
+      provider,
+      backupJson,
+      backupConfig,
+      encBackup,
+      currentBackupHash,
+      retryAfterPayment: true,
+    });
+  }
+}
+
+interface SyncTermsOfServiceResponse {
+  // maximum backup size supported
+  storage_limit_in_megabytes: number;
+
+  // Fee for an account, per year.
+  annual_fee: AmountString;
+
+  // protocol version supported by the server,
+  // for now always "0.0".
+  version: string;
+}
+
+const codecForSyncTermsOfServiceResponse = (): 
Codec<SyncTermsOfServiceResponse> =>
+  buildCodecForObject<SyncTermsOfServiceResponse>()
+    .property("storage_limit_in_megabytes", codecForNumber())
+    .property("annual_fee", codecForAmountString())
+    .property("version", codecForString())
+    .build("SyncTermsOfServiceResponse");
+
+export interface AddBackupProviderRequest {
+  backupProviderBaseUrl: string;
+  /**
+   * Activate the provider.  Should only be done after
+   * the user has reviewed the provider.
+   */
+  activate?: boolean;
+}
+
+export const codecForAddBackupProviderRequest = (): 
Codec<AddBackupProviderRequest> =>
+  buildCodecForObject<AddBackupProviderRequest>()
+    .property("backupProviderBaseUrl", codecForString())
+    .property("activate", codecOptional(codecForBoolean()))
+    .build("AddBackupProviderRequest");
+
+export async function addBackupProvider(
+  ws: InternalWalletState,
+  req: AddBackupProviderRequest,
+): Promise<void> {
+  logger.info(`adding backup provider ${j2s(req)}`);
+  await provideBackupState(ws);
+  const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
+  const oldProv = await ws.db.get(Stores.backupProviders, canonUrl);
+  if (oldProv) {
+    logger.info("old backup provider found");
+    if (req.activate) {
+      oldProv.active = true;
+      logger.info("setting existing backup provider to active");
+      await ws.db.put(Stores.backupProviders, oldProv);
+    }
+    return;
+  }
+  const termsUrl = new URL("terms", canonUrl);
+  const resp = await ws.http.get(termsUrl.href);
+  const terms = await readSuccessResponseJsonOrThrow(
+    resp,
+    codecForSyncTermsOfServiceResponse(),
+  );
+  await ws.db.put(Stores.backupProviders, {
+    active: !!req.activate,
+    terms: {
+      annualFee: terms.annual_fee,
+      storageLimitInMegabytes: terms.storage_limit_in_megabytes,
+      supportedProtocolVersion: terms.version,
+    },
+    paymentProposalIds: [],
+    baseUrl: canonUrl,
+    lastError: undefined,
+    retryInfo: initRetryInfo(false),
+  });
+}
+
+export async function removeBackupProvider(
+  syncProviderBaseUrl: string,
+): Promise<void> {}
+
+export async function restoreFromRecoverySecret(): Promise<void> {}
+
+/**
+ * Information about one provider.
+ *
+ * We don't store the account key here,
+ * as that's derived from the wallet root key.
+ */
+export interface ProviderInfo {
+  active: boolean;
+  syncProviderBaseUrl: string;
+  lastError?: TalerErrorDetails;
+  lastRemoteClock?: number;
+  lastBackupTimestamp?: Timestamp;
+  paymentProposalIds: string[];
+  paymentStatus: ProviderPaymentStatus;
+}
+
+export type ProviderPaymentStatus =
+  | ProviderPaymentPaid
+  | ProviderPaymentInsufficientBalance
+  | ProviderPaymentUnpaid
+  | ProviderPaymentPending;
+
+export interface BackupInfo {
+  walletRootPub: string;
+  deviceId: string;
+  lastLocalClock: number;
+  providers: ProviderInfo[];
+}
+
+export async function importBackupPlain(
+  ws: InternalWalletState,
+  blob: any,
+): Promise<void> {
+  // FIXME: parse
+  const backup: WalletBackupContentV1 = blob;
+
+  const cryptoData = await computeBackupCryptoData(ws.cryptoApi, backup);
+
+  await importBackup(ws, blob, cryptoData);
+}
+
+export enum ProviderPaymentType {
+  Unpaid = "unpaid",
+  Pending = "pending",
+  InsufficientBalance = "insufficient-balance",
+  Paid = "paid",
+}
+
+export interface ProviderPaymentUnpaid {
+  type: ProviderPaymentType.Unpaid;
+}
+
+export interface ProviderPaymentInsufficientBalance {
+  type: ProviderPaymentType.InsufficientBalance;
+}
+
+export interface ProviderPaymentPending {
+  type: ProviderPaymentType.Pending;
+}
+
+export interface ProviderPaymentPaid {
+  type: ProviderPaymentType.Paid;
+  paidUntil: Timestamp;
+}
+
+async function getProviderPaymentInfo(
+  ws: InternalWalletState,
+  provider: BackupProviderRecord,
+): Promise<ProviderPaymentStatus> {
+  if (!provider.currentPaymentProposalId) {
+    return {
+      type: ProviderPaymentType.Unpaid,
+    };
+  }
+  const status = await checkPaymentByProposalId(
+    ws,
+    provider.currentPaymentProposalId,
+  );
+  if (status.status === PreparePayResultType.InsufficientBalance) {
+    return {
+      type: ProviderPaymentType.InsufficientBalance,
+    };
+  }
+  if (status.status === PreparePayResultType.PaymentPossible) {
+    return {
+      type: ProviderPaymentType.Pending,
+    };
+  }
+  if (status.status === PreparePayResultType.AlreadyConfirmed) {
+    if (status.paid) {
+      return {
+        type: ProviderPaymentType.Paid,
+        paidUntil: timestampAddDuration(
+          status.contractTerms.timestamp,
+          durationFromSpec({ years: 1 }),
+        ),
+      };
+    } else {
+      return {
+        type: ProviderPaymentType.Pending,
+      };
+    }
+  }
+  throw Error("not reached");
+}
+
+/**
+ * Get information about the current state of wallet backups.
+ */
+export async function getBackupInfo(
+  ws: InternalWalletState,
+): Promise<BackupInfo> {
+  const backupConfig = await provideBackupState(ws);
+  const providerRecords = await ws.db.iter(Stores.backupProviders).toArray();
+  const providers: ProviderInfo[] = [];
+  for (const x of providerRecords) {
+    providers.push({
+      active: x.active,
+      lastRemoteClock: x.lastBackupClock,
+      syncProviderBaseUrl: x.baseUrl,
+      lastBackupTimestamp: x.lastBackupTimestamp,
+      paymentProposalIds: x.paymentProposalIds,
+      lastError: x.lastError,
+      paymentStatus: await getProviderPaymentInfo(ws, x),
+    });
+  }
+  return {
+    deviceId: backupConfig.deviceId,
+    lastLocalClock: backupConfig.clocks[backupConfig.deviceId],
+    walletRootPub: backupConfig.walletRootPub,
+    providers,
+  };
+}
+
+export interface BackupRecovery {
+  walletRootPriv: string;
+  providers: {
+    url: string;
+  }[];
+}
+
+/**
+ * Get information about the current state of wallet backups.
+ */
+export async function getBackupRecovery(
+  ws: InternalWalletState,
+): Promise<BackupRecovery> {
+  const bs = await provideBackupState(ws);
+  const providers = await ws.db.iter(Stores.backupProviders).toArray();
+  return {
+    providers: providers
+      .filter((x) => x.active)
+      .map((x) => {
+        return {
+          url: x.baseUrl,
+        };
+      }),
+    walletRootPriv: bs.walletRootPriv,
+  };
+}
+
+async function backupRecoveryTheirs(
+  ws: InternalWalletState,
+  br: BackupRecovery,
+) {
+  await ws.db.runWithWriteTransaction(
+    [Stores.config, Stores.backupProviders],
+    async (tx) => {
+      let backupStateEntry:
+        | ConfigRecord<WalletBackupConfState>
+        | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
+      checkDbInvariant(!!backupStateEntry);
+      backupStateEntry.value.lastBackupNonce = undefined;
+      backupStateEntry.value.lastBackupTimestamp = undefined;
+      backupStateEntry.value.lastBackupCheckTimestamp = undefined;
+      backupStateEntry.value.lastBackupPlainHash = undefined;
+      backupStateEntry.value.walletRootPriv = br.walletRootPriv;
+      backupStateEntry.value.walletRootPub = encodeCrock(
+        eddsaGetPublic(decodeCrock(br.walletRootPriv)),
+      );
+      await tx.put(Stores.config, backupStateEntry);
+      for (const prov of br.providers) {
+        const existingProv = await tx.get(Stores.backupProviders, prov.url);
+        if (!existingProv) {
+          await tx.put(Stores.backupProviders, {
+            active: true,
+            baseUrl: prov.url,
+            paymentProposalIds: [],
+            retryInfo: initRetryInfo(false),
+            lastError: undefined,
+          });
+        }
+      }
+      const providers = await tx.iter(Stores.backupProviders).toArray();
+      for (const prov of providers) {
+        prov.lastBackupTimestamp = undefined;
+        prov.lastBackupHash = undefined;
+        prov.lastBackupClock = undefined;
+        await tx.put(Stores.backupProviders, prov);
+      }
+    },
+  );
+}
+
+async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) 
{
+  throw Error("not implemented");
+}
+
+export async function loadBackupRecovery(
+  ws: InternalWalletState,
+  br: RecoveryLoadRequest,
+): Promise<void> {
+  const bs = await provideBackupState(ws);
+  const providers = await ws.db.iter(Stores.backupProviders).toArray();
+  let strategy = br.strategy;
+  if (
+    br.recovery.walletRootPriv != bs.walletRootPriv &&
+    providers.length > 0 &&
+    !strategy
+  ) {
+    throw Error(
+      "recovery load strategy must be specified for wallet with existing 
providers",
+    );
+  } else if (!strategy) {
+    // Default to using the new key if we don't have providers yet.
+    strategy = RecoveryMergeStrategy.Theirs;
+  }
+  if (strategy === RecoveryMergeStrategy.Theirs) {
+    return backupRecoveryTheirs(ws, br.recovery);
+  } else {
+    return backupRecoveryOurs(ws, br.recovery);
+  }
+}
+
+export async function exportBackupEncrypted(
+  ws: InternalWalletState,
+): Promise<Uint8Array> {
+  await provideBackupState(ws);
+  const blob = await exportBackup(ws);
+  const bs = await ws.db.runWithWriteTransaction(
+    [Stores.config],
+    async (tx) => {
+      return await getWalletBackupState(ws, tx);
+    },
+  );
+  return encryptBackup(bs, blob);
+}
+
+export async function decryptBackup(
+  backupConfig: WalletBackupConfState,
+  data: Uint8Array,
+): Promise<WalletBackupContentV1> {
+  const rMagic = bytesToString(data.slice(0, 8));
+  if (rMagic != magic) {
+    throw Error("invalid backup file (magic tag mismatch)");
+  }
+
+  const nonce = data.slice(8, 8 + 24);
+  const box = data.slice(8 + 24);
+  const secret = deriveBlobSecret(backupConfig);
+  const dataCompressed = secretbox_open(box, nonce, secret);
+  if (!dataCompressed) {
+    throw Error("decryption failed");
+  }
+  return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
+}
+
+export async function importBackupEncrypted(
+  ws: InternalWalletState,
+  data: Uint8Array,
+): Promise<void> {
+  const backupConfig = await provideBackupState(ws);
+  const blob = await decryptBackup(backupConfig, data);
+  const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
+  await importBackup(ws, blob, cryptoData);
+}
diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts 
b/packages/taler-wallet-core/src/operations/backup/state.ts
new file mode 100644
index 00000000..29c9402c
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/backup/state.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ 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/>
+ */
+
+import {
+  ConfigRecord,
+  encodeCrock,
+  getRandomBytes,
+  Stores,
+  Timestamp,
+  TransactionHandle,
+} from "../..";
+import { checkDbInvariant } from "../../util/invariants";
+import { InternalWalletState } from "../state";
+
+export interface WalletBackupConfState {
+  deviceId: string;
+  walletRootPub: string;
+  walletRootPriv: string;
+  clocks: { [device_id: string]: number };
+
+  /**
+   * Last hash of the canonicalized plain-text backup.
+   *
+   * Used to determine whether the wallet's content changed
+   * and we need to bump the clock.
+   */
+  lastBackupPlainHash?: string;
+
+  /**
+   * Timestamp stored in the last backup.
+   */
+  lastBackupTimestamp?: Timestamp;
+
+  /**
+   * Last time we tried to do a backup.
+   */
+  lastBackupCheckTimestamp?: Timestamp;
+  lastBackupNonce?: string;
+}
+
+export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
+
+export async function provideBackupState(
+  ws: InternalWalletState,
+): Promise<WalletBackupConfState> {
+  const bs: ConfigRecord<WalletBackupConfState> | undefined = await ws.db.get(
+    Stores.config,
+    WALLET_BACKUP_STATE_KEY,
+  );
+  if (bs) {
+    return bs.value;
+  }
+  // We need to generate the key outside of the transaction
+  // due to how IndexedDB works.
+  const k = await ws.cryptoApi.createEddsaKeypair();
+  const d = getRandomBytes(5);
+  // FIXME: device ID should be configured when wallet is initialized
+  // and be based on hostname
+  const deviceId = `wallet-core-${encodeCrock(d)}`;
+  return await ws.db.runWithWriteTransaction([Stores.config], async (tx) => {
+    let backupStateEntry:
+      | ConfigRecord<WalletBackupConfState>
+      | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
+    if (!backupStateEntry) {
+      backupStateEntry = {
+        key: WALLET_BACKUP_STATE_KEY,
+        value: {
+          deviceId,
+          clocks: { [deviceId]: 1 },
+          walletRootPub: k.pub,
+          walletRootPriv: k.priv,
+          lastBackupPlainHash: undefined,
+        },
+      };
+      await tx.put(Stores.config, backupStateEntry);
+    }
+    return backupStateEntry.value;
+  });
+}
+
+export async function getWalletBackupState(
+  ws: InternalWalletState,
+  tx: TransactionHandle<typeof Stores.config>,
+): Promise<WalletBackupConfState> {
+  let bs = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
+  checkDbInvariant(!!bs, "wallet backup state should be in DB");
+  return bs.value;
+}
diff --git a/packages/taler-wallet-core/src/operations/pay.ts 
b/packages/taler-wallet-core/src/operations/pay.ts
index cccbb3ca..03bf9e11 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -1150,36 +1150,11 @@ async function submitPay(
   };
 }
 
-/**
- * Check if a payment for the given taler://pay/ URI is possible.
- *
- * If the payment is possible, the signature are already generated but not
- * yet send to the merchant.
- */
-export async function preparePayForUri(
+export async function checkPaymentByProposalId(
   ws: InternalWalletState,
-  talerPayUri: string,
+  proposalId: string,
+  sessionId?: string,
 ): Promise<PreparePayResult> {
-  const uriResult = parsePayUri(talerPayUri);
-
-  if (!uriResult) {
-    throw OperationFailedError.fromCode(
-      TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
-      `invalid taler://pay URI (${talerPayUri})`,
-      {
-        talerPayUri,
-      },
-    );
-  }
-
-  let proposalId = await startDownloadProposal(
-    ws,
-    uriResult.merchantBaseUrl,
-    uriResult.orderId,
-    uriResult.sessionId,
-    uriResult.claimToken,
-  );
-
   let proposal = await ws.db.get(Stores.proposals, proposalId);
   if (!proposal) {
     throw Error(`could not get proposal ${proposalId}`);
@@ -1238,7 +1213,7 @@ export async function preparePayForUri(
     };
   }
 
-  if (purchase.lastSessionId !== uriResult.sessionId) {
+  if (purchase.lastSessionId !== sessionId) {
     logger.trace(
       "automatically re-submitting payment with different session ID",
     );
@@ -1247,7 +1222,7 @@ export async function preparePayForUri(
       if (!p) {
         return;
       }
-      p.lastSessionId = uriResult.sessionId;
+      p.lastSessionId = sessionId;
       await tx.put(Stores.purchases, p);
     });
     const r = await guardOperationException(
@@ -1292,6 +1267,39 @@ export async function preparePayForUri(
   }
 }
 
+/**
+ * Check if a payment for the given taler://pay/ URI is possible.
+ *
+ * If the payment is possible, the signature are already generated but not
+ * yet send to the merchant.
+ */
+export async function preparePayForUri(
+  ws: InternalWalletState,
+  talerPayUri: string,
+): Promise<PreparePayResult> {
+  const uriResult = parsePayUri(talerPayUri);
+
+  if (!uriResult) {
+    throw OperationFailedError.fromCode(
+      TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
+      `invalid taler://pay URI (${talerPayUri})`,
+      {
+        talerPayUri,
+      },
+    );
+  }
+
+  let proposalId = await startDownloadProposal(
+    ws,
+    uriResult.merchantBaseUrl,
+    uriResult.orderId,
+    uriResult.sessionId,
+    uriResult.claimToken,
+  );
+
+  return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
+}
+
 /**
  * Generate deposit permissions for a purchase.
  *
diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts 
b/packages/taler-wallet-core/src/types/dbTypes.ts
index c5f62105..6972744a 100644
--- a/packages/taler-wallet-core/src/types/dbTypes.ts
+++ b/packages/taler-wallet-core/src/types/dbTypes.ts
@@ -1462,8 +1462,19 @@ export interface BackupProviderRecord {
 
   lastBackupTimestamp?: Timestamp;
 
+  /**
+   * 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.
+   */
   paymentProposalIds: string[];
 
   /**
diff --git a/packages/taler-wallet-core/src/util/helpers.ts 
b/packages/taler-wallet-core/src/util/helpers.ts
index 3d8999ed..f5c20431 100644
--- a/packages/taler-wallet-core/src/util/helpers.ts
+++ b/packages/taler-wallet-core/src/util/helpers.ts
@@ -59,7 +59,7 @@ export function canonicalizeBaseUrl(url: string): string {
  */
 export function canonicalJson(obj: any): string {
   // Check for cycles, etc.
-  JSON.stringify(obj);
+  obj = JSON.parse(JSON.stringify(obj));
   if (typeof obj === "string" || typeof obj === "number" || obj === null) {
     return JSON.stringify(obj);
   }
diff --git a/packages/taler-wallet-core/src/util/http.ts 
b/packages/taler-wallet-core/src/util/http.ts
index 43a0ab16..73f08d40 100644
--- a/packages/taler-wallet-core/src/util/http.ts
+++ b/packages/taler-wallet-core/src/util/http.ts
@@ -36,6 +36,7 @@ import {
   timestampMin,
   timestampMax,
 } from "./time";
+import { TalerErrorDetails } from "..";
 
 const logger = new Logger("http.ts");
 
@@ -134,29 +135,35 @@ type ResponseOrError<T> =
   | { isError: false; response: T }
   | { isError: true; talerErrorResponse: TalerErrorResponse };
 
+export async function readTalerErrorResponse(
+  httpResponse: HttpResponse,
+): Promise<TalerErrorDetails> {
+  const errJson = await httpResponse.json();
+  const talerErrorCode = errJson.code;
+  if (typeof talerErrorCode !== "number") {
+    throw new OperationFailedError(
+      makeErrorDetails(
+        TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+        "Error response did not contain error code",
+        {
+          requestUrl: httpResponse.requestUrl,
+          requestMethod: httpResponse.requestMethod,
+          httpStatusCode: httpResponse.status,
+        },
+      ),
+    );
+  }
+  return errJson;
+}
+
 export async function readSuccessResponseJsonOrErrorCode<T>(
   httpResponse: HttpResponse,
   codec: Codec<T>,
 ): Promise<ResponseOrError<T>> {
   if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
-    const errJson = await httpResponse.json();
-    const talerErrorCode = errJson.code;
-    if (typeof talerErrorCode !== "number") {
-      throw new OperationFailedError(
-        makeErrorDetails(
-          TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
-          "Error response did not contain error code",
-          {
-            requestUrl: httpResponse.requestUrl,
-            requestMethod: httpResponse.requestMethod,
-            httpStatusCode: httpResponse.status,
-          },
-        ),
-      );
-    }
     return {
       isError: true,
-      talerErrorResponse: errJson,
+      talerErrorResponse: await readTalerErrorResponse(httpResponse),
     };
   }
   const respJson = await httpResponse.json();
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index 8f9999cc..26f10600 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -22,7 +22,7 @@
 /**
  * Imports.
  */
-import { TalerErrorCode } from ".";
+import { codecForAny, TalerErrorCode } from ".";
 import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi";
 import {
   addBackupProvider,
@@ -30,7 +30,6 @@ import {
   BackupInfo,
   BackupRecovery,
   codecForAddBackupProviderRequest,
-  exportBackup,
   exportBackupEncrypted,
   getBackupInfo,
   getBackupRecovery,
@@ -39,6 +38,7 @@ import {
   loadBackupRecovery,
   runBackupCycle,
 } from "./operations/backup";
+import { exportBackup } from "./operations/backup/export";
 import { getBalances } from "./operations/balance";
 import {
   createDepositGroup,
@@ -1159,6 +1159,15 @@ export class Wallet {
         await runBackupCycle(this.ws);
         return {};
       }
+      case "exportBackupRecovery": {
+        const resp = await getBackupRecovery(this.ws);
+        return resp;
+      }
+      case "importBackupRecovery": {
+        const req = codecForAny().decode(payload);
+        await loadBackupRecovery(this.ws, req);
+        return {};
+      }
       case "getBackupInfo": {
         const resp = await getBackupInfo(this.ws);
         return resp;

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]