gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] 02/02: history events WIP


From: gnunet
Subject: [taler-wallet-core] 02/02: history events WIP
Date: Mon, 16 Dec 2019 12:53:32 +0100

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

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

commit fa4621e70c48500a372504eb8ae9b9481531c555
Author: Florian Dold <address@hidden>
AuthorDate: Mon Dec 16 12:53:22 2019 +0100

    history events WIP
---
 src/operations/exchanges.ts |  75 +++++++---
 src/operations/history.ts   | 337 +++++++++++++++++++++++++++++++++++++++++++-
 src/operations/pay.ts       |   1 +
 src/operations/pending.ts   |  18 ++-
 src/operations/refresh.ts   |   2 +-
 src/operations/refund.ts    | 120 ++++++++++------
 src/operations/reserves.ts  |  44 +++++-
 src/operations/tip.ts       |   9 +-
 src/operations/withdraw.ts  |   2 +-
 src/types/dbTypes.ts        |  69 +++++++--
 src/types/history.ts        |  46 +++---
 src/types/pending.ts        |  19 +++
 src/types/talerTypes.ts     |  30 ++--
 src/util/amounts.ts         |   7 -
 src/util/codec-test.ts      |  26 ++--
 src/util/codec.ts           | 159 ++++++++++++---------
 src/util/helpers.ts         |  10 ++
 src/util/query.ts           |  11 ++
 src/wallet.ts               |  82 ++++++-----
 tsconfig.json               |   2 +
 20 files changed, 793 insertions(+), 276 deletions(-)

diff --git a/src/operations/exchanges.ts b/src/operations/exchanges.ts
index 6c4c1aa0..fc1a50f0 100644
--- a/src/operations/exchanges.ts
+++ b/src/operations/exchanges.ts
@@ -25,15 +25,15 @@ import {
   DenominationRecord,
   DenominationStatus,
   WireFee,
+  ExchangeUpdateReason,
+  ExchangeUpdatedEventRecord,
 } from "../types/dbTypes";
 import {
   canonicalizeBaseUrl,
   extractTalerStamp,
   extractTalerStampOrThrow,
 } from "../util/helpers";
-import {
-  Database
-} from "../util/query";
+import { Database } from "../util/query";
 import * as Amounts from "../util/amounts";
 import { parsePaytoUri } from "../util/payto";
 import {
@@ -78,7 +78,7 @@ async function setExchangeError(
     exchange.lastError = err;
     return exchange;
   };
-  await ws.db.mutate( Stores.exchanges, baseUrl, mut);
+  await ws.db.mutate(Stores.exchanges, baseUrl, mut);
 }
 
 /**
@@ -91,12 +91,9 @@ async function updateExchangeWithKeys(
   ws: InternalWalletState,
   baseUrl: string,
 ): Promise<void> {
-  const existingExchangeRecord = await ws.db.get(
-    Stores.exchanges,
-    baseUrl,
-  );
+  const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl);
 
-  if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) 
{
+  if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) {
     return;
   }
   const keysUrl = new URL("keys", baseUrl);
@@ -194,7 +191,7 @@ async function updateExchangeWithKeys(
         masterPublicKey: exchangeKeysJson.master_public_key,
         protocolVersion: protocolVersion,
       };
-      r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE;
+      r.updateStatus = ExchangeUpdateStatus.FetchWire;
       r.lastError = undefined;
       await tx.put(Stores.exchanges, r);
 
@@ -213,6 +210,38 @@ async function updateExchangeWithKeys(
   );
 }
 
+async function updateExchangeFinalize(
+  ws: InternalWalletState,
+  exchangeBaseUrl: string,
+) {
+  const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
+  if (!exchange) {
+    return;
+  }
+  if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
+    return;
+  }
+  await ws.db.runWithWriteTransaction(
+    [Stores.exchanges, Stores.exchangeUpdatedEvents],
+    async tx => {
+      const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+      if (!r) {
+        return;
+      }
+      if (r.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
+        return;
+      }
+      r.updateStatus = ExchangeUpdateStatus.Finished;
+      await tx.put(Stores.exchanges, r);
+      const updateEvent: ExchangeUpdatedEventRecord = {
+        exchangeBaseUrl: exchange.baseUrl,
+        timestamp: getTimestampNow(),
+      };
+      await tx.put(Stores.exchangeUpdatedEvents, updateEvent);
+    },
+  );
+}
+
 async function updateExchangeWithTermsOfService(
   ws: InternalWalletState,
   exchangeBaseUrl: string,
@@ -221,7 +250,7 @@ async function updateExchangeWithTermsOfService(
   if (!exchange) {
     return;
   }
-  if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) {
+  if (exchange.updateStatus != ExchangeUpdateStatus.FetchTerms) {
     return;
   }
   const reqUrl = new URL("terms", exchangeBaseUrl);
@@ -243,12 +272,12 @@ async function updateExchangeWithTermsOfService(
     if (!r) {
       return;
     }
-    if (r.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) {
+    if (r.updateStatus != ExchangeUpdateStatus.FetchTerms) {
       return;
     }
     r.termsOfServiceText = tosText;
     r.termsOfServiceLastEtag = tosEtag;
-    r.updateStatus = ExchangeUpdateStatus.FINISHED;
+    r.updateStatus = ExchangeUpdateStatus.FinalizeUpdate;
     await tx.put(Stores.exchanges, r);
   });
 }
@@ -282,7 +311,7 @@ async function updateExchangeWithWireInfo(
   if (!exchange) {
     return;
   }
-  if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
+  if (exchange.updateStatus != ExchangeUpdateStatus.FetchWire) {
     return;
   }
   const details = exchange.details;
@@ -349,14 +378,14 @@ async function updateExchangeWithWireInfo(
     if (!r) {
       return;
     }
-    if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
+    if (r.updateStatus != ExchangeUpdateStatus.FetchWire) {
       return;
     }
     r.wireInfo = {
       accounts: wireInfo.accounts,
       feesForType: feesForType,
     };
-    r.updateStatus = ExchangeUpdateStatus.FETCH_TERMS;
+    r.updateStatus = ExchangeUpdateStatus.FetchTerms;
     r.lastError = undefined;
     await tx.put(Stores.exchanges, r);
   });
@@ -390,12 +419,13 @@ async function updateExchangeFromUrlImpl(
   const r = await ws.db.get(Stores.exchanges, baseUrl);
   if (!r) {
     const newExchangeRecord: ExchangeRecord = {
+      builtIn: false,
       baseUrl: baseUrl,
       details: undefined,
       wireInfo: undefined,
-      updateStatus: ExchangeUpdateStatus.FETCH_KEYS,
+      updateStatus: ExchangeUpdateStatus.FetchKeys,
       updateStarted: now,
-      updateReason: "initial",
+      updateReason: ExchangeUpdateReason.Initial,
       timestampAdded: getTimestampNow(),
       termsOfServiceAcceptedEtag: undefined,
       termsOfServiceAcceptedTimestamp: undefined,
@@ -409,14 +439,14 @@ async function updateExchangeFromUrlImpl(
       if (!rec) {
         return;
       }
-      if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !forceNow) {
+      if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && !forceNow) {
         return;
       }
-      if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && forceNow) {
-        rec.updateReason = "forced";
+      if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && forceNow) {
+        rec.updateReason = ExchangeUpdateReason.Forced;
       }
       rec.updateStarted = now;
-      rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS;
+      rec.updateStatus = ExchangeUpdateStatus.FetchKeys;
       rec.lastError = undefined;
       t.put(Stores.exchanges, rec);
     });
@@ -425,6 +455,7 @@ async function updateExchangeFromUrlImpl(
   await updateExchangeWithKeys(ws, baseUrl);
   await updateExchangeWithWireInfo(ws, baseUrl);
   await updateExchangeWithTermsOfService(ws, baseUrl);
+  await updateExchangeFinalize(ws, baseUrl);
 
   const updatedExchange = await ws.db.get(Stores.exchanges, baseUrl);
 
diff --git a/src/operations/history.ts b/src/operations/history.ts
index 8b225ea0..7e985d21 100644
--- a/src/operations/history.ts
+++ b/src/operations/history.ts
@@ -18,10 +18,132 @@
  * Imports.
  */
 import { InternalWalletState } from "./state";
-import { Stores, TipRecord } from "../types/dbTypes";
+import {
+  Stores,
+  TipRecord,
+  ProposalStatus,
+  ProposalRecord,
+} from "../types/dbTypes";
 import * as Amounts from "../util/amounts";
 import { AmountJson } from "../util/amounts";
