gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] branch master updated: implement peer to peer push p


From: gnunet
Subject: [taler-wallet-core] branch master updated: implement peer to peer push payments
Date: Tue, 16 Aug 2022 17:55:42 +0200

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

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

The following commit(s) were added to refs/heads/master by this push:
     new ac8f1167 implement peer to peer push payments
ac8f1167 is described below

commit ac8f116780a860c8f4acfdf5553bf90d76afe236
Author: Florian Dold <florian@dold.me>
AuthorDate: Tue Aug 9 15:00:45 2022 +0200

    implement peer to peer push payments
---
 .../src/components/menu/SideBar.tsx                |  11 +-
 packages/taler-util/src/codec.ts                   |   4 +-
 packages/taler-util/src/contractTerms.test.ts      |   5 +
 packages/taler-util/src/talerCrypto.test.ts        |  22 +-
 packages/taler-util/src/taleruri.test.ts           |  43 ++
 packages/taler-util/src/taleruri.ts                |  57 +-
 packages/taler-util/src/time.ts                    |   8 +
 packages/taler-util/src/walletTypes.ts             |  28 +-
 packages/taler-wallet-cli/src/harness/harness.ts   |  86 +--
 packages/taler-wallet-cli/src/harness/libeufin.ts  |  43 +-
 .../test-libeufin-api-bankaccount.ts               |   3 +-
 .../src/integrationtests/test-libeufin-basic.ts    |   9 +-
 .../src/integrationtests/test-peer-to-peer.ts      |   8 +-
 packages/taler-wallet-core/src/db.ts               | 168 ++--
 packages/taler-wallet-core/src/index.ts            |   1 -
 .../taler-wallet-core/src/internal-wallet-state.ts |  10 -
 .../src/operations/backup/export.ts                |  24 -
 .../src/operations/backup/import.ts                | 185 +++--
 .../taler-wallet-core/src/operations/balance.ts    |  13 -
 .../src/operations/peer-to-peer.ts                 | 149 ++--
 .../taler-wallet-core/src/operations/pending.ts    |  40 -
 .../taler-wallet-core/src/operations/recoup.ts     |  65 +-
 .../taler-wallet-core/src/operations/reserves.ts   | 843 ---------------------
 .../taler-wallet-core/src/operations/testing.ts    |  15 +-
 .../src/operations/transactions.ts                 |  88 +--
 .../taler-wallet-core/src/operations/withdraw.ts   | 657 ++++++++++++++--
 packages/taler-wallet-core/src/pending-types.ts    |  18 -
 packages/taler-wallet-core/src/wallet.ts           | 124 +--
 28 files changed, 1095 insertions(+), 1632 deletions(-)

diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx 
b/packages/anastasis-webui/src/components/menu/SideBar.tsx
index 7cc65a62..f83131ae 100644
--- a/packages/anastasis-webui/src/components/menu/SideBar.tsx
+++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx
@@ -28,15 +28,14 @@ interface Props {
   mobile?: boolean;
 }
 
-const VERSION: string = process.env.__VERSION__ || "dev";
-const GIT_HASH: string | undefined = process.env.__GIT_HASH__;
+// @ts-ignore
+const maybeEnv = process?.env || {};
+
+const VERSION: string = maybeEnv.__VERSION__ || "dev";
+const GIT_HASH: string | undefined = maybeEnv.__GIT_HASH__;
 const VERSION_WITH_HASH = GIT_HASH ? `${VERSION}-${GIT_HASH}` : VERSION;
 
 export function Sidebar({ mobile }: Props): VNode {
-  // const config = useConfigContext();
-  const config = { version: "none" };
-  // FIXME: add replacement for __VERSION__ with the current version
-  const process = { env: { __VERSION__: "0.0.0" } };
   const reducer = useAnastasisContext()!;
 
   function saveSession(): void {
diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts
index 2ea64a24..02e6a883 100644
--- a/packages/taler-util/src/codec.ts
+++ b/packages/taler-util/src/codec.ts
@@ -186,7 +186,7 @@ class UnionCodecBuilder<
           throw new DecodingError(
             `expected tag for ${objectDisplayName} at ${renderContext(
               c,
-            )}.${discriminator}`,
+            )}.${String(discriminator)}`,
           );
         }
         const alt = alternatives.get(d);
@@ -194,7 +194,7 @@ class UnionCodecBuilder<
           throw new DecodingError(
             `unknown tag for ${objectDisplayName} ${d} at ${renderContext(
               c,
-            )}.${discriminator}`,
+            )}.${String(discriminator)}`,
           );
         }
         const altDecoded = alt.codec.decode(x);
diff --git a/packages/taler-util/src/contractTerms.test.ts 
b/packages/taler-util/src/contractTerms.test.ts
index 74cae4ca..d021495d 100644
--- a/packages/taler-util/src/contractTerms.test.ts
+++ b/packages/taler-util/src/contractTerms.test.ts
@@ -18,8 +18,13 @@
  * Imports.
  */
 import test from "ava";
+import { initNodePrng } from "./prng-node.js";
 import { ContractTermsUtil } from "./contractTerms.js";
 
