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 deposits


From: gnunet
Subject: [taler-wallet-core] branch master updated: implement deposits
Date: Mon, 18 Jan 2021 23:35:46 +0100

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

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

The following commit(s) were added to refs/heads/master by this push:
     new 5f3c02d3 implement deposits
5f3c02d3 is described below

commit 5f3c02d31a223add55a32b20f4a289210cbb4f15
Author: Florian Dold <florian@dold.me>
AuthorDate: Mon Jan 18 23:35:41 2021 +0100

    implement deposits
---
 packages/taler-wallet-cli/src/index.ts             |  31 ++
 .../src/integrationtests/harness.ts                |  43 ++-
 .../src/integrationtests/test-deposit.ts           |  65 ++++
 .../src/integrationtests/test-revocation.ts        |   5 -
 .../src/integrationtests/testrunner.ts             |   2 +
 .../src/crypto/workers/cryptoApi.ts                |   5 +
 .../src/crypto/workers/cryptoImplementation.ts     |  12 +
 .../taler-wallet-core/src/operations/deposits.ts   | 420 +++++++++++++++++++++
 packages/taler-wallet-core/src/operations/pay.ts   | 239 ++++++++----
 .../taler-wallet-core/src/operations/pending.ts    |  30 ++
 .../taler-wallet-core/src/operations/refund.ts     |   1 +
 packages/taler-wallet-core/src/operations/state.ts |   1 +
 .../src/operations/transactions.ts                 |  23 ++
 .../taler-wallet-core/src/types/cryptoTypes.ts     |   8 +
 packages/taler-wallet-core/src/types/dbTypes.ts    |  56 +++
 .../taler-wallet-core/src/types/notifications.ts   |   7 +
 .../taler-wallet-core/src/types/pendingTypes.ts    |  12 +
 packages/taler-wallet-core/src/types/talerTypes.ts |   2 +-
 .../src/types/transactionsTypes.ts                 |  29 +-
 .../taler-wallet-core/src/types/walletTypes.ts     |  35 ++
 packages/taler-wallet-core/src/wallet.ts           |  34 ++
 .../taler-wallet-webextension/src/pages/popup.tsx  |  12 +
 22 files changed, 975 insertions(+), 97 deletions(-)

diff --git a/packages/taler-wallet-cli/src/index.ts 
b/packages/taler-wallet-cli/src/index.ts
index e4f1ccb5..7f32b844 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -503,6 +503,37 @@ backupCli
     });
   });
 
+const depositCli = walletCli.subcommand("depositArgs", "deposit", {
+  help: "Subcommands for depositing money to payto:// accounts",
+});
+
+depositCli
+  .subcommand("createDepositArgs", "create")
+  .requiredArgument("amount", clk.STRING)
+  .requiredArgument("targetPayto", clk.STRING)
+  .action(async (args) => {
+    await withWallet(args, async (wallet) => {
+      const resp = await wallet.createDepositGroup({
+        amount: args.createDepositArgs.amount,
+        depositPaytoUri: args.createDepositArgs.targetPayto,
+      });
+      console.log(`Created deposit ${resp.depositGroupId}`);
+      await wallet.runPending();
+    });
+  });
+
+depositCli
+  .subcommand("trackDepositArgs", "track")
+  .requiredArgument("depositGroupId", clk.STRING)
+  .action(async (args) => {
+    await withWallet(args, async (wallet) => {
+      const resp = await wallet.trackDepositGroup({
+        depositGroupId: args.trackDepositArgs.depositGroupId,
+      });
+      console.log(JSON.stringify(resp, undefined, 2));
+    });
+  });
+
 const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
   help:
     "Subcommands for advanced operations (only use if you know what you're 
doing!).",
diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts 
b/packages/taler-wallet-cli/src/integrationtests/harness.ts
index b6b82213..eb14b32b 100644
--- a/packages/taler-wallet-cli/src/integrationtests/harness.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts
@@ -78,6 +78,10 @@ import {
   AbortPayWithRefundRequest,
   openPromise,
   parsePaytoUri,
+  CreateDepositGroupRequest,
+  CreateDepositGroupResponse,
+  TrackDepositGroupRequest,
+  TrackDepositGroupResponse,
 } from "taler-wallet-core";
 import { URL } from "url";
 import axios, { AxiosError } from "axios";
