gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] branch master updated: wallet-core: towards better D


From: gnunet
Subject: [taler-wallet-core] branch master updated: wallet-core: towards better DD48 support
Date: Tue, 12 Dec 2023 15:42:40 +0100

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

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

The following commit(s) were added to refs/heads/master by this push:
     new e31f18b8f wallet-core: towards better DD48 support
e31f18b8f is described below

commit e31f18b8f129adb9cbe33158297a9cff56a7143e
Author: Florian Dold <florian@dold.me>
AuthorDate: Mon Dec 11 20:01:28 2023 +0100

    wallet-core: towards better DD48 support
---
 API_CHANGES.md                                     |   4 +
 .../src/integrationtests/test-denom-unoffered.ts   |   4 +-
 .../src/integrationtests/test-revocation.ts        |   6 +-
 .../src/integrationtests/test-wallet-balance.ts    |   4 +
 packages/taler-util/src/notifications.ts           |  33 +-
 packages/taler-util/src/wallet-types.ts            |  17 +-
 packages/taler-wallet-cli/src/index.ts             |   3 +-
 packages/taler-wallet-core/src/db.ts               |  28 +-
 packages/taler-wallet-core/src/dbless.ts           |   7 +-
 .../taler-wallet-core/src/operations/common.ts     | 200 ++++--
 .../taler-wallet-core/src/operations/exchanges.ts  | 723 ++++++++++++++++-----
 .../src/operations/pay-peer-pull-credit.ts         |   4 +-
 .../src/operations/pay-peer-push-credit.ts         |   4 +-
 .../taler-wallet-core/src/operations/pending.ts    |  29 +-
 .../taler-wallet-core/src/operations/refresh.ts    |  12 +-
 .../taler-wallet-core/src/operations/reward.ts     |   4 +-
 .../taler-wallet-core/src/operations/testing.ts    |   4 +-
 .../taler-wallet-core/src/operations/withdraw.ts   |  10 +-
 packages/taler-wallet-core/src/wallet.ts           | 262 +-------
 19 files changed, 838 insertions(+), 520 deletions(-)

diff --git a/API_CHANGES.md b/API_CHANGES.md
index 6fceca31c..f53daf598 100644
--- a/API_CHANGES.md
+++ b/API_CHANGES.md
@@ -23,3 +23,7 @@ This files contains all the API changes for the current 
release:
   via a taler://withdraw-exchange URI.
 - 2023-12-11 dold: Add exchangeBaseUrl to the checkPeerPushDebit response.
 - 2023-12-11 dold: Add scopeInfo to exchange entry list items.
