gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] 01/02: restructure sync, store errors


From: gnunet
Subject: [taler-wallet-core] 01/02: restructure sync, store errors
Date: Wed, 10 Mar 2021 17:12:06 +0100

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

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

commit ac89c3d277134e49e44d8b0afd4930fd4df934aa
Author: Florian Dold <florian@dold.me>
AuthorDate: Wed Mar 10 12:00:30 2021 +0100

    restructure sync, store errors
---
 .../src/integrationtests/harness.ts                |   17 +-
 .../integrationtests/test-wallet-backup-basic.ts   |    9 +-
 .../taler-wallet-core/src/operations/backup.ts     | 1907 --------------------
 .../src/operations/backup/export.ts                |  447 +++++
 .../src/operations/backup/import.ts                |  825 +++++++++
 .../src/operations/backup/index.ts                 |  650 +++++++
 .../src/operations/backup/state.ts                 |  101 ++
 packages/taler-wallet-core/src/util/http.ts        |   39 +-
 packages/taler-wallet-core/src/wallet.ts           |    2 +-
 9 files changed, 2070 insertions(+), 1927 deletions(-)

diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts 
b/packages/taler-wallet-cli/src/integrationtests/harness.ts
index 2b26ef7f..835eb7a0 100644
--- a/packages/taler-wallet-cli/src/integrationtests/harness.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts
@@ -99,7 +99,10 @@ 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,
+} from "@gnu-taler/taler-wallet-core/src/operations/backup";
 
 const exec = util.promisify(require("child_process").exec);
 
@@ -1474,7 +1477,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 +1886,12 @@ export class WalletCli {
     }
     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/test-wallet-backup-basic.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts
index 9201c558..9804f7ab 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
@@ -56,11 +56,18 @@ 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);
+  }
 }
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..fa081974
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -0,0 +1,825 @@
+/*
+ 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,
+  AmountJson,
+  codecForContractTerms,
+  CoinSource,
+  DenominationStatus,
+  DenomSelectionState,
+  ExchangeUpdateStatus,
+  ExchangeWireInfo,
+  PayCoinSelection,
+  ProposalDownload,
+  RefreshReason,
+  RefreshSessionRecord,
+  ReserveBankInfo,
+  ReserveRecordStatus,
+  TransactionHandle,
+  WalletContractData,
+  WalletRefundItem,
+} 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,
+  BackupDenomSel,
+  BackupRefreshReason,
+} from "../../types/backupTypes";
+import { canonicalizeBaseUrl, canonicalJson, 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);
+  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..fd027421
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/backup/index.ts
@@ -0,0 +1,650 @@
+/*
+ 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 { 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 { getTimestampNow, Timestamp } 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 { confirmPay, preparePayForUri } from "../pay";
+import { exportBackup } from "./export";
+import { BackupCryptoPrecomputedData, importBackup } from "./import";
+import {
+  provideBackupState,
+  WALLET_BACKUP_STATE_KEY,
+  getWalletBackupState,
+  WalletBackupConfState,
+} from "./state";
+
+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"),
+  );
+}
+
+/**
+ * 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(`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: 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];
+          prov.lastError = undefined;
+          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();
+          prov.lastError = undefined;
+          await tx.put(Stores.backupProviders, prov);
+        },
+      );
+      logger.info("processed existing backup");
+      continue;
+    }
+
+    // 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;
+      },
+    );
+  }
+}
+
+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[];
+}
+
+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,
+      lastError: x.lastError,
+    })),
+  };
+}
+
+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/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..dc320b17 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -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,

-- 
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]