gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] branch master updated (a5137c32 -> 829acdd3)


From: gnunet
Subject: [taler-wallet-core] branch master updated (a5137c32 -> 829acdd3)
Date: Tue, 03 Dec 2019 14:40:10 +0100

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

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

    from a5137c32 rollup
     new c33dd757 pending operations (pay/proposals)
     new 8683c936 version bump / pending balance tweaks
     new 829acdd3 android

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


Summary of changes:
 manifest.json               |   4 +-
 src/android/index.ts        |  26 ++-
 src/dbTypes.ts              |  73 ++++--
 src/util/query.ts           |  42 ++--
 src/wallet-impl/balance.ts  |  25 ++-
 src/wallet-impl/history.ts  |   4 +-
 src/wallet-impl/pay.ts      | 524 ++++++++++++++++++++++++++++++++------------
 src/wallet-impl/pending.ts  | 357 ++++++++++++++++--------------
 src/wallet-impl/refund.ts   | 245 ---------------------
 src/wallet-impl/reserves.ts |  17 +-
 src/wallet-impl/tip.ts      |   3 +-
 src/wallet-impl/withdraw.ts |   3 +
 src/wallet.ts               |  19 +-
 src/walletTypes.ts          |  26 ++-
 tsconfig.json               |   1 -
 15 files changed, 757 insertions(+), 612 deletions(-)
 delete mode 100644 src/wallet-impl/refund.ts

diff --git a/manifest.json b/manifest.json
index 5abbccae..826f5241 100644
--- a/manifest.json
+++ b/manifest.json
@@ -4,8 +4,8 @@
   "name": "GNU Taler Wallet (git)",
   "description": "Privacy preserving and transparent payments",
   "author": "GNU Taler Developers",
-  "version": "0.6.70",
-  "version_name": "0.6.0pre3",
+  "version": "0.6.71",
+  "version_name": "0.6.0pre4",
 
   "minimum_chrome_version": "51",
   "minimum_opera_version": "36",