+- BREAK 2023-12-12 dold: Remove forceUpdate and masterPub arguments from 
addExchange
+  request. This request has previously been overloaded both to update an
+  exchange entry as well as to add it.
+  To update the entry, updateExchangeEntry should be used instead.
\ No newline at end of file
diff --git 
a/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts 
b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
index 259cc33f9..1a62a6065 100644
--- a/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
+++ b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
@@ -127,9 +127,9 @@ export async function runDenomUnofferedTest(t: 
GlobalTestState) {
   //   TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND,
   // );
 
-  await walletClient.call(WalletApiOperation.AddExchange, {
+  // Force updating the exchange entry so that the wallet knows about the new 
denominations.
+  await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
     exchangeBaseUrl: exchange.baseUrl,
-    forceUpdate: true,
   });
 
   await walletClient.call(WalletApiOperation.DeleteTransaction, {
diff --git a/packages/taler-harness/src/integrationtests/test-revocation.ts 
b/packages/taler-harness/src/integrationtests/test-revocation.ts
index 9ed2d6206..6b47951bc 100644
--- a/packages/taler-harness/src/integrationtests/test-revocation.ts
+++ b/packages/taler-harness/src/integrationtests/test-revocation.ts
@@ -180,9 +180,8 @@ export async function runRevocationTest(t: GlobalTestState) 
{
 
   // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
   // is implemented.
-  await walletClient.call(WalletApiOperation.AddExchange, {
+  await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
     exchangeBaseUrl: exchange.baseUrl,
-    forceUpdate: true,
   });
   await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
   const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
@@ -218,9 +217,8 @@ export async function runRevocationTest(t: GlobalTestState) 
{
 
   // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
   // is implemented.
-  await walletClient.call(WalletApiOperation.AddExchange, {
+  await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
     exchangeBaseUrl: exchange.baseUrl,
-    forceUpdate: true,
   });
   await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
   {
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance.ts 
b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
index 0e57ce477..c4ca94dc0 100644
--- a/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
@@ -75,6 +75,8 @@ export async function runWalletBalanceTest(t: 
GlobalTestState) {
     fulfillment_url: "taler://fulfillment-success/thx",
   };
 
+  console.log("creating order");
+
   const orderResp = await merchantClient.createOrder({
     order,
   });
@@ -117,6 +119,8 @@ export async function runWalletBalanceTest(t: 
GlobalTestState) {
     Amounts.isZero(preparePayResult.balanceDetails.balanceMerchantDepositable),
   );
 
+  console.log("waiting for transactions to finalize");
+
   await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
 }
 
diff --git a/packages/taler-util/src/notifications.ts 
b/packages/taler-util/src/notifications.ts
index b91d91777..571d8f036 100644
--- a/packages/taler-util/src/notifications.ts
+++ b/packages/taler-util/src/notifications.ts
@@ -23,15 +23,14 @@
  * Imports.
  */
 import { TransactionState } from "./transactions-types.js";
-import { TalerErrorDetail } from "./wallet-types.js";
+import { ExchangeEntryState, TalerErrorDetail } from "./wallet-types.js";
 
 export enum NotificationType {
   BalanceChange = "balance-change",
-  ExchangeOperationError = "exchange-operation-error",
-  ExchangeAdded = "exchange-added",
   BackupOperationError = "backup-error",
   PendingOperationProcessed = "pending-operation-processed",
   TransactionStateTransition = "transaction-state-transition",
+  ExchangeStateTransition = "exchange-state-transition",
 }
 
 export interface ErrorInfoSummary {
@@ -59,19 +58,29 @@ export interface TransactionStateTransitionNotification {
   experimentalUserData?: any;
 }
 
-export interface ExchangeAddedNotification {
-  type: NotificationType.ExchangeAdded;
+export interface ExchangeStateTransitionNotification {
+  type: NotificationType.ExchangeStateTransition;
+  /**
+   * Identification of the exchange entry that this
+   * notification is about.
+   */
+  exchangeBaseUrl: string;
+
+  /**
+   * If missing, the notification means that
+   * the exchange entry is newly created.
+   */
+  oldExchangeState?: ExchangeEntryState;
+
+  newExchangeState: ExchangeEntryState;
+
+  errorInfo?: ErrorInfoSummary;
 }
 
 export interface BalanceChangeNotification {
   type: NotificationType.BalanceChange;
 }
 
-export interface ExchangeOperationErrorNotification {
-  type: NotificationType.ExchangeOperationError;
-  error: TalerErrorDetail;
-}
-
 export interface BackupOperationErrorNotification {
   type: NotificationType.BackupOperationError;
   error: TalerErrorDetail;
@@ -80,12 +89,12 @@ export interface BackupOperationErrorNotification {
 export interface PendingOperationProcessedNotification {
   type: NotificationType.PendingOperationProcessed;
   id: string;
+  taskResultType: string;
 }
 
 export type WalletNotification =
   | BalanceChangeNotification
   | BackupOperationErrorNotification
-  | ExchangeAddedNotification
-  | ExchangeOperationErrorNotification
+  | ExchangeStateTransitionNotification
   | PendingOperationProcessedNotification
   | TransactionStateTransitionNotification;
diff --git a/packages/taler-util/src/wallet-types.ts 
b/packages/taler-util/src/wallet-types.ts
index 82c58246a..aa498c409 100644
--- a/packages/taler-util/src/wallet-types.ts
+++ b/packages/taler-util/src/wallet-types.ts
@@ -1289,12 +1289,11 @@ export enum ExchangeEntryStatus {
 
 export enum ExchangeUpdateStatus {
   Initial = "initial",
-  InitialUpdate = "initial(update)",
+  InitialUpdate = "initial-update",
   Suspended = "suspended",
-  Failed = "failed",
-  OutdatedUpdate = "outdated(update)",
+  UnavailableUpdate = "unavailable-update",
   Ready = "ready",
-  ReadyUpdate = "ready(update)",
+  ReadyUpdate = "ready-update",
 }
 
 export interface OperationErrorInfo {
@@ -1645,15 +1644,11 @@ export type GetExchangeEntryByUrlResponse = 
ExchangeListItem;
 
 export interface AddExchangeRequest {
   exchangeBaseUrl: string;
-  masterPub?: string;
-  forceUpdate?: boolean;
 }
 
 export const codecForAddExchangeRequest = (): Codec<AddExchangeRequest> =>
   buildCodecForObject<AddExchangeRequest>()
     .property("exchangeBaseUrl", codecForString())
-    .property("forceUpdate", codecOptional(codecForBoolean()))
-    .property("masterPub", codecOptional(codecForString()))
     .build("AddExchangeRequest");
 
 export interface UpdateExchangeEntryRequest {
@@ -2875,3 +2870,9 @@ export interface PrepareWithdrawExchangeResponse {
    */
   amount?: AmountString;
 }
+
+export interface ExchangeEntryState {
+  tosStatus: ExchangeTosStatus;
+  exchangeEntryStatus: ExchangeEntryStatus;
+  exchangeUpdateStatus: ExchangeUpdateStatus;
+}
diff --git a/packages/taler-wallet-cli/src/index.ts 
b/packages/taler-wallet-cli/src/index.ts
index ea250de19..8a8f9737a 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -800,9 +800,8 @@ exchangesCli
   .flag("force", ["-f", "--force"])
   .action(async (args) => {
     await withWallet(args, async (wallet) => {
-      await wallet.client.call(WalletApiOperation.AddExchange, {
+      await wallet.client.call(WalletApiOperation.UpdateExchangeEntry, {
         exchangeBaseUrl: args.exchangesUpdateCmd.url,
-        forceUpdate: args.exchangesUpdateCmd.force,
       });
     });
   });
diff --git a/packages/taler-wallet-core/src/db.ts 
b/packages/taler-wallet-core/src/db.ts
index 6b1fc2f5f..d2c6b8368 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -569,21 +569,6 @@ export interface ExchangeDetailsRecord {
    */
   globalFees: ExchangeGlobalFees[];
 
-  /**
-   * Etag of the current ToS of the exchange.
-   */
-  tosCurrentEtag: string;
-
-  /**
-   * Information about ToS acceptance from the user.
-   */
-  tosAccepted:
-    | {
-        etag: string;
-        timestamp: DbPreciseTimestamp;
-      }
-    | undefined;
-
   wireInfo: WireInfo;
 
   /**
@@ -615,8 +600,8 @@ export enum ExchangeEntryDbUpdateStatus {
   Initial = 1,
   InitialUpdate = 2,
   Suspended = 3,
-  Failed = 4,
-  OutdatedUpdate = 5,
+  UnavailableUpdate = 4,
+  // Reserved 5 for backwards compatibility.
   Ready = 6,
   ReadyUpdate = 7,
 }
@@ -659,6 +644,15 @@ export interface ExchangeEntryRecord {
 
   updateStatus: ExchangeEntryDbUpdateStatus;
 
+  /**
+   * Etag of the current ToS of the exchange.
+   */
+  tosCurrentEtag: string | undefined;
+
+  tosAcceptedEtag: string | undefined;
+
+  tosAcceptedTimestamp: DbPreciseTimestamp | undefined;
+
   /**
    * Last time when the exchange /keys info was updated.
    */
diff --git a/packages/taler-wallet-core/src/dbless.ts 
b/packages/taler-wallet-core/src/dbless.ts
index 7f4e9ca9b..e841d1d20 100644
--- a/packages/taler-wallet-core/src/dbless.ts
+++ b/packages/taler-wallet-core/src/dbless.ts
@@ -59,8 +59,11 @@ import {
 } from "@gnu-taler/taler-util/http";
 import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
 import { DenominationRecord } from "./db.js";
-import { isWithdrawableDenom } from "./index.js";
-import { ExchangeInfo } from "./operations/exchanges.js";
+import {
+  ExchangeInfo,
+  ExchangeKeysDownloadResult,
+  isWithdrawableDenom,
+} from "./index.js";
 import { assembleRefreshRevealRequest } from "./operations/refresh.js";
 import {
   getBankStatusUrl,
diff --git a/packages/taler-wallet-core/src/operations/common.ts 
b/packages/taler-wallet-core/src/operations/common.ts
index 6ab6a54d9..abba3f7a7 100644
--- a/packages/taler-wallet-core/src/operations/common.ts
+++ b/packages/taler-wallet-core/src/operations/common.ts
@@ -26,6 +26,7 @@ import {
   CoinRefreshRequest,
   CoinStatus,
   Duration,
+  ExchangeEntryState,
   ExchangeEntryStatus,
   ExchangeListItem,
   ExchangeTosStatus,
@@ -75,7 +76,10 @@ import { PendingTaskType, TaskId } from 
"../pending-types.js";
 import { assertUnreachable } from "../util/assertUnreachable.js";
 import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
 import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
-import { constructTransactionIdentifier } from "./transactions.js";
+import {
+  constructTransactionIdentifier,
+  parseTransactionIdentifier,
+} from "./transactions.js";
 
 const logger = new Logger("operations/common.ts");
 
@@ -320,11 +324,7 @@ function convertTaskToTransactionId(
   }
 }
 
-/**
- * For tasks that process a transaction,
- * generate a state transition notification.
- */
-async function taskToTransactionNotification(
+async function makeTransactionRetryNotification(
   ws: InternalWalletState,
   tx: GetReadOnlyAccess<typeof WalletStoresV1>,
   pendingTaskId: string,
@@ -353,6 +353,75 @@ async function taskToTransactionNotification(
   return notif;
 }
 
+async function makeExchangeRetryNotification(
+  ws: InternalWalletState,
+  tx: GetReadOnlyAccess<typeof WalletStoresV1>,
+  pendingTaskId: string,
+  e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+  logger.info("making exchange retry notification");
+  const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+  if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) {
+    throw Error("invalid task identifier");
+  }
+  const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl);
+
+  if (!rec) {
+    logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`);
+    return undefined;
+  }
+
+  const notif: WalletNotification = {
+    type: NotificationType.ExchangeStateTransition,
+    exchangeBaseUrl: parsedTaskId.exchangeBaseUrl,
+    oldExchangeState: getExchangeState(rec),
+    newExchangeState: getExchangeState(rec),
+  };
+  if (e) {
+    notif.errorInfo = {
+      code: e.code as number,
+      hint: e.hint,
+    };
+  }
+  return notif;
+}
+
+/**
+ * Generate an appropriate error transition notification
+ * for applicable tasks.
+ *
+ * Namely, transition notifications are generated for:
+ * - exchange update errors
+ * - transactions
+ */
+async function taskToRetryNotification(
+  ws: InternalWalletState,
+  tx: GetReadOnlyAccess<typeof WalletStoresV1>,
+  pendingTaskId: string,
+  e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+  const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+
+  switch (parsedTaskId.tag) {
+    case PendingTaskType.ExchangeUpdate:
+      return makeExchangeRetryNotification(ws, tx, pendingTaskId, e);
+    case PendingTaskType.PeerPullCredit:
+    case PendingTaskType.PeerPullDebit:
+    case PendingTaskType.Withdraw:
+    case PendingTaskType.PeerPushCredit:
+    case PendingTaskType.Deposit:
+    case PendingTaskType.Refresh:
+    case PendingTaskType.RewardPickup:
+    case PendingTaskType.PeerPushDebit:
+    case PendingTaskType.Purchase:
+      return makeTransactionRetryNotification(ws, tx, pendingTaskId, e);
+    case PendingTaskType.Backup:
+    case PendingTaskType.ExchangeCheckRefresh:
+    case PendingTaskType.Recoup:
+      return undefined;
+  }
+}
+
 async function storePendingTaskError(
   ws: InternalWalletState,
   pendingTaskId: string,
@@ -372,7 +441,7 @@ async function storePendingTaskError(
       retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
     }
     await tx.operationRetries.put(retryRecord);
-    return taskToTransactionNotification(ws, tx, pendingTaskId, e);
+    return taskToRetryNotification(ws, tx, pendingTaskId, e);
   });
   if (maybeNotification) {
     ws.notify(maybeNotification);
@@ -391,7 +460,7 @@ export async function resetPendingTaskTimeout(
       retryRecord.retryInfo = DbRetryInfo.reset();
       await tx.operationRetries.put(retryRecord);
     }
-    return taskToTransactionNotification(ws, tx, pendingTaskId, undefined);
+    return taskToRetryNotification(ws, tx, pendingTaskId, undefined);
   });
   if (maybeNotification) {
     ws.notify(maybeNotification);
@@ -419,7 +488,7 @@ async function storePendingTaskPending(
     }
     await tx.operationRetries.put(retryRecord);
     if (hadError) {
-      return taskToTransactionNotification(ws, tx, pendingTaskId, undefined);
+      return taskToRetryNotification(ws, tx, pendingTaskId, undefined);
     } else {
       return undefined;
     }
@@ -532,66 +601,72 @@ export enum TombstoneTag {
   DeletePeerPushCredit = "delete-peer-push-credit",
 }
 
-export function getExchangeTosStatus(
-  exchangeDetails: ExchangeDetailsRecord,
+export function getExchangeTosStatusFromRecord(
+  exchange: ExchangeEntryRecord,
 ): ExchangeTosStatus {
-  if (!exchangeDetails.tosAccepted) {
+  if (!exchange.tosAcceptedEtag) {
     return ExchangeTosStatus.Proposed;
   }
-  if (exchangeDetails.tosAccepted?.etag == exchangeDetails.tosCurrentEtag) {
+  if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) {
     return ExchangeTosStatus.Accepted;
   }
   return ExchangeTosStatus.Proposed;
 }
 
-export function makeExchangeListItem(
+export function getExchangeUpdateStatusFromRecord(
   r: ExchangeEntryRecord,
-  exchangeDetails: ExchangeDetailsRecord | undefined,
-  lastError: TalerErrorDetail | undefined,
-): ExchangeListItem {
-  const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
-    ? {
-        error: lastError,
-      }
-    : undefined;
-
-  let exchangeUpdateStatus: ExchangeUpdateStatus;
+): ExchangeUpdateStatus {
   switch (r.updateStatus) {
-    case ExchangeEntryDbUpdateStatus.Failed:
-      exchangeUpdateStatus = ExchangeUpdateStatus.Failed;
-      break;
+    case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+      return ExchangeUpdateStatus.UnavailableUpdate;
     case ExchangeEntryDbUpdateStatus.Initial:
-      exchangeUpdateStatus = ExchangeUpdateStatus.Initial;
-      break;
+      return ExchangeUpdateStatus.Initial;
     case ExchangeEntryDbUpdateStatus.InitialUpdate:
-      exchangeUpdateStatus = ExchangeUpdateStatus.InitialUpdate;
-      break;
-    case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
-      exchangeUpdateStatus = ExchangeUpdateStatus.OutdatedUpdate;
-      break;
+      return ExchangeUpdateStatus.InitialUpdate;
     case ExchangeEntryDbUpdateStatus.Ready:
-      exchangeUpdateStatus = ExchangeUpdateStatus.Ready;
-      break;
+      return ExchangeUpdateStatus.Ready;
     case ExchangeEntryDbUpdateStatus.ReadyUpdate:
-      exchangeUpdateStatus = ExchangeUpdateStatus.ReadyUpdate;
-      break;
+      return ExchangeUpdateStatus.ReadyUpdate;
     case ExchangeEntryDbUpdateStatus.Suspended:
-      exchangeUpdateStatus = ExchangeUpdateStatus.Suspended;
-      break;
+      return ExchangeUpdateStatus.Suspended;
   }
+}
 
-  let exchangeEntryStatus: ExchangeEntryStatus;
+export function getExchangeEntryStatusFromRecord(
+  r: ExchangeEntryRecord,
+): ExchangeEntryStatus {
   switch (r.entryStatus) {
     case ExchangeEntryDbRecordStatus.Ephemeral:
-      exchangeEntryStatus = ExchangeEntryStatus.Ephemeral;
-      break;
+      return ExchangeEntryStatus.Ephemeral;
     case ExchangeEntryDbRecordStatus.Preset:
-      exchangeEntryStatus = ExchangeEntryStatus.Preset;
-      break;
+      return ExchangeEntryStatus.Preset;
     case ExchangeEntryDbRecordStatus.Used:
-      exchangeEntryStatus = ExchangeEntryStatus.Used;
-      break;
+      return ExchangeEntryStatus.Used;
   }
+}
+
+/**
+ * Compute the state of an exchange entry from the DB
+ * record.
+ */
+export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
+  return {
+    exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+    exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+    tosStatus: getExchangeTosStatusFromRecord(r),
+  };
+}
+
+export function makeExchangeListItem(
+  r: ExchangeEntryRecord,
+  exchangeDetails: ExchangeDetailsRecord | undefined,
+  lastError: TalerErrorDetail | undefined,
+): ExchangeListItem {
+  const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
+    ? {
+        error: lastError,
+      }
+    : undefined;
 
   let scopeInfo: ScopeInfo | undefined = undefined;
   if (exchangeDetails) {
@@ -606,11 +681,9 @@ export function makeExchangeListItem(
   return {
     exchangeBaseUrl: r.baseUrl,
     currency: exchangeDetails?.currency ?? r.presetCurrencyHint,
-    exchangeUpdateStatus,
-    exchangeEntryStatus,
-    tosStatus: exchangeDetails
-      ? getExchangeTosStatus(exchangeDetails)
-      : ExchangeTosStatus.Pending,
+    exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+    exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+    tosStatus: getExchangeTosStatusFromRecord(r),
     ageRestrictionOptions: exchangeDetails?.ageMask
       ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
       : [],
@@ -852,7 +925,6 @@ export type ParsedTaskIdentifier =
   | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
   | { tag: PendingTaskType.Deposit; depositGroupId: string }
   | { tag: PendingTaskType.ExchangeCheckRefresh; exchangeBaseUrl: string }
-  | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
   | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
   | { tag: PendingTaskType.PeerPullCredit; pursePub: string }
   | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
@@ -872,13 +944,13 @@ export function parseTaskIdentifier(x: string): 
ParsedTaskIdentifier {
   const [type, ...rest] = task;
   switch (type) {
     case PendingTaskType.Backup:
-      return { tag: type, backupProviderBaseUrl: rest[0] };
+      return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
     case PendingTaskType.Deposit:
       return { tag: type, depositGroupId: rest[0] };
     case PendingTaskType.ExchangeCheckRefresh:
-      return { tag: type, exchangeBaseUrl: rest[0] };
+      return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
     case PendingTaskType.ExchangeUpdate:
-      return { tag: type, exchangeBaseUrl: rest[0] };
+      return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
     case PendingTaskType.PeerPullCredit:
       return { tag: type, pursePub: rest[0] };
     case PendingTaskType.PeerPullDebit:
@@ -940,13 +1012,19 @@ export namespace TaskIdentifiers {
     return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId;
   }
   export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId {
-    return `${PendingTaskType.ExchangeUpdate}:${exch.baseUrl}` as TaskId;
+    return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+      exch.baseUrl,
+    )}` as TaskId;
   }
   export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId {
-    return `${PendingTaskType.ExchangeUpdate}:${exchBaseUrl}` as TaskId;
+    return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+      exchBaseUrl,
+    )}` as TaskId;
   }
   export function forExchangeCheckRefresh(exch: ExchangeEntryRecord): TaskId {
-    return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}` as TaskId;
+    return `${PendingTaskType.ExchangeCheckRefresh}:${encodeURIComponent(
+      exch.baseUrl,
+    )}` as TaskId;
   }
   export function forTipPickup(tipRecord: RewardRecord): TaskId {
     return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as 
TaskId;
@@ -964,7 +1042,9 @@ export namespace TaskIdentifiers {
     return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as 
TaskId;
   }
   export function forBackup(backupRecord: BackupProviderRecord): TaskId {
-    return `${PendingTaskType.Backup}:${backupRecord.baseUrl}` as TaskId;
+    return `${PendingTaskType.Backup}:${encodeURIComponent(
+      backupRecord.baseUrl,
+    )}` as TaskId;
   }
   export function forPeerPushPaymentInitiation(
     ppi: PeerPushDebitRecord,
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts 
b/packages/taler-wallet-core/src/operations/exchanges.ts
index 622f04bd3..253801e93 100644
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -14,6 +14,12 @@
  GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
  */
 
+/**
+ * @fileoverview
+ * Implementation of exchange entry management in wallet-core.
+ * The details of exchange entry management are specified in DD48.
+ */
+
 /**
  * Imports.
  */
@@ -31,7 +37,9 @@ import {
   ExchangeAuditor,
   ExchangeGlobalFees,
   ExchangeSignKeyJson,
+  ExchangeEntryState,
   ExchangeWireAccount,
+  GetExchangeTosResult,
   GlobalFees,
   hashDenomPub,
   j2s,
@@ -48,10 +56,17 @@ import {
   TalerProtocolDuration,
   TalerProtocolTimestamp,
   URL,
+  WalletNotification,
   WireFee,
   WireFeeMap,
   WireFeesJson,
   WireInfo,
+  FeeDescription,
+  DenomOperationMap,
+  DenominationInfo,
+  ExchangeDetailedResponse,
+  ExchangeListItem,
+  ExchangesListResponse,
 } from "@gnu-taler/taler-util";
 import {
   getExpiry,
@@ -67,24 +82,29 @@ import {
   WalletStoresV1,
 } from "../db.js";
 import {
+  createTimeline,
   ExchangeEntryDbRecordStatus,
   ExchangeEntryDbUpdateStatus,
   isWithdrawableDenom,
+  OpenedPromise,
+  openPromise,
+  selectBestForOverlappingDenominations,
+  selectMinimumFee,
   timestampPreciseFromDb,
   timestampPreciseToDb,
   timestampProtocolToDb,
   WalletDbReadWriteTransaction,
 } from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
+import { CancelFn, InternalWalletState } from "../internal-wallet-state.js";
 import { checkDbInvariant } from "../util/invariants.js";
-import {
-  DbAccess,
-  GetReadOnlyAccess,
-  GetReadWriteAccess,
-} from "../util/query.js";
+import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
 import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
 import {
-  runTaskWithErrorReporting,
+  getExchangeEntryStatusFromRecord,
+  getExchangeState,
+  getExchangeTosStatusFromRecord,
+  getExchangeUpdateStatusFromRecord,
+  makeExchangeListItem,
   TaskIdentifiers,
   TaskRunResult,
   TaskRunResultType,
@@ -92,19 +112,19 @@ import {
 
 const logger = new Logger("exchanges.ts");
 
-export function getExchangeRequestTimeout(): Duration {
+function getExchangeRequestTimeout(): Duration {
   return Duration.fromSpec({
     seconds: 5,
   });
 }
 
-export interface ExchangeTosDownloadResult {
+interface ExchangeTosDownloadResult {
   tosText: string;
   tosEtag: string;
   tosContentType: string;
 }
 
-export async function downloadExchangeWithTermsOfService(
+async function downloadExchangeWithTermsOfService(
   exchangeBaseUrl: string,
   http: HttpRequestLibrary,
   timeout: Duration,
@@ -129,6 +149,8 @@ export async function downloadExchangeWithTermsOfService(
 
 /**
  * Get exchange details from the database.
+ *
+ * FIXME: Should we encapsulate the result better, instead of returning the 
raw DB records here?
  */
 export async function getExchangeDetails(
   tx: GetReadOnlyAccess<{
@@ -153,9 +175,6 @@ export async function getExchangeDetails(
   ]);
 }
 
-getExchangeDetails.makeContext = (db: DbAccess<typeof WalletStoresV1>) =>
-  db.mktx((x) => [x.exchanges, x.exchangeDetails]);
-
 /**
  * Mark a ToS version as accepted by the user.
  *
@@ -169,13 +188,13 @@ export async function acceptExchangeTermsOfService(
   await ws.db
     .mktx((x) => [x.exchanges, x.exchangeDetails])
     .runReadWrite(async (tx) => {
-      const d = await getExchangeDetails(tx, exchangeBaseUrl);
-      if (d) {
-        d.tosAccepted = {
-          etag: etag || d.tosCurrentEtag,
-          timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
-        };
-        await tx.exchangeDetails.put(d);
+      const exch = await tx.exchanges.get(exchangeBaseUrl);
+      if (exch && exch.tosCurrentEtag) {
+        exch.tosAcceptedEtag = exch.tosCurrentEtag;
+        exch.tosAcceptedTimestamp = timestampPreciseToDb(
+          TalerPreciseTimestamp.now(),
+        );
+        await tx.exchanges.put(exch);
       }
     });
 }
@@ -284,29 +303,18 @@ async function validateGlobalFees(
   return egf;
 }
 
-export interface ExchangeInfo {
-  keys: ExchangeKeysDownloadResult;
-}
-
-export async function downloadExchangeInfo(
-  exchangeBaseUrl: string,
-  http: HttpRequestLibrary,
-): Promise<ExchangeInfo> {
-  const keysInfo = await downloadExchangeKeysInfo(
-    exchangeBaseUrl,
-    http,
-    Duration.getForever(),
-  );
-  return {
-    keys: keysInfo,
-  };
-}
-
+/**
+ * Add an exchange entry to the wallet database in the
+ * entry state "preset".
+ *
+ * Returns the notification to the caller that should be emitted
+ * if the DB transaction succeeds.
+ */
 export async function addPresetExchangeEntry(
   tx: WalletDbReadWriteTransaction<"exchanges">,
   exchangeBaseUrl: string,
   currencyHint?: string,
-): Promise<void> {
+): Promise<{ notification?: WalletNotification }> {
   let exchange = await tx.exchanges.get(exchangeBaseUrl);
   if (!exchange) {
     const r: ExchangeEntryRecord = {
@@ -323,9 +331,22 @@ export async function addPresetExchangeEntry(
       nextUpdateStamp: timestampPreciseToDb(
         AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
       ),
+      tosAcceptedEtag: undefined,
+      tosAcceptedTimestamp: undefined,
+      tosCurrentEtag: undefined,
     };
     await tx.exchanges.put(r);
+    return {
+      notification: {
+        type: NotificationType.ExchangeStateTransition,
+        exchangeBaseUrl: exchangeBaseUrl,
+        // Exchange did not exist yet
+        oldExchangeState: undefined,
+        newExchangeState: getExchangeState(r),
+      },
+    };
   }
+  return {};
 }
 
 async function provideExchangeRecordInTx(
@@ -339,7 +360,9 @@ async function provideExchangeRecordInTx(
 ): Promise<{
   exchange: ExchangeEntryRecord;
   exchangeDetails: ExchangeDetailsRecord | undefined;
+  notification?: WalletNotification;
 }> {
+  let notification: WalletNotification | undefined = undefined;
   let exchange = await tx.exchanges.get(baseUrl);
   if (!exchange) {
     const r: ExchangeEntryRecord = {
@@ -355,15 +378,24 @@ async function provideExchangeRecordInTx(
         AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
       ),
       lastKeysEtag: undefined,
+      tosAcceptedEtag: undefined,
+      tosAcceptedTimestamp: undefined,
+      tosCurrentEtag: undefined,
     };
     await tx.exchanges.put(r);
     exchange = r;
+    notification = {
+      type: NotificationType.ExchangeStateTransition,
+      exchangeBaseUrl: r.baseUrl,
+      oldExchangeState: undefined,
+      newExchangeState: getExchangeState(r),
+    };
   }
   const exchangeDetails = await getExchangeDetails(tx, baseUrl);
-  return { exchange, exchangeDetails };
+  return { exchange, exchangeDetails, notification };
 }
 
-interface ExchangeKeysDownloadResult {
+export interface ExchangeKeysDownloadResult {
   baseUrl: string;
   masterPublicKey: string;
   currency: string;
@@ -393,28 +425,36 @@ async function downloadExchangeKeysInfo(
   const resp = await http.fetch(keysUrl.href, {
     timeout,
   });
-  const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
-    resp,
-    codecForExchangeKeysJson(),
-  );
 
-  if (exchangeKeysJsonUnchecked.denominations.length === 0) {
-    throw TalerError.fromDetail(
-      TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
-      {
-        exchangeBaseUrl: baseUrl,
-      },
-      "exchange doesn't offer any denominations",
-    );
-  }
+  // We must make sure to parse out the protocol version
+  // before we validate the body.
+  // Otherwise the parser might complain with a hard to understand
+  // message about some other field, when it is just a version
+  // incompatibility.
 
-  const protocolVersion = exchangeKeysJsonUnchecked.version;
+  const keysJson = await resp.json();
+
+  const protocolVersion = keysJson.version;
+  if (typeof protocolVersion !== "string") {
+    throw Error("bad exchange, does not even specify protocol version");
+  }
 
   const versionRes = LibtoolVersion.compare(
     WALLET_EXCHANGE_PROTOCOL_VERSION,
     protocolVersion,
   );
-  if (versionRes?.compatible != true) {
+  if (!versionRes) {
+    throw TalerError.fromDetail(
+      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+      {
+        requestUrl: resp.requestUrl,
+        httpStatusCode: resp.status,
+        requestMethod: resp.requestMethod,
+      },
+      "exchange protocol version malformed",
+    );
+  }
+  if (!versionRes.compatible) {
     throw TalerError.fromDetail(
       TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
       {
@@ -425,6 +465,21 @@ async function downloadExchangeKeysInfo(
     );
   }
 
+  const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
+    resp,
+    codecForExchangeKeysJson(),
+  );
+
+  if (exchangeKeysJsonUnchecked.denominations.length === 0) {
+    throw TalerError.fromDetail(
+      TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+      {
+        exchangeBaseUrl: baseUrl,
+      },
+      "exchange doesn't offer any denominations",
+    );
+  }
+
   const currency = exchangeKeysJsonUnchecked.currency;
 
   const currentDenominations: DenominationRecord[] = [];
@@ -512,7 +567,7 @@ async function downloadExchangeKeysInfo(
   };
 }
 
-export async function downloadTosFromAcceptedFormat(
+async function downloadTosFromAcceptedFormat(
   ws: InternalWalletState,
   baseUrl: string,
   timeout: Duration,
@@ -546,54 +601,225 @@ export async function downloadTosFromAcceptedFormat(
 }
 
 /**
- * FIXME: Split this into two parts: (a) triggering the exchange
- * to be updated and (b) waiting for the update to finish.
+ * Transition an exchange into an updating state.
+ *
+ * If the update is forced, the exchange is put into an updating state
+ * even if the old information should still be up to date.
+ *
+ * For backwards compatibility, if the exchange entry doesn't exist,
+ * a new ephemeral entry is created.
  */
-export async function updateExchangeFromUrl(
+export async function startUpdateExchangeEntry(
   ws: InternalWalletState,
-  baseUrl: string,
-  options: {
-    checkMasterPub?: string;
-    forceNow?: boolean;
-    cancellationToken?: CancellationToken;
-  } = {},
+  exchangeBaseUrl: string,
+  options: { forceUpdate?: boolean } = {},
+): Promise<void> {
+  const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+
+  const now = AbsoluteTime.now();
+
+  const { notification } = await ws.db
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
+    .runReadWrite(async (tx) => {
+      return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now);
+    });
+
+  if (notification) {
+    ws.notify(notification);
+  }
+
+  const { oldExchangeState, newExchangeState } = await ws.db
+    .mktx((x) => [x.exchanges, x.operationRetries])
+    .runReadWrite(async (tx) => {
+      const r = await tx.exchanges.get(canonBaseUrl);
+      if (!r) {
+        throw Error("exchange not found");
+      }
+      const oldExchangeState = getExchangeState(r);
+      switch (r.updateStatus) {
+        case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+          break;
+        case ExchangeEntryDbUpdateStatus.Suspended:
+          break;
+        case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+          break;
+        case ExchangeEntryDbUpdateStatus.Ready: {
+          const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
+            timestampPreciseFromDb(r.nextUpdateStamp),
+          );
+          // Only update if entry is outdated or update is forced.
+          if (
+            options.forceUpdate ||
+            AbsoluteTime.isExpired(nextUpdateTimestamp)
+          ) {
+            r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
+          }
+          break;
+        }
+        case ExchangeEntryDbUpdateStatus.Initial:
+          r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
+          break;
+      }
+      await tx.exchanges.put(r);
+      const newExchangeState = getExchangeState(r);
+      // Reset retries for updating the exchange entry.
+      const taskId = TaskIdentifiers.forExchangeUpdate(r);
+      await tx.operationRetries.delete(taskId);
+      return { oldExchangeState, newExchangeState };
+    });
+  ws.notify({
+    type: NotificationType.ExchangeStateTransition,
+    exchangeBaseUrl: canonBaseUrl,
+    newExchangeState: newExchangeState,
+    oldExchangeState: oldExchangeState,
+  });
+  ws.workAvailable.trigger();
+}
+
+export interface NotificationWaiter {
+  waitNext(): Promise<void>;
+  cancel(): void;
+}
+
+export function createNotificationWaiter(
+  ws: InternalWalletState,
+  pred: (x: WalletNotification) => boolean,
+): NotificationWaiter {
+  ws.ensureTaskLoopRunning();
+  let cancelFn: CancelFn | undefined = undefined;
+  let p: OpenedPromise<void> | undefined = undefined;
+
+  return {
+    cancel() {
+      cancelFn?.();
+    },
+    waitNext(): Promise<void> {
+      if (!p) {
+        p = openPromise();
+        cancelFn = ws.addNotificationListener((notif) => {
+          if (pred(notif)) {
+            // We got a notification that matches our predicate.
+            // Resolve promise for existing waiters,
+            // and create a new promise to wait for the next
+            // notification occurrence.
+            const myResolve = p?.resolve;
+            const myCancel = cancelFn;
+            p = undefined;
+            cancelFn = undefined;
+            myResolve?.();
+            myCancel?.();
+          }
+        });
+      }
+      return p.promise;
+    },
+  };
+}
+
+/**
+ * Wait until an exchange entry got successfully updated.
+ *
+ * Reject with an exception if the update encountered an error.
+ */
+export async function waitExchangeEntryUpdated(
+  ws: InternalWalletState,
+  exchangeBaseUrl: string,
+  cancellationToken?: CancellationToken,
 ): Promise<{
   exchange: ExchangeEntryRecord;
   exchangeDetails: ExchangeDetailsRecord;
 }> {
-  const canonUrl = canonicalizeBaseUrl(baseUrl);
-  const res = await runTaskWithErrorReporting(
+  exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+
+  const waiter = createNotificationWaiter(
     ws,
-    TaskIdentifiers.forExchangeUpdateFromUrl(canonUrl),
-    () => updateExchangeFromUrlHandler(ws, canonUrl, options),
+    (notif) =>
+      notif.type == NotificationType.ExchangeStateTransition &&
+      notif.exchangeBaseUrl === exchangeBaseUrl,
   );
-  switch (res.type) {
-    case TaskRunResultType.Finished: {
-      const now = AbsoluteTime.now();
-      const { exchange, exchangeDetails } = await ws.db
-        .mktx((x) => [x.exchanges, x.exchangeDetails])
-        .runReadWrite(async (tx) => {
-          let exchange = await tx.exchanges.get(canonUrl);
-          const exchangeDetails = await getExchangeDetails(tx, canonUrl);
-          return { exchange, exchangeDetails };
-        });
-      if (!exchange) {
-        throw Error("exchange not found");
-      }
-      if (!exchangeDetails) {
-        throw Error("exchange details not found");
+
+  const taskId = TaskIdentifiers.forExchangeUpdateFromUrl(exchangeBaseUrl);
+
+  while (1) {
+    const { exchange, retryRecord } = await ws.db
+      .mktx((x) => [x.exchanges, x.exchangeDetails, x.operationRetries])
+      .runReadOnly(async (tx) => {
+        const exchange = await tx.exchanges.get(exchangeBaseUrl);
+        const retryRecord = await tx.operationRetries.get(taskId);
+        return { exchange, retryRecord };
+      });
+
+    if (!exchange) {
+      throw Error("exchange does not exist anymore");
+    }
+
+    switch (exchange.updateStatus) {
+      case ExchangeEntryDbUpdateStatus.Ready:
+        const details = await ws.db
+          .mktx((x) => [x.exchanges, x.exchangeDetails])
+          .runReadOnly(async (tx) => {
+            return getExchangeDetails(tx, exchangeBaseUrl);
+          });
+        if (!details) {
+          throw Error("exchange entry inconsistent");
+        }
+        waiter.cancel();
+        return { exchange, exchangeDetails: details };
+      case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+      case ExchangeEntryDbUpdateStatus.InitialUpdate: {
+        waiter.cancel();
+        if (retryRecord?.lastError) {
+          throw TalerError.fromUncheckedDetail(retryRecord.lastError);
+        }
+        break;
       }
-      return { exchange, exchangeDetails };
+      case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+        waiter.cancel();
+        if (retryRecord?.lastError) {
+          throw TalerError.fromUncheckedDetail(retryRecord.lastError);
+        } else {
+          throw Error(
+            "updating exchange failed, error info unavailable (bug!)",
+          );
+        }
     }
-    case TaskRunResultType.Error:
-      throw TalerError.fromUncheckedDetail(res.errorDetail);
-    default:
-      throw Error(`unexpected operation result (${res.type})`);
+
+    await waiter.waitNext();
   }
+  throw Error("not reached");
 }
 
 /**
- * Update or add exchange DB entry by fetching the /keys and /wire information.
+ * Ensure that a fresh exchange entry exists for the given
+ * exchange base URL.
+ *
+ * The cancellation token can be used to abort waiting for the
+ * updated exchange entry.
+ *
+ * If an exchange entry for the database doesn't exist in the
+ * DB, it will be added ephemerally.
+ */
+export async function fetchFreshExchange(
+  ws: InternalWalletState,
+  baseUrl: string,
+  options: {
+    cancellationToken?: CancellationToken;
+    forceUpdate?: boolean;
+  } = {},
+): Promise<{
+  exchange: ExchangeEntryRecord;
+  exchangeDetails: ExchangeDetailsRecord;
+}> {
+  const canonUrl = canonicalizeBaseUrl(baseUrl);
+  await startUpdateExchangeEntry(ws, canonUrl, {
+    forceUpdate: options.forceUpdate,
+  });
+  return waitExchangeEntryUpdated(ws, canonUrl, options.cancellationToken);
+}
+
+/**
+ * Update an exchange entry in the wallet's database
+ * by fetching the /keys and /wire information.
  * Optionally link the reserve entry to the new or existing
  * exchange entry in then DB.
  */
@@ -601,48 +827,11 @@ export async function updateExchangeFromUrlHandler(
   ws: InternalWalletState,
   exchangeBaseUrl: string,
   options: {
-    checkMasterPub?: string;
-    forceNow?: boolean;
     cancellationToken?: CancellationToken;
   } = {},
 ): Promise<TaskRunResult> {
-  const forceNow = options.forceNow ?? false;
-  logger.trace(
-    `updating exchange info for ${exchangeBaseUrl}, forced: ${forceNow}`,
-  );
-
-  const now = AbsoluteTime.now();
+  logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
   exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-  let isNewExchange = true;
-  const { exchange, exchangeDetails } = await ws.db
-    .mktx((x) => [x.exchanges, x.exchangeDetails])
-    .runReadWrite(async (tx) => {
-      let oldExch = await tx.exchanges.get(exchangeBaseUrl);
-      if (oldExch) {
-        isNewExchange = false;
-      }
-      return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now);
-    });
-
-  if (
-    !forceNow &&
-    exchangeDetails !== undefined &&
-    !AbsoluteTime.isExpired(
-      AbsoluteTime.fromPreciseTimestamp(
-        timestampPreciseFromDb(exchange.nextUpdateStamp),
-      ),
-    )
-  ) {
-    logger.trace("using existing exchange info");
-
-    if (options.checkMasterPub) {
-      if (exchangeDetails.masterPublicKey !== options.checkMasterPub) {
-        throw Error(`master public key mismatch`);
-      }
-    }
-
-    return TaskRunResult.finished();
-  }
 
   logger.trace("updating exchange /keys info");
 
@@ -654,12 +843,6 @@ export async function updateExchangeFromUrlHandler(
     timeout,
   );
 
-  if (options.checkMasterPub) {
-    if (keysInfo.masterPublicKey !== options.checkMasterPub) {
-      throw Error(`master public key mismatch`);
-    }
-  }
-
   logger.trace("validating exchange wire info");
 
   const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
@@ -740,6 +923,7 @@ export async function updateExchangeFromUrlHandler(
         logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
         return;
       }
+      const oldExchangeState = getExchangeState(r);
       const existingDetails = await getExchangeDetails(tx, r.baseUrl);
       if (!existingDetails) {
         detailsPointerChanged = true;
@@ -753,7 +937,6 @@ export async function updateExchangeFromUrlHandler(
         }
         // FIXME: We need to do some consistency checks!
       }
-      const existingTosAccepted = existingDetails?.tosAccepted;
       const newDetails: ExchangeDetailsRecord = {
         auditors: keysInfo.auditors,
         currency: keysInfo.currency,
@@ -763,10 +946,9 @@ export async function updateExchangeFromUrlHandler(
         globalFees,
         exchangeBaseUrl: r.baseUrl,
         wireInfo,
-        tosCurrentEtag: tosDownload.tosEtag,
-        tosAccepted: existingTosAccepted,
         ageMask,
       };
+      r.tosCurrentEtag = tosDownload.tosEtag;
       if (existingDetails?.rowId) {
         newDetails.rowId = existingDetails.rowId;
       }
@@ -787,6 +969,7 @@ export async function updateExchangeFromUrlHandler(
           updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
         };
       }
+      r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
       await tx.exchanges.put(r);
       const drRowId = await tx.exchangeDetails.put(newDetails);
       checkDbInvariant(typeof drRowId.key === "number");
@@ -881,14 +1064,18 @@ export async function updateExchangeFromUrlHandler(
         recoupGroupId = await ws.recoupOps.createRecoupGroup(
           ws,
           tx,
-          exchange.baseUrl,
+          exchangeBaseUrl,
           newlyRevokedCoinPubs,
         );
       }
 
+      const newExchangeState = getExchangeState(r);
+
       return {
         exchange: r,
         exchangeDetails: newDetails,
+        oldExchangeState,
+        newExchangeState,
       };
     });
 
@@ -904,11 +1091,12 @@ export async function updateExchangeFromUrlHandler(
 
   logger.trace("done updating exchange info in database");
 
-  if (isNewExchange) {
-    ws.notify({
-      type: NotificationType.ExchangeAdded,
-    });
-  }
+  ws.notify({
+    type: NotificationType.ExchangeStateTransition,
+    exchangeBaseUrl,
+    newExchangeState: updated.newExchangeState,
+    oldExchangeState: updated.oldExchangeState,
+  });
 
   return TaskRunResult.finished();
 }
@@ -926,8 +1114,8 @@ export async function getExchangePaytoUri(
 ): Promise<string> {
   // We do the update here, since the exchange might not even exist
   // yet in our database.
-  const details = await getExchangeDetails
-    .makeContext(ws.db)
+  const details = await ws.db
+    .mktx((x) => [x.exchangeDetails, x.exchanges])
     .runReadOnly(async (tx) => {
       return getExchangeDetails(tx, exchangeBaseUrl);
     });
@@ -947,3 +1135,246 @@ export async function getExchangePaytoUri(
     )}`,
   );
 }
+
+/**
+ * Get the exchange ToS in the requested format.
+ * Try to download in the accepted format not cached.
+ */
+export async function getExchangeTos(
+  ws: InternalWalletState,
+  exchangeBaseUrl: string,
+  acceptedFormat?: string[],
+): Promise<GetExchangeTosResult> {
+  // FIXME: download ToS in acceptable format if passed!
+  const { exchange, exchangeDetails } = await fetchFreshExchange(
+    ws,
+    exchangeBaseUrl,
+  );
+
+  const tosDownload = await downloadTosFromAcceptedFormat(
+    ws,
+    exchangeBaseUrl,
+    getExchangeRequestTimeout(),
+    acceptedFormat,
+  );
+
+  await ws.db
+    .mktx((x) => [x.exchanges, x.exchangeDetails])
+    .runReadWrite(async (tx) => {
+      const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
+      if (updateExchangeEntry) {
+        updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
+        await tx.exchanges.put(updateExchangeEntry);
+      }
+    });
+
+  return {
+    acceptedEtag: exchange.tosAcceptedEtag,
+    currentEtag: tosDownload.tosEtag,
+    content: tosDownload.tosText,
+    contentType: tosDownload.tosContentType,
+    tosStatus: getExchangeTosStatusFromRecord(exchange),
+  };
+}
+
+export interface ExchangeInfo {
+  keys: ExchangeKeysDownloadResult;
+}
+
+/**
+ * Helper function to download the exchange /keys info.
+ *
+ * Only used for testing / dbless wallet.
+ */
+export async function downloadExchangeInfo(
+  exchangeBaseUrl: string,
+  http: HttpRequestLibrary,
+): Promise<ExchangeInfo> {
+  const keysInfo = await downloadExchangeKeysInfo(
+    exchangeBaseUrl,
+    http,
+    Duration.getForever(),
+  );
+  return {
+    keys: keysInfo,
+  };
+}
+
+export async function getExchanges(
+  ws: InternalWalletState,
+): Promise<ExchangesListResponse> {
+  const exchanges: ExchangeListItem[] = [];
+  await ws.db
+    .mktx((x) => [
+      x.exchanges,
+      x.exchangeDetails,
+      x.denominations,
+      x.operationRetries,
+    ])
+    .runReadOnly(async (tx) => {
+      const exchangeRecords = await tx.exchanges.iter().toArray();
+      for (const r of exchangeRecords) {
+        const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
+        const opRetryRecord = await tx.operationRetries.get(
+          TaskIdentifiers.forExchangeUpdate(r),
+        );
+        exchanges.push(
+          makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError),
+        );
+      }
+    });
+  return { exchanges };
+}
+
+export async function getExchangeDetailedInfo(
+  ws: InternalWalletState,
+  exchangeBaseurl: string,
+): Promise<ExchangeDetailedResponse> {
+  //TODO: should we use the forceUpdate parameter?
+  const exchange = await ws.db
+    .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
+    .runReadOnly(async (tx) => {
+      const ex = await tx.exchanges.get(exchangeBaseurl);
+      const dp = ex?.detailsPointer;
+      if (!dp) {
+        return;
+      }
+      const { currency } = dp;
+      const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl);
+      if (!exchangeDetails) {
+        return;
+      }
+
+      const denominationRecords =
+        await tx.denominations.indexes.byExchangeBaseUrl
+          .iter(ex.baseUrl)
+          .toArray();
+
+      if (!denominationRecords) {
+        return;
+      }
+
+      const denominations: DenominationInfo[] = denominationRecords.map((x) =>
+        DenominationRecord.toDenomInfo(x),
+      );
+
+      return {
+        info: {
+          exchangeBaseUrl: ex.baseUrl,
+          currency,
+          paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+          auditors: exchangeDetails.auditors,
+          wireInfo: exchangeDetails.wireInfo,
+          globalFees: exchangeDetails.globalFees,
+        },
+        denominations,
+      };
+    });
+
+  if (!exchange) {
+    throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
+  }
+
+  const denoms = exchange.denominations.map((d) => ({
+    ...d,
+    group: Amounts.stringifyValue(d.value),
+  }));
+  const denomFees: DenomOperationMap<FeeDescription[]> = {
+    deposit: createTimeline(
+      denoms,
+      "denomPubHash",
+      "stampStart",
+      "stampExpireDeposit",
+      "feeDeposit",
+      "group",
+      selectBestForOverlappingDenominations,
+    ),
+    refresh: createTimeline(
+      denoms,
+      "denomPubHash",
+      "stampStart",
+      "stampExpireWithdraw",
+      "feeRefresh",
+      "group",
+      selectBestForOverlappingDenominations,
+    ),
+    refund: createTimeline(
+      denoms,
+      "denomPubHash",
+      "stampStart",
+      "stampExpireWithdraw",
+      "feeRefund",
+      "group",
+      selectBestForOverlappingDenominations,
+    ),
+    withdraw: createTimeline(
+      denoms,
+      "denomPubHash",
+      "stampStart",
+      "stampExpireWithdraw",
+      "feeWithdraw",
+      "group",
+      selectBestForOverlappingDenominations,
+    ),
+  };
+
+  const transferFees = Object.entries(
+    exchange.info.wireInfo.feesForType,
+  ).reduce((prev, [wireType, infoForType]) => {
+    const feesByGroup = [
+      ...infoForType.map((w) => ({
+        ...w,
+        fee: Amounts.stringify(w.closingFee),
+        group: "closing",
+      })),
+      ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
+    ];
+    prev[wireType] = createTimeline(
+      feesByGroup,
+      "sig",
+      "startStamp",
+      "endStamp",
+      "fee",
+      "group",
+      selectMinimumFee,
+    );
+    return prev;
+  }, {} as Record<string, FeeDescription[]>);
+
+  const globalFeesByGroup = [
+    ...exchange.info.globalFees.map((w) => ({
+      ...w,
+      fee: w.accountFee,
+      group: "account",
+    })),
+    ...exchange.info.globalFees.map((w) => ({
+      ...w,
+      fee: w.historyFee,
+      group: "history",
+    })),
+    ...exchange.info.globalFees.map((w) => ({
+      ...w,
+      fee: w.purseFee,
+      group: "purse",
+    })),
+  ];
+
+  const globalFees = createTimeline(
+    globalFeesByGroup,
+    "signature",
+    "startDate",
+    "endDate",
+    "fee",
+    "group",
+    selectMinimumFee,
+  );
+
+  return {
+    exchange: {
+      ...exchange.info,
+      denomFees,
+      transferFees,
+      globalFees,
+    },
+  };
+}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts 
b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
index 44c9436b1..f8ab07b10 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
@@ -63,7 +63,7 @@ import {
   timestampOptionalPreciseFromDb,
   timestampPreciseFromDb,
   timestampPreciseToDb,
-  updateExchangeFromUrl,
+  fetchFreshExchange,
 } from "../index.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { PendingTaskType } from "../pending-types.js";
@@ -764,7 +764,7 @@ export async function initiatePeerPullPayment(
 
   const exchangeBaseUrl = maybeExchangeBaseUrl;
 
-  await updateExchangeFromUrl(ws, exchangeBaseUrl);
+  await fetchFreshExchange(ws, exchangeBaseUrl);
 
   const mergeReserveInfo = await getMergeReserveInfo(ws, {
     exchangeBaseUrl: exchangeBaseUrl,
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts 
b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
index 690edf2e7..575780ba4 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
@@ -69,7 +69,7 @@ import {
   constructTaskIdentifier,
   runLongpollAsync,
 } from "./common.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
+import { fetchFreshExchange } from "./exchanges.js";
 import {
   codecForExchangePurseStatus,
   getMergeReserveInfo,
@@ -141,7 +141,7 @@ export async function preparePeerPushCredit(
 
   const exchangeBaseUrl = uri.exchangeBaseUrl;
 
-  await updateExchangeFromUrl(ws, exchangeBaseUrl);
+  await fetchFreshExchange(ws, exchangeBaseUrl);
 
   const contractPriv = uri.contractPriv;
   const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
diff --git a/packages/taler-wallet-core/src/operations/pending.ts 
b/packages/taler-wallet-core/src/operations/pending.ts
index 282f84ad7..a9d6c5595 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -22,12 +22,17 @@
  * Imports.
  */
 import { GlobalIDB } from "@gnu-taler/idb-bridge";
-import { AbsoluteTime, TransactionRecordFilter } from "@gnu-taler/taler-util";
+import {
+  AbsoluteTime,
+  TalerErrorDetail,
+  TalerPreciseTimestamp,
+  TransactionRecordFilter,
+} from "@gnu-taler/taler-util";
 import {
   BackupProviderStateTag,
+  DbPreciseTimestamp,
   DepositElementStatus,
   DepositGroupRecord,
-  DepositOperationStatus,
   ExchangeEntryDbUpdateStatus,
   PeerPullCreditRecord,
   PeerPullDebitRecordStatus,
@@ -48,7 +53,6 @@ import {
   RewardRecordStatus,
   WalletStoresV1,
   WithdrawalGroupRecord,
-  WithdrawalGroupStatus,
   depositOperationNonfinalStatusRange,
   timestampAbsoluteFromDb,
   timestampOptionalAbsoluteFromDb,
@@ -94,18 +98,29 @@ async function gatherExchangePending(
   now: AbsoluteTime,
   resp: PendingOperationsResponse,
 ): Promise<void> {
-  // FIXME: We should do a range query here based on the update time
-  // and/or the entry state.
+  let timestampDue: DbPreciseTimestamp | undefined = undefined;
   await tx.exchanges.iter().forEachAsync(async (exch) => {
     switch (exch.updateStatus) {
       case ExchangeEntryDbUpdateStatus.Initial:
       case ExchangeEntryDbUpdateStatus.Suspended:
-      case ExchangeEntryDbUpdateStatus.Failed:
         return;
     }
     const opUpdateExchangeTag = TaskIdentifiers.forExchangeUpdate(exch);
     let opr = await tx.operationRetries.get(opUpdateExchangeTag);
-    const timestampDue = opr?.retryInfo.nextRetry ?? 
exch.nextRefreshCheckStamp;
+
+    switch (exch.updateStatus) {
+      case ExchangeEntryDbUpdateStatus.Ready:
+        timestampDue = opr?.retryInfo.nextRetry ?? exch.nextRefreshCheckStamp;
+        break;
+      case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+      case ExchangeEntryDbUpdateStatus.InitialUpdate:
+      case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+        timestampDue =
+          opr?.retryInfo.nextRetry ??
+          timestampPreciseToDb(TalerPreciseTimestamp.now());
+        break;
+    }
+
     resp.pendingOperations.push({
       type: PendingTaskType.ExchangeUpdate,
       ...getPendingCommon(
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts 
b/packages/taler-wallet-core/src/operations/refresh.ts
index 3afdd2d71..51dd9adac 100644
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -98,7 +98,11 @@ import {
   TaskRunResult,
   TaskRunResultType,
 } from "./common.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
+import {
+  fetchFreshExchange,
+  startUpdateExchangeEntry,
+  waitExchangeEntryUpdated,
+} from "./exchanges.js";
 import {
   constructTransactionIdentifier,
   notifyTransition,
@@ -221,7 +225,7 @@ async function provideRefreshSession(
 
   const { refreshGroup, coin } = d;
 
-  const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
+  const { exchange } = await fetchFreshExchange(ws, coin.exchangeBaseUrl);
   if (!exchange) {
     throw Error("db inconsistent: exchange of coin not found");
   }
@@ -1157,9 +1161,7 @@ export async function autoRefresh(
 
   // We must make sure that the exchange is up-to-date so that
   // can refresh into new denominations.
-  await updateExchangeFromUrl(ws, exchangeBaseUrl, {
-    forceNow: true,
-  });
+  await fetchFreshExchange(ws, exchangeBaseUrl);
 
   let minCheckThreshold = AbsoluteTime.addDuration(
     AbsoluteTime.now(),
diff --git a/packages/taler-wallet-core/src/operations/reward.ts 
b/packages/taler-wallet-core/src/operations/reward.ts
index 5d609f41d..90320d7cb 100644
--- a/packages/taler-wallet-core/src/operations/reward.ts
+++ b/packages/taler-wallet-core/src/operations/reward.ts
@@ -69,7 +69,7 @@ import {
   TaskRunResult,
   TaskRunResultType,
 } from "./common.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
+import { fetchFreshExchange } from "./exchanges.js";
 import {
   getCandidateWithdrawalDenoms,
   getExchangeWithdrawalInfo,
@@ -175,7 +175,7 @@ export async function prepareTip(
     const amount = Amounts.parseOrThrow(tipPickupStatus.reward_amount);
 
     logger.trace("new tip, creating tip record");
-    await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
+    await fetchFreshExchange(ws, tipPickupStatus.exchange_url);
 
     //FIXME: is this needed? withdrawDetails is not used
     // * if the intention is to update the exchange information in the database
diff --git a/packages/taler-wallet-core/src/operations/testing.ts 
b/packages/taler-wallet-core/src/operations/testing.ts
index b30c5f80b..a03d54d3a 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -58,7 +58,7 @@ import { OpenedPromise, openPromise } from "../index.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { checkLogicInvariant } from "../util/invariants.js";
 import { getBalances } from "./balance.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
+import { fetchFreshExchange } from "./exchanges.js";
 import {
   confirmPay,
   preparePayForUri,
@@ -579,7 +579,7 @@ export async function runIntegrationTest2(
   // waiting for notifications.
   logger.info("running test with arguments", args);
 
-  const exchangeInfo = await updateExchangeFromUrl(ws, args.exchangeBaseUrl);
+  const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl);
 
   const currency = exchangeInfo.exchangeDetails.currency;
 
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts 
b/packages/taler-wallet-core/src/operations/withdraw.ts
index b9ba3058f..e7ba6d820 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -135,7 +135,7 @@ import {
 import {
   getExchangeDetails,
   getExchangePaytoUri,
-  updateExchangeFromUrl,
+  fetchFreshExchange,
 } from "./exchanges.js";
 import {
   TransitionInfo,
@@ -1862,8 +1862,8 @@ export async function getExchangeWithdrawalInfo(
   }
 
   let tosAccepted = false;
-  if (exchangeDetails.tosAccepted?.timestamp) {
-    if (exchangeDetails.tosAccepted.etag === exchangeDetails.tosCurrentEtag) {
+  if (exchange.tosAcceptedTimestamp) {
+    if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
       tosAccepted = true;
     }
   }
@@ -2372,7 +2372,7 @@ export async function 
internalPrepareCreateWithdrawalGroup(
     wgInfo: args.wgInfo,
   };
 
-  const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange);
+  const exchangeInfo = await fetchFreshExchange(ws, canonExchange);
   const exchangeDetails = exchangeInfo.exchangeDetails;
   const transactionId = constructTransactionIdentifier({
     tag: TransactionType.Withdrawal,
@@ -2515,7 +2515,7 @@ export async function acceptWithdrawalFromUri(
     };
   }
 
-  await updateExchangeFromUrl(ws, selectedExchange);
+  await fetchFreshExchange(ws, selectedExchange);
   const withdrawInfo = await getBankWithdrawalInfo(
     ws.http,
     req.talerWithdrawUri,
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index 25cfd7f6f..c9612da5f 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -33,17 +33,10 @@ import {
   CoreApiResponse,
   CreateStoredBackupResponse,
   DeleteStoredBackupRequest,
-  DenomOperationMap,
   DenominationInfo,
   Duration,
-  ExchangeDetailedResponse,
-  ExchangeListItem,
-  ExchangesListResponse,
   ExchangesShortListResponse,
-  FeeDescription,
   GetCurrencySpecificationResponse,
-  GetExchangeEntryByUrlResponse,
-  GetExchangeTosResult,
   InitResponse,
   KnownBankAccounts,
   KnownBankAccountsInfo,
@@ -194,7 +187,6 @@ import { getBalanceDetail, getBalances } from 
"./operations/balance.js";
 import {
   TaskIdentifiers,
   TaskRunResult,
-  getExchangeTosStatus,
   makeExchangeListItem,
   runTaskWithErrorReporting,
 } from "./operations/common.js";
@@ -208,10 +200,11 @@ import {
 import {
   acceptExchangeTermsOfService,
   addPresetExchangeEntry,
-  downloadTosFromAcceptedFormat,
+  fetchFreshExchange,
+  getExchangeDetailedInfo,
   getExchangeDetails,
-  getExchangeRequestTimeout,
-  updateExchangeFromUrl,
+  getExchangeTos,
+  getExchanges,
   updateExchangeFromUrlHandler,
 } from "./operations/exchanges.js";
 import { getMerchantInfo } from "./operations/merchants.js";
@@ -295,11 +288,6 @@ import {
 } from "./operations/withdraw.js";
 import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
 import { assertUnreachable } from "./util/assertUnreachable.js";
-import {
-  createTimeline,
-  selectBestForOverlappingDenominations,
-  selectMinimumFee,
-} from "./util/denominations.js";
 import {
   convertDepositAmount,
   convertPeerPushAmount,
@@ -520,12 +508,13 @@ async function runTaskLoop(
           continue;
         }
         logger.trace(`running task ${p.id}`);
-        await runTaskWithErrorReporting(ws, p.id, async () => {
+        const res = await runTaskWithErrorReporting(ws, p.id, async () => {
           return await callOperationHandler(ws, p);
         });
         ws.notify({
           type: NotificationType.PendingOperationProcessed,
           id: p.id,
+          taskResultType: res.type,
         });
         if (ws.stopped) {
           ws.isTaskLoopRunning = false;
@@ -549,6 +538,7 @@ async function runTaskLoop(
  * already been applied.
  */
 async function fillDefaults(ws: InternalWalletState): Promise<void> {
+  const notifications: WalletNotification[] = [];
   await ws.db
     .mktx((x) => [x.config, x.exchanges, x.exchangeDetails])
     .runReadWrite(async (tx) => {
@@ -559,55 +549,23 @@ async function fillDefaults(ws: InternalWalletState): 
Promise<void> {
         return;
       }
       for (const exch of ws.config.builtin.exchanges) {
-        await addPresetExchangeEntry(
+        const resp = await addPresetExchangeEntry(
           tx,
           exch.exchangeBaseUrl,
           exch.currencyHint,
         );
+        if (resp.notification) {
+          notifications.push(resp.notification);
+        }
       }
       await tx.config.put({
         key: ConfigRecordKey.CurrencyDefaultsApplied,
         value: true,
       });
     });
-}
-
-/**
- * Get the exchange ToS in the requested format.
- * Try to download in the accepted format not cached.
- */
-async function getExchangeTos(
-  ws: InternalWalletState,
-  exchangeBaseUrl: string,
-  acceptedFormat?: string[],
-): Promise<GetExchangeTosResult> {
-  // FIXME: download ToS in acceptable format if passed!
-  const { exchangeDetails } = await updateExchangeFromUrl(ws, exchangeBaseUrl);
-
-  const tosDownload = await downloadTosFromAcceptedFormat(
-    ws,
-    exchangeBaseUrl,
-    getExchangeRequestTimeout(),
-    acceptedFormat,
-  );
-
-  await ws.db
-    .mktx((x) => [x.exchanges, x.exchangeDetails])
-    .runReadWrite(async (tx) => {
-      const d = await getExchangeDetails(tx, exchangeBaseUrl);
-      if (d) {
-        d.tosCurrentEtag = tosDownload.tosEtag;
-        await tx.exchangeDetails.put(d);
-      }
-    });
-
-  return {
-    acceptedEtag: exchangeDetails.tosAccepted?.etag,
-    currentEtag: tosDownload.tosEtag,
-    content: tosDownload.tosText,
-    contentType: tosDownload.tosContentType,
-    tosStatus: getExchangeTosStatus(exchangeDetails),
-  };
+  for (const notif of notifications) {
+    ws.notify(notif);
+  }
 }
 
 /**
@@ -680,185 +638,6 @@ async function forgetKnownBankAccounts(
   return;
 }
 
-async function getExchanges(
-  ws: InternalWalletState,
-): Promise<ExchangesListResponse> {
-  const exchanges: ExchangeListItem[] = [];
-  await ws.db
-    .mktx((x) => [
-      x.exchanges,
-      x.exchangeDetails,
-      x.denominations,
-      x.operationRetries,
-    ])
-    .runReadOnly(async (tx) => {
-      const exchangeRecords = await tx.exchanges.iter().toArray();
-      for (const r of exchangeRecords) {
-        const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
-        const opRetryRecord = await tx.operationRetries.get(
-          TaskIdentifiers.forExchangeUpdate(r),
-        );
-        exchanges.push(
-          makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError),
-        );
-      }
-    });
-  return { exchanges };
-}
-
-async function getExchangeDetailedInfo(
-  ws: InternalWalletState,
-  exchangeBaseurl: string,
-): Promise<ExchangeDetailedResponse> {
-  //TODO: should we use the forceUpdate parameter?
-  const exchange = await ws.db
-    .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
-    .runReadOnly(async (tx) => {
-      const ex = await tx.exchanges.get(exchangeBaseurl);
-      const dp = ex?.detailsPointer;
-      if (!dp) {
-        return;
-      }
-      const { currency } = dp;
-      const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl);
-      if (!exchangeDetails) {
-        return;
-      }
-
-      const denominationRecords =
-        await tx.denominations.indexes.byExchangeBaseUrl
-          .iter(ex.baseUrl)
-          .toArray();
-
-      if (!denominationRecords) {
-        return;
-      }
-
-      const denominations: DenominationInfo[] = denominationRecords.map((x) =>
-        DenominationRecord.toDenomInfo(x),
-      );
-
-      return {
-        info: {
-          exchangeBaseUrl: ex.baseUrl,
-          currency,
-          paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
-          auditors: exchangeDetails.auditors,
-          wireInfo: exchangeDetails.wireInfo,
-          globalFees: exchangeDetails.globalFees,
-        },
-        denominations,
-      };
-    });
-
-  if (!exchange) {
-    throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
-  }
-
-  const denoms = exchange.denominations.map((d) => ({
-    ...d,
-    group: Amounts.stringifyValue(d.value),
-  }));
-  const denomFees: DenomOperationMap<FeeDescription[]> = {
-    deposit: createTimeline(
-      denoms,
-      "denomPubHash",
-      "stampStart",
-      "stampExpireDeposit",
-      "feeDeposit",
-      "group",
-      selectBestForOverlappingDenominations,
-    ),
-    refresh: createTimeline(
-      denoms,
-      "denomPubHash",
-      "stampStart",
-      "stampExpireWithdraw",
-      "feeRefresh",
-      "group",
-      selectBestForOverlappingDenominations,
-    ),
-    refund: createTimeline(
-      denoms,
-      "denomPubHash",
-      "stampStart",
-      "stampExpireWithdraw",
-      "feeRefund",
-      "group",
-      selectBestForOverlappingDenominations,
-    ),
-    withdraw: createTimeline(
-      denoms,
-      "denomPubHash",
-      "stampStart",
-      "stampExpireWithdraw",
-      "feeWithdraw",
-      "group",
-      selectBestForOverlappingDenominations,
-    ),
-  };
-
-  const transferFees = Object.entries(
-    exchange.info.wireInfo.feesForType,
-  ).reduce((prev, [wireType, infoForType]) => {
-    const feesByGroup = [
-      ...infoForType.map((w) => ({
-        ...w,
-        fee: Amounts.stringify(w.closingFee),
-        group: "closing",
-      })),
-      ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
-    ];
-    prev[wireType] = createTimeline(
-      feesByGroup,
-      "sig",
-      "startStamp",
-      "endStamp",
-      "fee",
-      "group",
-      selectMinimumFee,
-    );
-    return prev;
-  }, {} as Record<string, FeeDescription[]>);
-
-  const globalFeesByGroup = [
-    ...exchange.info.globalFees.map((w) => ({
-      ...w,
-      fee: w.accountFee,
-      group: "account",
-    })),
-    ...exchange.info.globalFees.map((w) => ({
-      ...w,
-      fee: w.historyFee,
-      group: "history",
-    })),
-    ...exchange.info.globalFees.map((w) => ({
-      ...w,
-      fee: w.purseFee,
-      group: "purse",
-    })),
-  ];
-
-  const globalFees = createTimeline(
-    globalFeesByGroup,
-    "signature",
-    "startDate",
-    "endDate",
-    "fee",
-    "group",
-    selectMinimumFee,
-  );
-
-  return {
-    exchange: {
-      ...exchange.info,
-      denomFees,
-      transferFees,
-      globalFees,
-    },
-  };
-}
-
 async function setCoinSuspended(
   ws: InternalWalletState,
   coinPub: string,
@@ -1059,7 +838,7 @@ async function handlePrepareWithdrawExchange(
     throw Error("expected a taler://withdraw-exchange URI");
   }
   const exchangeBaseUrl = parsedUri.exchangeBaseUrl;
-  const exchange = await updateExchangeFromUrl(ws, exchangeBaseUrl);
+  const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
   if (exchange.exchangeDetails.masterPublicKey != parsedUri.exchangePub) {
     throw Error("mismatch of exchange master public key (URI vs actual)");
   }
@@ -1166,15 +945,14 @@ async function dispatchRequestInternal<Op extends 
WalletApiOperation>(
     }
     case WalletApiOperation.AddExchange: {
       const req = codecForAddExchangeRequest().decode(payload);
-      await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {
-        checkMasterPub: req.masterPub,
-        forceNow: req.forceUpdate,
-      });
+      await fetchFreshExchange(ws, req.exchangeBaseUrl);
       return {};
     }
     case WalletApiOperation.UpdateExchangeEntry: {
       const req = codecForUpdateExchangeEntryRequest().decode(payload);
-      await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {});
+      await fetchFreshExchange(ws, req.exchangeBaseUrl, {
+        forceUpdate: true,
+      });
       return {};
     }
     case WalletApiOperation.ListExchanges: {
@@ -1896,7 +1674,7 @@ class InternalWalletStateImpl implements 
InternalWalletState {
 
   exchangeOps: ExchangeOperations = {
     getExchangeDetails,
-    updateExchangeFromUrl,
+    updateExchangeFromUrl: fetchFreshExchange,
   };
 
   recoupOps: RecoupOperations = {

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