-import { HistoryQuery, HistoryEvent, HistoryEventType } from 
"../types/history";
+import {
+  HistoryQuery,
+  HistoryEvent,
+  HistoryEventType,
+  OrderShortInfo,
+  ReserveType,
+  ReserveCreationDetail,
+} from "../types/history";
+import { assertUnreachable } from "../util/assertUnreachable";
+import { TransactionHandle, Store } from "../util/query";
+import { ReserveTransactionType } from "../types/ReserveTransaction";
+
+/**
+ * Create an event ID from the type and the primary key for the event.
+ */
+function makeEventId(type: HistoryEventType, ...args: string[]) {
+  return type + ";" + args.map(x => encodeURIComponent(x)).join(";");
+}
+
+function getOrderShortInfo(
+  proposal: ProposalRecord,
+): OrderShortInfo | undefined {
+  const download = proposal.download;
+  if (!download) {
+    return undefined;
+  }
+  return {
+    amount: download.contractTerms.amount,
+    orderId: download.contractTerms.order_id,
+    merchantBaseUrl: download.contractTerms.merchant_base_url,
+    proposalId: proposal.proposalId,
+    summary: download.contractTerms.summary || "",
+  };
+}
+
+
+async function collectProposalHistory(
+  tx: TransactionHandle,
+  history: HistoryEvent[],
+  historyQuery?: HistoryQuery,
+) {
+  tx.iter(Stores.proposals).forEachAsync(async proposal => {
+    const status = proposal.proposalStatus;
+    switch (status) {
+      case ProposalStatus.ACCEPTED:
+        {
+          const shortInfo = getOrderShortInfo(proposal);
+          if (!shortInfo) {
+            break;
+          }
+          history.push({
+            type: HistoryEventType.OrderAccepted,
+            eventId: makeEventId(
+              HistoryEventType.OrderAccepted,
+              proposal.proposalId,
+            ),
+            orderShortInfo: shortInfo,
+            timestamp: proposal.timestamp,
+          });
+        }
+        break;
+      case ProposalStatus.DOWNLOADING:
+      case ProposalStatus.PROPOSED:
+        // no history event needed
+        break;
+      case ProposalStatus.REJECTED:
+        {
+          const shortInfo = getOrderShortInfo(proposal);
+          if (!shortInfo) {
+            break;
+          }
+          history.push({
+            type: HistoryEventType.OrderRefused,
+            eventId: makeEventId(
+              HistoryEventType.OrderRefused,
+              proposal.proposalId,
+            ),
+            orderShortInfo: shortInfo,
+            timestamp: proposal.timestamp,
+          });
+        }
+        break;
+      case ProposalStatus.REPURCHASE:
+        {
+          const alreadyPaidProposal = await tx.get(
+            Stores.proposals,
+            proposal.repurchaseProposalId,
+          );
+          if (!alreadyPaidProposal) {
+            break;
+          }
+          const alreadyPaidOrderShortInfo = getOrderShortInfo(
+            alreadyPaidProposal,
+          );
+          if (!alreadyPaidOrderShortInfo) {
+            break;
+          }
+          const newOrderShortInfo = getOrderShortInfo(proposal);
+          if (!newOrderShortInfo) {
+            break;
+          }
+          history.push({
+            type: HistoryEventType.OrderRedirected,
+            eventId: makeEventId(
+              HistoryEventType.OrderRedirected,
+              proposal.proposalId,
+            ),
+            alreadyPaidOrderShortInfo,
+            newOrderShortInfo,
+            timestamp: proposal.timestamp,
+          });
+        }
+        break;
+      default:
+        assertUnreachable(status);
+    }
+  });
+}
 
 /**
  * Retrive the full event history for this wallet.
@@ -40,19 +162,222 @@ export async function getHistory(
   await ws.db.runWithReadTransaction(
     [
       Stores.currencies,
-      Stores.coins,
-      Stores.denominations,
       Stores.exchanges,
+      Stores.exchangeUpdatedEvents,
       Stores.proposals,
       Stores.purchases,
       Stores.refreshGroups,
       Stores.reserves,
       Stores.tips,
       Stores.withdrawalSession,
+      Stores.payEvents,
+      Stores.refundEvents,
+      Stores.reserveUpdatedEvents,
     ],
     async tx => {
-      // FIXME: implement new history schema!!
-    }
+      tx.iter(Stores.exchanges).forEach(exchange => {
+        history.push({
+          type: HistoryEventType.ExchangeAdded,
+          builtIn: false,
+          eventId: makeEventId(
+            HistoryEventType.ExchangeAdded,
+            exchange.baseUrl,
+          ),
+          exchangeBaseUrl: exchange.baseUrl,
+          timestamp: exchange.timestampAdded,
+        });
+      });
+
+      tx.iter(Stores.exchangeUpdatedEvents).forEach(eu => {
+        history.push({
+          type: HistoryEventType.ExchangeUpdated,
+          eventId: makeEventId(
+            HistoryEventType.ExchangeUpdated,
+            eu.exchangeBaseUrl,
+          ),
+          exchangeBaseUrl: eu.exchangeBaseUrl,
+          timestamp: eu.timestamp,
+        });
+      });
+
+      tx.iter(Stores.withdrawalSession).forEach(wsr => {
+        if (wsr.finishTimestamp) {
+          history.push({
+            type: HistoryEventType.Withdrawn,
+            withdrawSessionId: wsr.withdrawSessionId,
+            eventId: makeEventId(
+              HistoryEventType.Withdrawn,
+              wsr.withdrawSessionId,
+            ),
+            amountWithdrawnEffective: Amounts.toString(wsr.totalCoinValue),
+            amountWithdrawnRaw: Amounts.toString(wsr.rawWithdrawalAmount),
+            exchangeBaseUrl: wsr.exchangeBaseUrl,
+            timestamp: wsr.finishTimestamp,
+          });
+        }
+      });
+
+      await collectProposalHistory(tx, history, historyQuery);
+
+      await tx.iter(Stores.payEvents).forEachAsync(async (pe) => {
+        const proposal = await tx.get(Stores.proposals, pe.proposalId);
+        if (!proposal) {
+          return;
+        }
+        const orderShortInfo = getOrderShortInfo(proposal);
+        if (!orderShortInfo) {
+          return;
+        }
+        history.push({
+          type: HistoryEventType.PaymentSent,
+          eventId: makeEventId(HistoryEventType.PaymentSent, pe.proposalId),
+          orderShortInfo,
+          replay: pe.isReplay,
+          sessionId: pe.sessionId,
+          timestamp: pe.timestamp,
+        });
+      });
+
+      await tx.iter(Stores.refreshGroups).forEachAsync(async (rg) => {
+        if (!rg.finishedTimestamp) {
+          return;
+        }
+        let numInputCoins = 0;
+        let numRefreshedInputCoins = 0;
+        let numOutputCoins = 0;
+        const amountsRaw: AmountJson[] = [];
+        const amountsEffective: AmountJson[] = [];
+        for (let i = 0; i < rg.refreshSessionPerCoin.length; i++) {
+          const session = rg.refreshSessionPerCoin[i];
+          numInputCoins++;
+          if (session) {
+            numRefreshedInputCoins++;
+            amountsRaw.push(session.valueWithFee);
+            amountsEffective.push(session.valueOutput);
+            numOutputCoins += session.newDenoms.length;
+          } else {
+            const c = await tx.get(Stores.coins, rg.oldCoinPubs[i]);
+            if (!c) {
+              continue;
+            }
+            amountsRaw.push(c.currentAmount);
+          }
+        }
+        let amountRefreshedRaw = Amounts.sum(amountsRaw).amount;
+        let amountRefreshedEffective: AmountJson;
+        if (amountsEffective.length == 0) {
+          amountRefreshedEffective = 
Amounts.getZero(amountRefreshedRaw.currency);
+        } else {
+          amountRefreshedEffective = Amounts.sum(amountsEffective).amount;
+        }
+        history.push({
+          type: HistoryEventType.Refreshed,
+          refreshGroupId: rg.refreshGroupId,
+          eventId: makeEventId(HistoryEventType.Refreshed, rg.refreshGroupId),
+          timestamp: rg.finishedTimestamp,
+          refreshReason: rg.reason,
+          amountRefreshedEffective: Amounts.toString(amountRefreshedEffective),
+          amountRefreshedRaw: Amounts.toString(amountRefreshedRaw),
+          numInputCoins,
+          numOutputCoins,
+          numRefreshedInputCoins,
+        });
+      });
+
+      tx.iter(Stores.reserveUpdatedEvents).forEachAsync(async (ru) => {
+        const reserve = await tx.get(Stores.reserves, ru.reservePub);
+        if (!reserve) {
+          return;
+        }
+        let reserveCreationDetail: ReserveCreationDetail;
+        if (reserve.bankWithdrawStatusUrl) {
+          reserveCreationDetail = {
+            type: ReserveType.TalerBankWithdraw,
+            bankUrl: reserve.bankWithdrawStatusUrl,
+          }
+        } else {
+          reserveCreationDetail = {
+            type: ReserveType.Manual,
+          }
+        }
+        history.push({
+          type: HistoryEventType.ReserveBalanceUpdated,
+          eventId: makeEventId(HistoryEventType.ReserveBalanceUpdated, 
ru.reserveUpdateId),
+          amountExpected: ru.amountExpected,
+          amountReserveBalance: ru.amountReserveBalance,
+          timestamp: reserve.created,
+          newHistoryTransactions: ru.newHistoryTransactions,
+          reserveShortInfo: {
+            exchangeBaseUrl: reserve.exchangeBaseUrl,
+            reserveCreationDetail,
+            reservePub: reserve.reservePub,
+          }
+        });
+      });
+
+      tx.iter(Stores.tips).forEach((tip) => {
+        if (tip.acceptedTimestamp) {
+          history.push({
+            type: HistoryEventType.TipAccepted,
+            eventId: makeEventId(HistoryEventType.TipAccepted, tip.tipId),
+            timestamp: tip.acceptedTimestamp,
+            tipId: tip.tipId,
+            tipAmount: Amounts.toString(tip.amount),
+          });
+        }
+      });
+
+      tx.iter(Stores.refundEvents).forEachAsync(async (re) => {
+        const proposal = await tx.get(Stores.proposals, re.proposalId);
+        if (!proposal) {
+          return;
+        }
+        const purchase = await tx.get(Stores.purchases, re.proposalId);
+        if (!purchase) {
+          return;
+        }
+        const orderShortInfo = getOrderShortInfo(proposal);
+        if (!orderShortInfo) {
+          return;
+        }
+        const purchaseAmount = 
Amounts.parseOrThrow(purchase.contractTerms.amount);
+        let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency);
+        let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency);
+        let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency);
+        Object.keys(purchase.refundState.refundsDone).forEach((x, i) => {
+          const r = purchase.refundState.refundsDone[x];
+          if (r.refundGroupId !== re.refundGroupId) {
+            return;
+          }
+          const refundAmount = Amounts.parseOrThrow(r.perm.refund_amount);
+          const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
+          amountRefundedRaw = Amounts.add(amountRefundedRaw, 
refundAmount).amount;
+          amountRefundedEffective = Amounts.add(amountRefundedEffective, 
refundAmount).amount;
+          amountRefundedEffective = Amounts.sub(amountRefundedEffective, 
refundFee).amount;
+        });
+        Object.keys(purchase.refundState.refundsFailed).forEach((x, i) => {
+          const r = purchase.refundState.refundsFailed[x];
+          if (r.refundGroupId !== re.refundGroupId) {
+            return;
+          }
+          const ra = Amounts.parseOrThrow(r.perm.refund_amount);
+          const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
+          amountRefundedRaw = Amounts.add(amountRefundedRaw, ra).amount;
+          amountRefundedInvalid = Amounts.add(amountRefundedInvalid, 
ra).amount;
+          amountRefundedEffective = Amounts.sub(amountRefundedEffective, 
refundFee).amount;
+        });
+        history.push({
+          type: HistoryEventType.Refund,
+          eventId: makeEventId(HistoryEventType.Refund, re.refundGroupId),
+          refundGroupId: re.refundGroupId,
+          orderShortInfo,
+          timestamp: re.timestamp,
+          amountRefundedEffective: Amounts.toString(amountRefundedEffective),
+          amountRefundedRaw: Amounts.toString(amountRefundedRaw),
+          amountRefundedInvalid: Amounts.toString(amountRefundedInvalid),
+        });
+      });
+    },
   );
 
   history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms));
diff --git a/src/operations/pay.ts b/src/operations/pay.ts
index 363688db..66452469 100644
--- a/src/operations/pay.ts
+++ b/src/operations/pay.ts
@@ -755,6 +755,7 @@ export async function submitPay(
         proposalId,
         sessionId,
         timestamp: now,
+        isReplay: !isFirst,
       };
       await tx.put(Stores.payEvents, payEvent);
     },
diff --git a/src/operations/pending.ts b/src/operations/pending.ts
index b9b2c664..252c9e98 100644
--- a/src/operations/pending.ts
+++ b/src/operations/pending.ts
@@ -54,7 +54,7 @@ async function gatherExchangePending(
   }
   await tx.iter(Stores.exchanges).forEach(e => {
     switch (e.updateStatus) {
-      case ExchangeUpdateStatus.FINISHED:
+      case ExchangeUpdateStatus.Finished:
         if (e.lastError) {
           resp.pendingOperations.push({
             type: PendingOperationType.Bug,
@@ -89,7 +89,7 @@ async function gatherExchangePending(
           });
         }
         break;
-      case ExchangeUpdateStatus.FETCH_KEYS:
+      case ExchangeUpdateStatus.FetchKeys:
         resp.pendingOperations.push({
           type: PendingOperationType.ExchangeUpdate,
           givesLifeness: false,
@@ -99,7 +99,7 @@ async function gatherExchangePending(
           reason: e.updateReason || "unknown",
         });
         break;
-      case ExchangeUpdateStatus.FETCH_WIRE:
+      case ExchangeUpdateStatus.FetchWire:
         resp.pendingOperations.push({
           type: PendingOperationType.ExchangeUpdate,
           givesLifeness: false,
@@ -109,6 +109,16 @@ async function gatherExchangePending(
           reason: e.updateReason || "unknown",
         });
         break;
+      case ExchangeUpdateStatus.FinalizeUpdate:
+          resp.pendingOperations.push({
+            type: PendingOperationType.ExchangeUpdate,
+            givesLifeness: false,
+            stage: "finalize-update",
+            exchangeBaseUrl: e.baseUrl,
+            lastError: e.lastError,
+            reason: e.updateReason || "unknown",
+          });
+        break;
       default:
         resp.pendingOperations.push({
           type: PendingOperationType.Bug,
@@ -311,7 +321,7 @@ async function gatherTipPending(
     if (onlyDue && tip.retryInfo.nextRetry.t_ms > now.t_ms) {
       return;
     }
-    if (tip.accepted) {
+    if (tip.acceptedTimestamp) {
       resp.pendingOperations.push({
         type: PendingOperationType.TipPickup,
         givesLifeness: true,
diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts
index be23a5bb..d9a080bd 100644
--- a/src/operations/refresh.ts
+++ b/src/operations/refresh.ts
@@ -548,7 +548,7 @@ export async function createRefreshGroup(
     finishedTimestamp: undefined,
     finishedPerCoin: oldCoinPubs.map(x => false),
     lastError: undefined,
-    lastErrorPerCoin: oldCoinPubs.map(x => undefined),
+    lastErrorPerCoin: {},
     oldCoinPubs: oldCoinPubs.map(x => x.coinPub),
     reason,
     refreshGroupId,
diff --git a/src/operations/refund.ts b/src/operations/refund.ts
index a2b4dbe2..58941857 100644
--- a/src/operations/refund.ts
+++ b/src/operations/refund.ts
@@ -28,6 +28,7 @@ import {
   OperationError,
   getTimestampNow,
   RefreshReason,
+  CoinPublicKey,
 } from "../types/walletTypes";
 import {
   Stores,
@@ -36,6 +37,7 @@ import {
   CoinStatus,
   RefundReason,
   RefundEventRecord,
+  RefundInfo,
 } from "../types/dbTypes";
 import { NotificationType } from "../types/notifications";
 import { parseRefundUri } from "../util/taleruri";
@@ -214,13 +216,6 @@ export async function acceptRefundResponse(
         timestampQueried: now,
         reason,
       });
-
-      const refundEvent: RefundEventRecord = {
-        proposalId,
-        refundGroupId,
-        timestamp: now,
-      };
-      await tx.put(Stores.refundEvents, refundEvent);
     }
 
     await tx.put(Stores.purchases, p);
@@ -406,6 +401,9 @@ async function processPurchaseApplyRefundImpl(
     console.log("no pending refunds");
     return;
   }
+
+  const newRefundsDone: { [sig: string]: RefundInfo } = {};
+  const newRefundsFailed: { [sig: string]: RefundInfo } = {};
   for (const pk of pendingKeys) {
     const info = purchase.refundState.refundsPending[pk];
     const perm = info.perm;
@@ -424,13 +422,13 @@ async function processPurchaseApplyRefundImpl(
     const reqUrl = new URL("refund", exchangeUrl);
     const resp = await ws.http.postJson(reqUrl.href, req);
     console.log("sent refund permission");
-    let refundGone = false;
     switch (resp.status) {
       case HttpResponseStatus.Ok:
+        newRefundsDone[pk] = info;
         break;
       case HttpResponseStatus.Gone:
         // We're too late, refund is expired.
-        refundGone = true;
+        newRefundsFailed[pk] = info;
         break;
       default:
         let body: string | null = null;
@@ -446,53 +444,89 @@ async function processPurchaseApplyRefundImpl(
           },
         });
     }
+  }
+  let allRefundsProcessed = false;
+  await ws.db.runWithWriteTransaction(
+    [Stores.purchases, Stores.coins, Stores.refreshGroups, 
Stores.refundEvents],
+    async tx => {
+      const p = await tx.get(Stores.purchases, proposalId);
+      if (!p) {
+        return;
+      }
 
-    let allRefundsProcessed = false;
+      // Groups that failed/succeeded
+      let groups: { [refundGroupId: string]: boolean } = {};
 
-    await ws.db.runWithWriteTransaction(
-      [Stores.purchases, Stores.coins, Stores.refreshGroups],
-      async tx => {
-        const p = await tx.get(Stores.purchases, proposalId);
-        if (!p) {
-          return;
-        }
-        if (p.refundState.refundsPending[pk]) {
-          if (refundGone) {
-            p.refundState.refundsFailed[pk] = p.refundState.refundsPending[pk];
-          } else {
-            p.refundState.refundsDone[pk] = p.refundState.refundsPending[pk];
-          }
-          delete p.refundState.refundsPending[pk];
-        }
-        if (Object.keys(p.refundState.refundsPending).length === 0) {
-          p.refundStatusRetryInfo = initRetryInfo();
-          p.lastRefundStatusError = undefined;
-          allRefundsProcessed = true;
-        }
-        await tx.put(Stores.purchases, p);
+      // Avoid duplicates
+      const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {};
+
+      const modCoin = async (perm: MerchantRefundPermission) => {
         const c = await tx.get(Stores.coins, perm.coin_pub);
         if (!c) {
           console.warn("coin not found, can't apply refund");
           return;
         }
+        refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub };
         const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
         const refundFee = Amounts.parseOrThrow(perm.refund_fee);
         c.status = CoinStatus.Dormant;
         c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
         c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
         await tx.put(Stores.coins, c);
-        await createRefreshGroup(
-          tx,
-          [{ coinPub: perm.coin_pub }],
-          RefreshReason.Refund,
-        );
-      },
-    );
-    if (allRefundsProcessed) {
-      ws.notify({
-        type: NotificationType.RefundFinished,
-      });
-    }
+      };
+
+      for (const pk of Object.keys(newRefundsFailed)) {
+        const r = newRefundsFailed[pk];
+        groups[r.refundGroupId] = true;
+        delete p.refundState.refundsPending[pk];
+        p.refundState.refundsFailed[pk] = r;
+        await modCoin(r.perm);
+      }
+
+      for (const pk of Object.keys(newRefundsDone)) {
+        const r = newRefundsDone[pk];
+        groups[r.refundGroupId] = true;
+        delete p.refundState.refundsPending[pk];
+        p.refundState.refundsDone[pk] = r;
+        await modCoin(r.perm);
+      }
+
+      const now = getTimestampNow();
+      for (const g of Object.keys(groups)) {
+        let groupDone = true;
+        for (const pk of Object.keys(p.refundState.refundsPending)) {
+          const r  = p.refundState.refundsPending[pk];
+          if (r.refundGroupId == g) {
+            groupDone = false;
+          }
+        }
+        if (groupDone) {
+          const refundEvent: RefundEventRecord = {
+            proposalId,
+            refundGroupId: g,
+            timestamp: now,
+          }
+          await tx.put(Stores.refundEvents, refundEvent);
+        }
+      }
+
+      if (Object.keys(p.refundState.refundsPending).length === 0) {
+        p.refundStatusRetryInfo = initRetryInfo();
+        p.lastRefundStatusError = undefined;
+        allRefundsProcessed = true;
+      }
+      await tx.put(Stores.purchases, p);
+      await createRefreshGroup(
+        tx,
+        Object.values(refreshCoinsMap),
+        RefreshReason.Refund,
+      );
+    },
+  );
+  if (allRefundsProcessed) {
+    ws.notify({
+      type: NotificationType.RefundFinished,
+    });
   }
 
   ws.notify({
diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts
index 559d3ab0..56e9c25d 100644
--- a/src/operations/reserves.ts
+++ b/src/operations/reserves.ts
@@ -31,17 +31,17 @@ import {
   WithdrawalSessionRecord,
   initRetryInfo,
   updateRetryInfoTimeout,
+  ReserveUpdatedEventRecord,
 } from "../types/dbTypes";
 import {
-  Database,
   TransactionAbort,
 } from "../util/query";
 import { Logger } from "../util/logging";
 import * as Amounts from "../util/amounts";
 import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges";
-import { WithdrawOperationStatusResponse, ReserveStatus } from 
"../types/talerTypes";
+import { WithdrawOperationStatusResponse } from "../types/talerTypes";
 import { assertUnreachable } from "../util/assertUnreachable";
-import { encodeCrock } from "../crypto/talerCrypto";
+import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
 import { randomBytes } from "../crypto/primitives/nacl-fast";
 import {
   getVerifiedWithdrawDenomList,
@@ -49,6 +49,7 @@ import {
 } from "./withdraw";
 import { guardOperationException, OperationFailedAndReportedError } from 
"./errors";
 import { NotificationType } from "../types/notifications";
+import { codecForReserveStatus } from "../types/ReserveStatus";
 
 const logger = new Logger("reserves.ts");
 
@@ -94,6 +95,7 @@ export async function createReserve(
     lastSuccessfulStatusQuery: undefined,
     retryInfo: initRetryInfo(),
     lastError: undefined,
+    reserveTransactions: [],
   };
 
   const senderWire = req.senderWire;
@@ -393,17 +395,35 @@ async function updateReserve(
     });
     throw new OperationFailedAndReportedError(m);
   }
-  const reserveInfo = ReserveStatus.checked(await resp.json());
+  const respJson = await resp.json();
+  const reserveInfo = codecForReserveStatus.decode(respJson);
   const balance = Amounts.parseOrThrow(reserveInfo.balance);
-  await ws.db.mutate(Stores.reserves, reserve.reservePub, r => {
+  await ws.db.runWithWriteTransaction([Stores.reserves, 
Stores.reserveUpdatedEvents], async (tx) => {
+    const r = await tx.get(Stores.reserves, reservePub);
+    if (!r) {
+      return;
+    }
     if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
       return;
     }
 
+    const newHistoryTransactions = 
reserveInfo.history.slice(r.reserveTransactions.length);
+
+    const reserveUpdateId = encodeCrock(getRandomBytes(32));
+
     // FIXME: check / compare history!
     if (!r.lastSuccessfulStatusQuery) {
       // FIXME: check if this matches initial expectations
       r.withdrawRemainingAmount = balance;
+      const reserveUpdate: ReserveUpdatedEventRecord = {
+        reservePub: r.reservePub,
+        timestamp: getTimestampNow(),
+        amountReserveBalance: Amounts.toString(balance),
+        amountExpected: Amounts.toString(reserve.initiallyRequestedAmount),
+        newHistoryTransactions,
+        reserveUpdateId,
+      };
+      await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
     } else {
       const expectedBalance = Amounts.sub(
         r.withdrawAllocatedAmount,
@@ -423,11 +443,21 @@ async function updateReserve(
       } else {
         // We're missing some money.
       }
+      const reserveUpdate: ReserveUpdatedEventRecord = {
+        reservePub: r.reservePub,
+        timestamp: getTimestampNow(),
+        amountReserveBalance: Amounts.toString(balance),
+        amountExpected: Amounts.toString(expectedBalance.amount),
+        newHistoryTransactions,
+        reserveUpdateId,
+      };
+      await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
     }
     r.lastSuccessfulStatusQuery = getTimestampNow();
     r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
     r.retryInfo = initRetryInfo();
-    return r;
+    r.reserveTransactions = reserveInfo.history;
+    await tx.put(Stores.reserves, r);
   });
   ws.notify( { type: NotificationType.ReserveUpdated });
 }
@@ -561,7 +591,7 @@ async function depleteReserve(
     planchets: denomsForWithdraw.map(x => undefined),
     totalCoinValue,
     retryInfo: initRetryInfo(),
-    lastCoinErrors: denomsForWithdraw.map(x => undefined),
+    lastErrorPerCoin: {},
     lastError: undefined,
   };
 
diff --git a/src/operations/tip.ts b/src/operations/tip.ts
index f9953b51..ba4b8097 100644
--- a/src/operations/tip.ts
+++ b/src/operations/tip.ts
@@ -68,7 +68,8 @@ export async function getTipStatus(
 
     tipRecord = {
       tipId,
-      accepted: false,
+      acceptedTimestamp: undefined,
+      rejectedTimestamp: undefined,
       amount,
       deadline: extractTalerStampOrThrow(tipPickupStatus.stamp_expire),
       exchangeUrl: tipPickupStatus.exchange_url,
@@ -90,7 +91,7 @@ export async function getTipStatus(
   }
 
   const tipStatus: TipStatus = {
-    accepted: !!tipRecord && tipRecord.accepted,
+    accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
     amount: Amounts.parseOrThrow(tipPickupStatus.amount),
     amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
     exchangeUrl: tipPickupStatus.exchange_url,
@@ -259,7 +260,7 @@ async function processTipImpl(
     rawWithdrawalAmount: tipRecord.amount,
     withdrawn: planchets.map((x) => false),
     totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount,
-    lastCoinErrors: planchets.map((x) => undefined),
+    lastErrorPerCoin: {},
     retryInfo: initRetryInfo(),
     finishTimestamp: undefined,
     lastError: undefined,
@@ -296,7 +297,7 @@ export async function acceptTip(
     return;
   }
 
-  tipRecord.accepted = true;
+  tipRecord.acceptedTimestamp = getTimestampNow();
   await ws.db.put(Stores.tips, tipRecord);
 
   await processTip(ws, tipId);
diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts
index a34eec5a..c7c91494 100644
--- a/src/operations/withdraw.ts
+++ b/src/operations/withdraw.ts
@@ -272,7 +272,7 @@ async function processPlanchet(
         return false;
       }
       ws.withdrawn[coinIdx] = true;
-      ws.lastCoinErrors[coinIdx] = undefined;
+      delete ws.lastErrorPerCoin[coinIdx];
       let numDone = 0;
       for (let i = 0; i < ws.withdrawn.length; i++) {
         if (ws.withdrawn[i]) {
diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts
index 7447fc54..897c3503 100644
--- a/src/types/dbTypes.ts
+++ b/src/types/dbTypes.ts
@@ -43,6 +43,7 @@ import {
   getTimestampNow,
   RefreshReason,
 } from "./walletTypes";
+import { ReserveTransaction } from "./ReserveTransaction";
 
 export enum ReserveRecordStatus {
   /**
@@ -130,6 +131,7 @@ export function initRetryInfo(
   return info;
 }
 
+
 /**
  * A reserve record as stored in the wallet's database.
  */
