gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] branch master updated (f9f2911c -> 676ae510)


From: gnunet
Subject: [taler-wallet-core] branch master updated (f9f2911c -> 676ae510)
Date: Tue, 13 Sep 2022 16:10:45 +0200

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

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

    from f9f2911c adding missing i18n
     new 13e7a674 wallet-core: uniform retry handling
     new 48540f62 wallet-core: introduce easier syntax for transactions
     new 676ae510 fix test and logging

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


Summary of changes:
 packages/taler-util/src/time.ts                    |   5 +-
 packages/taler-util/src/walletTypes.ts             |  84 ++---
 packages/taler-wallet-cli/src/index.ts             |   3 +
 .../integrationtests/test-exchange-management.ts   |  16 +-
 .../src/integrationtests/test-payment-claim.ts     |   9 +-
 packages/taler-wallet-core/src/db-utils.ts         |  16 +-
 packages/taler-wallet-core/src/db.ts               | 267 +++++++-------
 packages/taler-wallet-core/src/errors.ts           |   2 +-
 .../src/operations/backup/export.ts                |  28 +-
 .../src/operations/backup/import.ts                |  52 +--
 .../src/operations/backup/index.ts                 | 216 ++++++------
 .../src/operations/backup/state.ts                 |  12 +-
 .../taler-wallet-core/src/operations/balance.ts    |   7 +-
 .../taler-wallet-core/src/operations/deposits.ts   | 158 ++-------
 .../taler-wallet-core/src/operations/exchanges.ts  | 162 +++------
 packages/taler-wallet-core/src/operations/pay.ts   | 390 +++++++--------------
 .../src/operations/peer-to-peer.ts                 |  52 ++-
 .../taler-wallet-core/src/operations/pending.ts    | 198 +++++++----
 .../taler-wallet-core/src/operations/recoup.ts     | 145 ++------
 .../taler-wallet-core/src/operations/refresh.ts    | 165 ++-------
 .../taler-wallet-core/src/operations/refund.ts     | 143 +-------
 .../taler-wallet-core/src/operations/testing.ts    |   2 +-
 packages/taler-wallet-core/src/operations/tip.ts   | 200 ++++-------
 .../src/operations/transactions.ts                 | 126 +++----
 .../taler-wallet-core/src/operations/withdraw.ts   | 360 ++++++++-----------
 packages/taler-wallet-core/src/pending-types.ts    |  24 +-
 packages/taler-wallet-core/src/util/query.ts       |  85 +++--
 packages/taler-wallet-core/src/util/retries.ts     | 117 ++++++-
 packages/taler-wallet-core/src/wallet.ts           | 276 +++++++++------
 29 files changed, 1412 insertions(+), 1908 deletions(-)

diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts
index 9c25af40..1e79b943 100644
--- a/packages/taler-util/src/time.ts
+++ b/packages/taler-util/src/time.ts
@@ -100,6 +100,10 @@ export namespace Duration {
     return durationMin(d1, d2);
   }
 
+  export function multiply(d1: Duration, n: number): Duration {
+    return durationMul(d1, n);
+  }
+
   export function toIntegerYears(d: Duration): number {
     if (typeof d.d_ms !== "number") {
       throw Error("infinite duration");
@@ -357,7 +361,6 @@ export const codecForAbsoluteTime: Codec<AbsoluteTime> = {
   },
 };
 
-
 export const codecForTimestamp: Codec<TalerProtocolTimestamp> = {
   decode(x: any, c?: Context): TalerProtocolTimestamp {
     // Compatibility, should be removed soon.
diff --git a/packages/taler-util/src/walletTypes.ts 
b/packages/taler-util/src/walletTypes.ts
index a993f29a..c10e3be4 100644
--- a/packages/taler-util/src/walletTypes.ts
+++ b/packages/taler-util/src/walletTypes.ts
@@ -32,7 +32,12 @@ import {
   codecForAmountJson,
   codecForAmountString,
 } from "./amounts.js";
-import { AbsoluteTime, codecForAbsoluteTime, codecForTimestamp, 
TalerProtocolTimestamp } from "./time.js";
+import {
+  AbsoluteTime,
+  codecForAbsoluteTime,
+  codecForTimestamp,
+  TalerProtocolTimestamp,
+} from "./time.js";
 import {
   buildCodecForObject,
   codecForString,
@@ -797,46 +802,43 @@ const codecForExchangeTos = (): Codec<ExchangeTos> =>
     .property("content", codecOptional(codecForString()))
     .build("ExchangeTos");
 
-export const codecForFeeDescriptionPair =
-  (): Codec<FeeDescriptionPair> =>
-    buildCodecForObject<FeeDescriptionPair>()
-      .property("value", codecForAmountJson())
-      .property("from", codecForAbsoluteTime)
-      .property("until", codecForAbsoluteTime)
-      .property("left", codecOptional(codecForAmountJson()))
-      .property("right", codecOptional(codecForAmountJson()))
-      .build("FeeDescriptionPair");
-
-export const codecForFeeDescription =
-  (): Codec<FeeDescription> =>
-    buildCodecForObject<FeeDescription>()
-      .property("value", codecForAmountJson())
-      .property("from", codecForAbsoluteTime)
-      .property("until", codecForAbsoluteTime)
-      .property("fee", codecOptional(codecForAmountJson()))
-      .build("FeeDescription");
-
-
-export const codecForFeesByOperations =
-  (): Codec<OperationMap<FeeDescription[]>> =>
-    buildCodecForObject<OperationMap<FeeDescription[]>>()
-      .property("deposit", codecForList(codecForFeeDescription()))
-      .property("withdraw", codecForList(codecForFeeDescription()))
-      .property("refresh", codecForList(codecForFeeDescription()))
-      .property("refund", codecForList(codecForFeeDescription()))
-      .build("FeesByOperations");
-
-export const codecForExchangeFullDetails =
-  (): Codec<ExchangeFullDetails> =>
-    buildCodecForObject<ExchangeFullDetails>()
-      .property("currency", codecForString())
-      .property("exchangeBaseUrl", codecForString())
-      .property("paytoUris", codecForList(codecForString()))
-      .property("tos", codecForExchangeTos())
-      .property("auditors", codecForList(codecForExchangeAuditor()))
-      .property("wireInfo", codecForWireInfo())
-      .property("feesDescription", codecForFeesByOperations())
-      .build("ExchangeFullDetails");
+export const codecForFeeDescriptionPair = (): Codec<FeeDescriptionPair> =>
+  buildCodecForObject<FeeDescriptionPair>()
+    .property("value", codecForAmountJson())
+    .property("from", codecForAbsoluteTime)
+    .property("until", codecForAbsoluteTime)
+    .property("left", codecOptional(codecForAmountJson()))
+    .property("right", codecOptional(codecForAmountJson()))
+    .build("FeeDescriptionPair");
+
+export const codecForFeeDescription = (): Codec<FeeDescription> =>
+  buildCodecForObject<FeeDescription>()
+    .property("value", codecForAmountJson())
+    .property("from", codecForAbsoluteTime)
+    .property("until", codecForAbsoluteTime)
+    .property("fee", codecOptional(codecForAmountJson()))
+    .build("FeeDescription");
+
+export const codecForFeesByOperations = (): Codec<
+  OperationMap<FeeDescription[]>
+> =>
+  buildCodecForObject<OperationMap<FeeDescription[]>>()
+    .property("deposit", codecForList(codecForFeeDescription()))
+    .property("withdraw", codecForList(codecForFeeDescription()))
+    .property("refresh", codecForList(codecForFeeDescription()))
+    .property("refund", codecForList(codecForFeeDescription()))
+    .build("FeesByOperations");
+
+export const codecForExchangeFullDetails = (): Codec<ExchangeFullDetails> =>
+  buildCodecForObject<ExchangeFullDetails>()
+    .property("currency", codecForString())
+    .property("exchangeBaseUrl", codecForString())
+    .property("paytoUris", codecForList(codecForString()))
+    .property("tos", codecForExchangeTos())
+    .property("auditors", codecForList(codecForExchangeAuditor()))
+    .property("wireInfo", codecForWireInfo())
+    .property("feesDescription", codecForFeesByOperations())
+    .build("ExchangeFullDetails");
 
 export const codecForExchangeListItem = (): Codec<ExchangeListItem> =>
   buildCodecForObject<ExchangeListItem>()
diff --git a/packages/taler-wallet-cli/src/index.ts 
b/packages/taler-wallet-cli/src/index.ts
index 2ed37142..5fd608f7 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -226,6 +226,9 @@ async function withWallet<T>(
   const wallet = await getDefaultNodeWallet({
     persistentStoragePath: dbPath,
     httpLib: myHttpLib,
+    notifyHandler: (n) => {
+      logger.info(`wallet notification: ${j2s(n)}`);
+    },
   });
 
   if (checkEnvFlag("TALER_WALLET_BATCH_WITHDRAWAL")) {
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts
index 0193322f..56c3cf23 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts
@@ -189,17 +189,10 @@ export async function runExchangeManagementTest(
     });
   });
 
-  // Updating the exchange from the base URL is technically a pending operation
-  // and it will be retried later.
-  t.assertTrue(
-    err1.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED),
-  );
-
   // Response is malformed, since it didn't even contain a version code
   // in a format the wallet can understand.
   t.assertTrue(
-    err1.errorDetail.innerError.code ===
-      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+    err1.errorDetail.code === 
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
   );
 
   exchangesList = await wallet.client.call(
@@ -238,12 +231,9 @@ export async function runExchangeManagementTest(
   });
 
   t.assertTrue(
-    err2.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED),
-  );
-
-  t.assertTrue(
-    err2.errorDetail.innerError.code ===
+    err2.hasErrorCode(
       TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+    ),
   );
 
   exchangesList = await wallet.client.call(
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts
index e878854f..e93d2c44 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts
@@ -102,14 +102,7 @@ export async function runPaymentClaimTest(t: 
GlobalTestState) {
     });
   });
 
-  t.assertTrue(
-    err.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED),
-  );
-
-  t.assertTrue(
-    err.errorDetail.innerError.code ===
-      TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
-  );
+  t.assertTrue(err.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED));
 
   await t.shutdown();
 }