@@ -873,6 +877,9 @@ export class ExchangeService implements 
ExchangeServiceInterface {
 
     config.setString("exchangedb-postgres", "config", e.database);
 
+    config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s");
+    config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s");
+
     const exchangeMasterKey = createEddsaKeyPair();
 
     config.setString(
@@ -1017,13 +1024,7 @@ export class ExchangeService implements 
ExchangeServiceInterface {
       this.globalState,
       "exchange-offline",
       "taler-exchange-offline",
-      [
-        "-c",
-        this.configFilename,
-        "download",
-        "sign",
-        "upload",
-      ],
+      ["-c", this.configFilename, "download", "sign", "upload"],
     );
 
     const accounts: string[] = [];
@@ -1049,13 +1050,7 @@ export class ExchangeService implements 
ExchangeServiceInterface {
         this.globalState,
         "exchange-offline",
         "taler-exchange-offline",
-        [
-          "-c",
-          this.configFilename,
-          "enable-account",
-          acc,
-          "upload",
-        ],
+        ["-c", this.configFilename, "enable-account", acc, "upload"],
       );
     }
 
@@ -1615,6 +1610,16 @@ export class WalletCli {
     throw new OperationFailedError(resp.error);
   }
 
+  async createDepositGroup(
+    req: CreateDepositGroupRequest,
+  ): Promise<CreateDepositGroupResponse> {
+    const resp = await this.apiRequest("createDepositGroup", req);
+    if (resp.type === "response") {
+      return resp.result as CreateDepositGroupResponse;
+    }
+    throw new OperationFailedError(resp.error);
+  }
+
   async abortFailedPayWithRefund(
     req: AbortPayWithRefundRequest,
   ): Promise<void> {
@@ -1714,6 +1719,16 @@ export class WalletCli {
     throw new OperationFailedError(resp.error);
   }
 
+  async trackDepositGroup(
+    req: TrackDepositGroupRequest,
+  ): Promise<TrackDepositGroupResponse> {
+    const resp = await this.apiRequest("trackDepositGroup", req);
+    if (resp.type === "response") {
+      return resp.result as TrackDepositGroupResponse;
+    }
+    throw new OperationFailedError(resp.error);
+  }
+
   async runIntegrationTest(args: IntegrationTestArgs): Promise<void> {
     const resp = await this.apiRequest("runIntegrationTest", args);
     if (resp.type === "response") {
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts
new file mode 100644
index 00000000..3e59a6cc
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "./harness";
+import {
+  createSimpleTestkudosEnvironment,
+  withdrawViaBank,
+} from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runDepositTest(t: GlobalTestState) {
+  // Set up test environment
+
+  const {
+    wallet,
+    bank,
+    exchange,
+    merchant,
+  } = await createSimpleTestkudosEnvironment(t);
+
+  // Withdraw digital cash into the wallet.
+
+  await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+  await wallet.runUntilDone();
+
+  const { depositGroupId } = await wallet.createDepositGroup({
+    amount: "TESTKUDOS:10",
+    depositPaytoUri: "payto://x-taler-bank/localhost/foo",
+  });
+
+  await wallet.runUntilDone();
+
+  const transactions = await wallet.getTransactions();
+  console.log("transactions", JSON.stringify(transactions, undefined, 2));
+  t.assertDeepEqual(transactions.transactions[0].type, "withdrawal");
+  t.assertDeepEqual(transactions.transactions[1].type, "deposit");
+  // The raw amount is what ends up on the bank account, which includes
+  // deposit and wire fees.
+  t.assertDeepEqual(transactions.transactions[1].amountRaw, "TESTKUDOS:9.79");
+
+  const trackResult = wallet.trackDepositGroup({
+    depositGroupId,
+  })
+
+  console.log(JSON.stringify(trackResult, undefined, 2));
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts
index 05204530..a7779731 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts
@@ -82,11 +82,6 @@ async function createTestEnvironment(
     database: db.connStr,
   });
 
-  exchange.changeConfig((config) => {
-    config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s");
-    config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s");
-  });
-
   const exchangeBankAccount = await bank.createExchangeAccount(
     "MyExchange",
     "x",
diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts 
b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
index 04e803b7..d20bf189 100644
--- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
@@ -49,6 +49,7 @@ import { runWithdrawalBankIntegratedTest } from 
"./test-withdrawal-bank-integrat
 import M from "minimatch";
 import { runMerchantExchangeConfusionTest } from 
"./test-merchant-exchange-confusion";
 import { runLibeufinBasicTest } from "./test-libeufin-basic";
+import { runDepositTest } from "./test-deposit";
 
 /**
  * Test runner.
@@ -64,6 +65,7 @@ interface TestMainFunction {
 const allTests: TestMainFunction[] = [
   runBankApiTest,
   runClaimLoopTest,
+  runDepositTest,
   runExchangeManagementTest,
   runFeeRegressionTest,
   runLibeufinBasicTest,
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts 
b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
index ef149823..d7eddd69 100644
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
@@ -43,6 +43,7 @@ import {
   DerivedTipPlanchet,
   DeriveRefreshSessionRequest,
   DeriveTipRequest,
+  SignTrackTransactionRequest,
 } from "../../types/cryptoTypes";
 
 const logger = new Logger("cryptoApi.ts");
@@ -326,6 +327,10 @@ export class CryptoApi {
     return this.doRpc<DerivedTipPlanchet>("createTipPlanchet", 1, req);
   }
 
+  signTrackTransaction(req: SignTrackTransactionRequest): Promise<string> {
+    return this.doRpc<string>("signTrackTransaction", 1, req);
+  }
+
   hashString(str: string): Promise<string> {
     return this.doRpc<string>("hashString", 1, str);
   }
diff --git 
a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts 
b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts
index 1f44d627..87fad863 100644
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts
@@ -72,11 +72,13 @@ import {
   DerivedTipPlanchet,
   DeriveRefreshSessionRequest,
   DeriveTipRequest,
+  SignTrackTransactionRequest,
 } from "../../types/cryptoTypes";
 
 const logger = new Logger("cryptoImplementation.ts");
 
 enum SignaturePurpose {
+  MERCHANT_TRACK_TRANSACTION = 1103,
   WALLET_RESERVE_WITHDRAW = 1200,
   WALLET_COIN_DEPOSIT = 1201,
   MASTER_DENOMINATION_KEY_VALIDITY = 1025,
@@ -211,6 +213,16 @@ export class CryptoImplementation {
     return tipPlanchet;
   }
 
+  signTrackTransaction(req: SignTrackTransactionRequest): string {
+    const p = buildSigPS(SignaturePurpose.MERCHANT_TRACK_TRANSACTION)
+      .put(decodeCrock(req.contractTermsHash))
+      .put(decodeCrock(req.wireHash))
+      .put(decodeCrock(req.merchantPub))
+      .put(decodeCrock(req.coinPub))
+      .build();
+      return encodeCrock(eddsaSign(p, decodeCrock(req.merchantPriv)));
+  }
+
   /**
    * Create and sign a message to recoup a coin.
    */
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts 
b/packages/taler-wallet-core/src/operations/deposits.ts
new file mode 100644
index 00000000..50921a17
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/deposits.ts
@@ -0,0 +1,420 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ 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 {
+  Amounts,
+  CreateDepositGroupRequest,
+  guardOperationException,
+  Logger,
+  NotificationType,
+  TalerErrorDetails,
+} from "..";
+import { kdf } from "../crypto/primitives/kdf";
+import {
+  encodeCrock,
+  getRandomBytes,
+  stringToBytes,
+} from "../crypto/talerCrypto";
+import { DepositGroupRecord, Stores } from "../types/dbTypes";
+import { ContractTerms } from "../types/talerTypes";
+import { CreateDepositGroupResponse, TrackDepositGroupRequest, 
TrackDepositGroupResponse } from "../types/walletTypes";
+import {
+  buildCodecForObject,
+  Codec,
+  codecForString,
+  codecOptional,
+} from "../util/codec";
+import { canonicalJson } from "../util/helpers";
+import { readSuccessResponseJsonOrThrow } from "../util/http";
+import { parsePaytoUri } from "../util/payto";
+import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries";
+import {
+  codecForTimestamp,
+  durationFromSpec,
+  getTimestampNow,
+  Timestamp,
+  timestampAddDuration,
+  timestampTruncateToSecond,
+} from "../util/time";
+import { URL } from "../util/url";
+import {
+  applyCoinSpend,
+  extractContractData,
+  generateDepositPermissions,
+  getCoinsForPayment,
+  getEffectiveDepositAmount,
+  getTotalPaymentCost,
+} from "./pay";
+import { InternalWalletState } from "./state";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("deposits.ts");
+
+interface DepositSuccess {
+  // Optional base URL of the exchange for looking up wire transfers
+  // associated with this transaction.  If not given,
+  // the base URL is the same as the one used for this request.
+  // Can be used if the base URL for /transactions/ differs from that
+  // for /coins/, i.e. for load balancing.  Clients SHOULD
+  // respect the transaction_base_url if provided.  Any HTTP server
+  // belonging to an exchange MUST generate a 307 or 308 redirection
+  // to the correct base URL should a client uses the wrong base
+  // URL, or if the base URL has changed since the deposit.
+  transaction_base_url?: string;
+
+  // timestamp when the deposit was received by the exchange.
+  exchange_timestamp: Timestamp;
+
+  // the EdDSA signature of TALER_DepositConfirmationPS using a current
+  // signing key of the exchange affirming the successful
+  // deposit and that the exchange will transfer the funds after the refund
+  // deadline, or as soon as possible if the refund deadline is zero.
+  exchange_sig: string;
+
+  // public EdDSA key of the exchange that was used to
+  // generate the signature.
+  // Should match one of the exchange's signing keys from /keys.  It is given
+  // explicitly as the client might otherwise be confused by clock skew as to
+  // which signing key was used.
+  exchange_pub: string;
+}
+
+const codecForDepositSuccess = (): Codec<DepositSuccess> =>
+  buildCodecForObject<DepositSuccess>()
+    .property("exchange_pub", codecForString())
+    .property("exchange_sig", codecForString())
+    .property("exchange_timestamp", codecForTimestamp)
+    .property("transaction_base_url", codecOptional(codecForString()))
+    .build("DepositSuccess");
+
+function hashWire(paytoUri: string, salt: string): string {
+  const r = kdf(
+    64,
+    stringToBytes(paytoUri + "\0"),
+    stringToBytes(salt + "\0"),
+    stringToBytes("merchant-wire-signature"),
+  );
+  return encodeCrock(r);
+}
+
+async function resetDepositGroupRetry(
+  ws: InternalWalletState,
+  depositGroupId: string,
+): Promise<void> {
+  await ws.db.mutate(Stores.depositGroups, depositGroupId, (x) => {
+    if (x.retryInfo.active) {
+      x.retryInfo = initRetryInfo();
+    }
+    return x;
+  });
+}
+
+async function incrementDepositRetry(
+  ws: InternalWalletState,
+  depositGroupId: string,
+  err: TalerErrorDetails | undefined,
+): Promise<void> {
+  await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => {
+    const r = await tx.get(Stores.depositGroups, depositGroupId);
+    if (!r) {
+      return;
+    }
+    if (!r.retryInfo) {
+      return;
+    }
+    r.retryInfo.retryCounter++;
+    updateRetryInfoTimeout(r.retryInfo);
+    r.lastError = err;
+    await tx.put(Stores.depositGroups, r);
+  });
+  if (err) {
+    ws.notify({ type: NotificationType.DepositOperationError, error: err });
+  }
+}
+
+export async function processDepositGroup(
+  ws: InternalWalletState,
+  depositGroupId: string,
+  forceNow = false,
+): Promise<void> {
+  await ws.memoProcessDeposit.memo(depositGroupId, async () => {
+    const onOpErr = (e: TalerErrorDetails): Promise<void> =>
+      incrementDepositRetry(ws, depositGroupId, e);
+    return await guardOperationException(
+      async () => await processDepositGroupImpl(ws, depositGroupId, forceNow),
+      onOpErr,
+    );
+  });
+}
+
+async function processDepositGroupImpl(
+  ws: InternalWalletState,
+  depositGroupId: string,
+  forceNow: boolean = false,
+): Promise<void> {
+  if (forceNow) {
+    await resetDepositGroupRetry(ws, depositGroupId);
+  }
+  const depositGroup = await ws.db.get(Stores.depositGroups, depositGroupId);
+  if (!depositGroup) {
+    logger.warn(`deposit group ${depositGroupId} not found`);
+    return;
+  }
+  if (depositGroup.timestampFinished) {
+    logger.trace(`deposit group ${depositGroupId} already finished`);
+    return;
+  }
+
+  const contractData = extractContractData(
+    depositGroup.contractTermsRaw,
+    depositGroup.contractTermsHash,
+    "",
+  );
+
+  const depositPermissions = await generateDepositPermissions(
+    ws,
+    depositGroup.payCoinSelection,
+    contractData,
+  );
+
+  for (let i = 0; i < depositPermissions.length; i++) {
+    if (depositGroup.depositedPerCoin[i]) {
+      continue;
+    }
+    const perm = depositPermissions[i];
+    const url = new URL(`/coins/${perm.coin_pub}/deposit`, perm.exchange_url);
+    const httpResp = await ws.http.postJson(url.href, {
+      contribution: Amounts.stringify(perm.contribution),
+      wire: depositGroup.wire,
+      h_wire: depositGroup.contractTermsRaw.h_wire,
+      h_contract_terms: depositGroup.contractTermsHash,
+      ub_sig: perm.ub_sig,
+      timestamp: depositGroup.contractTermsRaw.timestamp,
+      wire_transfer_deadline:
+        depositGroup.contractTermsRaw.wire_transfer_deadline,
+      refund_deadline: depositGroup.contractTermsRaw.refund_deadline,
+      coin_sig: perm.coin_sig,
+      denom_pub_hash: perm.h_denom,
+      merchant_pub: depositGroup.merchantPub,
+    });
+    await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess());
+    await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => {
+      const dg = await tx.get(Stores.depositGroups, depositGroupId);
+      if (!dg) {
+        return;
+      }
+      dg.depositedPerCoin[i] = true;
+      await tx.put(Stores.depositGroups, dg);
+    });
+  }
+
+  await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => {
+    const dg = await tx.get(Stores.depositGroups, depositGroupId);
+    if (!dg) {
+      return;
+    }
+    let allDeposited = true;
+    for (const d of depositGroup.depositedPerCoin) {
+      if (!d) {
+        allDeposited = false;
+      }
+    }
+    if (allDeposited) {
+      dg.timestampFinished = getTimestampNow();
+      await tx.put(Stores.depositGroups, dg);
+    }
+  });
+}
+
+
+export async function trackDepositGroup(
+  ws: InternalWalletState,
+  req: TrackDepositGroupRequest,
+): Promise<TrackDepositGroupResponse> {
+  const responses: {
+    status: number;
+    body: any;
+  }[] = [];
+  const depositGroup = await ws.db.get(
+    Stores.depositGroups,
+    req.depositGroupId,
+  );
+  if (!depositGroup) {
+    throw Error("deposit group not found");
+  }
+  const contractData = extractContractData(
+    depositGroup.contractTermsRaw,
+    depositGroup.contractTermsHash,
+    "",
+  );
+
+  const depositPermissions = await generateDepositPermissions(
+    ws,
+    depositGroup.payCoinSelection,
+    contractData,
+  );
+
+  const wireHash = depositGroup.contractTermsRaw.h_wire;
+
+  for (const dp of depositPermissions) {
+    const url = new URL(
+      
`/deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`,
+      dp.exchange_url,
+    );
+    const sig = await ws.cryptoApi.signTrackTransaction({
+      coinPub: dp.coin_pub,
+      contractTermsHash: depositGroup.contractTermsHash,
+      merchantPriv: depositGroup.merchantPriv,
+      merchantPub: depositGroup.merchantPub,
+      wireHash,
+    });
+    url.searchParams.set("merchant_sig", sig);
+    const httpResp = await ws.http.get(url.href);
+    const body = await httpResp.json();
+    responses.push({
+      body,
+      status: httpResp.status,
+    });
+  }
+  return {
+    responses,
+  };
+}
+
+export async function createDepositGroup(
+  ws: InternalWalletState,
+  req: CreateDepositGroupRequest,
+): Promise<CreateDepositGroupResponse> {
+  const p = parsePaytoUri(req.depositPaytoUri);
+  if (!p) {
+    throw Error("invalid payto URI");
+  }
+
+  const amount = Amounts.parseOrThrow(req.amount);
+
+  const allExchanges = await ws.db.iter(Stores.exchanges).toArray();
+  const exchangeInfos: { url: string; master_pub: string }[] = [];
+  for (const e of allExchanges) {
+    if (!e.details) {
+      continue;
+    }
+    if (e.details.currency != amount.currency) {
+      continue;
+    }
+    exchangeInfos.push({
+      master_pub: e.details.masterPublicKey,
+      url: e.baseUrl,
+    });
+  }
+
+  const timestamp = getTimestampNow();
+  const timestampRound = timestampTruncateToSecond(timestamp);
+  const noncePair = await ws.cryptoApi.createEddsaKeypair();
+  const merchantPair = await ws.cryptoApi.createEddsaKeypair();
+  const wireSalt = encodeCrock(getRandomBytes(64));
+  const wireHash = hashWire(req.depositPaytoUri, wireSalt);
+  const contractTerms: ContractTerms = {
+    auditors: [],
+    exchanges: exchangeInfos,
+    amount: req.amount,
+    max_fee: Amounts.stringify(amount),
+    max_wire_fee: Amounts.stringify(amount),
+    wire_method: p.targetType,
+    timestamp: timestampRound,
+    merchant_base_url: "",
+    summary: "",
+    nonce: noncePair.pub,
+    wire_transfer_deadline: timestampRound,
+    order_id: "",
+    h_wire: wireHash,
+    pay_deadline: timestampAddDuration(
+      timestampRound,
+      durationFromSpec({ hours: 1 }),
+    ),
+    merchant: {
+      name: "",
+    },
+    merchant_pub: merchantPair.pub,
+    refund_deadline: { t_ms: 0 },
+  };
+
+  const contractTermsHash = await ws.cryptoApi.hashString(
+    canonicalJson(contractTerms),
+  );
+
+  const contractData = extractContractData(
+    contractTerms,
+    contractTermsHash,
+    "",
+  );
+
+  const payCoinSel = await getCoinsForPayment(ws, contractData);
+
+  if (!payCoinSel) {
+    throw Error("insufficient funds");
+  }
+
+  const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel);
+
+  const depositGroupId = encodeCrock(getRandomBytes(32));
+
+  const effectiveDepositAmount = await getEffectiveDepositAmount(
+    ws,
+    p.targetType,
+    payCoinSel,
+  );
+
+  const depositGroup: DepositGroupRecord = {
+    contractTermsHash,
+    contractTermsRaw: contractTerms,
+    depositGroupId,
+    noncePriv: noncePair.priv,
+    noncePub: noncePair.pub,
+    timestampCreated: timestamp,
+    timestampFinished: undefined,
+    payCoinSelection: payCoinSel,
+    depositedPerCoin: payCoinSel.coinPubs.map((x) => false),
+    merchantPriv: merchantPair.priv,
+    merchantPub: merchantPair.pub,
+    totalPayCost: totalDepositCost,
+    effectiveDepositAmount,
+    wire: {
+      payto_uri: req.depositPaytoUri,
+      salt: wireSalt,
+    },
+    retryInfo: initRetryInfo(true),
+    lastError: undefined,
+  };
+
+  await ws.db.runWithWriteTransaction(
+    [
+      Stores.depositGroups,
+      Stores.coins,
+      Stores.refreshGroups,
+      Stores.denominations,
+    ],
+    async (tx) => {
+      await applyCoinSpend(ws, tx, payCoinSel);
+      await tx.put(Stores.depositGroups, depositGroup);
+    },
+  );
+
+  await ws.db.put(Stores.depositGroups, depositGroup);
+
+  return { depositGroupId };
+}
diff --git a/packages/taler-wallet-core/src/operations/pay.ts 
b/packages/taler-wallet-core/src/operations/pay.ts
index ee42d347..d8168acd 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -36,6 +36,8 @@ import {
   DenominationRecord,
   PayCoinSelection,
   AbortStatus,
+  AllowedExchangeInfo,
+  AllowedAuditorInfo,
 } from "../types/dbTypes";
 import { NotificationType } from "../types/notifications";
 import {
@@ -43,6 +45,7 @@ import {
   codecForContractTerms,
   CoinDepositPermission,
   codecForMerchantPayResponse,
+  ContractTerms,
 } from "../types/talerTypes";
 import {
   ConfirmPayResult,
@@ -72,7 +75,8 @@ import {
   durationMin,
   isTimestampExpired,
   durationMul,
-  durationAdd,
+  Timestamp,
+  timestampIsBetween,
 } from "../util/time";
 import { strcmp, canonicalJson } from "../util/helpers";
 import {
@@ -88,6 +92,7 @@ import {
   updateRetryInfoTimeout,
   getRetryDuration,
 } from "../util/retries";
+import { TransactionHandle } from "../util/query";
 
 /**
  * Logger.
@@ -162,6 +167,49 @@ export async function getTotalPaymentCost(
   return Amounts.sum(costs).amount;
 }
 
+/**
+ * Get the amount that will be deposited on the merchant's bank
+ * account, not considering aggregation.
+ */
+export async function getEffectiveDepositAmount(
+  ws: InternalWalletState,
+  wireType: string,
+  pcs: PayCoinSelection,
+): Promise<AmountJson> {
+  const amt: AmountJson[] = [];
+  const fees: AmountJson[] = [];
+  const exchangeSet: Set<string> = new Set();
+  for (let i = 0; i < pcs.coinPubs.length; i++) {
+    const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]);
+    if (!coin) {
+      throw Error("can't calculate deposit amountt, coin not found");
+    }
+    const denom = await ws.db.get(Stores.denominations, [
+      coin.exchangeBaseUrl,
+      coin.denomPubHash,
+    ]);
+    if (!denom) {
+      throw Error("can't find denomination to calculate deposit amount");
+    }
+    amt.push(pcs.coinContributions[i]);
+    fees.push(denom.feeDeposit);
+    exchangeSet.add(coin.exchangeBaseUrl);
+  }
+  for (const exchangeUrl of exchangeSet.values()) {
+    const exchange = await ws.db.get(Stores.exchanges, exchangeUrl);
+    if (!exchange?.wireInfo) {
+      continue;
+    }
+    const fee = exchange.wireInfo.feesForType[wireType].find((x) => {
+      return timestampIsBetween(getTimestampNow(), x.startStamp, x.endStamp);
+    })?.wireFee;
+    if (fee) {
+      fees.push(fee);
+    }
+  }
+  return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
+}
+
 /**
  * Given a list of available coins, select coins to spend under the merchant's
  * constraints.
@@ -277,17 +325,36 @@ export function isSpendableCoin(
   return true;
 }
 
+export interface CoinSelectionRequest {
+  amount: AmountJson;
+  allowedAuditors: AllowedAuditorInfo[];
+  allowedExchanges: AllowedExchangeInfo[];
+
+  /**
+   * Timestamp of the contract.
+   */
+  timestamp: Timestamp;
+
+  wireMethod: string;
+
+  wireFeeAmortization: number;
+
+  maxWireFee: AmountJson;
+
+  maxDepositFee: AmountJson;
+}
+
 /**
  * Select coins from the wallet's database that can be used
  * to pay for the given contract.
  *
  * If payment is impossible, undefined is returned.
  */