diff --git a/src/android/index.ts b/src/android/index.ts
index 71144176..4d0136ec 100644
--- a/src/android/index.ts
+++ b/src/android/index.ts
@@ -157,6 +157,7 @@ export function installAndroidWalletListener() {
       case "withdrawTestkudos": {
         const wallet = await wp.promise;
         await withdrawTestBalance(wallet);
+        result = {};
         break;
       }
       case "getHistory": {
@@ -164,6 +165,12 @@ export function installAndroidWalletListener() {
         result = await wallet.getHistory();
         break;
       }
+      case "retryPendingNow": {
+        const wallet = await wp.promise;
+        await wallet.runPending(true);
+        result = {};
+        break;
+      }
       case "preparePay": {
         const wallet = await wp.promise;
         result = await wallet.preparePay(msg.args.url);
@@ -197,19 +204,28 @@ export function installAndroidWalletListener() {
         break;
       }
       case "reset": {
-        const wallet = await wp.promise;
-        wallet.stop();
-        wp = openPromise<Wallet>();
-        if (walletArgs && walletArgs.persistentStoragePath) {
+        const oldArgs = walletArgs;
+        walletArgs = { ...oldArgs };
+        if (oldArgs && oldArgs.persistentStoragePath) {
           try {
-            fs.unlinkSync(walletArgs.persistentStoragePath);
+            fs.unlinkSync(oldArgs.persistentStoragePath);
           } catch (e) {
             console.error("Error while deleting the wallet db:", e);
           }
           // Prevent further storage!
           walletArgs.persistentStoragePath = undefined;
         }
+        const wallet = await wp.promise;
+        wallet.stop();
+        wp = openPromise<Wallet>();
         maybeWallet = undefined;
+        const w = await getDefaultNodeWallet(walletArgs);
+        maybeWallet = w;
+        w.runLoopScheduledRetries().catch((e) => {
+          console.error("Error during wallet retry loop", e);
+        });
+        wp.resolve(w);
+        result = {};
         break;
       }
       default:
diff --git a/src/dbTypes.ts b/src/dbTypes.ts
index 731f0358..66c4fa8b 100644
--- a/src/dbTypes.ts
+++ b/src/dbTypes.ts
@@ -44,7 +44,7 @@ import { Timestamp, OperationError } from "./walletTypes";
  * In the future we might consider adding migration functions for
  * each version increment.
  */
-export const WALLET_DB_VERSION = 27;
+export const WALLET_DB_VERSION = 28;
 
 export enum ReserveRecordStatus {
   /**
@@ -586,22 +586,30 @@ export interface CoinRecord {
 }
 
 export enum ProposalStatus {
+  /**
+   * Not downloaded yet.
+   */
+  DOWNLOADING = "downloading",
+  /**
+   * Proposal downloaded, but the user needs to accept/reject it.
+   */
   PROPOSED = "proposed",
+  /**
+   * The user has accepted the proposal.
+   */
   ACCEPTED = "accepted",
+  /**
+   * The user has rejected the proposal.
+   */
   REJECTED = "rejected",
-}
-
-/**
- * Record for a downloaded order, stored in the wallet's database.
- */
-@Checkable.Class()
-export class ProposalRecord {
   /**
-   * URL where the proposal was downloaded.
+   * Downloaded proposal was detected as a re-purchase.
    */
-  @Checkable.String()
-  url: string;
+  REPURCHASE = "repurchase",
+}
 
+@Checkable.Class()
+export class ProposalDownload {
   /**
    * The contract that was offered by the merchant.
    */
@@ -615,10 +623,27 @@ export class ProposalRecord {
   merchantSig: string;
 
   /**
-   * Hash of the contract terms.
+   * Signature by the merchant over the contract details.
    */
   @Checkable.String()
   contractTermsHash: string;
+}
+
+/**
+ * Record for a downloaded order, stored in the wallet's database.
+ */
+@Checkable.Class()
+export class ProposalRecord {
+  /**
+   * URL where the proposal was downloaded.
+   */
+  @Checkable.String()
+  url: string;
+
+  /**
+   * Downloaded data from the merchant.
+   */
+  download: ProposalDownload | undefined;
 
   /**
    * Unique ID when the order is stored in the wallet DB.
@@ -639,9 +664,18 @@ export class ProposalRecord {
   @Checkable.String()
   noncePriv: string;
 
+  /**
+   * Public key for the nonce.
+   */
+  @Checkable.String()
+  noncePub: string;
+
   @Checkable.String()
   proposalStatus: ProposalStatus;
 
+  @Checkable.String()
+  repurchaseProposalId: string | undefined;
+
   /**
    * Session ID we got when downloading the contract.
    */
@@ -911,6 +945,12 @@ export interface PurchaseRecord {
    * The abort (with refund) was completed for this (incomplete!) purchase.
    */
   abortDone: boolean;
+
+  /**
+   * Proposal ID for this purchase.  Uniquely identifies the
+   * purchase and the proposal.
+   */
+  proposalId: string;
 }
 
 /**
@@ -1005,11 +1045,12 @@ export interface WithdrawalSessionRecord {
    */
   finishTimestamp?: Timestamp;
 
+  totalCoinValue: AmountJson;
+
   /**
-   * Amount that is being withdrawn with this operation.
-   * This does not include fees.
+   * Amount including fees.
    */
-  withdrawalAmount: string;
+  rawWithdrawalAmount: AmountJson;
 
   denoms: string[];
 
@@ -1076,7 +1117,7 @@ export namespace Stores {
 
   class PurchasesStore extends Store<PurchaseRecord> {
     constructor() {
-      super("purchases", { keyPath: "contractTermsHash" });
+      super("purchases", { keyPath: "proposalId" });
     }
 
     fulfillmentUrlIndex = new Index<string, PurchaseRecord>(
diff --git a/src/util/query.ts b/src/util/query.ts
index 6942d471..b1b19665 100644
--- a/src/util/query.ts
+++ b/src/util/query.ts
@@ -25,7 +25,6 @@
  */
 import { openPromise } from "./promiseUtils";
 
-
 /**
  * Result of an inner join.
  */
@@ -67,7 +66,7 @@ export interface IndexOptions {
 }
 
 function requestToPromise(req: IDBRequest): Promise<any> {
-  const stack = Error("Failed request was started here.")
+  const stack = Error("Failed request was started here.");
   return new Promise((resolve, reject) => {
     req.onsuccess = () => {
       resolve(req.result);
@@ -103,7 +102,7 @@ export async function oneShotGet<T>(
 ): Promise<T | undefined> {
   const tx = db.transaction([store.name], "readonly");
   const req = tx.objectStore(store.name).get(key);
-  const v = await requestToPromise(req)
+  const v = await requestToPromise(req);
   await transactionToPromise(tx);
   return v;
 }
@@ -335,6 +334,17 @@ class TransactionHandle {
     return requestToPromise(req);
   }
 
+  getIndexed<S extends IDBValidKey, T>(
+    index: Index<S, T>,
+    key: any,
+  ): Promise<T | undefined> {
+    const req = this.tx
+      .objectStore(index.storeName)
+      .index(index.indexName)
+      .get(key);
+    return requestToPromise(req);
+  }
+
   iter<T>(store: Store<T>, key?: any): ResultStream<T> {
     const req = this.tx.objectStore(store.name).openCursor(key);
     return new ResultStream<T>(req);
@@ -407,18 +417,20 @@ function runWithTransaction<T>(
     };
     const th = new TransactionHandle(tx);
     const resP = f(th);
-    resP.then(result => {
-      gotFunResult = true;
-      funResult = result;
-    }).catch((e) => {
-      if (e == TransactionAbort) {
-        console.info("aborting transaction");
-      } else {
-        tx.abort();
-        console.error("Transaction failed:", e);
-        console.error(stack);
-      }
-    });
+    resP
+      .then(result => {
+        gotFunResult = true;
+        funResult = result;
+      })
+      .catch(e => {
+        if (e == TransactionAbort) {
+          console.info("aborting transaction");
+        } else {
+          tx.abort();
+          console.error("Transaction failed:", e);
+          console.error(stack);
+        }
+      });
   });
 }
 
diff --git a/src/wallet-impl/balance.ts b/src/wallet-impl/balance.ts
index 0abc9663..94d65fa9 100644
--- a/src/wallet-impl/balance.ts
+++ b/src/wallet-impl/balance.ts
@@ -17,10 +17,7 @@
 /**
  * Imports.
  */
-import {
-  WalletBalance,
-  WalletBalanceEntry,
-} from "../walletTypes";
+import { WalletBalance, WalletBalanceEntry } from "../walletTypes";
 import { runWithReadTransaction } from "../util/query";
 import { InternalWalletState } from "./state";
 import { Stores, TipRecord, CoinStatus } from "../dbTypes";
@@ -77,7 +74,7 @@ export async function getBalances(
 
   await runWithReadTransaction(
     ws.db,
-    [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases],
+    [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases, 
Stores.withdrawalSession],
     async tx => {
       await tx.iter(Stores.coins).forEach(c => {
         if (c.suspended) {
@@ -121,6 +118,24 @@ export async function getBalances(
         );
       });
 
+      await tx.iter(Stores.withdrawalSession).forEach(wds => {
+        let w = wds.totalCoinValue;
+        for (let i = 0; i < wds.planchets.length; i++) {
+          if (wds.withdrawn[i]) {
+            const p = wds.planchets[i];
+            if (p) {
+              w = Amounts.sub(w, p.coinValue).amount;
+            }
+          }
+        }
+        addTo(
+          balanceStore,
+          "pendingIncoming",
+          w,
+          wds.exchangeBaseUrl,
+        );
+      });
+
       await tx.iter(Stores.purchases).forEach(t => {
         if (t.finished) {
           return;
diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts
index 976dab88..dfc683e6 100644
--- a/src/wallet-impl/history.ts
+++ b/src/wallet-impl/history.ts
@@ -39,6 +39,7 @@ export async function getHistory(
   // This works as timestamps are guaranteed to be monotonically
   // increasing even
 
+  /*
   const proposals = await oneShotIter(ws.db, Stores.proposals).toArray();
   for (const p of proposals) {
     history.push({
@@ -51,6 +52,7 @@ export async function getHistory(
       explicit: false,
     });
   }
+  */
 
   const withdrawals = await oneShotIter(
     ws.db,
@@ -59,7 +61,7 @@ export async function getHistory(
   for (const w of withdrawals) {
     history.push({
       detail: {
-        withdrawalAmount: w.withdrawalAmount,
+        withdrawalAmount: w.rawWithdrawalAmount,
       },
       timestamp: w.startTimestamp,
       type: "withdraw",
diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts
index 31a1500e..9942139a 100644
--- a/src/wallet-impl/pay.ts
+++ b/src/wallet-impl/pay.ts
@@ -22,6 +22,8 @@ import {
   PayReq,
   Proposal,
   ContractTerms,
+  MerchantRefundPermission,
+  RefundRequest,
 } from "../talerTypes";
 import {
   Timestamp,
@@ -39,6 +41,7 @@ import {
   runWithWriteTransaction,
   oneShotPut,
   oneShotGetIndexed,
+  oneShotMutate,
 } from "../util/query";
 import {
   Stores,
@@ -55,12 +58,12 @@ import {
   strcmp,
   extractTalerStamp,
   canonicalJson,
+  extractTalerStampOrThrow,
 } from "../util/helpers";
 import { Logger } from "../util/logging";
 import { InternalWalletState } from "./state";
-import { parsePayUri } from "../util/taleruri";
+import { parsePayUri, parseRefundUri } from "../util/taleruri";
 import { getTotalRefreshCost, refresh } from "./refresh";
-import { acceptRefundResponse } from "./refund";
 import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
 
 export interface SpeculativePayData {
@@ -320,31 +323,41 @@ async function recordConfirmPay(
   payCoinInfo: PayCoinInfo,
   chosenExchange: string,
 ): Promise<PurchaseRecord> {
+  const d = proposal.download;
+  if (!d) {
+    throw Error("proposal is in invalid state");
+  }
   const payReq: PayReq = {
     coins: payCoinInfo.sigs,
-    merchant_pub: proposal.contractTerms.merchant_pub,
+    merchant_pub: d.contractTerms.merchant_pub,
     mode: "pay",
-    order_id: proposal.contractTerms.order_id,
+    order_id: d.contractTerms.order_id,
   };
   const t: PurchaseRecord = {
     abortDone: false,
     abortRequested: false,
-    contractTerms: proposal.contractTerms,
-    contractTermsHash: proposal.contractTermsHash,
+    contractTerms: d.contractTerms,
+    contractTermsHash: d.contractTermsHash,
     finished: false,
     lastSessionId: undefined,
-    merchantSig: proposal.merchantSig,
+    merchantSig: d.merchantSig,
     payReq,
     refundsDone: {},
     refundsPending: {},
     timestamp: getTimestampNow(),
     timestamp_refund: undefined,
+    proposalId: proposal.proposalId,
   };
 
   await runWithWriteTransaction(
     ws.db,
-    [Stores.coins, Stores.purchases],
+    [Stores.coins, Stores.purchases, Stores.proposals],
     async tx => {
+      const p = await tx.get(Stores.proposals, proposal.proposalId);
+      if (p) {
+        p.proposalStatus = ProposalStatus.ACCEPTED;
+        await tx.put(Stores.proposals, p);
+      }
       await tx.put(Stores.purchases, t);
       for (let c of payCoinInfo.updatedCoins) {
         await tx.put(Stores.coins, c);
@@ -360,7 +373,7 @@ async function recordConfirmPay(
 function getNextUrl(contractTerms: ContractTerms): string {
   const f = contractTerms.fulfillment_url;
   if (f.startsWith("http://";) || f.startsWith("https://";)) {
-    const fu = new URL(contractTerms.fulfillment_url)
+    const fu = new URL(contractTerms.fulfillment_url);
     fu.searchParams.set("order_id", contractTerms.order_id);
     return fu.href;
   } else {
@@ -370,9 +383,9 @@ function getNextUrl(contractTerms: ContractTerms): string {
 
 export async function abortFailedPayment(
   ws: InternalWalletState,
-  contractTermsHash: string,
+  proposalId: string,
 ): Promise<void> {
-  const purchase = await oneShotGet(ws.db, Stores.purchases, 
contractTermsHash);
+  const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
   if (!purchase) {
     throw Error("Purchase not found, unable to abort with refund");
   }
@@ -409,7 +422,7 @@ export async function abortFailedPayment(
   await acceptRefundResponse(ws, refundResponse);
 
   await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
-    const p = await tx.get(Stores.purchases, purchase.contractTermsHash);
+    const p = await tx.get(Stores.purchases, proposalId);
     if (!p) {
       return;
     }
@@ -418,6 +431,76 @@ export async function abortFailedPayment(
   });
 }
 
+export async function processDownloadProposal(
+  ws: InternalWalletState,
+  proposalId: string,
+): Promise<void> {
+  const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
+  if (!proposal) {
+    return;
+  }
+  if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
+    return;
+  }
+  const parsed_url = new URL(proposal.url);
+  parsed_url.searchParams.set("nonce", proposal.noncePub);
+  const urlWithNonce = parsed_url.href;
+  console.log("downloading contract from '" + urlWithNonce + "'");
+  let resp;
+  try {
+    resp = await ws.http.get(urlWithNonce);
+  } catch (e) {
+    console.log("contract download failed", e);
+    throw e;
+  }
+
+  const proposalResp = Proposal.checked(resp.responseJson);
+
+  const contractTermsHash = await ws.cryptoApi.hashString(
+    canonicalJson(proposalResp.contract_terms),
+  );
+
+  const fulfillmentUrl = proposalResp.contract_terms.fulfillment_url;
+
+  await runWithWriteTransaction(
+    ws.db,
+    [Stores.proposals, Stores.purchases],
+    async tx => {
+      const p = await tx.get(Stores.proposals, proposalId);
+      if (!p) {
+        return;
+      }
+      if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
+        return;
+      }
+      if (
+        fulfillmentUrl.startsWith("http://";) ||
+        fulfillmentUrl.startsWith("https://";)
+      ) {
+        const differentPurchase = await tx.getIndexed(
+          Stores.purchases.fulfillmentUrlIndex,
+          fulfillmentUrl,
+        );
+        if (differentPurchase) {
+          p.proposalStatus = ProposalStatus.REPURCHASE;
+          p.repurchaseProposalId = differentPurchase.proposalId;
+          await tx.put(Stores.proposals, p);
+          return;
+        }
+      }
+      p.download = {
+        contractTerms: proposalResp.contract_terms,
+        merchantSig: proposalResp.sig,
+        contractTermsHash,
+      };
+      p.proposalStatus = ProposalStatus.PROPOSED;
+      await tx.put(Stores.proposals, p);
+    },
+  );
+
+  ws.notifier.notify();
+}
+
 /**
  * Download a proposal and store it in the database.
  * Returns an id for it to retrieve it later.
@@ -425,7 +508,7 @@ export async function abortFailedPayment(
  * @param sessionId Current session ID, if the proposal is being
  *  downloaded in the context of a session ID.
  */
-async function downloadProposal(
+async function startDownloadProposal(
   ws: InternalWalletState,
   url: string,
   sessionId?: string,
@@ -436,55 +519,38 @@ async function downloadProposal(
     url,
   );
   if (oldProposal) {
+    await processDownloadProposal(ws, oldProposal.proposalId);
     return oldProposal.proposalId;
   }
 
   const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
-  const parsed_url = new URL(url);
-  parsed_url.searchParams.set("nonce", pub);
-  const urlWithNonce = parsed_url.href;
-  console.log("downloading contract from '" + urlWithNonce + "'");
-  let resp;
-  try {
-    resp = await ws.http.get(urlWithNonce);
-  } catch (e) {
-    console.log("contract download failed", e);
-    throw e;
-  }
-
-  const proposal = Proposal.checked(resp.responseJson);
-
-  const contractTermsHash = await ws.cryptoApi.hashString(
-    canonicalJson(proposal.contract_terms),
-  );
-
   const proposalId = encodeCrock(getRandomBytes(32));
 
   const proposalRecord: ProposalRecord = {
-    contractTerms: proposal.contract_terms,
-    contractTermsHash,
-    merchantSig: proposal.sig,
+    download: undefined,
     noncePriv: priv,
+    noncePub: pub,
     timestamp: getTimestampNow(),
     url,
     downloadSessionId: sessionId,
     proposalId: proposalId,
-    proposalStatus: ProposalStatus.PROPOSED,
+    proposalStatus: ProposalStatus.DOWNLOADING,
+    repurchaseProposalId: undefined,
   };
-  await oneShotPut(ws.db, Stores.proposals, proposalRecord);
-  ws.notifier.notify();
 
+  await oneShotPut(ws.db, Stores.proposals, proposalRecord);
+  await processDownloadProposal(ws, proposalId);
   return proposalId;
 }
 
-async function submitPay(
+export async function submitPay(
   ws: InternalWalletState,
-  contractTermsHash: string,
+  proposalId: string,
   sessionId: string | undefined,
 ): Promise<ConfirmPayResult> {
-  const purchase = await oneShotGet(ws.db, Stores.purchases, 
contractTermsHash);
+  const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
   if (!purchase) {
-    throw Error("Purchase not found: " + contractTermsHash);
+    throw Error("Purchase not found: " + proposalId);
   }
   if (purchase.abortRequested) {
     throw Error("not submitting payment for aborted purchase");
@@ -507,7 +573,7 @@ async function submitPay(
   const merchantPub = purchase.contractTerms.merchant_pub;
   const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
     merchantResp.sig,
-    contractTermsHash,
+    purchase.contractTermsHash,
     merchantPub,
   );
   if (!valid) {
@@ -532,14 +598,16 @@ async function submitPay(
     [Stores.coins, Stores.purchases],
     async tx => {
       for (let c of modifiedCoins) {
-        tx.put(Stores.coins, c);
+        await tx.put(Stores.coins, c);
       }
-      tx.put(Stores.purchases, purchase);
+      await tx.put(Stores.purchases, purchase);
     },
   );
 
   for (const c of purchase.payReq.coins) {
-    refresh(ws, c.coin_pub);
+    refresh(ws, c.coin_pub).catch(e => {
+      console.log("error in refreshing after payment:", e);
+    });
   }
 
   const nextUrl = getNextUrl(purchase.contractTerms);
@@ -570,100 +638,67 @@ export async function preparePay(
     };
   }
 
-  let proposalId: string;
-  try {
-    proposalId = await downloadProposal(
-      ws,
-      uriResult.downloadUrl,
-      uriResult.sessionId,
-    );
-  } catch (e) {
-    return {
-      status: "error",
-      error: e.toString(),
-    };
-  }
-  const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
-  if (!proposal) {
-    throw Error(`could not get proposal ${proposalId}`);
-  }
-
-  console.log("proposal", proposal);
-
-  const differentPurchase = await oneShotGetIndexed(
-    ws.db,
-    Stores.purchases.fulfillmentUrlIndex,
-    proposal.contractTerms.fulfillment_url,
+  const proposalId = await startDownloadProposal(
+    ws,
+    uriResult.downloadUrl,
+    uriResult.sessionId,
   );
 
-  let fulfillmentUrl = proposal.contractTerms.fulfillment_url;
-  let doublePurchaseDetection = false;
-  if (fulfillmentUrl.startsWith("http")) {
-    doublePurchaseDetection = true;
+  let proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
+  if (!proposal) {
+    throw Error(`could not get proposal ${proposalId}`);
   }
-
-  if (differentPurchase && doublePurchaseDetection) {
-    // We do this check to prevent merchant B to find out if we bought a
-    // digital product with merchant A by abusing the existing payment
-    // redirect feature.
-    if (
-      differentPurchase.contractTerms.merchant_pub !=
-      proposal.contractTerms.merchant_pub
-    ) {
-      console.warn(
-        "merchant with different public key offered contract with same 
fulfillment URL as an existing purchase",
-      );
-    } else {
-      if (uriResult.sessionId) {
-        await submitPay(
-          ws,
-          differentPurchase.contractTermsHash,
-          uriResult.sessionId,
-        );
-      }
-      return {
-        status: "paid",
-        contractTerms: differentPurchase.contractTerms,
-        nextUrl: getNextUrl(differentPurchase.contractTerms),
-      };
+  if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
+    const existingProposalId = proposal.repurchaseProposalId;
+    if (!existingProposalId) {
+      throw Error("invalid proposal state");
+    }
+    proposal = await oneShotGet(ws.db, Stores.proposals, existingProposalId);
+    if (!proposal) {
+      throw Error("existing proposal is in wrong state");
     }
   }
+  const d = proposal.download;
+  if (!d) {
+    console.error("bad proposal", proposal);
+    throw Error("proposal is in invalid state");
+  }
+  const contractTerms = d.contractTerms;
+  const merchantSig = d.merchantSig;
+  if (!contractTerms || !merchantSig) {
+    throw Error("BUG: proposal is in invalid state");
+  }
+
+  console.log("proposal", proposal);
 
   // First check if we already payed for it.
-  const purchase = await oneShotGet(
-    ws.db,
-    Stores.purchases,
-    proposal.contractTermsHash,
-  );
+  const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
 
   if (!purchase) {
-    const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount);
+    const paymentAmount = Amounts.parseOrThrow(contractTerms.amount);
     let wireFeeLimit;
-    if (proposal.contractTerms.max_wire_fee) {
-      wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee);
+    if (contractTerms.max_wire_fee) {
+      wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee);
     } else {
       wireFeeLimit = Amounts.getZero(paymentAmount.currency);
     }
     // If not already payed, check if we could pay for it.
     const res = await getCoinsForPayment(ws, {
-      allowedAuditors: proposal.contractTerms.auditors,
-      allowedExchanges: proposal.contractTerms.exchanges,
-      depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee),
+      allowedAuditors: contractTerms.auditors,
+      allowedExchanges: contractTerms.exchanges,
+      depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee),
       paymentAmount,
-      wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1,
+      wireFeeAmortization: contractTerms.wire_fee_amortization || 1,
       wireFeeLimit,
-      // FIXME: parse this properly
-      wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || {
-        t_ms: 0,
-      },
-      wireMethod: proposal.contractTerms.wire_method,
+      wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp),
+      wireMethod: contractTerms.wire_method,
     });
 
     if (!res) {
       console.log("not confirming payment, insufficient coins");
       return {
         status: "insufficient-balance",
-        contractTerms: proposal.contractTerms,
+        contractTerms: contractTerms,
         proposalId: proposal.proposalId,
       };
     }
@@ -676,7 +711,7 @@ export async function preparePay(
     ) {
       const { exchangeUrl, cds, totalAmount } = res;
       const payCoinInfo = await ws.cryptoApi.signDeposit(
-        proposal.contractTerms,
+        contractTerms,
         cds,
         totalAmount,
       );
@@ -691,19 +726,19 @@ export async function preparePay(
 
     return {
       status: "payment-possible",
-      contractTerms: proposal.contractTerms,
+      contractTerms: contractTerms,
       proposalId: proposal.proposalId,
       totalFees: res.totalFees,
     };
   }
 
   if (uriResult.sessionId) {
-    await submitPay(ws, purchase.contractTermsHash, uriResult.sessionId);
+    await submitPay(ws, proposalId, uriResult.sessionId);
   }
 
   return {
     status: "paid",
-    contractTerms: proposal.contractTerms,
+    contractTerms: purchase.contractTerms,
     nextUrl: getNextUrl(purchase.contractTerms),
   };
 }
@@ -762,39 +797,37 @@ export async function confirmPay(
     throw Error(`proposal with id ${proposalId} not found`);
   }
 
+  const d = proposal.download;
+  if (!d) {
+    throw Error("proposal is in invalid state");
+  }
+
   const sessionId = sessionIdOverride || proposal.downloadSessionId;
 
-  let purchase = await oneShotGet(
-    ws.db,
-    Stores.purchases,
-    proposal.contractTermsHash,
-  );
+  let purchase = await oneShotGet(ws.db, Stores.purchases, 
d.contractTermsHash);
 
   if (purchase) {
-    return submitPay(ws, purchase.contractTermsHash, sessionId);
+    return submitPay(ws, proposalId, sessionId);
   }
 
-  const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount);
+  const contractAmount = Amounts.parseOrThrow(d.contractTerms.amount);
 
   let wireFeeLimit;
-  if (!proposal.contractTerms.max_wire_fee) {
+  if (!d.contractTerms.max_wire_fee) {
     wireFeeLimit = Amounts.getZero(contractAmount.currency);
   } else {
-    wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee);
+    wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee);
   }
 
   const res = await getCoinsForPayment(ws, {
-    allowedAuditors: proposal.contractTerms.auditors,
-    allowedExchanges: proposal.contractTerms.exchanges,
-    depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee),
-    paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount),
-    wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1,
+    allowedAuditors: d.contractTerms.auditors,
+    allowedExchanges: d.contractTerms.exchanges,
+    depositFeeLimit: Amounts.parseOrThrow(d.contractTerms.max_fee),
+    paymentAmount: Amounts.parseOrThrow(d.contractTerms.amount),
+    wireFeeAmortization: d.contractTerms.wire_fee_amortization || 1,
     wireFeeLimit,
-    // FIXME: parse this properly
-    wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || {
-      t_ms: 0,
-    },
-    wireMethod: proposal.contractTerms.wire_method,
+    wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp),
+    wireMethod: d.contractTerms.wire_method,
   });
 
   logger.trace("coin selection result", res);
@@ -809,7 +842,7 @@ export async function confirmPay(
   if (!sd) {
     const { exchangeUrl, cds, totalAmount } = res;
     const payCoinInfo = await ws.cryptoApi.signDeposit(
-      proposal.contractTerms,
+      d.contractTerms,
       cds,
       totalAmount,
     );
@@ -823,5 +856,214 @@ export async function confirmPay(
     );
   }
 
-  return submitPay(ws, purchase.contractTermsHash, sessionId);
+  return submitPay(ws, proposalId, sessionId);
+}
+
+
+
+export async function getFullRefundFees(
+  ws: InternalWalletState,
+  refundPermissions: MerchantRefundPermission[],
+): Promise<AmountJson> {
+  if (refundPermissions.length === 0) {
+    throw Error("no refunds given");
+  }
+  const coin0 = await oneShotGet(
+    ws.db,
+    Stores.coins,
+    refundPermissions[0].coin_pub,
+  );
+  if (!coin0) {
+    throw Error("coin not found");
+  }
+  let feeAcc = Amounts.getZero(
+    Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
+  );
+
+  const denoms = await oneShotIterIndex(
+    ws.db,
+    Stores.denominations.exchangeBaseUrlIndex,
+    coin0.exchangeBaseUrl,
+  ).toArray();
+
+  for (const rp of refundPermissions) {
+    const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub);
+    if (!coin) {
+      throw Error("coin not found");
+    }
+    const denom = await oneShotGet(ws.db, Stores.denominations, [
+      coin0.exchangeBaseUrl,
+      coin.denomPub,
+    ]);
+    if (!denom) {
+      throw Error(`denom not found (${coin.denomPub})`);
+    }
+    // FIXME:  this assumes that the refund already happened.
+    // When it hasn't, the refresh cost is inaccurate.  To fix this,
+    // we need introduce a flag to tell if a coin was refunded or
+    // refreshed normally (and what about incremental refunds?)
+    const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
+    const refundFee = Amounts.parseOrThrow(rp.refund_fee);
+    const refreshCost = getTotalRefreshCost(
+      denoms,
+      denom,
+      Amounts.sub(refundAmount, refundFee).amount,
+    );
+    feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
+  }
+  return feeAcc;
+}
+
+async function submitRefunds(
+  ws: InternalWalletState,
+  proposalId: string,
+): Promise<void> {
+  const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
+  if (!purchase) {
+    console.error(
+      "not submitting refunds, payment not found:",
+    );
+    return;
+  }
+  const pendingKeys = Object.keys(purchase.refundsPending);
+  if (pendingKeys.length === 0) {
+    return;
+  }
+  for (const pk of pendingKeys) {
+    const perm = purchase.refundsPending[pk];
+    const req: RefundRequest = {
+      coin_pub: perm.coin_pub,
+      h_contract_terms: purchase.contractTermsHash,
+      merchant_pub: purchase.contractTerms.merchant_pub,
+      merchant_sig: perm.merchant_sig,
+      refund_amount: perm.refund_amount,
+      refund_fee: perm.refund_fee,
+      rtransaction_id: perm.rtransaction_id,
+    };
+    console.log("sending refund permission", perm);
+    // FIXME: not correct once we support multiple exchanges per payment
+    const exchangeUrl = purchase.payReq.coins[0].exchange_url;
+    const reqUrl = new URL("refund", exchangeUrl);
+    const resp = await ws.http.postJson(reqUrl.href, req);
+    if (resp.status !== 200) {
+      console.error("refund failed", resp);
+      continue;
+    }
+
+    // Transactionally mark successful refunds as done
+    const transformPurchase = (
+      t: PurchaseRecord | undefined,
+    ): PurchaseRecord | undefined => {
+      if (!t) {
+        console.warn("purchase not found, not updating refund");
+        return;
+      }
+      if (t.refundsPending[pk]) {
+        t.refundsDone[pk] = t.refundsPending[pk];
+        delete t.refundsPending[pk];
+      }
+      return t;
+    };
+    const transformCoin = (
+      c: CoinRecord | undefined,
+    ): CoinRecord | undefined => {
+      if (!c) {
+        console.warn("coin not found, can't apply refund");
+        return;
+      }
+      const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
+      const refundFee = Amounts.parseOrThrow(perm.refund_fee);
+      c.status = CoinStatus.Dirty;
+      c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
+      c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
+
+      return c;
+    };
+
+    await runWithWriteTransaction(
+      ws.db,
+      [Stores.purchases, Stores.coins],
+      async tx => {
+        await tx.mutate(Stores.purchases, proposalId, transformPurchase);
+        await tx.mutate(Stores.coins, perm.coin_pub, transformCoin);
+      },
+    );
+    refresh(ws, perm.coin_pub);
+  }
+
+  ws.badge.showNotification();
+  ws.notifier.notify();
+}
+
+export async function acceptRefundResponse(
+  ws: InternalWalletState,
+  refundResponse: MerchantRefundResponse,
+): Promise<string> {
+  const refundPermissions = refundResponse.refund_permissions;
+
+  if (!refundPermissions.length) {
+    console.warn("got empty refund list");
+    throw Error("empty refund");
+  }
+
+  /**
+   * Add refund to purchase if not already added.
+   */
+  function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined {
+    if (!t) {
+      console.error("purchase not found, not adding refunds");
+      return;
+    }
+
+    t.timestamp_refund = getTimestampNow();
+
+    for (const perm of refundPermissions) {
+      if (
+        !t.refundsPending[perm.merchant_sig] &&
+        !t.refundsDone[perm.merchant_sig]
+      ) {
+        t.refundsPending[perm.merchant_sig] = perm;
+      }
+    }
+    return t;
+  }
+
+  const hc = refundResponse.h_contract_terms;
+
+  // Add the refund permissions to the purchase within a DB transaction
+  await oneShotMutate(ws.db, Stores.purchases, hc, f);
+  ws.notifier.notify();
+
+  await submitRefunds(ws, hc);
+
+  return hc;
+}
+
+/**
+ * Accept a refund, return the contract hash for the contract
+ * that was involved in the refund.
+ */
+export async function applyRefund(
+  ws: InternalWalletState,
+  talerRefundUri: string,
+): Promise<string> {
+  const parseResult = parseRefundUri(talerRefundUri);
+
+  if (!parseResult) {
+    throw Error("invalid refund URI");
+  }
+
+  const refundUrl = parseResult.refundUrl;
+
+  logger.trace("processing refund");
+  let resp;
+  try {
+    resp = await ws.http.get(refundUrl);
+  } catch (e) {
+    console.error("error downloading refund permission", e);
+    throw e;
+  }
+
+  const refundResponse = MerchantRefundResponse.checked(resp.responseJson);
+  return acceptRefundResponse(ws, refundResponse);
 }
diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts
index dbc6672c..72102e3a 100644
--- a/src/wallet-impl/pending.ts
+++ b/src/wallet-impl/pending.ts
@@ -22,7 +22,7 @@ import {
   PendingOperationsResponse,
   getTimestampNow,
 } from "../walletTypes";
-import { oneShotIter } from "../util/query";
+import { runWithReadTransaction } from "../util/query";
 import { InternalWalletState } from "./state";
 import {
   Stores,
@@ -37,187 +37,212 @@ export async function getPendingOperations(
 ): Promise<PendingOperationsResponse> {
   const pendingOperations: PendingOperationInfo[] = [];
   let minRetryDurationMs = 5000;
-  const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray();
-  for (let e of exchanges) {
-    switch (e.updateStatus) {
-      case ExchangeUpdateStatus.FINISHED:
-        if (e.lastError) {
-          pendingOperations.push({
-            type: "bug",
-            message:
-              "Exchange record is in FINISHED state but has lastError set",
-            details: {
+  await runWithReadTransaction(
+    ws.db,
+    [
+      Stores.exchanges,
+      Stores.reserves,
+      Stores.refresh,
+      Stores.coins,
+      Stores.withdrawalSession,
+      Stores.proposals,
+      Stores.tips,
+    ],
+    async tx => {
+      await tx.iter(Stores.exchanges).forEach(e => {
+        switch (e.updateStatus) {
+          case ExchangeUpdateStatus.FINISHED:
+            if (e.lastError) {
+              pendingOperations.push({
+                type: "bug",
+                message:
+                  "Exchange record is in FINISHED state but has lastError set",
+                details: {
+                  exchangeBaseUrl: e.baseUrl,
+                },
+              });
+            }
+            if (!e.details) {
+              pendingOperations.push({
+                type: "bug",
+                message:
+                  "Exchange record does not have details, but no update in 
progress.",
+                details: {
+                  exchangeBaseUrl: e.baseUrl,
+                },
+              });
+            }
+            if (!e.wireInfo) {
+              pendingOperations.push({
+                type: "bug",
+                message:
+                  "Exchange record does not have wire info, but no update in 
progress.",
+                details: {
+                  exchangeBaseUrl: e.baseUrl,
+                },
+              });
+            }
+            break;
+          case ExchangeUpdateStatus.FETCH_KEYS:
+            pendingOperations.push({
+              type: "exchange-update",
+              stage: "fetch-keys",
               exchangeBaseUrl: e.baseUrl,
-            },
-          });
-        }
-        if (!e.details) {
-          pendingOperations.push({
-            type: "bug",
-            message:
-              "Exchange record does not have details, but no update in 
progress.",
-            details: {
+              lastError: e.lastError,
+              reason: e.updateReason || "unknown",
+            });
+            break;
+          case ExchangeUpdateStatus.FETCH_WIRE:
+            pendingOperations.push({
+              type: "exchange-update",
+              stage: "fetch-wire",
               exchangeBaseUrl: e.baseUrl,
-            },
-          });
+              lastError: e.lastError,
+              reason: e.updateReason || "unknown",
+            });
+            break;
+          default:
+            pendingOperations.push({
+              type: "bug",
+              message: "Unknown exchangeUpdateStatus",
+              details: {
+                exchangeBaseUrl: e.baseUrl,
+                exchangeUpdateStatus: e.updateStatus,
+              },
+            });
+            break;
         }
-        if (!e.wireInfo) {
-          pendingOperations.push({
-            type: "bug",
-            message:
-              "Exchange record does not have wire info, but no update in 
progress.",
-            details: {
-              exchangeBaseUrl: e.baseUrl,
-            },
-          });
+      });
+      await tx.iter(Stores.reserves).forEach(reserve => {
+        const reserveType = reserve.bankWithdrawStatusUrl
+          ? "taler-bank"
+          : "manual";
+        const now = getTimestampNow();
+        switch (reserve.reserveStatus) {
+          case ReserveRecordStatus.DORMANT:
+            // nothing to report as pending
+            break;
+          case ReserveRecordStatus.WITHDRAWING:
+          case ReserveRecordStatus.UNCONFIRMED:
+          case ReserveRecordStatus.QUERYING_STATUS:
+          case ReserveRecordStatus.REGISTERING_BANK:
+            pendingOperations.push({
+              type: "reserve",
+              stage: reserve.reserveStatus,
+              timestampCreated: reserve.created,
+              reserveType,
+              reservePub: reserve.reservePub,
+            });
+            if (reserve.created.t_ms < now.t_ms - 5000) {
+              minRetryDurationMs = 500;
+            } else if (reserve.created.t_ms < now.t_ms - 30000) {
+              minRetryDurationMs = 2000;
+            }
+            break;
+          case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+            pendingOperations.push({
+              type: "reserve",
+              stage: reserve.reserveStatus,
+              timestampCreated: reserve.created,
+              reserveType,
+              reservePub: reserve.reservePub,
+              bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl,
+            });
+            if (reserve.created.t_ms < now.t_ms - 5000) {
+              minRetryDurationMs = 500;
+            } else if (reserve.created.t_ms < now.t_ms - 30000) {
+              minRetryDurationMs = 2000;
+            }
+            break;
+          default:
+            pendingOperations.push({
+              type: "bug",
+              message: "Unknown reserve record status",
+              details: {
+                reservePub: reserve.reservePub,
+                reserveStatus: reserve.reserveStatus,
+              },
+            });
+            break;
         }
-        break;
-      case ExchangeUpdateStatus.FETCH_KEYS:
-        pendingOperations.push({
-          type: "exchange-update",
-          stage: "fetch-keys",
-          exchangeBaseUrl: e.baseUrl,
-          lastError: e.lastError,
-          reason: e.updateReason || "unknown",
-        });
-        break;
-      case ExchangeUpdateStatus.FETCH_WIRE:
-        pendingOperations.push({
-          type: "exchange-update",
-          stage: "fetch-wire",
-          exchangeBaseUrl: e.baseUrl,
-          lastError: e.lastError,
-          reason: e.updateReason || "unknown",
-        });
-        break;
-      default:
-        pendingOperations.push({
-          type: "bug",
-          message: "Unknown exchangeUpdateStatus",
-          details: {
-            exchangeBaseUrl: e.baseUrl,
-            exchangeUpdateStatus: e.updateStatus,
-          },
-        });
-        break;
-    }
-  }
-  await oneShotIter(ws.db, Stores.reserves).forEach(reserve => {
-    const reserveType = reserve.bankWithdrawStatusUrl ? "taler-bank" : 
"manual";
-    const now = getTimestampNow();
-    switch (reserve.reserveStatus) {
-      case ReserveRecordStatus.DORMANT:
-        // nothing to report as pending
-        break;
-      case ReserveRecordStatus.WITHDRAWING:
-      case ReserveRecordStatus.UNCONFIRMED:
-      case ReserveRecordStatus.QUERYING_STATUS:
-      case ReserveRecordStatus.REGISTERING_BANK:
-        pendingOperations.push({
-          type: "reserve",
-          stage: reserve.reserveStatus,
-          timestampCreated: reserve.created,
-          reserveType,
-          reservePub: reserve.reservePub,
-        });
-        if (reserve.created.t_ms < now.t_ms - 5000) {
-          minRetryDurationMs = 500;
-        } else if (reserve.created.t_ms < now.t_ms - 30000) {
-          minRetryDurationMs = 2000;
+      });
+
+      await tx.iter(Stores.refresh).forEach(r => {
+        if (r.finished) {
+          return;
         }
-        break;
-      case ReserveRecordStatus.WAIT_CONFIRM_BANK:
-        pendingOperations.push({
-          type: "reserve",
-          stage: reserve.reserveStatus,
-          timestampCreated: reserve.created,
-          reserveType,
-          reservePub: reserve.reservePub,
-          bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl,
-        });
-        if (reserve.created.t_ms < now.t_ms - 5000) {
-          minRetryDurationMs = 500;
-        } else if (reserve.created.t_ms < now.t_ms - 30000) {
-          minRetryDurationMs = 2000;
+        let refreshStatus: string;
+        if (r.norevealIndex === undefined) {
+          refreshStatus = "melt";
+        } else {
+          refreshStatus = "reveal";
         }
-        break;
-      default:
+
         pendingOperations.push({
-          type: "bug",
-          message: "Unknown reserve record status",
-          details: {
-            reservePub: reserve.reservePub,
-            reserveStatus: reserve.reserveStatus,
-          },
+          type: "refresh",
+          oldCoinPub: r.meltCoinPub,
+          refreshStatus,
+          refreshOutputSize: r.newDenoms.length,
+          refreshSessionId: r.refreshSessionId,
         });
-        break;
-    }
-  });
-
-  await oneShotIter(ws.db, Stores.refresh).forEach(r => {
-    if (r.finished) {
-      return;
-    }
-    let refreshStatus: string;
-    if (r.norevealIndex === undefined) {
-      refreshStatus = "melt";
-    } else {
-      refreshStatus = "reveal";
-    }
-
-    pendingOperations.push({
-      type: "refresh",
-      oldCoinPub: r.meltCoinPub,
-      refreshStatus,
-      refreshOutputSize: r.newDenoms.length,
-      refreshSessionId: r.refreshSessionId,
-    });
-  });
+      });
 
-  await oneShotIter(ws.db, Stores.coins).forEach(coin => {
-    if (coin.status == CoinStatus.Dirty) {
-      pendingOperations.push({
-        type: "dirty-coin",
-        coinPub: coin.coinPub,
+      await tx.iter(Stores.coins).forEach(coin => {
+        if (coin.status == CoinStatus.Dirty) {
+          pendingOperations.push({
+            type: "dirty-coin",
+            coinPub: coin.coinPub,
+          });
+        }
       });
-    }
-  });
 
-  await oneShotIter(ws.db, Stores.withdrawalSession).forEach(ws => {
-    const numCoinsWithdrawn = ws.withdrawn.reduce((a, x) => a + (x ? 1 : 0), 
0);
-    const numCoinsTotal = ws.withdrawn.length;
-    if (numCoinsWithdrawn < numCoinsTotal) {
-      pendingOperations.push({
-        type: "withdraw",
-        numCoinsTotal,
-        numCoinsWithdrawn,
-        source: ws.source,
-        withdrawSessionId: ws.withdrawSessionId,
+      await tx.iter(Stores.withdrawalSession).forEach(ws => {
+        const numCoinsWithdrawn = ws.withdrawn.reduce(
+          (a, x) => a + (x ? 1 : 0),
+          0,
+        );
+        const numCoinsTotal = ws.withdrawn.length;
+        if (numCoinsWithdrawn < numCoinsTotal) {
+          pendingOperations.push({
+            type: "withdraw",
+            numCoinsTotal,
+            numCoinsWithdrawn,
+            source: ws.source,
+            withdrawSessionId: ws.withdrawSessionId,
+          });
+        }
       });
-    }
-  });
 
-  await oneShotIter(ws.db, Stores.proposals).forEach(proposal => {
-    if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
-      pendingOperations.push({
-        type: "proposal",
-        merchantBaseUrl: proposal.contractTerms.merchant_base_url,
-        proposalId: proposal.proposalId,
-        proposalTimestamp: proposal.timestamp,
+      await tx.iter(Stores.proposals).forEach((proposal) => {
+        if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
+          pendingOperations.push({
+            type: "proposal-choice",
+            merchantBaseUrl: 
proposal.download!!.contractTerms.merchant_base_url,
+            proposalId: proposal.proposalId,
+            proposalTimestamp: proposal.timestamp,
+          });
+        } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) {
+          pendingOperations.push({
+            type: "proposal-download",
+            merchantBaseUrl: 
proposal.download!!.contractTerms.merchant_base_url,
+            proposalId: proposal.proposalId,
+            proposalTimestamp: proposal.timestamp,
+          });
+        }
       });
-    }
-  });
 
-  await oneShotIter(ws.db, Stores.tips).forEach(tip => {
-    if (tip.accepted && !tip.pickedUp) {
-      pendingOperations.push({
-        type: "tip",
-        merchantBaseUrl: tip.merchantBaseUrl,
-        tipId: tip.tipId,
-        merchantTipId: tip.merchantTipId,
+      await tx.iter(Stores.tips).forEach((tip) => {
+        if (tip.accepted && !tip.pickedUp) {
+          pendingOperations.push({
+            type: "tip",
+            merchantBaseUrl: tip.merchantBaseUrl,
+            tipId: tip.tipId,
+            merchantTipId: tip.merchantTipId,
+          });
+        }
       });
-    }
-  });
+    },
+  );
 
   return {
     pendingOperations,
diff --git a/src/wallet-impl/refund.ts b/src/wallet-impl/refund.ts
deleted file mode 100644
index 2a9dea14..00000000
--- a/src/wallet-impl/refund.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
-  MerchantRefundResponse,
-  RefundRequest,
-  MerchantRefundPermission,
-} from "../talerTypes";
-import { PurchaseRecord, Stores, CoinRecord, CoinStatus } from "../dbTypes";
-import { getTimestampNow } from "../walletTypes";
-import {
-  oneShotMutate,
-  oneShotGet,
-  runWithWriteTransaction,
-  oneShotIterIndex,
-} from "../util/query";
-import { InternalWalletState } from "./state";
-import { parseRefundUri } from "../util/taleruri";
-import { Logger } from "../util/logging";
-import { AmountJson } from "../util/amounts";
-import * as Amounts from "../util/amounts";
-import { getTotalRefreshCost, refresh } from "./refresh";
-
-const logger = new Logger("refund.ts");
-
-export async function getFullRefundFees(
-  ws: InternalWalletState,
-  refundPermissions: MerchantRefundPermission[],
-): Promise<AmountJson> {
-  if (refundPermissions.length === 0) {
-    throw Error("no refunds given");
-  }
-  const coin0 = await oneShotGet(
-    ws.db,
-    Stores.coins,
-    refundPermissions[0].coin_pub,
-  );
-  if (!coin0) {
-    throw Error("coin not found");
-  }
-  let feeAcc = Amounts.getZero(
-    Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
-  );
-
-  const denoms = await oneShotIterIndex(
-    ws.db,
-    Stores.denominations.exchangeBaseUrlIndex,
-    coin0.exchangeBaseUrl,
-  ).toArray();
-
-  for (const rp of refundPermissions) {
-    const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub);
-    if (!coin) {
-      throw Error("coin not found");
-    }
-    const denom = await oneShotGet(ws.db, Stores.denominations, [
-      coin0.exchangeBaseUrl,
-      coin.denomPub,
-    ]);
-    if (!denom) {
-      throw Error(`denom not found (${coin.denomPub})`);
-    }
-    // FIXME:  this assumes that the refund already happened.
-    // When it hasn't, the refresh cost is inaccurate.  To fix this,
-    // we need introduce a flag to tell if a coin was refunded or
-    // refreshed normally (and what about incremental refunds?)
-    const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
-    const refundFee = Amounts.parseOrThrow(rp.refund_fee);
-    const refreshCost = getTotalRefreshCost(
-      denoms,
-      denom,
-      Amounts.sub(refundAmount, refundFee).amount,
-    );
-    feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
-  }
-  return feeAcc;
-}
-
-async function submitRefunds(
-  ws: InternalWalletState,
-  contractTermsHash: string,
-): Promise<void> {
-  const purchase = await oneShotGet(ws.db, Stores.purchases, 
contractTermsHash);
-  if (!purchase) {
-    console.error(
-      "not submitting refunds, contract terms not found:",
-      contractTermsHash,
-    );
-    return;
-  }
-  const pendingKeys = Object.keys(purchase.refundsPending);
-  if (pendingKeys.length === 0) {
-    return;
-  }
-  for (const pk of pendingKeys) {
-    const perm = purchase.refundsPending[pk];
-    const req: RefundRequest = {
-      coin_pub: perm.coin_pub,
-      h_contract_terms: purchase.contractTermsHash,
-      merchant_pub: purchase.contractTerms.merchant_pub,
-      merchant_sig: perm.merchant_sig,
-      refund_amount: perm.refund_amount,
-      refund_fee: perm.refund_fee,
-      rtransaction_id: perm.rtransaction_id,
-    };
-    console.log("sending refund permission", perm);
-    // FIXME: not correct once we support multiple exchanges per payment
-    const exchangeUrl = purchase.payReq.coins[0].exchange_url;
-    const reqUrl = new URL("refund", exchangeUrl);
-    const resp = await ws.http.postJson(reqUrl.href, req);
-    if (resp.status !== 200) {
-      console.error("refund failed", resp);
-      continue;
-    }
-
-    // Transactionally mark successful refunds as done
-    const transformPurchase = (
-      t: PurchaseRecord | undefined,
-    ): PurchaseRecord | undefined => {
-      if (!t) {
-        console.warn("purchase not found, not updating refund");
-        return;
-      }
-      if (t.refundsPending[pk]) {
-        t.refundsDone[pk] = t.refundsPending[pk];
-        delete t.refundsPending[pk];
-      }
-      return t;
-    };
-    const transformCoin = (
-      c: CoinRecord | undefined,
-    ): CoinRecord | undefined => {
-      if (!c) {
-        console.warn("coin not found, can't apply refund");
-        return;
-      }
-      const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
-      const refundFee = Amounts.parseOrThrow(perm.refund_fee);
-      c.status = CoinStatus.Dirty;
-      c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
-      c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
-
-      return c;
-    };
-
-    await runWithWriteTransaction(
-      ws.db,
-      [Stores.purchases, Stores.coins],
-      async tx => {
-        await tx.mutate(Stores.purchases, contractTermsHash, 
transformPurchase);
-        await tx.mutate(Stores.coins, perm.coin_pub, transformCoin);
-      },
-    );
-    refresh(ws, perm.coin_pub);
-  }
-
-  ws.badge.showNotification();
-  ws.notifier.notify();
-}
-
-export async function acceptRefundResponse(
-  ws: InternalWalletState,
-  refundResponse: MerchantRefundResponse,
-): Promise<string> {
-  const refundPermissions = refundResponse.refund_permissions;
-
-  if (!refundPermissions.length) {
-    console.warn("got empty refund list");
-    throw Error("empty refund");
-  }
-
-  /**
-   * Add refund to purchase if not already added.
-   */
-  function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined {
-    if (!t) {
-      console.error("purchase not found, not adding refunds");
-      return;
-    }
-
-    t.timestamp_refund = getTimestampNow();
-
-    for (const perm of refundPermissions) {
-      if (
-        !t.refundsPending[perm.merchant_sig] &&
-        !t.refundsDone[perm.merchant_sig]
-      ) {
-        t.refundsPending[perm.merchant_sig] = perm;
-      }
-    }
-    return t;
-  }
-
-  const hc = refundResponse.h_contract_terms;
-
-  // Add the refund permissions to the purchase within a DB transaction
-  await oneShotMutate(ws.db, Stores.purchases, hc, f);
-  ws.notifier.notify();
-
-  await submitRefunds(ws, hc);
-
-  return hc;
-}
-
-/**
- * Accept a refund, return the contract hash for the contract
- * that was involved in the refund.
- */
-export async function applyRefund(
-  ws: InternalWalletState,
-  talerRefundUri: string,
-): Promise<string> {
-  const parseResult = parseRefundUri(talerRefundUri);
-
-  if (!parseResult) {
-    throw Error("invalid refund URI");
-  }
-
-  const refundUrl = parseResult.refundUrl;
-
-  logger.trace("processing refund");
-  let resp;
-  try {
-    resp = await ws.http.get(refundUrl);
-  } catch (e) {
-    console.error("error downloading refund permission", e);
-    throw e;
-  }
-
-  const refundResponse = MerchantRefundResponse.checked(resp.responseJson);
-  return acceptRefundResponse(ws, refundResponse);
-}
diff --git a/src/wallet-impl/reserves.ts b/src/wallet-impl/reserves.ts
index 5d624fe2..d70f0257 100644
--- a/src/wallet-impl/reserves.ts
+++ b/src/wallet-impl/reserves.ts
@@ -344,10 +344,16 @@ async function updateReserve(
     resp = await ws.http.get(reqUrl.href);
   } catch (e) {
     if (e.response?.status === 404) {
-      return;
+      const m = "The exchange does not know about this reserve (yet).";
+      await setReserveError(ws, reservePub, {
+        type: "waiting",
+        details: {},
+        message: "The exchange does not know about this reserve (yet).",
+      });
+      throw new OperationFailedAndReportedError(m);
     } else {
       const m = e.message;
-      setReserveError(ws, reservePub, {
+      await setReserveError(ws, reservePub, {
         type: "network",
         details: {},
         message: m,
@@ -496,6 +502,8 @@ async function depleteReserve(
 
   const withdrawalSessionId = encodeCrock(randomBytes(32));
 
+  const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => 
x.value)).amount;
+
   const withdrawalRecord: WithdrawalSessionRecord = {
     withdrawSessionId: withdrawalSessionId,
     exchangeBaseUrl: reserve.exchangeBaseUrl,
@@ -503,15 +511,14 @@ async function depleteReserve(
       type: "reserve",
       reservePub: reserve.reservePub,
     },
-    withdrawalAmount: Amounts.toString(withdrawAmount),
+    rawWithdrawalAmount: withdrawAmount,
     startTimestamp: getTimestampNow(),
     denoms: denomsForWithdraw.map(x => x.denomPub),
     withdrawn: denomsForWithdraw.map(x => false),
     planchets: denomsForWithdraw.map(x => undefined),
+    totalCoinValue,
   };
 
-  const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value))
-    .amount;
   const totalCoinWithdrawFee = Amounts.sum(
     denomsForWithdraw.map(x => x.feeWithdraw),
   ).amount;
diff --git a/src/wallet-impl/tip.ts b/src/wallet-impl/tip.ts
index b102d026..593f0d61 100644
--- a/src/wallet-impl/tip.ts
+++ b/src/wallet-impl/tip.ts
@@ -202,8 +202,9 @@ export async function processTip(
     },
     startTimestamp: getTimestampNow(),
     withdrawSessionId: withdrawalSessionId,
-    withdrawalAmount: Amounts.toString(tipRecord.amount),
+    rawWithdrawalAmount: tipRecord.amount,
     withdrawn: planchets.map((x) => false),
+    totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount,
   };
 
 
diff --git a/src/wallet-impl/withdraw.ts b/src/wallet-impl/withdraw.ts
index baeae1a5..d02ae14a 100644
--- a/src/wallet-impl/withdraw.ts
+++ b/src/wallet-impl/withdraw.ts
@@ -143,9 +143,12 @@ export async function acceptWithdrawal(
     senderWire: withdrawInfo.senderWire,
     exchangeWire: exchangeWire,
   });
+  ws.badge.showNotification();
+  ws.notifier.notify();
   // We do this here, as the reserve should be registered before we return,
   // so that we can redirect the user to the bank's status page.
   await processReserveBankStatus(ws, reserve.reservePub);
+  ws.notifier.notify();
   console.log("acceptWithdrawal: returning");
   return {
     reservePub: reserve.reservePub,
diff --git a/src/wallet.ts b/src/wallet.ts
index a6eecb8a..772bb01a 100644
--- a/src/wallet.ts
+++ b/src/wallet.ts
@@ -46,6 +46,9 @@ import {
   abortFailedPayment,
   preparePay,
   confirmPay,
+  processDownloadProposal,
+  applyRefund,
+  getFullRefundFees,
 } from "./wallet-impl/pay";
 
 import {
@@ -87,8 +90,6 @@ import { Logger } from "./util/logging";
 
 import { assertUnreachable } from "./util/assertUnreachable";
 
-import { applyRefund, getFullRefundFees } from "./wallet-impl/refund";
-
 import {
   updateExchangeFromUrl,
   getExchangeTrust,
@@ -119,7 +120,7 @@ import { AsyncCondition } from "./util/promiseUtils";
  */
 export const WALLET_PROTOCOL_VERSION = "3:0:0";
 
-export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "2";
+export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "3";
 
 const builtinCurrencies: CurrencyRecord[] = [
   {
@@ -208,6 +209,7 @@ export class Wallet {
    */
   async processOnePendingOperation(
     pending: PendingOperationInfo,
+    forceNow: boolean = false,
   ): Promise<void> {
     switch (pending.type) {
       case "bug":
@@ -227,12 +229,17 @@ export class Wallet {
       case "withdraw":
         await processWithdrawSession(this.ws, pending.withdrawSessionId);
         break;
-      case "proposal":
+      case "proposal-choice":
         // Nothing to do, user needs to accept/reject
         break;
+      case "proposal-download":
+        await processDownloadProposal(this.ws, pending.proposalId);
+        break;
       case "tip":
         await processTip(this.ws, pending.tipId);
         break;
+      case "pay":
+        break;
       default:
         assertUnreachable(pending);
     }
@@ -241,11 +248,11 @@ export class Wallet {
   /**
    * Process pending operations.
    */
-  public async runPending(): Promise<void> {
+  public async runPending(forceNow: boolean = false): Promise<void> {
     const pendingOpsResponse = await this.getPendingOperations();
     for (const p of pendingOpsResponse.pendingOperations) {
       try {
-        await this.processOnePendingOperation(p);
+        await this.processOnePendingOperation(p, forceNow);
       } catch (e) {
         console.error(e);
       }
diff --git a/src/walletTypes.ts b/src/walletTypes.ts
index b12b29c5..be88fc5b 100644
--- a/src/walletTypes.ts
+++ b/src/walletTypes.ts
@@ -578,13 +578,25 @@ export interface PendingRefreshOperation {
   refreshOutputSize: number;
 }
 
+
 export interface PendingDirtyCoinOperation {
   type: "dirty-coin";
   coinPub: string;
 }
 
-export interface PendingProposalOperation {
-  type: "proposal";
+export interface PendingProposalDownloadOperation {
+  type: "proposal-download";
+  merchantBaseUrl: string;
+  proposalTimestamp: Timestamp;
+  proposalId: string;
+}
+
+/**
+ * User must choose whether to accept or reject the merchant's
+ * proposed contract terms.
+ */
+export interface PendingProposalChoiceOperation {
+  type: "proposal-choice";
   merchantBaseUrl: string;
   proposalTimestamp: Timestamp;
   proposalId: string;
@@ -597,6 +609,12 @@ export interface PendingTipOperation {
   merchantTipId: string;
 }
 
+export interface PendingPayOperation {
+  type: "pay";
+  proposalId: string;
+  isReplay: boolean;
+}
+
 export type PendingOperationInfo =
   | PendingWithdrawOperation
   | PendingReserveOperation
@@ -605,7 +623,9 @@ export type PendingOperationInfo =
   | PendingExchangeUpdateOperation
   | PendingRefreshOperation
   | PendingTipOperation
-  | PendingProposalOperation;
+  | PendingProposalDownloadOperation
+  | PendingProposalChoiceOperation
+  | PendingPayOperation;
 
 export interface PendingOperationsResponse {
   pendingOperations: PendingOperationInfo[];
diff --git a/tsconfig.json b/tsconfig.json
index 75214637..50359419 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -74,7 +74,6 @@
     "src/wallet-impl/payback.ts",
     "src/wallet-impl/pending.ts",
     "src/wallet-impl/refresh.ts",
-    "src/wallet-impl/refund.ts",
     "src/wallet-impl/reserves.ts",
     "src/wallet-impl/return.ts",
     "src/wallet-impl/state.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]