@@ -237,6 +239,8 @@ export interface ReserveRecord {
    * (either talking to the bank or the exchange).
    */
   lastError: OperationError | undefined;
+
+  reserveTransactions: ReserveTransaction[];
 }
 
 /**
@@ -449,10 +453,11 @@ export interface ExchangeDetails {
 }
 
 export const enum ExchangeUpdateStatus {
-  FETCH_KEYS = "fetch_keys",
-  FETCH_WIRE = "fetch_wire",
-  FETCH_TERMS = "fetch_terms",
-  FINISHED = "finished",
+  FetchKeys = "fetch-keys",
+  FetchWire = "fetch-wire",
+  FetchTerms = "fetch-terms",
+  FinalizeUpdate = "finalize-update",
+  Finished = "finished",
 }
 
 export interface ExchangeBankAccount {
@@ -464,6 +469,12 @@ export interface ExchangeWireInfo {
   accounts: ExchangeBankAccount[];
 }
 
+export const enum ExchangeUpdateReason {
+  Initial = "initial",
+  Forced = "forced",
+  Scheduled = "scheduled",
+}
+
 /**
  * Exchange record as stored in the wallet's database.
  */
@@ -473,6 +484,11 @@ export interface ExchangeRecord {
    */
   baseUrl: string;
 
+  /**
+   * Was the exchange added as a built-in exchange?
+   */
+  builtIn: boolean;
+
   /**
    * Details, once known.
    */
@@ -514,7 +530,7 @@ export interface ExchangeRecord {
    */
   updateStarted: Timestamp | undefined;
   updateStatus: ExchangeUpdateStatus;
-  updateReason?: "initial" | "forced";
+  updateReason?: ExchangeUpdateReason;
 
   lastError?: OperationError;
 }
@@ -660,7 +676,7 @@ export interface CoinRecord {
   status: CoinStatus;
 }
 
-export enum ProposalStatus {
+export const enum ProposalStatus {
   /**
    * Not downloaded yet.
    */
@@ -777,11 +793,17 @@ export class ProposalRecord {
  */
 export interface TipRecord {
   lastError: OperationError | undefined;
+
   /**
    * Has the user accepted the tip?  Only after the tip has been accepted coins
    * withdrawn from the tip may be used.
    */
-  accepted: boolean;
+  acceptedTimestamp: Timestamp | undefined;
+
+  /**
+   * Has the user rejected the tip?
+   */
+  rejectedTimestamp: Timestamp | undefined;
 
   /**
    * Have we picked up the tip record from the merchant already?
@@ -855,7 +877,7 @@ export interface RefreshGroupRecord {
 
   lastError: OperationError | undefined;
 
-  lastErrorPerCoin: (OperationError | undefined)[];
+  lastErrorPerCoin: { [coinIndex: number]: OperationError };
 
   refreshGroupId: string;
 
@@ -1066,9 +1088,24 @@ export interface PurchaseRefundState {
 export interface PayEventRecord {
   proposalId: string;
   sessionId: string | undefined;
+  isReplay: boolean;
   timestamp: Timestamp;
 }
 
+export interface ExchangeUpdatedEventRecord {
+  exchangeBaseUrl: string;
+  timestamp: Timestamp;
+}
+
+export interface ReserveUpdatedEventRecord {
+  amountReserveBalance: string;
+  amountExpected: string;
+  reservePub: string;
+  timestamp: Timestamp;
+  reserveUpdateId: string;
+  newHistoryTransactions: ReserveTransaction[];
+}
+
 /**
  * Record that stores status information about one purchase, starting from when
  * the customer accepts a proposal.  Includes refund status if applicable.
@@ -1298,7 +1335,7 @@ export interface WithdrawalSessionRecord {
    * Last error per coin/planchet, or undefined if no error occured for
    * the coin/planchet.
    */
-  lastCoinErrors: (OperationError | undefined)[];
+  lastErrorPerCoin: { [coinIndex: number]: OperationError };
 
   lastError: OperationError | undefined;
 }
@@ -1448,6 +1485,18 @@ export namespace Stores {
     }
   }
 
+  class ExchangeUpdatedEventsStore extends Store<ExchangeUpdatedEventRecord> {
+    constructor() {
+      super("exchangeUpdatedEvents", { keyPath: "exchangeBaseUrl" });
+    }
+  }
+
+  class ReserveUpdatedEventsStore extends Store<ReserveUpdatedEventRecord> {
+    constructor() {
+      super("reserveUpdatedEvents", { keyPath: "reservePub" });
+    }
+  }
+
   class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
     constructor() {
       super("bankWithdrawUris", { keyPath: "talerWithdrawUri" });
@@ -1474,6 +1523,8 @@ export namespace Stores {
   export const bankWithdrawUris = new BankWithdrawUrisStore();
   export const refundEvents = new RefundEventsStore();
   export const payEvents = new PayEventsStore();
+  export const reserveUpdatedEvents = new ReserveUpdatedEventsStore();
+  export const exchangeUpdatedEvents = new ExchangeUpdatedEventsStore();
 }
 
 /* tslint:enable:completed-docs */
diff --git a/src/types/history.ts b/src/types/history.ts
index 54004b12..21000631 100644
--- a/src/types/history.ts
+++ b/src/types/history.ts
@@ -1,4 +1,5 @@
 import { Timestamp, RefreshReason } from "./walletTypes";
+import { ReserveTransaction } from "./ReserveTransaction";
 
 /*
  This file is part of GNU Taler
@@ -140,10 +141,7 @@ export interface HistoryReserveBalanceUpdatedEvent {
    */
   timestamp: Timestamp;
 
-  /**
-   * Unique identifier to query more information about this update.
-   */
-  reserveUpdateId: string;
+  newHistoryTransactions: ReserveTransaction[];
 
   /**
    * Condensed information about the reserve.
@@ -210,13 +208,7 @@ export interface HistoryTipAcceptedEvent {
   /**
    * Raw amount of the tip, without extra fees that apply.
    */
-  tipRawAmount: string;
-
-  /**
-   * Amount that the user effectively adds to their balance when
-   * the tip is accepted.
-   */
-  tipEffectiveAmount: string;
+  tipRaw: string;
 }
 
 /**
@@ -238,13 +230,7 @@ export interface HistoryTipDeclinedEvent {
   /**
    * Raw amount of the tip, without extra fees that apply.
    */
-  tipRawAmount: string;
-
-  /**
-   * Amount that the user effectively adds to their balance when
-   * the tip is accepted.
-   */
-  tipEffectiveAmount: string;
+  tipAmount: string;
 }
 
 /**
@@ -454,14 +440,7 @@ export interface OrderShortInfo {
   /**
    * Amount that must be paid for the contract.
    */
-  amountRequested: string;
-
-  /**
-   * Amount that would be subtracted from the wallet when paying,
-   * includes fees and funds lost due to refreshing or left-over
-   * amounts too small to refresh.
-   */
-  amountEffective: string;
+  amount: string;
 
   /**
    * Summary of the proposal, given by the merchant.
@@ -548,7 +527,7 @@ export interface HistoryPaymentSent {
   /**
    * Type tag.
    */
-  type: HistoryEventType.PaymentAborted;
+  type: HistoryEventType.PaymentSent;
 
   /**
    * Condensed info about the order that we already paid for.
@@ -584,7 +563,7 @@ export interface HistoryRefund {
    * Unique identifier for this refund.
    * (Identifies multiple refund permissions that were obtained at once.)
    */
-  refundId: string;
+  refundGroupId: string;
 
   /**
    * Part of the refund that couldn't be applied because
@@ -616,13 +595,22 @@ export interface HistoryRefreshedEvent {
    * Amount that is now available again because it has
    * been refreshed.
    */
-  amountRefreshed: string;
+  amountRefreshedEffective: string;
+
+  /**
+   * Amount that we spent for refreshing.
+   */
+  amountRefreshedRaw: string;
 
   /**
    * Why was the refreshing done?
    */
   refreshReason: RefreshReason;
 
+  numInputCoins: number;
+  numRefreshedInputCoins: number;
+  numOutputCoins: number;
+
   /**
    * Identifier for a refresh group, contains one or
    * more refresh session IDs.
diff --git a/src/types/pending.ts b/src/types/pending.ts
index d08d2c54..53932e8f 100644
--- a/src/types/pending.ts
+++ b/src/types/pending.ts
@@ -32,6 +32,7 @@ export const enum PendingOperationType {
   ProposalDownload = "proposal-download",
   Refresh = "refresh",
   Reserve = "reserve",
+  Recoup = "recoup",
   RefundApply = "refund-apply",
   RefundQuery = "refund-query",
   TipChoice = "tip-choice",
@@ -53,6 +54,7 @@ export type PendingOperationInfo = PendingOperationInfoCommon 
&
     | PendingRefundApplyOperation
     | PendingRefundQueryOperation
     | PendingReserveOperation
+    | PendingTipChoiceOperation
     | PendingTipPickupOperation
     | PendingWithdrawOperation
   );
@@ -115,6 +117,13 @@ export interface PendingTipPickupOperation {
   merchantTipId: string;
 }
 
+export interface PendingTipChoiceOperation {
+  type: PendingOperationType.TipChoice;
+  tipId: string;
+  merchantBaseUrl: string;
+  merchantTipId: string;
+}
+
 export interface PendingPayOperation {
   type: PendingOperationType.Pay;
   proposalId: string;
@@ -147,8 +156,18 @@ export interface PendingWithdrawOperation {
   numCoinsTotal: number;
 }
 
+export interface PendingOperationFlags {
+  isWaitingUser: boolean;
+  isError: boolean;
+  givesLifeness: boolean;
+}
+
 export interface PendingOperationInfoCommon {
+  /**
+   * Type of the pending operation.
+   */
   type: PendingOperationType;
+
   givesLifeness: boolean;
 }
 
diff --git a/src/types/talerTypes.ts b/src/types/talerTypes.ts
index df89b997..bb286b64 100644
--- a/src/types/talerTypes.ts
+++ b/src/types/talerTypes.ts
@@ -639,28 +639,6 @@ export class ReserveSigSingleton {
   static checked: (obj: any) => ReserveSigSingleton;
 }
 
-/**
- * Response to /reserve/status
- */
-@Checkable.Class()
-export class ReserveStatus {
-  /**
-   * Reserve signature.
-   */
-  @Checkable.String()
-  balance: string;
-
-  /**
-   * Reserve history, currently not used by the wallet.
-   */
-  @Checkable.Any()
-  history: any;
-
-  /**
-   * Create a ReserveSigSingleton from untyped JSON.
-   */
-  static checked: (obj: any) => ReserveStatus;
-}
 
 /**
  * Response of the merchant
@@ -942,3 +920,11 @@ export class TipPickupGetResponse {
    */
   static checked: (obj: any) => TipPickupGetResponse;
 }