-async function getCoinsForPayment(
+export async function getCoinsForPayment(
   ws: InternalWalletState,
-  contractData: WalletContractData,
+  req: CoinSelectionRequest,
 ): Promise<PayCoinSelection | undefined> {
-  const remainingAmount = contractData.amount;
+  const remainingAmount = req.amount;
 
   const exchanges = await ws.db.iter(Stores.exchanges).toArray();
 
@@ -303,7 +370,7 @@ async function getCoinsForPayment(
     }
 
     // is the exchange explicitly allowed?
-    for (const allowedExchange of contractData.allowedExchanges) {
+    for (const allowedExchange of req.allowedExchanges) {
       if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
         isOkay = true;
         break;
@@ -312,7 +379,7 @@ async function getCoinsForPayment(
 
     // is the exchange allowed because of one of its auditors?
     if (!isOkay) {
-      for (const allowedAuditor of contractData.allowedAuditors) {
+      for (const allowedAuditor of req.allowedAuditors) {
         for (const auditor of exchangeDetails.auditors) {
           if (auditor.auditor_pub === allowedAuditor.auditorPub) {
             isOkay = true;
@@ -374,11 +441,8 @@ async function getCoinsForPayment(
     }
 
     let wireFee: AmountJson | undefined;
-    for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) 
{
-      if (
-        fee.startStamp <= contractData.timestamp &&
-        fee.endStamp >= contractData.timestamp
-      ) {
+    for (const fee of exchangeFees.feesForType[req.wireMethod] || []) {
+      if (fee.startStamp <= req.timestamp && fee.endStamp >= req.timestamp) {
         wireFee = fee.wireFee;
         break;
       }
@@ -386,12 +450,9 @@ async function getCoinsForPayment(
 
     let customerWireFee: AmountJson;
 
-    if (wireFee) {
-      const amortizedWireFee = Amounts.divide(
-        wireFee,
-        contractData.wireFeeAmortization,
-      );
-      if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
+    if (wireFee && req.wireFeeAmortization) {
+      const amortizedWireFee = Amounts.divide(wireFee, 
req.wireFeeAmortization);
+      if (Amounts.cmp(req.maxWireFee, amortizedWireFee) < 0) {
         customerWireFee = amortizedWireFee;
       } else {
         customerWireFee = Amounts.getZero(currency);
@@ -405,7 +466,7 @@ async function getCoinsForPayment(
       acis,
       remainingAmount,
       customerWireFee,
-      contractData.maxDepositFee,
+      req.maxDepositFee,
     );
     if (res) {
       return res;
@@ -414,6 +475,37 @@ async function getCoinsForPayment(
   return undefined;
 }
 
+export async function applyCoinSpend(
+  ws: InternalWalletState,
+  tx: TransactionHandle<
+    | typeof Stores.coins
+    | typeof Stores.refreshGroups
+    | typeof Stores.denominations
+  >,
+  coinSelection: PayCoinSelection,
+) {
+  for (let i = 0; i < coinSelection.coinPubs.length; i++) {
+    const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
+    if (!coin) {
+      throw Error("coin allocated for payment doesn't exist anymore");
+    }
+    coin.status = CoinStatus.Dormant;
+    const remaining = Amounts.sub(
+      coin.currentAmount,
+      coinSelection.coinContributions[i],
+    );
+    if (remaining.saturated) {
+      throw Error("not enough remaining balance on coin for payment");
+    }
+    coin.currentAmount = remaining.amount;
+    await tx.put(Stores.coins, coin);
+  }
+  const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
+    coinPub: x,
+  }));
+  await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
+}
+
 /**
  * Record all information that is necessary to
  * pay for a proposal in the wallet's database.
@@ -480,26 +572,7 @@ async function recordConfirmPay(
         await tx.put(Stores.proposals, p);
       }
       await tx.put(Stores.purchases, t);
-      for (let i = 0; i < coinSelection.coinPubs.length; i++) {
-        const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
-        if (!coin) {
-          throw Error("coin allocated for payment doesn't exist anymore");
-        }
-        coin.status = CoinStatus.Dormant;
-        const remaining = Amounts.sub(
-          coin.currentAmount,
-          coinSelection.coinContributions[i],
-        );
-        if (remaining.saturated) {
-          throw Error("not enough remaining balance on coin for payment");
-        }
-        coin.currentAmount = remaining.amount;
-        await tx.put(Stores.coins, coin);
-      }
-      const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
-        coinPub: x,
-      }));
-      await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
+      await applyCoinSpend(ws, tx, coinSelection);
     },
   );
 
@@ -609,6 +682,50 @@ function getPayRequestTimeout(purchase: PurchaseRecord): 
Duration {
   );
 }
 
+export function extractContractData(
+  parsedContractTerms: ContractTerms,
+  contractTermsHash: string,
+  merchantSig: string,
+): WalletContractData {
+  const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+  let maxWireFee: AmountJson;
+  if (parsedContractTerms.max_wire_fee) {
+    maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
+  } else {
+    maxWireFee = Amounts.getZero(amount.currency);
+  }
+  return {
+    amount,
+    contractTermsHash: contractTermsHash,
+    fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
+    merchantBaseUrl: parsedContractTerms.merchant_base_url,
+    merchantPub: parsedContractTerms.merchant_pub,
+    merchantSig,
+    orderId: parsedContractTerms.order_id,
+    summary: parsedContractTerms.summary,
+    autoRefund: parsedContractTerms.auto_refund,
+    maxWireFee,
+    payDeadline: parsedContractTerms.pay_deadline,
+    refundDeadline: parsedContractTerms.refund_deadline,
+    wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
+    allowedAuditors: parsedContractTerms.auditors.map((x) => ({
+      auditorBaseUrl: x.url,
+      auditorPub: x.auditor_pub,
+    })),
+    allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+      exchangeBaseUrl: x.url,
+      exchangePub: x.master_pub,
+    })),
+    timestamp: parsedContractTerms.timestamp,
+    wireMethod: parsedContractTerms.wire_method,
+    wireInfoHash: parsedContractTerms.h_wire,
+    maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
+    merchant: parsedContractTerms.merchant,
+    products: parsedContractTerms.products,
+    summaryI18n: parsedContractTerms.summary_i18n,
+  };
+}
+
 async function processDownloadProposalImpl(
   ws: InternalWalletState,
   proposalId: string,
@@ -714,6 +831,12 @@ async function processDownloadProposalImpl(
     throw new OperationFailedAndReportedError(err);
   }
 
+  const contractData = extractContractData(
+    parsedContractTerms,
+    contractTermsHash,
+    proposalResp.sig,
+  );
+
   await ws.db.runWithWriteTransaction(
     [Stores.proposals, Stores.purchases],
     async (tx) => {
@@ -724,44 +847,8 @@ async function processDownloadProposalImpl(
       if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
         return;
       }
-      const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
-      let maxWireFee: AmountJson;
-      if (parsedContractTerms.max_wire_fee) {
-        maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
-      } else {
-        maxWireFee = Amounts.getZero(amount.currency);
-      }
       p.download = {
-        contractData: {
-          amount,
-          contractTermsHash: contractTermsHash,
-          fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
-          merchantBaseUrl: parsedContractTerms.merchant_base_url,
-          merchantPub: parsedContractTerms.merchant_pub,
-          merchantSig: proposalResp.sig,
-          orderId: parsedContractTerms.order_id,
-          summary: parsedContractTerms.summary,
-          autoRefund: parsedContractTerms.auto_refund,
-          maxWireFee,
-          payDeadline: parsedContractTerms.pay_deadline,
-          refundDeadline: parsedContractTerms.refund_deadline,
-          wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
-          allowedAuditors: parsedContractTerms.auditors.map((x) => ({
-            auditorBaseUrl: x.url,
-            auditorPub: x.auditor_pub,
-          })),
-          allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
-            exchangeBaseUrl: x.url,
-            exchangePub: x.master_pub,
-          })),
-          timestamp: parsedContractTerms.timestamp,
-          wireMethod: parsedContractTerms.wire_method,
-          wireInfoHash: parsedContractTerms.h_wire,
-          maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
-          merchant: parsedContractTerms.merchant,
-          products: parsedContractTerms.products,
-          summaryI18n: parsedContractTerms.summary_i18n,
-        },
+        contractData,
         contractTermsRaw: proposalResp.contract_terms,
       };
       if (
@@ -1210,7 +1297,7 @@ export async function preparePayForUri(
  *
  * Accesses the database and the crypto worker.
  */
-async function generateDepositPermissions(
+export async function generateDepositPermissions(
   ws: InternalWalletState,
   payCoinSel: PayCoinSelection,
   contractData: WalletContractData,
diff --git a/packages/taler-wallet-core/src/operations/pending.ts 
b/packages/taler-wallet-core/src/operations/pending.ts
index cc693a49..bae28193 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -445,6 +445,34 @@ async function gatherRecoupPending(
   });
 }
 
+async function gatherDepositPending(
+  tx: TransactionHandle<typeof Stores.depositGroups>,
+  now: Timestamp,
+  resp: PendingOperationsResponse,
+  onlyDue = false,
+): Promise<void> {
+  await tx.iter(Stores.depositGroups).forEach((dg) => {
+    if (dg.timestampFinished) {
+      return;
+    }
+    resp.nextRetryDelay = updateRetryDelay(
+      resp.nextRetryDelay,
+      now,
+      dg.retryInfo.nextRetry,
+    );
+    if (onlyDue && dg.retryInfo.nextRetry.t_ms > now.t_ms) {
+      return;
+    }
+    resp.pendingOperations.push({
+      type: PendingOperationType.Deposit,
+      givesLifeness: true,
+      depositGroupId: dg.depositGroupId,
+      retryInfo: dg.retryInfo,
+      lastError: dg.lastError,
+    });
+  });
+}
+
 export async function getPendingOperations(
   ws: InternalWalletState,
   { onlyDue = false } = {},
@@ -462,6 +490,7 @@ export async function getPendingOperations(
       Stores.purchases,
       Stores.recoupGroups,
       Stores.planchets,
+      Stores.depositGroups,
     ],
     async (tx) => {
       const walletBalance = await getBalancesInsideTransaction(ws, tx);
@@ -479,6 +508,7 @@ export async function getPendingOperations(
       await gatherTipPending(tx, now, resp, onlyDue);
       await gatherPurchasePending(tx, now, resp, onlyDue);
       await gatherRecoupPending(tx, now, resp, onlyDue);
+      await gatherDepositPending(tx, now, resp, onlyDue);
       return resp;
     },
   );
diff --git a/packages/taler-wallet-core/src/operations/refund.ts 
b/packages/taler-wallet-core/src/operations/refund.ts
index 13df438e..28d48d5b 100644
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ b/packages/taler-wallet-core/src/operations/refund.ts
@@ -600,6 +600,7 @@ async function processPurchaseQueryRefundImpl(
       `orders/${purchase.download.contractData.orderId}/refund`,
       purchase.download.contractData.merchantBaseUrl,
     );
+    
 
     logger.trace(`making refund request to ${requestUrl.href}`);
 
diff --git a/packages/taler-wallet-core/src/operations/state.ts 
b/packages/taler-wallet-core/src/operations/state.ts
index 645ad8ad..ce52affe 100644
--- a/packages/taler-wallet-core/src/operations/state.ts
+++ b/packages/taler-wallet-core/src/operations/state.ts
@@ -41,6 +41,7 @@ export class InternalWalletState {
   memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new 
AsyncOpMemoSingle();
   memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
   memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+  memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
   cryptoApi: CryptoApi;
 
   listeners: NotificationListener[] = [];
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts 
b/packages/taler-wallet-core/src/operations/transactions.ts
index c7e6a9c5..d4903155 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -96,6 +96,7 @@ export async function getTransactions(
       Stores.withdrawalGroups,
       Stores.planchets,
       Stores.recoupGroups,
+      Stores.depositGroups,
     ],
     // Report withdrawals that are currently in progress.
     async (tx) => {
@@ -203,6 +204,28 @@ export async function getTransactions(
         });
       });
 
+      tx.iter(Stores.depositGroups).forEachAsync(async (dg) => {
+        const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
+        if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
+          return;
+        }
+
+        transactions.push({
+          type: TransactionType.Deposit,
+          amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
+          amountEffective: Amounts.stringify(dg.totalPayCost),
+          pending: !dg.timestampFinished,
+          timestamp: dg.timestampCreated,
+          targetPaytoUri: dg.wire.payto_uri,
+          transactionId: makeEventId(
+            TransactionType.Deposit,
+            dg.depositGroupId,
+          ),
+          depositGroupId: dg.depositGroupId,
+          ...(dg.lastError ? { error: dg.lastError } : {}),
+        });
+      });
+
       tx.iter(Stores.purchases).forEachAsync(async (pr) => {
         if (
           shouldSkipCurrency(
diff --git a/packages/taler-wallet-core/src/types/cryptoTypes.ts 
b/packages/taler-wallet-core/src/types/cryptoTypes.ts
index eb18d83f..9b67b596 100644
--- a/packages/taler-wallet-core/src/types/cryptoTypes.ts
+++ b/packages/taler-wallet-core/src/types/cryptoTypes.ts
@@ -131,3 +131,11 @@ export interface DerivedTipPlanchet {
   coinPriv: string;
   coinPub: string;
 }
+
+export interface SignTrackTransactionRequest {
+  contractTermsHash: string;
+  wireHash: string;
+  coinPub: string;
+  merchantPriv: string;
+  merchantPub: string;
+}
diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts 
b/packages/taler-wallet-core/src/types/dbTypes.ts
index e0d13753..bc7d7728 100644
--- a/packages/taler-wallet-core/src/types/dbTypes.ts
+++ b/packages/taler-wallet-core/src/types/dbTypes.ts
@@ -32,6 +32,7 @@ import {
   Product,
   InternationalizedString,
   AmountString,
+  ContractTerms,
 } from "./talerTypes";
 
 import { Index, Store } from "../util/query";
@@ -1481,6 +1482,54 @@ export interface BackupProviderRecord {
   lastError: TalerErrorDetails | undefined;
 }
 
+/**
+ * Group of deposits made by the wallet.
+ */
+export interface DepositGroupRecord {
+  depositGroupId: string;
+
+  merchantPub: string;
+  merchantPriv: string;
+
+  noncePriv: string;
+  noncePub: string;
+
+  /**
+   * Wire information used by all deposits in this
+   * deposit group.
+   */
+  wire: {
+    payto_uri: string;
+    salt: string;
+  };
+
+  /**
+   * Verbatim contract terms.
+   */
+  contractTermsRaw: ContractTerms;
+
+  contractTermsHash: string;
+
+  payCoinSelection: PayCoinSelection;
+
+  totalPayCost: AmountJson;
+
+  effectiveDepositAmount: AmountJson;
+
+  depositedPerCoin: boolean[];
+
+  timestampCreated: Timestamp;
+
+  timestampFinished: Timestamp | undefined;
+
+  lastError: TalerErrorDetails | undefined;
+
+  /**
+   * Retry info.
+   */
+  retryInfo: RetryInfo;
+}
+
 class ExchangesStore extends Store<"exchanges", ExchangeRecord> {
   constructor() {
     super("exchanges", { keyPath: "baseUrl" });
@@ -1657,6 +1706,12 @@ class BackupProvidersStore extends Store<
   }
 }
 
+class DepositGroupsStore extends Store<"depositGroups", DepositGroupRecord> {
+  constructor() {
+    super("depositGroups", { keyPath: "depositGroupId" });
+  }
+}
+
 /**
  * The stores and indices for the wallet database.
  */
@@ -1683,6 +1738,7 @@ export const Stores = {
   planchets: new PlanchetsStore(),
   bankWithdrawUris: new BankWithdrawUrisStore(),
   backupProviders: new BackupProvidersStore(),
+  depositGroups: new DepositGroupsStore(),
 };
 
 export class MetaConfigStore extends Store<"metaConfig", ConfigRecord<any>> {
diff --git a/packages/taler-wallet-core/src/types/notifications.ts 
b/packages/taler-wallet-core/src/types/notifications.ts
index 8601c65b..edfb377b 100644
--- a/packages/taler-wallet-core/src/types/notifications.ts
+++ b/packages/taler-wallet-core/src/types/notifications.ts
@@ -60,6 +60,7 @@ export enum NotificationType {
   PendingOperationProcessed = "pending-operation-processed",
   ProposalRefused = "proposal-refused",
   ReserveRegisteredWithBank = "reserve-registered-with-bank",
+  DepositOperationError = "deposit-operation-error",
 }
 
 export interface ProposalAcceptedNotification {
@@ -193,6 +194,11 @@ export interface RecoupOperationErrorNotification {
   error: TalerErrorDetails;
 }
 
+export interface DepositOperationErrorNotification {
+  type: NotificationType.DepositOperationError;
+  error: TalerErrorDetails;
+}
+
 export interface ReserveOperationErrorNotification {
   type: NotificationType.ReserveOperationError;
   error: TalerErrorDetails;
@@ -256,6 +262,7 @@ export type WalletNotification =
   | WithdrawalGroupCreatedNotification
   | CoinWithdrawnNotification
   | RecoupOperationErrorNotification
+  | DepositOperationErrorNotification
   | InternalErrorNotification
   | PendingOperationProcessedNotification
   | ProposalRefusedNotification
diff --git a/packages/taler-wallet-core/src/types/pendingTypes.ts 
b/packages/taler-wallet-core/src/types/pendingTypes.ts
index 18d9a2fa..d41d2a97 100644
--- a/packages/taler-wallet-core/src/types/pendingTypes.ts
+++ b/packages/taler-wallet-core/src/types/pendingTypes.ts
@@ -40,6 +40,7 @@ export enum PendingOperationType {
   TipChoice = "tip-choice",
   TipPickup = "tip-pickup",
   Withdraw = "withdraw",
+  Deposit = "deposit",
 }
 
 /**
@@ -60,6 +61,7 @@ export type PendingOperationInfo = PendingOperationInfoCommon 
&
     | PendingTipPickupOperation
     | PendingWithdrawOperation
     | PendingRecoupOperation
+    | PendingDepositOperation
   );
 
 /**
@@ -227,6 +229,16 @@ export interface PendingWithdrawOperation {
   numCoinsTotal: number;
 }
 
+/**
+ * Status of an ongoing deposit operation.
+ */
+export interface PendingDepositOperation {
+  type: PendingOperationType.Deposit;
+  lastError: TalerErrorDetails | undefined;
+  retryInfo: RetryInfo;
+  depositGroupId: string;
+}
+
 /**
  * Fields that are present in every pending operation.
  */
diff --git a/packages/taler-wallet-core/src/types/talerTypes.ts 
b/packages/taler-wallet-core/src/types/talerTypes.ts
index 80aa1fe3..f3749afe 100644
--- a/packages/taler-wallet-core/src/types/talerTypes.ts
+++ b/packages/taler-wallet-core/src/types/talerTypes.ts
@@ -484,7 +484,7 @@ export class ContractTerms {
   /**
    * Extra data, interpreted by the mechant only.
    */
-  extra: any;
+  extra?: any;
 }
 
 /**
diff --git a/packages/taler-wallet-core/src/types/transactionsTypes.ts 
b/packages/taler-wallet-core/src/types/transactionsTypes.ts
index 0a683f29..81dc7803 100644
--- a/packages/taler-wallet-core/src/types/transactionsTypes.ts
+++ b/packages/taler-wallet-core/src/types/transactionsTypes.ts
@@ -94,7 +94,8 @@ export type Transaction =
   | TransactionPayment
   | TransactionRefund
   | TransactionTip
-  | TransactionRefresh;
+  | TransactionRefresh
+  | TransactionDeposit;
 
 export enum TransactionType {
   Withdrawal = "withdrawal",
@@ -102,6 +103,7 @@ export enum TransactionType {
   Refund = "refund",
   Refresh = "refresh",
   Tip = "tip",
+  Deposit = "deposit",
 }
 
 export enum WithdrawalType {
@@ -308,6 +310,31 @@ interface TransactionRefresh extends TransactionCommon {
   amountEffective: AmountString;
 }
 
+/**
+ * Deposit transaction, which effectively sends
+ * money from this wallet somewhere else.
+ */
+interface TransactionDeposit extends TransactionCommon {
+  type: TransactionType.Deposit;
+
+  depositGroupId: string;
+
+  /**
+   * Target for the deposit.
+   */
+  targetPaytoUri: string;
+
+  /**
+   * Raw amount that is being deposited
+   */
+  amountRaw: AmountString;
+
+  /**
+   * Effective amount that is being deposited
+   */
+  amountEffective: AmountString;
+}
+
 export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>
   buildCodecForObject<TransactionsRequest>()
     .property("currency", codecOptional(codecForString()))
diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts 
b/packages/taler-wallet-core/src/types/walletTypes.ts
index 235ea11f..f195918a 100644
--- a/packages/taler-wallet-core/src/types/walletTypes.ts
+++ b/packages/taler-wallet-core/src/types/walletTypes.ts
@@ -1006,3 +1006,38 @@ export const codecForAbortPayWithRefundRequest = (): 
Codec<
   buildCodecForObject<AbortPayWithRefundRequest>()
     .property("proposalId", codecForString())
     .build("AbortPayWithRefundRequest");
+
+export interface CreateDepositGroupRequest {
+  depositPaytoUri: string;
+  amount: string;
+}
+
+export const codecForCreateDepositGroupRequest = (): Codec<
+  CreateDepositGroupRequest
+> =>
+  buildCodecForObject<CreateDepositGroupRequest>()
+    .property("amount", codecForAmountString())
+    .property("depositPaytoUri", codecForString())
+    .build("CreateDepositGroupRequest");
+
+export interface CreateDepositGroupResponse {
+  depositGroupId: string;
+}
+
+export interface TrackDepositGroupRequest {
+  depositGroupId: string;
+}
+
+export interface TrackDepositGroupResponse {
+  responses: {
+    status: number;
+    body: any;
+  }[];
+}
+
+export const codecForTrackDepositGroupRequest = (): Codec<
+  TrackDepositGroupRequest
+> =>
+  buildCodecForObject<TrackDepositGroupRequest>()
+    .property("depositGroupId", codecForAmountString())
+    .build("TrackDepositGroupRequest");
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index 65b816cc..51987c34 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -53,6 +53,7 @@ import {
   CoinSourceType,
   RefundState,
   MetaStores,
+  DepositGroupRecord,
 } from "./types/dbTypes";
 import { CoinDumpJson, WithdrawUriInfoResponse } from "./types/talerTypes";
 import {
@@ -96,6 +97,12 @@ import {
   codecForAbortPayWithRefundRequest,
   ApplyRefundResponse,
   RecoveryLoadRequest,
+  codecForCreateDepositGroupRequest,
+  CreateDepositGroupRequest,
+  CreateDepositGroupResponse,
+  codecForTrackDepositGroupRequest,
+  TrackDepositGroupRequest,
+  TrackDepositGroupResponse,
 } from "./types/walletTypes";
 import { Logger } from "./util/logging";
 
@@ -173,6 +180,11 @@ import {
   BackupInfo,
   loadBackupRecovery,
 } from "./operations/backup";
+import {
+  createDepositGroup,
+  processDepositGroup,
+  trackDepositGroup,
+} from "./operations/deposits";
 
 const builtinCurrencies: CurrencyRecord[] = [
   {
@@ -299,6 +311,9 @@ export class Wallet {
       case PendingOperationType.ExchangeCheckRefresh:
         await autoRefresh(this.ws, pending.exchangeBaseUrl);
         break;
+      case PendingOperationType.Deposit:
+        await processDepositGroup(this.ws, pending.depositGroupId);
+        break;
       default:
         assertUnreachable(pending);
     }
@@ -972,6 +987,12 @@ export class Wallet {
     return addBackupProvider(this.ws, req);
   }
 
+  async createDepositGroup(
+    req: CreateDepositGroupRequest,
+  ): Promise<CreateDepositGroupResponse> {
+    return createDepositGroup(this.ws, req);
+  }
+
   async runBackupCycle(): Promise<void> {
     return runBackupCycle(this.ws);
   }
@@ -980,6 +1001,12 @@ export class Wallet {
     return getBackupInfo(this.ws);
   }
 
+  async trackDepositGroup(
+    req: TrackDepositGroupRequest,
+  ): Promise<TrackDepositGroupResponse> {
+    return trackDepositGroup(this.ws, req);
+  }
+
   /**
    * Implementation of the "wallet-core" API.
    */
@@ -1141,6 +1168,13 @@ export class Wallet {
         await runBackupCycle(this.ws);
         return {};
       }
+      case "createDepositGroup": {
+        const req = codecForCreateDepositGroupRequest().decode(payload);
+        return await createDepositGroup(this.ws, req);
+      }
+      case "trackDepositGroup":
+        const req = codecForTrackDepositGroupRequest().decode(payload);
+        return trackDepositGroup(this.ws, req);
     }
     throw OperationFailedError.fromCode(
       TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
diff --git a/packages/taler-wallet-webextension/src/pages/popup.tsx 
b/packages/taler-wallet-webextension/src/pages/popup.tsx
index 8d8d5a85..9c8a8f75 100644
--- a/packages/taler-wallet-webextension/src/pages/popup.tsx
+++ b/packages/taler-wallet-webextension/src/pages/popup.tsx
@@ -457,6 +457,18 @@ function TransactionItem(props: { tx: Transaction }): 
JSX.Element {
           pending={tx.pending}
         ></TransactionLayout>
       );
+    case TransactionType.Deposit:
+      return (
+        <TransactionLayout
+          amount={tx.amountEffective}
+          debitCreditIndicator={"debit"}
+          title="Refresh"
+          subtitle={`to ${tx.targetPaytoUri}`}
+          timestamp={tx.timestamp}
+          iconPath="/static/img/ri-refresh-line.svg"
+          pending={tx.pending}
+        ></TransactionLayout>
+      );
   }
 }
 

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