diff --git a/packages/taler-wallet-core/src/db-utils.ts 
b/packages/taler-wallet-core/src/db-utils.ts
index 9f18ed9b..de54719c 100644
--- a/packages/taler-wallet-core/src/db-utils.ts
+++ b/packages/taler-wallet-core/src/db-utils.ts
@@ -46,9 +46,13 @@ function upgradeFromStoreMap(
 ): void {
   if (oldVersion === 0) {
     for (const n in storeMap) {
-      const swi: StoreWithIndexes<StoreDescriptor<unknown>, any> = storeMap[n];
+      const swi: StoreWithIndexes<
+        any,
+        StoreDescriptor<unknown>,
+        any
+      > = storeMap[n];
       const storeDesc: StoreDescriptor<unknown> = swi.store;
-      const s = db.createObjectStore(storeDesc.name, {
+      const s = db.createObjectStore(swi.storeName, {
         autoIncrement: storeDesc.autoIncrement,
         keyPath: storeDesc.keyPath,
       });
@@ -117,9 +121,7 @@ export async function openTalerDatabase(
   const metaDb = new DbAccess(metaDbHandle, walletMetadataStore);
   let currentMainVersion: string | undefined;
   await metaDb
-    .mktx((x) => ({
-      metaConfig: x.metaConfig,
-    }))
+    .mktx((stores) => [stores.metaConfig])
     .runReadWrite(async (tx) => {
       const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
       if (!dbVersionRecord) {
@@ -141,9 +143,7 @@ export async function openTalerDatabase(
         // We consider this a pre-release
         // development version, no migration is done.
         await metaDb
-          .mktx((x) => ({
-            metaConfig: x.metaConfig,
-          }))
+          .mktx((stores) => [stores.metaConfig])
           .runReadWrite(async (tx) => {
             await tx.metaConfig.put({
               key: CURRENT_DB_CONFIG_KEY,
diff --git a/packages/taler-wallet-core/src/db.ts 
b/packages/taler-wallet-core/src/db.ts
index 9d41f211..832bbb9a 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -361,14 +361,14 @@ export interface ExchangeDetailsRecord {
    * Terms of service text or undefined if not downloaded yet.
    *
    * This is just used as a cache of the last downloaded ToS.
-   * 
+   *
    * FIXME:  Put in separate object store!
    */
   termsOfServiceText: string | undefined;
 
   /**
    * content-type of the last downloaded termsOfServiceText.
-   * 
+   *
    * * FIXME:  Put in separate object store!
    */
   termsOfServiceContentType: string | undefined;
@@ -454,17 +454,6 @@ export interface ExchangeRecord {
    */
   nextRefreshCheck: TalerProtocolTimestamp;
 
-  /**
-   * Last error (if any) for fetching updated information about the
-   * exchange.
-   */
-  lastError?: TalerErrorDetail;
-
-  /**
-   * Retry status for fetching updated information about the exchange.
-   */
-  retryInfo?: RetryInfo;
-
   /**
    * Public key of the reserve that we're currently using for
    * receiving P2P payments.
@@ -734,24 +723,12 @@ export interface ProposalRecord {
    * Session ID we got when downloading the contract.
    */
   downloadSessionId?: string;
-
-  /**
-   * Retry info, even present when the operation isn't active to allow indexing
-   * on the next retry timestamp.
-   *
-   * FIXME: Clarify what we even retry.
-   */
-  retryInfo?: RetryInfo;
-
-  lastError: TalerErrorDetail | undefined;
 }
 
 /**
  * Status of a tip we got from a merchant.
  */
 export interface TipRecord {
-  lastError: TalerErrorDetail | undefined;
-
   /**
    * Has the user accepted the tip?  Only after the tip has been accepted coins
    * withdrawn from the tip may be used.
@@ -810,12 +787,6 @@ export interface TipRecord {
    * from the merchant.
    */
   pickedUpTimestamp: TalerProtocolTimestamp | undefined;
-
-  /**
-   * Retry info, even present when the operation isn't active to allow indexing
-   * on the next retry timestamp.
-   */
-  retryInfo: RetryInfo;
 }
 
 export enum RefreshCoinStatus {
@@ -837,16 +808,7 @@ export enum OperationStatus {
 export interface RefreshGroupRecord {
   operationStatus: OperationStatus;
 
-  /**
-   * Retry info, even present when the operation isn't active to allow indexing
-   * on the next retry timestamp.
-   *
-   * FIXME: No, this can be optional, indexing is still possible
-   */
-  retryInfo: RetryInfo;
-
-  lastError: TalerErrorDetail | undefined;
-
+  // FIXME: Put this into a different object store?
   lastErrorPerCoin: { [coinIndex: number]: TalerErrorDetail };
 
   /**
@@ -1117,6 +1079,8 @@ export interface PurchaseRecord {
   /**
    * Pending refunds for the purchase.  A refund is pending
    * when the merchant reports a transient error from the exchange.
+   *
+   * FIXME: Put this into a separate object store?
    */
   refunds: { [refundKey: string]: WalletRefundItem };
 
@@ -1132,6 +1096,7 @@ export interface PurchaseRecord {
   lastSessionId: string | undefined;
 
   /**
+   * Do we still need to post the deposit permissions to the merchant?
    * Set for the first payment, or on re-plays.
    */
   paymentSubmitPending: boolean;
@@ -1142,22 +1107,6 @@ export interface PurchaseRecord {
    */
   refundQueryRequested: boolean;
 
-  abortStatus: AbortStatus;
-
-  payRetryInfo?: RetryInfo;
-
-  lastPayError: TalerErrorDetail | undefined;
-
-  /**
-   * Retry information for querying the refund status with the merchant.
-   */
-  refundStatusRetryInfo: RetryInfo;
-
-  /**
-   * Last error (or undefined) for querying the refund status with the 
merchant.
-   */
-  lastRefundStatusError: TalerErrorDetail | undefined;
-
   /**
    * Continue querying the refund status until this deadline has expired.
    */
@@ -1174,6 +1123,11 @@ export interface PurchaseRecord {
    * an error where it doesn't make sense to retry.
    */
   payFrozen?: boolean;
+
+  /**
+   * FIXME: How does this interact with payFrozen?
+   */
+  abortStatus: AbortStatus;
 }
 
 export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
@@ -1184,9 +1138,9 @@ export const WALLET_BACKUP_STATE_KEY = 
"walletBackupState";
  */
 export type ConfigRecord =
   | {
-    key: typeof WALLET_BACKUP_STATE_KEY;
-    value: WalletBackupConfState;
-  }
+      key: typeof WALLET_BACKUP_STATE_KEY;
+      value: WalletBackupConfState;
+    }
   | { key: "currencyDefaultsApplied"; value: boolean };
 
 export interface WalletBackupConfState {
@@ -1368,13 +1322,6 @@ export interface WithdrawalGroupRecord {
    * FIXME: Should this not also include a timestamp for more logical merging?
    */
   denomSelUid: string;
-
-  /**
-   * Retry info.
-   */
-  retryInfo?: RetryInfo;
-
-  lastError: TalerErrorDetail | undefined;
 }
 
 export interface BankWithdrawUriRecord {
@@ -1432,16 +1379,6 @@ export interface RecoupGroupRecord {
    * after all individual recoups are done.
    */
   scheduleRefreshCoins: string[];
-
-  /**
-   * Retry info.
-   */
-  retryInfo: RetryInfo;
-
-  /**
-   * Last error that occurred, if any.
-   */
-  lastError: TalerErrorDetail | undefined;
 }
 
 export enum BackupProviderStateTag {
@@ -1452,17 +1389,15 @@ export enum BackupProviderStateTag {
 
 export type BackupProviderState =
   | {
-    tag: BackupProviderStateTag.Provisional;
-  }
+      tag: BackupProviderStateTag.Provisional;
+    }
   | {
-    tag: BackupProviderStateTag.Ready;
-    nextBackupTimestamp: TalerProtocolTimestamp;
-  }
+      tag: BackupProviderStateTag.Ready;
+      nextBackupTimestamp: TalerProtocolTimestamp;
+    }
   | {
-    tag: BackupProviderStateTag.Retrying;
-    retryInfo: RetryInfo;
-    lastError?: TalerErrorDetail;
-  };
+      tag: BackupProviderStateTag.Retrying;
+    };
 
 export interface BackupProviderTerms {
   supportedProtocolVersion: string;
@@ -1573,13 +1508,6 @@ export interface DepositGroupRecord {
   timestampFinished: TalerProtocolTimestamp | undefined;
 
   operationStatus: OperationStatus;
-
-  lastError: TalerErrorDetail | undefined;
-
-  /**
-   * Retry info.
-   */
-  retryInfo?: RetryInfo;
 }
 
 /**
@@ -1749,9 +1677,64 @@ export interface ReserveRecord {
   reservePriv: string;
 }
 
+export interface OperationRetryRecord {
+  /**
+   * Unique identifier for the operation.  Typically of
+   * the format `${opType}-${opUniqueKey}`
+   */
+  id: string;
+
+  lastError?: TalerErrorDetail;
+
+  retryInfo: RetryInfo;
+}
+
+export enum OperationAttemptResultType {
+  Finished = "finished",
+  Pending = "pending",
+  Error = "error",
+  Longpoll = "longpoll",
+}
+
+// FIXME: not part of DB!
+export type OperationAttemptResult<TSuccess = unknown, TPending = unknown> =
+  | OperationAttemptFinishedResult<TSuccess>
+  | OperationAttemptErrorResult
+  | OperationAttemptLongpollResult
+  | OperationAttemptPendingResult<TPending>;
+
+export namespace OperationAttemptResult {
+  export function finishedEmpty(): OperationAttemptResult<unknown, unknown> {
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
+  }
+}
+
+export interface OperationAttemptFinishedResult<T> {
+  type: OperationAttemptResultType.Finished;
+  result: T;
+}
+
+export interface OperationAttemptPendingResult<T> {
+  type: OperationAttemptResultType.Pending;
+  result: T;
+}
+
+export interface OperationAttemptErrorResult {
+  type: OperationAttemptResultType.Error;
+  errorDetail: TalerErrorDetail;
+}
+
+export interface OperationAttemptLongpollResult {
+  type: OperationAttemptResultType.Longpoll;
+}
+
 export const WalletStoresV1 = {
   coins: describeStore(
-    describeContents<CoinRecord>("coins", {
+    "coins",
+    describeContents<CoinRecord>({
       keyPath: "coinPub",
     }),
     {
@@ -1761,17 +1744,20 @@ export const WalletStoresV1 = {
     },
   ),
   reserves: describeStore(
-    describeContents<ReserveRecord>("reserves", {
+    "reserves",
+    describeContents<ReserveRecord>({
       keyPath: "reservePub",
     }),
     {},
   ),
   config: describeStore(
-    describeContents<ConfigRecord>("config", { keyPath: "key" }),
+    "config",
+    describeContents<ConfigRecord>({ keyPath: "key" }),
     {},
   ),
   auditorTrust: describeStore(
-    describeContents<AuditorTrustRecord>("auditorTrust", {
+    "auditorTrust",
+    describeContents<AuditorTrustRecord>({
       keyPath: ["currency", "auditorBaseUrl"],
     }),
     {
@@ -1782,7 +1768,8 @@ export const WalletStoresV1 = {
     },
   ),
   exchangeTrust: describeStore(
-    describeContents<ExchangeTrustRecord>("exchangeTrust", {
+    "exchangeTrust",
+    describeContents<ExchangeTrustRecord>({
       keyPath: ["currency", "exchangeBaseUrl"],
     }),
     {
@@ -1793,7 +1780,8 @@ export const WalletStoresV1 = {
     },
   ),
   denominations: describeStore(
-    describeContents<DenominationRecord>("denominations", {
+    "denominations",
+    describeContents<DenominationRecord>({
       keyPath: ["exchangeBaseUrl", "denomPubHash"],
     }),
     {
@@ -1801,19 +1789,22 @@ export const WalletStoresV1 = {
     },
   ),
   exchanges: describeStore(
-    describeContents<ExchangeRecord>("exchanges", {
+    "exchanges",
+    describeContents<ExchangeRecord>({
       keyPath: "baseUrl",
     }),
     {},
   ),
   exchangeDetails: describeStore(
-    describeContents<ExchangeDetailsRecord>("exchangeDetails", {
+    "exchangeDetails",
+    describeContents<ExchangeDetailsRecord>({
       keyPath: ["exchangeBaseUrl", "currency", "masterPublicKey"],
     }),
     {},
   ),
   proposals: describeStore(
-    describeContents<ProposalRecord>("proposals", { keyPath: "proposalId" }),
+    "proposals",
+    describeContents<ProposalRecord>({ keyPath: "proposalId" }),
     {
       byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
         "merchantBaseUrl",
@@ -1822,7 +1813,8 @@ export const WalletStoresV1 = {
     },
   ),
   refreshGroups: describeStore(
-    describeContents<RefreshGroupRecord>("refreshGroups", {
+    "refreshGroups",
+    describeContents<RefreshGroupRecord>({
       keyPath: "refreshGroupId",
     }),
     {
@@ -1830,13 +1822,15 @@ export const WalletStoresV1 = {
     },
   ),
   recoupGroups: describeStore(
-    describeContents<RecoupGroupRecord>("recoupGroups", {
+    "recoupGroups",
+    describeContents<RecoupGroupRecord>({
       keyPath: "recoupGroupId",
     }),
     {},
   ),
   purchases: describeStore(
-    describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }),
+    "purchases",
+    describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
     {
       byFulfillmentUrl: describeIndex(
         "byFulfillmentUrl",
@@ -1849,7 +1843,8 @@ export const WalletStoresV1 = {
     },
   ),
   tips: describeStore(
-    describeContents<TipRecord>("tips", { keyPath: "walletTipId" }),
+    "tips",
+    describeContents<TipRecord>({ keyPath: "walletTipId" }),
     {
       byMerchantTipIdAndBaseUrl: describeIndex("byMerchantTipIdAndBaseUrl", [
         "merchantTipId",
@@ -1858,7 +1853,8 @@ export const WalletStoresV1 = {
     },
   ),
   withdrawalGroups: describeStore(
-    describeContents<WithdrawalGroupRecord>("withdrawalGroups", {
+    "withdrawalGroups",
+    describeContents<WithdrawalGroupRecord>({
       keyPath: "withdrawalGroupId",
     }),
     {
@@ -1871,7 +1867,8 @@ export const WalletStoresV1 = {
     },
   ),
   planchets: describeStore(
-    describeContents<PlanchetRecord>("planchets", { keyPath: "coinPub" }),
+    "planchets",
+    describeContents<PlanchetRecord>({ keyPath: "coinPub" }),
     {
       byGroupAndIndex: describeIndex("byGroupAndIndex", [
         "withdrawalGroupId",
@@ -1882,13 +1879,15 @@ export const WalletStoresV1 = {
     },
   ),
   bankWithdrawUris: describeStore(
-    describeContents<BankWithdrawUriRecord>("bankWithdrawUris", {
+    "bankWithdrawUris",
+    describeContents<BankWithdrawUriRecord>({
       keyPath: "talerWithdrawUri",
     }),
     {},
   ),
   backupProviders: describeStore(
-    describeContents<BackupProviderRecord>("backupProviders", {
+    "backupProviders",
+    describeContents<BackupProviderRecord>({
       keyPath: "baseUrl",
     }),
     {
@@ -1902,7 +1901,8 @@ export const WalletStoresV1 = {
     },
   ),
   depositGroups: describeStore(
-    describeContents<DepositGroupRecord>("depositGroups", {
+    "depositGroups",
+    describeContents<DepositGroupRecord>({
       keyPath: "depositGroupId",
     }),
     {
@@ -1910,23 +1910,34 @@ export const WalletStoresV1 = {
     },
   ),
   tombstones: describeStore(
-    describeContents<TombstoneRecord>("tombstones", { keyPath: "id" }),
+    "tombstones",
+    describeContents<TombstoneRecord>({ keyPath: "id" }),
+    {},
+  ),
+  operationRetries: describeStore(
+    "operationRetries",
+    describeContents<OperationRetryRecord>({
+      keyPath: "id",
+    }),
     {},
   ),
   ghostDepositGroups: describeStore(
-    describeContents<GhostDepositGroupRecord>("ghostDepositGroups", {
+    "ghostDepositGroups",
+    describeContents<GhostDepositGroupRecord>({
       keyPath: "contractTermsHash",
     }),
     {},
   ),
   balancesPerCurrency: describeStore(
-    describeContents<BalancePerCurrencyRecord>("balancesPerCurrency", {
+    "balancesPerCurrency",
+    describeContents<BalancePerCurrencyRecord>({
       keyPath: "currency",
     }),
     {},
   ),
   peerPushPaymentIncoming: describeStore(
-    describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", 
{
+    "peerPushPaymentIncoming",
+    describeContents<PeerPushPaymentIncomingRecord>({
       keyPath: "peerPushPaymentIncomingId",
     }),
     {
@@ -1937,7 +1948,8 @@ export const WalletStoresV1 = {
     },
   ),
   peerPullPaymentIncoming: describeStore(
-    describeContents<PeerPullPaymentIncomingRecord>("peerPullPaymentIncoming", 
{
+    "peerPullPaymentIncoming",
+    describeContents<PeerPullPaymentIncomingRecord>({
       keyPath: "peerPullPaymentIncomingId",
     }),
     {
@@ -1948,21 +1960,17 @@ export const WalletStoresV1 = {
     },
   ),
   peerPullPaymentInitiations: describeStore(
-    describeContents<PeerPullPaymentInitiationRecord>(
-      "peerPullPaymentInitiations",
-      {
-        keyPath: "pursePub",
-      },
-    ),
+    "peerPullPaymentInitiations",
+    describeContents<PeerPullPaymentInitiationRecord>({
+      keyPath: "pursePub",
+    }),
     {},
   ),
   peerPushPaymentInitiations: describeStore(
-    describeContents<PeerPushPaymentInitiationRecord>(
-      "peerPushPaymentInitiations",
-      {
-        keyPath: "pursePub",
-      },
-    ),
+    "peerPushPaymentInitiations",
+    describeContents<PeerPushPaymentInitiationRecord>({
+      keyPath: "pursePub",
+    }),
     {},
   ),
 };
@@ -1974,7 +1982,8 @@ export interface MetaConfigRecord {
 
 export const walletMetadataStore = {
   metaConfig: describeStore(
-    describeContents<MetaConfigRecord>("metaConfig", { keyPath: "key" }),
+    "metaConfig",
+    describeContents<MetaConfigRecord>({ keyPath: "key" }),
     {},
   ),
 };
diff --git a/packages/taler-wallet-core/src/errors.ts 
b/packages/taler-wallet-core/src/errors.ts
index 56017cc0..d56e936c 100644
--- a/packages/taler-wallet-core/src/errors.ts
+++ b/packages/taler-wallet-core/src/errors.ts
@@ -102,7 +102,7 @@ export function summarizeTalerErrorDetail(ed: 
TalerErrorDetail): string {
 export class TalerError<T = any> extends Error {
   errorDetail: TalerErrorDetail & T;
   private constructor(d: TalerErrorDetail & T) {
-    super();
+    super(d.hint ?? `Error (code ${d.code})`);
     this.errorDetail = d;
     Object.setPrototypeOf(this, TalerError.prototype);
   }
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts 
b/packages/taler-wallet-core/src/operations/backup/export.ts
index c77ce1a8..fb1fbf90 100644
--- a/packages/taler-wallet-core/src/operations/backup/export.ts
+++ b/packages/taler-wallet-core/src/operations/backup/export.ts
@@ -76,20 +76,20 @@ export async function exportBackup(
 ): Promise<WalletBackupContentV1> {
   await provideBackupState(ws);
   return ws.db
-    .mktx((x) => ({
-      config: x.config,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      coins: x.coins,
-      denominations: x.denominations,
-      purchases: x.purchases,
-      proposals: x.proposals,
-      refreshGroups: x.refreshGroups,
-      backupProviders: x.backupProviders,
-      tips: x.tips,
-      recoupGroups: x.recoupGroups,
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [
+      x.config,
+      x.exchanges,
+      x.exchangeDetails,
+      x.coins,
+      x.denominations,
+      x.purchases,
+      x.proposals,
+      x.refreshGroups,
+      x.backupProviders,
+      x.tips,
+      x.recoupGroups,
+      x.withdrawalGroups,
+    ])
     .runReadWrite(async (tx) => {
       const bs = await getWalletBackupState(ws, tx);
 
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts 
b/packages/taler-wallet-core/src/operations/backup/import.ts
index ff7ff0d0..8f5d019d 100644
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -224,22 +224,22 @@ export async function importBackup(
   logger.info(`importing backup ${j2s(backupBlobArg)}`);
 
   return ws.db
-    .mktx((x) => ({
-      config: x.config,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      coins: x.coins,
-      denominations: x.denominations,
-      purchases: x.purchases,
-      proposals: x.proposals,
-      refreshGroups: x.refreshGroups,
-      backupProviders: x.backupProviders,
-      tips: x.tips,
-      recoupGroups: x.recoupGroups,
-      withdrawalGroups: x.withdrawalGroups,
-      tombstones: x.tombstones,
-      depositGroups: x.depositGroups,
-    }))
+    .mktx((x) => [
+      x.config,
+      x.exchangeDetails,
+      x.exchanges,
+      x.coins,
+      x.denominations,
+      x.purchases,
+      x.proposals,
+      x.refreshGroups,
+      x.backupProviders,
+      x.tips,
+      x.recoupGroups,
+      x.withdrawalGroups,
+      x.tombstones,
+      x.depositGroups,
+    ])
     .runReadWrite(async (tx) => {
       // FIXME: validate schema!
       const backupBlob = backupBlobArg as WalletBackupContentV1;
@@ -274,7 +274,6 @@ export async function importBackup(
             protocolVersionRange: backupExchange.protocol_version_range,
           },
           permanent: true,
-          retryInfo: RetryInfo.reset(),
           lastUpdate: undefined,
           nextUpdate: TalerProtocolTimestamp.now(),
           nextRefreshCheck: TalerProtocolTimestamp.now(),
@@ -341,7 +340,7 @@ export async function importBackup(
           }
           const denomPubHash =
             cryptoComp.rsaDenomPubToHash[
-            backupDenomination.denom_pub.rsa_public_key
+              backupDenomination.denom_pub.rsa_public_key
             ];
           checkLogicInvariant(!!denomPubHash);
           const existingDenom = await tx.denominations.get([
@@ -426,7 +425,6 @@ export async function importBackup(
           }
         }
 
-
         // FIXME: import reserves with new schema
 
         // for (const backupReserve of backupExchangeDetails.reserves) {
@@ -517,7 +515,6 @@ export async function importBackup(
         //     }
         //   }
         // }
-
       }
 
       for (const backupProposal of backupBlob.proposals) {
@@ -560,7 +557,7 @@ export async function importBackup(
             const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
             const contractTermsHash =
               cryptoComp.proposalIdToContractTermsHash[
-              backupProposal.proposal_id
+                backupProposal.proposal_id
               ];
             let maxWireFee: AmountJson;
             if (parsedContractTerms.max_wire_fee) {
@@ -611,7 +608,6 @@ export async function importBackup(
           }
           await tx.proposals.put({
             claimToken: backupProposal.claim_token,
-            lastError: undefined,
             merchantBaseUrl: backupProposal.merchant_base_url,
             timestamp: backupProposal.timestamp,
             orderId: backupProposal.order_id,
@@ -620,7 +616,6 @@ export async function importBackup(
               cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
             proposalId: backupProposal.proposal_id,
             repurchaseProposalId: backupProposal.repurchase_proposal_id,
-            retryInfo: RetryInfo.reset(),
             download,
             proposalStatus,
           });
@@ -706,7 +701,7 @@ export async function importBackup(
           const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
           const contractTermsHash =
             cryptoComp.proposalIdToContractTermsHash[
-            backupPurchase.proposal_id
+              backupPurchase.proposal_id
             ];
           let maxWireFee: AmountJson;
           if (parsedContractTerms.max_wire_fee) {
@@ -755,10 +750,7 @@ export async function importBackup(
             noncePriv: backupPurchase.nonce_priv,
             noncePub:
               cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
-            lastPayError: undefined,
             autoRefundDeadline: TalerProtocolTimestamp.never(),
-            refundStatusRetryInfo: RetryInfo.reset(),
-            lastRefundStatusError: undefined,
             refundAwaiting: undefined,
             timestampAccept: backupPurchase.timestamp_accept,
             timestampFirstSuccessfulPay:
@@ -767,8 +759,6 @@ export async function importBackup(
             merchantPaySig: backupPurchase.merchant_pay_sig,
             lastSessionId: undefined,
             abortStatus,
-            // FIXME!
-            payRetryInfo: RetryInfo.reset(),
             download,
             paymentSubmitPending:
               !backupPurchase.timestamp_first_successful_pay,
@@ -851,7 +841,6 @@ export async function importBackup(
             timestampCreated: backupRefreshGroup.timestamp_created,
             refreshGroupId: backupRefreshGroup.refresh_group_id,
             reason,
-            lastError: undefined,
             lastErrorPerCoin: {},
             oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),
             statusPerCoin: backupRefreshGroup.old_coins.map((x) =>
@@ -869,7 +858,6 @@ export async function importBackup(
               Amounts.parseOrThrow(x.estimated_output_amount),
             ),
             refreshSessionPerCoin,
-            retryInfo: RetryInfo.reset(),
           });
         }
       }
@@ -891,11 +879,9 @@ export async function importBackup(
             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: RetryInfo.reset(),
             secretSeed: backupTip.secret_seed,
             tipAmountEffective: denomsSel.totalCoinValue,
             tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw),
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts 
b/packages/taler-wallet-core/src/operations/backup/index.ts
index 45b8491d..db003af8 100644
--- a/packages/taler-wallet-core/src/operations/backup/index.ts
+++ b/packages/taler-wallet-core/src/operations/backup/index.ts
@@ -25,9 +25,12 @@
  * Imports.
  */
 import {
-  AbsoluteTime, AmountString,
+  AbsoluteTime,
+  AmountString,
   BackupRecovery,
-  buildCodecForObject, bytesToString, canonicalizeBaseUrl,
+  buildCodecForObject,
+  bytesToString,
+  canonicalizeBaseUrl,
   canonicalJson,
   Codec,
   codecForAmountString,
@@ -36,19 +39,32 @@ import {
   codecForNumber,
   codecForString,
   codecOptional,
-  ConfirmPayResultType, decodeCrock, DenomKeyType,
-  durationFromSpec, eddsaGetPublic,
+  ConfirmPayResultType,
+  decodeCrock,
+  DenomKeyType,
+  durationFromSpec,
+  eddsaGetPublic,
   EddsaKeyPair,
   encodeCrock,
   getRandomBytes,
-  hash, hashDenomPub,
+  hash,
+  hashDenomPub,
   HttpStatusCode,
-  j2s, kdf, Logger,
+  j2s,
+  kdf,
+  Logger,
   notEmpty,
   PreparePayResultType,
   RecoveryLoadRequest,
-  RecoveryMergeStrategy, rsaBlind, secretbox, secretbox_open, stringToBytes, 
TalerErrorDetail, TalerProtocolTimestamp, URL,
-  WalletBackupContentV1
+  RecoveryMergeStrategy,
+  rsaBlind,
+  secretbox,
+  secretbox_open,
+  stringToBytes,
+  TalerErrorDetail,
+  TalerProtocolTimestamp,
+  URL,
+  WalletBackupContentV1,
 } from "@gnu-taler/taler-util";
 import { gunzipSync, gzipSync } from "fflate";
 import { TalerCryptoInterface } from "../../crypto/cryptoImplementation.js";
@@ -58,26 +74,28 @@ import {
   BackupProviderStateTag,
   BackupProviderTerms,
   ConfigRecord,
+  OperationAttemptResult,
+  OperationAttemptResultType,
   WalletBackupConfState,
   WalletStoresV1,
-  WALLET_BACKUP_STATE_KEY
+  WALLET_BACKUP_STATE_KEY,
 } from "../../db.js";
 import { InternalWalletState } from "../../internal-wallet-state.js";
 import {
   readSuccessResponseJsonOrThrow,
-  readTalerErrorResponse
+  readTalerErrorResponse,
 } from "../../util/http.js";
 import {
   checkDbInvariant,
-  checkLogicInvariant
+  checkLogicInvariant,
 } from "../../util/invariants.js";
 import { GetReadWriteAccess } from "../../util/query.js";
-import { RetryInfo } from "../../util/retries.js";
+import { RetryInfo, RetryTags, scheduleRetryInTx } from 
"../../util/retries.js";
 import { guardOperationException } from "../common.js";
 import {
   checkPaymentByProposalId,
   confirmPay,
-  preparePayForUri
+  preparePayForUri,
 } from "../pay.js";
 import { exportBackup } from "./export.js";
 import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
@@ -244,17 +262,19 @@ function getNextBackupTimestamp(): TalerProtocolTimestamp 
{
 async function runBackupCycleForProvider(
   ws: InternalWalletState,
   args: BackupForProviderArgs,
-): Promise<void> {
-
+): Promise<OperationAttemptResult> {
   const provider = await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadOnly(async (tx) => {
       return tx.backupProviders.get(args.backupProviderBaseUrl);
     });
 
   if (!provider) {
     logger.warn("provider disappeared");
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   const backupJson = await exportBackup(ws);
@@ -292,8 +312,8 @@ async function runBackupCycleForProvider(
       "if-none-match": newHash,
       ...(provider.lastBackupHash
         ? {
-          "if-match": provider.lastBackupHash,
-        }
+            "if-match": provider.lastBackupHash,
+          }
         : {}),
     },
   });
@@ -302,9 +322,9 @@ async function runBackupCycleForProvider(
 
   if (resp.status === HttpStatusCode.NotModified) {
     await ws.db
-      .mktx((x) => ({ backupProvider: x.backupProviders }))
+      .mktx((x) => [x.backupProviders])
       .runReadWrite(async (tx) => {
-        const prov = await tx.backupProvider.get(provider.baseUrl);
+        const prov = await tx.backupProviders.get(provider.baseUrl);
         if (!prov) {
           return;
         }
@@ -313,9 +333,12 @@ async function runBackupCycleForProvider(
           tag: BackupProviderStateTag.Ready,
           nextBackupTimestamp: getNextBackupTimestamp(),
         };
-        await tx.backupProvider.put(prov);
+        await tx.backupProviders.put(prov);
       });
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   if (resp.status === HttpStatusCode.PaymentRequired) {
@@ -344,7 +367,7 @@ async function runBackupCycleForProvider(
     // FIXME: check if the provider is overcharging us!
 
     await ws.db
-      .mktx((x) => ({ backupProviders: x.backupProviders }))
+      .mktx((x) => [x.backupProviders, x.operationRetries])
       .runReadWrite(async (tx) => {
         const provRec = await tx.backupProviders.get(provider.baseUrl);
         checkDbInvariant(!!provRec);
@@ -354,11 +377,8 @@ async function runBackupCycleForProvider(
         provRec.currentPaymentProposalId = proposalId;
         // FIXME: allocate error code for this!
         await tx.backupProviders.put(provRec);
-        await incrementBackupRetryInTx(
-          tx,
-          args.backupProviderBaseUrl,
-          undefined,
-        );
+        const opId = RetryTags.forBackup(provRec);
+        await scheduleRetryInTx(ws, tx, opId);
       });
 
     if (doPay) {
@@ -371,17 +391,20 @@ async function runBackupCycleForProvider(
     }
 
     if (args.retryAfterPayment) {
-      await runBackupCycleForProvider(ws, {
+      return await runBackupCycleForProvider(ws, {
         ...args,
         retryAfterPayment: false,
       });
     }
-    return;
+    return {
+      type: OperationAttemptResultType.Pending,
+      result: undefined,
+    };
   }
 
   if (resp.status === HttpStatusCode.NoContent) {
     await ws.db
-      .mktx((x) => ({ backupProviders: x.backupProviders }))
+      .mktx((x) => [x.backupProviders])
       .runReadWrite(async (tx) => {
         const prov = await tx.backupProviders.get(provider.baseUrl);
         if (!prov) {
@@ -395,7 +418,10 @@ async function runBackupCycleForProvider(
         };
         await tx.backupProviders.put(prov);
       });
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   if (resp.status === HttpStatusCode.Conflict) {
@@ -406,28 +432,29 @@ async function runBackupCycleForProvider(
     const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
     await importBackup(ws, blob, cryptoData);
     await ws.db
-      .mktx((x) => ({ backupProvider: x.backupProviders }))
+      .mktx((x) => [x.backupProviders, x.operationRetries])
       .runReadWrite(async (tx) => {
-        const prov = await tx.backupProvider.get(provider.baseUrl);
+        const prov = await tx.backupProviders.get(provider.baseUrl);
         if (!prov) {
           logger.warn("backup provider not found anymore");
           return;
         }
         prov.lastBackupHash = encodeCrock(hash(backupEnc));
-        // FIXME:  Allocate error code for this situation?
+        // FIXME: Allocate error code for this situation?
+        // FIXME: Add operation retry record!
+        const opId = RetryTags.forBackup(prov);
+        await scheduleRetryInTx(ws, tx, opId);
         prov.state = {
           tag: BackupProviderStateTag.Retrying,
-          retryInfo: RetryInfo.reset(),
         };
-        await tx.backupProvider.put(prov);
+        await tx.backupProviders.put(prov);
       });
     logger.info("processed existing backup");
     // Now upload our own, merged backup.
-    await runBackupCycleForProvider(ws, {
+    return await runBackupCycleForProvider(ws, {
       ...args,
       retryAfterPayment: false,
     });
-    return;
   }
 
   // Some other response that we did not expect!
@@ -436,55 +463,18 @@ async function runBackupCycleForProvider(
 
   const err = await readTalerErrorResponse(resp);
   logger.error(`got error response from backup provider: ${j2s(err)}`);
-  await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
-    .runReadWrite(async (tx) => {
-      incrementBackupRetryInTx(tx, args.backupProviderBaseUrl, err);
-    });
-}
-
-async function incrementBackupRetryInTx(
-  tx: GetReadWriteAccess<{
-    backupProviders: typeof WalletStoresV1.backupProviders;
-  }>,
-  backupProviderBaseUrl: string,
-  err: TalerErrorDetail | undefined,
-): Promise<void> {
-  const pr = await tx.backupProviders.get(backupProviderBaseUrl);
-  if (!pr) {
-    return;
-  }
-  if (pr.state.tag === BackupProviderStateTag.Retrying) {
-    pr.state.lastError = err;
-    pr.state.retryInfo = RetryInfo.increment(pr.state.retryInfo);
-  } else if (pr.state.tag === BackupProviderStateTag.Ready) {
-    pr.state = {
-      tag: BackupProviderStateTag.Retrying,
-      retryInfo: RetryInfo.reset(),
-      lastError: err,
-    };
-  }
-  await tx.backupProviders.put(pr);
-}
-
-async function incrementBackupRetry(
-  ws: InternalWalletState,
-  backupProviderBaseUrl: string,
-  err: TalerErrorDetail | undefined,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
-    .runReadWrite(async (tx) =>
-      incrementBackupRetryInTx(tx, backupProviderBaseUrl, err),
-    );
+  return {
+    type: OperationAttemptResultType.Error,
+    errorDetail: err,
+  };
 }
 
 export async function processBackupForProvider(
   ws: InternalWalletState,
   backupProviderBaseUrl: string,
-): Promise<void> {
+): Promise<OperationAttemptResult> {
   const provider = await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadOnly(async (tx) => {
       return await tx.backupProviders.get(backupProviderBaseUrl);
     });
@@ -492,17 +482,10 @@ export async function processBackupForProvider(
     throw Error("unknown backup provider");
   }
 
-  const onOpErr = (err: TalerErrorDetail): Promise<void> =>
-    incrementBackupRetry(ws, backupProviderBaseUrl, err);
-
-  const run = async () => {
-    await runBackupCycleForProvider(ws, {
-      backupProviderBaseUrl: provider.baseUrl,
-      retryAfterPayment: true,
-    });
-  };
-
-  await guardOperationException(run, onOpErr);
+  return await runBackupCycleForProvider(ws, {
+    backupProviderBaseUrl: provider.baseUrl,
+    retryAfterPayment: true,
+  });
 }
 
 export interface RemoveBackupProviderRequest {
@@ -520,7 +503,7 @@ export async function removeBackupProvider(
   req: RemoveBackupProviderRequest,
 ): Promise<void> {
   await ws.db
-    .mktx(({ backupProviders }) => ({ backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadWrite(async (tx) => {
       await tx.backupProviders.delete(req.provider);
     });
@@ -550,7 +533,7 @@ export async function runBackupCycle(
   req: RunBackupCycleRequest,
 ): Promise<void> {
   const providers = await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadOnly(async (tx) => {
       if (req.providers) {
         const rs = await Promise.all(
@@ -616,7 +599,7 @@ export async function addBackupProvider(
   await provideBackupState(ws);
   const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
   await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadWrite(async (tx) => {
       const oldProv = await tx.backupProviders.get(canonUrl);
       if (oldProv) {
@@ -639,7 +622,7 @@ export async function addBackupProvider(
     codecForSyncTermsOfServiceResponse(),
   );
   await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadWrite(async (tx) => {
       let state: BackupProviderState;
       if (req.activate) {
@@ -818,24 +801,31 @@ export async function getBackupInfo(
 ): Promise<BackupInfo> {
   const backupConfig = await provideBackupState(ws);
   const providerRecords = await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders, x.operationRetries])
     .runReadOnly(async (tx) => {
-      return await tx.backupProviders.iter().toArray();
+      return await tx.backupProviders.iter().mapAsync(async (bp) => {
+        const opId = RetryTags.forBackup(bp);
+        const retryRecord = await tx.operationRetries.get(opId);
+        return {
+          provider: bp,
+          retryRecord,
+        };
+      });
     });
   const providers: ProviderInfo[] = [];
   for (const x of providerRecords) {
     providers.push({
-      active: x.state.tag !== BackupProviderStateTag.Provisional,
-      syncProviderBaseUrl: x.baseUrl,
-      lastSuccessfulBackupTimestamp: x.lastBackupCycleTimestamp,
-      paymentProposalIds: x.paymentProposalIds,
+      active: x.provider.state.tag !== BackupProviderStateTag.Provisional,
+      syncProviderBaseUrl: x.provider.baseUrl,
+      lastSuccessfulBackupTimestamp: x.provider.lastBackupCycleTimestamp,
+      paymentProposalIds: x.provider.paymentProposalIds,
       lastError:
-        x.state.tag === BackupProviderStateTag.Retrying
-          ? x.state.lastError
+        x.provider.state.tag === BackupProviderStateTag.Retrying
+          ? x.retryRecord?.lastError
           : undefined,
-      paymentStatus: await getProviderPaymentInfo(ws, x),
-      terms: x.terms,
-      name: x.name,
+      paymentStatus: await getProviderPaymentInfo(ws, x.provider),
+      terms: x.provider.terms,
+      name: x.provider.name,
     });
   }
   return {
@@ -854,7 +844,7 @@ export async function getBackupRecovery(
 ): Promise<BackupRecovery> {
   const bs = await provideBackupState(ws);
   const providers = await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadOnly(async (tx) => {
       return await tx.backupProviders.iter().toArray();
     });
@@ -875,7 +865,7 @@ async function backupRecoveryTheirs(
   br: BackupRecovery,
 ) {
   await ws.db
-    .mktx((x) => ({ config: x.config, backupProviders: x.backupProviders }))
+    .mktx((x) => [x.config, x.backupProviders])
     .runReadWrite(async (tx) => {
       let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
         WALLET_BACKUP_STATE_KEY,
@@ -925,7 +915,7 @@ export async function loadBackupRecovery(
 ): Promise<void> {
   const bs = await provideBackupState(ws);
   const providers = await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadOnly(async (tx) => {
       return await tx.backupProviders.iter().toArray();
     });
@@ -955,7 +945,7 @@ export async function exportBackupEncrypted(
   await provideBackupState(ws);
   const blob = await exportBackup(ws);
   const bs = await ws.db
-    .mktx((x) => ({ config: x.config }))
+    .mktx((x) => [x.config])
     .runReadOnly(async (tx) => {
       return await getWalletBackupState(ws, tx);
     });
diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts 
b/packages/taler-wallet-core/src/operations/backup/state.ts
index 293f5613..2efd9be8 100644
--- a/packages/taler-wallet-core/src/operations/backup/state.ts
+++ b/packages/taler-wallet-core/src/operations/backup/state.ts
@@ -29,9 +29,7 @@ export async function provideBackupState(
   ws: InternalWalletState,
 ): Promise<WalletBackupConfState> {
   const bs: ConfigRecord | undefined = await ws.db
-    .mktx((x) => ({
-      config: x.config,
-    }))
+    .mktx((stores) => [stores.config])
     .runReadOnly(async (tx) => {
       return await tx.config.get(WALLET_BACKUP_STATE_KEY);
     });
@@ -47,9 +45,7 @@ export async function provideBackupState(
   // and be based on hostname
   const deviceId = `wallet-core-${encodeCrock(d)}`;
   return await ws.db
-    .mktx((x) => ({
-      config: x.config,
-    }))
+    .mktx((x) => [x.config])
     .runReadWrite(async (tx) => {
       let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
         WALLET_BACKUP_STATE_KEY,
@@ -87,9 +83,7 @@ export async function setWalletDeviceId(
 ): Promise<void> {
   await provideBackupState(ws);
   await ws.db
-    .mktx((x) => ({
-      config: x.config,
-    }))
+    .mktx((x) => [x.config])
     .runReadWrite(async (tx) => {
       let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
         WALLET_BACKUP_STATE_KEY,
diff --git a/packages/taler-wallet-core/src/operations/balance.ts 
b/packages/taler-wallet-core/src/operations/balance.ts
index 4590f505..44357fdf 100644
--- a/packages/taler-wallet-core/src/operations/balance.ts
+++ b/packages/taler-wallet-core/src/operations/balance.ts
@@ -139,12 +139,7 @@ export async function getBalances(
   logger.trace("starting to compute balance");
 
   const wbal = await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      refreshGroups: x.refreshGroups,
-      purchases: x.purchases,
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.coins, x.refreshGroups, x.purchases, x.withdrawalGroups])
     .runReadOnly(async (tx) => {
       return getBalancesInsideTransaction(ws, tx);
     });
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts 
b/packages/taler-wallet-core/src/operations/deposits.ts
index 734bc4c2..5838be76 100644
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ b/packages/taler-wallet-core/src/operations/deposits.ts
@@ -44,7 +44,12 @@ import {
   TrackDepositGroupResponse,
   URL,
 } from "@gnu-taler/taler-util";
-import { DepositGroupRecord, OperationStatus } from "../db.js";
+import {
+  DepositGroupRecord,
+  OperationAttemptErrorResult,
+  OperationAttemptResult,
+  OperationStatus,
+} from "../db.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { selectPayCoins } from "../util/coinSelection.js";
 import { readSuccessResponseJsonOrThrow } from "../util/http.js";
@@ -66,106 +71,29 @@ import { getTotalRefreshCost } from "./refresh.js";
  */
 const logger = new Logger("deposits.ts");
 
-/**
- * Set up the retry timeout for a deposit group.
- */
-async function setupDepositGroupRetry(
-  ws: InternalWalletState,
-  depositGroupId: string,
-  options: {
-    resetRetry: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      depositGroups: x.depositGroups,
-    }))
-    .runReadWrite(async (tx) => {
-      const x = await tx.depositGroups.get(depositGroupId);
-      if (!x) {
-        return;
-      }
-      if (options.resetRetry) {
-        x.retryInfo = RetryInfo.reset();
-      } else {
-        x.retryInfo = RetryInfo.increment(x.retryInfo);
-      }
-      delete x.lastError;
-      await tx.depositGroups.put(x);
-    });
-}
-
-/**
- * Report an error that occurred while processing the deposit group.
- */
-async function reportDepositGroupError(
-  ws: InternalWalletState,
-  depositGroupId: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ depositGroups: x.depositGroups }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.depositGroups.get(depositGroupId);
-      if (!r) {
-        return;
-      }
-      if (!r.retryInfo) {
-        logger.error(
-          `deposit group record (${depositGroupId}) reports error, but no 
retry active`,
-        );
-        return;
-      }
-      r.lastError = err;
-      await tx.depositGroups.put(r);
-    });
-  ws.notify({ type: NotificationType.DepositOperationError, error: err });
-}
-
-export async function processDepositGroup(
-  ws: InternalWalletState,
-  depositGroupId: string,
-  options: {
-    forceNow?: boolean;
-    cancellationToken?: CancellationToken;
-  } = {},
-): Promise<void> {
-  const onOpErr = (err: TalerErrorDetail): Promise<void> =>
-    reportDepositGroupError(ws, depositGroupId, err);
-  return await guardOperationException(
-    async () => await processDepositGroupImpl(ws, depositGroupId, options),
-    onOpErr,
-  );
-}
-
 /**
  * @see {processDepositGroup}
  */
-async function processDepositGroupImpl(
+export async function processDepositGroup(
   ws: InternalWalletState,
   depositGroupId: string,
   options: {
     forceNow?: boolean;
     cancellationToken?: CancellationToken;
   } = {},
-): Promise<void> {
-  const forceNow = options.forceNow ?? false;
-  await setupDepositGroupRetry(ws, depositGroupId, { resetRetry: forceNow });
-
+): Promise<OperationAttemptResult> {
   const depositGroup = await ws.db
-    .mktx((x) => ({
-      depositGroups: x.depositGroups,
-    }))
+    .mktx((x) => [x.depositGroups])
     .runReadOnly(async (tx) => {
       return tx.depositGroups.get(depositGroupId);
     });
   if (!depositGroup) {
     logger.warn(`deposit group ${depositGroupId} not found`);
-    return;
+    return OperationAttemptResult.finishedEmpty();
   }
   if (depositGroup.timestampFinished) {
     logger.trace(`deposit group ${depositGroupId} already finished`);
-    return;
+    return OperationAttemptResult.finishedEmpty();
   }
 
   const contractData = extractContractData(
@@ -211,7 +139,7 @@ async function processDepositGroupImpl(
     });
     await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess());
     await ws.db
-      .mktx((x) => ({ depositGroups: x.depositGroups }))
+      .mktx((x) => [x.depositGroups])
       .runReadWrite(async (tx) => {
         const dg = await tx.depositGroups.get(depositGroupId);
         if (!dg) {
@@ -223,9 +151,7 @@ async function processDepositGroupImpl(
   }
 
   await ws.db
-    .mktx((x) => ({
-      depositGroups: x.depositGroups,
-    }))
+    .mktx((x) => [x.depositGroups])
     .runReadWrite(async (tx) => {
       const dg = await tx.depositGroups.get(depositGroupId);
       if (!dg) {
@@ -240,11 +166,10 @@ async function processDepositGroupImpl(
       if (allDeposited) {
         dg.timestampFinished = TalerProtocolTimestamp.now();
         dg.operationStatus = OperationStatus.Finished;
-        delete dg.lastError;
-        delete dg.retryInfo;
         await tx.depositGroups.put(dg);
       }
     });
+  return OperationAttemptResult.finishedEmpty();
 }
 
 export async function trackDepositGroup(
@@ -256,9 +181,7 @@ export async function trackDepositGroup(
     body: any;
   }[] = [];
   const depositGroup = await ws.db
-    .mktx((x) => ({
-      depositGroups: x.depositGroups,
-    }))
+    .mktx((x) => [x.depositGroups])
     .runReadOnly(async (tx) => {
       return tx.depositGroups.get(req.depositGroupId);
     });
@@ -318,10 +241,7 @@ export async function getFeeForDeposit(
   const exchangeInfos: { url: string; master_pub: string }[] = [];
 
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadOnly(async (tx) => {
       const allExchanges = await tx.exchanges.iter().toArray();
       for (const e of allExchanges) {
@@ -338,9 +258,9 @@ export async function getFeeForDeposit(
 
   const csr: CoinSelectionRequest = {
     allowedAuditors: [],
-    allowedExchanges: Object.values(exchangeInfos).map(v => ({
+    allowedExchanges: Object.values(exchangeInfos).map((v) => ({
       exchangeBaseUrl: v.url,
-      exchangePub: v.master_pub
+      exchangePub: v.master_pub,
     })),
     amount: Amounts.parseOrThrow(req.amount),
     maxDepositFee: Amounts.parseOrThrow(req.amount),
@@ -383,14 +303,10 @@ export async function prepareDepositGroup(
   }
   const amount = Amounts.parseOrThrow(req.amount);
 
-
   const exchangeInfos: { url: string; master_pub: string }[] = [];
 
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadOnly(async (tx) => {
       const allExchanges = await tx.exchanges.iter().toArray();
       for (const e of allExchanges) {
@@ -473,7 +389,7 @@ export async function prepareDepositGroup(
     payCoinSel,
   );
 
-  return { totalDepositCost, effectiveDepositAmount }
+  return { totalDepositCost, effectiveDepositAmount };
 }
 export async function createDepositGroup(
   ws: InternalWalletState,
@@ -489,10 +405,7 @@ export async function createDepositGroup(
   const exchangeInfos: { url: string; master_pub: string }[] = [];
 
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadOnly(async (tx) => {
       const allExchanges = await tx.exchanges.iter().toArray();
       for (const e of allExchanges) {
@@ -600,18 +513,17 @@ export async function createDepositGroup(
       payto_uri: req.depositPaytoUri,
       salt: wireSalt,
     },
-    retryInfo: RetryInfo.reset(),
     operationStatus: OperationStatus.Pending,
-    lastError: undefined,
   };
 
   await ws.db
-    .mktx((x) => ({
-      depositGroups: x.depositGroups,
-      coins: x.coins,
-      refreshGroups: x.refreshGroups,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [
+      x.depositGroups,
+      x.coins,
+      x.recoupGroups,
+      x.denominations,
+      x.refreshGroups,
+    ])
     .runReadWrite(async (tx) => {
       await applyCoinSpend(
         ws,
@@ -639,12 +551,7 @@ export async function getEffectiveDepositAmount(
   const exchangeSet: Set<string> = new Set();
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails])
     .runReadOnly(async (tx) => {
       for (let i = 0; i < pcs.coinPubs.length; i++) {
         const coin = await tx.coins.get(pcs.coinPubs[i]);
@@ -711,12 +618,7 @@ export async function getTotalFeesForDepositAmount(
   const exchangeSet: Set<string> = new Set();
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails])
     .runReadOnly(async (tx) => {
       for (let i = 0; i < pcs.coinPubs.length; i++) {
         const coin = await tx.coins.get(pcs.coinPubs[i]);
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts 
b/packages/taler-wallet-core/src/operations/exchanges.ts
index b75bdfd7..50497844 100644
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -53,6 +53,8 @@ import {
   DenominationVerificationStatus,
   ExchangeDetailsRecord,
   ExchangeRecord,
+  OperationAttemptResult,
+  OperationAttemptResultType,
   WalletStoresV1,
 } from "../db.js";
 import { TalerError } from "../errors.js";
@@ -64,7 +66,7 @@ import {
   readSuccessResponseTextOrThrow,
 } from "../util/http.js";
 import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
-import { RetryInfo } from "../util/retries.js";
+import { RetryInfo, runOperationHandlerForResult } from "../util/retries.js";
 import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
 import { guardOperationException } from "./common.js";
 
@@ -102,51 +104,6 @@ function denominationRecordFromKeys(
   return d;
 }
 
-async function reportExchangeUpdateError(
-  ws: InternalWalletState,
-  baseUrl: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ exchanges: x.exchanges }))
-    .runReadWrite(async (tx) => {
-      const exchange = await tx.exchanges.get(baseUrl);
-      if (!exchange) {
-        return;
-      }
-      if (!exchange.retryInfo) {
-        logger.reportBreak();
-      }
-      exchange.lastError = err;
-      await tx.exchanges.put(exchange);
-    });
-  ws.notify({ type: NotificationType.ExchangeOperationError, error: err });
-}
-
-async function setupExchangeUpdateRetry(
-  ws: InternalWalletState,
-  baseUrl: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ exchanges: x.exchanges }))
-    .runReadWrite(async (tx) => {
-      const exchange = await tx.exchanges.get(baseUrl);
-      if (!exchange) {
-        return;
-      }
-      if (options.reset) {
-        exchange.retryInfo = RetryInfo.reset();
-      } else {
-        exchange.retryInfo = RetryInfo.increment(exchange.retryInfo);
-      }
-      delete exchange.lastError;
-      await tx.exchanges.put(exchange);
-    });
-}
-
 export function getExchangeRequestTimeout(): Duration {
   return Duration.fromSpec({
     seconds: 5,
@@ -204,10 +161,7 @@ export async function getExchangeDetails(
 }
 
 getExchangeDetails.makeContext = (db: DbAccess<typeof WalletStoresV1>) =>
-  db.mktx((x) => ({
-    exchanges: x.exchanges,
-    exchangeDetails: x.exchangeDetails,
-  }));
+  db.mktx((x) => [x.exchanges, x.exchangeDetails]);
 
 export async function updateExchangeTermsOfService(
   ws: InternalWalletState,
@@ -215,10 +169,7 @@ export async function updateExchangeTermsOfService(
   tos: ExchangeTosDownloadResult,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadWrite(async (tx) => {
       const d = await getExchangeDetails(tx, exchangeBaseUrl);
       if (d) {
@@ -236,10 +187,7 @@ export async function acceptExchangeTermsOfService(
   etag: string | undefined,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadWrite(async (tx) => {
       const d = await getExchangeDetails(tx, exchangeBaseUrl);
       if (d) {
@@ -360,25 +308,6 @@ async function downloadExchangeWireInfo(
   return wireInfo;
 }
 
-export async function updateExchangeFromUrl(
-  ws: InternalWalletState,
-  baseUrl: string,
-  options: {
-    forceNow?: boolean;
-    cancellationToken?: CancellationToken;
-  } = {},
-): Promise<{
-  exchange: ExchangeRecord;
-  exchangeDetails: ExchangeDetailsRecord;
-}> {
-  const onOpErr = (e: TalerErrorDetail): Promise<void> =>
-    reportExchangeUpdateError(ws, baseUrl, e);
-  return await guardOperationException(
-    () => updateExchangeFromUrlImpl(ws, baseUrl, options),
-    onOpErr,
-  );
-}
-
 async function provideExchangeRecord(
   ws: InternalWalletState,
   baseUrl: string,
@@ -388,17 +317,13 @@ async function provideExchangeRecord(
   exchangeDetails: ExchangeDetailsRecord | undefined;
 }> {
   return await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadWrite(async (tx) => {
       let exchange = await tx.exchanges.get(baseUrl);
       if (!exchange) {
         const r: ExchangeRecord = {
           permanent: true,
           baseUrl: baseUrl,
-          retryInfo: RetryInfo.reset(),
           detailsPointer: undefined,
           lastUpdate: undefined,
           nextUpdate: AbsoluteTime.toTimestamp(now),
@@ -530,25 +455,42 @@ export async function downloadTosFromAcceptedFormat(
   );
 }
 
+export async function updateExchangeFromUrl(
+  ws: InternalWalletState,
+  baseUrl: string,
+  options: {
+    forceNow?: boolean;
+    cancellationToken?: CancellationToken;
+  } = {},
+): Promise<{
+  exchange: ExchangeRecord;
+  exchangeDetails: ExchangeDetailsRecord;
+}> {
+  return runOperationHandlerForResult(
+    await updateExchangeFromUrlHandler(ws, baseUrl, options),
+  );
+}
+
 /**
  * Update or add exchange DB entry by fetching the /keys and /wire information.
  * Optionally link the reserve entry to the new or existing
  * exchange entry in then DB.
  */
-async function updateExchangeFromUrlImpl(
+export async function updateExchangeFromUrlHandler(
   ws: InternalWalletState,
   baseUrl: string,
   options: {
     forceNow?: boolean;
     cancellationToken?: CancellationToken;
   } = {},
-): Promise<{
-  exchange: ExchangeRecord;
-  exchangeDetails: ExchangeDetailsRecord;
-}> {
+): Promise<
+  OperationAttemptResult<{
+    exchange: ExchangeRecord;
+    exchangeDetails: ExchangeDetailsRecord;
+  }>
+> {
   const forceNow = options.forceNow ?? false;
   logger.info(`updating exchange info for ${baseUrl}, forced: ${forceNow}`);
-  await setupExchangeUpdateRetry(ws, baseUrl, { reset: forceNow });
 
   const now = AbsoluteTime.now();
   baseUrl = canonicalizeBaseUrl(baseUrl);
@@ -565,7 +507,10 @@ async function updateExchangeFromUrlImpl(
     !AbsoluteTime.isExpired(AbsoluteTime.fromTimestamp(exchange.nextUpdate))
   ) {
     logger.info("using existing exchange info");
-    return { exchange, exchangeDetails };
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: { exchange, exchangeDetails },
+    };
   }
 
   logger.info("updating exchange /keys info");
@@ -612,14 +557,14 @@ async function updateExchangeFromUrlImpl(
   logger.trace("updating exchange info in database");
 
   const updated = await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      denominations: x.denominations,
-      coins: x.coins,
-      refreshGroups: x.refreshGroups,
-      recoupGroups: x.recoupGroups,
-    }))
+    .mktx((x) => [
+      x.exchanges,
+      x.exchangeDetails,
+      x.denominations,
+      x.coins,
+      x.refreshGroups,
+      x.recoupGroups,
+    ])
     .runReadWrite(async (tx) => {
       const r = await tx.exchanges.get(baseUrl);
       if (!r) {
@@ -649,8 +594,6 @@ async function updateExchangeFromUrlImpl(
         termsOfServiceAcceptedTimestamp: TalerProtocolTimestamp.now(),
       };
       // FIXME: only update if pointer got updated
-      delete r.lastError;
-      delete r.retryInfo;
       r.lastUpdate = TalerProtocolTimestamp.now();
       r.nextUpdate = keysInfo.expiry;
       // New denominations might be available.
@@ -771,8 +714,11 @@ async function updateExchangeFromUrlImpl(
     type: NotificationType.ExchangeAdded,
   });
   return {
-    exchange: updated.exchange,
-    exchangeDetails: updated.exchangeDetails,
+    type: OperationAttemptResultType.Finished,
+    result: {
+      exchange: updated.exchange,
+      exchangeDetails: updated.exchangeDetails,
+    },
   };
 }
 
@@ -812,12 +758,12 @@ export async function getExchangeTrust(
   let isAudited = false;
 
   return await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      exchangesTrustStore: x.exchangeTrust,
-      auditorTrust: x.auditorTrust,
-    }))
+    .mktx((x) => [
+      x.exchanges,
+      x.exchangeDetails,
+      x.exchangeTrust,
+      x.auditorTrust,
+    ])
     .runReadOnly(async (tx) => {
       const exchangeDetails = await getExchangeDetails(
         tx,
@@ -828,7 +774,7 @@ export async function getExchangeTrust(
         throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
       }
       const exchangeTrustRecord =
-        await tx.exchangesTrustStore.indexes.byExchangeMasterPub.get(
+        await tx.exchangeTrust.indexes.byExchangeMasterPub.get(
           exchangeDetails.masterPublicKey,
         );
       if (
diff --git a/packages/taler-wallet-core/src/operations/pay.ts 
b/packages/taler-wallet-core/src/operations/pay.ts
index 3d4d2b5a..322e9048 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -37,9 +37,6 @@ import {
   ContractTerms,
   ContractTermsUtil,
   Duration,
-  durationMax,
-  durationMin,
-  durationMul,
   encodeCrock,
   ForcedCoinSel,
   getRandomBytes,
@@ -59,10 +56,7 @@ import {
   TransactionType,
   URL,
 } from "@gnu-taler/taler-util";
-import {
-  EXCHANGE_COINS_LOCK,
-  InternalWalletState,
-} from "../internal-wallet-state.js";
+import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
 import {
   AbortStatus,
   AllowedAuditorInfo,
@@ -71,6 +65,8 @@ import {
   CoinRecord,
   CoinStatus,
   DenominationRecord,
+  OperationAttemptResult,
+  OperationAttemptResultType,
   ProposalRecord,
   ProposalStatus,
   PurchaseRecord,
@@ -82,6 +78,11 @@ import {
   makePendingOperationFailedError,
   TalerError,
 } from "../errors.js";
+import {
+  EXCHANGE_COINS_LOCK,
+  InternalWalletState,
+} from "../internal-wallet-state.js";
+import { assertUnreachable } from "../util/assertUnreachable.js";
 import {
   AvailableCoinInfo,
   CoinCandidateSelection,
@@ -98,11 +99,9 @@ import {
   throwUnexpectedRequestError,
 } from "../util/http.js";
 import { GetReadWriteAccess } from "../util/query.js";
-import { RetryInfo } from "../util/retries.js";
+import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js";
 import { getExchangeDetails } from "./exchanges.js";
 import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
-import { guardOperationException } from "./common.js";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
 
 /**
  * Logger.
@@ -121,7 +120,7 @@ export async function getTotalPaymentCost(
   pcs: PayCoinSelection,
 ): Promise<AmountJson> {
   return ws.db
-    .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
+    .mktx((x) => [x.coins, x.denominations])
     .runReadOnly(async (tx) => {
       const costs: AmountJson[] = [];
       for (let i = 0; i < pcs.coinPubs.length; i++) {
@@ -223,12 +222,7 @@ export async function getCandidatePayCoins(
   const wireFeesPerExchange: Record<string, AmountJson> = {};
 
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      denominations: x.denominations,
-      coins: x.coins,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations, x.coins])
     .runReadOnly(async (tx) => {
       const exchanges = await tx.exchanges.iter().toArray();
       for (const exchange of exchanges) {
@@ -448,10 +442,6 @@ async function recordConfirmPay(
     timestampAccept: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
     timestampLastRefundStatus: undefined,
     proposalId: proposal.proposalId,
-    lastPayError: undefined,
-    lastRefundStatusError: undefined,
-    payRetryInfo: RetryInfo.reset(),
-    refundStatusRetryInfo: RetryInfo.reset(),
     refundQueryRequested: false,
     timestampFirstSuccessfulPay: undefined,
     autoRefundDeadline: undefined,
@@ -464,19 +454,17 @@ async function recordConfirmPay(
   };
 
   await ws.db
-    .mktx((x) => ({
-      proposals: x.proposals,
-      purchases: x.purchases,
-      coins: x.coins,
-      refreshGroups: x.refreshGroups,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [
+      x.proposals,
+      x.purchases,
+      x.coins,
+      x.refreshGroups,
+      x.denominations,
+    ])
     .runReadWrite(async (tx) => {
       const p = await tx.proposals.get(proposal.proposalId);
       if (p) {
         p.proposalStatus = ProposalStatus.Accepted;
-        delete p.lastError;
-        delete p.retryInfo;
         await tx.proposals.put(p);
       }
       await tx.purchases.put(t);
@@ -490,146 +478,33 @@ async function recordConfirmPay(
   return t;
 }
 
-async function reportProposalError(
-  ws: InternalWalletState,
-  proposalId: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
-    .runReadWrite(async (tx) => {
-      const pr = await tx.proposals.get(proposalId);
-      if (!pr) {
-        return;
-      }
-      if (!pr.retryInfo) {
-        logger.error(
-          `Asked to report an error for a proposal (${proposalId}) that is not 
active (no retryInfo)`,
-        );
-        logger.reportBreak();
-        return;
-      }
-      pr.lastError = err;
-      await tx.proposals.put(pr);
-    });
-  ws.notify({ type: NotificationType.ProposalOperationError, error: err });
-}
-
-async function setupProposalRetry(
-  ws: InternalWalletState,
-  proposalId: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
-    .runReadWrite(async (tx) => {
-      const pr = await tx.proposals.get(proposalId);
-      if (!pr) {
-        return;
-      }
-      if (options.reset) {
-        pr.retryInfo = RetryInfo.reset();
-      } else {
-        pr.retryInfo = RetryInfo.increment(pr.retryInfo);
-      }
-      delete pr.lastError;
-      await tx.proposals.put(pr);
-    });
-}
-
-async function setupPurchasePayRetry(
-  ws: InternalWalletState,
-  proposalId: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
-    .runReadWrite(async (tx) => {
-      const p = await tx.purchases.get(proposalId);
-      if (!p) {
-        return;
-      }
-      if (options.reset) {
-        p.payRetryInfo = RetryInfo.reset();
-      } else {
-        p.payRetryInfo = RetryInfo.increment(p.payRetryInfo);
-      }
-      delete p.lastPayError;
-      await tx.purchases.put(p);
-    });
-}
-
-async function reportPurchasePayError(
-  ws: InternalWalletState,
-  proposalId: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
-    .runReadWrite(async (tx) => {
-      const pr = await tx.purchases.get(proposalId);
-      if (!pr) {
-        return;
-      }
-      if (!pr.payRetryInfo) {
-        logger.error(
-          `purchase record (${proposalId}) reports error, but no retry active`,
-        );
-      }
-      pr.lastPayError = err;
-      await tx.purchases.put(pr);
-    });
-  ws.notify({ type: NotificationType.PayOperationError, error: err });
-}
-
-export async function processDownloadProposal(
-  ws: InternalWalletState,
-  proposalId: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  const onOpErr = (err: TalerErrorDetail): Promise<void> =>
-    reportProposalError(ws, proposalId, err);
-  await guardOperationException(
-    () => processDownloadProposalImpl(ws, proposalId, options),
-    onOpErr,
-  );
-}
-
 async function failProposalPermanently(
   ws: InternalWalletState,
   proposalId: string,
   err: TalerErrorDetail,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadWrite(async (tx) => {
       const p = await tx.proposals.get(proposalId);
       if (!p) {
         return;
       }
-      delete p.retryInfo;
-      p.lastError = err;
       p.proposalStatus = ProposalStatus.PermanentlyFailed;
       await tx.proposals.put(p);
     });
 }
 
-function getProposalRequestTimeout(proposal: ProposalRecord): Duration {
+function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration {
   return Duration.clamp({
     lower: Duration.fromSpec({ seconds: 1 }),
     upper: Duration.fromSpec({ seconds: 60 }),
-    value: RetryInfo.getDuration(proposal.retryInfo),
+    value: retryInfo ? RetryInfo.getDuration(retryInfo) : 
Duration.fromSpec({}),
   });
 }
 
 function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
-  return durationMul(
+  return Duration.multiply(
     { d_ms: 15000 },
     1 + purchase.payCoinSelection.coinPubs.length / 5,
   );
@@ -682,28 +557,29 @@ export function extractContractData(
   };
 }
 
-async function processDownloadProposalImpl(
+export async function processDownloadProposal(
   ws: InternalWalletState,
   proposalId: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  const forceNow = options.forceNow ?? false;
-  await setupProposalRetry(ws, proposalId, { reset: forceNow });
-
+  options: {} = {},
+): Promise<OperationAttemptResult> {
   const proposal = await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadOnly(async (tx) => {
-      return tx.proposals.get(proposalId);
+      return await tx.proposals.get(proposalId);
     });
 
   if (!proposal) {
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   if (proposal.proposalStatus != ProposalStatus.Downloading) {
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   const orderClaimUrl = new URL(
@@ -722,8 +598,16 @@ async function processDownloadProposalImpl(
     requestBody.token = proposal.claimToken;
   }
 
+  const opId = RetryTags.forProposalClaim(proposal);
+  const retryRecord = await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadOnly(async (tx) => {
+      return tx.operationRetries.get(opId);
+    });
+
+  // FIXME: Do this in the background using the new return value
   const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
-    timeout: getProposalRequestTimeout(proposal),
+    timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
   });
   const r = await readSuccessResponseJsonOrErrorCode(
     httpResponse,
@@ -856,7 +740,7 @@ async function processDownloadProposalImpl(
   logger.trace(`extracted contract data: ${j2s(contractData)}`);
 
   await ws.db
-    .mktx((x) => ({ proposals: x.proposals, purchases: x.purchases }))
+    .mktx((x) => [x.purchases, x.proposals])
     .runReadWrite(async (tx) => {
       const p = await tx.proposals.get(proposalId);
       if (!p) {
@@ -892,6 +776,11 @@ async function processDownloadProposalImpl(
     type: NotificationType.ProposalDownloaded,
     proposalId: proposal.proposalId,
   });
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
 }
 
 /**
@@ -910,7 +799,7 @@ async function startDownloadProposal(
   noncePriv: string | undefined,
 ): Promise<string> {
   const oldProposal = await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadOnly(async (tx) => {
       return tx.proposals.indexes.byUrlAndOrderId.get([
         merchantBaseUrl,
@@ -954,13 +843,11 @@ async function startDownloadProposal(
     proposalId: proposalId,
     proposalStatus: ProposalStatus.Downloading,
     repurchaseProposalId: undefined,
-    retryInfo: RetryInfo.reset(),
-    lastError: undefined,
     downloadSessionId: sessionId,
   };
 
   await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadWrite(async (tx) => {
       const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([
         merchantBaseUrl,
@@ -985,7 +872,7 @@ async function storeFirstPaySuccess(
 ): Promise<void> {
   const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
   await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
       const purchase = await tx.purchases.get(proposalId);
 
@@ -1000,17 +887,13 @@ async function storeFirstPaySuccess(
       }
       purchase.timestampFirstSuccessfulPay = now;
       purchase.paymentSubmitPending = false;
-      purchase.lastPayError = undefined;
       purchase.lastSessionId = sessionId;
-      purchase.payRetryInfo = RetryInfo.reset();
       purchase.merchantPaySig = paySig;
       const protoAr = purchase.download.contractData.autoRefund;
       if (protoAr) {
         const ar = Duration.fromTalerProtocolDuration(protoAr);
         logger.info("auto_refund present");
         purchase.refundQueryRequested = true;
-        purchase.refundStatusRetryInfo = RetryInfo.reset();
-        purchase.lastRefundStatusError = undefined;
         purchase.autoRefundDeadline = AbsoluteTime.toTimestamp(
           AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
         );
@@ -1025,7 +908,7 @@ async function storePayReplaySuccess(
   sessionId: string | undefined,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
       const purchase = await tx.purchases.get(proposalId);
 
@@ -1038,8 +921,6 @@ async function storePayReplaySuccess(
         throw Error("invalid payment state");
       }
       purchase.paymentSubmitPending = false;
-      purchase.lastPayError = undefined;
-      purchase.payRetryInfo = RetryInfo.reset();
       purchase.lastSessionId = sessionId;
       await tx.purchases.put(purchase);
     });
@@ -1061,9 +942,9 @@ async function handleInsufficientFunds(
   logger.trace("handling insufficient funds, trying to re-select coins");
 
   const proposal = await ws.db
-    .mktx((x) => ({ purchaes: x.purchases }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
-      return tx.purchaes.get(proposalId);
+      return tx.purchases.get(proposalId);
     });
   if (!proposal) {
     return;
@@ -1101,7 +982,7 @@ async function handleInsufficientFunds(
   const prevPayCoins: PreviousPayCoins = [];
 
   await ws.db
-    .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
+    .mktx((x) => [x.coins, x.denominations])
     .runReadOnly(async (tx) => {
       for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) {
         const coinPub = proposal.payCoinSelection.coinPubs[i];
@@ -1147,12 +1028,7 @@ async function handleInsufficientFunds(
   logger.trace("re-selected coins");
 
   await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-      coins: x.coins,
-      denominations: x.denominations,
-      refreshGroups: x.refreshGroups,
-    }))
+    .mktx((x) => [x.purchases, x.coins, x.denominations, x.refreshGroups])
     .runReadWrite(async (tx) => {
       const p = await tx.purchases.get(proposalId);
       if (!p) {
@@ -1171,7 +1047,7 @@ async function unblockBackup(
   proposalId: string,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => ({ backupProviders: x.backupProviders }))
+    .mktx((x) => [x.backupProviders])
     .runReadWrite(async (tx) => {
       await tx.backupProviders.indexes.byPaymentProposalId
         .iter(proposalId)
@@ -1192,7 +1068,7 @@ export async function checkPaymentByProposalId(
   sessionId?: string,
 ): Promise<PreparePayResult> {
   let proposal = await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadOnly(async (tx) => {
       return tx.proposals.get(proposalId);
     });
@@ -1206,7 +1082,7 @@ export async function checkPaymentByProposalId(
     }
     logger.trace("using existing purchase for same product");
     proposal = await ws.db
-      .mktx((x) => ({ proposals: x.proposals }))
+      .mktx((x) => [x.proposals])
       .runReadOnly(async (tx) => {
         return tx.proposals.get(existingProposalId);
       });
@@ -1229,7 +1105,7 @@ export async function checkPaymentByProposalId(
 
   // First check if we already paid for it.
   const purchase = await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
       return tx.purchases.get(proposalId);
     });
@@ -1287,7 +1163,7 @@ export async function checkPaymentByProposalId(
       "automatically re-submitting payment with different session ID",
     );
     await ws.db
-      .mktx((x) => ({ purchases: x.purchases }))
+      .mktx((x) => [x.purchases])
       .runReadWrite(async (tx) => {
         const p = await tx.purchases.get(proposalId);
         if (!p) {
@@ -1298,7 +1174,8 @@ export async function checkPaymentByProposalId(
         await tx.purchases.put(p);
       });
     const r = await processPurchasePay(ws, proposalId, { forceNow: true });
-    if (r.type !== ConfirmPayResultType.Done) {
+    if (r.type !== OperationAttemptResultType.Finished) {
+      // FIXME: This does not surface the original error
       throw Error("submitting pay failed");
     }
     return {
@@ -1340,7 +1217,7 @@ export async function getContractTermsDetails(
   proposalId: string,
 ): Promise<WalletContractData> {
   const proposal = await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadOnly(async (tx) => {
       return tx.proposals.get(proposalId);
     });
@@ -1406,7 +1283,7 @@ export async function generateDepositPermissions(
     denom: DenominationRecord;
   }> = [];
   await ws.db
-    .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
+    .mktx((x) => [x.coins, x.denominations])
     .runReadOnly(async (tx) => {
       for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
         const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
@@ -1457,6 +1334,45 @@ export async function generateDepositPermissions(
   return depositPermissions;
 }
 
+/**
+ * Run the operation handler for a payment
+ * and return the result as a {@link ConfirmPayResult}.
+ */
+export async function runPayForConfirmPay(
+  ws: InternalWalletState,
+  proposalId: string,
+): Promise<ConfirmPayResult> {
+  const res = await processPurchasePay(ws, proposalId, { forceNow: true });
+  switch (res.type) {
+    case OperationAttemptResultType.Finished: {
+      const purchase = await ws.db
+        .mktx((x) => [x.purchases])
+        .runReadOnly(async (tx) => {
+          return tx.purchases.get(proposalId);
+        });
+      if (!purchase?.download) {
+        throw Error("purchase record not available anymore");
+      }
+      return {
+        type: ConfirmPayResultType.Done,
+        contractTerms: purchase.download.contractTermsRaw,
+      };
+    }
+    case OperationAttemptResultType.Error:
+      // FIXME: allocate error code!
+      throw Error("payment failed");
+    case OperationAttemptResultType.Pending:
+      return {
+        type: ConfirmPayResultType.Pending,
+        lastError: undefined,
+      };
+    case OperationAttemptResultType.Longpoll:
+      throw Error("unexpected processPurchasePay result (longpoll)");
+    default:
+      assertUnreachable(res);
+  }
+}
+
 /**
  * Add a contract to the wallet and sign coins, and send them.
  */
@@ -1470,7 +1386,7 @@ export async function confirmPay(
     `executing confirmPay with proposalId ${proposalId} and sessionIdOverride 
${sessionIdOverride}`,
   );
   const proposal = await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadOnly(async (tx) => {
       return tx.proposals.get(proposalId);
     });
@@ -1485,7 +1401,7 @@ export async function confirmPay(
   }
 
   const existingPurchase = await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
       const purchase = await tx.purchases.get(proposalId);
       if (
@@ -1503,7 +1419,7 @@ export async function confirmPay(
 
   if (existingPurchase) {
     logger.trace("confirmPay: submitting payment for existing purchase");
-    return await processPurchasePay(ws, proposalId, { forceNow: true });
+    return runPayForConfirmPay(ws, proposalId);
   }
 
   logger.trace("confirmPay: purchase record does not exist yet");
@@ -1559,6 +1475,7 @@ export async function confirmPay(
     res,
     d.contractData,
   );
+
   await recordConfirmPay(
     ws,
     proposal,
@@ -1567,7 +1484,7 @@ export async function confirmPay(
     sessionIdOverride,
   );
 
-  return await processPurchasePay(ws, proposalId, { forceNow: true });
+  return runPayForConfirmPay(ws, proposalId);
 }
 
 export async function processPurchasePay(
@@ -1576,33 +1493,16 @@ export async function processPurchasePay(
   options: {
     forceNow?: boolean;
   } = {},
-): Promise<ConfirmPayResult> {
-  const onOpErr = (e: TalerErrorDetail): Promise<void> =>
-    reportPurchasePayError(ws, proposalId, e);
-  return await guardOperationException(
-    () => processPurchasePayImpl(ws, proposalId, options),
-    onOpErr,
-  );
-}
-
-async function processPurchasePayImpl(
-  ws: InternalWalletState,
-  proposalId: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<ConfirmPayResult> {
-  const forceNow = options.forceNow ?? false;
-  await setupPurchasePayRetry(ws, proposalId, { reset: forceNow });
+): Promise<OperationAttemptResult> {
   const purchase = await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
       return tx.purchases.get(proposalId);
     });
   if (!purchase) {
     return {
-      type: ConfirmPayResultType.Pending,
-      lastError: {
+      type: OperationAttemptResultType.Error,
+      errorDetail: {
         // FIXME: allocate more specific error code
         code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
         hint: `trying to pay for purchase that is not in the database`,
@@ -1611,10 +1511,7 @@ async function processPurchasePayImpl(
     };
   }
   if (!purchase.paymentSubmitPending) {
-    return {
-      type: ConfirmPayResultType.Pending,
-      lastError: purchase.lastPayError,
-    };
+    OperationAttemptResult.finishedEmpty();
   }
   logger.trace(`processing purchase pay ${proposalId}`);
 
@@ -1658,39 +1555,18 @@ async function processPurchasePayImpl(
     );
 
     logger.trace(`got resp ${JSON.stringify(resp)}`);
-
-    // Hide transient errors.
-    if (
-      (purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
-      resp.status >= 500 &&
-      resp.status <= 599
-    ) {
-      logger.trace("treating /pay error as transient");
-      const err = makeErrorDetail(
-        TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
-        getHttpResponseErrorDetails(resp),
-        "/pay failed",
-      );
-      return {
-        type: ConfirmPayResultType.Pending,
-        lastError: err,
-      };
-    }
-
     if (resp.status === HttpStatusCode.BadRequest) {
       const errDetails = await readUnexpectedResponseDetails(resp);
       logger.warn("unexpected 400 response for /pay");
       logger.warn(j2s(errDetails));
       await ws.db
-        .mktx((x) => ({ purchases: x.purchases }))
+        .mktx((x) => [x.purchases])
         .runReadWrite(async (tx) => {
           const purch = await tx.purchases.get(proposalId);
           if (!purch) {
             return;
           }
           purch.payFrozen = true;
-          purch.lastPayError = errDetails;
-          delete purch.payRetryInfo;
           await tx.purchases.put(purch);
         });
       throw makePendingOperationFailedError(
@@ -1708,7 +1584,9 @@ async function processPurchasePayImpl(
       ) {
         // Do this in the background, as it might take some time
         handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
-          reportPurchasePayError(ws, proposalId, {
+          console.log("handling insufficient funds failed");
+
+          await scheduleRetry(ws, RetryTags.forPay(purchase), {
             code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
             message: "unexpected exception",
             hint: "unexpected exception",
@@ -1719,9 +1597,8 @@ async function processPurchasePayImpl(
         });
 
         return {
-          type: ConfirmPayResultType.Pending,
-          // FIXME: should we return something better here?
-          lastError: err,
+          type: OperationAttemptResultType.Pending,
+          result: undefined,
         };
       }
     }
@@ -1761,22 +1638,6 @@ async function processPurchasePayImpl(
     const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
       ws.http.postJson(payAgainUrl, reqBody),
     );
-    // Hide transient errors.
-    if (
-      (purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
-      resp.status >= 500 &&
-      resp.status <= 599
-    ) {
-      const err = makeErrorDetail(
-        TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
-        getHttpResponseErrorDetails(resp),
-        "/paid failed",
-      );
-      return {
-        type: ConfirmPayResultType.Pending,
-        lastError: err,
-      };
-    }
     if (resp.status !== 204) {
       throw TalerError.fromDetail(
         TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
@@ -1793,10 +1654,7 @@ async function processPurchasePayImpl(
     proposalId: purchase.proposalId,
   });
 
-  return {
-    type: ConfirmPayResultType.Done,
-    contractTerms: purchase.download.contractTermsRaw,
-  };
+  return OperationAttemptResult.finishedEmpty();
 }
 
 export async function refuseProposal(
@@ -1804,7 +1662,7 @@ export async function refuseProposal(
   proposalId: string,
 ): Promise<void> {
   const success = await ws.db
-    .mktx((x) => ({ proposals: x.proposals }))
+    .mktx((x) => [x.proposals])
     .runReadWrite(async (tx) => {
       const proposal = await tx.proposals.get(proposalId);
       if (!proposal) {
diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts 
b/packages/taler-wallet-core/src/operations/peer-to-peer.ts
index 9ce460ad..59dad3d5 100644
--- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts
+++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts
@@ -242,13 +242,14 @@ export async function initiatePeerToPeerPush(
   });
 
   const coinSelRes: PeerCoinSelection | undefined = await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      coins: x.coins,
-      denominations: x.denominations,
-      refreshGroups: x.refreshGroups,
-      peerPushPaymentInitiations: x.peerPushPaymentInitiations,
-    }))
+    .mktx((x) => [
+      x.exchanges,
+      x.coins,
+      x.denominations,
+      x.refreshGroups,
+      x.peerPullPaymentInitiations,
+      x.peerPushPaymentInitiations,
+    ])
     .runReadWrite(async (tx) => {
       const sel = await selectPeerCoins(ws, tx, instructedAmount);
       if (!sel) {
@@ -401,9 +402,7 @@ export async function checkPeerPushPayment(
   const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32));
 
   await ws.db
-    .mktx((x) => ({
-      peerPushPaymentIncoming: x.peerPushPaymentIncoming,
-    }))
+    .mktx((x) => [x.peerPushPaymentIncoming])
     .runReadWrite(async (tx) => {
       await tx.peerPushPaymentIncoming.add({
         peerPushPaymentIncomingId,
@@ -456,10 +455,7 @@ async function getMergeReserveInfo(
   const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
 
   const mergeReserveInfo: MergeReserveInfo = await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.exchanges, x.withdrawalGroups])
     .runReadWrite(async (tx) => {
       const ex = await tx.exchanges.get(req.exchangeBaseUrl);
       checkDbInvariant(!!ex);
@@ -482,7 +478,7 @@ export async function acceptPeerPushPayment(
   req: AcceptPeerPushPaymentRequest,
 ): Promise<void> {
   const peerInc = await ws.db
-    .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming }))
+    .mktx((x) => [x.peerPushPaymentIncoming])
     .runReadOnly(async (tx) => {
       return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId);
     });
@@ -564,7 +560,7 @@ export async function acceptPeerPullPayment(
   req: AcceptPeerPullPaymentRequest,
 ): Promise<void> {
   const peerPullInc = await ws.db
-    .mktx((x) => ({ peerPullPaymentIncoming: x.peerPullPaymentIncoming }))
+    .mktx((x) => [x.peerPullPaymentIncoming])
     .runReadOnly(async (tx) => {
       return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId);
     });
@@ -579,13 +575,13 @@ export async function acceptPeerPullPayment(
     peerPullInc.contractTerms.amount,
   );
   const coinSelRes: PeerCoinSelection | undefined = await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      coins: x.coins,
-      denominations: x.denominations,
-      refreshGroups: x.refreshGroups,
-      peerPullPaymentIncoming: x.peerPullPaymentIncoming,
-    }))
+    .mktx((x) => [
+      x.exchanges,
+      x.coins,
+      x.denominations,
+      x.refreshGroups,
+      x.peerPullPaymentIncoming,
+    ])
     .runReadWrite(async (tx) => {
       const sel = await selectPeerCoins(ws, tx, instructedAmount);
       if (!sel) {
@@ -689,9 +685,7 @@ export async function checkPeerPullPayment(
   const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32));
 
   await ws.db
-    .mktx((x) => ({
-      peerPullPaymentIncoming: x.peerPullPaymentIncoming,
-    }))
+    .mktx((x) => [x.peerPullPaymentIncoming])
     .runReadWrite(async (tx) => {
       await tx.peerPullPaymentIncoming.add({
         peerPullPaymentIncomingId,
@@ -775,11 +769,9 @@ export async function initiatePeerRequestForPay(
   });
 
   await ws.db
-    .mktx((x) => ({
-      peerPullPaymentInitiation: x.peerPullPaymentInitiations,
-    }))
+    .mktx((x) => [x.peerPullPaymentInitiations])
     .runReadWrite(async (tx) => {
-      await tx.peerPullPaymentInitiation.put({
+      await tx.peerPullPaymentInitiations.put({
         amount: req.amount,
         contractTerms,
         exchangeBaseUrl: req.exchangeBaseUrl,
diff --git a/packages/taler-wallet-core/src/operations/pending.ts 
b/packages/taler-wallet-core/src/operations/pending.ts
index 5cf3afd4..9ba532ab 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -36,40 +36,50 @@ import {
 import { AbsoluteTime } from "@gnu-taler/taler-util";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { GetReadOnlyAccess } from "../util/query.js";
+import { RetryTags } from "../util/retries.js";
+import { Wallet } from "../wallet.js";
 
 async function gatherExchangePending(
   tx: GetReadOnlyAccess<{
     exchanges: typeof WalletStoresV1.exchanges;
     exchangeDetails: typeof WalletStoresV1.exchangeDetails;
+    operationRetries: typeof WalletStoresV1.operationRetries;
   }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
-  await tx.exchanges.iter().forEachAsync(async (e) => {
+  await tx.exchanges.iter().forEachAsync(async (exch) => {
+    const opTag = RetryTags.forExchangeUpdate(exch);
+    let opr = await tx.operationRetries.get(opTag);
     resp.pendingOperations.push({
       type: PendingTaskType.ExchangeUpdate,
+      id: opTag,
       givesLifeness: false,
       timestampDue:
-        e.retryInfo?.nextRetry ?? AbsoluteTime.fromTimestamp(e.nextUpdate),
-      exchangeBaseUrl: e.baseUrl,
-      lastError: e.lastError,
+        opr?.retryInfo.nextRetry ?? 
AbsoluteTime.fromTimestamp(exch.nextUpdate),
+      exchangeBaseUrl: exch.baseUrl,
+      lastError: opr?.lastError,
     });
 
     // We only schedule a check for auto-refresh if the exchange update
     // was successful.
-    if (!e.lastError) {
+    if (!opr?.lastError) {
       resp.pendingOperations.push({
         type: PendingTaskType.ExchangeCheckRefresh,
-        timestampDue: AbsoluteTime.fromTimestamp(e.nextRefreshCheck),
+        id: RetryTags.forExchangeCheckRefresh(exch),
+        timestampDue: AbsoluteTime.fromTimestamp(exch.nextRefreshCheck),
         givesLifeness: false,
-        exchangeBaseUrl: e.baseUrl,
+        exchangeBaseUrl: exch.baseUrl,
       });
     }
   });
 }
 
 async function gatherRefreshPending(
-  tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups 
}>,
+  tx: GetReadOnlyAccess<{
+    refreshGroups: typeof WalletStoresV1.refreshGroups;
+    operationRetries: typeof WalletStoresV1.operationRetries;
+  }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
@@ -83,15 +93,19 @@ async function gatherRefreshPending(
     if (r.frozen) {
       return;
     }
+    const opId = RetryTags.forRefresh(r);
+    const retryRecord = await tx.operationRetries.get(opId);
+
     resp.pendingOperations.push({
       type: PendingTaskType.Refresh,
+      id: opId,
       givesLifeness: true,
-      timestampDue: r.retryInfo.nextRetry,
+      timestampDue: retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(),
       refreshGroupId: r.refreshGroupId,
       finishedPerCoin: r.statusPerCoin.map(
         (x) => x === RefreshCoinStatus.Finished,
       ),
-      retryInfo: r.retryInfo,
+      retryInfo: retryRecord?.retryInfo,
     });
   }
 }
@@ -100,6 +114,7 @@ async function gatherWithdrawalPending(
   tx: GetReadOnlyAccess<{
     withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
     planchets: typeof WalletStoresV1.planchets;
+    operationRetries: typeof WalletStoresV1.operationRetries;
   }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
@@ -111,54 +126,68 @@ async function gatherWithdrawalPending(
     if (wsr.timestampFinish) {
       return;
     }
-    let numCoinsWithdrawn = 0;
-    let numCoinsTotal = 0;
-    await tx.planchets.indexes.byGroup
-      .iter(wsr.withdrawalGroupId)
-      .forEach((x) => {
-        numCoinsTotal++;
-        if (x.withdrawalDone) {
-          numCoinsWithdrawn++;
-        }
-      });
+    const opTag = RetryTags.forWithdrawal(wsr);
+    let opr = await tx.operationRetries.get(opTag);
+    const now = AbsoluteTime.now();
+    if (!opr) {
+      opr = {
+        id: opTag,
+        retryInfo: {
+          firstTry: now,
+          nextRetry: now,
+          retryCounter: 0,
+        },
+      };
+    }
     resp.pendingOperations.push({
       type: PendingTaskType.Withdraw,
+      id: opTag,
       givesLifeness: true,
-      timestampDue: wsr.retryInfo?.nextRetry ?? AbsoluteTime.now(),
+      timestampDue: opr.retryInfo?.nextRetry ?? AbsoluteTime.now(),
       withdrawalGroupId: wsr.withdrawalGroupId,
-      lastError: wsr.lastError,
-      retryInfo: wsr.retryInfo,
+      lastError: opr.lastError,
+      retryInfo: opr.retryInfo,
     });
   }
 }
 
 async function gatherProposalPending(
-  tx: GetReadOnlyAccess<{ proposals: typeof WalletStoresV1.proposals }>,
+  tx: GetReadOnlyAccess<{
+    proposals: typeof WalletStoresV1.proposals;
+    operationRetries: typeof WalletStoresV1.operationRetries;
+  }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
-  await tx.proposals.iter().forEach((proposal) => {
+  await tx.proposals.iter().forEachAsync(async (proposal) => {
     if (proposal.proposalStatus == ProposalStatus.Proposed) {
       // Nothing to do, user needs to choose.
     } else if (proposal.proposalStatus == ProposalStatus.Downloading) {
-      const timestampDue = proposal.retryInfo?.nextRetry ?? AbsoluteTime.now();
+      const opId = RetryTags.forProposalClaim(proposal);
+      const retryRecord = await tx.operationRetries.get(opId);
+      const timestampDue =
+        retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now();
       resp.pendingOperations.push({
         type: PendingTaskType.ProposalDownload,
+        id: opId,
         givesLifeness: true,
         timestampDue,
         merchantBaseUrl: proposal.merchantBaseUrl,
         orderId: proposal.orderId,
         proposalId: proposal.proposalId,
         proposalTimestamp: proposal.timestamp,
-        lastError: proposal.lastError,
-        retryInfo: proposal.retryInfo,
+        lastError: retryRecord?.lastError,
+        retryInfo: retryRecord?.retryInfo,
       });
     }
   });
 }
 
 async function gatherDepositPending(
-  tx: GetReadOnlyAccess<{ depositGroups: typeof WalletStoresV1.depositGroups 
}>,
+  tx: GetReadOnlyAccess<{
+    depositGroups: typeof WalletStoresV1.depositGroups;
+    operationRetries: typeof WalletStoresV1.operationRetries;
+  }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
@@ -169,32 +198,42 @@ async function gatherDepositPending(
     if (dg.timestampFinished) {
       return;
     }
-    const timestampDue = dg.retryInfo?.nextRetry ?? AbsoluteTime.now();
+    const opId = RetryTags.forDeposit(dg);
+    const retryRecord = await tx.operationRetries.get(opId);
+    const timestampDue = retryRecord?.retryInfo.nextRetry ?? 
AbsoluteTime.now();
     resp.pendingOperations.push({
       type: PendingTaskType.Deposit,
+      id: opId,
       givesLifeness: true,
       timestampDue,
       depositGroupId: dg.depositGroupId,
-      lastError: dg.lastError,
-      retryInfo: dg.retryInfo,
+      lastError: retryRecord?.lastError,
+      retryInfo: retryRecord?.retryInfo,
     });
   }
 }
 
 async function gatherTipPending(
-  tx: GetReadOnlyAccess<{ tips: typeof WalletStoresV1.tips }>,
+  tx: GetReadOnlyAccess<{
+    tips: typeof WalletStoresV1.tips;
+    operationRetries: typeof WalletStoresV1.operationRetries;
+  }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
-  await tx.tips.iter().forEach((tip) => {
+  await tx.tips.iter().forEachAsync(async (tip) => {
+    // FIXME: The tip record needs a proper status field!
     if (tip.pickedUpTimestamp) {
       return;
     }
+    const opId = RetryTags.forTipPickup(tip);
+    const retryRecord = await tx.operationRetries.get(opId);
     if (tip.acceptedTimestamp) {
       resp.pendingOperations.push({
         type: PendingTaskType.TipPickup,
+        id: opId,
         givesLifeness: true,
-        timestampDue: tip.retryInfo.nextRetry,
+        timestampDue: retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(),
         merchantBaseUrl: tip.merchantBaseUrl,
         tipId: tip.walletTipId,
         merchantTipId: tip.merchantTipId,
@@ -204,56 +243,77 @@ async function gatherTipPending(
 }
 
 async function gatherPurchasePending(
-  tx: GetReadOnlyAccess<{ purchases: typeof WalletStoresV1.purchases }>,
+  tx: GetReadOnlyAccess<{
+    purchases: typeof WalletStoresV1.purchases;
+    operationRetries: typeof WalletStoresV1.operationRetries;
+  }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
-  await tx.purchases.iter().forEach((pr) => {
+  // FIXME: Only iter purchases with some "active" flag!
+  await tx.purchases.iter().forEachAsync(async (pr) => {
     if (
       pr.paymentSubmitPending &&
       pr.abortStatus === AbortStatus.None &&
       !pr.payFrozen
     ) {
-      const timestampDue = pr.payRetryInfo?.nextRetry ?? AbsoluteTime.now();
+      const payOpId = RetryTags.forPay(pr);
+      const payRetryRecord = await tx.operationRetries.get(payOpId);
+
+      const timestampDue =
+        payRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
       resp.pendingOperations.push({
         type: PendingTaskType.Pay,
+        id: payOpId,
         givesLifeness: true,
         timestampDue,
         isReplay: false,
         proposalId: pr.proposalId,
-        retryInfo: pr.payRetryInfo,
-        lastError: pr.lastPayError,
+        retryInfo: payRetryRecord?.retryInfo,
+        lastError: payRetryRecord?.lastError,
       });
     }
     if (pr.refundQueryRequested) {
+      const refundQueryOpId = RetryTags.forRefundQuery(pr);
+      const refundQueryRetryRecord = await tx.operationRetries.get(
+        refundQueryOpId,
+      );
       resp.pendingOperations.push({
         type: PendingTaskType.RefundQuery,
+        id: refundQueryOpId,
         givesLifeness: true,
-        timestampDue: pr.refundStatusRetryInfo.nextRetry,
+        timestampDue:
+          refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(),
         proposalId: pr.proposalId,
-        retryInfo: pr.refundStatusRetryInfo,
-        lastError: pr.lastRefundStatusError,
+        retryInfo: refundQueryRetryRecord?.retryInfo,
+        lastError: refundQueryRetryRecord?.lastError,
       });
     }
   });
 }
 
 async function gatherRecoupPending(
-  tx: GetReadOnlyAccess<{ recoupGroups: typeof WalletStoresV1.recoupGroups }>,
+  tx: GetReadOnlyAccess<{
+    recoupGroups: typeof WalletStoresV1.recoupGroups;
+    operationRetries: typeof WalletStoresV1.operationRetries;
+  }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
-  await tx.recoupGroups.iter().forEach((rg) => {
+  await tx.recoupGroups.iter().forEachAsync(async (rg) => {
     if (rg.timestampFinished) {
       return;
     }
+    const opId = RetryTags.forRecoup(rg);
+    const retryRecord = await tx.operationRetries.get(opId);
     resp.pendingOperations.push({
       type: PendingTaskType.Recoup,
+      id: opId,
       givesLifeness: true,
-      timestampDue: rg.retryInfo.nextRetry,
+      timestampDue: retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(),
       recoupGroupId: rg.recoupGroupId,
-      retryInfo: rg.retryInfo,
-      lastError: rg.lastError,
+      retryInfo: retryRecord?.retryInfo,
+      lastError: retryRecord?.lastError,
     });
   });
 }
@@ -261,14 +321,18 @@ async function gatherRecoupPending(
 async function gatherBackupPending(
   tx: GetReadOnlyAccess<{
     backupProviders: typeof WalletStoresV1.backupProviders;
+    operationRetries: typeof WalletStoresV1.operationRetries;
   }>,
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
-  await tx.backupProviders.iter().forEach((bp) => {
+  await tx.backupProviders.iter().forEachAsync(async (bp) => {
+    const opId = RetryTags.forBackup(bp);
+    const retryRecord = await tx.operationRetries.get(opId);
     if (bp.state.tag === BackupProviderStateTag.Ready) {
       resp.pendingOperations.push({
         type: PendingTaskType.Backup,
+        id: opId,
         givesLifeness: false,
         timestampDue: AbsoluteTime.fromTimestamp(bp.state.nextBackupTimestamp),
         backupProviderBaseUrl: bp.baseUrl,
@@ -277,11 +341,12 @@ async function gatherBackupPending(
     } else if (bp.state.tag === BackupProviderStateTag.Retrying) {
       resp.pendingOperations.push({
         type: PendingTaskType.Backup,
+        id: opId,
         givesLifeness: false,
-        timestampDue: bp.state.retryInfo.nextRetry,
+        timestampDue: retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now(),
         backupProviderBaseUrl: bp.baseUrl,
-        retryInfo: bp.state.retryInfo,
-        lastError: bp.state.lastError,
+        retryInfo: retryRecord?.retryInfo,
+        lastError: retryRecord?.lastError,
       });
     }
   });
@@ -292,20 +357,21 @@ export async function getPendingOperations(
 ): Promise<PendingOperationsResponse> {
   const now = AbsoluteTime.now();
   return await ws.db
-    .mktx((x) => ({
-      backupProviders: x.backupProviders,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      refreshGroups: x.refreshGroups,
-      coins: x.coins,
-      withdrawalGroups: x.withdrawalGroups,
-      proposals: x.proposals,
-      tips: x.tips,
-      purchases: x.purchases,
-      planchets: x.planchets,
-      depositGroups: x.depositGroups,
-      recoupGroups: x.recoupGroups,
-    }))
+    .mktx((x) => [
+      x.backupProviders,
+      x.exchanges,
+      x.exchangeDetails,
+      x.refreshGroups,
+      x.coins,
+      x.withdrawalGroups,
+      x.proposals,
+      x.tips,
+      x.purchases,
+      x.planchets,
+      x.depositGroups,
+      x.recoupGroups,
+      x.operationRetries,
+    ])
     .runReadWrite(async (tx) => {
       const resp: PendingOperationsResponse = {
         pendingOperations: [],
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts 
b/packages/taler-wallet-core/src/operations/recoup.ts
index 28370794..100bbc07 100644
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ b/packages/taler-wallet-core/src/operations/recoup.ts
@@ -42,6 +42,8 @@ import {
   CoinRecord,
   CoinSourceType,
   CoinStatus,
+  OperationAttemptResult,
+  OperationAttemptResultType,
   RecoupGroupRecord,
   RefreshCoinSource,
   ReserveRecordStatus,
@@ -52,64 +54,13 @@ import {
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { readSuccessResponseJsonOrThrow } from "../util/http.js";
 import { GetReadWriteAccess } from "../util/query.js";
-import { RetryInfo } from "../util/retries.js";
+import { RetryInfo, runOperationHandlerForResult } from "../util/retries.js";
 import { guardOperationException } from "./common.js";
 import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
 import { internalCreateWithdrawalGroup } from "./withdraw.js";
 
 const logger = new Logger("operations/recoup.ts");
 
-async function setupRecoupRetry(
-  ws: InternalWalletState,
-  recoupGroupId: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      recoupGroups: x.recoupGroups,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.recoupGroups.get(recoupGroupId);
-      if (!r) {
-        return;
-      }
-      if (options.reset) {
-        r.retryInfo = RetryInfo.reset();
-      } else {
-        r.retryInfo = RetryInfo.increment(r.retryInfo);
-      }
-      delete r.lastError;
-      await tx.recoupGroups.put(r);
-    });
-}
-
-async function reportRecoupError(
-  ws: InternalWalletState,
-  recoupGroupId: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      recoupGroups: x.recoupGroups,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.recoupGroups.get(recoupGroupId);
-      if (!r) {
-        return;
-      }
-      if (!r.retryInfo) {
-        logger.error(
-          "reporting error for inactive recoup group (no retry info)",
-        );
-      }
-      r.lastError = err;
-      await tx.recoupGroups.put(r);
-    });
-  ws.notify({ type: NotificationType.RecoupOperationError, error: err });
-}
-
 /**
  * Store a recoup group record in the database after marking
  * a coin in the group as finished.
@@ -145,12 +96,12 @@ async function recoupTipCoin(
   // Thus we just put the coin to sleep.
   // FIXME: somehow report this to the user
   await ws.db
-    .mktx((x) => ({
-      recoupGroups: x.recoupGroups,
-      denominations: WalletStoresV1.denominations,
-      refreshGroups: WalletStoresV1.refreshGroups,
-      coins: WalletStoresV1.coins,
-    }))
+    .mktx((stores) => [
+      stores.recoupGroups,
+      stores.denominations,
+      stores.refreshGroups,
+      stores.coins,
+    ])
     .runReadWrite(async (tx) => {
       const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
       if (!recoupGroup) {
@@ -172,9 +123,7 @@ async function recoupWithdrawCoin(
 ): Promise<void> {
   const reservePub = cs.reservePub;
   const denomInfo = await ws.db
-    .mktx((x) => ({
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.denominations])
     .runReadOnly(async (tx) => {
       const denomInfo = await ws.getDenomInfo(
         ws,
@@ -218,12 +167,7 @@ async function recoupWithdrawCoin(
   // FIXME: verify that our expectations about the amount match
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-      recoupGroups: x.recoupGroups,
-      refreshGroups: x.refreshGroups,
-    }))
+    .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
     .runReadWrite(async (tx) => {
       const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
       if (!recoupGroup) {
@@ -256,10 +200,7 @@ async function recoupRefreshCoin(
   cs: RefreshCoinSource,
 ): Promise<void> {
   const d = await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.coins, x.denominations])
     .runReadOnly(async (tx) => {
       const denomInfo = await ws.getDenomInfo(
         ws,
@@ -306,12 +247,7 @@ async function recoupRefreshCoin(
   }
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-      recoupGroups: x.recoupGroups,
-      refreshGroups: x.refreshGroups,
-    }))
+    .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
     .runReadWrite(async (tx) => {
       const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
       if (!recoupGroup) {
@@ -353,38 +289,31 @@ export async function processRecoupGroup(
     forceNow?: boolean;
   } = {},
 ): Promise<void> {
-  await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
-    const onOpErr = (e: TalerErrorDetail): Promise<void> =>
-      reportRecoupError(ws, recoupGroupId, e);
-    return await guardOperationException(
-      async () => await processRecoupGroupImpl(ws, recoupGroupId, options),
-      onOpErr,
-    );
-  });
+  await runOperationHandlerForResult(
+    await processRecoupGroupHandler(ws, recoupGroupId, options),
+  );
+  return;
 }
 
-async function processRecoupGroupImpl(
+export async function processRecoupGroupHandler(
   ws: InternalWalletState,
   recoupGroupId: string,
   options: {
     forceNow?: boolean;
   } = {},
-): Promise<void> {
+): Promise<OperationAttemptResult> {
   const forceNow = options.forceNow ?? false;
-  await setupRecoupRetry(ws, recoupGroupId, { reset: forceNow });
   let recoupGroup = await ws.db
-    .mktx((x) => ({
-      recoupGroups: x.recoupGroups,
-    }))
+    .mktx((x) => [x.recoupGroups])
     .runReadOnly(async (tx) => {
       return tx.recoupGroups.get(recoupGroupId);
     });
   if (!recoupGroup) {
-    return;
+    return OperationAttemptResult.finishedEmpty();
   }
   if (recoupGroup.timestampFinished) {
     logger.trace("recoup group finished");
-    return;
+    return OperationAttemptResult.finishedEmpty();
   }
   const ps = recoupGroup.coinPubs.map(async (x, i) => {
     try {
@@ -397,19 +326,17 @@ async function processRecoupGroupImpl(
   await Promise.all(ps);
 
   recoupGroup = await ws.db
-    .mktx((x) => ({
-      recoupGroups: x.recoupGroups,
-    }))
+    .mktx((x) => [x.recoupGroups])
     .runReadOnly(async (tx) => {
       return tx.recoupGroups.get(recoupGroupId);
     });
   if (!recoupGroup) {
-    return;
+    return OperationAttemptResult.finishedEmpty();
   }
 
   for (const b of recoupGroup.recoupFinishedPerCoin) {
     if (!b) {
-      return;
+      return OperationAttemptResult.finishedEmpty();
     }
   }
 
@@ -420,10 +347,7 @@ async function processRecoupGroupImpl(
   for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
     const coinPub = recoupGroup.coinPubs[i];
     await ws.db
-      .mktx((x) => ({
-        coins: x.coins,
-        reserves: x.reserves,
-      }))
+      .mktx((x) => [x.coins, x.reserves])
       .runReadOnly(async (tx) => {
         const coin = await tx.coins.get(coinPub);
         if (!coin) {
@@ -468,20 +392,13 @@ async function processRecoupGroupImpl(
   }
 
   await ws.db
-    .mktx((x) => ({
-      recoupGroups: x.recoupGroups,
-      denominations: WalletStoresV1.denominations,
-      refreshGroups: WalletStoresV1.refreshGroups,
-      coins: WalletStoresV1.coins,
-    }))
+    .mktx((x) => [x.recoupGroups, x.denominations, x.refreshGroups, x.coins])
     .runReadWrite(async (tx) => {
       const rg2 = await tx.recoupGroups.get(recoupGroupId);
       if (!rg2) {
         return;
       }
       rg2.timestampFinished = TalerProtocolTimestamp.now();
-      rg2.retryInfo = RetryInfo.reset();
-      rg2.lastError = undefined;
       if (rg2.scheduleRefreshCoins.length > 0) {
         const refreshGroupId = await createRefreshGroup(
           ws,
@@ -495,6 +412,7 @@ async function processRecoupGroupImpl(
       }
       await tx.recoupGroups.put(rg2);
     });
+  return OperationAttemptResult.finishedEmpty();
 }
 
 export async function createRecoupGroup(
@@ -514,10 +432,8 @@ export async function createRecoupGroup(
     recoupGroupId,
     exchangeBaseUrl: exchangeBaseUrl,
     coinPubs: coinPubs,
-    lastError: undefined,
     timestampFinished: undefined,
     timestampStarted: TalerProtocolTimestamp.now(),
-    retryInfo: RetryInfo.reset(),
     recoupFinishedPerCoin: coinPubs.map(() => false),
     // Will be populated later
     oldAmountPerCoin: [],
@@ -554,10 +470,7 @@ async function processRecoup(
   coinIdx: number,
 ): Promise<void> {
   const coin = await ws.db
-    .mktx((x) => ({
-      recoupGroups: x.recoupGroups,
-      coins: x.coins,
-    }))
+    .mktx((x) => [x.recoupGroups, x.coins])
     .runReadOnly(async (tx) => {
       const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
       if (!recoupGroup) {
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts 
b/packages/taler-wallet-core/src/operations/refresh.ts
index 64a734bb..719093bd 100644
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -57,6 +57,8 @@ import {
   CoinSourceType,
   CoinStatus,
   DenominationRecord,
+  OperationAttemptResult,
+  OperationAttemptResultType,
   OperationStatus,
   RefreshCoinStatus,
   RefreshGroupRecord,
@@ -74,7 +76,7 @@ import {
 } from "../util/http.js";
 import { checkDbInvariant } from "../util/invariants.js";
 import { GetReadWriteAccess } from "../util/query.js";
-import { RetryInfo } from "../util/retries.js";
+import { RetryInfo, runOperationHandlerForResult } from "../util/retries.js";
 import { guardOperationException } from "./common.js";
 import { updateExchangeFromUrl } from "./exchanges.js";
 import {
@@ -133,11 +135,9 @@ function updateGroupStatus(rg: RefreshGroupRecord): void {
   if (allDone) {
     if (anyFrozen) {
       rg.frozen = true;
-      rg.retryInfo = RetryInfo.reset();
     } else {
       rg.timestampFinished = AbsoluteTime.toTimestamp(AbsoluteTime.now());
       rg.operationStatus = OperationStatus.Finished;
-      rg.retryInfo = RetryInfo.reset();
     }
   }
 }
@@ -155,10 +155,7 @@ async function refreshCreateSession(
   );
 
   const d = await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-      coins: x.coins,
-    }))
+    .mktx((x) => [x.refreshGroups, x.coins])
     .runReadWrite(async (tx) => {
       const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
       if (!refreshGroup) {
@@ -197,9 +194,7 @@ async function refreshCreateSession(
   // to update and filter withdrawable denoms.
 
   const { availableAmount, availableDenoms } = await ws.db
-    .mktx((x) => ({
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.denominations])
     .runReadOnly(async (tx) => {
       const oldDenom = await ws.getDenomInfo(
         ws,
@@ -237,10 +232,7 @@ async function refreshCreateSession(
       )} too small`,
     );
     await ws.db
-      .mktx((x) => ({
-        coins: x.coins,
-        refreshGroups: x.refreshGroups,
-      }))
+      .mktx((x) => [x.coins, x.refreshGroups])
       .runReadWrite(async (tx) => {
         const rg = await tx.refreshGroups.get(refreshGroupId);
         if (!rg) {
@@ -259,10 +251,7 @@ async function refreshCreateSession(
 
   // Store refresh session for this coin in the database.
   await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-      coins: x.coins,
-    }))
+    .mktx((x) => [x.refreshGroups, x.coins])
     .runReadWrite(async (tx) => {
       const rg = await tx.refreshGroups.get(refreshGroupId);
       if (!rg) {
@@ -300,11 +289,7 @@ async function refreshMelt(
   coinIndex: number,
 ): Promise<void> {
   const d = await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-      coins: x.coins,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.refreshGroups, x.coins, x.denominations])
     .runReadWrite(async (tx) => {
       const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
       if (!refreshGroup) {
@@ -414,9 +399,7 @@ async function refreshMelt(
   if (resp.status === HttpStatusCode.NotFound) {
     const errDetails = await readUnexpectedResponseDetails(resp);
     await ws.db
-      .mktx((x) => ({
-        refreshGroups: x.refreshGroups,
-      }))
+      .mktx((x) => [x.refreshGroups])
       .runReadWrite(async (tx) => {
         const rg = await tx.refreshGroups.get(refreshGroupId);
         if (!rg) {
@@ -446,9 +429,7 @@ async function refreshMelt(
   refreshSession.norevealIndex = norevealIndex;
 
   await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-    }))
+    .mktx((x) => [x.refreshGroups])
     .runReadWrite(async (tx) => {
       const rg = await tx.refreshGroups.get(refreshGroupId);
       if (!rg) {
@@ -538,11 +519,7 @@ async function refreshReveal(
 ): Promise<void> {
   logger.info("doing refresh reveal");
   const d = await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-      coins: x.coins,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.refreshGroups, x.coins, x.denominations])
     .runReadOnly(async (tx) => {
       const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
       if (!refreshGroup) {
@@ -703,10 +680,7 @@ async function refreshReveal(
   }
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      refreshGroups: x.refreshGroups,
-    }))
+    .mktx((x) => [x.coins, x.refreshGroups])
     .runReadWrite(async (tx) => {
       const rg = await tx.refreshGroups.get(refreshGroupId);
       if (!rg) {
@@ -730,102 +704,29 @@ async function refreshReveal(
   });
 }
 
-async function setupRefreshRetry(
-  ws: InternalWalletState,
-  refreshGroupId: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.refreshGroups.get(refreshGroupId);
-      if (!r) {
-        return;
-      }
-      if (options.reset) {
-        r.retryInfo = RetryInfo.reset();
-      } else {
-        r.retryInfo = RetryInfo.increment(r.retryInfo);
-      }
-      delete r.lastError;
-      await tx.refreshGroups.put(r);
-    });
-}
-
-async function reportRefreshError(
-  ws: InternalWalletState,
-  refreshGroupId: string,
-  err: TalerErrorDetail | undefined,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.refreshGroups.get(refreshGroupId);
-      if (!r) {
-        return;
-      }
-      if (!r.retryInfo) {
-        logger.error(
-          "reported error for inactive refresh group (no retry info)",
-        );
-      }
-      r.lastError = err;
-      await tx.refreshGroups.put(r);
-    });
-  if (err) {
-    ws.notify({ type: NotificationType.RefreshOperationError, error: err });
-  }
-}
-
-/**
- * Actually process a refresh group that has been created.
- */
 export async function processRefreshGroup(
   ws: InternalWalletState,
   refreshGroupId: string,
   options: {
     forceNow?: boolean;
   } = {},
-): Promise<void> {
-  await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
-    const onOpErr = (e: TalerErrorDetail): Promise<void> =>
-      reportRefreshError(ws, refreshGroupId, e);
-    return await guardOperationException(
-      async () => await processRefreshGroupImpl(ws, refreshGroupId, options),
-      onOpErr,
-    );
-  });
-}
-
-async function processRefreshGroupImpl(
-  ws: InternalWalletState,
-  refreshGroupId: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  const forceNow = options.forceNow ?? false;
+): Promise<OperationAttemptResult> {
   logger.info(`processing refresh group ${refreshGroupId}`);
-  await setupRefreshRetry(ws, refreshGroupId, { reset: forceNow });
 
   const refreshGroup = await ws.db
-    .mktx((x) => ({
-      refreshGroups: x.refreshGroups,
-    }))
-    .runReadOnly(async (tx) => {
-      return tx.refreshGroups.get(refreshGroupId);
-    });
+    .mktx((x) => [x.refreshGroups])
+    .runReadOnly(async (tx) => tx.refreshGroups.get(refreshGroupId));
   if (!refreshGroup) {
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
   if (refreshGroup.timestampFinished) {
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
   // Process refresh sessions of the group in parallel.
   logger.trace("processing refresh sessions for old coins");
@@ -855,6 +756,10 @@ async function processRefreshGroupImpl(
     logger.warn("process refresh sessions got exception");
     logger.warn(`exception: ${e}`);
   }
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
 }
 
 async function processRefreshSession(
@@ -866,7 +771,7 @@ async function processRefreshSession(
     `processing refresh session for coin ${coinIndex} of group 
${refreshGroupId}`,
   );
   let refreshGroup = await ws.db
-    .mktx((x) => ({ refreshGroups: x.refreshGroups }))
+    .mktx((x) => [x.refreshGroups])
     .runReadOnly(async (tx) => {
       return tx.refreshGroups.get(refreshGroupId);
     });
@@ -879,7 +784,7 @@ async function processRefreshSession(
   if (!refreshGroup.refreshSessionPerCoin[coinIndex]) {
     await refreshCreateSession(ws, refreshGroupId, coinIndex);
     refreshGroup = await ws.db
-      .mktx((x) => ({ refreshGroups: x.refreshGroups }))
+      .mktx((x) => [x.refreshGroups])
       .runReadOnly(async (tx) => {
         return tx.refreshGroups.get(refreshGroupId);
       });
@@ -975,13 +880,11 @@ export async function createRefreshGroup(
     operationStatus: OperationStatus.Pending,
     timestampFinished: undefined,
     statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
-    lastError: undefined,
     lastErrorPerCoin: {},
     oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
     reason,
     refreshGroupId,
     refreshSessionPerCoin: oldCoinPubs.map(() => undefined),
-    retryInfo: RetryInfo.reset(),
     inputPerCoin,
     estimatedOutputPerCoin,
     timestampCreated: TalerProtocolTimestamp.now(),
@@ -1034,7 +937,7 @@ function getAutoRefreshExecuteThreshold(d: 
DenominationRecord): AbsoluteTime {
 export async function autoRefresh(
   ws: InternalWalletState,
   exchangeBaseUrl: string,
-): Promise<void> {
+): Promise<OperationAttemptResult> {
   logger.info(`doing auto-refresh check for '${exchangeBaseUrl}'`);
 
   // We must make sure that the exchange is up-to-date so that
@@ -1048,12 +951,7 @@ export async function autoRefresh(
     durationFromSpec({ days: 1 }),
   );
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-      refreshGroups: x.refreshGroups,
-      exchanges: x.exchanges,
-    }))
+    .mktx((x) => [x.coins, x.denominations, x.refreshGroups, x.exchanges])
     .runReadWrite(async (tx) => {
       const exchange = await tx.exchanges.get(exchangeBaseUrl);
       if (!exchange) {
@@ -1109,4 +1007,5 @@ export async function autoRefresh(
       exchange.nextRefreshCheck = AbsoluteTime.toTimestamp(minCheckThreshold);
       await tx.exchanges.put(exchange);
     });
+  return OperationAttemptResult.finishedEmpty();
 }
diff --git a/packages/taler-wallet-core/src/operations/refund.ts 
b/packages/taler-wallet-core/src/operations/refund.ts
index 8f5c1143..5ee0680d 100644
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ b/packages/taler-wallet-core/src/operations/refund.ts
@@ -51,6 +51,7 @@ import {
 import {
   AbortStatus,
   CoinStatus,
+  OperationAttemptResult,
   PurchaseRecord,
   RefundReason,
   RefundState,
@@ -60,8 +61,6 @@ import { InternalWalletState } from 
"../internal-wallet-state.js";
 import { readSuccessResponseJsonOrThrow } from "../util/http.js";
 import { checkDbInvariant } from "../util/invariants.js";
 import { GetReadWriteAccess } from "../util/query.js";
-import { RetryInfo } from "../util/retries.js";
-import { guardOperationException } from "./common.js";
 import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
 
 const logger = new Logger("refund.ts");
@@ -79,9 +78,7 @@ export async function prepareRefund(
   }
 
   const purchase = await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
       return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
         parseResult.merchantBaseUrl,
@@ -120,68 +117,6 @@ export async function prepareRefund(
     },
   };
 }
-/**
- * Retry querying and applying refunds for an order later.
- */
-async function setupPurchaseQueryRefundRetry(
-  ws: InternalWalletState,
-  proposalId: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
-    .runReadWrite(async (tx) => {
-      const pr = await tx.purchases.get(proposalId);
-      if (!pr) {
-        return;
-      }
-      if (options.reset) {
-        pr.refundStatusRetryInfo = RetryInfo.reset();
-      } else {
-        pr.refundStatusRetryInfo = RetryInfo.increment(
-          pr.refundStatusRetryInfo,
-        );
-      }
-      await tx.purchases.put(pr);
-    });
-}
-
-/**
- * Report an error that happending when querying for a purchase's refund.
- */
-async function reportPurchaseQueryRefundError(
-  ws: InternalWalletState,
-  proposalId: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
-    .runReadWrite(async (tx) => {
-      const pr = await tx.purchases.get(proposalId);
-      if (!pr) {
-        return;
-      }
-      if (!pr.refundStatusRetryInfo) {
-        logger.error(
-          "reported error on an inactive purchase (no refund status retry 
info)",
-        );
-      }
-      pr.lastRefundStatusError = err;
-      await tx.purchases.put(pr);
-    });
-  if (err) {
-    ws.notify({
-      type: NotificationType.RefundStatusOperationError,
-      error: err,
-    });
-  }
-}
 
 function getRefundKey(d: MerchantCoinRefundStatus): string {
   return `${d.coin_pub}-${d.rtransaction_id}`;
@@ -398,12 +333,7 @@ async function acceptRefunds(
   const now = TalerProtocolTimestamp.now();
 
   await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-      coins: x.coins,
-      denominations: x.denominations,
-      refreshGroups: x.refreshGroups,
-    }))
+    .mktx((x) => [x.purchases, x.coins, x.denominations, x.refreshGroups])
     .runReadWrite(async (tx) => {
       const p = await tx.purchases.get(proposalId);
       if (!p) {
@@ -492,8 +422,6 @@ async function acceptRefunds(
 
       if (queryDone) {
         p.timestampLastRefundStatus = now;
-        p.lastRefundStatusError = undefined;
-        p.refundStatusRetryInfo = RetryInfo.reset();
         p.refundQueryRequested = false;
         if (p.abortStatus === AbortStatus.AbortRefund) {
           p.abortStatus = AbortStatus.AbortFinished;
@@ -502,8 +430,6 @@ async function acceptRefunds(
       } else {
         // No error, but we need to try again!
         p.timestampLastRefundStatus = now;
-        p.refundStatusRetryInfo = RetryInfo.increment(p.refundStatusRetryInfo);
-        p.lastRefundStatusError = undefined;
         logger.trace("refund query not done");
       }
 
@@ -584,9 +510,7 @@ export async function applyRefund(
   }
 
   const purchase = await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
       return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
         parseResult.merchantBaseUrl,
@@ -611,9 +535,7 @@ export async function applyRefundFromPurchaseId(
 
   logger.info("processing purchase for refund");
   const success = await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
       const p = await tx.purchases.get(proposalId);
       if (!p) {
@@ -621,8 +543,6 @@ export async function applyRefundFromPurchaseId(
         return false;
       }
       p.refundQueryRequested = true;
-      p.lastRefundStatusError = undefined;
-      p.refundStatusRetryInfo = RetryInfo.reset();
       await tx.purchases.put(p);
       return true;
     });
@@ -631,16 +551,14 @@ export async function applyRefundFromPurchaseId(
     ws.notify({
       type: NotificationType.RefundStarted,
     });
-    await processPurchaseQueryRefundImpl(ws, proposalId, {
+    await processPurchaseQueryRefund(ws, proposalId, {
       forceNow: true,
       waitForAutoRefund: false,
     });
   }
 
   const purchase = await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
       return tx.purchases.get(proposalId);
     });
@@ -672,22 +590,6 @@ export async function applyRefundFromPurchaseId(
   };
 }
 
-export async function processPurchaseQueryRefund(
-  ws: InternalWalletState,
-  proposalId: string,
-  options: {
-    forceNow?: boolean;
-    waitForAutoRefund?: boolean;
-  } = {},
-): Promise<void> {
-  const onOpErr = (e: TalerErrorDetail): Promise<void> =>
-    reportPurchaseQueryRefundError(ws, proposalId, e);
-  await guardOperationException(
-    () => processPurchaseQueryRefundImpl(ws, proposalId, options),
-    onOpErr,
-  );
-}
-
 async function queryAndSaveAwaitingRefund(
   ws: InternalWalletState,
   purchase: PurchaseRecord,
@@ -727,7 +629,7 @@ async function queryAndSaveAwaitingRefund(
     Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 0
   ) {
     await ws.db
-      .mktx((x) => ({ purchases: x.purchases }))
+      .mktx((x) => [x.purchases])
       .runReadWrite(async (tx) => {
         const p = await tx.purchases.get(purchase.proposalId);
         if (!p) {
@@ -742,30 +644,26 @@ async function queryAndSaveAwaitingRefund(
   return refundAwaiting;
 }
 
-async function processPurchaseQueryRefundImpl(
+export async function processPurchaseQueryRefund(
   ws: InternalWalletState,
   proposalId: string,
   options: {
     forceNow?: boolean;
     waitForAutoRefund?: boolean;
   } = {},
-): Promise<void> {
-  const forceNow = options.forceNow ?? false;
+): Promise<OperationAttemptResult> {
   const waitForAutoRefund = options.waitForAutoRefund ?? false;
-  await setupPurchaseQueryRefundRetry(ws, proposalId, { reset: forceNow });
   const purchase = await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
       return tx.purchases.get(proposalId);
     });
   if (!purchase) {
-    return;
+    return OperationAttemptResult.finishedEmpty();
   }
 
   if (!purchase.refundQueryRequested) {
-    return;
+    return OperationAttemptResult.finishedEmpty();
   }
 
   if (purchase.timestampFirstSuccessfulPay) {
@@ -780,7 +678,9 @@ async function processPurchaseQueryRefundImpl(
         purchase,
         waitForAutoRefund,
       );
-      if (Amounts.isZero(awaitingAmount)) return;
+      if (Amounts.isZero(awaitingAmount)) {
+        return OperationAttemptResult.finishedEmpty();
+      }
     }
 
     const requestUrl = new URL(
@@ -814,9 +714,7 @@ async function processPurchaseQueryRefundImpl(
     const abortingCoins: AbortingCoin[] = [];
 
     await ws.db
-      .mktx((x) => ({
-        coins: x.coins,
-      }))
+      .mktx((x) => [x.coins])
       .runReadOnly(async (tx) => {
         for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
           const coinPub = purchase.payCoinSelection.coinPubs[i];
@@ -873,6 +771,7 @@ async function processPurchaseQueryRefundImpl(
     }
     await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
   }
+  return OperationAttemptResult.finishedEmpty();
 }
 
 export async function abortFailedPayWithRefund(
@@ -880,9 +779,7 @@ export async function abortFailedPayWithRefund(
   proposalId: string,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => ({
-      purchases: x.purchases,
-    }))
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
       const purchase = await tx.purchases.get(proposalId);
       if (!purchase) {
@@ -899,8 +796,6 @@ export async function abortFailedPayWithRefund(
       purchase.refundQueryRequested = true;
       purchase.paymentSubmitPending = false;
       purchase.abortStatus = AbortStatus.AbortRefund;
-      purchase.lastPayError = undefined;
-      purchase.payRetryInfo = RetryInfo.reset();
       await tx.purchases.put(purchase);
     });
   processPurchaseQueryRefund(ws, proposalId, {
diff --git a/packages/taler-wallet-core/src/operations/testing.ts 
b/packages/taler-wallet-core/src/operations/testing.ts
index 5c54d22c..e2a0c7db 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -467,7 +467,7 @@ export async function testPay(
     throw Error("payment not done");
   }
   const purchase = await ws.db
-    .mktx((x) => ({ purchases: x.purchases }))
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
       return tx.purchases.get(result.proposalId);
     });
diff --git a/packages/taler-wallet-core/src/operations/tip.ts 
b/packages/taler-wallet-core/src/operations/tip.ts
index 7148999c..04da2b98 100644
--- a/packages/taler-wallet-core/src/operations/tip.ts
+++ b/packages/taler-wallet-core/src/operations/tip.ts
@@ -18,29 +18,45 @@
  * Imports.
  */
 import {
-  Amounts, BlindedDenominationSignature,
-  codecForMerchantTipResponseV2, codecForTipPickupGetResponse, DenomKeyType, 
encodeCrock, getRandomBytes, j2s, Logger, NotificationType, parseTipUri, 
PrepareTipResult, TalerErrorCode, TalerErrorDetail, TalerProtocolTimestamp, 
TipPlanchetDetail, URL
+  Amounts,
+  BlindedDenominationSignature,
+  codecForMerchantTipResponseV2,
+  codecForTipPickupGetResponse,
+  DenomKeyType,
+  encodeCrock,
+  getRandomBytes,
+  j2s,
+  Logger,
+  parseTipUri,
+  PrepareTipResult,
+  TalerErrorCode,
+  TalerProtocolTimestamp,
+  TipPlanchetDetail,
+  URL,
 } from "@gnu-taler/taler-util";
 import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js";
 import {
   CoinRecord,
   CoinSourceType,
-  CoinStatus, DenominationRecord, TipRecord
+  CoinStatus,
+  DenominationRecord,
+  OperationAttemptResult,
+  OperationAttemptResultType,
+  TipRecord,
 } from "../db.js";
 import { makeErrorDetail } from "../errors.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import {
   getHttpResponseErrorDetails,
-  readSuccessResponseJsonOrThrow
+  readSuccessResponseJsonOrThrow,
 } from "../util/http.js";
 import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
-  RetryInfo
-} from "../util/retries.js";
-import { guardOperationException } from "./common.js";
 import { updateExchangeFromUrl } from "./exchanges.js";
 import {
-  getCandidateWithdrawalDenoms, getExchangeWithdrawalInfo, 
selectWithdrawalDenominations, updateWithdrawalDenoms
+  getCandidateWithdrawalDenoms,
+  getExchangeWithdrawalInfo,
+  selectWithdrawalDenominations,
+  updateWithdrawalDenoms,
 } from "./withdraw.js";
 
 const logger = new Logger("operations/tip.ts");
@@ -55,9 +71,7 @@ export async function prepareTip(
   }
 
   let tipRecord = await ws.db
-    .mktx((x) => ({
-      tips: x.tips,
-    }))
+    .mktx((x) => [x.tips])
     .runReadOnly(async (tx) => {
       return tx.tips.indexes.byMerchantTipIdAndBaseUrl.get([
         res.merchantTipId,
@@ -84,13 +98,13 @@ export async function prepareTip(
     await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
 
     //FIXME: is this needed? withdrawDetails is not used
-    // * if the intention is to update the exchange information in the 
database 
+    // * if the intention is to update the exchange information in the database
     //   maybe we can use another name. `get` seems like a pure-function
     const withdrawDetails = await getExchangeWithdrawalInfo(
       ws,
       tipPickupStatus.exchange_url,
       amount,
-      undefined
+      undefined,
     );
 
     const walletTipId = encodeCrock(getRandomBytes(32));
@@ -114,17 +128,13 @@ export async function prepareTip(
       createdTimestamp: TalerProtocolTimestamp.now(),
       merchantTipId: res.merchantTipId,
       tipAmountEffective: selectedDenoms.totalCoinValue,
-      retryInfo: RetryInfo.reset(),
-      lastError: undefined,
       denomsSel: selectedDenoms,
       pickedUpTimestamp: undefined,
       secretSeed,
       denomSelUid,
     };
     await ws.db
-      .mktx((x) => ({
-        tips: x.tips,
-      }))
+      .mktx((x) => [x.tips])
       .runReadWrite(async (tx) => {
         await tx.tips.put(newTipRecord);
       });
@@ -144,96 +154,31 @@ export async function prepareTip(
   return tipStatus;
 }
 
-async function reportTipError(
-  ws: InternalWalletState,
-  walletTipId: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      tips: x.tips,
-    }))
-    .runReadWrite(async (tx) => {
-      const t = await tx.tips.get(walletTipId);
-      if (!t) {
-        return;
-      }
-      if (!t.retryInfo) {
-        logger.reportBreak();
-      }
-      t.lastError = err;
-      await tx.tips.put(t);
-    });
-  if (err) {
-    ws.notify({ type: NotificationType.TipOperationError, error: err });
-  }
-}
-
-async function setupTipRetry(
-  ws: InternalWalletState,
-  walletTipId: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      tips: x.tips,
-    }))
-    .runReadWrite(async (tx) => {
-      const t = await tx.tips.get(walletTipId);
-      if (!t) {
-        return;
-      }
-      if (options.reset) {
-        t.retryInfo = RetryInfo.reset();
-      } else {
-        t.retryInfo = RetryInfo.increment(t.retryInfo);
-      }
-      delete t.lastError;
-      await tx.tips.put(t);
-    });
-}
-
 export async function processTip(
-  ws: InternalWalletState,
-  tipId: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  const onOpErr = (e: TalerErrorDetail): Promise<void> =>
-    reportTipError(ws, tipId, e);
-  await guardOperationException(
-    () => processTipImpl(ws, tipId, options),
-    onOpErr,
-  );
-}
-
-async function processTipImpl(
   ws: InternalWalletState,
   walletTipId: string,
   options: {
     forceNow?: boolean;
   } = {},
-): Promise<void> {
-  const forceNow = options.forceNow ?? false;
-  await setupTipRetry(ws, walletTipId, { reset: forceNow });
-
+): Promise<OperationAttemptResult> {
   const tipRecord = await ws.db
-    .mktx((x) => ({
-      tips: x.tips,
-    }))
+    .mktx((x) => [x.tips])
     .runReadOnly(async (tx) => {
       return tx.tips.get(walletTipId);
     });
   if (!tipRecord) {
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   if (tipRecord.pickedUpTimestamp) {
     logger.warn("tip already picked up");
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   const denomsForWithdraw = tipRecord.denomsSel;
@@ -245,9 +190,7 @@ async function processTipImpl(
 
   for (const dh of denomsForWithdraw.selectedDenoms) {
     const denom = await ws.db
-      .mktx((x) => ({
-        denominations: x.denominations,
-      }))
+      .mktx((x) => [x.denominations])
       .runReadOnly(async (tx) => {
         return tx.denominations.get([
           tipRecord.exchangeBaseUrl,
@@ -284,22 +227,21 @@ async function processTipImpl(
 
   logger.trace(`got tip response, status ${merchantResp.status}`);
 
-  // Hide transient errors.
+  // FIXME: Why do we do this?
   if (
-    tipRecord.retryInfo.retryCounter < 5 &&
-    ((merchantResp.status >= 500 && merchantResp.status <= 599) ||
-      merchantResp.status === 424)
+    (merchantResp.status >= 500 && merchantResp.status <= 599) ||
+    merchantResp.status === 424
   ) {
     logger.trace(`got transient tip error`);
     // FIXME: wrap in another error code that indicates a transient error
-    const err = makeErrorDetail(
-      TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
-      getHttpResponseErrorDetails(merchantResp),
-      "tip pickup failed (transient)",
-    );
-    await reportTipError(ws, tipRecord.walletTipId, err);
-    // FIXME: Maybe we want to signal to the caller that the transient error 
happened?
-    return;
+    return {
+      type: OperationAttemptResultType.Error,
+      errorDetail: makeErrorDetail(
+        TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+        getHttpResponseErrorDetails(merchantResp),
+        "tip pickup failed (transient)",
+      ),
+    };
   }
   let blindedSigs: BlindedDenominationSignature[] = [];
 
@@ -344,21 +286,14 @@ async function processTipImpl(
     });
 
     if (!isValid) {
-      await ws.db
-        .mktx((x) => ({ tips: x.tips }))
-        .runReadWrite(async (tx) => {
-          const tipRecord = await tx.tips.get(walletTipId);
-          if (!tipRecord) {
-            return;
-          }
-          tipRecord.lastError = makeErrorDetail(
-            TalerErrorCode.WALLET_TIPPING_COIN_SIGNATURE_INVALID,
-            {},
-            "invalid signature from the exchange (via merchant tip) after 
unblinding",
-          );
-          await tx.tips.put(tipRecord);
-        });
-      return;
+      return {
+        type: OperationAttemptResultType.Error,
+        errorDetail: makeErrorDetail(
+          TalerErrorCode.WALLET_TIPPING_COIN_SIGNATURE_INVALID,
+          {},
+          "invalid signature from the exchange (via merchant tip) after 
unblinding",
+        ),
+      };
     }
 
     newCoinRecords.push({
@@ -381,11 +316,7 @@ async function processTipImpl(
   }
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      tips: x.tips,
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.coins, x.tips, x.withdrawalGroups])
     .runReadWrite(async (tx) => {
       const tr = await tx.tips.get(walletTipId);
       if (!tr) {
@@ -395,13 +326,16 @@ async function processTipImpl(
         return;
       }
       tr.pickedUpTimestamp = TalerProtocolTimestamp.now();
-      tr.lastError = undefined;
-      tr.retryInfo = RetryInfo.reset();
       await tx.tips.put(tr);
       for (const cr of newCoinRecords) {
         await tx.coins.put(cr);
       }
     });
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
 }
 
 export async function acceptTip(
@@ -409,9 +343,7 @@ export async function acceptTip(
   tipId: string,
 ): Promise<void> {
   const found = await ws.db
-    .mktx((x) => ({
-      tips: x.tips,
-    }))
+    .mktx((x) => [x.tips])
     .runReadWrite(async (tx) => {
       const tipRecord = await tx.tips.get(tipId);
       if (!tipRecord) {
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts 
b/packages/taler-wallet-core/src/operations/transactions.ts
index 956d565a..5be24c57 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -38,7 +38,6 @@ import { InternalWalletState } from 
"../internal-wallet-state.js";
 import {
   AbortStatus,
   RefundState,
-  ReserveRecordStatus,
   WalletRefundItem,
   WithdrawalRecordType,
 } from "../db.js";
@@ -48,6 +47,7 @@ import { processPurchasePay } from "./pay.js";
 import { processRefreshGroup } from "./refresh.js";
 import { processTip } from "./tip.js";
 import { processWithdrawalGroup } from "./withdraw.js";
+import { RetryTags } from "../util/retries.js";
 
 const logger = new Logger("taler-wallet-core:transactions.ts");
 
@@ -126,23 +126,24 @@ export async function getTransactions(
   const transactions: Transaction[] = [];
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      proposals: x.proposals,
-      purchases: x.purchases,
-      refreshGroups: x.refreshGroups,
-      tips: x.tips,
-      withdrawalGroups: x.withdrawalGroups,
-      planchets: x.planchets,
-      recoupGroups: x.recoupGroups,
-      depositGroups: x.depositGroups,
-      tombstones: x.tombstones,
-      peerPushPaymentInitiations: x.peerPushPaymentInitiations,
-      peerPullPaymentIncoming: x.peerPullPaymentIncoming,
-    }))
+    .mktx((x) => [
+      x.coins,
+      x.denominations,
+      x.depositGroups,
+      x.exchangeDetails,
+      x.exchanges,
+      x.operationRetries,
+      x.peerPullPaymentIncoming,
+      x.peerPushPaymentInitiations,
+      x.planchets,
+      x.proposals,
+      x.purchases,
+      x.recoupGroups,
+      x.recoupGroups,
+      x.tips,
+      x.tombstones,
+      x.withdrawalGroups,
+    ])
     .runReadOnly(async (tx) => {
       tx.peerPushPaymentInitiations.iter().forEachAsync(async (pi) => {
         const amount = Amounts.parseOrThrow(pi.amount);
@@ -220,6 +221,10 @@ export async function getTransactions(
         if (shouldSkipSearch(transactionsRequest, [])) {
           return;
         }
+
+        const opId = RetryTags.forWithdrawal(wsr);
+        const ort = await tx.operationRetries.get(opId);
+
         let withdrawalDetails: WithdrawalDetails;
         if (wsr.wgInfo.withdrawalType === WithdrawalRecordType.PeerPullCredit) 
{
           transactions.push({
@@ -242,7 +247,7 @@ export async function getTransactions(
               wsr.withdrawalGroupId,
             ),
             frozen: false,
-            ...(wsr.lastError ? { error: wsr.lastError } : {}),
+            ...(ort?.lastError ? { error: ort.lastError } : {}),
           });
           return;
         } else if (
@@ -264,7 +269,7 @@ export async function getTransactions(
               wsr.withdrawalGroupId,
             ),
             frozen: false,
-            ...(wsr.lastError ? { error: wsr.lastError } : {}),
+            ...(ort?.lastError ? { error: ort.lastError } : {}),
           });
           return;
         } else if (
@@ -310,7 +315,7 @@ export async function getTransactions(
             wsr.withdrawalGroupId,
           ),
           frozen: false,
-          ...(wsr.lastError ? { error: wsr.lastError } : {}),
+          ...(ort?.lastError ? { error: ort.lastError } : {}),
         });
       });
 
@@ -319,7 +324,8 @@ export async function getTransactions(
         if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
           return;
         }
-
+        const opId = RetryTags.forDeposit(dg);
+        const retryRecord = await tx.operationRetries.get(opId);
         transactions.push({
           type: TransactionType.Deposit,
           amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
@@ -333,7 +339,7 @@ export async function getTransactions(
             dg.depositGroupId,
           ),
           depositGroupId: dg.depositGroupId,
-          ...(dg.lastError ? { error: dg.lastError } : {}),
+          ...(retryRecord?.lastError ? { error: retryRecord.lastError } : {}),
         });
       });
 
@@ -456,7 +462,15 @@ export async function getTransactions(
           });
         }
 
-        const err = pr.lastPayError ?? pr.lastRefundStatusError;
+        const payOpId = RetryTags.forPay(pr);
+        const refundQueryOpId = RetryTags.forRefundQuery(pr);
+        const payRetryRecord = await tx.operationRetries.get(payOpId);
+        const refundQueryRetryRecord = await tx.operationRetries.get(
+          refundQueryOpId,
+        );
+
+        const err =
+          refundQueryRetryRecord?.lastError ?? payRetryRecord?.lastError;
         transactions.push({
           type: TransactionType.Payment,
           amountRaw: Amounts.stringify(contractData.amount),
@@ -495,6 +509,8 @@ export async function getTransactions(
         if (!tipRecord.acceptedTimestamp) {
           return;
         }
+        const opId = RetryTags.forTipPickup(tipRecord);
+        const retryRecord = await tx.operationRetries.get(opId);
         transactions.push({
           type: TransactionType.Tip,
           amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
@@ -507,10 +523,7 @@ export async function getTransactions(
             tipRecord.walletTipId,
           ),
           merchantBaseUrl: tipRecord.merchantBaseUrl,
-          // merchant: {
-          //   name: tipRecord.merchantBaseUrl,
-          // },
-          error: tipRecord.lastError,
+          error: retryRecord?.lastError,
         });
       });
     });
@@ -589,13 +602,14 @@ export async function deleteTransaction(
 ): Promise<void> {
   const [typeStr, ...rest] = transactionId.split(":");
   const type = typeStr as TransactionType;
-  if (type === TransactionType.Withdrawal || type === 
TransactionType.PeerPullCredit || type === TransactionType.PeerPushCredit) {
+  if (
+    type === TransactionType.Withdrawal ||
+    type === TransactionType.PeerPullCredit ||
+    type === TransactionType.PeerPushCredit
+  ) {
     const withdrawalGroupId = rest[0];
     await ws.db
-      .mktx((x) => ({
-        withdrawalGroups: x.withdrawalGroups,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.withdrawalGroups, x.tombstones])
       .runReadWrite(async (tx) => {
         const withdrawalGroupRecord = await tx.withdrawalGroups.get(
           withdrawalGroupId,
@@ -611,11 +625,7 @@ export async function deleteTransaction(
   } else if (type === TransactionType.Payment) {
     const proposalId = rest[0];
     await ws.db
-      .mktx((x) => ({
-        proposals: x.proposals,
-        purchases: x.purchases,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.proposals, x.purchases, x.tombstones])
       .runReadWrite(async (tx) => {
         let found = false;
         const proposal = await tx.proposals.get(proposalId);
@@ -637,10 +647,7 @@ export async function deleteTransaction(
   } else if (type === TransactionType.Refresh) {
     const refreshGroupId = rest[0];
     await ws.db
-      .mktx((x) => ({
-        refreshGroups: x.refreshGroups,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.refreshGroups, x.tombstones])
       .runReadWrite(async (tx) => {
         const rg = await tx.refreshGroups.get(refreshGroupId);
         if (rg) {
@@ -653,10 +660,7 @@ export async function deleteTransaction(
   } else if (type === TransactionType.Tip) {
     const tipId = rest[0];
     await ws.db
-      .mktx((x) => ({
-        tips: x.tips,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.tips, x.tombstones])
       .runReadWrite(async (tx) => {
         const tipRecord = await tx.tips.get(tipId);
         if (tipRecord) {
@@ -669,10 +673,7 @@ export async function deleteTransaction(
   } else if (type === TransactionType.Deposit) {
     const depositGroupId = rest[0];
     await ws.db
-      .mktx((x) => ({
-        depositGroups: x.depositGroups,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.depositGroups, x.tombstones])
       .runReadWrite(async (tx) => {
         const tipRecord = await tx.depositGroups.get(depositGroupId);
         if (tipRecord) {
@@ -687,11 +688,7 @@ export async function deleteTransaction(
     const executionTimeStr = rest[1];
 
     await ws.db
-      .mktx((x) => ({
-        proposals: x.proposals,
-        purchases: x.purchases,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.proposals, x.purchases, x.tombstones])
       .runReadWrite(async (tx) => {
         const purchase = await tx.purchases.get(proposalId);
         if (purchase) {
@@ -709,12 +706,11 @@ export async function deleteTransaction(
   } else if (type === TransactionType.PeerPullDebit) {
     const peerPullPaymentIncomingId = rest[0];
     await ws.db
-      .mktx((x) => ({
-        peerPullPaymentIncoming: x.peerPullPaymentIncoming,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.peerPullPaymentIncoming, x.tombstones])
       .runReadWrite(async (tx) => {
-        const debit = await 
tx.peerPullPaymentIncoming.get(peerPullPaymentIncomingId);
+        const debit = await tx.peerPullPaymentIncoming.get(
+          peerPullPaymentIncomingId,
+        );
         if (debit) {
           await tx.peerPullPaymentIncoming.delete(peerPullPaymentIncomingId);
           await tx.tombstones.put({
@@ -728,19 +724,13 @@ export async function deleteTransaction(
   } else if (type === TransactionType.PeerPushDebit) {
     const pursePub = rest[0];
     await ws.db
-      .mktx((x) => ({
-        peerPushPaymentInitiations: x.peerPushPaymentInitiations,
-        tombstones: x.tombstones,
-      }))
+      .mktx((x) => [x.peerPushPaymentInitiations, x.tombstones])
       .runReadWrite(async (tx) => {
         const debit = await tx.peerPushPaymentInitiations.get(pursePub);
         if (debit) {
           await tx.peerPushPaymentInitiations.delete(pursePub);
           await tx.tombstones.put({
-            id: makeEventId(
-              TombstoneTag.DeletePeerPushDebit,
-              pursePub,
-            ),
+            id: makeEventId(TombstoneTag.DeletePeerPushDebit, pursePub),
           });
         }
       });
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts 
b/packages/taler-wallet-core/src/operations/withdraw.ts
index b8074531..1b838377 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -56,7 +56,6 @@ import {
   WithdrawBatchResponse,
   WithdrawResponse,
   WithdrawUriInfoResponse,
-  WithdrawUriResult,
 } from "@gnu-taler/taler-util";
 import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
 import {
@@ -68,9 +67,10 @@ import {
   DenomSelectionState,
   ExchangeDetailsRecord,
   ExchangeRecord,
+  OperationAttemptResult,
+  OperationAttemptResultType,
   OperationStatus,
   PlanchetRecord,
-  ReserveBankInfo,
   ReserveRecordStatus,
   WalletStoresV1,
   WgInfo,
@@ -98,7 +98,6 @@ import {
   WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
   WALLET_EXCHANGE_PROTOCOL_VERSION,
 } from "../versions.js";
-import { guardOperationException } from "./common.js";
 import {
   getExchangeDetails,
   getExchangePaytoUri,
@@ -196,9 +195,9 @@ export interface ExchangeWithdrawDetails {
   /**
    * If the exchange supports age-restricted coins it will return
    * the array of ages.
-   * 
+   *
    */
-  ageRestrictionOptions?: number[],
+  ageRestrictionOptions?: number[];
 }
 
 /**
@@ -249,7 +248,7 @@ export function selectWithdrawalDenominations(
   for (const d of denoms) {
     let count = 0;
     const cost = Amounts.add(d.value, d.feeWithdraw).amount;
-    for (; ;) {
+    for (;;) {
       if (Amounts.cmp(remaining, cost) < 0) {
         break;
       }
@@ -413,7 +412,7 @@ export async function getCandidateWithdrawalDenoms(
   exchangeBaseUrl: string,
 ): Promise<DenominationRecord[]> {
   return await ws.db
-    .mktx((x) => ({ denominations: x.denominations }))
+    .mktx((x) => [x.denominations])
     .runReadOnly(async (tx) => {
       const allDenoms = await 
tx.denominations.indexes.byExchangeBaseUrl.getAll(
         exchangeBaseUrl,
@@ -435,9 +434,7 @@ async function processPlanchetGenerate(
   coinIdx: number,
 ): Promise<void> {
   let planchet = await ws.db
-    .mktx((x) => ({
-      planchets: x.planchets,
-    }))
+    .mktx((x) => [x.planchets])
     .runReadOnly(async (tx) => {
       return tx.planchets.indexes.byGroupAndIndex.get([
         withdrawalGroup.withdrawalGroupId,
@@ -463,9 +460,7 @@ async function processPlanchetGenerate(
   const denomPubHash = maybeDenomPubHash;
 
   const denom = await ws.db
-    .mktx((x) => ({
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.denominations])
     .runReadOnly(async (tx) => {
       return ws.getDenomInfo(
         ws,
@@ -501,7 +496,7 @@ async function processPlanchetGenerate(
     lastError: undefined,
   };
   await ws.db
-    .mktx((x) => ({ planchets: x.planchets }))
+    .mktx((x) => [x.planchets])
     .runReadWrite(async (tx) => {
       const p = await tx.planchets.indexes.byGroupAndIndex.get([
         withdrawalGroup.withdrawalGroupId,
@@ -530,12 +525,12 @@ async function processPlanchetExchangeRequest(
     `processing planchet exchange request 
${withdrawalGroup.withdrawalGroupId}/${coinIdx}`,
   );
   const d = await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-      planchets: x.planchets,
-      exchanges: x.exchanges,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [
+      x.withdrawalGroups,
+      x.planchets,
+      x.exchanges,
+      x.denominations,
+    ])
     .runReadOnly(async (tx) => {
       let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
         withdrawalGroup.withdrawalGroupId,
@@ -600,7 +595,7 @@ async function processPlanchetExchangeRequest(
     logger.trace("withdrawal request failed", e);
     logger.trace(e);
     await ws.db
-      .mktx((x) => ({ planchets: x.planchets }))
+      .mktx((x) => [x.planchets])
       .runReadWrite(async (tx) => {
         let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
           withdrawalGroup.withdrawalGroupId,
@@ -632,12 +627,12 @@ async function processPlanchetExchangeBatchRequest(
     .map((x) => x.count)
     .reduce((a, b) => a + b);
   const d = await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-      planchets: x.planchets,
-      exchanges: x.exchanges,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [
+      x.withdrawalGroups,
+      x.planchets,
+      x.exchanges,
+      x.denominations,
+    ])
     .runReadOnly(async (tx) => {
       const reqBody: { planchets: ExchangeWithdrawRequest[] } = {
         planchets: [],
@@ -691,31 +686,12 @@ async function processPlanchetExchangeBatchRequest(
     withdrawalGroup.exchangeBaseUrl,
   ).href;
 
-  try {
-    const resp = await ws.http.postJson(reqUrl, d);
-    const r = await readSuccessResponseJsonOrThrow(
-      resp,
-      codecForWithdrawBatchResponse(),
-    );
-    return r;
-  } catch (e) {
-    const errDetail = getErrorDetailFromException(e);
-    logger.trace("withdrawal batch request failed", e);
-    logger.trace(e);
-    await ws.db
-      .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
-      .runReadWrite(async (tx) => {
-        let wg = await tx.withdrawalGroups.get(
-          withdrawalGroup.withdrawalGroupId,
-        );
-        if (!wg) {
-          return;
-        }
-        wg.lastError = errDetail;
-        await tx.withdrawalGroups.put(wg);
-      });
-    return;
-  }
+  const resp = await ws.http.postJson(reqUrl, d);
+  const r = await readSuccessResponseJsonOrThrow(
+    resp,
+    codecForWithdrawBatchResponse(),
+  );
+  return r;
 }
 
 async function processPlanchetVerifyAndStoreCoin(
@@ -725,11 +701,7 @@ async function processPlanchetVerifyAndStoreCoin(
   resp: WithdrawResponse,
 ): Promise<void> {
   const d = await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-      planchets: x.planchets,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.withdrawalGroups, x.planchets, x.denominations])
     .runReadOnly(async (tx) => {
       let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
         withdrawalGroup.withdrawalGroupId,
@@ -788,7 +760,7 @@ async function processPlanchetVerifyAndStoreCoin(
 
   if (!isValid) {
     await ws.db
-      .mktx((x) => ({ planchets: x.planchets }))
+      .mktx((x) => [x.planchets])
       .runReadWrite(async (tx) => {
         let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
           withdrawalGroup.withdrawalGroupId,
@@ -843,11 +815,7 @@ async function processPlanchetVerifyAndStoreCoin(
   // withdrawal succeeded.  If so, mark the withdrawal
   // group as finished.
   const firstSuccess = await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      withdrawalGroups: x.withdrawalGroups,
-      planchets: x.planchets,
-    }))
+    .mktx((x) => [x.coins, x.withdrawalGroups, x.planchets])
     .runReadWrite(async (tx) => {
       const p = await tx.planchets.get(planchetCoinPub);
       if (!p || p.withdrawalDone) {
@@ -878,10 +846,7 @@ export async function updateWithdrawalDenoms(
     `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
   );
   const exchangeDetails = await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadOnly(async (tx) => {
       return ws.exchangeOps.getExchangeDetails(tx, exchangeBaseUrl);
     });
@@ -910,7 +875,8 @@ export async function updateWithdrawalDenoms(
         denom.verificationStatus === DenominationVerificationStatus.Unverified
       ) {
         logger.trace(
-          `Validating denomination (${current + 1}/${denominations.length
+          `Validating denomination (${current + 1}/${
+            denominations.length
           }) signature of ${denom.denomPubHash}`,
         );
         let valid = false;
@@ -939,7 +905,7 @@ export async function updateWithdrawalDenoms(
     if (updatedDenominations.length > 0) {
       logger.trace("writing denomination batch to db");
       await ws.db
-        .mktx((x) => ({ denominations: x.denominations }))
+        .mktx((x) => [x.denominations])
         .runReadWrite(async (tx) => {
           for (let i = 0; i < updatedDenominations.length; i++) {
             const denom = updatedDenominations[i];
@@ -951,50 +917,6 @@ export async function updateWithdrawalDenoms(
   }
 }
 
-async function setupWithdrawalRetry(
-  ws: InternalWalletState,
-  withdrawalGroupId: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
-    .runReadWrite(async (tx) => {
-      const wsr = await tx.withdrawalGroups.get(withdrawalGroupId);
-      if (!wsr) {
-        return;
-      }
-      if (options.reset) {
-        wsr.retryInfo = RetryInfo.reset();
-      } else {
-        wsr.retryInfo = RetryInfo.increment(wsr.retryInfo);
-      }
-      await tx.withdrawalGroups.put(wsr);
-    });
-}
-
-async function reportWithdrawalError(
-  ws: InternalWalletState,
-  withdrawalGroupId: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
-    .runReadWrite(async (tx) => {
-      const wsr = await tx.withdrawalGroups.get(withdrawalGroupId);
-      if (!wsr) {
-        return;
-      }
-      if (!wsr.retryInfo) {
-        logger.reportBreak();
-      }
-      wsr.lastError = err;
-      await tx.withdrawalGroups.put(wsr);
-    });
-  ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
-}
-
 /**
  * Update the information about a reserve that is stored in the wallet
  * by querying the reserve's exchange.
@@ -1037,7 +959,7 @@ async function queryReserve(
     if (
       resp.status === 404 &&
       result.talerErrorResponse.code ===
-      TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
+        TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
     ) {
       ws.notify({
         type: NotificationType.ReserveNotYetFound,
@@ -1052,9 +974,7 @@ async function queryReserve(
   logger.trace(`got reserve status ${j2s(result.response)}`);
 
   await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadWrite(async (tx) => {
       const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
       if (!wg) {
@@ -1071,30 +991,11 @@ async function queryReserve(
 export async function processWithdrawalGroup(
   ws: InternalWalletState,
   withdrawalGroupId: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  const onOpErr = (e: TalerErrorDetail): Promise<void> =>
-    reportWithdrawalError(ws, withdrawalGroupId, e);
-  await guardOperationException(
-    () => processWithdrawGroupImpl(ws, withdrawalGroupId, options),
-    onOpErr,
-  );
-}
-
-async function processWithdrawGroupImpl(
-  ws: InternalWalletState,
-  withdrawalGroupId: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  const forceNow = options.forceNow ?? false;
-  logger.trace("processing withdraw group", withdrawalGroupId);
-  await setupWithdrawalRetry(ws, withdrawalGroupId, { reset: forceNow });
+  options: {} = {},
+): Promise<OperationAttemptResult> {
+  logger.trace("processing withdrawal group", withdrawalGroupId);
   const withdrawalGroup = await ws.db
-    .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadOnly(async (tx) => {
       return tx.withdrawalGroups.get(withdrawalGroupId);
     });
@@ -1106,24 +1007,44 @@ async function processWithdrawGroupImpl(
   switch (withdrawalGroup.reserveStatus) {
     case ReserveRecordStatus.RegisteringBank:
       await processReserveBankStatus(ws, withdrawalGroupId);
-      return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
+      return await processWithdrawalGroup(ws, withdrawalGroupId, {
         forceNow: true,
       });
     case ReserveRecordStatus.QueryingStatus: {
       const res = await queryReserve(ws, withdrawalGroupId);
       if (res.ready) {
-        return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
+        return await processWithdrawalGroup(ws, withdrawalGroupId, {
           forceNow: true,
         });
       }
-      return;
+      return {
+        type: OperationAttemptResultType.Pending,
+        result: undefined,
+      };
+    }
+    case ReserveRecordStatus.WaitConfirmBank: {
+      const res = await processReserveBankStatus(ws, withdrawalGroupId);
+      switch (res.status) {
+        case BankStatusResultCode.Aborted:
+        case BankStatusResultCode.Done:
+          return {
+            type: OperationAttemptResultType.Finished,
+            result: undefined,
+          };
+        case BankStatusResultCode.Waiting: {
+          return {
+            type: OperationAttemptResultType.Pending,
+            result: undefined,
+          };
+        }
+      }
     }
-    case ReserveRecordStatus.WaitConfirmBank:
-      await processReserveBankStatus(ws, withdrawalGroupId);
-      return;
     case ReserveRecordStatus.BankAborted:
       // FIXME
-      return;
+      return {
+        type: OperationAttemptResultType.Pending,
+        result: undefined,
+      };
     case ReserveRecordStatus.Dormant:
       // We can try to withdraw, nothing needs to be done with the reserve.
       break;
@@ -1143,18 +1064,19 @@ async function processWithdrawGroupImpl(
 
   if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
     await ws.db
-      .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
+      .mktx((x) => [x.withdrawalGroups])
       .runReadWrite(async (tx) => {
         const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
         if (!wg) {
           return;
         }
         wg.operationStatus = OperationStatus.Finished;
-        delete wg.lastError;
-        delete wg.retryInfo;
         await tx.withdrawalGroups.put(wg);
       });
-    return;
+    return {
+      type: OperationAttemptResultType.Finished,
+      result: undefined,
+    };
   }
 
   const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
@@ -1175,7 +1097,7 @@ async function processWithdrawGroupImpl(
   if (ws.batchWithdrawal) {
     const resp = await processPlanchetExchangeBatchRequest(ws, 
withdrawalGroup);
     if (!resp) {
-      return;
+      throw Error("unable to do batch withdrawal");
     }
     for (let coinIdx = 0; coinIdx < numTotalCoins; coinIdx++) {
       work.push(
@@ -1210,11 +1132,7 @@ async function processWithdrawGroupImpl(
   let errorsPerCoin: Record<number, TalerErrorDetail> = {};
 
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      withdrawalGroups: x.withdrawalGroups,
-      planchets: x.planchets,
-    }))
+    .mktx((x) => [x.coins, x.withdrawalGroups, x.planchets])
     .runReadWrite(async (tx) => {
       const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
       if (!wg) {
@@ -1236,8 +1154,6 @@ async function processWithdrawGroupImpl(
         finishedForFirstTime = true;
         wg.timestampFinish = TalerProtocolTimestamp.now();
         wg.operationStatus = OperationStatus.Finished;
-        delete wg.lastError;
-        wg.retryInfo = RetryInfo.reset();
       }
 
       await tx.withdrawalGroups.put(wg);
@@ -1259,9 +1175,16 @@ async function processWithdrawGroupImpl(
       reservePub: withdrawalGroup.reservePub,
     });
   }
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
 }
 
-const AGE_MASK_GROUPS = "8:10:12:14:16:18".split(":").map(n => parseInt(n, 10))
+const AGE_MASK_GROUPS = "8:10:12:14:16:18"
+  .split(":")
+  .map((n) => parseInt(n, 10));
 
 export async function getExchangeWithdrawalInfo(
   ws: InternalWalletState,
@@ -1296,14 +1219,14 @@ export async function getExchangeWithdrawalInfo(
     exchange,
   );
 
-  let hasDenomWithAgeRestriction = false
+  let hasDenomWithAgeRestriction = false;
 
   let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
   for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
     const ds = selectedDenoms.selectedDenoms[i];
     // FIXME: Do in one transaction!
     const denom = await ws.db
-      .mktx((x) => ({ denominations: x.denominations }))
+      .mktx((x) => [x.denominations])
       .runReadOnly(async (tx) => {
         return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
       });
@@ -1321,13 +1244,14 @@ export async function getExchangeWithdrawalInfo(
     ) {
       earliestDepositExpiration = expireDeposit;
     }
-    hasDenomWithAgeRestriction = hasDenomWithAgeRestriction || 
denom.denomPub.age_mask > 0
+    hasDenomWithAgeRestriction =
+      hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
   }
 
   checkLogicInvariant(!!earliestDepositExpiration);
 
   const possibleDenoms = await ws.db
-    .mktx((x) => ({ denominations: x.denominations }))
+    .mktx((x) => [x.denominations])
     .runReadOnly(async (tx) => {
       const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
         exchangeBaseUrl,
@@ -1349,7 +1273,7 @@ export async function getExchangeWithdrawalInfo(
     ) {
       logger.warn(
         `wallet's support for exchange protocol version 
${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
-        `(exchange has ${exchangeDetails.protocolVersion}), checking for 
updates`,
+          `(exchange has ${exchangeDetails.protocolVersion}), checking for 
updates`,
       );
     }
   }
@@ -1384,7 +1308,9 @@ export async function getExchangeWithdrawalInfo(
     withdrawalAmountRaw: Amounts.stringify(instructedAmount),
     // TODO: remove hardcoding, this should be calculated from the 
denominations info
     // force enabled for testing
-    ageRestrictionOptions: hasDenomWithAgeRestriction ? AGE_MASK_GROUPS : 
undefined
+    ageRestrictionOptions: hasDenomWithAgeRestriction
+      ? AGE_MASK_GROUPS
+      : undefined,
   };
   return ret;
 }
@@ -1428,11 +1354,7 @@ export async function getWithdrawalDetailsForUri(
   const exchanges: ExchangeListItem[] = [];
 
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
     .runReadOnly(async (tx) => {
       const exchangeRecords = await tx.exchanges.iter().toArray();
       for (const r of exchangeRecords) {
@@ -1468,11 +1390,7 @@ export async function getFundingPaytoUrisTx(
   withdrawalGroupId: string,
 ): Promise<string[]> {
   return await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails, x.withdrawalGroups])
     .runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId));
 }
 
@@ -1520,19 +1438,14 @@ async function getWithdrawalGroupRecordTx(
   },
 ): Promise<WithdrawalGroupRecord | undefined> {
   return await db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadOnly(async (tx) => {
       return tx.withdrawalGroups.get(req.withdrawalGroupId);
     });
 }
 
 export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
-  return Duration.max(
-    { d_ms: 60000 },
-    Duration.min({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
-  );
+  return { d_ms: 60000 };
 }
 
 export function getBankStatusUrl(talerWithdrawUri: string): string {
@@ -1552,9 +1465,7 @@ async function registerReserveWithBank(
   withdrawalGroupId: string,
 ): Promise<void> {
   const withdrawalGroup = await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadOnly(async (tx) => {
       return await tx.withdrawalGroups.get(withdrawalGroupId);
     });
@@ -1588,9 +1499,7 @@ async function registerReserveWithBank(
     codecForBankWithdrawalOperationPostResponse(),
   );
   await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadWrite(async (tx) => {
       const r = await tx.withdrawalGroups.get(withdrawalGroupId);
       if (!r) {
@@ -1611,17 +1520,25 @@ async function registerReserveWithBank(
       );
       r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
       r.operationStatus = OperationStatus.Pending;
-      r.retryInfo = RetryInfo.reset();
       await tx.withdrawalGroups.put(r);
     });
   ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
-  return processReserveBankStatus(ws, withdrawalGroupId);
+}
+
+enum BankStatusResultCode {
+  Done = "done",
+  Waiting = "waiting",
+  Aborted = "aborted",
+}
+
+interface BankStatusResult {
+  status: BankStatusResultCode;
 }
 
 async function processReserveBankStatus(
   ws: InternalWalletState,
   withdrawalGroupId: string,
-): Promise<void> {
+): Promise<BankStatusResult> {
   const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
     withdrawalGroupId,
   });
@@ -1630,17 +1547,21 @@ async function processReserveBankStatus(
     case ReserveRecordStatus.RegisteringBank:
       break;
     default:
-      return;
+      return {
+        status: BankStatusResultCode.Done,
+      };
   }
 
   if (
     withdrawalGroup.wgInfo.withdrawalType != 
WithdrawalRecordType.BankIntegrated
   ) {
-    throw Error();
+    throw Error("wrong withdrawal record type");
   }
   const bankInfo = withdrawalGroup.wgInfo.bankInfo;
   if (!bankInfo) {
-    return;
+    return {
+      status: BankStatusResultCode.Done,
+    };
   }
 
   const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
@@ -1656,9 +1577,7 @@ async function processReserveBankStatus(
   if (status.aborted) {
     logger.info("bank aborted the withdrawal");
     await ws.db
-      .mktx((x) => ({
-        withdrawalGroups: x.withdrawalGroups,
-      }))
+      .mktx((x) => [x.withdrawalGroups])
       .runReadWrite(async (tx) => {
         const r = await tx.withdrawalGroups.get(withdrawalGroupId);
         if (!r) {
@@ -1678,10 +1597,11 @@ async function processReserveBankStatus(
         r.wgInfo.bankInfo.timestampBankConfirmed = now;
         r.reserveStatus = ReserveRecordStatus.BankAborted;
         r.operationStatus = OperationStatus.Finished;
-        r.retryInfo = RetryInfo.reset();
         await tx.withdrawalGroups.put(r);
       });
-    return;
+    return {
+      status: BankStatusResultCode.Aborted,
+    };
   }
 
   // Bank still needs to know our reserve info
@@ -1697,9 +1617,7 @@ async function processReserveBankStatus(
   }
 
   await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadWrite(async (tx) => {
       const r = await tx.withdrawalGroups.get(withdrawalGroupId);
       if (!r) {
@@ -1722,15 +1640,17 @@ async function processReserveBankStatus(
         r.wgInfo.bankInfo.timestampBankConfirmed = now;
         r.reserveStatus = ReserveRecordStatus.QueryingStatus;
         r.operationStatus = OperationStatus.Pending;
-        r.retryInfo = RetryInfo.reset();
       } else {
         logger.info("withdrawal: transfer not yet confirmed by bank");
         r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
         r.senderWire = status.sender_wire;
-        r.retryInfo = RetryInfo.increment(r.retryInfo);
       }
       await tx.withdrawalGroups.put(r);
     });
+
+  return {
+    status: BankStatusResultCode.Done,
+  };
 }
 
 export async function internalCreateWithdrawalGroup(
@@ -1775,14 +1695,12 @@ export async function internalCreateWithdrawalGroup(
     exchangeBaseUrl: canonExchange,
     instructedAmount: amount,
     timestampStart: now,
-    lastError: undefined,
     operationStatus: OperationStatus.Pending,
     rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
     secretSeed,
     reservePriv: reserveKeyPair.priv,
     reservePub: reserveKeyPair.pub,
     reserveStatus: args.reserveStatus,
-    retryInfo: RetryInfo.reset(),
     withdrawalGroupId,
     restrictAge: args.restrictAge,
     senderWire: undefined,
@@ -1802,13 +1720,13 @@ export async function internalCreateWithdrawalGroup(
   );
 
   await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-      reserves: x.reserves,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      exchangeTrust: x.exchangeTrust,
-    }))
+    .mktx((x) => [
+      x.withdrawalGroups,
+      x.reserves,
+      x.exchanges,
+      x.exchangeDetails,
+      x.exchangeTrust,
+    ])
     .runReadWrite(async (tx) => {
       await tx.withdrawalGroups.add(withdrawalGroup);
       await tx.reserves.put({
@@ -1839,7 +1757,7 @@ export async function acceptWithdrawalFromUri(
   },
 ): Promise<AcceptWithdrawalResponse> {
   const existingWithdrawalGroup = await ws.db
-    .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadOnly(async (tx) => {
       return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
         req.talerWithdrawUri,
@@ -1948,12 +1866,12 @@ export async function createManualWithdrawal(
   const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
 
   const exchangePaytoUris = await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      exchangeTrust: x.exchangeTrust,
-    }))
+    .mktx((x) => [
+      x.withdrawalGroups,
+      x.exchanges,
+      x.exchangeDetails,
+      x.exchangeTrust,
+    ])
     .runReadWrite(async (tx) => {
       return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
     });
diff --git a/packages/taler-wallet-core/src/pending-types.ts 
b/packages/taler-wallet-core/src/pending-types.ts
index 39df9d0c..61c7136d 100644
--- a/packages/taler-wallet-core/src/pending-types.ts
+++ b/packages/taler-wallet-core/src/pending-types.ts
@@ -30,14 +30,12 @@ import {
   AbsoluteTime,
   TalerProtocolTimestamp,
 } from "@gnu-taler/taler-util";
-import { ReserveRecordStatus } from "./db.js";
 import { RetryInfo } from "./util/retries.js";
 
 export enum PendingTaskType {
   ExchangeUpdate = "exchange-update",
   ExchangeCheckRefresh = "exchange-check-refresh",
   Pay = "pay",
-  ProposalChoice = "proposal-choice",
   ProposalDownload = "proposal-download",
   Refresh = "refresh",
   Recoup = "recoup",
@@ -109,7 +107,7 @@ export interface PendingRefreshTask {
   lastError?: TalerErrorDetail;
   refreshGroupId: string;
   finishedPerCoin: boolean[];
-  retryInfo: RetryInfo;
+  retryInfo?: RetryInfo;
 }
 
 /**
@@ -125,17 +123,6 @@ export interface PendingProposalDownloadTask {
   retryInfo?: RetryInfo;
 }
 
-/**
- * User must choose whether to accept or reject the merchant's
- * proposed contract terms.
- */
-export interface PendingProposalChoiceOperation {
-  type: PendingTaskType.ProposalChoice;
-  merchantBaseUrl: string;
-  proposalTimestamp: AbsoluteTime;
-  proposalId: string;
-}
-
 /**
  * The wallet is picking up a tip that the user has accepted.
  */
@@ -165,14 +152,14 @@ export interface PendingPayTask {
 export interface PendingRefundQueryTask {
   type: PendingTaskType.RefundQuery;
   proposalId: string;
-  retryInfo: RetryInfo;
+  retryInfo?: RetryInfo;
   lastError: TalerErrorDetail | undefined;
 }
 
 export interface PendingRecoupTask {
   type: PendingTaskType.Recoup;
   recoupGroupId: string;
-  retryInfo: RetryInfo;
+  retryInfo?: RetryInfo;
   lastError: TalerErrorDetail | undefined;
 }
 
@@ -205,6 +192,11 @@ export interface PendingTaskInfoCommon {
    */
   type: PendingTaskType;
 
+  /**
+   * Unique identifier for the pending task.
+   */
+  id: string;
+
   /**
    * Set to true if the operation indicates that something is really in 
progress,
    * as opposed to some regular scheduled operation that can be tried later.
diff --git a/packages/taler-wallet-core/src/util/query.ts 
b/packages/taler-wallet-core/src/util/query.ts
index e954e5c7..02595925 100644
--- a/packages/taler-wallet-core/src/util/query.ts
+++ b/packages/taler-wallet-core/src/util/query.ts
@@ -36,6 +36,7 @@ import {
 } from "@gnu-taler/idb-bridge";
 import { Logger } from "@gnu-taler/taler-util";
 import { performanceNow } from "./timer.js";
+import { access } from "fs";
 
 const logger = new Logger("query.ts");
 
@@ -152,6 +153,19 @@ class ResultStream<T> {
     return arr;
   }
 
+  async mapAsync<R>(f: (x: T) => Promise<R>): Promise<R[]> {
+    const arr: R[] = [];
+    while (true) {
+      const x = await this.next();
+      if (x.hasValue) {
+        arr.push(await f(x.value));
+      } else {
+        break;
+      }
+    }
+    return arr;
+  }
+
   async forEachAsync(f: (x: T) => Promise<void>): Promise<void> {
     while (true) {
       const x = await this.next();
@@ -267,7 +281,6 @@ export interface IndexDescriptor {
 
 export interface StoreDescriptor<RecordType> {
   _dummy: undefined & RecordType;
-  name: string;
   keyPath?: IDBKeyPath | IDBKeyPath[];
   autoIncrement?: boolean;
 }
@@ -278,10 +291,9 @@ export interface StoreOptions {
 }
 
 export function describeContents<RecordType = never>(
-  name: string,
   options: StoreOptions,
 ): StoreDescriptor<RecordType> {
-  return { name, keyPath: options.keyPath, _dummy: undefined as any };
+  return { keyPath: options.keyPath, _dummy: undefined as any };
 }
 
 export function describeIndex(
@@ -332,9 +344,11 @@ export interface StoreReadWriteAccessor<RecordType, 
IndexMap> {
 }
 
 export interface StoreWithIndexes<
+  StoreName extends string,
   SD extends StoreDescriptor<unknown>,
   IndexMap,
 > {
+  storeName: StoreName;
   store: SD;
   indexMap: IndexMap;
 
@@ -349,11 +363,17 @@ export type GetRecordType<T> = T extends 
StoreDescriptor<infer X> ? X : unknown;
 
 const storeWithIndexesSymbol = Symbol("StoreWithIndexesMark");
 
-export function describeStore<SD extends StoreDescriptor<unknown>, IndexMap>(
+export function describeStore<
+  StoreName extends string,
+  SD extends StoreDescriptor<unknown>,
+  IndexMap,
+>(
+  name: StoreName,
   s: SD,
   m: IndexMap,
-): StoreWithIndexes<SD, IndexMap> {
+): StoreWithIndexes<StoreName, SD, IndexMap> {
   return {
+    storeName: name,
     store: s,
     indexMap: m,
     mark: storeWithIndexesSymbol,
@@ -362,6 +382,7 @@ export function describeStore<SD extends 
StoreDescriptor<unknown>, IndexMap>(
 
 export type GetReadOnlyAccess<BoundStores> = {
   [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
+    infer SN,
     infer SD,
     infer IM
   >
@@ -371,6 +392,7 @@ export type GetReadOnlyAccess<BoundStores> = {
 
 export type GetReadWriteAccess<BoundStores> = {
   [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
+    infer SN,
     infer SD,
     infer IM
   >
@@ -391,8 +413,12 @@ export interface TransactionContext<BoundStores> {
   runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>;
 }
 
-type CheckDescriptor<T> = T extends StoreWithIndexes<infer SD, infer IM>
-  ? StoreWithIndexes<SD, IM>
+type CheckDescriptor<T> = T extends StoreWithIndexes<
+  infer SN,
+  infer SD,
+  infer IM
+>
+  ? StoreWithIndexes<SN, SD, IM>
   : unknown;
 
 type GetPickerType<F, SM> = F extends (x: SM) => infer Out
@@ -464,13 +490,13 @@ function runTx<Arg, Res>(
 
 function makeReadContext(
   tx: IDBTransaction,
-  storePick: { [n: string]: StoreWithIndexes<any, any> },
+  storePick: { [n: string]: StoreWithIndexes<any, any, any> },
 ): any {
   const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {};
   for (const storeAlias in storePick) {
     const indexes: { [s: string]: IndexReadOnlyAccessor<any> } = {};
     const swi = storePick[storeAlias];
-    const storeName = swi.store.name;
+    const storeName = swi.storeName;
     for (const indexAlias in storePick[storeAlias].indexMap) {
       const indexDescriptor: IndexDescriptor =
         storePick[storeAlias].indexMap[indexAlias];
@@ -513,13 +539,13 @@ function makeReadContext(
 
 function makeWriteContext(
   tx: IDBTransaction,
-  storePick: { [n: string]: StoreWithIndexes<any, any> },
+  storePick: { [n: string]: StoreWithIndexes<any, any, any> },
 ): any {
   const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {};
   for (const storeAlias in storePick) {
     const indexes: { [s: string]: IndexReadWriteAccessor<any> } = {};
     const swi = storePick[storeAlias];
-    const storeName = swi.store.name;
+    const storeName = swi.storeName;
     for (const indexAlias in storePick[storeAlias].indexMap) {
       const indexDescriptor: IndexDescriptor =
         storePick[storeAlias].indexMap[indexAlias];
@@ -572,6 +598,12 @@ function makeWriteContext(
   return ctx;
 }
 
+type StoreNamesOf<X> = X extends { [x: number]: infer F }
+  ? F extends { storeName: infer I }
+    ? I
+    : never
+  : never;
+
 /**
  * Type-safe access to a database with a particular store map.
  *
@@ -584,28 +616,41 @@ export class DbAccess<StoreMap> {
     return this.db;
   }
 
+  /**
+   * Run a transaction with selected object stores.
+   *
+   * The {@link namePicker} must be a function that selects a list of object
+   * stores from all available object stores.
+   */
   mktx<
-    PickerType extends (x: StoreMap) => unknown,
-    BoundStores extends GetPickerType<PickerType, StoreMap>,
-  >(f: PickerType): TransactionContext<BoundStores> {
-    const storePick = f(this.stores) as any;
+    StoreNames extends keyof StoreMap,
+    Stores extends StoreMap[StoreNames],
+    StoreList extends Stores[],
+    BoundStores extends {
+      [X in StoreNamesOf<StoreList>]: StoreList[number] & { storeName: X };
+    },
+  >(namePicker: (x: StoreMap) => StoreList): TransactionContext<BoundStores> {
+    const storePick = namePicker(this.stores) as any;
     if (typeof storePick !== "object" || storePick === null) {
       throw Error();
     }
     const storeNames: string[] = [];
-    for (const storeAlias of Object.keys(storePick)) {
-      const swi = (storePick as any)[storeAlias] as StoreWithIndexes<any, any>;
+    const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+      {};
+    for (const swiPicked of storePick) {
+      const swi = swiPicked as StoreWithIndexes<any, any, any>;
       if (swi.mark !== storeWithIndexesSymbol) {
         throw Error("invalid store descriptor returned from selector 
function");
       }
-      storeNames.push(swi.store.name);
+      storeNames.push(swi.storeName);
+      accessibleStores[swi.storeName] = swi;
     }
 
     const runReadOnly = <T>(
       txf: ReadOnlyTransactionFunction<BoundStores, T>,
     ): Promise<T> => {
       const tx = this.db.transaction(storeNames, "readonly");
-      const readContext = makeReadContext(tx, storePick);
+      const readContext = makeReadContext(tx, accessibleStores);
       return runTx(tx, readContext, txf);
     };
 
@@ -613,7 +658,7 @@ export class DbAccess<StoreMap> {
       txf: ReadWriteTransactionFunction<BoundStores, T>,
     ): Promise<T> => {
       const tx = this.db.transaction(storeNames, "readwrite");
-      const writeContext = makeWriteContext(tx, storePick);
+      const writeContext = makeWriteContext(tx, accessibleStores);
       return runTx(tx, writeContext, txf);
     };
 
diff --git a/packages/taler-wallet-core/src/util/retries.ts 
b/packages/taler-wallet-core/src/util/retries.ts
index 13a05b38..4763bbc4 100644
--- a/packages/taler-wallet-core/src/util/retries.ts
+++ b/packages/taler-wallet-core/src/util/retries.ts
@@ -21,7 +21,29 @@
 /**
  * Imports.
  */
-import { AbsoluteTime, Duration } from "@gnu-taler/taler-util";
+import {
+  AbsoluteTime,
+  Duration,
+  TalerErrorDetail,
+} from "@gnu-taler/taler-util";
+import {
+  BackupProviderRecord,
+  DepositGroupRecord,
+  ExchangeRecord,
+  OperationAttemptResult,
+  OperationAttemptResultType,
+  ProposalRecord,
+  PurchaseRecord,
+  RecoupGroupRecord,
+  RefreshGroupRecord,
+  TipRecord,
+  WalletStoresV1,
+  WithdrawalGroupRecord,
+} from "../db.js";
+import { TalerError } from "../errors.js";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import { PendingTaskType } from "../pending-types.js";
+import { GetReadWriteAccess } from "./query.js";
 
 export interface RetryInfo {
   firstTry: AbsoluteTime;
@@ -108,3 +130,96 @@ export namespace RetryInfo {
     return r2;
   }
 }
+
+export namespace RetryTags {
+  export function forWithdrawal(wg: WithdrawalGroupRecord): string {
+    return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}`;
+  }
+  export function forExchangeUpdate(exch: ExchangeRecord): string {
+    return `${PendingTaskType.ExchangeUpdate}:${exch.baseUrl}`;
+  }
+  export function forExchangeCheckRefresh(exch: ExchangeRecord): string {
+    return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}`;
+  }
+  export function forProposalClaim(pr: ProposalRecord): string {
+    return `${PendingTaskType.ProposalDownload}:${pr.proposalId}`;
+  }
+  export function forTipPickup(tipRecord: TipRecord): string {
+    return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}`;
+  }
+  export function forRefresh(refreshGroupRecord: RefreshGroupRecord): string {
+    return `${PendingTaskType.TipPickup}:${refreshGroupRecord.refreshGroupId}`;
+  }
+  export function forPay(purchaseRecord: PurchaseRecord): string {
+    return `${PendingTaskType.Pay}:${purchaseRecord.proposalId}`;
+  }
+  export function forRefundQuery(purchaseRecord: PurchaseRecord): string {
+    return `${PendingTaskType.RefundQuery}:${purchaseRecord.proposalId}`;
+  }
+  export function forRecoup(recoupRecord: RecoupGroupRecord): string {
+    return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}`;
+  }
+  export function forDeposit(depositRecord: DepositGroupRecord): string {
+    return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}`;
+  }
+  export function forBackup(backupRecord: BackupProviderRecord): string {
+    return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`;
+  }
+}
+
+export async function scheduleRetryInTx(
+  ws: InternalWalletState,
+  tx: GetReadWriteAccess<{
+    operationRetries: typeof WalletStoresV1.operationRetries;
+  }>,
+  opId: string,
+  errorDetail?: TalerErrorDetail,
+): Promise<void> {
+  let retryRecord = await tx.operationRetries.get(opId);
+  if (!retryRecord) {
+    retryRecord = {
+      id: opId,
+      retryInfo: RetryInfo.reset(),
+    };
+    if (errorDetail) {
+      retryRecord.lastError = errorDetail;
+    }
+  } else {
+    retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
+    if (errorDetail) {
+      retryRecord.lastError = errorDetail;
+    } else {
+      delete retryRecord.lastError;
+    }
+  }
+  await tx.operationRetries.put(retryRecord);
+}
+
+export async function scheduleRetry(
+  ws: InternalWalletState,
+  opId: string,
+  errorDetail?: TalerErrorDetail,
+): Promise<void> {
+  return await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadWrite(async (tx) => {
+      tx.operationRetries
+      scheduleRetryInTx(ws, tx, opId, errorDetail);
+    });
+}
+
+/**
+ * Run an operation handler, expect a success result and extract the success 
value.
+ */
+export async function runOperationHandlerForResult<T>(
+  res: OperationAttemptResult<T>,
+): Promise<T> {
+  switch (res.type) {
+    case OperationAttemptResultType.Finished:
+      return res.result;
+    case OperationAttemptResultType.Error:
+      throw TalerError.fromUncheckedDetail(res.errorDetail);
+    default:
+      throw Error(`unexpected operation result (${res.type})`);
+  }
+}
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index 779fe952..0e777225 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -90,6 +90,7 @@ import {
   ExchangeListItem,
   OperationMap,
   FeeDescription,
+  TalerErrorDetail,
 } from "@gnu-taler/taler-util";
 import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
 import {
@@ -101,9 +102,15 @@ import {
   CoinSourceType,
   exportDb,
   importDb,
+  OperationAttemptResult,
+  OperationAttemptResultType,
   WalletStoresV1,
 } from "./db.js";
-import { getErrorDetailFromException, TalerError } from "./errors.js";
+import {
+  getErrorDetailFromException,
+  makeErrorDetail,
+  TalerError,
+} from "./errors.js";
 import { createDenominationTimeline } from "./index.browser.js";
 import {
   DenomInfo,
@@ -143,6 +150,7 @@ import {
   getExchangeRequestTimeout,
   getExchangeTrust,
   updateExchangeFromUrl,
+  updateExchangeFromUrlHandler,
   updateExchangeTermsOfService,
 } from "./operations/exchanges.js";
 import { getMerchantInfo } from "./operations/merchants.js";
@@ -162,7 +170,11 @@ import {
   initiatePeerToPeerPush,
 } from "./operations/peer-to-peer.js";
 import { getPendingOperations } from "./operations/pending.js";
-import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
+import {
+  createRecoupGroup,
+  processRecoupGroup,
+  processRecoupGroupHandler,
+} from "./operations/recoup.js";
 import {
   autoRefresh,
   createRefreshGroup,
@@ -210,6 +222,7 @@ import {
   openPromise,
 } from "./util/promiseUtils.js";
 import { DbAccess, GetReadWriteAccess } from "./util/query.js";
+import { RetryInfo, runOperationHandlerForResult } from "./util/retries.js";
 import { TimerAPI, TimerGroup } from "./util/timer.js";
 import {
   WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
@@ -237,7 +250,12 @@ async function getWithdrawalDetailsForAmount(
   amount: AmountJson,
   restrictAge: number | undefined,
 ): Promise<ManualWithdrawalDetails> {
-  const wi = await getExchangeWithdrawalInfo(ws, exchangeBaseUrl, amount, 
restrictAge);
+  const wi = await getExchangeWithdrawalInfo(
+    ws,
+    exchangeBaseUrl,
+    amount,
+    restrictAge,
+  );
   const paytoUris = wi.exchangeDetails.wireInfo.accounts.map(
     (x) => x.payto_uri,
   );
@@ -253,55 +271,166 @@ async function getWithdrawalDetailsForAmount(
 }
 
 /**
- * Execute one operation based on the pending operation info record.
+ * Call the right handler for a pending operation without doing
+ * any special error handling.
  */
-async function processOnePendingOperation(
+async function callOperationHandler(
   ws: InternalWalletState,
   pending: PendingTaskInfo,
   forceNow = false,
-): Promise<void> {
-  logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`);
+): Promise<OperationAttemptResult<unknown, unknown>> {
   switch (pending.type) {
     case PendingTaskType.ExchangeUpdate:
-      await updateExchangeFromUrl(ws, pending.exchangeBaseUrl, {
+      return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl, {
         forceNow,
       });
-      break;
     case PendingTaskType.Refresh:
-      await processRefreshGroup(ws, pending.refreshGroupId, { forceNow });
-      break;
+      return await processRefreshGroup(ws, pending.refreshGroupId, {
+        forceNow,
+      });
     case PendingTaskType.Withdraw:
-      await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow 
});
-      break;
+      return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
+        forceNow,
+      });
     case PendingTaskType.ProposalDownload:
-      await processDownloadProposal(ws, pending.proposalId, { forceNow });
-      break;
+      return await processDownloadProposal(ws, pending.proposalId, {
+        forceNow,
+      });
     case PendingTaskType.TipPickup:
-      await processTip(ws, pending.tipId, { forceNow });
-      break;
+      return await processTip(ws, pending.tipId, { forceNow });
     case PendingTaskType.Pay:
-      await processPurchasePay(ws, pending.proposalId, { forceNow });
-      break;
+      return await processPurchasePay(ws, pending.proposalId, { forceNow });
     case PendingTaskType.RefundQuery:
-      await processPurchaseQueryRefund(ws, pending.proposalId, { forceNow });
-      break;
+      return await processPurchaseQueryRefund(ws, pending.proposalId, {
+        forceNow,
+      });
     case PendingTaskType.Recoup:
-      await processRecoupGroup(ws, pending.recoupGroupId, { forceNow });
-      break;
+      return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
+        forceNow,
+      });
     case PendingTaskType.ExchangeCheckRefresh:
-      await autoRefresh(ws, pending.exchangeBaseUrl);
-      break;
+      return await autoRefresh(ws, pending.exchangeBaseUrl);
     case PendingTaskType.Deposit: {
-      await processDepositGroup(ws, pending.depositGroupId, {
+      return await processDepositGroup(ws, pending.depositGroupId, {
         forceNow,
       });
-      break;
     }
     case PendingTaskType.Backup:
-      await processBackupForProvider(ws, pending.backupProviderBaseUrl);
-      break;
+      return await processBackupForProvider(ws, pending.backupProviderBaseUrl);
     default:
-      assertUnreachable(pending);
+      return assertUnreachable(pending);
+  }
+  throw Error(`not reached ${pending.type}`);
+}
+
+export async function storeOperationError(
+  ws: InternalWalletState,
+  pendingTaskId: string,
+  e: TalerErrorDetail,
+): Promise<void> {
+  await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadWrite(async (tx) => {
+      let retryRecord = await tx.operationRetries.get(pendingTaskId);
+      if (!retryRecord) {
+        retryRecord = {
+          id: pendingTaskId,
+          lastError: e,
+          retryInfo: RetryInfo.reset(),
+        };
+      } else {
+        retryRecord.lastError = e;
+        retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
+      }
+      await tx.operationRetries.put(retryRecord);
+    });
+}
+
+export async function storeOperationFinished(
+  ws: InternalWalletState,
+  pendingTaskId: string,
+): Promise<void> {
+  await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadWrite(async (tx) => {
+      await tx.operationRetries.delete(pendingTaskId);
+    });
+}
+
+export async function storeOperationPending(
+  ws: InternalWalletState,
+  pendingTaskId: string,
+): Promise<void> {
+  await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadWrite(async (tx) => {
+      let retryRecord = await tx.operationRetries.get(pendingTaskId);
+      if (!retryRecord) {
+        retryRecord = {
+          id: pendingTaskId,
+          retryInfo: RetryInfo.reset(),
+        };
+      } else {
+        delete retryRecord.lastError;
+        retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
+      }
+      await tx.operationRetries.put(retryRecord);
+    });
+}
+
+/**
+ * Execute one operation based on the pending operation info record.
+ *
+ * Store success/failure result in the database.
+ */
+async function processOnePendingOperation(
+  ws: InternalWalletState,
+  pending: PendingTaskInfo,
+  forceNow = false,
+): Promise<void> {
+  logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`);
+  let maybeError: TalerErrorDetail | undefined;
+  try {
+    const resp = await callOperationHandler(ws, pending, forceNow);
+    switch (resp.type) {
+      case OperationAttemptResultType.Error:
+        return await storeOperationError(ws, pending.id, resp.errorDetail);
+      case OperationAttemptResultType.Finished:
+        return await storeOperationFinished(ws, pending.id);
+      case OperationAttemptResultType.Pending:
+        return await storeOperationPending(ws, pending.id);
+      case OperationAttemptResultType.Longpoll:
+        break;
+    }
+  } catch (e) {
+    if (e instanceof TalerError) {
+      logger.warn("operation processed resulted in error");
+      logger.warn(`error was: ${j2s(e.errorDetail)}`);
+      maybeError = e.errorDetail;
+      return await storeOperationError(ws, pending.id, maybeError!);
+    } else if (e instanceof Error) {
+      // This is a bug, as we expect pending operations to always
+      // do their own error handling and only throw 
WALLET_PENDING_OPERATION_FAILED
+      // or return something.
+      logger.error(`Uncaught exception: ${e.message}`);
+      logger.error(`Stack: ${e.stack}`);
+      maybeError = makeErrorDetail(
+        TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+        {
+          stack: e.stack,
+        },
+        `unexpected exception (message: ${e.message})`,
+      );
+      return await storeOperationError(ws, pending.id, maybeError);
+    } else {
+      logger.error("Uncaught exception, value is not even an error.");
+      maybeError = makeErrorDetail(
+        TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+        {},
+        `unexpected exception (not even an error)`,
+      );
+      return await storeOperationError(ws, pending.id, maybeError);
+    }
   }
 }
 
@@ -317,18 +446,7 @@ export async function runPending(
     if (!forceNow && !AbsoluteTime.isExpired(p.timestampDue)) {
       continue;
     }
-    try {
-      await processOnePendingOperation(ws, p, forceNow);
-    } catch (e) {
-      if (e instanceof TalerError) {
-        console.error(
-          "Pending operation failed:",
-          JSON.stringify(e.errorDetail, undefined, 2),
-        );
-      } else {
-        console.error(e);
-      }
-    }
+    await processOnePendingOperation(ws, p, forceNow);
   }
 }
 
@@ -420,27 +538,7 @@ async function runTaskLoop(
         if (!AbsoluteTime.isExpired(p.timestampDue)) {
           continue;
         }
-        try {
-          await processOnePendingOperation(ws, p);
-        } catch (e) {
-          if (
-            e instanceof TalerError &&
-            e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED)
-          ) {
-            logger.warn("operation processed resulted in error");
-            logger.warn(`error was: ${j2s(e.errorDetail)}`);
-          } else {
-            // This is a bug, as we expect pending operations to always
-            // do their own error handling and only throw 
WALLET_PENDING_OPERATION_FAILED
-            // or return something.
-            logger.error("Uncaught exception", e);
-            ws.notify({
-              type: NotificationType.InternalError,
-              message: "uncaught exception",
-              exception: e,
-            });
-          }
-        }
+        await processOnePendingOperation(ws, p);
         ws.notify({
           type: NotificationType.PendingOperationProcessed,
         });
@@ -457,7 +555,7 @@ async function runTaskLoop(
  */
 async function fillDefaults(ws: InternalWalletState): Promise<void> {
   await ws.db
-    .mktx((x) => ({ config: x.config, auditorTrustStore: x.auditorTrust }))
+    .mktx((x) => [x.config, x.auditorTrust])
     .runReadWrite(async (tx) => {
       let applied = false;
       await tx.config.iter().forEach((x) => {
@@ -467,7 +565,7 @@ async function fillDefaults(ws: InternalWalletState): 
Promise<void> {
       });
       if (!applied) {
         for (const c of builtinAuditors) {
-          await tx.auditorTrustStore.put(c);
+          await tx.auditorTrust.put(c);
         }
       }
       // FIXME: make sure exchanges are added transactionally to
@@ -549,9 +647,7 @@ async function listKnownBankAccounts(
 ): Promise<KnownBankAccounts> {
   const accounts: { [account: string]: PaytoUri } = {};
   await ws.db
-    .mktx((x) => ({
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.withdrawalGroups])
     .runReadOnly(async (tx) => {
       const withdrawalGroups = await tx.withdrawalGroups.iter().toArray();
       for (const r of withdrawalGroups) {
@@ -575,11 +671,7 @@ async function getExchanges(
 ): Promise<ExchangesListResponse> {
   const exchanges: ExchangeListItem[] = [];
   await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
     .runReadOnly(async (tx) => {
       const exchangeRecords = await tx.exchanges.iter().toArray();
       for (const r of exchangeRecords) {
@@ -623,13 +715,9 @@ async function getExchangeDetailedInfo(
 ): Promise<ExchangeFullDetails> {
   //TODO: should we use the forceUpdate parameter?
   const exchange = await ws.db
-    .mktx((x) => ({
-      exchanges: x.exchanges,
-      exchangeDetails: x.exchangeDetails,
-      denominations: x.denominations,
-    }))
+    .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
     .runReadOnly(async (tx) => {
-      const ex = await tx.exchanges.get(exchangeBaseurl)
+      const ex = await tx.exchanges.get(exchangeBaseurl);
       const dp = ex?.detailsPointer;
       if (!dp) {
         return;
@@ -663,11 +751,11 @@ async function getExchangeDetailedInfo(
           wireInfo: exchangeDetails.wireInfo,
         },
         denominations: denominations,
-      }
+      };
     });
 
   if (!exchange) {
-    throw Error(`exchange with base url "${exchangeBaseurl}" not found`)
+    throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
   }
 
   const feesDescription: OperationMap<FeeDescription[]> = {
@@ -705,9 +793,7 @@ async function setCoinSuspended(
   suspended: boolean,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-    }))
+    .mktx((x) => [x.coins])
     .runReadWrite(async (tx) => {
       const c = await tx.coins.get(coinPub);
       if (!c) {
@@ -726,11 +812,7 @@ async function dumpCoins(ws: InternalWalletState): 
Promise<CoinDumpJson> {
   const coinsJson: CoinDumpJson = { coins: [] };
   logger.info("dumping coins");
   await ws.db
-    .mktx((x) => ({
-      coins: x.coins,
-      denominations: x.denominations,
-      withdrawalGroups: x.withdrawalGroups,
-    }))
+    .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups])
     .runReadOnly(async (tx) => {
       const coins = await tx.coins.iter().toArray();
       for (const c of coins) {
@@ -809,6 +891,7 @@ declare const __GIT_HASH__: string;
 
 const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
 const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : 
undefined;
+
 /**
  * Implementation of the "wallet-core" API.
  */
@@ -908,7 +991,7 @@ async function dispatchRequestInternal(
         ws,
         req.exchangeBaseUrl,
         Amounts.parseOrThrow(req.amount),
-        req.restrictAge
+        req.restrictAge,
       );
     }
     case "getBalances": {
@@ -979,11 +1062,7 @@ async function dispatchRequestInternal(
       const req = codecForForceRefreshRequest().decode(payload);
       const coinPubs = req.coinPubList.map((x) => ({ coinPub: x }));
       const refreshGroupId = await ws.db
-        .mktx((x) => ({
-          refreshGroups: x.refreshGroups,
-          denominations: x.denominations,
-          coins: x.coins,
-        }))
+        .mktx((x) => [x.refreshGroups, x.denominations, x.coins])
         .runReadWrite(async (tx) => {
           return await createRefreshGroup(
             ws,
@@ -1078,10 +1157,7 @@ async function dispatchRequestInternal(
     }
     case "listCurrencies": {
       return await ws.db
-        .mktx((x) => ({
-          auditorTrust: x.auditorTrust,
-          exchangeTrust: x.exchangeTrust,
-        }))
+        .mktx((x) => [x.auditorTrust, x.exchangeTrust])
         .runReadOnly(async (tx) => {
           const trustedAuditors = await tx.auditorTrust.iter().toArray();
           const trustedExchanges = await tx.exchangeTrust.iter().toArray();
@@ -1106,7 +1182,7 @@ async function dispatchRequestInternal(
         ws,
         req.exchange,
         amount,
-        undefined
+        undefined,
       );
       const wres = await createManualWithdrawal(ws, {
         amount: amount,

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