+// Since we import nacl-fast directly (and not via index.node.ts), we need to
+// init the PRNG manually.
+initNodePrng();
+
 test("contract terms canon hashing", (t) => {
   const cReq = {
     foo: 42,
diff --git a/packages/taler-util/src/talerCrypto.test.ts 
b/packages/taler-util/src/talerCrypto.test.ts
index b4a0106f..aa1873c7 100644
--- a/packages/taler-util/src/talerCrypto.test.ts
+++ b/packages/taler-util/src/talerCrypto.test.ts
@@ -381,7 +381,7 @@ test("taler age restriction crypto", async (t) => {
 
   const pub2Ref = await Edx25519.getPublic(priv2);
 
-  t.is(pub2, pub2Ref);
+  t.deepEqual(pub2, pub2Ref);
 });
 
 test("edx signing", async (t) => {
@@ -390,21 +390,13 @@ test("edx signing", async (t) => {
 
   const msg = stringToBytes("hello world");
 
-  const sig = nacl.crypto_edx25519_sign_detached(
-    msg,
-    priv1,
-    pub1,
-  );
+  const sig = nacl.crypto_edx25519_sign_detached(msg, priv1, pub1);
 
-  t.true(
-    nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
-  );
+  t.true(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
 
   sig[0]++;
 
-  t.false(
-    nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
-  );
+  t.false(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
 });
 
 test("edx test vector", async (t) => {
@@ -422,18 +414,18 @@ test("edx test vector", async (t) => {
 
   {
     const pub1Prime = await Edx25519.getPublic(decodeCrock(tv.priv1_edx));
-    t.is(pub1Prime, decodeCrock(tv.pub1_edx));
+    t.deepEqual(pub1Prime, decodeCrock(tv.pub1_edx));
   }
 
   const pub2Prime = await Edx25519.publicKeyDerive(
     decodeCrock(tv.pub1_edx),
     decodeCrock(tv.seed),
   );
-  t.is(pub2Prime, decodeCrock(tv.pub2_edx));
+  t.deepEqual(pub2Prime, decodeCrock(tv.pub2_edx));
 
   const priv2Prime = await Edx25519.privateKeyDerive(
     decodeCrock(tv.priv1_edx),
     decodeCrock(tv.seed),
   );
-  t.is(priv2Prime, decodeCrock(tv.priv2_edx));
+  t.deepEqual(priv2Prime, decodeCrock(tv.priv2_edx));
 });
diff --git a/packages/taler-util/src/taleruri.test.ts 
b/packages/taler-util/src/taleruri.test.ts
index 5bf7ad4e..3ee243fb 100644
--- a/packages/taler-util/src/taleruri.test.ts
+++ b/packages/taler-util/src/taleruri.test.ts
@@ -20,6 +20,8 @@ import {
   parseWithdrawUri,
   parseRefundUri,
   parseTipUri,
+  parsePayPushUri,
+  constructPayPushUri,
 } from "./taleruri.js";
 
 test("taler pay url parsing: wrong scheme", (t) => {
@@ -182,3 +184,44 @@ test("taler tip pickup uri with instance and prefix", (t) 
=> {
   t.is(r1.merchantBaseUrl, "https://merchant.example.com/my/pfx/tipm/";);
   t.is(r1.merchantTipId, "tipid");
 });
+
+test("taler peer to peer push URI", (t) => {
+  const url1 = "taler://pay-push/exch.example.com/foo";
+  const r1 = parsePayPushUri(url1);
+  if (!r1) {
+    t.fail();
+    return;
+  }
+  t.is(r1.exchangeBaseUrl, "https://exch.example.com/";);
+  t.is(r1.contractPriv, "foo");
+});
+
+test("taler peer to peer push URI (path)", (t) => {
+  const url1 = "taler://pay-push/exch.example.com:123/bla/foo";
+  const r1 = parsePayPushUri(url1);
+  if (!r1) {
+    t.fail();
+    return;
+  }
+  t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/";);
+  t.is(r1.contractPriv, "foo");
+});
+
+test("taler peer to peer push URI (http)", (t) => {
+  const url1 = "taler+http://pay-push/exch.example.com:123/bla/foo";;
+  const r1 = parsePayPushUri(url1);
+  if (!r1) {
+    t.fail();
+    return;
+  }
+  t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/";);
+  t.is(r1.contractPriv, "foo");
+});
+
+test("taler peer to peer push URI (construction)", (t) => {
+  const url = constructPayPushUri({
+    exchangeBaseUrl: "https://foo.example.com/bla/";,
+    contractPriv: "123",
+  });
+  t.deepEqual(url, "taler://pay-push/foo.example.com/bla/123");
+});
diff --git a/packages/taler-util/src/taleruri.ts 
b/packages/taler-util/src/taleruri.ts
index b487c73a..e3bd120f 100644
--- a/packages/taler-util/src/taleruri.ts
+++ b/packages/taler-util/src/taleruri.ts
@@ -15,7 +15,7 @@
  */
 
 import { canonicalizeBaseUrl } from "./helpers.js";
-import { URLSearchParams } from "./url.js";
+import { URLSearchParams, URL } from "./url.js";
 
 export interface PayUriResult {
   merchantBaseUrl: string;
@@ -40,6 +40,11 @@ export interface TipUriResult {
   merchantBaseUrl: string;
 }
 
+export interface PayPushUriResult {
+  exchangeBaseUrl: string;
+  contractPriv: string;
+}
+
 /**
  * Parse a taler[+http]://withdraw URI.
  * Return undefined if not passed a valid URI.
@@ -79,6 +84,7 @@ export enum TalerUriType {
   TalerTip = "taler-tip",
   TalerRefund = "taler-refund",
   TalerNotifyReserve = "taler-notify-reserve",
+  TalerPayPush = "pay-push",
   Unknown = "unknown",
 }
 
@@ -111,6 +117,12 @@ export function classifyTalerUri(s: string): TalerUriType {
   if (sl.startsWith("taler+http://withdraw/";)) {
     return TalerUriType.TalerWithdraw;
   }
+  if (sl.startsWith("taler://pay-push/")) {
+    return TalerUriType.TalerPayPush;
+  }
+  if (sl.startsWith("taler+http://pay-push/";)) {
+    return TalerUriType.TalerPayPush;
+  }
   if (sl.startsWith("taler://notify-reserve/")) {
     return TalerUriType.TalerNotifyReserve;
   }
@@ -176,6 +188,28 @@ export function parsePayUri(s: string): PayUriResult | 
undefined {
   };
 }
 
+export function parsePayPushUri(s: string): PayPushUriResult | undefined {
+  const pi = parseProtoInfo(s, "pay-push");
+  if (!pi) {
+    return undefined;
+  }
+  const c = pi?.rest.split("?");
+  const parts = c[0].split("/");
+  if (parts.length < 2) {
+    return undefined;
+  }
+  const host = parts[0].toLowerCase();
+  const contractPriv = parts[parts.length - 1];
+  const pathSegments = parts.slice(1, parts.length - 1);
+  const p = [host, ...pathSegments].join("/");
+  const exchangeBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+
+  return {
+    exchangeBaseUrl,
+    contractPriv,
+  };
+}
+
 /**
  * Parse a taler[+http]://tip URI.
  * Return undefined if not passed a valid URI.
@@ -228,3 +262,24 @@ export function parseRefundUri(s: string): RefundUriResult 
| undefined {
     orderId,
   };
 }
+
+export function constructPayPushUri(args: {
+  exchangeBaseUrl: string;
+  contractPriv: string;
+}): string {
+  const url = new URL(args.exchangeBaseUrl);
+  let proto: string;
+  if (url.protocol === "https:") {
+    proto = "taler";
+  } else if (url.protocol === "http:") {
+    proto = "taler+http";
+  } else {
+    throw Error(`Unsupported exchange URL protocol ${args.exchangeBaseUrl}`);
+  }
+  if (!url.pathname.endsWith("/")) {
+    throw Error(
+      `exchange base URL must end with a slash (got 
${args.exchangeBaseUrl}instead)`,
+    );
+  }
+  return `${proto}://pay-push/${url.host}${url.pathname}${args.contractPriv}`;
+}
diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts
index 8b0516bf..0ba684be 100644
--- a/packages/taler-util/src/time.ts
+++ b/packages/taler-util/src/time.ts
@@ -92,6 +92,14 @@ export namespace Duration {
     return { d_ms: deadline.t_ms - now.t_ms };
   }
 
+  export function max(d1: Duration, d2: Duration): Duration {
+    return durationMax(d1, d2);
+  }
+
+  export function min(d1: Duration, d2: Duration): Duration {
+    return durationMin(d1, d2);
+  }
+
   export function toIntegerYears(d: Duration): number {
     if (typeof d.d_ms !== "number") {
       throw Error("infinite duration");
diff --git a/packages/taler-util/src/walletTypes.ts 
b/packages/taler-util/src/walletTypes.ts
index 9f7ba417..eac9cf7d 100644
--- a/packages/taler-util/src/walletTypes.ts
+++ b/packages/taler-util/src/walletTypes.ts
@@ -858,10 +858,11 @@ interface GetContractTermsDetailsRequest {
   proposalId: string;
 }
 
-export const codecForGetContractTermsDetails = (): 
Codec<GetContractTermsDetailsRequest> =>
-  buildCodecForObject<GetContractTermsDetailsRequest>()
-    .property("proposalId", codecForString())
-    .build("GetContractTermsDetails");
+export const codecForGetContractTermsDetails =
+  (): Codec<GetContractTermsDetailsRequest> =>
+    buildCodecForObject<GetContractTermsDetailsRequest>()
+      .property("proposalId", codecForString())
+      .build("GetContractTermsDetails");
 
 export interface PreparePayRequest {
   talerPayUri: string;
@@ -1280,6 +1281,7 @@ export interface InitiatePeerPushPaymentResponse {
   pursePub: string;
   mergePriv: string;
   contractPriv: string;
+  talerUri: string;
 }
 
 export const codecForInitiatePeerPushPaymentRequest =
@@ -1290,32 +1292,30 @@ export const codecForInitiatePeerPushPaymentRequest =
       .build("InitiatePeerPushPaymentRequest");
 
 export interface CheckPeerPushPaymentRequest {
-  exchangeBaseUrl: string;
-  pursePub: string;
-  contractPriv: string;
+  talerUri: string;
 }
 
 export interface CheckPeerPushPaymentResponse {
   contractTerms: any;
   amount: AmountString;
+  peerPushPaymentIncomingId: string;
 }
 
 export const codecForCheckPeerPushPaymentRequest =
   (): Codec<CheckPeerPushPaymentRequest> =>
     buildCodecForObject<CheckPeerPushPaymentRequest>()
-      .property("pursePub", codecForString())
-      .property("contractPriv", codecForString())
-      .property("exchangeBaseUrl", codecForString())
+      .property("talerUri", codecForString())
       .build("CheckPeerPushPaymentRequest");
 
 export interface AcceptPeerPushPaymentRequest {
-  exchangeBaseUrl: string;
-  pursePub: string;
+  /**
+   * Transparent identifier of the incoming peer push payment.
+   */
+  peerPushPaymentIncomingId: string;
 }
 
 export const codecForAcceptPeerPushPaymentRequest =
   (): Codec<AcceptPeerPushPaymentRequest> =>
     buildCodecForObject<AcceptPeerPushPaymentRequest>()
-      .property("pursePub", codecForString())
-      .property("exchangeBaseUrl", codecForString())
+      .property("peerPushPaymentIncomingId", codecForString())
       .build("AcceptPeerPushPaymentRequest");
diff --git a/packages/taler-wallet-cli/src/harness/harness.ts 
b/packages/taler-wallet-cli/src/harness/harness.ts
index 3b58219b..c735c995 100644
--- a/packages/taler-wallet-cli/src/harness/harness.ts
+++ b/packages/taler-wallet-cli/src/harness/harness.ts
@@ -70,7 +70,7 @@ import {
   TipCreateConfirmation,
   TipCreateRequest,
   TippingReserveStatus,
-} from "./merchantApiTypes";
+} from "./merchantApiTypes.js";
 
 const exec = util.promisify(require("child_process").exec);
 
@@ -478,14 +478,14 @@ class BankServiceBase {
     protected globalTestState: GlobalTestState,
     protected bankConfig: BankConfig,
     protected configFile: string,
-  ) { }
+  ) {}
 }
 
 /**
  * Work in progress.  The key point is that both Sandbox and Nexus
  * will be configured and started by this class.
  */
-class EufinBankService extends BankServiceBase implements BankServiceHandle {
+class LibEuFinBankService extends BankServiceBase implements BankServiceHandle 
{
   sandboxProc: ProcessWrapper | undefined;
   nexusProc: ProcessWrapper | undefined;
 
@@ -494,8 +494,8 @@ class EufinBankService extends BankServiceBase implements 
BankServiceHandle {
   static async create(
     gc: GlobalTestState,
     bc: BankConfig,
-  ): Promise<EufinBankService> {
-    return new EufinBankService(gc, bc, "foo");
+  ): Promise<LibEuFinBankService> {
+    return new LibEuFinBankService(gc, bc, "foo");
   }
 
   get port() {
@@ -761,7 +761,10 @@ class EufinBankService extends BankServiceBase implements 
BankServiceHandle {
   }
 }
 
-class PybankService extends BankServiceBase implements BankServiceHandle {
+/**
+ * Implementation of the bank service using the "taler-fakebank-run" tool.
+ */
+class FakebankService extends BankServiceBase implements BankServiceHandle {
   proc: ProcessWrapper | undefined;
 
   http = new NodeHttpLib();
@@ -769,41 +772,23 @@ class PybankService extends BankServiceBase implements 
BankServiceHandle {
   static async create(
     gc: GlobalTestState,
     bc: BankConfig,
-  ): Promise<PybankService> {
+  ): Promise<FakebankService> {
     const config = new Configuration();
     setTalerPaths(config, gc.testDir + "/talerhome");
     config.setString("taler", "currency", bc.currency);
-    config.setString("bank", "database", bc.database);
     config.setString("bank", "http_port", `${bc.httpPort}`);
     config.setString("bank", "serve", "http");
     config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
     config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
-    config.setString(
-      "bank",
-      "allow_registrations",
-      bc.allowRegistrations ? "yes" : "no",
-    );
     const cfgFilename = gc.testDir + "/bank.conf";
     config.write(cfgFilename);
 
-    await sh(
-      gc,
-      "taler-bank-manage_django",
-      `taler-bank-manage -c '${cfgFilename}' django migrate`,
-    );
-    await sh(
-      gc,
-      "taler-bank-manage_django",
-      `taler-bank-manage -c '${cfgFilename}' django provide_accounts`,
-    );
-
-    return new PybankService(gc, bc, cfgFilename);
+    return new FakebankService(gc, bc, cfgFilename);
   }
 
   setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
     const config = Configuration.load(this.configFile);
     config.setString("bank", "suggested_exchange", e.baseUrl);
-    config.setString("bank", "suggested_exchange_payto", exchangePayto);
     config.write(this.configFile);
   }
 
@@ -815,21 +800,6 @@ class PybankService extends BankServiceBase implements 
BankServiceHandle {
     accountName: string,
     password: string,
   ): Promise<HarnessExchangeBankAccount> {
-    await sh(
-      this.globalTestState,
-      "taler-bank-manage_django",
-      `taler-bank-manage -c '${this.configFile}' django add_bank_account 
${accountName}`,
-    );
-    await sh(
-      this.globalTestState,
-      "taler-bank-manage_django",
-      `taler-bank-manage -c '${this.configFile}' django changepassword_unsafe 
${accountName} ${password}`,
-    );
-    await sh(
-      this.globalTestState,
-      "taler-bank-manage_django",
-      `taler-bank-manage -c '${this.configFile}' django top_up ${accountName} 
${this.bankConfig.currency}:100000`,
-    );
     return {
       accountName: accountName,
       accountPassword: password,
@@ -844,8 +814,8 @@ class PybankService extends BankServiceBase implements 
BankServiceHandle {
 
   async start(): Promise<void> {
     this.proc = this.globalTestState.spawnService(
-      "taler-bank-manage",
-      ["-c", this.configFile, "serve"],
+      "taler-fakebank-run",
+      ["-c", this.configFile],
       "bank",
     );
   }
@@ -857,7 +827,7 @@ class PybankService extends BankServiceBase implements 
BankServiceHandle {
 }
 
 // Use libeufin bank instead of pybank.
-const useLibeufinBank = process.env.WALLET_HARNESS_WITH_EUFIN;
+const useLibeufinBank = true;
 
 /**
  * Return a euFin or a pyBank implementation of
@@ -866,21 +836,21 @@ const useLibeufinBank = 
process.env.WALLET_HARNESS_WITH_EUFIN;
  * on a particular env variable.
  */
 function getBankServiceImpl(): {
-  prototype: typeof PybankService.prototype;
-  create: typeof PybankService.create;
+  prototype: typeof FakebankService.prototype;
+  create: typeof FakebankService.create;
 } {
   if (useLibeufinBank)
     return {
-      prototype: EufinBankService.prototype,
-      create: EufinBankService.create,
+      prototype: LibEuFinBankService.prototype,
+      create: LibEuFinBankService.create,
     };
   return {
-    prototype: PybankService.prototype,
-    create: PybankService.create,
+    prototype: FakebankService.prototype,
+    create: FakebankService.create,
   };
 }
 
-export type BankService = PybankService;
+export type BankService = FakebankService;
 export const BankService = getBankServiceImpl();
 
 export class FakeBankService {
@@ -923,7 +893,7 @@ export class FakeBankService {
     private globalTestState: GlobalTestState,
     private bankConfig: FakeBankConfig,
     private configFile: string,
-  ) { }
+  ) {}
 
   async start(): Promise<void> {
     this.proc = this.globalTestState.spawnService(
@@ -1189,7 +1159,7 @@ export class ExchangeService implements 
ExchangeServiceInterface {
     private exchangeConfig: ExchangeConfig,
     private configFilename: string,
     private keyPair: EddsaKeyPair,
-  ) { }
+  ) {}
 
   get name() {
     return this.exchangeConfig.name;
@@ -1442,7 +1412,7 @@ export class MerchantApiClient {
   constructor(
     private baseUrl: string,
     public readonly auth: MerchantAuthConfiguration,
-  ) { }
+  ) {}
 
   async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
     const url = new URL("private/auth", this.baseUrl);
@@ -1635,7 +1605,7 @@ export class MerchantService implements 
MerchantServiceInterface {
     private globalState: GlobalTestState,
     private merchantConfig: MerchantConfig,
     private configFilename: string,
-  ) { }
+  ) {}
 
   private currentTimetravel: Duration | undefined;
 
@@ -1947,8 +1917,10 @@ export class WalletCli {
         const resp = await sh(
           self.globalTestState,
           `wallet-${self.name}`,
-          `taler-wallet-cli ${self.timetravelArg ?? ""
-          } --no-throttle -LTRACE --wallet-db '${self.dbfile
+          `taler-wallet-cli ${
+            self.timetravelArg ?? ""
+          } --no-throttle -LTRACE --wallet-db '${
+            self.dbfile
           }' api '${op}' ${shellWrap(JSON.stringify(payload))}`,
         );
         console.log("--- wallet core response ---");
diff --git a/packages/taler-wallet-cli/src/harness/libeufin.ts 
b/packages/taler-wallet-cli/src/harness/libeufin.ts
index 0107d5a8..7356a627 100644
--- a/packages/taler-wallet-cli/src/harness/libeufin.ts
+++ b/packages/taler-wallet-cli/src/harness/libeufin.ts
@@ -36,7 +36,7 @@ import {
   runCommand,
   setupDb,
   sh,
-  getRandomIban
+  getRandomIban,
 } from "../harness/harness.js";
 import {
   LibeufinSandboxApi,
@@ -53,13 +53,10 @@ import {
   CreateAnastasisFacadeRequest,
   PostNexusTaskRequest,
   PostNexusPermissionRequest,
-  CreateNexusUserRequest
+  CreateNexusUserRequest,
 } from "../harness/libeufin-apis.js";
 
-export {
-  LibeufinSandboxApi,
-  LibeufinNexusApi
-}
+export { LibeufinSandboxApi, LibeufinNexusApi };
 
 export interface LibeufinServices {
   libeufinSandbox: LibeufinSandboxService;
@@ -206,6 +203,16 @@ export class LibeufinSandboxService implements 
LibeufinSandboxServiceInterface {
   }
 
   async start(): Promise<void> {
+    await sh(
+      this.globalTestState,
+      "libeufin-sandbox-config",
+      "libeufin-sandbox config default",
+      {
+        ...process.env,
+        LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
+      },
+    );
+
     this.sandboxProc = this.globalTestState.spawnService(
       "libeufin-sandbox",
       ["serve", "--port", `${this.sandboxConfig.httpPort}`],
@@ -235,7 +242,8 @@ export class LibeufinSandboxService implements 
LibeufinSandboxServiceInterface {
     debit: string,
     credit: string,
     amount: string, // $currency:x.y
-    subject: string,): Promise<string> {
+    subject: string,
+  ): Promise<string> {
     const stdout = await sh(
       this.globalTestState,
       "libeufin-sandbox-maketransfer",
@@ -428,7 +436,7 @@ export class LibeufinCli {
       LIBEUFIN_SANDBOX_URL: this.cliDetails.sandboxUrl,
       LIBEUFIN_SANDBOX_USERNAME: "admin",
       LIBEUFIN_SANDBOX_PASSWORD: "secret",
-    }
+    };
   }
 
   async checkSandbox(): Promise<void> {
@@ -436,7 +444,7 @@ export class LibeufinCli {
       this.globalTestState,
       "libeufin-cli-checksandbox",
       "libeufin-cli sandbox check",
-      this.env()
+      this.env(),
     );
   }
 
@@ -445,7 +453,7 @@ export class LibeufinCli {
       this.globalTestState,
       "libeufin-cli-createebicshost",
       `libeufin-cli sandbox ebicshost create --host-id=${hostId}`,
-      this.env()
+      this.env(),
     );
     console.log(stdout);
   }
@@ -460,7 +468,7 @@ export class LibeufinCli {
         ` --host-id=${details.hostId}` +
         ` --partner-id=${details.partnerId}` +
         ` --user-id=${details.userId}`,
-      this.env()
+      this.env(),
     );
     console.log(stdout);
   }
@@ -480,7 +488,7 @@ export class LibeufinCli {
         ` --ebics-host-id=${sd.hostId}` +
         ` --ebics-partner-id=${sd.partnerId}` +
         ` --ebics-user-id=${sd.userId}`,
-      this.env()
+      this.env(),
     );
     console.log(stdout);
   }
@@ -490,7 +498,7 @@ export class LibeufinCli {
       this.globalTestState,
       "libeufin-cli-generatetransactions",
       `libeufin-cli sandbox bankaccount generate-transactions ${accountName}`,
-      this.env()
+      this.env(),
     );
     console.log(stdout);
   }
@@ -500,7 +508,7 @@ export class LibeufinCli {
       this.globalTestState,
       "libeufin-cli-showsandboxtransactions",
       `libeufin-cli sandbox bankaccount transactions ${accountName}`,
-      this.env()
+      this.env(),
     );
     console.log(stdout);
   }
@@ -834,9 +842,12 @@ export async function launchLibeufinServices(
             libeufinNexus,
             nb.twgHistoryPermission,
           );
-         break;
+          break;
         case "anastasis":
-         await LibeufinNexusApi.createAnastasisFacade(libeufinNexus, 
nb.anastasisReq);
+          await LibeufinNexusApi.createAnastasisFacade(
+            libeufinNexus,
+            nb.anastasisReq,
+          );
       }
     }
   }
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts
 
b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts
index 84b40111..cb57c7d0 100644
--- 
a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts
+++ 
b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts
@@ -96,7 +96,7 @@ export async function runLibeufinApiBankaccountTest(t: 
GlobalTestState) {
       debtorName: "mock2",
       amount: "1",
       subject: "mock subject",
-    }
+    },
   );
   await LibeufinNexusApi.fetchTransactions(nexus, "local-mock");
   let transactions = await LibeufinNexusApi.getAccountTransactions(
@@ -106,4 +106,5 @@ export async function runLibeufinApiBankaccountTest(t: 
GlobalTestState) {
   let el = findNexusPayment("mock subject", transactions.data);
   t.assertTrue(el instanceof Object);
 }
+
 runLibeufinApiBankaccountTest.suites = ["libeufin"];
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts
index aa5d4c9c..ca7dc33d 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts
@@ -17,12 +17,7 @@
 /**
  * Imports.
  */
-import {
-  AbsoluteTime,
-  ContractTerms,
-  Duration,
-  durationFromSpec,
-} from "@gnu-taler/taler-util";
+import { AbsoluteTime, ContractTerms, Duration } from "@gnu-taler/taler-util";
 import {
   WalletApiOperation,
   HarnessExchangeBankAccount,
@@ -42,7 +37,7 @@ import {
   LibeufinNexusService,
   LibeufinSandboxApi,
   LibeufinSandboxService,
-} from "../harness/libeufin";
+} from "../harness/libeufin.js";
 
 const exchangeIban = "DE71500105179674997361";
 const customerIban = "DE84500105176881385584";
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
index 5c716dc5..c22258bc 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
@@ -22,7 +22,6 @@ import { GlobalTestState } from "../harness/harness.js";
 import {
   createSimpleTestkudosEnvironment,
   withdrawViaBank,
-  makeTestPayment,
 } from "../harness/helpers.js";
 
 /**
@@ -55,9 +54,7 @@ export async function runPeerToPeerTest(t: GlobalTestState) {
   const checkResp = await wallet.client.call(
     WalletApiOperation.CheckPeerPushPayment,
     {
-      contractPriv: resp.contractPriv,
-      exchangeBaseUrl: resp.exchangeBaseUrl,
-      pursePub: resp.pursePub,
+      talerUri: resp.talerUri,
     },
   );
 
@@ -66,8 +63,7 @@ export async function runPeerToPeerTest(t: GlobalTestState) {
   const acceptResp = await wallet.client.call(
     WalletApiOperation.AcceptPeerPushPayment,
     {
-      exchangeBaseUrl: resp.exchangeBaseUrl,
-      pursePub: resp.pursePub,
+      peerPushPaymentIncomingId: checkResp.peerPushPaymentIncomingId,
     },
   );
 
diff --git a/packages/taler-wallet-core/src/db.ts 
b/packages/taler-wallet-core/src/db.ts
index f763aae6..8f558abd 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -127,36 +127,6 @@ export interface ReserveBankInfo {
    * Exchange payto URI that the bank will use to fund the reserve.
    */
   exchangePaytoUri: string;
-}
-
-/**
- * A reserve record as stored in the wallet's database.
- */
-export interface ReserveRecord {
-  /**
-   * The reserve public key.
-   */
-  reservePub: string;
-
-  /**
-   * The reserve private key.
-   */
-  reservePriv: string;
-
-  /**
-   * The exchange base URL for the reserve.
-   */
-  exchangeBaseUrl: string;
-
-  /**
-   * Currency of the reserve.
-   */
-  currency: string;
-
-  /**
-   * Time when the reserve was created.
-   */
-  timestampCreated: TalerProtocolTimestamp;
 
   /**
    * Time when the information about this reserve was posted to the bank.
@@ -165,83 +135,14 @@ export interface ReserveRecord {
    *
    * Set to undefined if that hasn't happened yet.
    */
-  timestampReserveInfoPosted: TalerProtocolTimestamp | undefined;
+  timestampReserveInfoPosted?: TalerProtocolTimestamp;
 
   /**
    * Time when the reserve was confirmed by the bank.
    *
    * Set to undefined if not confirmed yet.
    */
-  timestampBankConfirmed: TalerProtocolTimestamp | undefined;
-
-  /**
-   * Wire information (as payto URI) for the bank account that
-   * transferred funds for this reserve.
-   */
-  senderWire?: string;
-
-  /**
-   * Amount that was sent by the user to fund the reserve.
-   */
-  instructedAmount: AmountJson;
-
-  /**
-   * Extra state for when this is a withdrawal involving
-   * a Taler-integrated bank.
-   */
-  bankInfo?: ReserveBankInfo;
-
-  /**
-   * Restrict withdrawals from this reserve to this age.
-   */
-  restrictAge?: number;
-
-  /**
-   * Pre-allocated ID of the withdrawal group for the first withdrawal
-   * on this reserve.
-   */
-  initialWithdrawalGroupId: string;
-
-  /**
-   * Did we start the first withdrawal for this reserve?
-   *
-   * We only report a pending withdrawal for the reserve before
-   * the first withdrawal has started.
-   */
-  initialWithdrawalStarted: boolean;
-
-  /**
-   * Initial denomination selection, stored here so that
-   * we can show this information in the transactions/balances
-   * before we have a withdrawal group.
-   */
-  initialDenomSel: DenomSelectionState;
-
-  /**
-   * Current status of the reserve.
-   */
-  reserveStatus: ReserveRecordStatus;
-
-  /**
-   * Is there any work to be done for this reserve?
-   *
-   * Technically redundant, since the reserveStatus would indicate this.
-   * However, we use the operationStatus for DB indexing of pending operations.
-   */
-  operationStatus: OperationStatus;
-
-  /**
-   * Retry info, in case the reserve needs to be processed again
-   * later, either due to an error or because the wallet needs to
-   * wait for something.
-   */
-  retryInfo: RetryInfo | undefined;
-
-  /**
-   * Last error that happened in a reserve operation
-   * (either talking to the bank or the exchange).
-   */
-  lastError: TalerErrorDetail | undefined;
+  timestampBankConfirmed?: TalerProtocolTimestamp;
 }
 
 /**
@@ -514,6 +415,11 @@ export interface ExchangeDetailsPointer {
   updateClock: TalerProtocolTimestamp;
 }
 
+export interface MergeReserveInfo {
+  reservePub: string;
+  reservePriv: string;
+}
+
 /**
  * Exchange record as stored in the wallet's database.
  */
@@ -568,7 +474,7 @@ export interface ExchangeRecord {
    * Public key of the reserve that we're currently using for
    * receiving P2P payments.
    */
-  currentMergeReservePub?: string;
+  currentMergeReserveInfo?: MergeReserveInfo;
 }
 
 /**
@@ -1373,6 +1279,7 @@ export interface WithdrawalGroupRecord {
 
   /**
    * Secret seed used to derive planchets.
+   * Stored since planchets are created lazily.
    */
   secretSeed: string;
 
@@ -1381,6 +1288,11 @@ export interface WithdrawalGroupRecord {
    */
   reservePub: string;
 
+  /**
+   * The reserve private key.
+   */
+  reservePriv: string;
+
   /**
    * The exchange base URL that we're withdrawing from.
    * (Redundantly stored, as the reserve record also has this info.)
@@ -1395,8 +1307,6 @@ export interface WithdrawalGroupRecord {
 
   /**
    * When was the withdrawal operation completed?
-   *
-   * FIXME: We should probably drop this and introduce an OperationStatus 
field.
    */
   timestampFinish?: TalerProtocolTimestamp;
 
@@ -1406,6 +1316,33 @@ export interface WithdrawalGroupRecord {
    */
   operationStatus: OperationStatus;
 
+  /**
+   * Current status of the reserve.
+   */
+  reserveStatus: ReserveRecordStatus;
+
+  /**
+   * Amount that was sent by the user to fund the reserve.
+   */
+  instructedAmount: AmountJson;
+
+  /**
+   * Wire information (as payto URI) for the bank account that
+   * transferred funds for this reserve.
+   */
+  senderWire?: string;
+
+  /**
+   * Restrict withdrawals from this reserve to this age.
+   */
+  restrictAge?: number;
+
+  /**
+   * Extra state for when this is a withdrawal involving
+   * a Taler-integrated bank.
+   */
+  bankInfo?: ReserveBankInfo;
+
   /**
    * Amount including fees (i.e. the amount subtracted from the
    * reserve to withdraw all coins in this withdrawal session).
@@ -1730,9 +1667,11 @@ export interface PeerPushPaymentInitiationRecord {
 /**
  * Record for a push P2P payment that this wallet was offered.
  *
- * Primary key: (exchangeBaseUrl, pursePub)
+ * Unique: (exchangeBaseUrl, pursePub)
  */
 export interface PeerPushPaymentIncomingRecord {
+  peerPushPaymentIncomingId: string;
+
   exchangeBaseUrl: string;
 
   pursePub: string;
@@ -1828,16 +1767,6 @@ export const WalletStoresV1 = {
     }),
     {},
   ),
-  reserves: describeStore(
-    describeContents<ReserveRecord>("reserves", { keyPath: "reservePub" }),
-    {
-      byInitialWithdrawalGroupId: describeIndex(
-        "byInitialWithdrawalGroupId",
-        "initialWithdrawalGroupId",
-      ),
-      byStatus: describeIndex("byStatus", "operationStatus"),
-    },
-  ),
   purchases: describeStore(
     describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }),
     {
@@ -1926,9 +1855,14 @@ export const WalletStoresV1 = {
   ),
   peerPushPaymentIncoming: describeStore(
     describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", 
{
-      keyPath: ["exchangeBaseUrl", "pursePub"],
+      keyPath: "peerPushPaymentIncomingId",
     }),
-    {},
+    {
+      byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
+        "exchangeBaseUrl",
+        "pursePub",
+      ]),
+    },
   ),
 };
 
diff --git a/packages/taler-wallet-core/src/index.ts 
b/packages/taler-wallet-core/src/index.ts
index 8b0f1749..92fe852a 100644
--- a/packages/taler-wallet-core/src/index.ts
+++ b/packages/taler-wallet-core/src/index.ts
@@ -53,7 +53,6 @@ export * from "./operations/exchanges.js";
 
 export * from "./bank-api-client.js";
 
-export * from "./operations/reserves.js";
 export * from "./operations/withdraw.js";
 export * from "./operations/refresh.js";
 
diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts 
b/packages/taler-wallet-core/src/internal-wallet-state.ts
index 7074128b..0650ed04 100644
--- a/packages/taler-wallet-core/src/internal-wallet-state.ts
+++ b/packages/taler-wallet-core/src/internal-wallet-state.ts
@@ -73,15 +73,6 @@ export interface MerchantOperations {
   ): Promise<MerchantInfo>;
 }
 
-export interface ReserveOperations {
-  processReserve(
-    ws: InternalWalletState,
-    reservePub: string,
-    options?: {
-      forceNow?: boolean;
-    },
-  ): Promise<void>;
-}
 
 /**
  * Interface for exchange-related operations.
@@ -234,7 +225,6 @@ export interface InternalWalletState {
   exchangeOps: ExchangeOperations;
   recoupOps: RecoupOperations;
   merchantOps: MerchantOperations;
-  reserveOps: ReserveOperations;
 
   getDenomInfo(
     ws: InternalWalletState,
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts 
b/packages/taler-wallet-core/src/operations/backup/export.ts
index d4c82297..c77ce1a8 100644
--- a/packages/taler-wallet-core/src/operations/backup/export.ts
+++ b/packages/taler-wallet-core/src/operations/backup/export.ts
@@ -88,7 +88,6 @@ export async function exportBackup(
       backupProviders: x.backupProviders,
       tips: x.tips,
       recoupGroups: x.recoupGroups,
-      reserves: x.reserves,
       withdrawalGroups: x.withdrawalGroups,
     }))
     .runReadWrite(async (tx) => {
@@ -128,29 +127,6 @@ export async function exportBackup(
         });
       });
 
-      await tx.reserves.iter().forEach((reserve) => {
-        const backupReserve: BackupReserve = {
-          initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map(
-            (x) => ({
-              count: x.count,
-              denom_pub_hash: x.denomPubHash,
-            }),
-          ),
-          initial_withdrawal_group_id: reserve.initialWithdrawalGroupId,
-          instructed_amount: Amounts.stringify(reserve.instructedAmount),
-          reserve_priv: reserve.reservePriv,
-          timestamp_created: reserve.timestampCreated,
-          withdrawal_groups:
-            withdrawalGroupsByReserve[reserve.reservePub] ?? [],
-          // FIXME!
-          timestamp_last_activity: reserve.timestampCreated,
-        };
-        const backupReserves = (backupReservesByExchange[
-          reserve.exchangeBaseUrl
-        ] ??= []);
-        backupReserves.push(backupReserve);
-      });
-
       await tx.tips.iter().forEach((tip) => {
         backupTips.push({
           exchange_base_url: tip.exchangeBaseUrl,
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts 
b/packages/taler-wallet-core/src/operations/backup/import.ts
index e099fae5..f26c4277 100644
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -236,7 +236,6 @@ export async function importBackup(
       backupProviders: x.backupProviders,
       tips: x.tips,
       recoupGroups: x.recoupGroups,
-      reserves: x.reserves,
       withdrawalGroups: x.withdrawalGroups,
       tombstones: x.tombstones,
       depositGroups: x.depositGroups,
@@ -427,94 +426,98 @@ export async function importBackup(
           }
         }
 
-        for (const backupReserve of backupExchangeDetails.reserves) {
-          const reservePub =
-            cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
-          const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
-          if (tombstoneSet.has(ts)) {
-            continue;
-          }
-          checkLogicInvariant(!!reservePub);
-          const existingReserve = await tx.reserves.get(reservePub);
-          const instructedAmount = Amounts.parseOrThrow(
-            backupReserve.instructed_amount,
-          );
-          if (!existingReserve) {
-            let bankInfo: ReserveBankInfo | undefined;
-            if (backupReserve.bank_info) {
-              bankInfo = {
-                exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
-                statusUrl: backupReserve.bank_info.status_url,
-                confirmUrl: backupReserve.bank_info.confirm_url,
-              };
-            }
-            await tx.reserves.put({
-              currency: instructedAmount.currency,
-              instructedAmount,
-              exchangeBaseUrl: backupExchangeDetails.base_url,
-              reservePub,
-              reservePriv: backupReserve.reserve_priv,
-              bankInfo,
-              timestampCreated: backupReserve.timestamp_created,
-              timestampBankConfirmed:
-                backupReserve.bank_info?.timestamp_bank_confirmed,
-              timestampReserveInfoPosted:
-                backupReserve.bank_info?.timestamp_reserve_info_posted,
-              senderWire: backupReserve.sender_wire,
-              retryInfo: RetryInfo.reset(),
-              lastError: undefined,
-              initialWithdrawalGroupId:
-                backupReserve.initial_withdrawal_group_id,
-              initialWithdrawalStarted:
-                backupReserve.withdrawal_groups.length > 0,
-              // FIXME!
-              reserveStatus: ReserveRecordStatus.QueryingStatus,
-              initialDenomSel: await getDenomSelStateFromBackup(
-                tx,
-                backupExchangeDetails.base_url,
-                backupReserve.initial_selected_denoms,
-              ),
-              // FIXME!
-              operationStatus: OperationStatus.Pending,
-            });
-          }
-          for (const backupWg of backupReserve.withdrawal_groups) {
-            const ts = makeEventId(
-              TombstoneTag.DeleteWithdrawalGroup,
-              backupWg.withdrawal_group_id,
-            );
-            if (tombstoneSet.has(ts)) {
-              continue;
-            }
-            const existingWg = await tx.withdrawalGroups.get(
-              backupWg.withdrawal_group_id,
-            );
-            if (!existingWg) {
-              await tx.withdrawalGroups.put({
-                denomsSel: await getDenomSelStateFromBackup(
-                  tx,
-                  backupExchangeDetails.base_url,
-                  backupWg.selected_denoms,
-                ),
-                exchangeBaseUrl: backupExchangeDetails.base_url,
-                lastError: undefined,
-                rawWithdrawalAmount: Amounts.parseOrThrow(
-                  backupWg.raw_withdrawal_amount,
-                ),
-                reservePub,
-                retryInfo: RetryInfo.reset(),
-                secretSeed: backupWg.secret_seed,
-                timestampStart: backupWg.timestamp_created,
-                timestampFinish: backupWg.timestamp_finish,
-                withdrawalGroupId: backupWg.withdrawal_group_id,
-                denomSelUid: backupWg.selected_denoms_id,
-                operationStatus: backupWg.timestamp_finish
-                  ? OperationStatus.Finished
-                  : OperationStatus.Pending,
-              });
-            }
-          }
-        }
+
+      // FIXME: import reserves with new schema
+
+        // for (const backupReserve of backupExchangeDetails.reserves) {
+        //   const reservePub =
+        //     cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
+        //   const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
+        //   if (tombstoneSet.has(ts)) {
+        //     continue;
+        //   }
+        //   checkLogicInvariant(!!reservePub);
+        //   const existingReserve = await tx.reserves.get(reservePub);
+        //   const instructedAmount = Amounts.parseOrThrow(
+        //     backupReserve.instructed_amount,
+        //   );
+        //   if (!existingReserve) {
+        //     let bankInfo: ReserveBankInfo | undefined;
+        //     if (backupReserve.bank_info) {
+        //       bankInfo = {
+        //         exchangePaytoUri: 
backupReserve.bank_info.exchange_payto_uri,
+        //         statusUrl: backupReserve.bank_info.status_url,
+        //         confirmUrl: backupReserve.bank_info.confirm_url,
+        //       };
+        //     }
+        //     await tx.reserves.put({
+        //       currency: instructedAmount.currency,
+        //       instructedAmount,
+        //       exchangeBaseUrl: backupExchangeDetails.base_url,
+        //       reservePub,
+        //       reservePriv: backupReserve.reserve_priv,
+        //       bankInfo,
+        //       timestampCreated: backupReserve.timestamp_created,
+        //       timestampBankConfirmed:
+        //         backupReserve.bank_info?.timestamp_bank_confirmed,
+        //       timestampReserveInfoPosted:
+        //         backupReserve.bank_info?.timestamp_reserve_info_posted,
+        //       senderWire: backupReserve.sender_wire,
+        //       retryInfo: RetryInfo.reset(),
+        //       lastError: undefined,
+        //       initialWithdrawalGroupId:
+        //         backupReserve.initial_withdrawal_group_id,
+        //       initialWithdrawalStarted:
+        //         backupReserve.withdrawal_groups.length > 0,
+        //       // FIXME!
+        //       reserveStatus: ReserveRecordStatus.QueryingStatus,
+        //       initialDenomSel: await getDenomSelStateFromBackup(
+        //         tx,
+        //         backupExchangeDetails.base_url,
+        //         backupReserve.initial_selected_denoms,
+        //       ),
+        //       // FIXME!
+        //       operationStatus: OperationStatus.Pending,
+        //     });
+        //   }
+        //   for (const backupWg of backupReserve.withdrawal_groups) {
+        //     const ts = makeEventId(
+        //       TombstoneTag.DeleteWithdrawalGroup,
+        //       backupWg.withdrawal_group_id,
+        //     );
+        //     if (tombstoneSet.has(ts)) {
+        //       continue;
+        //     }
+        //     const existingWg = await tx.withdrawalGroups.get(
+        //       backupWg.withdrawal_group_id,
+        //     );
+        //     if (!existingWg) {
+        //       await tx.withdrawalGroups.put({
+        //         denomsSel: await getDenomSelStateFromBackup(
+        //           tx,
+        //           backupExchangeDetails.base_url,
+        //           backupWg.selected_denoms,
+        //         ),
+        //         exchangeBaseUrl: backupExchangeDetails.base_url,
+        //         lastError: undefined,
+        //         rawWithdrawalAmount: Amounts.parseOrThrow(
+        //           backupWg.raw_withdrawal_amount,
+        //         ),
+        //         reservePub,
+        //         retryInfo: RetryInfo.reset(),
+        //         secretSeed: backupWg.secret_seed,
+        //         timestampStart: backupWg.timestamp_created,
+        //         timestampFinish: backupWg.timestamp_finish,
+        //         withdrawalGroupId: backupWg.withdrawal_group_id,
+        //         denomSelUid: backupWg.selected_denoms_id,
+        //         operationStatus: backupWg.timestamp_finish
+        //           ? OperationStatus.Finished
+        //           : OperationStatus.Pending,
+        //       });
+        //     }
+        //   }
+        // }
+
       }
 
       for (const backupProposal of backupBlob.proposals) {
@@ -920,10 +923,6 @@ export async function importBackup(
         } else if (type === TombstoneTag.DeleteRefund) {
           // Nothing required, will just prevent display
           // in the transactions list
-        } else if (type === TombstoneTag.DeleteReserve) {
-          // FIXME:  Once we also have account (=kyc) reserves,
-          // we need to check if the reserve is an account before deleting here
-          await tx.reserves.delete(rest[0]);
         } else if (type === TombstoneTag.DeleteTip) {
           await tx.tips.delete(rest[0]);
         } else if (type === TombstoneTag.DeleteWithdrawalGroup) {
diff --git a/packages/taler-wallet-core/src/operations/balance.ts 
b/packages/taler-wallet-core/src/operations/balance.ts
index c26eb0cf..4590f505 100644
--- a/packages/taler-wallet-core/src/operations/balance.ts
+++ b/packages/taler-wallet-core/src/operations/balance.ts
@@ -41,7 +41,6 @@ interface WalletBalance {
 export async function getBalancesInsideTransaction(
   ws: InternalWalletState,
   tx: GetReadOnlyAccess<{
-    reserves: typeof WalletStoresV1.reserves;
     coins: typeof WalletStoresV1.coins;
     refreshGroups: typeof WalletStoresV1.refreshGroups;
     withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
@@ -65,17 +64,6 @@ export async function getBalancesInsideTransaction(
     return balanceStore[currency];
   };
 
-  // Initialize balance to zero, even if we didn't start withdrawing yet.
-  await tx.reserves.iter().forEach((r) => {
-    const b = initBalance(r.currency);
-    if (!r.initialWithdrawalStarted) {
-      b.pendingIncoming = Amounts.add(
-        b.pendingIncoming,
-        r.initialDenomSel.totalCoinValue,
-      ).amount;
-    }
-  });
-
   await tx.coins.iter().forEach((c) => {
     // Only count fresh coins, as dormant coins will
     // already be in a refresh session.
@@ -154,7 +142,6 @@ export async function getBalances(
     .mktx((x) => ({
       coins: x.coins,
       refreshGroups: x.refreshGroups,
-      reserves: x.reserves,
       purchases: x.purchases,
       withdrawalGroups: x.withdrawalGroups,
     }))
diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts 
b/packages/taler-wallet-core/src/operations/peer-to-peer.ts
index 658cbe4f..4d2f2bb5 100644
--- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts
+++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts
@@ -1,6 +1,6 @@
 /*
  This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
+ (C) 2022 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
@@ -30,35 +30,35 @@ import {
   codecForAmountString,
   codecForAny,
   codecForExchangeGetContractResponse,
+  constructPayPushUri,
   ContractTermsUtil,
   decodeCrock,
   Duration,
   eddsaGetPublic,
   encodeCrock,
   ExchangePurseMergeRequest,
+  getRandomBytes,
   InitiatePeerPushPaymentRequest,
   InitiatePeerPushPaymentResponse,
   j2s,
   Logger,
+  parsePayPushUri,
   strcmp,
   TalerProtocolTimestamp,
   UnblindedSignature,
   WalletAccountMergeFlags,
 } from "@gnu-taler/taler-util";
-import { url } from "inspector";
 import {
   CoinStatus,
+  MergeReserveInfo,
   OperationStatus,
-  ReserveRecord,
   ReserveRecordStatus,
+  WithdrawalGroupRecord,
 } from "../db.js";
-import {
-  checkSuccessResponseOrThrow,
-  readSuccessResponseJsonOrThrow,
-  throwUnexpectedRequestError,
-} from "../util/http.js";
+import { readSuccessResponseJsonOrThrow } from "../util/http.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { checkDbInvariant } from "../util/invariants.js";
+import { internalCreateWithdrawalGroup } from "./withdraw.js";
 
 const logger = new Logger("operations/peer-to-peer.ts");
 
@@ -265,6 +265,10 @@ export async function initiatePeerToPeerPush(
     mergePriv: mergePair.priv,
     pursePub: pursePair.pub,
     exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
+    talerUri: constructPayPushUri({
+      exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
+      contractPriv: econtractResp.contractPriv,
+    }),
   };
 }
 
@@ -281,26 +285,19 @@ export async function checkPeerPushPayment(
   ws: InternalWalletState,
   req: CheckPeerPushPaymentRequest,
 ): Promise<CheckPeerPushPaymentResponse> {
-  const getPurseUrl = new URL(
-    `purses/${req.pursePub}/deposit`,
-    req.exchangeBaseUrl,
-  );
+  // FIXME: Check if existing record exists!
 
-  const contractPub = encodeCrock(
-    eddsaGetPublic(decodeCrock(req.contractPriv)),
-  );
+  const uri = parsePayPushUri(req.talerUri);
 
-  const purseHttpResp = await ws.http.get(getPurseUrl.href);
+  if (!uri) {
+    throw Error("got invalid taler://pay-push URI");
+  }
 
-  const purseStatus = await readSuccessResponseJsonOrThrow(
-    purseHttpResp,
-    codecForExchangePurseStatus(),
-  );
+  const exchangeBaseUrl = uri.exchangeBaseUrl;
+  const contractPriv = uri.contractPriv;
+  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
 
-  const getContractUrl = new URL(
-    `contracts/${contractPub}`,
-    req.exchangeBaseUrl,
-  );
+  const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
 
   const contractHttpResp = await ws.http.get(getContractUrl.href);
 
@@ -309,22 +306,36 @@ export async function checkPeerPushPayment(
     codecForExchangeGetContractResponse(),
   );
 
+  const pursePub = contractResp.purse_pub;
+
   const dec = await ws.cryptoApi.decryptContractForMerge({
     ciphertext: contractResp.econtract,
-    contractPriv: req.contractPriv,
-    pursePub: req.pursePub,
+    contractPriv: contractPriv,
+    pursePub: pursePub,
   });
 
+  const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
+
+  const purseHttpResp = await ws.http.get(getPurseUrl.href);
+
+  const purseStatus = await readSuccessResponseJsonOrThrow(
+    purseHttpResp,
+    codecForExchangePurseStatus(),
+  );
+
+  const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32));
+
   await ws.db
     .mktx((x) => ({
       peerPushPaymentIncoming: x.peerPushPaymentIncoming,
     }))
     .runReadWrite(async (tx) => {
       await tx.peerPushPaymentIncoming.add({
-        contractPriv: req.contractPriv,
-        exchangeBaseUrl: req.exchangeBaseUrl,
+        peerPushPaymentIncomingId,
+        contractPriv: contractPriv,
+        exchangeBaseUrl: exchangeBaseUrl,
         mergePriv: dec.mergePriv,
-        pursePub: req.pursePub,
+        pursePub: pursePub,
         timestampAccepted: TalerProtocolTimestamp.now(),
         contractTerms: dec.contractTerms,
       });
@@ -333,6 +344,7 @@ export async function checkPeerPushPayment(
   return {
     amount: purseStatus.balance,
     contractTerms: dec.contractTerms,
+    peerPushPaymentIncomingId,
   };
 }
 
@@ -343,9 +355,9 @@ export function talerPaytoFromExchangeReserve(
   const url = new URL(exchangeBaseUrl);
   let proto: string;
   if (url.protocol === "http:") {
-    proto = "taler+http";
+    proto = "taler-reserve-http";
   } else if (url.protocol === "https:") {
-    proto = "taler";
+    proto = "taler-reserve";
   } else {
     throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
   }
@@ -365,69 +377,45 @@ export async function acceptPeerPushPayment(
   const peerInc = await ws.db
     .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming }))
     .runReadOnly(async (tx) => {
-      return tx.peerPushPaymentIncoming.get([
-        req.exchangeBaseUrl,
-        req.pursePub,
-      ]);
+      return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId);
     });
 
   if (!peerInc) {
-    throw Error("can't accept unknown incoming p2p push payment");
+    throw Error(
+      `can't accept unknown incoming p2p push payment 
(${req.peerPushPaymentIncomingId})`,
+    );
   }
 
   const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount);
 
-  // We have to create the key pair outside of the transaction,
+  // We have to eagerly create the key pair outside of the transaction,
   // due to the async crypto API.
   const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
 
-  const reserve: ReserveRecord | undefined = await ws.db
+  const mergeReserveInfo: MergeReserveInfo = await ws.db
     .mktx((x) => ({
       exchanges: x.exchanges,
-      reserves: x.reserves,
+      withdrawalGroups: x.withdrawalGroups,
     }))
     .runReadWrite(async (tx) => {
-      const ex = await tx.exchanges.get(req.exchangeBaseUrl);
+      const ex = await tx.exchanges.get(peerInc.exchangeBaseUrl);
       checkDbInvariant(!!ex);
-      if (ex.currentMergeReservePub) {
-        return await tx.reserves.get(ex.currentMergeReservePub);
+      if (ex.currentMergeReserveInfo) {
+        return ex.currentMergeReserveInfo;
       }
-      const rec: ReserveRecord = {
-        exchangeBaseUrl: req.exchangeBaseUrl,
-        // FIXME: field will be removed in the future, folded into 
withdrawal/p2p record.
-        reserveStatus: ReserveRecordStatus.Dormant,
-        timestampCreated: TalerProtocolTimestamp.now(),
-        instructedAmount: Amounts.getZero(amount.currency),
-        currency: amount.currency,
-        reservePub: newReservePair.pub,
+      await tx.exchanges.put(ex);
+      ex.currentMergeReserveInfo = {
         reservePriv: newReservePair.priv,
-        timestampBankConfirmed: undefined,
-        timestampReserveInfoPosted: undefined,
-        // FIXME!
-        initialDenomSel: undefined as any,
-        // FIXME!
-        initialWithdrawalGroupId: "",
-        initialWithdrawalStarted: false,
-        lastError: undefined,
-        operationStatus: OperationStatus.Pending,
-        retryInfo: undefined,
-        bankInfo: undefined,
-        restrictAge: undefined,
-        senderWire: undefined,
+        reservePub: newReservePair.pub,
       };
-      await tx.reserves.put(rec);
-      return rec;
+      return ex.currentMergeReserveInfo;
     });
 
-  if (!reserve) {
-    throw Error("can't create reserve");
-  }
-
   const mergeTimestamp = TalerProtocolTimestamp.now();
 
   const reservePayto = talerPaytoFromExchangeReserve(
-    reserve.exchangeBaseUrl,
-    reserve.reservePub,
+    peerInc.exchangeBaseUrl,
+    mergeReserveInfo.reservePub,
   );
 
   const sigRes = await ws.cryptoApi.signPurseMerge({
@@ -442,12 +430,12 @@ export async function acceptPeerPushPayment(
     purseFee: Amounts.stringify(Amounts.getZero(amount.currency)),
     pursePub: peerInc.pursePub,
     reservePayto,
-    reservePriv: reserve.reservePriv,
+    reservePriv: mergeReserveInfo.reservePriv,
   });
 
   const mergePurseUrl = new URL(
-    `purses/${req.pursePub}/merge`,
-    req.exchangeBaseUrl,
+    `purses/${peerInc.pursePub}/merge`,
+    peerInc.exchangeBaseUrl,
   );
 
   const mergeReq: ExchangePurseMergeRequest = {
@@ -459,6 +447,17 @@ export async function acceptPeerPushPayment(
 
   const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq);
 
+  logger.info(`merge request: ${j2s(mergeReq)}`);
   const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, 
codecForAny());
-  logger.info(`merge result: ${j2s(res)}`);
+  logger.info(`merge response: ${j2s(res)}`);
+
+  await internalCreateWithdrawalGroup(ws, {
+    amount,
+    exchangeBaseUrl: peerInc.exchangeBaseUrl,
+    reserveStatus: ReserveRecordStatus.QueryingStatus,
+    reserveKeyPair: {
+      priv: mergeReserveInfo.reservePriv,
+      pub: mergeReserveInfo.reservePub,
+    },
+  });
 }
diff --git a/packages/taler-wallet-core/src/operations/pending.ts 
b/packages/taler-wallet-core/src/operations/pending.ts
index 0a262d3b..ae93711f 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -70,44 +70,6 @@ async function gatherExchangePending(
   });
 }
 
-async function gatherReservePending(
-  tx: GetReadOnlyAccess<{ reserves: typeof WalletStoresV1.reserves }>,
-  now: AbsoluteTime,
-  resp: PendingOperationsResponse,
-): Promise<void> {
-  const reserves = await tx.reserves.indexes.byStatus.getAll(
-    OperationStatus.Pending,
-  );
-  for (const reserve of reserves) {
-    const reserveType = reserve.bankInfo
-      ? ReserveType.TalerBankWithdraw
-      : ReserveType.Manual;
-    switch (reserve.reserveStatus) {
-      case ReserveRecordStatus.Dormant:
-        // nothing to report as pending
-        break;
-      case ReserveRecordStatus.WaitConfirmBank:
-      case ReserveRecordStatus.QueryingStatus:
-      case ReserveRecordStatus.RegisteringBank: {
-        resp.pendingOperations.push({
-          type: PendingTaskType.Reserve,
-          givesLifeness: true,
-          timestampDue: reserve.retryInfo?.nextRetry ?? AbsoluteTime.now(),
-          stage: reserve.reserveStatus,
-          timestampCreated: reserve.timestampCreated,
-          reserveType,
-          reservePub: reserve.reservePub,
-          retryInfo: reserve.retryInfo,
-        });
-        break;
-      }
-      default:
-        // FIXME: report problem!
-        break;
-    }
-  }
-}
-
 async function gatherRefreshPending(
   tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups 
}>,
   now: AbsoluteTime,
@@ -336,7 +298,6 @@ export async function getPendingOperations(
       backupProviders: x.backupProviders,
       exchanges: x.exchanges,
       exchangeDetails: x.exchangeDetails,
-      reserves: x.reserves,
       refreshGroups: x.refreshGroups,
       coins: x.coins,
       withdrawalGroups: x.withdrawalGroups,
@@ -352,7 +313,6 @@ export async function getPendingOperations(
         pendingOperations: [],
       };
       await gatherExchangePending(tx, now, resp);
-      await gatherReservePending(tx, now, resp);
       await gatherRefreshPending(tx, now, resp);
       await gatherWithdrawalPending(tx, now, resp);
       await gatherProposalPending(tx, now, resp);
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts 
b/packages/taler-wallet-core/src/operations/recoup.ts
index d36a1028..7c0f79da 100644
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ b/packages/taler-wallet-core/src/operations/recoup.ts
@@ -26,28 +26,35 @@
  */
 import {
   Amounts,
-  codecForRecoupConfirmation, encodeCrock, getRandomBytes, j2s, Logger, 
NotificationType,
+  codecForRecoupConfirmation,
+  encodeCrock,
+  getRandomBytes,
+  j2s,
+  Logger,
+  NotificationType,
   RefreshReason,
   TalerErrorDetail,
-  TalerProtocolTimestamp, URL
+  TalerProtocolTimestamp,
+  URL,
 } from "@gnu-taler/taler-util";
 import {
   CoinRecord,
   CoinSourceType,
-  CoinStatus, OperationStatus, RecoupGroupRecord,
+  CoinStatus,
+  OperationStatus,
+  RecoupGroupRecord,
   RefreshCoinSource,
-  ReserveRecordStatus, WalletStoresV1, WithdrawCoinSource
+  ReserveRecordStatus,
+  WalletStoresV1,
+  WithdrawCoinSource,
 } from "../db.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { readSuccessResponseJsonOrThrow } from "../util/http.js";
 import { GetReadWriteAccess } from "../util/query.js";
-import {
-  RetryInfo
-} from "../util/retries.js";
+import { RetryInfo } from "../util/retries.js";
 import { guardOperationException } from "./common.js";
 import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
-import { getReserveRequestTimeout, processReserve } from "./reserves.js";
-
+import { internalCreateWithdrawalGroup } from "./withdraw.js";
 
 const logger = new Logger("operations/recoup.ts");
 
@@ -182,34 +189,24 @@ async function recoupWithdrawCoin(
   cs: WithdrawCoinSource,
 ): Promise<void> {
   const reservePub = cs.reservePub;
-  const d = await ws.db
+  const denomInfo = await ws.db
     .mktx((x) => ({
-      reserves: x.reserves,
       denominations: x.denominations,
     }))
     .runReadOnly(async (tx) => {
-      const reserve = await tx.reserves.get(reservePub);
-      if (!reserve) {
-        return;
-      }
       const denomInfo = await ws.getDenomInfo(
         ws,
         tx,
-        reserve.exchangeBaseUrl,
+        coin.exchangeBaseUrl,
         coin.denomPubHash,
       );
-      if (!denomInfo) {
-        return;
-      }
-      return { reserve, denomInfo };
+      return denomInfo;
     });
-  if (!d) {
+  if (!denomInfo) {
     // FIXME:  We should at least emit some pending operation / warning for 
this?
     return;
   }
 
-  const { reserve, denomInfo } = d;
-
   ws.notify({
     type: NotificationType.RecoupStarted,
   });
@@ -224,9 +221,7 @@ async function recoupWithdrawCoin(
   });
   const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, 
coin.exchangeBaseUrl);
   logger.trace(`requesting recoup via ${reqUrl.href}`);
-  const resp = await ws.http.postJson(reqUrl.href, recoupRequest, {
-    timeout: getReserveRequestTimeout(reserve),
-  });
+  const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
   const recoupConfirmation = await readSuccessResponseJsonOrThrow(
     resp,
     codecForRecoupConfirmation(),
@@ -244,7 +239,6 @@ async function recoupWithdrawCoin(
     .mktx((x) => ({
       coins: x.coins,
       denominations: x.denominations,
-      reserves: x.reserves,
       recoupGroups: x.recoupGroups,
       refreshGroups: x.refreshGroups,
     }))
@@ -260,18 +254,12 @@ async function recoupWithdrawCoin(
       if (!updatedCoin) {
         return;
       }
-      const updatedReserve = await tx.reserves.get(reserve.reservePub);
-      if (!updatedReserve) {
-        return;
-      }
       updatedCoin.status = CoinStatus.Dormant;
       const currency = updatedCoin.currentAmount.currency;
       updatedCoin.currentAmount = Amounts.getZero(currency);
-      updatedReserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
-      updatedReserve.retryInfo = RetryInfo.reset();
-      updatedReserve.operationStatus = OperationStatus.Pending;
       await tx.coins.put(updatedCoin);
-      await tx.reserves.put(updatedReserve);
+      // FIXME: Actually withdraw here!
+      // await internalCreateWithdrawalGroup(ws, {...});
       await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
     });
 
@@ -341,7 +329,6 @@ async function recoupRefreshCoin(
     .mktx((x) => ({
       coins: x.coins,
       denominations: x.denominations,
-      reserves: x.reserves,
       recoupGroups: x.recoupGroups,
       refreshGroups: x.refreshGroups,
     }))
@@ -446,12 +433,6 @@ async function processRecoupGroupImpl(
       reserveSet.add(coin.coinSource.reservePub);
     }
   }
-
-  for (const r of reserveSet.values()) {
-    processReserve(ws, r, { forceNow: true }).catch((e) => {
-      logger.error(`processing reserve ${r} after recoup failed`);
-    });
-  }
 }
 
 export async function createRecoupGroup(
diff --git a/packages/taler-wallet-core/src/operations/reserves.ts 
b/packages/taler-wallet-core/src/operations/reserves.ts
deleted file mode 100644
index b33f574f..00000000
--- a/packages/taler-wallet-core/src/operations/reserves.ts
+++ /dev/null
@@ -1,843 +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 {
-  AbsoluteTime,
-  AcceptWithdrawalResponse,
-  addPaytoQueryParams,
-  Amounts,
-  canonicalizeBaseUrl,
-  codecForBankWithdrawalOperationPostResponse,
-  codecForReserveStatus,
-  codecForWithdrawOperationStatusResponse,
-  CreateReserveRequest,
-  CreateReserveResponse,
-  Duration,
-  durationMax,
-  durationMin,
-  encodeCrock,
-  ForcedDenomSel,
-  getRandomBytes,
-  j2s,
-  Logger,
-  NotificationType,
-  randomBytes,
-  TalerErrorCode,
-  TalerErrorDetail,
-  URL,
-} from "@gnu-taler/taler-util";
-import {
-  DenomSelectionState,
-  OperationStatus,
-  ReserveBankInfo,
-  ReserveRecord,
-  ReserveRecordStatus,
-  WalletStoresV1,
-  WithdrawalGroupRecord,
-} from "../db.js";
-import { TalerError } from "../errors.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import {
-  readSuccessResponseJsonOrErrorCode,
-  readSuccessResponseJsonOrThrow,
-  throwUnexpectedRequestError,
-} from "../util/http.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-import { RetryInfo } from "../util/retries.js";
-import { guardOperationException } from "./common.js";
-import {
-  getExchangeDetails,
-  getExchangePaytoUri,
-  getExchangeTrust,
-  updateExchangeFromUrl,
-} from "./exchanges.js";
-import {
-  getBankWithdrawalInfo,
-  getCandidateWithdrawalDenoms,
-  processWithdrawGroup,
-  selectForcedWithdrawalDenominations,
-  selectWithdrawalDenominations,
-  updateWithdrawalDenoms,
-} from "./withdraw.js";
-
-const logger = new Logger("taler-wallet-core:reserves.ts");
-
-/**
- * Set up the reserve's retry timeout in preparation for
- * processing the reserve.
- */
-async function setupReserveRetry(
-  ws: InternalWalletState,
-  reservePub: string,
-  options: {
-    reset: boolean;
-  },
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.reserves.get(reservePub);
-      if (!r) {
-        return;
-      }
-      if (options.reset) {
-        r.retryInfo = RetryInfo.reset();
-      } else {
-        r.retryInfo = RetryInfo.increment(r.retryInfo);
-      }
-      delete r.lastError;
-      await tx.reserves.put(r);
-    });
-}
-
-/**
- * Report an error that happened while processing the reserve.
- *
- * Logs the error via a notification and by storing it in the database.
- */
-async function reportReserveError(
-  ws: InternalWalletState,
-  reservePub: string,
-  err: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.reserves.get(reservePub);
-      if (!r) {
-        return;
-      }
-      if (!r.retryInfo) {
-        logger.error(`got reserve error for inactive reserve (no retryInfo)`);
-        return;
-      }
-      r.lastError = err;
-      await tx.reserves.put(r);
-    });
-  ws.notify({
-    type: NotificationType.ReserveOperationError,
-    error: err,
-  });
-}
-
-/**
- * Create a reserve, but do not flag it as confirmed yet.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- */
-export async function createReserve(
-  ws: InternalWalletState,
-  req: CreateReserveRequest,
-): Promise<CreateReserveResponse> {
-  const keypair = await ws.cryptoApi.createEddsaKeypair({});
-  const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
-  const canonExchange = canonicalizeBaseUrl(req.exchange);
-
-  let reserveStatus;
-  if (req.bankWithdrawStatusUrl) {
-    reserveStatus = ReserveRecordStatus.RegisteringBank;
-  } else {
-    reserveStatus = ReserveRecordStatus.QueryingStatus;
-  }
-
-  let bankInfo: ReserveBankInfo | undefined;
-
-  if (req.bankWithdrawStatusUrl) {
-    if (!req.exchangePaytoUri) {
-      throw Error(
-        "Exchange payto URI must be specified for a bank-integrated 
withdrawal",
-      );
-    }
-    bankInfo = {
-      statusUrl: req.bankWithdrawStatusUrl,
-      exchangePaytoUri: req.exchangePaytoUri,
-    };
-  }
-
-  const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32));
-
-  await updateWithdrawalDenoms(ws, canonExchange);
-  const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
-
-  let initialDenomSel: DenomSelectionState;
-  if (req.forcedDenomSel) {
-    logger.warn("using forced denom selection");
-    initialDenomSel = selectForcedWithdrawalDenominations(
-      req.amount,
-      denoms,
-      req.forcedDenomSel,
-    );
-  } else {
-    initialDenomSel = selectWithdrawalDenominations(req.amount, denoms);
-  }
-
-  const reserveRecord: ReserveRecord = {
-    instructedAmount: req.amount,
-    initialWithdrawalGroupId,
-    initialDenomSel,
-    initialWithdrawalStarted: false,
-    timestampCreated: now,
-    exchangeBaseUrl: canonExchange,
-    reservePriv: keypair.priv,
-    reservePub: keypair.pub,
-    senderWire: req.senderWire,
-    timestampBankConfirmed: undefined,
-    timestampReserveInfoPosted: undefined,
-    bankInfo,
-    reserveStatus,
-    retryInfo: RetryInfo.reset(),
-    lastError: undefined,
-    currency: req.amount.currency,
-    operationStatus: OperationStatus.Pending,
-    restrictAge: req.restrictAge,
-  };
-
-  const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
-  const exchangeDetails = exchangeInfo.exchangeDetails;
-  if (!exchangeDetails) {
-    logger.trace(exchangeDetails);
-    throw Error("exchange not updated");
-  }
-  const { isAudited, isTrusted } = await getExchangeTrust(
-    ws,
-    exchangeInfo.exchange,
-  );
-
-  const resp = await ws.db
-    .mktx((x) => ({
-      exchangeTrust: x.exchangeTrust,
-      reserves: x.reserves,
-      bankWithdrawUris: x.bankWithdrawUris,
-    }))
-    .runReadWrite(async (tx) => {
-      // Check if we have already created a reserve for that 
bankWithdrawStatusUrl
-      if (reserveRecord.bankInfo?.statusUrl) {
-        const bwi = await tx.bankWithdrawUris.get(
-          reserveRecord.bankInfo.statusUrl,
-        );
-        if (bwi) {
-          const otherReserve = await tx.reserves.get(bwi.reservePub);
-          if (otherReserve) {
-            logger.trace(
-              "returning existing reserve for bankWithdrawStatusUri",
-            );
-            return {
-              exchange: otherReserve.exchangeBaseUrl,
-              reservePub: otherReserve.reservePub,
-            };
-          }
-        }
-        await tx.bankWithdrawUris.put({
-          reservePub: reserveRecord.reservePub,
-          talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
-        });
-      }
-      if (!isAudited && !isTrusted) {
-        await tx.exchangeTrust.put({
-          currency: reserveRecord.currency,
-          exchangeBaseUrl: reserveRecord.exchangeBaseUrl,
-          exchangeMasterPub: exchangeDetails.masterPublicKey,
-          uids: [encodeCrock(getRandomBytes(32))],
-        });
-      }
-      await tx.reserves.put(reserveRecord);
-      const r: CreateReserveResponse = {
-        exchange: canonExchange,
-        reservePub: keypair.pub,
-      };
-      return r;
-    });
-
-  if (reserveRecord.reservePub === resp.reservePub) {
-    // Only emit notification when a new reserve was created.
-    ws.notify({
-      type: NotificationType.ReserveCreated,
-      reservePub: reserveRecord.reservePub,
-    });
-  }
-
-  // Asynchronously process the reserve, but return
-  // to the caller already.
-  processReserve(ws, resp.reservePub, { forceNow: true }).catch((e) => {
-    logger.error("Processing reserve (after createReserve) failed:", e);
-  });
-
-  return resp;
-}
-
-/**
- * Re-query the status of a reserve.
- */
-export async function forceQueryReserve(
-  ws: InternalWalletState,
-  reservePub: string,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadWrite(async (tx) => {
-      const reserve = await tx.reserves.get(reservePub);
-      if (!reserve) {
-        return;
-      }
-      // Only force status query where it makes sense
-      switch (reserve.reserveStatus) {
-        case ReserveRecordStatus.Dormant:
-          reserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
-          reserve.operationStatus = OperationStatus.Pending;
-          reserve.retryInfo = RetryInfo.reset();
-          break;
-        default:
-          break;
-      }
-      await tx.reserves.put(reserve);
-    });
-  await processReserve(ws, reservePub, { forceNow: true });
-}
-
-/**
- * First fetch information required to withdraw from the reserve,
- * then deplete the reserve, withdrawing coins until it is empty.
- *
- * The returned promise resolves once the reserve is set to the
- * state "Dormant".
- */
-export async function processReserve(
-  ws: InternalWalletState,
-  reservePub: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  return ws.memoProcessReserve.memo(reservePub, async () => {
-    const onOpError = (err: TalerErrorDetail): Promise<void> =>
-      reportReserveError(ws, reservePub, err);
-    await guardOperationException(
-      () => processReserveImpl(ws, reservePub, options),
-      onOpError,
-    );
-  });
-}
-
-async function registerReserveWithBank(
-  ws: InternalWalletState,
-  reservePub: string,
-): Promise<void> {
-  const reserve = await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadOnly(async (tx) => {
-      return await tx.reserves.get(reservePub);
-    });
-  switch (reserve?.reserveStatus) {
-    case ReserveRecordStatus.WaitConfirmBank:
-    case ReserveRecordStatus.RegisteringBank:
-      break;
-    default:
-      return;
-  }
-  const bankInfo = reserve.bankInfo;
-  if (!bankInfo) {
-    return;
-  }
-  const bankStatusUrl = bankInfo.statusUrl;
-  const httpResp = await ws.http.postJson(
-    bankStatusUrl,
-    {
-      reserve_pub: reservePub,
-      selected_exchange: bankInfo.exchangePaytoUri,
-    },
-    {
-      timeout: getReserveRequestTimeout(reserve),
-    },
-  );
-  await readSuccessResponseJsonOrThrow(
-    httpResp,
-    codecForBankWithdrawalOperationPostResponse(),
-  );
-  await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.reserves.get(reservePub);
-      if (!r) {
-        return;
-      }
-      switch (r.reserveStatus) {
-        case ReserveRecordStatus.RegisteringBank:
-        case ReserveRecordStatus.WaitConfirmBank:
-          break;
-        default:
-          return;
-      }
-      r.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
-        AbsoluteTime.now(),
-      );
-      r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
-      r.operationStatus = OperationStatus.Pending;
-      if (!r.bankInfo) {
-        throw Error("invariant failed");
-      }
-      r.retryInfo = RetryInfo.reset();
-      await tx.reserves.put(r);
-    });
-  ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
-  return processReserveBankStatus(ws, reservePub);
-}
-
-export function getReserveRequestTimeout(r: ReserveRecord): Duration {
-  return durationMax(
-    { d_ms: 60000 },
-    durationMin({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
-  );
-}
-
-async function processReserveBankStatus(
-  ws: InternalWalletState,
-  reservePub: string,
-): Promise<void> {
-  const reserve = await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadOnly(async (tx) => {
-      return tx.reserves.get(reservePub);
-    });
-  switch (reserve?.reserveStatus) {
-    case ReserveRecordStatus.WaitConfirmBank:
-    case ReserveRecordStatus.RegisteringBank:
-      break;
-    default:
-      return;
-  }
-  const bankStatusUrl = reserve.bankInfo?.statusUrl;
-  if (!bankStatusUrl) {
-    return;
-  }
-
-  const statusResp = await ws.http.get(bankStatusUrl, {
-    timeout: getReserveRequestTimeout(reserve),
-  });
-  const status = await readSuccessResponseJsonOrThrow(
-    statusResp,
-    codecForWithdrawOperationStatusResponse(),
-  );
-
-  if (status.aborted) {
-    logger.info("bank aborted the withdrawal");
-    await ws.db
-      .mktx((x) => ({
-        reserves: x.reserves,
-      }))
-      .runReadWrite(async (tx) => {
-        const r = await tx.reserves.get(reservePub);
-        if (!r) {
-          return;
-        }
-        switch (r.reserveStatus) {
-          case ReserveRecordStatus.RegisteringBank:
-          case ReserveRecordStatus.WaitConfirmBank:
-            break;
-          default:
-            return;
-        }
-        const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
-        r.timestampBankConfirmed = now;
-        r.reserveStatus = ReserveRecordStatus.BankAborted;
-        r.operationStatus = OperationStatus.Finished;
-        r.retryInfo = RetryInfo.reset();
-        await tx.reserves.put(r);
-      });
-    return;
-  }
-
-  // Bank still needs to know our reserve info
-  if (!status.selection_done) {
-    await registerReserveWithBank(ws, reservePub);
-    return await processReserveBankStatus(ws, reservePub);
-  }
-
-  // FIXME: Why do we do this?!
-  if (reserve.reserveStatus === ReserveRecordStatus.RegisteringBank) {
-    await registerReserveWithBank(ws, reservePub);
-    return await processReserveBankStatus(ws, reservePub);
-  }
-
-  await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadWrite(async (tx) => {
-      const r = await tx.reserves.get(reservePub);
-      if (!r) {
-        return;
-      }
-      // Re-check reserve status within transaction
-      switch (r.reserveStatus) {
-        case ReserveRecordStatus.RegisteringBank:
-        case ReserveRecordStatus.WaitConfirmBank:
-          break;
-        default:
-          return;
-      }
-      if (status.transfer_done) {
-        const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
-        r.timestampBankConfirmed = now;
-        r.reserveStatus = ReserveRecordStatus.QueryingStatus;
-        r.operationStatus = OperationStatus.Pending;
-        r.retryInfo = RetryInfo.reset();
-      } else {
-        logger.info("Withdrawal operation not yet confirmed by bank");
-        if (r.bankInfo) {
-          r.bankInfo.confirmUrl = status.confirm_transfer_url;
-        }
-        r.retryInfo = RetryInfo.increment(r.retryInfo);
-      }
-      await tx.reserves.put(r);
-    });
-}
-
-/**
- * Update the information about a reserve that is stored in the wallet
- * by querying the reserve's exchange.
- *
- * If the reserve have funds that are not allocated in a withdrawal group yet
- * and are big enough to withdraw with available denominations,
- * create a new withdrawal group for the remaining amount.
- */
-async function updateReserve(
-  ws: InternalWalletState,
-  reservePub: string,
-): Promise<{ ready: boolean }> {
-  const reserve = await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadOnly(async (tx) => {
-      return tx.reserves.get(reservePub);
-    });
-  if (!reserve) {
-    throw Error("reserve not in db");
-  }
-
-  if (reserve.reserveStatus !== ReserveRecordStatus.QueryingStatus) {
-    return { ready: true };
-  }
-
-  const reserveUrl = new URL(`reserves/${reservePub}`, 
reserve.exchangeBaseUrl);
-  reserveUrl.searchParams.set("timeout_ms", "30000");
-
-  logger.info(`querying reserve status via ${reserveUrl}`);
-
-  const resp = await ws.http.get(reserveUrl.href, {
-    timeout: getReserveRequestTimeout(reserve),
-  });
-
-  const result = await readSuccessResponseJsonOrErrorCode(
-    resp,
-    codecForReserveStatus(),
-  );
-
-  if (result.isError) {
-    if (
-      resp.status === 404 &&
-      result.talerErrorResponse.code ===
-        TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
-    ) {
-      ws.notify({
-        type: NotificationType.ReserveNotYetFound,
-        reservePub,
-      });
-      return { ready: false };
-    } else {
-      throwUnexpectedRequestError(resp, result.talerErrorResponse);
-    }
-  }
-
-  logger.trace(`got reserve status ${j2s(result.response)}`);
-
-  const reserveInfo = result.response;
-  const reserveBalance = Amounts.parseOrThrow(reserveInfo.balance);
-  const currency = reserveBalance.currency;
-
-  await updateWithdrawalDenoms(ws, reserve.exchangeBaseUrl);
-  const denoms = await getCandidateWithdrawalDenoms(
-    ws,
-    reserve.exchangeBaseUrl,
-  );
-
-  const newWithdrawalGroup = await ws.db
-    .mktx((x) => ({
-      planchets: x.planchets,
-      withdrawalGroups: x.withdrawalGroups,
-      reserves: x.reserves,
-      denominations: x.denominations,
-    }))
-    .runReadWrite(async (tx) => {
-      const newReserve = await tx.reserves.get(reserve.reservePub);
-      if (!newReserve) {
-        return;
-      }
-
-      let amountReservePlus = reserveBalance;
-      let amountReserveMinus = Amounts.getZero(currency);
-
-      // Subtract amount allocated in unfinished withdrawal groups
-      // for this reserve from the available amount.
-      await tx.withdrawalGroups.indexes.byReservePub
-        .iter(reservePub)
-        .forEachAsync(async (wg) => {
-          if (wg.timestampFinish) {
-            return;
-          }
-          await tx.planchets.indexes.byGroup
-            .iter(wg.withdrawalGroupId)
-            .forEachAsync(async (pr) => {
-              if (pr.withdrawalDone) {
-                return;
-              }
-              const denomInfo = await ws.getDenomInfo(
-                ws,
-                tx,
-                wg.exchangeBaseUrl,
-                pr.denomPubHash,
-              );
-              if (!denomInfo) {
-                logger.error(`no denom info found for ${pr.denomPubHash}`);
-                return;
-              }
-              amountReserveMinus = Amounts.add(
-                amountReserveMinus,
-                denomInfo.value,
-                denomInfo.feeWithdraw,
-              ).amount;
-            });
-        });
-
-      const remainingAmount = Amounts.sub(
-        amountReservePlus,
-        amountReserveMinus,
-      ).amount;
-
-      let withdrawalGroupId: string;
-      let denomSel: DenomSelectionState;
-
-      if (!newReserve.initialWithdrawalStarted) {
-        withdrawalGroupId = newReserve.initialWithdrawalGroupId;
-        newReserve.initialWithdrawalStarted = true;
-        denomSel = newReserve.initialDenomSel;
-      } else {
-        withdrawalGroupId = encodeCrock(randomBytes(32));
-
-        denomSel = selectWithdrawalDenominations(remainingAmount, denoms);
-
-        logger.trace(
-          `Remaining unclaimed amount in reseve is ${Amounts.stringify(
-            remainingAmount,
-          )} and can be withdrawn with ${denomSel.selectedDenoms.length} 
coins`,
-        );
-  
-        if (denomSel.selectedDenoms.length === 0) {
-          newReserve.reserveStatus = ReserveRecordStatus.Dormant;
-          newReserve.operationStatus = OperationStatus.Finished;
-          delete newReserve.lastError;
-          delete newReserve.retryInfo;
-          await tx.reserves.put(newReserve);
-          return;
-        }
-      }
-
-      const withdrawalRecord: WithdrawalGroupRecord = {
-        withdrawalGroupId: withdrawalGroupId,
-        exchangeBaseUrl: reserve.exchangeBaseUrl,
-        reservePub: reserve.reservePub,
-        rawWithdrawalAmount: remainingAmount,
-        timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
-        retryInfo: RetryInfo.reset(),
-        lastError: undefined,
-        denomsSel: denomSel,
-        secretSeed: encodeCrock(getRandomBytes(64)),
-        denomSelUid: encodeCrock(getRandomBytes(32)),
-        operationStatus: OperationStatus.Pending,
-      };
-
-      delete newReserve.lastError;
-      delete newReserve.retryInfo;
-      newReserve.reserveStatus = ReserveRecordStatus.Dormant;
-      newReserve.operationStatus = OperationStatus.Finished;
-
-      await tx.reserves.put(newReserve);
-      await tx.withdrawalGroups.put(withdrawalRecord);
-      return withdrawalRecord;
-    });
-
-  if (newWithdrawalGroup) {
-    logger.trace("processing new withdraw group");
-    ws.notify({
-      type: NotificationType.WithdrawGroupCreated,
-      withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
-    });
-    await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
-  }
-
-  return { ready: true };
-}
-
-async function processReserveImpl(
-  ws: InternalWalletState,
-  reservePub: string,
-  options: {
-    forceNow?: boolean;
-  } = {},
-): Promise<void> {
-  const forceNow = options.forceNow ?? false;
-  await setupReserveRetry(ws, reservePub, { reset: forceNow });
-  const reserve = await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadOnly(async (tx) => {
-      return tx.reserves.get(reservePub);
-    });
-  if (!reserve) {
-    logger.error(
-      `not processing reserve: reserve ${reservePub} does not exist`,
-    );
-    return;
-  }
-  logger.trace(
-    `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
-  );
-  switch (reserve.reserveStatus) {
-    case ReserveRecordStatus.RegisteringBank:
-      await processReserveBankStatus(ws, reservePub);
-      return await processReserveImpl(ws, reservePub, { forceNow: true });
-    case ReserveRecordStatus.QueryingStatus: {
-      const res = await updateReserve(ws, reservePub);
-      if (res.ready) {
-        return await processReserveImpl(ws, reservePub, { forceNow: true });
-      }
-      break;
-    }
-    case ReserveRecordStatus.Dormant:
-      // nothing to do
-      break;
-    case ReserveRecordStatus.WaitConfirmBank:
-      await processReserveBankStatus(ws, reservePub);
-      break;
-    case ReserveRecordStatus.BankAborted:
-      break;
-    default:
-      console.warn("unknown reserve record status:", reserve.reserveStatus);
-      assertUnreachable(reserve.reserveStatus);
-      break;
-  }
-}
-
-/**
- * Create a reserve for a bank-integrated withdrawal from
- * a taler://withdraw URI.
- */
-export async function createTalerWithdrawReserve(
-  ws: InternalWalletState,
-  talerWithdrawUri: string,
-  selectedExchange: string,
-  options: {
-    forcedDenomSel?: ForcedDenomSel;
-    restrictAge?: number;
-  } = {},
-): Promise<AcceptWithdrawalResponse> {
-  await updateExchangeFromUrl(ws, selectedExchange);
-  const withdrawInfo = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
-  const exchangePaytoUri = await getExchangePaytoUri(
-    ws,
-    selectedExchange,
-    withdrawInfo.wireTypes,
-  );
-  const reserve = await createReserve(ws, {
-    amount: withdrawInfo.amount,
-    bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
-    exchange: selectedExchange,
-    senderWire: withdrawInfo.senderWire,
-    exchangePaytoUri: exchangePaytoUri,
-    restrictAge: options.restrictAge,
-    forcedDenomSel: options.forcedDenomSel,
-  });
-  // 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);
-  const processedReserve = await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadOnly(async (tx) => {
-      return tx.reserves.get(reserve.reservePub);
-    });
-  if (processedReserve?.reserveStatus === ReserveRecordStatus.BankAborted) {
-    throw TalerError.fromDetail(
-      TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
-      {},
-    );
-  }
-  return {
-    reservePub: reserve.reservePub,
-    confirmTransferUrl: withdrawInfo.confirmTransferUrl,
-  };
-}
-
-/**
- * Get payto URIs that can be used to fund a reserve.
- */
-export async function getFundingPaytoUris(
-  tx: GetReadOnlyAccess<{
-    reserves: typeof WalletStoresV1.reserves;
-    exchanges: typeof WalletStoresV1.exchanges;
-    exchangeDetails: typeof WalletStoresV1.exchangeDetails;
-  }>,
-  reservePub: string,
-): Promise<string[]> {
-  const r = await tx.reserves.get(reservePub);
-  if (!r) {
-    logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
-    return [];
-  }
-  const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl);
-  if (!exchangeDetails) {
-    logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
-    return [];
-  }
-  const plainPaytoUris =
-    exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
-  if (!plainPaytoUris) {
-    logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
-    return [];
-  }
-  return plainPaytoUris.map((x) =>
-    addPaytoQueryParams(x, {
-      amount: Amounts.stringify(r.instructedAmount),
-      message: `Taler Withdrawal ${r.reservePub}`,
-    }),
-  );
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts 
b/packages/taler-wallet-core/src/operations/testing.ts
index d609011c..bec8ec8f 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -39,12 +39,12 @@ import {
   URL,
   PreparePayResultType,
 } from "@gnu-taler/taler-util";
-import { createTalerWithdrawReserve } from "./reserves.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { confirmPay, preparePayForUri } from "./pay.js";
 import { getBalances } from "./balance.js";
 import { applyRefund } from "./refund.js";
 import { checkLogicInvariant } from "../util/invariants.js";
+import { acceptWithdrawalFromUri } from "./withdraw.js";
 
 const logger = new Logger("operations/testing.ts");
 
@@ -104,14 +104,11 @@ export async function withdrawTestBalance(
     amount,
   );
 
-  await createTalerWithdrawReserve(
-    ws,
-    wresp.taler_withdraw_uri,
-    exchangeBaseUrl,
-    {
-      forcedDenomSel: req.forcedDenomSel,
-    },
-  );
+  await acceptWithdrawalFromUri(ws, {
+    talerWithdrawUri: wresp.taler_withdraw_uri,
+    selectedExchange: exchangeBaseUrl,
+    forcedDenomSel: req.forcedDenomSel,
+  });
 
   await confirmBankWithdrawalUri(
     ws.http,
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts 
b/packages/taler-wallet-core/src/operations/transactions.ts
index ebc223b2..ae4ce699 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -36,7 +36,6 @@ import { InternalWalletState } from 
"../internal-wallet-state.js";
 import {
   AbortStatus,
   RefundState,
-  ReserveRecord,
   ReserveRecordStatus,
   WalletRefundItem,
 } from "../db.js";
@@ -44,9 +43,8 @@ import { processDepositGroup } from "./deposits.js";
 import { getExchangeDetails } from "./exchanges.js";
 import { processPurchasePay } from "./pay.js";
 import { processRefreshGroup } from "./refresh.js";
-import { getFundingPaytoUris } from "./reserves.js";
 import { processTip } from "./tip.js";
-import { processWithdrawGroup } from "./withdraw.js";
+import { processWithdrawalGroup } from "./withdraw.js";
 
 const logger = new Logger("taler-wallet-core:transactions.ts");
 
@@ -127,7 +125,6 @@ export async function getTransactions(
       proposals: x.proposals,
       purchases: x.purchases,
       refreshGroups: x.refreshGroups,
-      reserves: x.reserves,
       tips: x.tips,
       withdrawalGroups: x.withdrawalGroups,
       planchets: x.planchets,
@@ -151,24 +148,13 @@ export async function getTransactions(
           if (shouldSkipSearch(transactionsRequest, [])) {
             return;
           }
-
-          const r = await tx.reserves.get(wsr.reservePub);
-          if (!r) {
-            return;
-          }
-          let amountRaw: AmountJson | undefined = undefined;
-          if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
-            amountRaw = r.instructedAmount;
-          } else {
-            amountRaw = wsr.denomsSel.totalWithdrawCost;
-          }
           let withdrawalDetails: WithdrawalDetails;
-          if (r.bankInfo) {
+          if (wsr.bankInfo) {
             withdrawalDetails = {
               type: WithdrawalType.TalerBankIntegrationApi,
-              confirmed: r.timestampBankConfirmed ? true : false,
+              confirmed: wsr.bankInfo.timestampBankConfirmed ? true : false,
               reservePub: wsr.reservePub,
-              bankConfirmationUrl: r.bankInfo.confirmUrl,
+              bankConfirmationUrl: wsr.bankInfo.confirmUrl,
             };
           } else {
             const exchangeDetails = await getExchangeDetails(
@@ -191,7 +177,7 @@ export async function getTransactions(
           transactions.push({
             type: TransactionType.Withdrawal,
             amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
-            amountRaw: Amounts.stringify(amountRaw),
+            amountRaw: Amounts.stringify(wsr.rawWithdrawalAmount),
             withdrawalDetails,
             exchangeBaseUrl: wsr.exchangeBaseUrl,
             pending: !wsr.timestampFinish,
@@ -205,56 +191,6 @@ export async function getTransactions(
           });
         });
 
-        // Report pending withdrawals based on reserves that
-        // were created, but where the actual withdrawal group has
-        // not started yet.
-        tx.reserves.iter().forEachAsync(async (r) => {
-          if (shouldSkipCurrency(transactionsRequest, r.currency)) {
-            return;
-          }
-          if (shouldSkipSearch(transactionsRequest, [])) {
-            return;
-          }
-          if (r.initialWithdrawalStarted) {
-            return;
-          }
-          if (r.reserveStatus === ReserveRecordStatus.BankAborted) {
-            return;
-          }
-          let withdrawalDetails: WithdrawalDetails;
-          if (r.bankInfo) {
-            withdrawalDetails = {
-              type: WithdrawalType.TalerBankIntegrationApi,
-              confirmed: false,
-              reservePub: r.reservePub,
-              bankConfirmationUrl: r.bankInfo.confirmUrl,
-            };
-          } else {
-            withdrawalDetails = {
-              type: WithdrawalType.ManualTransfer,
-              reservePub: r.reservePub,
-              exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
-            };
-          }
-          transactions.push({
-            type: TransactionType.Withdrawal,
-            amountRaw: Amounts.stringify(r.instructedAmount),
-            amountEffective: Amounts.stringify(
-              r.initialDenomSel.totalCoinValue,
-            ),
-            exchangeBaseUrl: r.exchangeBaseUrl,
-            pending: true,
-            timestamp: r.timestampCreated,
-            withdrawalDetails: withdrawalDetails,
-            transactionId: makeEventId(
-              TransactionType.Withdrawal,
-              r.initialWithdrawalGroupId,
-            ),
-            frozen: false,
-            ...(r.lastError ? { error: r.lastError } : {}),
-          });
-        });
-
         tx.depositGroups.iter().forEachAsync(async (dg) => {
           const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
           if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
@@ -499,7 +435,7 @@ export async function retryTransaction(
     }
     case TransactionType.Withdrawal: {
       const withdrawalGroupId = rest[0];
-      await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true });
+      await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true });
       break;
     }
     case TransactionType.Payment: {
@@ -536,7 +472,6 @@ export async function deleteTransaction(
     await ws.db
       .mktx((x) => ({
         withdrawalGroups: x.withdrawalGroups,
-        reserves: x.reserves,
         tombstones: x.tombstones,
       }))
       .runReadWrite(async (tx) => {
@@ -550,17 +485,6 @@ export async function deleteTransaction(
           });
           return;
         }
-        const reserveRecord: ReserveRecord | undefined =
-          await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
-            withdrawalGroupId,
-          );
-        if (reserveRecord && !reserveRecord.initialWithdrawalStarted) {
-          const reservePub = reserveRecord.reservePub;
-          await tx.reserves.delete(reservePub);
-          await tx.tombstones.put({
-            id: TombstoneTag.DeleteReserve + ":" + reservePub,
-          });
-        }
       });
   } else if (type === TransactionType.Payment) {
     const proposalId = rest[0];
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts 
b/packages/taler-wallet-core/src/operations/withdraw.ts
index ea9e2233..484b9b96 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -19,20 +19,29 @@
  */
 import {
   AbsoluteTime,
+  AcceptManualWithdrawalResult,
+  AcceptWithdrawalResponse,
+  addPaytoQueryParams,
   AmountJson,
+  AmountLike,
   Amounts,
   AmountString,
   BankWithdrawDetails,
+  canonicalizeBaseUrl,
+  codecForBankWithdrawalOperationPostResponse,
+  codecForReserveStatus,
   codecForTalerConfigResponse,
   codecForWithdrawBatchResponse,
   codecForWithdrawOperationStatusResponse,
   codecForWithdrawResponse,
   DenomKeyType,
   Duration,
-  durationFromSpec,
+  durationFromSpec, encodeCrock,
   ExchangeListItem,
   ExchangeWithdrawRequest,
   ForcedDenomSel,
+  getRandomBytes,
+  j2s,
   LibtoolVersion,
   Logger,
   NotificationType,
@@ -45,8 +54,9 @@ import {
   VersionMatchResult,
   WithdrawBatchResponse,
   WithdrawResponse,
-  WithdrawUriInfoResponse,
+  WithdrawUriInfoResponse
 } from "@gnu-taler/taler-util";
+import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
 import {
   CoinRecord,
   CoinSourceType,
@@ -58,26 +68,42 @@ import {
   ExchangeRecord,
   OperationStatus,
   PlanchetRecord,
-  WithdrawalGroupRecord,
+  ReserveBankInfo,
+  ReserveRecordStatus,
+  WalletStoresV1,
+  WithdrawalGroupRecord
 } from "../db.js";
 import {
   getErrorDetailFromException,
   makeErrorDetail,
-  TalerError,
+  TalerError
 } from "../errors.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
+import { assertUnreachable } from "../util/assertUnreachable.js";
 import { walletCoreDebugFlags } from "../util/debugFlags.js";
 import {
   HttpRequestLibrary,
+  readSuccessResponseJsonOrErrorCode,
   readSuccessResponseJsonOrThrow,
+  throwUnexpectedRequestError
 } from "../util/http.js";
 import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
+import {
+  DbAccess,
+  GetReadOnlyAccess
+} from "../util/query.js";
 import { RetryInfo } from "../util/retries.js";
 import {
   WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
-  WALLET_EXCHANGE_PROTOCOL_VERSION,
+  WALLET_EXCHANGE_PROTOCOL_VERSION
 } from "../versions.js";
 import { guardOperationException } from "./common.js";
+import {
+  getExchangeDetails,
+  getExchangePaytoUri,
+  getExchangeTrust,
+  updateExchangeFromUrl
+} from "./exchanges.js";
 
 /**
  * Logger for this file.
@@ -215,7 +241,7 @@ export function selectWithdrawalDenominations(
   for (const d of denoms) {
     let count = 0;
     const cost = Amounts.add(d.value, d.feeWithdraw).amount;
-    for (; ;) {
+    for (;;) {
       if (Amounts.cmp(remaining, cost) < 0) {
         break;
       }
@@ -410,47 +436,42 @@ async function processPlanchetGenerate(
     return;
   }
   let ci = 0;
-  let denomPubHash: string | undefined;
+  let maybeDenomPubHash: string | undefined;
   for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) 
{
     const d = withdrawalGroup.denomsSel.selectedDenoms[di];
     if (coinIdx >= ci && coinIdx < ci + d.count) {
-      denomPubHash = d.denomPubHash;
+      maybeDenomPubHash = d.denomPubHash;
       break;
     }
     ci += d.count;
   }
-  if (!denomPubHash) {
+  if (!maybeDenomPubHash) {
     throw Error("invariant violated");
   }
+  const denomPubHash = maybeDenomPubHash;
 
-  const { denom, reserve } = await ws.db
+  const denom = await ws.db
     .mktx((x) => ({
-      reserves: x.reserves,
       denominations: x.denominations,
     }))
     .runReadOnly(async (tx) => {
-      const denom = await tx.denominations.get([
+      return ws.getDenomInfo(
+        ws,
+        tx,
         withdrawalGroup.exchangeBaseUrl,
-        denomPubHash!,
-      ]);
-      if (!denom) {
-        throw Error("invariant violated");
-      }
-      const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
-      if (!reserve) {
-        throw Error("invariant violated");
-      }
-      return { denom, reserve };
+        denomPubHash,
+      );
     });
+  checkDbInvariant(!!denom);
   const r = await ws.cryptoApi.createPlanchet({
     denomPub: denom.denomPub,
     feeWithdraw: denom.feeWithdraw,
-    reservePriv: reserve.reservePriv,
-    reservePub: reserve.reservePub,
+    reservePriv: withdrawalGroup.reservePriv,
+    reservePub: withdrawalGroup.reservePub,
     value: denom.value,
     coinIndex: coinIdx,
     secretSeed: withdrawalGroup.secretSeed,
-    restrictAge: reserve.restrictAge,
+    restrictAge: withdrawalGroup.restrictAge,
   });
   const newPlanchet: PlanchetRecord = {
     blindingKey: r.blindingKey,
@@ -806,11 +827,13 @@ async function processPlanchetVerifyAndStoreCoin(
 
   const planchetCoinPub = planchet.coinPub;
 
+  // Check if this is the first time that the whole
+  // withdrawal succeeded.  If so, mark the withdrawal
+  // group as finished.
   const firstSuccess = await ws.db
     .mktx((x) => ({
       coins: x.coins,
       withdrawalGroups: x.withdrawalGroups,
-      reserves: x.reserves,
       planchets: x.planchets,
     }))
     .runReadWrite(async (tx) => {
@@ -875,7 +898,8 @@ export async function updateWithdrawalDenoms(
         denom.verificationStatus === DenominationVerificationStatus.Unverified
       ) {
         logger.trace(
-          `Validating denomination (${current + 1}/${denominations.length
+          `Validating denomination (${current + 1}/${
+            denominations.length
           }) signature of ${denom.denomPubHash}`,
         );
         let valid = false;
@@ -960,7 +984,80 @@ async function reportWithdrawalError(
   ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
 }
 
-export async function processWithdrawGroup(
+/**
+ * Update the information about a reserve that is stored in the wallet
+ * by querying the reserve's exchange.
+ *
+ * If the reserve have funds that are not allocated in a withdrawal group yet
+ * and are big enough to withdraw with available denominations,
+ * create a new withdrawal group for the remaining amount.
+ */
+async function queryReserve(
+  ws: InternalWalletState,
+  withdrawalGroupId: string,
+): Promise<{ ready: boolean }> {
+  const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+    withdrawalGroupId,
+  });
+  checkDbInvariant(!!withdrawalGroup);
+  if (withdrawalGroup.reserveStatus !== ReserveRecordStatus.QueryingStatus) {
+    return { ready: true };
+  }
+  const reservePub = withdrawalGroup.reservePub;
+
+  const reserveUrl = new URL(
+    `reserves/${reservePub}`,
+    withdrawalGroup.exchangeBaseUrl,
+  );
+  reserveUrl.searchParams.set("timeout_ms", "30000");
+
+  logger.info(`querying reserve status via ${reserveUrl}`);
+
+  const resp = await ws.http.get(reserveUrl.href, {
+    timeout: getReserveRequestTimeout(withdrawalGroup),
+  });
+
+  const result = await readSuccessResponseJsonOrErrorCode(
+    resp,
+    codecForReserveStatus(),
+  );
+
+  if (result.isError) {
+    if (
+      resp.status === 404 &&
+      result.talerErrorResponse.code ===
+        TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
+    ) {
+      ws.notify({
+        type: NotificationType.ReserveNotYetFound,
+        reservePub,
+      });
+      return { ready: false };
+    } else {
+      throwUnexpectedRequestError(resp, result.talerErrorResponse);
+    }
+  }
+
+  logger.trace(`got reserve status ${j2s(result.response)}`);
+
+  await ws.db
+    .mktx((x) => ({
+      withdrawalGroups: x.withdrawalGroups,
+    }))
+    .runReadWrite(async (tx) => {
+      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+      if (!wg) {
+        logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+        return;
+      }
+      wg.reserveStatus = ReserveRecordStatus.Dormant;
+      await tx.withdrawalGroups.put(wg);
+    });
+
+  return { ready: true };
+}
+
+export async function processWithdrawalGroup(
   ws: InternalWalletState,
   withdrawalGroupId: string,
   options: {
@@ -990,24 +1087,42 @@ async function processWithdrawGroupImpl(
     .runReadOnly(async (tx) => {
       return tx.withdrawalGroups.get(withdrawalGroupId);
     });
+
   if (!withdrawalGroup) {
-    // Withdrawal group doesn't exist yet, but reserve might exist
-    // (and reference the yet to be created withdrawal group)
-    const reservePub = await ws.db
-      .mktx((x) => ({ reserves: x.reserves }))
-      .runReadOnly(async (tx) => {
-        const r = await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
-          withdrawalGroupId,
-        );
-        return r?.reservePub;
+    throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+  }
+
+  switch (withdrawalGroup.reserveStatus) {
+    case ReserveRecordStatus.RegisteringBank:
+      await processReserveBankStatus(ws, withdrawalGroupId);
+      return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
+        forceNow: true,
       });
-    if (!reservePub) {
-      logger.warn(
-        "withdrawal group doesn't exist (and reserve doesn't exist either)",
-      );
+    case ReserveRecordStatus.QueryingStatus: {
+      const res = await queryReserve(ws, withdrawalGroupId);
+      if (res.ready) {
+        return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
+          forceNow: true,
+        });
+      }
       return;
     }
-    return await ws.reserveOps.processReserve(ws, reservePub, { forceNow });
+    case ReserveRecordStatus.WaitConfirmBank:
+      await processReserveBankStatus(ws, withdrawalGroupId);
+      return;
+    case ReserveRecordStatus.BankAborted:
+      // FIXME
+      return;
+    case ReserveRecordStatus.Dormant:
+      // We can try to withdraw, nothing needs to be done with the reserve.
+      break;
+    default:
+      logger.warn(
+        "unknown reserve record status:",
+        withdrawalGroup.reserveStatus,
+      );
+      assertUnreachable(withdrawalGroup.reserveStatus);
+      break;
   }
 
   await ws.exchangeOps.updateExchangeFromUrl(
@@ -1071,7 +1186,6 @@ async function processWithdrawGroupImpl(
     .mktx((x) => ({
       coins: x.coins,
       withdrawalGroups: x.withdrawalGroups,
-      reserves: x.reserves,
       planchets: x.planchets,
     }))
     .runReadWrite(async (tx) => {
@@ -1200,9 +1314,9 @@ export async function getExchangeWithdrawalInfo(
       !versionMatch.compatible &&
       versionMatch.currentCmp === -1
     ) {
-      console.warn(
+      logger.warn(
         `wallet's support for exchange protocol version 
${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
-        `(exchange has ${exchangeDetails.protocolVersion}), checking for 
updates`,
+          `(exchange has ${exchangeDetails.protocolVersion}), checking for 
updates`,
       );
     }
   }
@@ -1308,3 +1422,456 @@ export async function getWithdrawalDetailsForUri(
     possibleExchanges: exchanges,
   };
 }
+
+export async function getFundingPaytoUrisTx(
+  ws: InternalWalletState,
+  withdrawalGroupId: string,
+): Promise<string[]> {
+  return await ws.db
+    .mktx((x) => ({
+      exchanges: x.exchanges,
+      exchangeDetails: x.exchangeDetails,
+      withdrawalGroups: x.withdrawalGroups,
+    }))
+    .runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId));
+}
+
+/**
+ * Get payto URIs that can be used to fund a withdrawal operation.
+ */
+export async function getFundingPaytoUris(
+  tx: GetReadOnlyAccess<{
+    withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
+    exchanges: typeof WalletStoresV1.exchanges;
+    exchangeDetails: typeof WalletStoresV1.exchangeDetails;
+  }>,
+  withdrawalGroupId: string,
+): Promise<string[]> {
+  const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
+  checkDbInvariant(!!withdrawalGroup);
+  const exchangeDetails = await getExchangeDetails(
+    tx,
+    withdrawalGroup.exchangeBaseUrl,
+  );
+  if (!exchangeDetails) {
+    logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
+    return [];
+  }
+  const plainPaytoUris =
+    exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+  if (!plainPaytoUris) {
+    logger.error(
+      `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
+    );
+    return [];
+  }
+  return plainPaytoUris.map((x) =>
+    addPaytoQueryParams(x, {
+      amount: Amounts.stringify(withdrawalGroup.instructedAmount),
+      message: `Taler Withdrawal ${withdrawalGroup.reservePub}`,
+    }),
+  );
+}
+
+async function getWithdrawalGroupRecordTx(
+  db: DbAccess<typeof WalletStoresV1>,
+  req: {
+    withdrawalGroupId: string;
+  },
+): Promise<WithdrawalGroupRecord | undefined> {
+  return await db
+    .mktx((x) => ({
+      withdrawalGroups: x.withdrawalGroups,
+    }))
+    .runReadOnly(async (tx) => {
+      return tx.withdrawalGroups.get(req.withdrawalGroupId);
+    });
+}
+
+export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
+  return Duration.max(
+    { d_ms: 60000 },
+    Duration.min({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
+  );
+}
+
+async function registerReserveWithBank(
+  ws: InternalWalletState,
+  withdrawalGroupId: string,
+): Promise<void> {
+  const withdrawalGroup = await ws.db
+    .mktx((x) => ({
+      withdrawalGroups: x.withdrawalGroups,
+    }))
+    .runReadOnly(async (tx) => {
+      return await tx.withdrawalGroups.get(withdrawalGroupId);
+    });
+  switch (withdrawalGroup?.reserveStatus) {
+    case ReserveRecordStatus.WaitConfirmBank:
+    case ReserveRecordStatus.RegisteringBank:
+      break;
+    default:
+      return;
+  }
+  const bankInfo = withdrawalGroup.bankInfo;
+  if (!bankInfo) {
+    return;
+  }
+  const bankStatusUrl = bankInfo.statusUrl;
+  const httpResp = await ws.http.postJson(
+    bankStatusUrl,
+    {
+      reserve_pub: withdrawalGroup.reservePub,
+      selected_exchange: bankInfo.exchangePaytoUri,
+    },
+    {
+      timeout: getReserveRequestTimeout(withdrawalGroup),
+    },
+  );
+  await readSuccessResponseJsonOrThrow(
+    httpResp,
+    codecForBankWithdrawalOperationPostResponse(),
+  );
+  await ws.db
+    .mktx((x) => ({
+      withdrawalGroups: x.withdrawalGroups,
+    }))
+    .runReadWrite(async (tx) => {
+      const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+      if (!r) {
+        return;
+      }
+      switch (r.reserveStatus) {
+        case ReserveRecordStatus.RegisteringBank:
+        case ReserveRecordStatus.WaitConfirmBank:
+          break;
+        default:
+          return;
+      }
+      if (!r.bankInfo) {
+        throw Error("invariant failed");
+      }
+      r.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
+        AbsoluteTime.now(),
+      );
+      r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
+      r.operationStatus = OperationStatus.Pending;
+      r.retryInfo = RetryInfo.reset();
+      await tx.withdrawalGroups.put(r);
+    });
+  ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
+  return processReserveBankStatus(ws, withdrawalGroupId);
+}
+
+async function processReserveBankStatus(
+  ws: InternalWalletState,
+  withdrawalGroupId: string,
+): Promise<void> {
+  const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+    withdrawalGroupId,
+  });
+  switch (withdrawalGroup?.reserveStatus) {
+    case ReserveRecordStatus.WaitConfirmBank:
+    case ReserveRecordStatus.RegisteringBank:
+      break;
+    default:
+      return;
+  }
+  const bankStatusUrl = withdrawalGroup.bankInfo?.statusUrl;
+  if (!bankStatusUrl) {
+    return;
+  }
+
+  const statusResp = await ws.http.get(bankStatusUrl, {
+    timeout: getReserveRequestTimeout(withdrawalGroup),
+  });
+  const status = await readSuccessResponseJsonOrThrow(
+    statusResp,
+    codecForWithdrawOperationStatusResponse(),
+  );
+
+  if (status.aborted) {
+    logger.info("bank aborted the withdrawal");
+    await ws.db
+      .mktx((x) => ({
+        withdrawalGroups: x.withdrawalGroups,
+      }))
+      .runReadWrite(async (tx) => {
+        const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+        if (!r) {
+          return;
+        }
+        switch (r.reserveStatus) {
+          case ReserveRecordStatus.RegisteringBank:
+          case ReserveRecordStatus.WaitConfirmBank:
+            break;
+          default:
+            return;
+        }
+        if (!r.bankInfo) {
+          throw Error("invariant failed");
+        }
+        const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
+        r.bankInfo.timestampBankConfirmed = now;
+        r.reserveStatus = ReserveRecordStatus.BankAborted;
+        r.operationStatus = OperationStatus.Finished;
+        r.retryInfo = RetryInfo.reset();
+        await tx.withdrawalGroups.put(r);
+      });
+    return;
+  }
+
+  // Bank still needs to know our reserve info
+  if (!status.selection_done) {
+    await registerReserveWithBank(ws, withdrawalGroupId);
+    return await processReserveBankStatus(ws, withdrawalGroupId);
+  }
+
+  // FIXME: Why do we do this?!
+  if (withdrawalGroup.reserveStatus === ReserveRecordStatus.RegisteringBank) {
+    await registerReserveWithBank(ws, withdrawalGroupId);
+    return await processReserveBankStatus(ws, withdrawalGroupId);
+  }
+
+  await ws.db
+    .mktx((x) => ({
+      withdrawalGroups: x.withdrawalGroups,
+    }))
+    .runReadWrite(async (tx) => {
+      const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+      if (!r) {
+        return;
+      }
+      // Re-check reserve status within transaction
+      switch (r.reserveStatus) {
+        case ReserveRecordStatus.RegisteringBank:
+        case ReserveRecordStatus.WaitConfirmBank:
+          break;
+        default:
+          return;
+      }
+      if (status.transfer_done) {
+        logger.info("withdrawal: transfer confirmed by bank.");
+        const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
+        if (!r.bankInfo) {
+          throw Error("invariant failed");
+        }
+        r.bankInfo.timestampBankConfirmed = now;
+        r.reserveStatus = ReserveRecordStatus.QueryingStatus;
+        r.operationStatus = OperationStatus.Pending;
+        r.retryInfo = RetryInfo.reset();
+      } else {
+        logger.info("withdrawal: transfer not yet confirmed by bank");
+        if (r.bankInfo) {
+          r.bankInfo.confirmUrl = status.confirm_transfer_url;
+        }
+        r.retryInfo = RetryInfo.increment(r.retryInfo);
+      }
+      await tx.withdrawalGroups.put(r);
+    });
+}
+
+export async function internalCreateWithdrawalGroup(
+  ws: InternalWalletState,
+  args: {
+    reserveStatus: ReserveRecordStatus;
+    amount: AmountJson;
+    bankInfo?: ReserveBankInfo;
+    exchangeBaseUrl: string;
+    forcedDenomSel?: ForcedDenomSel;
+    reserveKeyPair?: EddsaKeypair;
+    restrictAge?: number;
+  },
+): Promise<WithdrawalGroupRecord> {
+  const reserveKeyPair =
+    args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
+  const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
+  const secretSeed = encodeCrock(getRandomBytes(32));
+  const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
+  const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+  const amount = args.amount;
+
+  await updateWithdrawalDenoms(ws, canonExchange);
+  const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
+
+  let initialDenomSel: DenomSelectionState;
+  const denomSelUid = encodeCrock(getRandomBytes(16));
+  if (args.forcedDenomSel) {
+    logger.warn("using forced denom selection");
+    initialDenomSel = selectForcedWithdrawalDenominations(
+      amount,
+      denoms,
+      args.forcedDenomSel,
+    );
+  } else {
+    initialDenomSel = selectWithdrawalDenominations(amount, denoms);
+  }
+
+  const withdrawalGroup: WithdrawalGroupRecord = {
+    denomSelUid,
+    denomsSel: initialDenomSel,
+    exchangeBaseUrl: canonExchange,
+    instructedAmount: amount,
+    timestampStart: now,
+    lastError: undefined,
+    operationStatus: OperationStatus.Pending,
+    rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
+    secretSeed,
+    reservePriv: reserveKeyPair.priv,
+    reservePub: reserveKeyPair.pub,
+    reserveStatus: args.reserveStatus,
+    retryInfo: RetryInfo.reset(),
+    withdrawalGroupId,
+    bankInfo: args.bankInfo,
+    restrictAge: args.restrictAge,
+    senderWire: undefined,
+    timestampFinish: undefined,
+  };
+
+  const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange);
+  const exchangeDetails = exchangeInfo.exchangeDetails;
+  if (!exchangeDetails) {
+    logger.trace(exchangeDetails);
+    throw Error("exchange not updated");
+  }
+  const { isAudited, isTrusted } = await getExchangeTrust(
+    ws,
+    exchangeInfo.exchange,
+  );
+
+  await ws.db
+    .mktx((x) => ({
+      withdrawalGroups: x.withdrawalGroups,
+      exchanges: x.exchanges,
+      exchangeDetails: x.exchangeDetails,
+      exchangeTrust: x.exchangeTrust,
+    }))
+    .runReadWrite(async (tx) => {
+      await tx.withdrawalGroups.add(withdrawalGroup);
+
+      if (!isAudited && !isTrusted) {
+        await tx.exchangeTrust.put({
+          currency: amount.currency,
+          exchangeBaseUrl: canonExchange,
+          exchangeMasterPub: exchangeDetails.masterPublicKey,
+          uids: [encodeCrock(getRandomBytes(32))],
+        });
+      }
+    });
+
+  return withdrawalGroup;
+}
+
+export async function acceptWithdrawalFromUri(
+  ws: InternalWalletState,
+  req: {
+    talerWithdrawUri: string;
+    selectedExchange: string;
+    forcedDenomSel?: ForcedDenomSel;
+    restrictAge?: number;
+  },
+): Promise<AcceptWithdrawalResponse> {
+  await updateExchangeFromUrl(ws, req.selectedExchange);
+  const withdrawInfo = await getBankWithdrawalInfo(
+    ws.http,
+    req.talerWithdrawUri,
+  );
+  const exchangePaytoUri = await getExchangePaytoUri(
+    ws,
+    req.selectedExchange,
+    withdrawInfo.wireTypes,
+  );
+
+  const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+    amount: withdrawInfo.amount,
+    exchangeBaseUrl: req.selectedExchange,
+    forcedDenomSel: req.forcedDenomSel,
+    reserveStatus: ReserveRecordStatus.RegisteringBank,
+    bankInfo: {
+      exchangePaytoUri,
+      statusUrl: withdrawInfo.extractedStatusUrl,
+      confirmUrl: withdrawInfo.confirmTransferUrl,
+    },
+  });
+
+  const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+  // 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, withdrawalGroupId);
+  const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+    withdrawalGroupId,
+  });
+  if (
+    processedWithdrawalGroup?.reserveStatus === ReserveRecordStatus.BankAborted
+  ) {
+    throw TalerError.fromDetail(
+      TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+      {},
+    );
+  }
+
+  // Start withdrawal in the background.
+  await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true 
}).catch(
+    (err) => {
+      logger.error("Processing withdrawal (after creation) failed:", err);
+    },
+  );
+
+  return {
+    reservePub: withdrawalGroup.reservePub,
+    confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+  };
+}
+
+/**
+ * Create a manual withdrawal operation.
+ *
+ * Adds the corresponding exchange as a trusted exchange if it is neither
+ * audited nor trusted already.
+ *
+ * Asynchronously starts the withdrawal.
+ */
+export async function createManualWithdrawal(
+  ws: InternalWalletState,
+  req: {
+    exchangeBaseUrl: string;
+    amount: AmountLike;
+    restrictAge?: number;
+    forcedDenomSel?: ForcedDenomSel;
+  },
+): Promise<AcceptManualWithdrawalResult> {
+  const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+    amount: Amounts.jsonifyAmount(req.amount),
+    exchangeBaseUrl: req.exchangeBaseUrl,
+    bankInfo: undefined,
+    forcedDenomSel: req.forcedDenomSel,
+    restrictAge: req.restrictAge,
+    reserveStatus: ReserveRecordStatus.QueryingStatus,
+  });
+
+  const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+  const exchangePaytoUris = await ws.db
+    .mktx((x) => ({
+      withdrawalGroups: x.withdrawalGroups,
+      exchanges: x.exchanges,
+      exchangeDetails: x.exchangeDetails,
+      exchangeTrust: x.exchangeTrust,
+    }))
+    .runReadWrite(async (tx) => {
+      return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+    });
+
+  // Start withdrawal in the background.
+  await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true 
}).catch(
+    (err) => {
+      logger.error("Processing withdrawal (after creation) failed:", err);
+    },
+  );
+
+  return {
+    reservePub: withdrawalGroup.reservePub,
+    exchangePaytoUris: exchangePaytoUris,
+  };
+}
diff --git a/packages/taler-wallet-core/src/pending-types.ts 
b/packages/taler-wallet-core/src/pending-types.ts
index f4e5216b..e372a593 100644
--- a/packages/taler-wallet-core/src/pending-types.ts
+++ b/packages/taler-wallet-core/src/pending-types.ts
@@ -40,7 +40,6 @@ export enum PendingTaskType {
   ProposalChoice = "proposal-choice",
   ProposalDownload = "proposal-download",
   Refresh = "refresh",
-  Reserve = "reserve",
   Recoup = "recoup",
   RefundQuery = "refund-query",
   TipPickup = "tip-pickup",
@@ -60,7 +59,6 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
     | PendingProposalDownloadTask
     | PendingRefreshTask
     | PendingRefundQueryTask
-    | PendingReserveTask
     | PendingTipPickupTask
     | PendingWithdrawTask
     | PendingRecoupTask
@@ -103,22 +101,6 @@ export enum ReserveType {
   TalerBankWithdraw = "taler-bank-withdraw",
 }
 
-/**
- * Status of processing a reserve.
- *
- * Does *not* include the withdrawal operation that might result
- * from this.
- */
-export interface PendingReserveTask {
-  type: PendingTaskType.Reserve;
-  retryInfo: RetryInfo | undefined;
-  stage: ReserveRecordStatus;
-  timestampCreated: TalerProtocolTimestamp;
-  reserveType: ReserveType;
-  reservePub: string;
-  bankWithdrawConfirmUrl?: string;
-}
-
 /**
  * Status of an ongoing withdrawal operation.
  */
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index 066f91a3..a74c6c17 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -107,7 +107,6 @@ import {
   MerchantOperations,
   NotificationListener,
   RecoupOperations,
-  ReserveOperations,
 } from "./internal-wallet-state.js";
 import { exportBackup } from "./operations/backup/export.js";
 import {
@@ -167,12 +166,6 @@ import {
   prepareRefund,
   processPurchaseQueryRefund,
 } from "./operations/refund.js";
-import {
-  createReserve,
-  createTalerWithdrawReserve,
-  getFundingPaytoUris,
-  processReserve,
-} from "./operations/reserves.js";
 import {
   runIntegrationTest,
   testPay,
@@ -185,9 +178,12 @@ import {
   retryTransaction,
 } from "./operations/transactions.js";
 import {
+  acceptWithdrawalFromUri,
+  createManualWithdrawal,
   getExchangeWithdrawalInfo,
+  getFundingPaytoUrisTx,
   getWithdrawalDetailsForUri,
-  processWithdrawGroup,
+  processWithdrawalGroup as processWithdrawalGroup,
 } from "./operations/withdraw.js";
 import {
   PendingOperationsResponse,
@@ -258,11 +254,8 @@ async function processOnePendingOperation(
     case PendingTaskType.Refresh:
       await processRefreshGroup(ws, pending.refreshGroupId, { forceNow });
       break;
-    case PendingTaskType.Reserve:
-      await processReserve(ws, pending.reservePub, { forceNow });
-      break;
     case PendingTaskType.Withdraw:
-      await processWithdrawGroup(ws, pending.withdrawalGroupId, { forceNow });
+      await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow 
});
       break;
     case PendingTaskType.ProposalDownload:
       await processDownloadProposal(ws, pending.proposalId, { forceNow });
@@ -464,40 +457,6 @@ async function fillDefaults(ws: InternalWalletState): 
Promise<void> {
     });
 }
 
-/**
- * Create a reserve for a manual withdrawal.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- */
-async function acceptManualWithdrawal(
-  ws: InternalWalletState,
-  exchangeBaseUrl: string,
-  amount: AmountJson,
-  restrictAge?: number,
-): Promise<AcceptManualWithdrawalResult> {
-  try {
-    const resp = await createReserve(ws, {
-      amount,
-      exchange: exchangeBaseUrl,
-      restrictAge,
-    });
-    const exchangePaytoUris = await ws.db
-      .mktx((x) => ({
-        exchanges: x.exchanges,
-        exchangeDetails: x.exchangeDetails,
-        reserves: x.reserves,
-      }))
-      .runReadWrite((tx) => getFundingPaytoUris(tx, resp.reservePub));
-    return {
-      reservePub: resp.reservePub,
-      exchangePaytoUris,
-    };
-  } finally {
-    ws.latch.trigger();
-  }
-}
-
 async function getExchangeTos(
   ws: InternalWalletState,
   exchangeBaseUrl: string,
@@ -552,6 +511,10 @@ async function getExchangeTos(
   };
 }
 
+/**
+ * List bank accounts known to the wallet from
+ * previous withdrawals.
+ */
 async function listKnownBankAccounts(
   ws: InternalWalletState,
   currency?: string,
@@ -559,12 +522,13 @@ async function listKnownBankAccounts(
   const accounts: PaytoUri[] = [];
   await ws.db
     .mktx((x) => ({
-      reserves: x.reserves,
+      withdrawalGroups: x.withdrawalGroups,
     }))
     .runReadOnly(async (tx) => {
-      const reservesRecords = await tx.reserves.iter().toArray();
-      for (const r of reservesRecords) {
-        if (currency && currency !== r.currency) {
+      const withdrawalGroups = await tx.withdrawalGroups.iter().toArray();
+      for (const r of withdrawalGroups) {
+        const amount = r.rawWithdrawalAmount;
+        if (currency && currency !== amount.currency) {
           continue;
         }
         const payto = r.senderWire ? parsePaytoUri(r.senderWire) : undefined;
@@ -614,31 +578,6 @@ async function getExchanges(
   return { exchanges };
 }
 
-/**
- * Inform the wallet that the status of a reserve has changed (e.g. due to a
- * confirmation from the bank.).
- */
-export async function handleNotifyReserve(
-  ws: InternalWalletState,
-): Promise<void> {
-  const reserves = await ws.db
-    .mktx((x) => ({
-      reserves: x.reserves,
-    }))
-    .runReadOnly(async (tx) => {
-      return tx.reserves.iter().toArray();
-    });
-  for (const r of reserves) {
-    if (r.reserveStatus === ReserveRecordStatus.WaitConfirmBank) {
-      try {
-        processReserve(ws, r.reservePub);
-      } catch (e) {
-        console.error(e);
-      }
-    }
-  }
-}
-
 async function setCoinSuspended(
   ws: InternalWalletState,
   coinPub: string,
@@ -817,12 +756,11 @@ async function dispatchRequestInternal(
     }
     case "acceptManualWithdrawal": {
       const req = codecForAcceptManualWithdrawalRequet().decode(payload);
-      const res = await acceptManualWithdrawal(
-        ws,
-        req.exchangeBaseUrl,
-        Amounts.parseOrThrow(req.amount),
-        req.restrictAge,
-      );
+      const res = await createManualWithdrawal(ws, {
+        amount: Amounts.parseOrThrow(req.amount),
+        exchangeBaseUrl: req.exchangeBaseUrl,
+        restrictAge: req.restrictAge,
+      });
       return res;
     }
     case "getWithdrawalDetailsForAmount": {
@@ -856,15 +794,12 @@ async function dispatchRequestInternal(
     case "acceptBankIntegratedWithdrawal": {
       const req =
         codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
-      return await createTalerWithdrawReserve(
-        ws,
-        req.talerWithdrawUri,
-        req.exchangeBaseUrl,
-        {
-          forcedDenomSel: req.forcedDenomSel,
-          restrictAge: req.restrictAge,
-        },
-      );
+      return await acceptWithdrawalFromUri(ws, {
+        selectedExchange: req.exchangeBaseUrl,
+        talerWithdrawUri: req.talerWithdrawUri,
+        forcedDenomSel: req.forcedDenomSel,
+        restrictAge: req.restrictAge,
+      });
     }
     case "getExchangeTos": {
       const req = codecForGetExchangeTosRequest().decode(payload);
@@ -1033,7 +968,10 @@ async function dispatchRequestInternal(
         req.exchange,
         amount,
       );
-      const wres = await acceptManualWithdrawal(ws, req.exchange, amount);
+      const wres = await createManualWithdrawal(ws, {
+        amount: amount,
+        exchangeBaseUrl: req.exchange,
+      });
       const paytoUri = details.paytoUris[0];
       const pt = parsePaytoUri(paytoUri);
       if (!pt) {
@@ -1229,10 +1167,6 @@ class InternalWalletStateImpl implements 
InternalWalletState {
     getMerchantInfo,
   };
 
-  reserveOps: ReserveOperations = {
-    processReserve,
-  };
-
   // FIXME: Use an LRU cache here.
   private denomCache: Record<string, DenomInfo> = {};
 

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

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