+
+
+export type AmountString = string;
+export type Base32String = string;
+export type EddsaSignatureString = string;
+export type EddsaPublicKeyString = string;
+export type CoinPublicKeyString = string;
+export type TimestampString = string;
\ No newline at end of file
diff --git a/src/util/amounts.ts b/src/util/amounts.ts
index 26cee7f8..c8fb7679 100644
--- a/src/util/amounts.ts
+++ b/src/util/amounts.ts
@@ -22,7 +22,6 @@
  * Imports.
  */
 import { Checkable } from "./checkable";
-import { objectCodec, numberCodec, stringCodec, Codec } from "./codec";
 
 /**
  * Number of fractional units that one value unit represents.
@@ -68,12 +67,6 @@ export class AmountJson {
   static checked: (obj: any) => AmountJson;
 }
 
-const amountJsonCodec: Codec<AmountJson> = objectCodec<AmountJson>()
-  .property("value", numberCodec)
-  .property("fraction", numberCodec)
-  .property("currency", stringCodec)
-  .build("AmountJson");
-
 /**
  * Result of a possibly overflowing operation.
  */
diff --git a/src/util/codec-test.ts b/src/util/codec-test.ts
index 22f6a0a9..7c7c93c7 100644
--- a/src/util/codec-test.ts
+++ b/src/util/codec-test.ts
@@ -19,13 +19,7 @@
  */
 
 import test from "ava";
-import {
-  stringCodec,
-  objectCodec,
-  unionCodec,
-  Codec,
-  stringConstCodec,
-} from "./codec";
+import { Codec, makeCodecForObject, makeCodecForConstString, codecForString, 
makeCodecForUnion } from "./codec";
 
 interface MyObj {
   foo: string;
@@ -44,8 +38,8 @@ interface AltTwo {
 type MyUnion = AltOne | AltTwo;
 
 test("basic codec", t => {
-  const myObjCodec = objectCodec<MyObj>()
-    .property("foo", stringCodec)
+  const myObjCodec = makeCodecForObject<MyObj>()
+    .property("foo", codecForString)
     .build("MyObj");
   const res = myObjCodec.decode({ foo: "hello" });
   t.assert(res.foo === "hello");
@@ -56,15 +50,15 @@ test("basic codec", t => {
 });
 
 test("union", t => {
-  const altOneCodec: Codec<AltOne> = objectCodec<AltOne>()
-    .property("type", stringConstCodec("one"))
-    .property("foo", stringCodec)
+  const altOneCodec: Codec<AltOne> = makeCodecForObject<AltOne>()
+    .property("type", makeCodecForConstString("one"))
+    .property("foo", codecForString)
     .build("AltOne");
-  const altTwoCodec: Codec<AltTwo> = objectCodec<AltTwo>()
-    .property("type", stringConstCodec("two"))
-    .property("bar", stringCodec)
+  const altTwoCodec: Codec<AltTwo> = makeCodecForObject<AltTwo>()
+    .property("type", makeCodecForConstString("two"))
+    .property("bar", codecForString)
     .build("AltTwo");
-  const myUnionCodec: Codec<MyUnion> = unionCodec<MyUnion>()
+  const myUnionCodec: Codec<MyUnion> = makeCodecForUnion<MyUnion>()
     .discriminateOn("type")
     .alternative("one", altOneCodec)
     .alternative("two", altTwoCodec)
diff --git a/src/util/codec.ts b/src/util/codec.ts
index 0215ce79..a13816c5 100644
--- a/src/util/codec.ts
+++ b/src/util/codec.ts
@@ -74,16 +74,16 @@ interface Alternative {
   codec: Codec<any>;
 }
 
-class ObjectCodecBuilder<T, TC> {
+class ObjectCodecBuilder<OutputType, PartialOutputType> {
   private propList: Prop[] = [];
 
   /**
    * Define a property for the object.
    */
-  property<K extends keyof T & string, V extends T[K]>(
+  property<K extends keyof OutputType & string, V extends OutputType[K]>(
     x: K,
     codec: Codec<V>,
-  ): ObjectCodecBuilder<T, TC & SingletonRecord<K, V>> {
+  ): ObjectCodecBuilder<OutputType, PartialOutputType & SingletonRecord<K, V>> 
{
     this.propList.push({ name: x, codec: codec });
     return this as any;
   }
@@ -94,10 +94,10 @@ class ObjectCodecBuilder<T, TC> {
    * @param objectDisplayName name of the object that this codec operates on,
    *   used in error messages.
    */
-  build(objectDisplayName: string): Codec<TC> {
+  build(objectDisplayName: string): Codec<PartialOutputType> {
     const propList = this.propList;
     return {
-      decode(x: any, c?: Context): TC {
+      decode(x: any, c?: Context): PartialOutputType {
         if (!c) {
           c = {
             path: [`(${objectDisplayName})`],
@@ -112,24 +112,37 @@ class ObjectCodecBuilder<T, TC> {
           );
           obj[prop.name] = propVal;
         }
-        return obj as TC;
+        return obj as PartialOutputType;
       },
     };
   }
 }
 
-class UnionCodecBuilder<T, D extends keyof T, B, TC> {
+class UnionCodecBuilder<
+  TargetType,
+  TagPropertyLabel extends keyof TargetType,
+  CommonBaseType,
+  PartialTargetType
+> {
   private alternatives = new Map<any, Alternative>();
 
-  constructor(private discriminator: D, private baseCodec?: Codec<B>) {}
+  constructor(
+    private discriminator: TagPropertyLabel,
+    private baseCodec?: Codec<CommonBaseType>,
+  ) {}
 
   /**
    * Define a property for the object.
    */
   alternative<V>(
-    tagValue: T[D],
+    tagValue: TargetType[TagPropertyLabel],
     codec: Codec<V>,
-  ): UnionCodecBuilder<T, D, B, TC | V> {
+  ): UnionCodecBuilder<
+    TargetType,
+    TagPropertyLabel,
+    CommonBaseType,
+    PartialTargetType | V
+  > {
     this.alternatives.set(tagValue, { codec, tagValue });
     return this as any;
   }
@@ -140,7 +153,9 @@ class UnionCodecBuilder<T, D extends keyof T, B, TC> {
    * @param objectDisplayName name of the object that this codec operates on,
    *   used in error messages.
    */
-  build<R extends TC & B>(objectDisplayName: string): Codec<R> {
+  build<R extends PartialTargetType & CommonBaseType = never>(
+    objectDisplayName: string,
+  ): Codec<R> {
     const alternatives = this.alternatives;
     const discriminator = this.discriminator;
     const baseCodec = this.baseCodec;
@@ -174,50 +189,50 @@ class UnionCodecBuilder<T, D extends keyof T, B, TC> {
   }
 }
 
+export class UnionCodecPreBuilder<T> {
+  discriminateOn<D extends keyof T, B = {}>(
+    discriminator: D,
+    baseCodec?: Codec<B>,
+  ): UnionCodecBuilder<T, D, B, never> {
+    return new UnionCodecBuilder<T, D, B, never>(discriminator, baseCodec);
+  }
+}
+
 /**
- * Return a codec for a value that must be a string.
+ * Return a builder for a codec that decodes an object with properties.
  */
-export const stringCodec: Codec<string> = {
-  decode(x: any, c?: Context): string {
-    if (typeof x === "string") {
-      return x;
-    }
-    throw new DecodingError(`expected string at ${renderContext(c)}`);
-  },
-};
+export function makeCodecForObject<T>(): ObjectCodecBuilder<T, {}> {
+  return new ObjectCodecBuilder<T, {}>();
+}
+
+export function makeCodecForUnion<T>(): UnionCodecPreBuilder<T> {
+  return new UnionCodecPreBuilder<T>();
+}
 
 /**
- * Return a codec for a value that must be a string.
+ * Return a codec for a mapping from a string to values described by the inner 
codec.
  */
-export function stringConstCodec<V extends string>(s: V): Codec<V> {
+export function makeCodecForMap<T>(
+  innerCodec: Codec<T>,
+): Codec<{ [x: string]: T }> {
   return {
-    decode(x: any, c?: Context): V {
-      if (x === s) {
-        return x;
+    decode(x: any, c?: Context): { [x: string]: T } {
+      const map: { [x: string]: T } = {};
+      if (typeof x !== "object") {
+        throw new DecodingError(`expected object at ${renderContext(c)}`);
       }
-      throw new DecodingError(
-        `expected string constant "${s}" at ${renderContext(c)}`,
-      );
+      for (const i in x) {
+        map[i] = innerCodec.decode(x[i], joinContext(c, `[${i}]`));
+      }
+      return map;
     },
   };
 }
 
-/**
- * Return a codec for a value that must be a number.
- */
-export const numberCodec: Codec<number> = {
-  decode(x: any, c?: Context): number {
-    if (typeof x === "number") {
-      return x;
-    }
-    throw new DecodingError(`expected number at ${renderContext(c)}`);
-  },
-};
-
 /**
  * Return a codec for a list, containing values described by the inner codec.
  */
-export function listCodec<T>(innerCodec: Codec<T>): Codec<T[]> {
+export function makeCodecForList<T>(innerCodec: Codec<T>): Codec<T[]> {
   return {
     decode(x: any, c?: Context): T[] {
       const arr: T[] = [];
@@ -233,39 +248,45 @@ export function listCodec<T>(innerCodec: Codec<T>): 
Codec<T[]> {
 }
 
 /**
- * Return a codec for a mapping from a string to values described by the inner 
codec.
+ * Return a codec for a value that must be a number.
  */
-export function mapCodec<T>(innerCodec: Codec<T>): Codec<{ [x: string]: T }> {
-  return {
-    decode(x: any, c?: Context): { [x: string]: T } {
-      const map: { [x: string]: T } = {};
-      if (typeof x !== "object") {
-        throw new DecodingError(`expected object at ${renderContext(c)}`);
-      }
-      for (const i in x) {
-        map[i] = innerCodec.decode(x[i], joinContext(c, `[${i}]`));
-      }
-      return map;
-    },
-  };
-}
+export const codecForNumber: Codec<number> = {
+  decode(x: any, c?: Context): number {
+    if (typeof x === "number") {
+      return x;
+    }
+    throw new DecodingError(`expected number at ${renderContext(c)}`);
+  },
+};
 
-export class UnionCodecPreBuilder<T> {
-  discriminateOn<D extends keyof T, B>(
-    discriminator: D,
-    baseCodec?: Codec<B>,
-  ): UnionCodecBuilder<T, D, B, never> {
-    return new UnionCodecBuilder<T, D, B, never>(discriminator, baseCodec);
-  }
-}
+/**
+ * Return a codec for a value that must be a string.
+ */
+export const codecForString: Codec<string> = {
+  decode(x: any, c?: Context): string {
+    if (typeof x === "string") {
+      return x;
+    }
+    throw new DecodingError(`expected string at ${renderContext(c)}`);
+  },
+};
 
 /**
- * Return a builder for a codec that decodes an object with properties.
+ * Return a codec for a value that must be a string.
  */
-export function objectCodec<T>(): ObjectCodecBuilder<T, {}> {
-  return new ObjectCodecBuilder<T, {}>();
+export function makeCodecForConstString<V extends string>(s: V): Codec<V> {
+  return {
+    decode(x: any, c?: Context): V {
+      if (x === s) {
+        return x;
+      }
+      throw new DecodingError(
+        `expected string constant "${s}" at ${renderContext(c)}`,
+      );
+    },
+  };
 }
 
-export function unionCodec<T>(): UnionCodecPreBuilder<T> {
-  return new UnionCodecPreBuilder<T>();
+export function typecheckedCodec<T = undefined>(c: Codec<T>): Codec<T> {
+  return c;
 }
diff --git a/src/util/helpers.ts b/src/util/helpers.ts
index 99d046f0..8136f44f 100644
--- a/src/util/helpers.ts
+++ b/src/util/helpers.ts
@@ -214,3 +214,13 @@ export function strcmp(s1: string, s2: string): number {
   }
   return 0;
 }
+
+/**
+ * Run a function and return its result.
+ * 
+ * Used as a nicer-looking way to do immediately invoked function
+ * expressions (IFFEs).
+ */
+export function runBlock<T>(f: () => T) {
+  return f();
+}
\ No newline at end of file
diff --git a/src/util/query.ts b/src/util/query.ts
index 08a8fec0..217c0674 100644
--- a/src/util/query.ts
+++ b/src/util/query.ts
@@ -176,6 +176,17 @@ class ResultStream<T> {
     return arr;
   }
 
+  async forEachAsync(f: (x: T) => Promise<void>): Promise<void> {
+    while (true) {
+      const x = await this.next();
+      if (x.hasValue) {
+        await f(x.value);
+      } else {
+        break;
+      }
+    }
+  }
+
   async forEach(f: (x: T) => void): Promise<void> {
     while (true) {
       const x = await this.next();
diff --git a/src/wallet.ts b/src/wallet.ts
index aca8a18a..3d28d089 100644
--- a/src/wallet.ts
+++ b/src/wallet.ts
@@ -24,9 +24,7 @@
  */
 import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi";
 import { HttpRequestLibrary } from "./util/http";
-import {
-  Database
-} from "./util/query";
+import { Database } from "./util/query";
 
 import { AmountJson } from "./util/amounts";
 import * as Amounts from "./util/amounts";
@@ -99,10 +97,19 @@ import { payback } from "./operations/payback";
 import { TimerGroup } from "./util/timer";
 import { AsyncCondition } from "./util/promiseUtils";
 import { AsyncOpMemoSingle } from "./util/asyncMemo";
-import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType 
} from "./types/pending";
+import {
+  PendingOperationInfo,
+  PendingOperationsResponse,
+  PendingOperationType,
+} from "./types/pending";
 import { WalletNotification, NotificationType } from "./types/notifications";
 import { HistoryQuery, HistoryEvent } from "./types/history";
-import { processPurchaseQueryRefund, processPurchaseApplyRefund, 
getFullRefundFees, applyRefund } from "./operations/refund";
+import {
+  processPurchaseQueryRefund,
+  processPurchaseApplyRefund,
+  getFullRefundFees,
+  applyRefund,
+} from "./operations/refund";
 
 /**
  * Wallet protocol version spoken with the exchange
@@ -184,11 +191,7 @@ export class Wallet {
         await updateExchangeFromUrl(this.ws, pending.exchangeBaseUrl, 
forceNow);
         break;
       case PendingOperationType.Refresh:
-        await processRefreshGroup(
-          this.ws,
-          pending.refreshGroupId,
-          forceNow,
-        );
+        await processRefreshGroup(this.ws, pending.refreshGroupId, forceNow);
         break;
       case PendingOperationType.Reserve:
         await processReserve(this.ws, pending.reservePub, forceNow);
@@ -203,9 +206,12 @@ export class Wallet {
       case PendingOperationType.ProposalChoice:
         // Nothing to do, user needs to accept/reject
         break;
-        case PendingOperationType.ProposalDownload:
+      case PendingOperationType.ProposalDownload:
         await processDownloadProposal(this.ws, pending.proposalId, forceNow);
         break;
+      case PendingOperationType.TipChoice:
+        // Nothing to do, user needs to accept/reject
+        break;
       case PendingOperationType.TipPickup:
         await processTip(this.ws, pending.tipId, forceNow);
         break;
@@ -470,9 +476,16 @@ export class Wallet {
 
   async refresh(oldCoinPub: string): Promise<void> {
     try {
-      const refreshGroupId = await 
this.db.runWithWriteTransaction([Stores.refreshGroups], async (tx) => {
-        return await createRefreshGroup(tx, [{ coinPub: oldCoinPub }], 
RefreshReason.Manual);
-      });
+      const refreshGroupId = await this.db.runWithWriteTransaction(
+        [Stores.refreshGroups],
+        async tx => {
+          return await createRefreshGroup(
+            tx,
+            [{ coinPub: oldCoinPub }],
+            RefreshReason.Manual,
+          );
+        },
+      );
       await processRefreshGroup(this.ws, refreshGroupId.refreshGroupId);
     } catch (e) {
       this.latch.trigger();
@@ -510,10 +523,9 @@ export class Wallet {
   }
 
   async getDenoms(exchangeUrl: string): Promise<DenominationRecord[]> {
-    const denoms = await this.db.iterIndex(
-      Stores.denominations.exchangeBaseUrlIndex,
-      exchangeUrl,
-    ).toArray();
+    const denoms = await this.db
+      .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeUrl)
+      .toArray();
     return denoms;
   }
 
@@ -536,15 +548,15 @@ export class Wallet {
   }
 
   async getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> {
-    return await this.db.iter(Stores.reserves).filter(
-      r => r.exchangeBaseUrl === exchangeBaseUrl,
-    );
+    return await this.db
+      .iter(Stores.reserves)
+      .filter(r => r.exchangeBaseUrl === exchangeBaseUrl);
   }
 
   async getCoinsForExchange(exchangeBaseUrl: string): Promise<CoinRecord[]> {
-    return await this.db.iter(Stores.coins).filter(
-      c => c.exchangeBaseUrl === exchangeBaseUrl,
-    );
+    return await this.db
+      .iter(Stores.coins)
+      .filter(c => c.exchangeBaseUrl === exchangeBaseUrl);
   }
 
   async getCoins(): Promise<CoinRecord[]> {
@@ -556,9 +568,7 @@ export class Wallet {
   }
 
   async getPaybackReserves(): Promise<ReserveRecord[]> {
-    return await this.db.iter(Stores.reserves).filter(
-      r => r.hasPayback,
-    );
+    return await this.db.iter(Stores.reserves).filter(r => r.hasPayback);
   }
 
   /**
@@ -691,9 +701,9 @@ export class Wallet {
     if (!purchase) {
       throw Error("unknown purchase");
     }
-    const refundsDoneAmounts = 
Object.values(purchase.refundState.refundsDone).map(x =>
-      Amounts.parseOrThrow(x.perm.refund_amount),
-    );
+    const refundsDoneAmounts = Object.values(
+      purchase.refundState.refundsDone,
+    ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
     const refundsPendingAmounts = Object.values(
       purchase.refundState.refundsPending,
     ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
@@ -701,12 +711,12 @@ export class Wallet {
       ...refundsDoneAmounts,
       ...refundsPendingAmounts,
     ]).amount;
-    const refundsDoneFees = 
Object.values(purchase.refundState.refundsDone).map(x =>
-      Amounts.parseOrThrow(x.perm.refund_amount),
-    );
-    const refundsPendingFees = 
Object.values(purchase.refundState.refundsPending).map(x =>
-      Amounts.parseOrThrow(x.perm.refund_amount),
-    );
+    const refundsDoneFees = Object.values(
+      purchase.refundState.refundsDone,
+    ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
+    const refundsPendingFees = Object.values(
+      purchase.refundState.refundsPending,
+    ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
     const totalRefundFees = Amounts.sum([
       ...refundsDoneFees,
       ...refundsPendingFees,
diff --git a/tsconfig.json b/tsconfig.json
index 81e529fa..ab2c42e1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -60,6 +60,8 @@
     "src/operations/state.ts",
     "src/operations/tip.ts",
     "src/operations/withdraw.ts",
+    "src/types/ReserveStatus.ts",
+    "src/types/ReserveTransaction.ts",
     "src/types/dbTypes.ts",
     "src/types/history.ts",
     "src/types/notifications.ts",

-- 
To stop receiving notification emails like this one, please contact
address@hidden.



reply via email to

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