gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] branch master updated: wallet-core: restructure p2p


From: gnunet
Subject: [taler-wallet-core] branch master updated: wallet-core: restructure p2p impl
Date: Mon, 05 Jun 2023 11:45:19 +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 fda5a0ed8 wallet-core: restructure p2p impl
fda5a0ed8 is described below

commit fda5a0ed87a6473a6b34bd1ac07d5f1d45dfbc19
Author: Florian Dold <florian@dold.me>
AuthorDate: Mon Jun 5 11:45:16 2023 +0200

    wallet-core: restructure p2p impl
---
 .../src/operations/pay-peer-common.ts              |  463 +++
 .../src/operations/pay-peer-pull-credit.ts         |  910 ++++++
 .../src/operations/pay-peer-pull-debit.ts          |  604 ++++
 .../src/operations/pay-peer-push-credit.ts         |  770 +++++
 .../src/operations/pay-peer-push-debit.ts          |  742 +++++
 .../taler-wallet-core/src/operations/pay-peer.ts   | 3226 --------------------
 .../taler-wallet-core/src/operations/testing.ts    |   12 +-
 .../src/operations/transactions.ts                 |   30 +-
 packages/taler-wallet-core/src/wallet.ts           |   24 +-
 9 files changed, 3511 insertions(+), 3270 deletions(-)

diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts 
b/packages/taler-wallet-core/src/operations/pay-peer-common.ts
new file mode 100644
index 000000000..4b1dd31a5
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts
@@ -0,0 +1,463 @@
+/*
+ This file is part of GNU Taler
+ (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
+ 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 {
+  AgeCommitmentProof,
+  AmountJson,
+  AmountString,
+  Amounts,
+  Codec,
+  CoinPublicKeyString,
+  CoinStatus,
+  Logger,
+  PayPeerInsufficientBalanceDetails,
+  TalerProtocolTimestamp,
+  UnblindedSignature,
+  buildCodecForObject,
+  codecForAmountString,
+  codecForTimestamp,
+  codecOptional,
+  strcmp,
+} from "@gnu-taler/taler-util";
+import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
+import {
+  DenominationRecord,
+  PeerPushPaymentCoinSelection,
+  ReserveRecord,
+} from "../db.js";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import { checkDbInvariant } from "../util/invariants.js";
+import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
+import { getTotalRefreshCost } from "./refresh.js";
+
+const logger = new Logger("operations/peer-to-peer.ts");
+
+interface SelectedPeerCoin {
+  coinPub: string;
+  coinPriv: string;
+  contribution: AmountString;
+  denomPubHash: string;
+  denomSig: UnblindedSignature;
+  ageCommitmentProof: AgeCommitmentProof | undefined;
+}
+
+interface PeerCoinSelectionDetails {
+  exchangeBaseUrl: string;
+
+  /**
+   * Info of Coins that were selected.
+   */
+  coins: SelectedPeerCoin[];
+
+  /**
+   * How much of the deposit fees is the customer paying?
+   */
+  depositFees: AmountJson;
+}
+
+/**
+ * Information about a selected coin for peer to peer payments.
+ */
+interface CoinInfo {
+  /**
+   * Public key of the coin.
+   */
+  coinPub: string;
+
+  coinPriv: string;
+
+  /**
+   * Deposit fee for the coin.
+   */
+  feeDeposit: AmountJson;
+
+  value: AmountJson;
+
+  denomPubHash: string;
+
+  denomSig: UnblindedSignature;
+
+  maxAge: number;
+
+  ageCommitmentProof?: AgeCommitmentProof;
+}
+
+export type SelectPeerCoinsResult =
+  | { type: "success"; result: PeerCoinSelectionDetails }
+  | {
+      type: "failure";
+      insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
+    };
+
+export async function queryCoinInfosForSelection(
+  ws: InternalWalletState,
+  csel: PeerPushPaymentCoinSelection,
+): Promise<SpendCoinDetails[]> {
+  let infos: SpendCoinDetails[] = [];
+  await ws.db
+    .mktx((x) => [x.coins, x.denominations])
+    .runReadOnly(async (tx) => {
+      for (let i = 0; i < csel.coinPubs.length; i++) {
+        const coin = await tx.coins.get(csel.coinPubs[i]);
+        if (!coin) {
+          throw Error("coin not found anymore");
+        }
+        const denom = await ws.getDenomInfo(
+          ws,
+          tx,
+          coin.exchangeBaseUrl,
+          coin.denomPubHash,
+        );
+        if (!denom) {
+          throw Error("denom for coin not found anymore");
+        }
+        infos.push({
+          coinPriv: coin.coinPriv,
+          coinPub: coin.coinPub,
+          denomPubHash: coin.denomPubHash,
+          denomSig: coin.denomSig,
+          ageCommitmentProof: coin.ageCommitmentProof,
+          contribution: csel.contributions[i],
+        });
+      }
+    });
+  return infos;
+}
+
+export interface PeerCoinSelectionRequest {
+  instructedAmount: AmountJson;
+
+  /**
+   * Instruct the coin selection to repair this coin
+   * selection instead of selecting completely new coins.
+   */
+  repair?: {
+    exchangeBaseUrl: string;
+    coinPubs: CoinPublicKeyString[];
+    contribs: AmountJson[];
+  };
+}
+
+export async function selectPeerCoins(
+  ws: InternalWalletState,
+  req: PeerCoinSelectionRequest,
+): Promise<SelectPeerCoinsResult> {
+  const instructedAmount = req.instructedAmount;
+  if (Amounts.isZero(instructedAmount)) {
+    // Other parts of the code assume that we have at least
+    // one coin to spend.
+    throw new Error("amount of zero not allowed");
+  }
+  return await ws.db
+    .mktx((x) => [
+      x.exchanges,
+      x.contractTerms,
+      x.coins,
+      x.coinAvailability,
+      x.denominations,
+      x.refreshGroups,
+      x.peerPushPaymentInitiations,
+    ])
+    .runReadWrite(async (tx) => {
+      const exchanges = await tx.exchanges.iter().toArray();
+      const exchangeFeeGap: { [url: string]: AmountJson } = {};
+      const currency = Amounts.currencyOf(instructedAmount);
+      for (const exch of exchanges) {
+        if (exch.detailsPointer?.currency !== currency) {
+          continue;
+        }
+        // FIXME: Can't we do this faster by using coinAvailability?
+        const coins = (
+          await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
+        ).filter((x) => x.status === CoinStatus.Fresh);
+        const coinInfos: CoinInfo[] = [];
+        for (const coin of coins) {
+          const denom = await ws.getDenomInfo(
+            ws,
+            tx,
+            coin.exchangeBaseUrl,
+            coin.denomPubHash,
+          );
+          if (!denom) {
+            throw Error("denom not found");
+          }
+          coinInfos.push({
+            coinPub: coin.coinPub,
+            feeDeposit: Amounts.parseOrThrow(denom.feeDeposit),
+            value: Amounts.parseOrThrow(denom.value),
+            denomPubHash: denom.denomPubHash,
+            coinPriv: coin.coinPriv,
+            denomSig: coin.denomSig,
+            maxAge: coin.maxAge,
+            ageCommitmentProof: coin.ageCommitmentProof,
+          });
+        }
+        if (coinInfos.length === 0) {
+          continue;
+        }
+        coinInfos.sort(
+          (o1, o2) =>
+            -Amounts.cmp(o1.value, o2.value) ||
+            strcmp(o1.denomPubHash, o2.denomPubHash),
+        );
+        let amountAcc = Amounts.zeroOfCurrency(currency);
+        let depositFeesAcc = Amounts.zeroOfCurrency(currency);
+        const resCoins: {
+          coinPub: string;
+          coinPriv: string;
+          contribution: AmountString;
+          denomPubHash: string;
+          denomSig: UnblindedSignature;
+          ageCommitmentProof: AgeCommitmentProof | undefined;
+        }[] = [];
+        let lastDepositFee = Amounts.zeroOfCurrency(currency);
+
+        if (req.repair) {
+          for (let i = 0; i < req.repair.coinPubs.length; i++) {
+            const contrib = req.repair.contribs[i];
+            const coin = await tx.coins.get(req.repair.coinPubs[i]);
+            if (!coin) {
+              throw Error("repair not possible, coin not found");
+            }
+            const denom = await ws.getDenomInfo(
+              ws,
+              tx,
+              coin.exchangeBaseUrl,
+              coin.denomPubHash,
+            );
+            checkDbInvariant(!!denom);
+            resCoins.push({
+              coinPriv: coin.coinPriv,
+              coinPub: coin.coinPub,
+              contribution: Amounts.stringify(contrib),
+              denomPubHash: coin.denomPubHash,
+              denomSig: coin.denomSig,
+              ageCommitmentProof: coin.ageCommitmentProof,
+            });
+            const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
+            lastDepositFee = depositFee;
+            amountAcc = Amounts.add(
+              amountAcc,
+              Amounts.sub(contrib, depositFee).amount,
+            ).amount;
+            depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount;
+          }
+        }
+
+        for (const coin of coinInfos) {
+          if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
+            break;
+          }
+          const gap = Amounts.add(
+            coin.feeDeposit,
+            Amounts.sub(instructedAmount, amountAcc).amount,
+          ).amount;
+          const contrib = Amounts.min(gap, coin.value);
+          amountAcc = Amounts.add(
+            amountAcc,
+            Amounts.sub(contrib, coin.feeDeposit).amount,
+          ).amount;
+          depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
+          resCoins.push({
+            coinPriv: coin.coinPriv,
+            coinPub: coin.coinPub,
+            contribution: Amounts.stringify(contrib),
+            denomPubHash: coin.denomPubHash,
+            denomSig: coin.denomSig,
+            ageCommitmentProof: coin.ageCommitmentProof,
+          });
+          lastDepositFee = coin.feeDeposit;
+        }
+        if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
+          const res: PeerCoinSelectionDetails = {
+            exchangeBaseUrl: exch.baseUrl,
+            coins: resCoins,
+            depositFees: depositFeesAcc,
+          };
+          return { type: "success", result: res };
+        }
+        const diff = Amounts.sub(instructedAmount, amountAcc).amount;
+        exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, 
diff).amount;
+
+        continue;
+      }
+
+      // We were unable to select coins.
+      // Now we need to produce error details.
+
+      const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
+        currency,
+      });
+
+      const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
+
+      let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
+
+      for (const exch of exchanges) {
+        if (exch.detailsPointer?.currency !== currency) {
+          continue;
+        }
+        const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
+          currency,
+          restrictExchangeTo: exch.baseUrl,
+        });
+        let gap =
+          exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
+        if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
+          // Show fee gap only if we should've been able to pay with the 
material amount
+          gap = Amounts.zeroOfCurrency(currency);
+        }
+        perExchange[exch.baseUrl] = {
+          balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
+          balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
+          feeGapEstimate: Amounts.stringify(gap),
+        };
+
+        maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
+      }
+
+      const errDetails: PayPeerInsufficientBalanceDetails = {
+        amountRequested: Amounts.stringify(instructedAmount),
+        balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
+        balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
+        feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
+        perExchange,
+      };
+
+      return { type: "failure", insufficientBalanceDetails: errDetails };
+    });
+}
+
+export async function getTotalPeerPaymentCost(
+  ws: InternalWalletState,
+  pcs: SelectedPeerCoin[],
+): Promise<AmountJson> {
+  return ws.db
+    .mktx((x) => [x.coins, x.denominations])
+    .runReadOnly(async (tx) => {
+      const costs: AmountJson[] = [];
+      for (let i = 0; i < pcs.length; i++) {
+        const coin = await tx.coins.get(pcs[i].coinPub);
+        if (!coin) {
+          throw Error("can't calculate payment cost, coin not found");
+        }
+        const denom = await tx.denominations.get([
+          coin.exchangeBaseUrl,
+          coin.denomPubHash,
+        ]);
+        if (!denom) {
+          throw Error(
+            "can't calculate payment cost, denomination for coin not found",
+          );
+        }
+        const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+          .iter(coin.exchangeBaseUrl)
+          .filter((x) =>
+            Amounts.isSameCurrency(
+              DenominationRecord.getValue(x),
+              pcs[i].contribution,
+            ),
+          );
+        const amountLeft = Amounts.sub(
+          DenominationRecord.getValue(denom),
+          pcs[i].contribution,
+        ).amount;
+        const refreshCost = getTotalRefreshCost(
+          allDenoms,
+          DenominationRecord.toDenomInfo(denom),
+          amountLeft,
+          ws.config.testing.denomselAllowLate,
+        );
+        costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+        costs.push(refreshCost);
+      }
+      const zero = Amounts.zeroOfAmount(pcs[0].contribution);
+      return Amounts.sum([zero, ...costs]).amount;
+    });
+}
+
+interface ExchangePurseStatus {
+  balance: AmountString;
+  deposit_timestamp?: TalerProtocolTimestamp;
+  merge_timestamp?: TalerProtocolTimestamp;
+}
+
+export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
+  buildCodecForObject<ExchangePurseStatus>()
+    .property("balance", codecForAmountString())
+    .property("deposit_timestamp", codecOptional(codecForTimestamp))
+    .property("merge_timestamp", codecOptional(codecForTimestamp))
+    .build("ExchangePurseStatus");
+
+export function talerPaytoFromExchangeReserve(
+  exchangeBaseUrl: string,
+  reservePub: string,
+): string {
+  const url = new URL(exchangeBaseUrl);
+  let proto: string;
+  if (url.protocol === "http:") {
+    proto = "taler-reserve-http";
+  } else if (url.protocol === "https:") {
+    proto = "taler-reserve";
+  } else {
+    throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
+  }
+
+  let path = url.pathname;
+  if (!path.endsWith("/")) {
+    path = path + "/";
+  }
+
+  return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
+}
+
+export async function getMergeReserveInfo(
+  ws: InternalWalletState,
+  req: {
+    exchangeBaseUrl: string;
+  },
+): Promise<ReserveRecord> {
+  // 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 mergeReserveRecord: ReserveRecord = await ws.db
+    .mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups])
+    .runReadWrite(async (tx) => {
+      const ex = await tx.exchanges.get(req.exchangeBaseUrl);
+      checkDbInvariant(!!ex);
+      if (ex.currentMergeReserveRowId != null) {
+        const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
+        checkDbInvariant(!!reserve);
+        return reserve;
+      }
+      const reserve: ReserveRecord = {
+        reservePriv: newReservePair.priv,
+        reservePub: newReservePair.pub,
+      };
+      const insertResp = await tx.reserves.put(reserve);
+      checkDbInvariant(typeof insertResp.key === "number");
+      reserve.rowId = insertResp.key;
+      ex.currentMergeReserveRowId = reserve.rowId;
+      await tx.exchanges.put(ex);
+      return reserve;
+    });
+
+  return mergeReserveRecord;
+}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts 
b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
new file mode 100644
index 000000000..b9c9728a1
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
@@ -0,0 +1,910 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 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 {
+  AbsoluteTime,
+  Amounts,
+  CancellationToken,
+  CheckPeerPullCreditRequest,
+  CheckPeerPullCreditResponse,
+  ContractTermsUtil,
+  ExchangeReservePurseRequest,
+  HttpStatusCode,
+  InitiatePeerPullCreditRequest,
+  InitiatePeerPullCreditResponse,
+  Logger,
+  TalerPreciseTimestamp,
+  TransactionAction,
+  TransactionMajorState,
+  TransactionMinorState,
+  TransactionState,
+  TransactionType,
+  WalletAccountMergeFlags,
+  codecForAny,
+  codecForWalletKycUuid,
+  constructPayPullUri,
+  encodeCrock,
+  getRandomBytes,
+  j2s,
+} from "@gnu-taler/taler-util";
+import {
+  readSuccessResponseJsonOrErrorCode,
+  readSuccessResponseJsonOrThrow,
+  throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+  PeerPullPaymentInitiationRecord,
+  PeerPullPaymentInitiationStatus,
+  WithdrawalGroupStatus,
+  WithdrawalRecordType,
+  updateExchangeFromUrl,
+} from "../index.js";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import { PendingTaskType } from "../pending-types.js";
+import { assertUnreachable } from "../util/assertUnreachable.js";
+import { checkDbInvariant } from "../util/invariants.js";
+import {
+  OperationAttemptResult,
+  OperationAttemptResultType,
+  constructTaskIdentifier,
+} from "../util/retries.js";
+import {
+  LongpollResult,
+  resetOperationTimeout,
+  runLongpollAsync,
+  runOperationWithErrorReporting,
+} from "./common.js";
+import {
+  codecForExchangePurseStatus,
+  getMergeReserveInfo,
+  talerPaytoFromExchangeReserve,
+} from "./pay-peer-common.js";
+import {
+  constructTransactionIdentifier,
+  notifyTransition,
+  stopLongpolling,
+} from "./transactions.js";
+import {
+  checkWithdrawalKycStatus,
+  getExchangeWithdrawalInfo,
+  internalCreateWithdrawalGroup,
+  processWithdrawalGroup,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-pull-credit.ts");
+
+export async function queryPurseForPeerPullCredit(
+  ws: InternalWalletState,
+  pullIni: PeerPullPaymentInitiationRecord,
+  cancellationToken: CancellationToken,
+): Promise<LongpollResult> {
+  const purseDepositUrl = new URL(
+    `purses/${pullIni.pursePub}/deposit`,
+    pullIni.exchangeBaseUrl,
+  );
+  purseDepositUrl.searchParams.set("timeout_ms", "30000");
+  logger.info(`querying purse status via ${purseDepositUrl.href}`);
+  const resp = await ws.http.get(purseDepositUrl.href, {
+    timeout: { d_ms: 60000 },
+    cancellationToken,
+  });
+
+  logger.info(`purse status code: HTTP ${resp.status}`);
+
+  const result = await readSuccessResponseJsonOrErrorCode(
+    resp,
+    codecForExchangePurseStatus(),
+  );
+
+  if (result.isError) {
+    logger.info(`got purse status error, 
EC=${result.talerErrorResponse.code}`);
+    if (resp.status === 404) {
+      return { ready: false };
+    } else {
+      throwUnexpectedRequestError(resp, result.talerErrorResponse);
+    }
+  }
+
+  if (!result.response.deposit_timestamp) {
+    logger.info("purse not ready yet (no deposit)");
+    return { ready: false };
+  }
+
+  const reserve = await ws.db
+    .mktx((x) => [x.reserves])
+    .runReadOnly(async (tx) => {
+      return await tx.reserves.get(pullIni.mergeReserveRowId);
+    });
+
+  if (!reserve) {
+    throw Error("reserve for peer pull credit not found in wallet DB");
+  }
+
+  await internalCreateWithdrawalGroup(ws, {
+    amount: Amounts.parseOrThrow(pullIni.amount),
+    wgInfo: {
+      withdrawalType: WithdrawalRecordType.PeerPullCredit,
+      contractTerms: pullIni.contractTerms,
+      contractPriv: pullIni.contractPriv,
+    },
+    forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
+    exchangeBaseUrl: pullIni.exchangeBaseUrl,
+    reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+    reserveKeyPair: {
+      priv: reserve.reservePriv,
+      pub: reserve.reservePub,
+    },
+  });
+
+  await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const finPi = await tx.peerPullPaymentInitiations.get(pullIni.pursePub);
+      if (!finPi) {
+        logger.warn("peerPullPaymentInitiation not found anymore");
+        return;
+      }
+      if (finPi.status === PeerPullPaymentInitiationStatus.PendingReady) {
+        finPi.status = PeerPullPaymentInitiationStatus.DonePurseDeposited;
+      }
+      await tx.peerPullPaymentInitiations.put(finPi);
+    });
+  return {
+    ready: true,
+  };
+}
+
+export async function processPeerPullCredit(
+  ws: InternalWalletState,
+  pursePub: string,
+): Promise<OperationAttemptResult> {
+  const pullIni = await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations])
+    .runReadOnly(async (tx) => {
+      return tx.peerPullPaymentInitiations.get(pursePub);
+    });
+  if (!pullIni) {
+    throw Error("peer pull payment initiation not found in database");
+  }
+
+  const retryTag = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullCredit,
+    pursePub,
+  });
+
+  // We're already running!
+  if (ws.activeLongpoll[retryTag]) {
+    logger.info("peer-pull-credit already in long-polling, returning!");
+    return {
+      type: OperationAttemptResultType.Longpoll,
+    };
+  }
+
+  logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
+
+  switch (pullIni.status) {
+    case PeerPullPaymentInitiationStatus.DonePurseDeposited: {
+      // We implement this case so that the "retry" action on a 
peer-pull-credit transaction
+      // also retries the withdrawal task.
+
+      logger.warn(
+        "peer pull payment initiation is already finished, retrying 
withdrawal",
+      );
+
+      const withdrawalGroupId = pullIni.withdrawalGroupId;
+
+      if (withdrawalGroupId) {
+        const taskId = constructTaskIdentifier({
+          tag: PendingTaskType.Withdraw,
+          withdrawalGroupId,
+        });
+        stopLongpolling(ws, taskId);
+        await resetOperationTimeout(ws, taskId);
+        await runOperationWithErrorReporting(ws, taskId, () =>
+          processWithdrawalGroup(ws, withdrawalGroupId),
+        );
+      }
+      return {
+        type: OperationAttemptResultType.Finished,
+        result: undefined,
+      };
+    }
+    case PeerPullPaymentInitiationStatus.PendingReady:
+      runLongpollAsync(ws, retryTag, async (cancellationToken) =>
+        queryPurseForPeerPullCredit(ws, pullIni, cancellationToken),
+      );
+      logger.trace(
+        "returning early from processPeerPullCredit for long-polling in 
background",
+      );
+      return {
+        type: OperationAttemptResultType.Longpoll,
+      };
+    case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: {
+      const transactionId = constructTransactionIdentifier({
+        tag: TransactionType.PeerPullCredit,
+        pursePub: pullIni.pursePub,
+      });
+      if (pullIni.kycInfo) {
+        await checkWithdrawalKycStatus(
+          ws,
+          pullIni.exchangeBaseUrl,
+          transactionId,
+          pullIni.kycInfo,
+          "individual",
+        );
+      }
+      break;
+    }
+    case PeerPullPaymentInitiationStatus.PendingCreatePurse:
+      break;
+    default:
+      throw Error(`unknown PeerPullPaymentInitiationStatus ${pullIni.status}`);
+  }
+
+  const mergeReserve = await ws.db
+    .mktx((x) => [x.reserves])
+    .runReadOnly(async (tx) => {
+      return tx.reserves.get(pullIni.mergeReserveRowId);
+    });
+
+  if (!mergeReserve) {
+    throw Error("merge reserve for peer pull payment not found in database");
+  }
+
+  const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
+
+  const reservePayto = talerPaytoFromExchangeReserve(
+    pullIni.exchangeBaseUrl,
+    mergeReserve.reservePub,
+  );
+
+  const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
+    contractPriv: pullIni.contractPriv,
+    contractPub: pullIni.contractPub,
+    contractTerms: pullIni.contractTerms,
+    pursePriv: pullIni.pursePriv,
+    pursePub: pullIni.pursePub,
+  });
+
+  const purseExpiration = pullIni.contractTerms.purse_expiration;
+  const sigRes = await ws.cryptoApi.signReservePurseCreate({
+    contractTermsHash: pullIni.contractTermsHash,
+    flags: WalletAccountMergeFlags.CreateWithPurseFee,
+    mergePriv: pullIni.mergePriv,
+    mergeTimestamp: TalerPreciseTimestamp.round(pullIni.mergeTimestamp),
+    purseAmount: pullIni.contractTerms.amount,
+    purseExpiration: purseExpiration,
+    purseFee: purseFee,
+    pursePriv: pullIni.pursePriv,
+    pursePub: pullIni.pursePub,
+    reservePayto,
+    reservePriv: mergeReserve.reservePriv,
+  });
+
+  const reservePurseReqBody: ExchangeReservePurseRequest = {
+    merge_sig: sigRes.mergeSig,
+    merge_timestamp: TalerPreciseTimestamp.round(pullIni.mergeTimestamp),
+    h_contract_terms: pullIni.contractTermsHash,
+    merge_pub: pullIni.mergePub,
+    min_age: 0,
+    purse_expiration: purseExpiration,
+    purse_fee: purseFee,
+    purse_pub: pullIni.pursePub,
+    purse_sig: sigRes.purseSig,
+    purse_value: pullIni.contractTerms.amount,
+    reserve_sig: sigRes.accountSig,
+    econtract: econtractResp.econtract,
+  };
+
+  logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
+
+  const reservePurseMergeUrl = new URL(
+    `reserves/${mergeReserve.reservePub}/purse`,
+    pullIni.exchangeBaseUrl,
+  );
+
+  const httpResp = await ws.http.postJson(
+    reservePurseMergeUrl.href,
+    reservePurseReqBody,
+  );
+
+  if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+    const respJson = await httpResp.json();
+    const kycPending = codecForWalletKycUuid().decode(respJson);
+    logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+
+    await ws.db
+      .mktx((x) => [x.peerPullPaymentInitiations])
+      .runReadWrite(async (tx) => {
+        const peerIni = await tx.peerPullPaymentInitiations.get(pursePub);
+        if (!peerIni) {
+          return;
+        }
+        peerIni.kycInfo = {
+          paytoHash: kycPending.h_payto,
+          requirementRow: kycPending.requirement_row,
+        };
+        peerIni.status =
+          PeerPullPaymentInitiationStatus.PendingMergeKycRequired;
+        await tx.peerPullPaymentInitiations.put(peerIni);
+      });
+    return {
+      type: OperationAttemptResultType.Pending,
+      result: undefined,
+    };
+  }
+
+  const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
+
+  logger.info(`reserve merge response: ${j2s(resp)}`);
+
+  await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pi2 = await tx.peerPullPaymentInitiations.get(pursePub);
+      if (!pi2) {
+        return;
+      }
+      pi2.status = PeerPullPaymentInitiationStatus.PendingReady;
+      await tx.peerPullPaymentInitiations.put(pi2);
+    });
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
+}
+
+/**
+ * Check fees and available exchanges for a peer push payment initiation.
+ */
+export async function checkPeerPullPaymentInitiation(
+  ws: InternalWalletState,
+  req: CheckPeerPullCreditRequest,
+): Promise<CheckPeerPullCreditResponse> {
+  // FIXME: We don't support exchanges with purse fees yet.
+  // Select an exchange where we have money in the specified currency
+  // FIXME: How do we handle regional currency scopes here? Is it an 
additional input?
+
+  logger.trace("checking peer-pull-credit fees");
+
+  const currency = Amounts.currencyOf(req.amount);
+  let exchangeUrl;
+  if (req.exchangeBaseUrl) {
+    exchangeUrl = req.exchangeBaseUrl;
+  } else {
+    exchangeUrl = await getPreferredExchangeForCurrency(ws, currency);
+  }
+
+  if (!exchangeUrl) {
+    throw Error("no exchange found for initiating a peer pull payment");
+  }
+
+  logger.trace(`found ${exchangeUrl} as preferred exchange`);
+
+  const wi = await getExchangeWithdrawalInfo(
+    ws,
+    exchangeUrl,
+    Amounts.parseOrThrow(req.amount),
+    undefined,
+  );
+
+  logger.trace(`got withdrawal info`);
+
+  return {
+    exchangeBaseUrl: exchangeUrl,
+    amountEffective: wi.withdrawalAmountEffective,
+    amountRaw: req.amount,
+  };
+}
+
+/**
+ * Find a preferred exchange based on when we withdrew last from this exchange.
+ */
+async function getPreferredExchangeForCurrency(
+  ws: InternalWalletState,
+  currency: string,
+): Promise<string | undefined> {
+  // Find an exchange with the matching currency.
+  // Prefer exchanges with the most recent withdrawal.
+  const url = await ws.db
+    .mktx((x) => [x.exchanges])
+    .runReadOnly(async (tx) => {
+      const exchanges = await tx.exchanges.iter().toArray();
+      let candidate = undefined;
+      for (const e of exchanges) {
+        if (e.detailsPointer?.currency !== currency) {
+          continue;
+        }
+        if (!candidate) {
+          candidate = e;
+          continue;
+        }
+        if (candidate.lastWithdrawal && !e.lastWithdrawal) {
+          continue;
+        }
+        if (candidate.lastWithdrawal && e.lastWithdrawal) {
+          if (
+            AbsoluteTime.cmp(
+              AbsoluteTime.fromPreciseTimestamp(e.lastWithdrawal),
+              AbsoluteTime.fromPreciseTimestamp(candidate.lastWithdrawal),
+            ) > 0
+          ) {
+            candidate = e;
+          }
+        }
+      }
+      if (candidate) {
+        return candidate.baseUrl;
+      }
+      return undefined;
+    });
+  return url;
+}
+
+/**
+ * Initiate a peer pull payment.
+ */
+export async function initiatePeerPullPayment(
+  ws: InternalWalletState,
+  req: InitiatePeerPullCreditRequest,
+): Promise<InitiatePeerPullCreditResponse> {
+  const currency = Amounts.currencyOf(req.partialContractTerms.amount);
+  let maybeExchangeBaseUrl: string | undefined;
+  if (req.exchangeBaseUrl) {
+    maybeExchangeBaseUrl = req.exchangeBaseUrl;
+  } else {
+    maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency);
+  }
+
+  if (!maybeExchangeBaseUrl) {
+    throw Error("no exchange found for initiating a peer pull payment");
+  }
+
+  const exchangeBaseUrl = maybeExchangeBaseUrl;
+
+  await updateExchangeFromUrl(ws, exchangeBaseUrl);
+
+  const mergeReserveInfo = await getMergeReserveInfo(ws, {
+    exchangeBaseUrl: exchangeBaseUrl,
+  });
+
+  const mergeTimestamp = TalerPreciseTimestamp.now();
+
+  const pursePair = await ws.cryptoApi.createEddsaKeypair({});
+  const mergePair = await ws.cryptoApi.createEddsaKeypair({});
+
+  const contractTerms = req.partialContractTerms;
+
+  const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+  const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
+
+  const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+  const mergeReserveRowId = mergeReserveInfo.rowId;
+  checkDbInvariant(!!mergeReserveRowId);
+
+  const wi = await getExchangeWithdrawalInfo(
+    ws,
+    exchangeBaseUrl,
+    Amounts.parseOrThrow(req.partialContractTerms.amount),
+    undefined,
+  );
+
+  await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations, x.contractTerms])
+    .runReadWrite(async (tx) => {
+      await tx.peerPullPaymentInitiations.put({
+        amount: req.partialContractTerms.amount,
+        contractTermsHash: hContractTerms,
+        exchangeBaseUrl: exchangeBaseUrl,
+        pursePriv: pursePair.priv,
+        pursePub: pursePair.pub,
+        mergePriv: mergePair.priv,
+        mergePub: mergePair.pub,
+        status: PeerPullPaymentInitiationStatus.PendingCreatePurse,
+        contractTerms: contractTerms,
+        mergeTimestamp,
+        mergeReserveRowId: mergeReserveRowId,
+        contractPriv: contractKeyPair.priv,
+        contractPub: contractKeyPair.pub,
+        withdrawalGroupId,
+        estimatedAmountEffective: wi.withdrawalAmountEffective,
+      });
+      await tx.contractTerms.put({
+        contractTermsRaw: contractTerms,
+        h: hContractTerms,
+      });
+    });
+
+  // FIXME: Should we somehow signal to the client
+  // whether purse creation has failed, or does the client/
+  // check this asynchronously from the transaction status?
+
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullCredit,
+    pursePub: pursePair.pub,
+  });
+
+  await runOperationWithErrorReporting(ws, taskId, async () => {
+    return processPeerPullCredit(ws, pursePair.pub);
+  });
+
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullCredit,
+    pursePub: pursePair.pub,
+  });
+
+  return {
+    talerUri: constructPayPullUri({
+      exchangeBaseUrl: exchangeBaseUrl,
+      contractPriv: contractKeyPair.priv,
+    }),
+    transactionId,
+  };
+}
+
+export async function suspendPeerPullCreditTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullCredit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullCredit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
+      if (!pullCreditRec) {
+        logger.warn(`peer pull credit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
+      switch (pullCreditRec.status) {
+        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
+          newStatus = PeerPullPaymentInitiationStatus.SuspendedCreatePurse;
+          break;
+        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
+          newStatus = 
PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired;
+          break;
+        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
+          newStatus = PeerPullPaymentInitiationStatus.SuspendedWithdrawing;
+          break;
+        case PeerPullPaymentInitiationStatus.PendingReady:
+          newStatus = PeerPullPaymentInitiationStatus.SuspendedReady;
+          break;
+        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
+          newStatus =
+            PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse;
+          break;
+        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
+        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
+        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
+        case PeerPullPaymentInitiationStatus.SuspendedReady:
+        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
+        case PeerPullPaymentInitiationStatus.Aborted:
+        case PeerPullPaymentInitiationStatus.Failed:
+        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+          break;
+        default:
+          assertUnreachable(pullCreditRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        pullCreditRec.status = newStatus;
+        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        await tx.peerPullPaymentInitiations.put(pullCreditRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function abortPeerPullCreditTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullCredit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullCredit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
+      if (!pullCreditRec) {
+        logger.warn(`peer pull credit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
+      switch (pullCreditRec.status) {
+        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
+        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
+          newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
+          break;
+        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
+          throw Error("can't abort anymore");
+        case PeerPullPaymentInitiationStatus.PendingReady:
+          newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
+          break;
+        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
+        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
+        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
+        case PeerPullPaymentInitiationStatus.SuspendedReady:
+        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
+        case PeerPullPaymentInitiationStatus.Aborted:
+        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
+        case PeerPullPaymentInitiationStatus.Failed:
+        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+          break;
+        default:
+          assertUnreachable(pullCreditRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        pullCreditRec.status = newStatus;
+        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        await tx.peerPullPaymentInitiations.put(pullCreditRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function failPeerPullCreditTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullCredit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullCredit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
+      if (!pullCreditRec) {
+        logger.warn(`peer pull credit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
+      switch (pullCreditRec.status) {
+        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
+        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
+        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
+        case PeerPullPaymentInitiationStatus.PendingReady:
+        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
+        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
+        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
+        case PeerPullPaymentInitiationStatus.SuspendedReady:
+        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
+        case PeerPullPaymentInitiationStatus.Aborted:
+        case PeerPullPaymentInitiationStatus.Failed:
+          break;
+        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
+        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+          newStatus = PeerPullPaymentInitiationStatus.Failed;
+          break;
+        default:
+          assertUnreachable(pullCreditRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        pullCreditRec.status = newStatus;
+        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        await tx.peerPullPaymentInitiations.put(pullCreditRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function resumePeerPullCreditTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullCredit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullCredit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
+      if (!pullCreditRec) {
+        logger.warn(`peer pull credit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
+      switch (pullCreditRec.status) {
+        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
+        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
+        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
+        case PeerPullPaymentInitiationStatus.PendingReady:
+        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
+        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
+        case PeerPullPaymentInitiationStatus.Failed:
+        case PeerPullPaymentInitiationStatus.Aborted:
+          break;
+        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
+          newStatus = PeerPullPaymentInitiationStatus.PendingCreatePurse;
+          break;
+        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
+          newStatus = PeerPullPaymentInitiationStatus.PendingMergeKycRequired;
+          break;
+        case PeerPullPaymentInitiationStatus.SuspendedReady:
+          newStatus = PeerPullPaymentInitiationStatus.PendingReady;
+          break;
+        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
+          newStatus = PeerPullPaymentInitiationStatus.PendingWithdrawing;
+          break;
+        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+          newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
+          break;
+        default:
+          assertUnreachable(pullCreditRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        pullCreditRec.status = newStatus;
+        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
+        await tx.peerPullPaymentInitiations.put(pullCreditRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  ws.workAvailable.trigger();
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export function computePeerPullCreditTransactionState(
+  pullCreditRecord: PeerPullPaymentInitiationRecord,
+): TransactionState {
+  switch (pullCreditRecord.status) {
+    case PeerPullPaymentInitiationStatus.PendingCreatePurse:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.CreatePurse,
+      };
+    case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.MergeKycRequired,
+      };
+    case PeerPullPaymentInitiationStatus.PendingReady:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.Ready,
+      };
+    case PeerPullPaymentInitiationStatus.DonePurseDeposited:
+      return {
+        major: TransactionMajorState.Done,
+      };
+    case PeerPullPaymentInitiationStatus.PendingWithdrawing:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.Withdraw,
+      };
+    case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.CreatePurse,
+      };
+    case PeerPullPaymentInitiationStatus.SuspendedReady:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.Ready,
+      };
+    case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.Withdraw,
+      };
+    case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.MergeKycRequired,
+      };
+    case PeerPullPaymentInitiationStatus.Aborted:
+      return {
+        major: TransactionMajorState.Aborted,
+      };
+    case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
+      return {
+        major: TransactionMajorState.Aborting,
+        minor: TransactionMinorState.DeletePurse,
+      };
+    case PeerPullPaymentInitiationStatus.Failed:
+      return {
+        major: TransactionMajorState.Failed,
+      };
+    case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+      return {
+        major: TransactionMajorState.Aborting,
+        minor: TransactionMinorState.DeletePurse,
+      };
+  }
+}
+
+export function computePeerPullCreditTransactionActions(
+  pullCreditRecord: PeerPullPaymentInitiationRecord,
+): TransactionAction[] {
+  switch (pullCreditRecord.status) {
+    case PeerPullPaymentInitiationStatus.PendingCreatePurse:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPullPaymentInitiationStatus.PendingReady:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPullPaymentInitiationStatus.DonePurseDeposited:
+      return [TransactionAction.Delete];
+    case PeerPullPaymentInitiationStatus.PendingWithdrawing:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
+      return [TransactionAction.Resume, TransactionAction.Abort];
+    case PeerPullPaymentInitiationStatus.SuspendedReady:
+      return [TransactionAction.Abort, TransactionAction.Resume];
+    case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
+      return [TransactionAction.Resume, TransactionAction.Fail];
+    case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
+      return [TransactionAction.Resume, TransactionAction.Fail];
+    case PeerPullPaymentInitiationStatus.Aborted:
+      return [TransactionAction.Delete];
+    case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
+      return [TransactionAction.Suspend, TransactionAction.Fail];
+    case PeerPullPaymentInitiationStatus.Failed:
+      return [TransactionAction.Delete];
+    case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+      return [TransactionAction.Resume, TransactionAction.Fail];
+  }
+}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts 
b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
new file mode 100644
index 000000000..fdec42bbd
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
@@ -0,0 +1,604 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 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 {
+  ConfirmPeerPullDebitRequest,
+  AcceptPeerPullPaymentResponse,
+  Amounts,
+  j2s,
+  TalerError,
+  TalerErrorCode,
+  TransactionType,
+  RefreshReason,
+  Logger,
+  PeerContractTerms,
+  PreparePeerPullDebitRequest,
+  PreparePeerPullDebitResponse,
+  TalerPreciseTimestamp,
+  codecForExchangeGetContractResponse,
+  codecForPeerContractTerms,
+  decodeCrock,
+  eddsaGetPublic,
+  encodeCrock,
+  getRandomBytes,
+  parsePayPullUri,
+  TransactionAction,
+  TransactionMajorState,
+  TransactionMinorState,
+  TransactionState,
+} from "@gnu-taler/taler-util";
+import {
+  InternalWalletState,
+  PeerPullDebitRecordStatus,
+  PeerPullPaymentIncomingRecord,
+  PendingTaskType,
+} from "../index.js";
+import { TaskIdentifiers, constructTaskIdentifier } from "../util/retries.js";
+import { spendCoins, runOperationWithErrorReporting } from "./common.js";
+import {
+  codecForExchangePurseStatus,
+  getTotalPeerPaymentCost,
+  selectPeerCoins,
+} from "./pay-peer-common.js";
+import { processPeerPullDebit } from "./pay-peer-push-credit.js";
+import {
+  constructTransactionIdentifier,
+  notifyTransition,
+  stopLongpolling,
+} from "./transactions.js";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import { assertUnreachable } from "../util/assertUnreachable.js";
+
+const logger = new Logger("pay-peer-pull-debit.ts");
+
+export async function confirmPeerPullDebit(
+  ws: InternalWalletState,
+  req: ConfirmPeerPullDebitRequest,
+): Promise<AcceptPeerPullPaymentResponse> {
+  const peerPullInc = await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadOnly(async (tx) => {
+      return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId);
+    });
+
+  if (!peerPullInc) {
+    throw Error(
+      `can't accept unknown incoming p2p pull payment 
(${req.peerPullPaymentIncomingId})`,
+    );
+  }
+
+  const instructedAmount = Amounts.parseOrThrow(
+    peerPullInc.contractTerms.amount,
+  );
+
+  const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+  logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+
+  if (coinSelRes.type !== "success") {
+    throw TalerError.fromDetail(
+      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+      {
+        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+      },
+    );
+  }
+
+  const sel = coinSelRes.result;
+
+  const totalAmount = await getTotalPeerPaymentCost(
+    ws,
+    coinSelRes.result.coins,
+  );
+
+  const ppi = await ws.db
+    .mktx((x) => [
+      x.exchanges,
+      x.coins,
+      x.denominations,
+      x.refreshGroups,
+      x.peerPullPaymentIncoming,
+      x.coinAvailability,
+    ])
+    .runReadWrite(async (tx) => {
+      await spendCoins(ws, tx, {
+        // allocationId: 
`txn:peer-pull-debit:${req.peerPullPaymentIncomingId}`,
+        allocationId: constructTransactionIdentifier({
+          tag: TransactionType.PeerPullDebit,
+          peerPullPaymentIncomingId: req.peerPullPaymentIncomingId,
+        }),
+        coinPubs: sel.coins.map((x) => x.coinPub),
+        contributions: sel.coins.map((x) =>
+          Amounts.parseOrThrow(x.contribution),
+        ),
+        refreshReason: RefreshReason.PayPeerPull,
+      });
+
+      const pi = await tx.peerPullPaymentIncoming.get(
+        req.peerPullPaymentIncomingId,
+      );
+      if (!pi) {
+        throw Error();
+      }
+      if (pi.status === PeerPullDebitRecordStatus.DialogProposed) {
+        pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+        pi.coinSel = {
+          coinPubs: sel.coins.map((x) => x.coinPub),
+          contributions: sel.coins.map((x) => x.contribution),
+          totalCost: Amounts.stringify(totalAmount),
+        };
+      }
+      await tx.peerPullPaymentIncoming.put(pi);
+      return pi;
+    });
+
+  await runOperationWithErrorReporting(
+    ws,
+    TaskIdentifiers.forPeerPullPaymentDebit(ppi),
+    async () => {
+      return processPeerPullDebit(ws, ppi.peerPullPaymentIncomingId);
+    },
+  );
+
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullDebit,
+    peerPullPaymentIncomingId: req.peerPullPaymentIncomingId,
+  });
+
+  return {
+    transactionId,
+  };
+}
+
+/**
+ * Look up information about an incoming peer pull payment.
+ * Store the results in the wallet DB.
+ */
+export async function preparePeerPullDebit(
+  ws: InternalWalletState,
+  req: PreparePeerPullDebitRequest,
+): Promise<PreparePeerPullDebitResponse> {
+  const uri = parsePayPullUri(req.talerUri);
+
+  if (!uri) {
+    throw Error("got invalid taler://pay-pull URI");
+  }
+
+  const existingPullIncomingRecord = await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadOnly(async (tx) => {
+      return tx.peerPullPaymentIncoming.indexes.byExchangeAndContractPriv.get([
+        uri.exchangeBaseUrl,
+        uri.contractPriv,
+      ]);
+    });
+
+  if (existingPullIncomingRecord) {
+    return {
+      amount: existingPullIncomingRecord.contractTerms.amount,
+      amountRaw: existingPullIncomingRecord.contractTerms.amount,
+      amountEffective: existingPullIncomingRecord.totalCostEstimated,
+      contractTerms: existingPullIncomingRecord.contractTerms,
+      peerPullPaymentIncomingId:
+        existingPullIncomingRecord.peerPullPaymentIncomingId,
+      transactionId: constructTransactionIdentifier({
+        tag: TransactionType.PeerPullDebit,
+        peerPullPaymentIncomingId:
+          existingPullIncomingRecord.peerPullPaymentIncomingId,
+      }),
+    };
+  }
+
+  const exchangeBaseUrl = uri.exchangeBaseUrl;
+  const contractPriv = uri.contractPriv;
+  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+  const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+  const contractHttpResp = await ws.http.get(getContractUrl.href);
+
+  const contractResp = await readSuccessResponseJsonOrThrow(
+    contractHttpResp,
+    codecForExchangeGetContractResponse(),
+  );
+
+  const pursePub = contractResp.purse_pub;
+
+  const dec = await ws.cryptoApi.decryptContractForDeposit({
+    ciphertext: contractResp.econtract,
+    contractPriv: contractPriv,
+    pursePub: pursePub,
+  });
+
+  const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
+
+  const purseHttpResp = await ws.http.get(getPurseUrl.href);
+
+  const purseStatus = await readSuccessResponseJsonOrThrow(
+    purseHttpResp,
+    codecForExchangePurseStatus(),
+  );
+
+  const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32));
+
+  let contractTerms: PeerContractTerms;
+
+  if (dec.contractTerms) {
+    contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+    // FIXME: Check that the purseStatus balance matches contract terms amount
+  } else {
+    // FIXME: In this case, where do we get the purse expiration from?!
+    // https://bugs.gnunet.org/view.php?id=7706
+    throw Error("pull payments without contract terms not supported yet");
+  }
+
+  // FIXME: Why don't we compute the totalCost here?!
+
+  const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
+
+  const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+  logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+
+  if (coinSelRes.type !== "success") {
+    throw TalerError.fromDetail(
+      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+      {
+        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+      },
+    );
+  }
+
+  const totalAmount = await getTotalPeerPaymentCost(
+    ws,
+    coinSelRes.result.coins,
+  );
+
+  await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      await tx.peerPullPaymentIncoming.add({
+        peerPullPaymentIncomingId,
+        contractPriv: contractPriv,
+        exchangeBaseUrl: exchangeBaseUrl,
+        pursePub: pursePub,
+        timestampCreated: TalerPreciseTimestamp.now(),
+        contractTerms,
+        status: PeerPullDebitRecordStatus.DialogProposed,
+        totalCostEstimated: Amounts.stringify(totalAmount),
+      });
+    });
+
+  return {
+    amount: contractTerms.amount,
+    amountEffective: Amounts.stringify(totalAmount),
+    amountRaw: contractTerms.amount,
+    contractTerms: contractTerms,
+    peerPullPaymentIncomingId,
+    transactionId: constructTransactionIdentifier({
+      tag: TransactionType.PeerPullDebit,
+      peerPullPaymentIncomingId: peerPullPaymentIncomingId,
+    }),
+  };
+}
+
+export async function suspendPeerPullDebitTransaction(
+  ws: InternalWalletState,
+  peerPullPaymentIncomingId: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
+        peerPullPaymentIncomingId,
+      );
+      if (!pullDebitRec) {
+        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
+        return;
+      }
+      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+      switch (pullDebitRec.status) {
+        case PeerPullDebitRecordStatus.DialogProposed:
+          break;
+        case PeerPullDebitRecordStatus.DonePaid:
+          break;
+        case PeerPullDebitRecordStatus.PendingDeposit:
+          newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
+          break;
+        case PeerPullDebitRecordStatus.SuspendedDeposit:
+          break;
+        case PeerPullDebitRecordStatus.Aborted:
+          break;
+        case PeerPullDebitRecordStatus.AbortingRefresh:
+          newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
+          break;
+        case PeerPullDebitRecordStatus.Failed:
+          break;
+        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+          break;
+        default:
+          assertUnreachable(pullDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        pullDebitRec.status = newStatus;
+        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        await tx.peerPullPaymentIncoming.put(pullDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function abortPeerPullDebitTransaction(
+  ws: InternalWalletState,
+  peerPullPaymentIncomingId: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
+        peerPullPaymentIncomingId,
+      );
+      if (!pullDebitRec) {
+        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
+        return;
+      }
+      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+      switch (pullDebitRec.status) {
+        case PeerPullDebitRecordStatus.DialogProposed:
+          newStatus = PeerPullDebitRecordStatus.Aborted;
+          break;
+        case PeerPullDebitRecordStatus.DonePaid:
+          break;
+        case PeerPullDebitRecordStatus.PendingDeposit:
+          newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
+          break;
+        case PeerPullDebitRecordStatus.SuspendedDeposit:
+          break;
+        case PeerPullDebitRecordStatus.Aborted:
+          break;
+        case PeerPullDebitRecordStatus.AbortingRefresh:
+          break;
+        case PeerPullDebitRecordStatus.Failed:
+          break;
+        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+          break;
+        default:
+          assertUnreachable(pullDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        pullDebitRec.status = newStatus;
+        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        await tx.peerPullPaymentIncoming.put(pullDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function failPeerPullDebitTransaction(
+  ws: InternalWalletState,
+  peerPullPaymentIncomingId: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
+        peerPullPaymentIncomingId,
+      );
+      if (!pullDebitRec) {
+        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
+        return;
+      }
+      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+      switch (pullDebitRec.status) {
+        case PeerPullDebitRecordStatus.DialogProposed:
+          newStatus = PeerPullDebitRecordStatus.Aborted;
+          break;
+        case PeerPullDebitRecordStatus.DonePaid:
+          break;
+        case PeerPullDebitRecordStatus.PendingDeposit:
+          break;
+        case PeerPullDebitRecordStatus.SuspendedDeposit:
+          break;
+        case PeerPullDebitRecordStatus.Aborted:
+          break;
+        case PeerPullDebitRecordStatus.Failed:
+          break;
+        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+        case PeerPullDebitRecordStatus.AbortingRefresh:
+          // FIXME: abort underlying refresh!
+          newStatus = PeerPullDebitRecordStatus.Failed;
+          break;
+        default:
+          assertUnreachable(pullDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        pullDebitRec.status = newStatus;
+        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        await tx.peerPullPaymentIncoming.put(pullDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function resumePeerPullDebitTransaction(
+  ws: InternalWalletState,
+  peerPullPaymentIncomingId: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPullDebit,
+    peerPullPaymentIncomingId,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
+        peerPullPaymentIncomingId,
+      );
+      if (!pullDebitRec) {
+        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
+        return;
+      }
+      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+      switch (pullDebitRec.status) {
+        case PeerPullDebitRecordStatus.DialogProposed:
+        case PeerPullDebitRecordStatus.DonePaid:
+        case PeerPullDebitRecordStatus.PendingDeposit:
+          break;
+        case PeerPullDebitRecordStatus.SuspendedDeposit:
+          newStatus = PeerPullDebitRecordStatus.PendingDeposit;
+          break;
+        case PeerPullDebitRecordStatus.Aborted:
+          break;
+        case PeerPullDebitRecordStatus.AbortingRefresh:
+          break;
+        case PeerPullDebitRecordStatus.Failed:
+          break;
+        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+          newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
+          break;
+        default:
+          assertUnreachable(pullDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        pullDebitRec.status = newStatus;
+        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+        await tx.peerPullPaymentIncoming.put(pullDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  ws.workAvailable.trigger();
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export function computePeerPullDebitTransactionState(
+  pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionState {
+  switch (pullDebitRecord.status) {
+    case PeerPullDebitRecordStatus.DialogProposed:
+      return {
+        major: TransactionMajorState.Dialog,
+        minor: TransactionMinorState.Proposed,
+      };
+    case PeerPullDebitRecordStatus.PendingDeposit:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.Deposit,
+      };
+    case PeerPullDebitRecordStatus.DonePaid:
+      return {
+        major: TransactionMajorState.Done,
+      };
+    case PeerPullDebitRecordStatus.SuspendedDeposit:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.Deposit,
+      };
+    case PeerPullDebitRecordStatus.Aborted:
+      return {
+        major: TransactionMajorState.Aborted,
+      };
+    case PeerPullDebitRecordStatus.AbortingRefresh:
+      return {
+        major: TransactionMajorState.Aborting,
+        minor: TransactionMinorState.Refresh,
+      };
+    case PeerPullDebitRecordStatus.Failed:
+      return {
+        major: TransactionMajorState.Failed,
+      };
+    case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+      return {
+        major: TransactionMajorState.SuspendedAborting,
+        minor: TransactionMinorState.Refresh,
+      };
+  }
+}
+
+export function computePeerPullDebitTransactionActions(
+  pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionAction[] {
+  switch (pullDebitRecord.status) {
+    case PeerPullDebitRecordStatus.DialogProposed:
+      return [];
+    case PeerPullDebitRecordStatus.PendingDeposit:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPullDebitRecordStatus.DonePaid:
+      return [TransactionAction.Delete];
+    case PeerPullDebitRecordStatus.SuspendedDeposit:
+      return [TransactionAction.Resume, TransactionAction.Abort];
+    case PeerPullDebitRecordStatus.Aborted:
+      return [TransactionAction.Delete];
+    case PeerPullDebitRecordStatus.AbortingRefresh:
+      return [TransactionAction.Fail, TransactionAction.Suspend];
+    case PeerPullDebitRecordStatus.Failed:
+      return [TransactionAction.Delete];
+    case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+      return [TransactionAction.Resume, TransactionAction.Fail];
+  }
+}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts 
b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
new file mode 100644
index 000000000..69e0f3c27
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
@@ -0,0 +1,770 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 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 {
+  PreparePeerPushCredit,
+  PreparePeerPushCreditResponse,
+  parsePayPushUri,
+  codecForPeerContractTerms,
+  TransactionType,
+  encodeCrock,
+  eddsaGetPublic,
+  decodeCrock,
+  codecForExchangeGetContractResponse,
+  getRandomBytes,
+  ContractTermsUtil,
+  Amounts,
+  TalerPreciseTimestamp,
+  AcceptPeerPushPaymentResponse,
+  ConfirmPeerPushCreditRequest,
+  ExchangePurseMergeRequest,
+  HttpStatusCode,
+  PeerContractTerms,
+  TalerProtocolTimestamp,
+  WalletAccountMergeFlags,
+  codecForAny,
+  codecForWalletKycUuid,
+  j2s,
+  Logger,
+  ExchangePurseDeposits,
+  TransactionAction,
+  TransactionMajorState,
+  TransactionMinorState,
+  TransactionState,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+  InternalWalletState,
+  PeerPullDebitRecordStatus,
+  PeerPushPaymentIncomingRecord,
+  PeerPushPaymentIncomingStatus,
+  PendingTaskType,
+  WithdrawalGroupStatus,
+  WithdrawalRecordType,
+} from "../index.js";
+import { updateExchangeFromUrl } from "./exchanges.js";
+import {
+  codecForExchangePurseStatus,
+  getMergeReserveInfo,
+  queryCoinInfosForSelection,
+  talerPaytoFromExchangeReserve,
+} from "./pay-peer-common.js";
+import { constructTransactionIdentifier, notifyTransition, stopLongpolling } 
from "./transactions.js";
+import {
+  checkWithdrawalKycStatus,
+  getExchangeWithdrawalInfo,
+  internalCreateWithdrawalGroup,
+} from "./withdraw.js";
+import { checkDbInvariant } from "../util/invariants.js";
+import {
+  OperationAttemptResult,
+  OperationAttemptResultType,
+  constructTaskIdentifier,
+} from "../util/retries.js";
+import { assertUnreachable } from "../util/assertUnreachable.js";
+
+const logger = new Logger("pay-peer-push-credit.ts");
+
+export async function preparePeerPushCredit(
+  ws: InternalWalletState,
+  req: PreparePeerPushCredit,
+): Promise<PreparePeerPushCreditResponse> {
+  const uri = parsePayPushUri(req.talerUri);
+
+  if (!uri) {
+    throw Error("got invalid taler://pay-push URI");
+  }
+
+  const existing = await ws.db
+    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
+    .runReadOnly(async (tx) => {
+      const existingPushInc =
+        await 
tx.peerPushPaymentIncoming.indexes.byExchangeAndContractPriv.get([
+          uri.exchangeBaseUrl,
+          uri.contractPriv,
+        ]);
+      if (!existingPushInc) {
+        return;
+      }
+      const existingContractTermsRec = await tx.contractTerms.get(
+        existingPushInc.contractTermsHash,
+      );
+      if (!existingContractTermsRec) {
+        throw Error(
+          "contract terms for peer push payment credit not found in database",
+        );
+      }
+      const existingContractTerms = codecForPeerContractTerms().decode(
+        existingContractTermsRec.contractTermsRaw,
+      );
+      return { existingPushInc, existingContractTerms };
+    });
+
+  if (existing) {
+    return {
+      amount: existing.existingContractTerms.amount,
+      amountEffective: existing.existingPushInc.estimatedAmountEffective,
+      amountRaw: existing.existingContractTerms.amount,
+      contractTerms: existing.existingContractTerms,
+      peerPushPaymentIncomingId:
+        existing.existingPushInc.peerPushPaymentIncomingId,
+      transactionId: constructTransactionIdentifier({
+        tag: TransactionType.PeerPushCredit,
+        peerPushPaymentIncomingId:
+          existing.existingPushInc.peerPushPaymentIncomingId,
+      }),
+    };
+  }
+
+  const exchangeBaseUrl = uri.exchangeBaseUrl;
+
+  await updateExchangeFromUrl(ws, exchangeBaseUrl);
+
+  const contractPriv = uri.contractPriv;
+  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+  const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+  const contractHttpResp = await ws.http.get(getContractUrl.href);
+
+  const contractResp = await readSuccessResponseJsonOrThrow(
+    contractHttpResp,
+    codecForExchangeGetContractResponse(),
+  );
+
+  const pursePub = contractResp.purse_pub;
+
+  const dec = await ws.cryptoApi.decryptContractForMerge({
+    ciphertext: contractResp.econtract,
+    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));
+
+  const contractTermsHash = ContractTermsUtil.hashContractTerms(
+    dec.contractTerms,
+  );
+
+  const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+  const wi = await getExchangeWithdrawalInfo(
+    ws,
+    exchangeBaseUrl,
+    Amounts.parseOrThrow(purseStatus.balance),
+    undefined,
+  );
+
+  await ws.db
+    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      await tx.peerPushPaymentIncoming.add({
+        peerPushPaymentIncomingId,
+        contractPriv: contractPriv,
+        exchangeBaseUrl: exchangeBaseUrl,
+        mergePriv: dec.mergePriv,
+        pursePub: pursePub,
+        timestamp: TalerPreciseTimestamp.now(),
+        contractTermsHash,
+        status: PeerPushPaymentIncomingStatus.DialogProposed,
+        withdrawalGroupId,
+        currency: Amounts.currencyOf(purseStatus.balance),
+        estimatedAmountEffective: Amounts.stringify(
+          wi.withdrawalAmountEffective,
+        ),
+      });
+
+      await tx.contractTerms.put({
+        h: contractTermsHash,
+        contractTermsRaw: dec.contractTerms,
+      });
+    });
+
+  return {
+    amount: purseStatus.balance,
+    amountEffective: wi.withdrawalAmountEffective,
+    amountRaw: purseStatus.balance,
+    contractTerms: dec.contractTerms,
+    peerPushPaymentIncomingId,
+    transactionId: constructTransactionIdentifier({
+      tag: TransactionType.PeerPushCredit,
+      peerPushPaymentIncomingId,
+    }),
+  };
+}
+
+export async function processPeerPushCredit(
+  ws: InternalWalletState,
+  peerPushPaymentIncomingId: string,
+): Promise<OperationAttemptResult> {
+  let peerInc: PeerPushPaymentIncomingRecord | undefined;
+  let contractTerms: PeerContractTerms | undefined;
+  await ws.db
+    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      peerInc = await 
tx.peerPushPaymentIncoming.get(peerPushPaymentIncomingId);
+      if (!peerInc) {
+        return;
+      }
+      const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
+      if (ctRec) {
+        contractTerms = ctRec.contractTermsRaw;
+      }
+      await tx.peerPushPaymentIncoming.put(peerInc);
+    });
+
+  if (!peerInc) {
+    throw Error(
+      `can't accept unknown incoming p2p push payment 
(${peerPushPaymentIncomingId})`,
+    );
+  }
+
+  checkDbInvariant(!!contractTerms);
+
+  const amount = Amounts.parseOrThrow(contractTerms.amount);
+
+  if (
+    peerInc.status === PeerPushPaymentIncomingStatus.PendingMergeKycRequired &&
+    peerInc.kycInfo
+  ) {
+    const txId = constructTransactionIdentifier({
+      tag: TransactionType.PeerPushCredit,
+      peerPushPaymentIncomingId: peerInc.peerPushPaymentIncomingId,
+    });
+    await checkWithdrawalKycStatus(
+      ws,
+      peerInc.exchangeBaseUrl,
+      txId,
+      peerInc.kycInfo,
+      "individual",
+    );
+  }
+
+  const mergeReserveInfo = await getMergeReserveInfo(ws, {
+    exchangeBaseUrl: peerInc.exchangeBaseUrl,
+  });
+
+  const mergeTimestamp = TalerProtocolTimestamp.now();
+
+  const reservePayto = talerPaytoFromExchangeReserve(
+    peerInc.exchangeBaseUrl,
+    mergeReserveInfo.reservePub,
+  );
+
+  const sigRes = await ws.cryptoApi.signPurseMerge({
+    contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
+    flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
+    mergePriv: peerInc.mergePriv,
+    mergeTimestamp: mergeTimestamp,
+    purseAmount: Amounts.stringify(amount),
+    purseExpiration: contractTerms.purse_expiration,
+    purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
+    pursePub: peerInc.pursePub,
+    reservePayto,
+    reservePriv: mergeReserveInfo.reservePriv,
+  });
+
+  const mergePurseUrl = new URL(
+    `purses/${peerInc.pursePub}/merge`,
+    peerInc.exchangeBaseUrl,
+  );
+
+  const mergeReq: ExchangePurseMergeRequest = {
+    payto_uri: reservePayto,
+    merge_timestamp: mergeTimestamp,
+    merge_sig: sigRes.mergeSig,
+    reserve_sig: sigRes.accountSig,
+  };
+
+  const mergeHttpResp = await ws.http.postJson(mergePurseUrl.href, mergeReq);
+
+  if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+    const respJson = await mergeHttpResp.json();
+    const kycPending = codecForWalletKycUuid().decode(respJson);
+    logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+
+    await ws.db
+      .mktx((x) => [x.peerPushPaymentIncoming])
+      .runReadWrite(async (tx) => {
+        const peerInc = await tx.peerPushPaymentIncoming.get(
+          peerPushPaymentIncomingId,
+        );
+        if (!peerInc) {
+          return;
+        }
+        peerInc.kycInfo = {
+          paytoHash: kycPending.h_payto,
+          requirementRow: kycPending.requirement_row,
+        };
+        peerInc.status = PeerPushPaymentIncomingStatus.PendingMergeKycRequired;
+        await tx.peerPushPaymentIncoming.put(peerInc);
+      });
+    return {
+      type: OperationAttemptResultType.Pending,
+      result: undefined,
+    };
+  }
+
+  logger.trace(`merge request: ${j2s(mergeReq)}`);
+  const res = await readSuccessResponseJsonOrThrow(
+    mergeHttpResp,
+    codecForAny(),
+  );
+  logger.trace(`merge response: ${j2s(res)}`);
+
+  await internalCreateWithdrawalGroup(ws, {
+    amount,
+    wgInfo: {
+      withdrawalType: WithdrawalRecordType.PeerPushCredit,
+      contractTerms,
+    },
+    forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
+    exchangeBaseUrl: peerInc.exchangeBaseUrl,
+    reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+    reserveKeyPair: {
+      priv: mergeReserveInfo.reservePriv,
+      pub: mergeReserveInfo.reservePub,
+    },
+  });
+
+  await ws.db
+    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const peerInc = await tx.peerPushPaymentIncoming.get(
+        peerPushPaymentIncomingId,
+      );
+      if (!peerInc) {
+        return;
+      }
+      if (
+        peerInc.status === PeerPushPaymentIncomingStatus.PendingMerge ||
+        peerInc.status === 
PeerPushPaymentIncomingStatus.PendingMergeKycRequired
+      ) {
+        peerInc.status = PeerPushPaymentIncomingStatus.Done;
+      }
+      await tx.peerPushPaymentIncoming.put(peerInc);
+    });
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
+}
+
+export async function confirmPeerPushCredit(
+  ws: InternalWalletState,
+  req: ConfirmPeerPushCreditRequest,
+): Promise<AcceptPeerPushPaymentResponse> {
+  let peerInc: PeerPushPaymentIncomingRecord | undefined;
+
+  await ws.db
+    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      peerInc = await tx.peerPushPaymentIncoming.get(
+        req.peerPushPaymentIncomingId,
+      );
+      if (!peerInc) {
+        return;
+      }
+      if (peerInc.status === PeerPushPaymentIncomingStatus.DialogProposed) {
+        peerInc.status = PeerPushPaymentIncomingStatus.PendingMerge;
+      }
+      await tx.peerPushPaymentIncoming.put(peerInc);
+    });
+
+  if (!peerInc) {
+    throw Error(
+      `can't accept unknown incoming p2p push payment 
(${req.peerPushPaymentIncomingId})`,
+    );
+  }
+
+  ws.workAvailable.trigger();
+
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushCredit,
+    peerPushPaymentIncomingId: req.peerPushPaymentIncomingId,
+  });
+
+  return {
+    transactionId,
+  };
+}
+
+
+export async function processPeerPullDebit(
+  ws: InternalWalletState,
+  peerPullPaymentIncomingId: string,
+): Promise<OperationAttemptResult> {
+  const peerPullInc = await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadOnly(async (tx) => {
+      return tx.peerPullPaymentIncoming.get(peerPullPaymentIncomingId);
+    });
+  if (!peerPullInc) {
+    throw Error("peer pull debit not found");
+  }
+  if (peerPullInc.status === PeerPullDebitRecordStatus.PendingDeposit) {
+    const pursePub = peerPullInc.pursePub;
+
+    const coinSel = peerPullInc.coinSel;
+    if (!coinSel) {
+      throw Error("invalid state, no coins selected");
+    }
+
+    const coins = await queryCoinInfosForSelection(ws, coinSel);
+
+    const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
+      exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+      pursePub: peerPullInc.pursePub,
+      coins,
+    });
+
+    const purseDepositUrl = new URL(
+      `purses/${pursePub}/deposit`,
+      peerPullInc.exchangeBaseUrl,
+    );
+
+    const depositPayload: ExchangePurseDeposits = {
+      deposits: depositSigsResp.deposits,
+    };
+
+    if (logger.shouldLogTrace()) {
+      logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
+    }
+
+    const httpResp = await ws.http.postJson(
+      purseDepositUrl.href,
+      depositPayload,
+    );
+    const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
+    logger.trace(`purse deposit response: ${j2s(resp)}`);
+  }
+
+  await ws.db
+    .mktx((x) => [x.peerPullPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pi = await tx.peerPullPaymentIncoming.get(
+        peerPullPaymentIncomingId,
+      );
+      if (!pi) {
+        throw Error("peer pull payment not found anymore");
+      }
+      if (pi.status === PeerPullDebitRecordStatus.PendingDeposit) {
+        pi.status = PeerPullDebitRecordStatus.DonePaid;
+      }
+      await tx.peerPullPaymentIncoming.put(pi);
+    });
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
+}
+
+
+export async function suspendPeerPushCreditTransaction(
+  ws: InternalWalletState,
+  peerPushPaymentIncomingId: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushCredit,
+    peerPushPaymentIncomingId,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushCredit,
+    peerPushPaymentIncomingId,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pushCreditRec = await tx.peerPushPaymentIncoming.get(
+        peerPushPaymentIncomingId,
+      );
+      if (!pushCreditRec) {
+        logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
+        return;
+      }
+      let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
+      switch (pushCreditRec.status) {
+        case PeerPushPaymentIncomingStatus.DialogProposed:
+        case PeerPushPaymentIncomingStatus.Done:
+        case PeerPushPaymentIncomingStatus.SuspendedMerge:
+        case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
+        case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
+          break;
+        case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
+          newStatus = PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired;
+          break;
+        case PeerPushPaymentIncomingStatus.PendingMerge:
+          newStatus = PeerPushPaymentIncomingStatus.SuspendedMerge;
+          break;
+        case PeerPushPaymentIncomingStatus.PendingWithdrawing:
+          // FIXME: Suspend internal withdrawal transaction!
+          newStatus = PeerPushPaymentIncomingStatus.SuspendedWithdrawing;
+          break;
+        case PeerPushPaymentIncomingStatus.Aborted:
+          break;
+        case PeerPushPaymentIncomingStatus.Failed:
+          break;
+        default:
+          assertUnreachable(pushCreditRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
+        pushCreditRec.status = newStatus;
+        const newTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
+        await tx.peerPushPaymentIncoming.put(pushCreditRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function abortPeerPushCreditTransaction(
+  ws: InternalWalletState,
+  peerPushPaymentIncomingId: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushCredit,
+    peerPushPaymentIncomingId,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushCredit,
+    peerPushPaymentIncomingId,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pushCreditRec = await tx.peerPushPaymentIncoming.get(
+        peerPushPaymentIncomingId,
+      );
+      if (!pushCreditRec) {
+        logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
+        return;
+      }
+      let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
+      switch (pushCreditRec.status) {
+        case PeerPushPaymentIncomingStatus.DialogProposed:
+          newStatus = PeerPushPaymentIncomingStatus.Aborted;
+          break;
+        case PeerPushPaymentIncomingStatus.Done:
+          break;
+        case PeerPushPaymentIncomingStatus.SuspendedMerge:
+        case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
+        case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
+          newStatus = PeerPushPaymentIncomingStatus.Aborted;
+          break;
+        case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
+          newStatus = PeerPushPaymentIncomingStatus.Aborted;
+          break;
+        case PeerPushPaymentIncomingStatus.PendingMerge:
+          newStatus = PeerPushPaymentIncomingStatus.Aborted;
+          break;
+        case PeerPushPaymentIncomingStatus.PendingWithdrawing:
+          newStatus = PeerPushPaymentIncomingStatus.Aborted;
+          break;
+        case PeerPushPaymentIncomingStatus.Aborted:
+          break;
+        case PeerPushPaymentIncomingStatus.Failed:
+          break;
+        default:
+          assertUnreachable(pushCreditRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
+        pushCreditRec.status = newStatus;
+        const newTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
+        await tx.peerPushPaymentIncoming.put(pushCreditRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function failPeerPushCreditTransaction(
+  ws: InternalWalletState,
+  peerPushPaymentIncomingId: string,
+) {
+  // We don't have any "aborting" states!
+  throw Error("can't run cancel-aborting on peer-push-credit transaction");
+}
+
+export async function resumePeerPushCreditTransaction(
+  ws: InternalWalletState,
+  peerPushPaymentIncomingId: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushCredit,
+    peerPushPaymentIncomingId,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushCredit,
+    peerPushPaymentIncomingId,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentIncoming])
+    .runReadWrite(async (tx) => {
+      const pushCreditRec = await tx.peerPushPaymentIncoming.get(
+        peerPushPaymentIncomingId,
+      );
+      if (!pushCreditRec) {
+        logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
+        return;
+      }
+      let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
+      switch (pushCreditRec.status) {
+        case PeerPushPaymentIncomingStatus.DialogProposed:
+        case PeerPushPaymentIncomingStatus.Done:
+        case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
+        case PeerPushPaymentIncomingStatus.PendingMerge:
+        case PeerPushPaymentIncomingStatus.PendingWithdrawing:
+        case PeerPushPaymentIncomingStatus.SuspendedMerge:
+          newStatus = PeerPushPaymentIncomingStatus.PendingMerge;
+          break;
+        case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
+          newStatus = PeerPushPaymentIncomingStatus.PendingMergeKycRequired;
+          break;
+        case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
+          // FIXME: resume underlying "internal-withdrawal" transaction.
+          newStatus = PeerPushPaymentIncomingStatus.PendingWithdrawing;
+          break;
+        case PeerPushPaymentIncomingStatus.Aborted:
+          break;
+        case PeerPushPaymentIncomingStatus.Failed:
+          break;
+        default:
+          assertUnreachable(pushCreditRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
+        pushCreditRec.status = newStatus;
+        const newTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
+        await tx.peerPushPaymentIncoming.put(pushCreditRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  ws.workAvailable.trigger();
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export function computePeerPushCreditTransactionState(
+  pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionState {
+  switch (pushCreditRecord.status) {
+    case PeerPushPaymentIncomingStatus.DialogProposed:
+      return {
+        major: TransactionMajorState.Dialog,
+        minor: TransactionMinorState.Proposed,
+      };
+    case PeerPushPaymentIncomingStatus.PendingMerge:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.Merge,
+      };
+    case PeerPushPaymentIncomingStatus.Done:
+      return {
+        major: TransactionMajorState.Done,
+      };
+    case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.KycRequired,
+      };
+    case PeerPushPaymentIncomingStatus.PendingWithdrawing:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.Withdraw,
+      };
+    case PeerPushPaymentIncomingStatus.SuspendedMerge:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.Merge,
+      };
+    case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.MergeKycRequired,
+      };
+    case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.Withdraw,
+      };
+    case PeerPushPaymentIncomingStatus.Aborted:
+      return {
+        major: TransactionMajorState.Aborted,
+      };
+    case PeerPushPaymentIncomingStatus.Failed:
+      return {
+        major: TransactionMajorState.Failed,
+      };
+    default:
+      assertUnreachable(pushCreditRecord.status);
+  }
+}
+
+export function computePeerPushCreditTransactionActions(
+  pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionAction[] {
+  switch (pushCreditRecord.status) {
+    case PeerPushPaymentIncomingStatus.DialogProposed:
+      return [];
+    case PeerPushPaymentIncomingStatus.PendingMerge:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPushPaymentIncomingStatus.Done:
+      return [TransactionAction.Delete];
+    case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPushPaymentIncomingStatus.PendingWithdrawing:
+      return [TransactionAction.Suspend, TransactionAction.Fail];
+    case PeerPushPaymentIncomingStatus.SuspendedMerge:
+      return [TransactionAction.Resume, TransactionAction.Abort];
+    case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
+      return [TransactionAction.Resume, TransactionAction.Abort];
+    case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
+      return [TransactionAction.Resume, TransactionAction.Fail];
+    case PeerPushPaymentIncomingStatus.Aborted:
+      return [TransactionAction.Delete];
+    case PeerPushPaymentIncomingStatus.Failed:
+      return [TransactionAction.Delete];
+    default:
+      assertUnreachable(pushCreditRecord.status);
+  }
+}
\ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts 
b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
new file mode 100644
index 000000000..dead6313d
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
@@ -0,0 +1,742 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 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,
+  CheckPeerPushDebitRequest,
+  CheckPeerPushDebitResponse,
+  ContractTermsUtil,
+  HttpStatusCode,
+  InitiatePeerPushDebitRequest,
+  InitiatePeerPushDebitResponse,
+  Logger,
+  RefreshReason,
+  TalerError,
+  TalerErrorCode,
+  TalerPreciseTimestamp,
+  TransactionAction,
+  TransactionMajorState,
+  TransactionMinorState,
+  TransactionState,
+  TransactionType,
+  constructPayPushUri,
+  j2s,
+} from "@gnu-taler/taler-util";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import {
+  selectPeerCoins,
+  getTotalPeerPaymentCost,
+  codecForExchangePurseStatus,
+  queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+  PeerPushPaymentInitiationRecord,
+  PeerPushPaymentInitiationStatus,
+} from "../index.js";
+import { PendingTaskType } from "../pending-types.js";
+import {
+  OperationAttemptResult,
+  OperationAttemptResultType,
+  constructTaskIdentifier,
+} from "../util/retries.js";
+import {
+  runLongpollAsync,
+  spendCoins,
+  runOperationWithErrorReporting,
+} from "./common.js";
+import {
+  constructTransactionIdentifier,
+  notifyTransition,
+  stopLongpolling,
+} from "./transactions.js";
+import { assertUnreachable } from "../util/assertUnreachable.js";
+
+const logger = new Logger("pay-peer-push-debit.ts");
+
+export async function checkPeerPushDebit(
+  ws: InternalWalletState,
+  req: CheckPeerPushDebitRequest,
+): Promise<CheckPeerPushDebitResponse> {
+  const instructedAmount = Amounts.parseOrThrow(req.amount);
+  const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+  if (coinSelRes.type === "failure") {
+    throw TalerError.fromDetail(
+      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+      {
+        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+      },
+    );
+  }
+  const totalAmount = await getTotalPeerPaymentCost(
+    ws,
+    coinSelRes.result.coins,
+  );
+  return {
+    amountEffective: Amounts.stringify(totalAmount),
+    amountRaw: req.amount,
+  };
+}
+
+async function processPeerPushDebitCreateReserve(
+  ws: InternalWalletState,
+  peerPushInitiation: PeerPushPaymentInitiationRecord,
+): Promise<OperationAttemptResult> {
+  const pursePub = peerPushInitiation.pursePub;
+  const purseExpiration = peerPushInitiation.purseExpiration;
+  const hContractTerms = peerPushInitiation.contractTermsHash;
+
+  const purseSigResp = await ws.cryptoApi.signPurseCreation({
+    hContractTerms,
+    mergePub: peerPushInitiation.mergePub,
+    minAge: 0,
+    purseAmount: peerPushInitiation.amount,
+    purseExpiration,
+    pursePriv: peerPushInitiation.pursePriv,
+  });
+
+  const coins = await queryCoinInfosForSelection(
+    ws,
+    peerPushInitiation.coinSel,
+  );
+
+  const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
+    exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
+    pursePub: peerPushInitiation.pursePub,
+    coins,
+  });
+
+  const econtractResp = await ws.cryptoApi.encryptContractForMerge({
+    contractTerms: peerPushInitiation.contractTerms,
+    mergePriv: peerPushInitiation.mergePriv,
+    pursePriv: peerPushInitiation.pursePriv,
+    pursePub: peerPushInitiation.pursePub,
+    contractPriv: peerPushInitiation.contractPriv,
+    contractPub: peerPushInitiation.contractPub,
+  });
+
+  const createPurseUrl = new URL(
+    `purses/${peerPushInitiation.pursePub}/create`,
+    peerPushInitiation.exchangeBaseUrl,
+  );
+
+  const httpResp = await ws.http.fetch(createPurseUrl.href, {
+    method: "POST",
+    body: {
+      amount: peerPushInitiation.amount,
+      merge_pub: peerPushInitiation.mergePub,
+      purse_sig: purseSigResp.sig,
+      h_contract_terms: hContractTerms,
+      purse_expiration: purseExpiration,
+      deposits: depositSigsResp.deposits,
+      min_age: 0,
+      econtract: econtractResp.econtract,
+    },
+  });
+
+  const resp = await httpResp.json();
+
+  logger.info(`resp: ${j2s(resp)}`);
+
+  if (httpResp.status !== HttpStatusCode.Ok) {
+    throw Error("got error response from exchange");
+  }
+
+  await ws.db
+    .mktx((x) => [x.peerPushPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const ppi = await tx.peerPushPaymentInitiations.get(pursePub);
+      if (!ppi) {
+        return;
+      }
+      ppi.status = PeerPushPaymentInitiationStatus.Done;
+      await tx.peerPushPaymentInitiations.put(ppi);
+    });
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
+}
+
+async function transitionPeerPushDebitFromReadyToDone(
+  ws: InternalWalletState,
+  pursePub: string,
+): Promise<void> {
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushDebit,
+    pursePub,
+  });
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const ppiRec = await tx.peerPushPaymentInitiations.get(pursePub);
+      if (!ppiRec) {
+        return undefined;
+      }
+      if (ppiRec.status !== PeerPushPaymentInitiationStatus.PendingReady) {
+        return undefined;
+      }
+      const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+      ppiRec.status = PeerPushPaymentInitiationStatus.Done;
+      const newTxState = computePeerPushDebitTransactionState(ppiRec);
+      return {
+        oldTxState,
+        newTxState,
+      };
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+/**
+ * Process the "pending(ready)" state of a peer-push-debit transaction.
+ */
+async function processPeerPushDebitReady(
+  ws: InternalWalletState,
+  peerPushInitiation: PeerPushPaymentInitiationRecord,
+): Promise<OperationAttemptResult> {
+  const pursePub = peerPushInitiation.pursePub;
+  const retryTag = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushDebit,
+    pursePub,
+  });
+  runLongpollAsync(ws, retryTag, async (ct) => {
+    const mergeUrl = new URL(`purses/${pursePub}/merge`);
+    mergeUrl.searchParams.set("timeout_ms", "30000");
+    const resp = await ws.http.fetch(mergeUrl.href, {
+      // timeout: getReserveRequestTimeout(withdrawalGroup),
+      cancellationToken: ct,
+    });
+    if (resp.status === HttpStatusCode.Ok) {
+      const purseStatus = await readSuccessResponseJsonOrThrow(
+        resp,
+        codecForExchangePurseStatus(),
+      );
+      if (purseStatus.deposit_timestamp) {
+        await transitionPeerPushDebitFromReadyToDone(
+          ws,
+          peerPushInitiation.pursePub,
+        );
+        return {
+          ready: true,
+        };
+      }
+    } else if (resp.status === HttpStatusCode.Gone) {
+      // FIXME: transition the reserve into the expired state
+    }
+    return {
+      ready: false,
+    };
+  });
+  logger.trace(
+    "returning early from peer-push-debit for long-polling in background",
+  );
+  return {
+    type: OperationAttemptResultType.Longpoll,
+  };
+}
+
+export async function processPeerPushDebit(
+  ws: InternalWalletState,
+  pursePub: string,
+): Promise<OperationAttemptResult> {
+  const peerPushInitiation = await ws.db
+    .mktx((x) => [x.peerPushPaymentInitiations])
+    .runReadOnly(async (tx) => {
+      return tx.peerPushPaymentInitiations.get(pursePub);
+    });
+  if (!peerPushInitiation) {
+    throw Error("peer push payment not found");
+  }
+
+  const retryTag = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushDebit,
+    pursePub,
+  });
+
+  // We're already running!
+  if (ws.activeLongpoll[retryTag]) {
+    logger.info("peer-push-debit task already in long-polling, returning!");
+    return {
+      type: OperationAttemptResultType.Longpoll,
+    };
+  }
+
+  switch (peerPushInitiation.status) {
+    case PeerPushPaymentInitiationStatus.PendingCreatePurse:
+      return processPeerPushDebitCreateReserve(ws, peerPushInitiation);
+    case PeerPushPaymentInitiationStatus.PendingReady:
+      return processPeerPushDebitReady(ws, peerPushInitiation);
+  }
+
+  return {
+    type: OperationAttemptResultType.Finished,
+    result: undefined,
+  };
+}
+
+/**
+ * Initiate sending a peer-to-peer push payment.
+ */
+export async function initiatePeerPushDebit(
+  ws: InternalWalletState,
+  req: InitiatePeerPushDebitRequest,
+): Promise<InitiatePeerPushDebitResponse> {
+  const instructedAmount = Amounts.parseOrThrow(
+    req.partialContractTerms.amount,
+  );
+  const purseExpiration = req.partialContractTerms.purse_expiration;
+  const contractTerms = req.partialContractTerms;
+
+  const pursePair = await ws.cryptoApi.createEddsaKeypair({});
+  const mergePair = await ws.cryptoApi.createEddsaKeypair({});
+
+  const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+  const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
+
+  const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+
+  if (coinSelRes.type !== "success") {
+    throw TalerError.fromDetail(
+      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+      {
+        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+      },
+    );
+  }
+
+  const sel = coinSelRes.result;
+
+  logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`);
+
+  const totalAmount = await getTotalPeerPaymentCost(
+    ws,
+    coinSelRes.result.coins,
+  );
+
+  await ws.db
+    .mktx((x) => [
+      x.exchanges,
+      x.contractTerms,
+      x.coins,
+      x.coinAvailability,
+      x.denominations,
+      x.refreshGroups,
+      x.peerPushPaymentInitiations,
+    ])
+    .runReadWrite(async (tx) => {
+      // FIXME: Instead of directly doing a spendCoin here,
+      // we might want to mark the coins as used and spend them
+      // after we've been able to create the purse.
+      await spendCoins(ws, tx, {
+        // allocationId: `txn:peer-push-debit:${pursePair.pub}`,
+        allocationId: constructTransactionIdentifier({
+          tag: TransactionType.PeerPushDebit,
+          pursePub: pursePair.pub,
+        }),
+        coinPubs: sel.coins.map((x) => x.coinPub),
+        contributions: sel.coins.map((x) =>
+          Amounts.parseOrThrow(x.contribution),
+        ),
+        refreshReason: RefreshReason.PayPeerPush,
+      });
+
+      await tx.peerPushPaymentInitiations.add({
+        amount: Amounts.stringify(instructedAmount),
+        contractPriv: contractKeyPair.priv,
+        contractPub: contractKeyPair.pub,
+        contractTermsHash: hContractTerms,
+        exchangeBaseUrl: sel.exchangeBaseUrl,
+        mergePriv: mergePair.priv,
+        mergePub: mergePair.pub,
+        purseExpiration: purseExpiration,
+        pursePriv: pursePair.priv,
+        pursePub: pursePair.pub,
+        timestampCreated: TalerPreciseTimestamp.now(),
+        status: PeerPushPaymentInitiationStatus.PendingCreatePurse,
+        contractTerms: contractTerms,
+        coinSel: {
+          coinPubs: sel.coins.map((x) => x.coinPub),
+          contributions: sel.coins.map((x) => x.contribution),
+        },
+        totalCost: Amounts.stringify(totalAmount),
+      });
+
+      await tx.contractTerms.put({
+        h: hContractTerms,
+        contractTermsRaw: contractTerms,
+      });
+    });
+
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushDebit,
+    pursePub: pursePair.pub,
+  });
+
+  await runOperationWithErrorReporting(ws, taskId, async () => {
+    return await processPeerPushDebit(ws, pursePair.pub);
+  });
+
+  return {
+    contractPriv: contractKeyPair.priv,
+    mergePriv: mergePair.priv,
+    pursePub: pursePair.pub,
+    exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+    talerUri: constructPayPushUri({
+      exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+      contractPriv: contractKeyPair.priv,
+    }),
+    transactionId: constructTransactionIdentifier({
+      tag: TransactionType.PeerPushDebit,
+      pursePub: pursePair.pub,
+    }),
+  };
+}
+
+export function computePeerPushDebitTransactionActions(
+  ppiRecord: PeerPushPaymentInitiationRecord,
+): TransactionAction[] {
+  switch (ppiRecord.status) {
+    case PeerPushPaymentInitiationStatus.PendingCreatePurse:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPushPaymentInitiationStatus.PendingReady:
+      return [TransactionAction.Abort, TransactionAction.Suspend];
+    case PeerPushPaymentInitiationStatus.Aborted:
+      return [TransactionAction.Delete];
+    case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
+      return [TransactionAction.Suspend, TransactionAction.Fail];
+    case PeerPushPaymentInitiationStatus.AbortingRefresh:
+      return [TransactionAction.Suspend, TransactionAction.Fail];
+    case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+      return [TransactionAction.Resume, TransactionAction.Fail];
+    case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
+      return [TransactionAction.Resume, TransactionAction.Fail];
+    case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
+      return [TransactionAction.Resume, TransactionAction.Abort];
+    case PeerPushPaymentInitiationStatus.SuspendedReady:
+      return [TransactionAction.Suspend, TransactionAction.Abort];
+    case PeerPushPaymentInitiationStatus.Done:
+      return [TransactionAction.Delete];
+    case PeerPushPaymentInitiationStatus.Failed:
+      return [TransactionAction.Delete];
+  }
+}
+
+export async function abortPeerPushDebitTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushDebit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushDebit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
+      if (!pushDebitRec) {
+        logger.warn(`peer push debit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
+      switch (pushDebitRec.status) {
+        case PeerPushPaymentInitiationStatus.PendingReady:
+        case PeerPushPaymentInitiationStatus.SuspendedReady:
+          newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
+          break;
+        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
+        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
+          // Network request might already be in-flight!
+          newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
+          break;
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+        case PeerPushPaymentInitiationStatus.AbortingRefresh:
+        case PeerPushPaymentInitiationStatus.Done:
+        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
+        case PeerPushPaymentInitiationStatus.Aborted:
+          // Do nothing
+          break;
+        case PeerPushPaymentInitiationStatus.Failed:
+          break;
+        default:
+          assertUnreachable(pushDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        pushDebitRec.status = newStatus;
+        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        await tx.peerPushPaymentInitiations.put(pushDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function failPeerPushDebitTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushDebit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushDebit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
+      if (!pushDebitRec) {
+        logger.warn(`peer push debit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
+      switch (pushDebitRec.status) {
+        case PeerPushPaymentInitiationStatus.AbortingRefresh:
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
+          // FIXME: We also need to abort the refresh group!
+          newStatus = PeerPushPaymentInitiationStatus.Aborted;
+          break;
+        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+          newStatus = PeerPushPaymentInitiationStatus.Aborted;
+          break;
+        case PeerPushPaymentInitiationStatus.PendingReady:
+        case PeerPushPaymentInitiationStatus.SuspendedReady:
+        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
+        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
+        case PeerPushPaymentInitiationStatus.Done:
+        case PeerPushPaymentInitiationStatus.Aborted:
+        case PeerPushPaymentInitiationStatus.Failed:
+          // Do nothing
+          break;
+        default:
+          assertUnreachable(pushDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        pushDebitRec.status = newStatus;
+        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        await tx.peerPushPaymentInitiations.put(pushDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function suspendPeerPushDebitTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushDebit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushDebit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
+      if (!pushDebitRec) {
+        logger.warn(`peer push debit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
+      switch (pushDebitRec.status) {
+        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
+          newStatus = PeerPushPaymentInitiationStatus.SuspendedCreatePurse;
+          break;
+        case PeerPushPaymentInitiationStatus.AbortingRefresh:
+          newStatus = PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh;
+          break;
+        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
+          newStatus =
+            PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse;
+          break;
+        case PeerPushPaymentInitiationStatus.PendingReady:
+          newStatus = PeerPushPaymentInitiationStatus.SuspendedReady;
+          break;
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
+        case PeerPushPaymentInitiationStatus.SuspendedReady:
+        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
+        case PeerPushPaymentInitiationStatus.Done:
+        case PeerPushPaymentInitiationStatus.Aborted:
+        case PeerPushPaymentInitiationStatus.Failed:
+          // Do nothing
+          break;
+        default:
+          assertUnreachable(pushDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        pushDebitRec.status = newStatus;
+        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        await tx.peerPushPaymentInitiations.put(pushDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+export async function resumePeerPushDebitTransaction(
+  ws: InternalWalletState,
+  pursePub: string,
+) {
+  const taskId = constructTaskIdentifier({
+    tag: PendingTaskType.PeerPushDebit,
+    pursePub,
+  });
+  const transactionId = constructTransactionIdentifier({
+    tag: TransactionType.PeerPushDebit,
+    pursePub,
+  });
+  stopLongpolling(ws, taskId);
+  const transitionInfo = await ws.db
+    .mktx((x) => [x.peerPushPaymentInitiations])
+    .runReadWrite(async (tx) => {
+      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
+      if (!pushDebitRec) {
+        logger.warn(`peer push debit ${pursePub} not found`);
+        return;
+      }
+      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
+      switch (pushDebitRec.status) {
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+          newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
+          break;
+        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
+          newStatus = PeerPushPaymentInitiationStatus.AbortingRefresh;
+          break;
+        case PeerPushPaymentInitiationStatus.SuspendedReady:
+          newStatus = PeerPushPaymentInitiationStatus.PendingReady;
+          break;
+        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
+          newStatus = PeerPushPaymentInitiationStatus.PendingCreatePurse;
+          break;
+        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
+        case PeerPushPaymentInitiationStatus.AbortingRefresh:
+        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
+        case PeerPushPaymentInitiationStatus.PendingReady:
+        case PeerPushPaymentInitiationStatus.Done:
+        case PeerPushPaymentInitiationStatus.Aborted:
+        case PeerPushPaymentInitiationStatus.Failed:
+          // Do nothing
+          break;
+        default:
+          assertUnreachable(pushDebitRec.status);
+      }
+      if (newStatus != null) {
+        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        pushDebitRec.status = newStatus;
+        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+        await tx.peerPushPaymentInitiations.put(pushDebitRec);
+        return {
+          oldTxState,
+          newTxState,
+        };
+      }
+      return undefined;
+    });
+  ws.workAvailable.trigger();
+  notifyTransition(ws, transactionId, transitionInfo);
+}
+
+
+export function computePeerPushDebitTransactionState(
+  ppiRecord: PeerPushPaymentInitiationRecord,
+): TransactionState {
+  switch (ppiRecord.status) {
+    case PeerPushPaymentInitiationStatus.PendingCreatePurse:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.CreatePurse,
+      };
+    case PeerPushPaymentInitiationStatus.PendingReady:
+      return {
+        major: TransactionMajorState.Pending,
+        minor: TransactionMinorState.Ready,
+      };
+    case PeerPushPaymentInitiationStatus.Aborted:
+      return {
+        major: TransactionMajorState.Aborted,
+      };
+    case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
+      return {
+        major: TransactionMajorState.Aborting,
+        minor: TransactionMinorState.DeletePurse,
+      };
+    case PeerPushPaymentInitiationStatus.AbortingRefresh:
+      return {
+        major: TransactionMajorState.Aborting,
+        minor: TransactionMinorState.Refresh,
+      };
+    case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
+      return {
+        major: TransactionMajorState.SuspendedAborting,
+        minor: TransactionMinorState.DeletePurse,
+      };
+    case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
+      return {
+        major: TransactionMajorState.SuspendedAborting,
+        minor: TransactionMinorState.Refresh,
+      };
+    case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.CreatePurse,
+      };
+    case PeerPushPaymentInitiationStatus.SuspendedReady:
+      return {
+        major: TransactionMajorState.Suspended,
+        minor: TransactionMinorState.Ready,
+      };
+    case PeerPushPaymentInitiationStatus.Done:
+      return {
+        major: TransactionMajorState.Done,
+      };
+    case PeerPushPaymentInitiationStatus.Failed:
+      return {
+        major: TransactionMajorState.Failed,
+      };
+  }
+}
\ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/pay-peer.ts 
b/packages/taler-wallet-core/src/operations/pay-peer.ts
deleted file mode 100644
index 28fef6afc..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer.ts
+++ /dev/null
@@ -1,3226 +0,0 @@
-/*
- This file is part of GNU Taler
- (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
- 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 {
-  AbsoluteTime,
-  ConfirmPeerPullDebitRequest,
-  AcceptPeerPullPaymentResponse,
-  ConfirmPeerPushCreditRequest,
-  AcceptPeerPushPaymentResponse,
-  AgeCommitmentProof,
-  AmountJson,
-  Amounts,
-  AmountString,
-  buildCodecForObject,
-  PreparePeerPullDebitRequest,
-  PreparePeerPullDebitResponse,
-  PreparePeerPushCredit,
-  PreparePeerPushCreditResponse,
-  Codec,
-  codecForAmountString,
-  codecForAny,
-  codecForExchangeGetContractResponse,
-  codecForPeerContractTerms,
-  CoinStatus,
-  constructPayPullUri,
-  constructPayPushUri,
-  ContractTermsUtil,
-  decodeCrock,
-  eddsaGetPublic,
-  encodeCrock,
-  ExchangePurseDeposits,
-  ExchangePurseMergeRequest,
-  ExchangeReservePurseRequest,
-  getRandomBytes,
-  InitiatePeerPullCreditRequest,
-  InitiatePeerPullCreditResponse,
-  InitiatePeerPushDebitRequest,
-  InitiatePeerPushDebitResponse,
-  j2s,
-  Logger,
-  parsePayPullUri,
-  parsePayPushUri,
-  PayPeerInsufficientBalanceDetails,
-  PeerContractTerms,
-  CheckPeerPullCreditRequest,
-  CheckPeerPullCreditResponse,
-  CheckPeerPushDebitRequest,
-  CheckPeerPushDebitResponse,
-  RefreshReason,
-  strcmp,
-  TalerErrorCode,
-  TalerProtocolTimestamp,
-  TransactionType,
-  UnblindedSignature,
-  WalletAccountMergeFlags,
-  codecOptional,
-  codecForTimestamp,
-  CancellationToken,
-  NotificationType,
-  HttpStatusCode,
-  codecForWalletKycUuid,
-  TransactionState,
-  TransactionMajorState,
-  TransactionMinorState,
-  TalerPreciseTimestamp,
-  TransactionAction,
-} from "@gnu-taler/taler-util";
-import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
-import {
-  DenominationRecord,
-  PeerPullPaymentIncomingRecord,
-  PeerPullDebitRecordStatus,
-  PeerPullPaymentInitiationRecord,
-  PeerPullPaymentInitiationStatus,
-  PeerPushPaymentCoinSelection,
-  PeerPushPaymentIncomingRecord,
-  PeerPushPaymentIncomingStatus,
-  PeerPushPaymentInitiationRecord,
-  PeerPushPaymentInitiationStatus,
-  ReserveRecord,
-  WithdrawalGroupStatus,
-  WithdrawalRecordType,
-} from "../db.js";
-import { TalerError } from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
-  LongpollResult,
-  resetOperationTimeout,
-  runLongpollAsync,
-  runOperationWithErrorReporting,
-  spendCoins,
-} from "../operations/common.js";
-import {
-  readSuccessResponseJsonOrErrorCode,
-  readSuccessResponseJsonOrThrow,
-  throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
-  constructTaskIdentifier,
-  OperationAttemptResult,
-  OperationAttemptResultType,
-  TaskIdentifiers,
-} from "../util/retries.js";
-import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
-import { getTotalRefreshCost } from "./refresh.js";
-import {
-  checkWithdrawalKycStatus,
-  getExchangeWithdrawalInfo,
-  internalCreateWithdrawalGroup,
-  processWithdrawalGroup,
-} from "./withdraw.js";
-import { PendingTaskType } from "../pending-types.js";
-import {
-  constructTransactionIdentifier,
-  notifyTransition,
-  stopLongpolling,
-} from "./transactions.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-
-const logger = new Logger("operations/peer-to-peer.ts");
-
-interface SelectedPeerCoin {
-  coinPub: string;
-  coinPriv: string;
-  contribution: AmountString;
-  denomPubHash: string;
-  denomSig: UnblindedSignature;
-  ageCommitmentProof: AgeCommitmentProof | undefined;
-}
-
-interface PeerCoinSelectionDetails {
-  exchangeBaseUrl: string;
-
-  /**
-   * Info of Coins that were selected.
-   */
-  coins: SelectedPeerCoin[];
-
-  /**
-   * How much of the deposit fees is the customer paying?
-   */
-  depositFees: AmountJson;
-}
-
-/**
- * Information about a selected coin for peer to peer payments.
- */
-interface CoinInfo {
-  /**
-   * Public key of the coin.
-   */
-  coinPub: string;
-
-  coinPriv: string;
-
-  /**
-   * Deposit fee for the coin.
-   */
-  feeDeposit: AmountJson;
-
-  value: AmountJson;
-
-  denomPubHash: string;
-
-  denomSig: UnblindedSignature;
-
-  maxAge: number;
-
-  ageCommitmentProof?: AgeCommitmentProof;
-}
-
-export type SelectPeerCoinsResult =
-  | { type: "success"; result: PeerCoinSelectionDetails }
-  | {
-      type: "failure";
-      insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
-    };
-
-export async function queryCoinInfosForSelection(
-  ws: InternalWalletState,
-  csel: PeerPushPaymentCoinSelection,
-): Promise<SpendCoinDetails[]> {
-  let infos: SpendCoinDetails[] = [];
-  await ws.db
-    .mktx((x) => [x.coins, x.denominations])
-    .runReadOnly(async (tx) => {
-      for (let i = 0; i < csel.coinPubs.length; i++) {
-        const coin = await tx.coins.get(csel.coinPubs[i]);
-        if (!coin) {
-          throw Error("coin not found anymore");
-        }
-        const denom = await ws.getDenomInfo(
-          ws,
-          tx,
-          coin.exchangeBaseUrl,
-          coin.denomPubHash,
-        );
-        if (!denom) {
-          throw Error("denom for coin not found anymore");
-        }
-        infos.push({
-          coinPriv: coin.coinPriv,
-          coinPub: coin.coinPub,
-          denomPubHash: coin.denomPubHash,
-          denomSig: coin.denomSig,
-          ageCommitmentProof: coin.ageCommitmentProof,
-          contribution: csel.contributions[i],
-        });
-      }
-    });
-  return infos;
-}
-
-export async function selectPeerCoins(
-  ws: InternalWalletState,
-  instructedAmount: AmountJson,
-): Promise<SelectPeerCoinsResult> {
-  if (Amounts.isZero(instructedAmount)) {
-    // Other parts of the code assume that we have at least
-    // one coin to spend.
-    throw new Error("amount of zero not allowed");
-  }
-  return await ws.db
-    .mktx((x) => [
-      x.exchanges,
-      x.contractTerms,
-      x.coins,
-      x.coinAvailability,
-      x.denominations,
-      x.refreshGroups,
-      x.peerPushPaymentInitiations,
-    ])
-    .runReadWrite(async (tx) => {
-      const exchanges = await tx.exchanges.iter().toArray();
-      const exchangeFeeGap: { [url: string]: AmountJson } = {};
-      const currency = Amounts.currencyOf(instructedAmount);
-      for (const exch of exchanges) {
-        if (exch.detailsPointer?.currency !== currency) {
-          continue;
-        }
-        const coins = (
-          await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
-        ).filter((x) => x.status === CoinStatus.Fresh);
-        const coinInfos: CoinInfo[] = [];
-        for (const coin of coins) {
-          const denom = await ws.getDenomInfo(
-            ws,
-            tx,
-            coin.exchangeBaseUrl,
-            coin.denomPubHash,
-          );
-          if (!denom) {
-            throw Error("denom not found");
-          }
-          coinInfos.push({
-            coinPub: coin.coinPub,
-            feeDeposit: Amounts.parseOrThrow(denom.feeDeposit),
-            value: Amounts.parseOrThrow(denom.value),
-            denomPubHash: denom.denomPubHash,
-            coinPriv: coin.coinPriv,
-            denomSig: coin.denomSig,
-            maxAge: coin.maxAge,
-            ageCommitmentProof: coin.ageCommitmentProof,
-          });
-        }
-        if (coinInfos.length === 0) {
-          continue;
-        }
-        coinInfos.sort(
-          (o1, o2) =>
-            -Amounts.cmp(o1.value, o2.value) ||
-            strcmp(o1.denomPubHash, o2.denomPubHash),
-        );
-        let amountAcc = Amounts.zeroOfCurrency(currency);
-        let depositFeesAcc = Amounts.zeroOfCurrency(currency);
-        const resCoins: {
-          coinPub: string;
-          coinPriv: string;
-          contribution: AmountString;
-          denomPubHash: string;
-          denomSig: UnblindedSignature;
-          ageCommitmentProof: AgeCommitmentProof | undefined;
-        }[] = [];
-        let lastDepositFee = Amounts.zeroOfCurrency(currency);
-        for (const coin of coinInfos) {
-          if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
-            break;
-          }
-          const gap = Amounts.add(
-            coin.feeDeposit,
-            Amounts.sub(instructedAmount, amountAcc).amount,
-          ).amount;
-          const contrib = Amounts.min(gap, coin.value);
-          amountAcc = Amounts.add(
-            amountAcc,
-            Amounts.sub(contrib, coin.feeDeposit).amount,
-          ).amount;
-          depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
-          resCoins.push({
-            coinPriv: coin.coinPriv,
-            coinPub: coin.coinPub,
-            contribution: Amounts.stringify(contrib),
-            denomPubHash: coin.denomPubHash,
-            denomSig: coin.denomSig,
-            ageCommitmentProof: coin.ageCommitmentProof,
-          });
-          lastDepositFee = coin.feeDeposit;
-        }
-        if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
-          const res: PeerCoinSelectionDetails = {
-            exchangeBaseUrl: exch.baseUrl,
-            coins: resCoins,
-            depositFees: depositFeesAcc,
-          };
-          return { type: "success", result: res };
-        }
-        const diff = Amounts.sub(instructedAmount, amountAcc).amount;
-        exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, 
diff).amount;
-
-        continue;
-      }
-      // We were unable to select coins.
-      // Now we need to produce error details.
-
-      const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
-        currency,
-      });
-
-      const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
-
-      let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
-
-      for (const exch of exchanges) {
-        if (exch.detailsPointer?.currency !== currency) {
-          continue;
-        }
-        const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
-          currency,
-          restrictExchangeTo: exch.baseUrl,
-        });
-        let gap =
-          exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
-        if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
-          // Show fee gap only if we should've been able to pay with the 
material amount
-          gap = Amounts.zeroOfCurrency(currency);
-        }
-        perExchange[exch.baseUrl] = {
-          balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
-          balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
-          feeGapEstimate: Amounts.stringify(gap),
-        };
-
-        maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
-      }
-
-      const errDetails: PayPeerInsufficientBalanceDetails = {
-        amountRequested: Amounts.stringify(instructedAmount),
-        balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
-        balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
-        feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
-        perExchange,
-      };
-
-      return { type: "failure", insufficientBalanceDetails: errDetails };
-    });
-}
-
-export async function getTotalPeerPaymentCost(
-  ws: InternalWalletState,
-  pcs: SelectedPeerCoin[],
-): Promise<AmountJson> {
-  return ws.db
-    .mktx((x) => [x.coins, x.denominations])
-    .runReadOnly(async (tx) => {
-      const costs: AmountJson[] = [];
-      for (let i = 0; i < pcs.length; i++) {
-        const coin = await tx.coins.get(pcs[i].coinPub);
-        if (!coin) {
-          throw Error("can't calculate payment cost, coin not found");
-        }
-        const denom = await tx.denominations.get([
-          coin.exchangeBaseUrl,
-          coin.denomPubHash,
-        ]);
-        if (!denom) {
-          throw Error(
-            "can't calculate payment cost, denomination for coin not found",
-          );
-        }
-        const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
-          .iter(coin.exchangeBaseUrl)
-          .filter((x) =>
-            Amounts.isSameCurrency(
-              DenominationRecord.getValue(x),
-              pcs[i].contribution,
-            ),
-          );
-        const amountLeft = Amounts.sub(
-          DenominationRecord.getValue(denom),
-          pcs[i].contribution,
-        ).amount;
-        const refreshCost = getTotalRefreshCost(
-          allDenoms,
-          DenominationRecord.toDenomInfo(denom),
-          amountLeft,
-          ws.config.testing.denomselAllowLate,
-        );
-        costs.push(Amounts.parseOrThrow(pcs[i].contribution));
-        costs.push(refreshCost);
-      }
-      const zero = Amounts.zeroOfAmount(pcs[0].contribution);
-      return Amounts.sum([zero, ...costs]).amount;
-    });
-}
-
-export async function checkPeerPushDebit(
-  ws: InternalWalletState,
-  req: CheckPeerPushDebitRequest,
-): Promise<CheckPeerPushDebitResponse> {
-  const instructedAmount = Amounts.parseOrThrow(req.amount);
-  const coinSelRes = await selectPeerCoins(ws, instructedAmount);
-  if (coinSelRes.type === "failure") {
-    throw TalerError.fromDetail(
-      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
-      {
-        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
-      },
-    );
-  }
-  const totalAmount = await getTotalPeerPaymentCost(
-    ws,
-    coinSelRes.result.coins,
-  );
-  return {
-    amountEffective: Amounts.stringify(totalAmount),
-    amountRaw: req.amount,
-  };
-}
-
-async function processPeerPushDebitCreateReserve(
-  ws: InternalWalletState,
-  peerPushInitiation: PeerPushPaymentInitiationRecord,
-): Promise<OperationAttemptResult> {
-  const pursePub = peerPushInitiation.pursePub;
-  const purseExpiration = peerPushInitiation.purseExpiration;
-  const hContractTerms = peerPushInitiation.contractTermsHash;
-
-  const purseSigResp = await ws.cryptoApi.signPurseCreation({
-    hContractTerms,
-    mergePub: peerPushInitiation.mergePub,
-    minAge: 0,
-    purseAmount: peerPushInitiation.amount,
-    purseExpiration,
-    pursePriv: peerPushInitiation.pursePriv,
-  });
-
-  const coins = await queryCoinInfosForSelection(
-    ws,
-    peerPushInitiation.coinSel,
-  );
-
-  const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
-    exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
-    pursePub: peerPushInitiation.pursePub,
-    coins,
-  });
-
-  const econtractResp = await ws.cryptoApi.encryptContractForMerge({
-    contractTerms: peerPushInitiation.contractTerms,
-    mergePriv: peerPushInitiation.mergePriv,
-    pursePriv: peerPushInitiation.pursePriv,
-    pursePub: peerPushInitiation.pursePub,
-    contractPriv: peerPushInitiation.contractPriv,
-    contractPub: peerPushInitiation.contractPub,
-  });
-
-  const createPurseUrl = new URL(
-    `purses/${peerPushInitiation.pursePub}/create`,
-    peerPushInitiation.exchangeBaseUrl,
-  );
-
-  const httpResp = await ws.http.fetch(createPurseUrl.href, {
-    method: "POST",
-    body: {
-      amount: peerPushInitiation.amount,
-      merge_pub: peerPushInitiation.mergePub,
-      purse_sig: purseSigResp.sig,
-      h_contract_terms: hContractTerms,
-      purse_expiration: purseExpiration,
-      deposits: depositSigsResp.deposits,
-      min_age: 0,
-      econtract: econtractResp.econtract,
-    },
-  });
-
-  const resp = await httpResp.json();
-
-  logger.info(`resp: ${j2s(resp)}`);
-
-  if (httpResp.status !== HttpStatusCode.Ok) {
-    throw Error("got error response from exchange");
-  }
-
-  await ws.db
-    .mktx((x) => [x.peerPushPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const ppi = await tx.peerPushPaymentInitiations.get(pursePub);
-      if (!ppi) {
-        return;
-      }
-      ppi.status = PeerPushPaymentInitiationStatus.Done;
-      await tx.peerPushPaymentInitiations.put(ppi);
-    });
-
-  return {
-    type: OperationAttemptResultType.Finished,
-    result: undefined,
-  };
-}
-
-async function transitionPeerPushDebitFromReadyToDone(
-  ws: InternalWalletState,
-  pursePub: string,
-): Promise<void> {
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushDebit,
-    pursePub,
-  });
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const ppiRec = await tx.peerPushPaymentInitiations.get(pursePub);
-      if (!ppiRec) {
-        return undefined;
-      }
-      if (ppiRec.status !== PeerPushPaymentInitiationStatus.PendingReady) {
-        return undefined;
-      }
-      const oldTxState = computePeerPushDebitTransactionState(ppiRec);
-      ppiRec.status = PeerPushPaymentInitiationStatus.Done;
-      const newTxState = computePeerPushDebitTransactionState(ppiRec);
-      return {
-        oldTxState,
-        newTxState,
-      };
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-/**
- * Process the "pending(ready)" state of a peer-push-debit transaction.
- */
-async function processPeerPushDebitReady(
-  ws: InternalWalletState,
-  peerPushInitiation: PeerPushPaymentInitiationRecord,
-): Promise<OperationAttemptResult> {
-  const pursePub = peerPushInitiation.pursePub;
-  const retryTag = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushDebit,
-    pursePub,
-  });
-  runLongpollAsync(ws, retryTag, async (ct) => {
-    const mergeUrl = new URL(`purses/${pursePub}/merge`);
-    mergeUrl.searchParams.set("timeout_ms", "30000");
-    const resp = await ws.http.fetch(mergeUrl.href, {
-      // timeout: getReserveRequestTimeout(withdrawalGroup),
-      cancellationToken: ct,
-    });
-    if (resp.status === HttpStatusCode.Ok) {
-      const purseStatus = await readSuccessResponseJsonOrThrow(
-        resp,
-        codecForExchangePurseStatus(),
-      );
-      if (purseStatus.deposit_timestamp) {
-        await transitionPeerPushDebitFromReadyToDone(
-          ws,
-          peerPushInitiation.pursePub,
-        );
-        return {
-          ready: true,
-        };
-      }
-    } else if (resp.status === HttpStatusCode.Gone) {
-      // FIXME: transition the reserve into the expired state
-    }
-    return {
-      ready: false,
-    };
-  });
-  logger.trace(
-    "returning early from withdrawal for long-polling in background",
-  );
-  return {
-    type: OperationAttemptResultType.Longpoll,
-  };
-}
-
-export async function processPeerPushDebit(
-  ws: InternalWalletState,
-  pursePub: string,
-): Promise<OperationAttemptResult> {
-  const peerPushInitiation = await ws.db
-    .mktx((x) => [x.peerPushPaymentInitiations])
-    .runReadOnly(async (tx) => {
-      return tx.peerPushPaymentInitiations.get(pursePub);
-    });
-  if (!peerPushInitiation) {
-    throw Error("peer push payment not found");
-  }
-
-  const retryTag = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushDebit,
-    pursePub,
-  });
-
-  // We're already running!
-  if (ws.activeLongpoll[retryTag]) {
-    logger.info("peer-push-debit task already in long-polling, returning!");
-    return {
-      type: OperationAttemptResultType.Longpoll,
-    };
-  }
-
-  switch (peerPushInitiation.status) {
-    case PeerPushPaymentInitiationStatus.PendingCreatePurse:
-      return processPeerPushDebitCreateReserve(ws, peerPushInitiation);
-    case PeerPushPaymentInitiationStatus.PendingReady:
-      return processPeerPushDebitReady(ws, peerPushInitiation);
-  }
-
-  return {
-    type: OperationAttemptResultType.Finished,
-    result: undefined,
-  };
-}
-
-/**
- * Initiate sending a peer-to-peer push payment.
- */
-export async function initiatePeerPushDebit(
-  ws: InternalWalletState,
-  req: InitiatePeerPushDebitRequest,
-): Promise<InitiatePeerPushDebitResponse> {
-  const instructedAmount = Amounts.parseOrThrow(
-    req.partialContractTerms.amount,
-  );
-  const purseExpiration = req.partialContractTerms.purse_expiration;
-  const contractTerms = req.partialContractTerms;
-
-  const pursePair = await ws.cryptoApi.createEddsaKeypair({});
-  const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-
-  const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
-  const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
-
-  const coinSelRes = await selectPeerCoins(ws, instructedAmount);
-
-  if (coinSelRes.type !== "success") {
-    throw TalerError.fromDetail(
-      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
-      {
-        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
-      },
-    );
-  }
-
-  const sel = coinSelRes.result;
-
-  logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`);
-
-  const totalAmount = await getTotalPeerPaymentCost(
-    ws,
-    coinSelRes.result.coins,
-  );
-
-  await ws.db
-    .mktx((x) => [
-      x.exchanges,
-      x.contractTerms,
-      x.coins,
-      x.coinAvailability,
-      x.denominations,
-      x.refreshGroups,
-      x.peerPushPaymentInitiations,
-    ])
-    .runReadWrite(async (tx) => {
-      // FIXME: Instead of directly doing a spendCoin here,
-      // we might want to mark the coins as used and spend them
-      // after we've been able to create the purse.
-      await spendCoins(ws, tx, {
-        // allocationId: `txn:peer-push-debit:${pursePair.pub}`,
-        allocationId: constructTransactionIdentifier({
-          tag: TransactionType.PeerPushDebit,
-          pursePub: pursePair.pub,
-        }),
-        coinPubs: sel.coins.map((x) => x.coinPub),
-        contributions: sel.coins.map((x) =>
-          Amounts.parseOrThrow(x.contribution),
-        ),
-        refreshReason: RefreshReason.PayPeerPush,
-      });
-
-      await tx.peerPushPaymentInitiations.add({
-        amount: Amounts.stringify(instructedAmount),
-        contractPriv: contractKeyPair.priv,
-        contractPub: contractKeyPair.pub,
-        contractTermsHash: hContractTerms,
-        exchangeBaseUrl: sel.exchangeBaseUrl,
-        mergePriv: mergePair.priv,
-        mergePub: mergePair.pub,
-        purseExpiration: purseExpiration,
-        pursePriv: pursePair.priv,
-        pursePub: pursePair.pub,
-        timestampCreated: TalerPreciseTimestamp.now(),
-        status: PeerPushPaymentInitiationStatus.PendingCreatePurse,
-        contractTerms: contractTerms,
-        coinSel: {
-          coinPubs: sel.coins.map((x) => x.coinPub),
-          contributions: sel.coins.map((x) => x.contribution),
-        },
-        totalCost: Amounts.stringify(totalAmount),
-      });
-
-      await tx.contractTerms.put({
-        h: hContractTerms,
-        contractTermsRaw: contractTerms,
-      });
-    });
-
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushDebit,
-    pursePub: pursePair.pub,
-  });
-
-  await runOperationWithErrorReporting(ws, taskId, async () => {
-    return await processPeerPushDebit(ws, pursePair.pub);
-  });
-
-  return {
-    contractPriv: contractKeyPair.priv,
-    mergePriv: mergePair.priv,
-    pursePub: pursePair.pub,
-    exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
-    talerUri: constructPayPushUri({
-      exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
-      contractPriv: contractKeyPair.priv,
-    }),
-    transactionId: constructTransactionIdentifier({
-      tag: TransactionType.PeerPushDebit,
-      pursePub: pursePair.pub,
-    }),
-  };
-}
-
-interface ExchangePurseStatus {
-  balance: AmountString;
-  deposit_timestamp?: TalerProtocolTimestamp;
-  merge_timestamp?: TalerProtocolTimestamp;
-}
-
-export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
-  buildCodecForObject<ExchangePurseStatus>()
-    .property("balance", codecForAmountString())
-    .property("deposit_timestamp", codecOptional(codecForTimestamp))
-    .property("merge_timestamp", codecOptional(codecForTimestamp))
-    .build("ExchangePurseStatus");
-
-export async function preparePeerPushCredit(
-  ws: InternalWalletState,
-  req: PreparePeerPushCredit,
-): Promise<PreparePeerPushCreditResponse> {
-  const uri = parsePayPushUri(req.talerUri);
-
-  if (!uri) {
-    throw Error("got invalid taler://pay-push URI");
-  }
-
-  const existing = await ws.db
-    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
-    .runReadOnly(async (tx) => {
-      const existingPushInc =
-        await 
tx.peerPushPaymentIncoming.indexes.byExchangeAndContractPriv.get([
-          uri.exchangeBaseUrl,
-          uri.contractPriv,
-        ]);
-      if (!existingPushInc) {
-        return;
-      }
-      const existingContractTermsRec = await tx.contractTerms.get(
-        existingPushInc.contractTermsHash,
-      );
-      if (!existingContractTermsRec) {
-        throw Error(
-          "contract terms for peer push payment credit not found in database",
-        );
-      }
-      const existingContractTerms = codecForPeerContractTerms().decode(
-        existingContractTermsRec.contractTermsRaw,
-      );
-      return { existingPushInc, existingContractTerms };
-    });
-
-  if (existing) {
-    return {
-      amount: existing.existingContractTerms.amount,
-      amountEffective: existing.existingPushInc.estimatedAmountEffective,
-      amountRaw: existing.existingContractTerms.amount,
-      contractTerms: existing.existingContractTerms,
-      peerPushPaymentIncomingId:
-        existing.existingPushInc.peerPushPaymentIncomingId,
-      transactionId: constructTransactionIdentifier({
-        tag: TransactionType.PeerPushCredit,
-        peerPushPaymentIncomingId:
-          existing.existingPushInc.peerPushPaymentIncomingId,
-      }),
-    };
-  }
-
-  const exchangeBaseUrl = uri.exchangeBaseUrl;
-
-  await updateExchangeFromUrl(ws, exchangeBaseUrl);
-
-  const contractPriv = uri.contractPriv;
-  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
-
-  const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
-
-  const contractHttpResp = await ws.http.get(getContractUrl.href);
-
-  const contractResp = await readSuccessResponseJsonOrThrow(
-    contractHttpResp,
-    codecForExchangeGetContractResponse(),
-  );
-
-  const pursePub = contractResp.purse_pub;
-
-  const dec = await ws.cryptoApi.decryptContractForMerge({
-    ciphertext: contractResp.econtract,
-    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));
-
-  const contractTermsHash = ContractTermsUtil.hashContractTerms(
-    dec.contractTerms,
-  );
-
-  const withdrawalGroupId = encodeCrock(getRandomBytes(32));
-
-  const wi = await getExchangeWithdrawalInfo(
-    ws,
-    exchangeBaseUrl,
-    Amounts.parseOrThrow(purseStatus.balance),
-    undefined,
-  );
-
-  await ws.db
-    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      await tx.peerPushPaymentIncoming.add({
-        peerPushPaymentIncomingId,
-        contractPriv: contractPriv,
-        exchangeBaseUrl: exchangeBaseUrl,
-        mergePriv: dec.mergePriv,
-        pursePub: pursePub,
-        timestamp: TalerPreciseTimestamp.now(),
-        contractTermsHash,
-        status: PeerPushPaymentIncomingStatus.DialogProposed,
-        withdrawalGroupId,
-        currency: Amounts.currencyOf(purseStatus.balance),
-        estimatedAmountEffective: Amounts.stringify(
-          wi.withdrawalAmountEffective,
-        ),
-      });
-
-      await tx.contractTerms.put({
-        h: contractTermsHash,
-        contractTermsRaw: dec.contractTerms,
-      });
-    });
-
-  return {
-    amount: purseStatus.balance,
-    amountEffective: wi.withdrawalAmountEffective,
-    amountRaw: purseStatus.balance,
-    contractTerms: dec.contractTerms,
-    peerPushPaymentIncomingId,
-    transactionId: constructTransactionIdentifier({
-      tag: TransactionType.PeerPushCredit,
-      peerPushPaymentIncomingId,
-    }),
-  };
-}
-
-export function talerPaytoFromExchangeReserve(
-  exchangeBaseUrl: string,
-  reservePub: string,
-): string {
-  const url = new URL(exchangeBaseUrl);
-  let proto: string;
-  if (url.protocol === "http:") {
-    proto = "taler-reserve-http";
-  } else if (url.protocol === "https:") {
-    proto = "taler-reserve";
-  } else {
-    throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
-  }
-
-  let path = url.pathname;
-  if (!path.endsWith("/")) {
-    path = path + "/";
-  }
-
-  return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
-}
-
-async function getMergeReserveInfo(
-  ws: InternalWalletState,
-  req: {
-    exchangeBaseUrl: string;
-  },
-): Promise<ReserveRecord> {
-  // 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 mergeReserveRecord: ReserveRecord = await ws.db
-    .mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups])
-    .runReadWrite(async (tx) => {
-      const ex = await tx.exchanges.get(req.exchangeBaseUrl);
-      checkDbInvariant(!!ex);
-      if (ex.currentMergeReserveRowId != null) {
-        const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
-        checkDbInvariant(!!reserve);
-        return reserve;
-      }
-      const reserve: ReserveRecord = {
-        reservePriv: newReservePair.priv,
-        reservePub: newReservePair.pub,
-      };
-      const insertResp = await tx.reserves.put(reserve);
-      checkDbInvariant(typeof insertResp.key === "number");
-      reserve.rowId = insertResp.key;
-      ex.currentMergeReserveRowId = reserve.rowId;
-      await tx.exchanges.put(ex);
-      return reserve;
-    });
-
-  return mergeReserveRecord;
-}
-
-export async function processPeerPushCredit(
-  ws: InternalWalletState,
-  peerPushPaymentIncomingId: string,
-): Promise<OperationAttemptResult> {
-  let peerInc: PeerPushPaymentIncomingRecord | undefined;
-  let contractTerms: PeerContractTerms | undefined;
-  await ws.db
-    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      peerInc = await 
tx.peerPushPaymentIncoming.get(peerPushPaymentIncomingId);
-      if (!peerInc) {
-        return;
-      }
-      const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
-      if (ctRec) {
-        contractTerms = ctRec.contractTermsRaw;
-      }
-      await tx.peerPushPaymentIncoming.put(peerInc);
-    });
-
-  if (!peerInc) {
-    throw Error(
-      `can't accept unknown incoming p2p push payment 
(${peerPushPaymentIncomingId})`,
-    );
-  }
-
-  checkDbInvariant(!!contractTerms);
-
-  const amount = Amounts.parseOrThrow(contractTerms.amount);
-
-  if (
-    peerInc.status === PeerPushPaymentIncomingStatus.PendingMergeKycRequired &&
-    peerInc.kycInfo
-  ) {
-    const txId = constructTransactionIdentifier({
-      tag: TransactionType.PeerPushCredit,
-      peerPushPaymentIncomingId: peerInc.peerPushPaymentIncomingId,
-    });
-    await checkWithdrawalKycStatus(
-      ws,
-      peerInc.exchangeBaseUrl,
-      txId,
-      peerInc.kycInfo,
-      "individual",
-    );
-  }
-
-  const mergeReserveInfo = await getMergeReserveInfo(ws, {
-    exchangeBaseUrl: peerInc.exchangeBaseUrl,
-  });
-
-  const mergeTimestamp = TalerProtocolTimestamp.now();
-
-  const reservePayto = talerPaytoFromExchangeReserve(
-    peerInc.exchangeBaseUrl,
-    mergeReserveInfo.reservePub,
-  );
-
-  const sigRes = await ws.cryptoApi.signPurseMerge({
-    contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
-    flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
-    mergePriv: peerInc.mergePriv,
-    mergeTimestamp: mergeTimestamp,
-    purseAmount: Amounts.stringify(amount),
-    purseExpiration: contractTerms.purse_expiration,
-    purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
-    pursePub: peerInc.pursePub,
-    reservePayto,
-    reservePriv: mergeReserveInfo.reservePriv,
-  });
-
-  const mergePurseUrl = new URL(
-    `purses/${peerInc.pursePub}/merge`,
-    peerInc.exchangeBaseUrl,
-  );
-
-  const mergeReq: ExchangePurseMergeRequest = {
-    payto_uri: reservePayto,
-    merge_timestamp: mergeTimestamp,
-    merge_sig: sigRes.mergeSig,
-    reserve_sig: sigRes.accountSig,
-  };
-
-  const mergeHttpResp = await ws.http.postJson(mergePurseUrl.href, mergeReq);
-
-  if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
-    const respJson = await mergeHttpResp.json();
-    const kycPending = codecForWalletKycUuid().decode(respJson);
-    logger.info(`kyc uuid response: ${j2s(kycPending)}`);
-
-    await ws.db
-      .mktx((x) => [x.peerPushPaymentIncoming])
-      .runReadWrite(async (tx) => {
-        const peerInc = await tx.peerPushPaymentIncoming.get(
-          peerPushPaymentIncomingId,
-        );
-        if (!peerInc) {
-          return;
-        }
-        peerInc.kycInfo = {
-          paytoHash: kycPending.h_payto,
-          requirementRow: kycPending.requirement_row,
-        };
-        peerInc.status = PeerPushPaymentIncomingStatus.PendingMergeKycRequired;
-        await tx.peerPushPaymentIncoming.put(peerInc);
-      });
-    return {
-      type: OperationAttemptResultType.Pending,
-      result: undefined,
-    };
-  }
-
-  logger.trace(`merge request: ${j2s(mergeReq)}`);
-  const res = await readSuccessResponseJsonOrThrow(
-    mergeHttpResp,
-    codecForAny(),
-  );
-  logger.trace(`merge response: ${j2s(res)}`);
-
-  await internalCreateWithdrawalGroup(ws, {
-    amount,
-    wgInfo: {
-      withdrawalType: WithdrawalRecordType.PeerPushCredit,
-      contractTerms,
-    },
-    forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
-    exchangeBaseUrl: peerInc.exchangeBaseUrl,
-    reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
-    reserveKeyPair: {
-      priv: mergeReserveInfo.reservePriv,
-      pub: mergeReserveInfo.reservePub,
-    },
-  });
-
-  await ws.db
-    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const peerInc = await tx.peerPushPaymentIncoming.get(
-        peerPushPaymentIncomingId,
-      );
-      if (!peerInc) {
-        return;
-      }
-      if (
-        peerInc.status === PeerPushPaymentIncomingStatus.PendingMerge ||
-        peerInc.status === 
PeerPushPaymentIncomingStatus.PendingMergeKycRequired
-      ) {
-        peerInc.status = PeerPushPaymentIncomingStatus.Done;
-      }
-      await tx.peerPushPaymentIncoming.put(peerInc);
-    });
-
-  return {
-    type: OperationAttemptResultType.Finished,
-    result: undefined,
-  };
-}
-
-export async function confirmPeerPushCredit(
-  ws: InternalWalletState,
-  req: ConfirmPeerPushCreditRequest,
-): Promise<AcceptPeerPushPaymentResponse> {
-  let peerInc: PeerPushPaymentIncomingRecord | undefined;
-
-  await ws.db
-    .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      peerInc = await tx.peerPushPaymentIncoming.get(
-        req.peerPushPaymentIncomingId,
-      );
-      if (!peerInc) {
-        return;
-      }
-      if (peerInc.status === PeerPushPaymentIncomingStatus.DialogProposed) {
-        peerInc.status = PeerPushPaymentIncomingStatus.PendingMerge;
-      }
-      await tx.peerPushPaymentIncoming.put(peerInc);
-    });
-
-  if (!peerInc) {
-    throw Error(
-      `can't accept unknown incoming p2p push payment 
(${req.peerPushPaymentIncomingId})`,
-    );
-  }
-
-  ws.workAvailable.trigger();
-
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushCredit,
-    peerPushPaymentIncomingId: req.peerPushPaymentIncomingId,
-  });
-
-  return {
-    transactionId,
-  };
-}
-
-export async function processPeerPullDebit(
-  ws: InternalWalletState,
-  peerPullPaymentIncomingId: string,
-): Promise<OperationAttemptResult> {
-  const peerPullInc = await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadOnly(async (tx) => {
-      return tx.peerPullPaymentIncoming.get(peerPullPaymentIncomingId);
-    });
-  if (!peerPullInc) {
-    throw Error("peer pull debit not found");
-  }
-  if (peerPullInc.status === PeerPullDebitRecordStatus.PendingDeposit) {
-    const pursePub = peerPullInc.pursePub;
-
-    const coinSel = peerPullInc.coinSel;
-    if (!coinSel) {
-      throw Error("invalid state, no coins selected");
-    }
-
-    const coins = await queryCoinInfosForSelection(ws, coinSel);
-
-    const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
-      exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
-      pursePub: peerPullInc.pursePub,
-      coins,
-    });
-
-    const purseDepositUrl = new URL(
-      `purses/${pursePub}/deposit`,
-      peerPullInc.exchangeBaseUrl,
-    );
-
-    const depositPayload: ExchangePurseDeposits = {
-      deposits: depositSigsResp.deposits,
-    };
-
-    if (logger.shouldLogTrace()) {
-      logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
-    }
-
-    const httpResp = await ws.http.postJson(
-      purseDepositUrl.href,
-      depositPayload,
-    );
-    const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
-    logger.trace(`purse deposit response: ${j2s(resp)}`);
-  }
-
-  await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pi = await tx.peerPullPaymentIncoming.get(
-        peerPullPaymentIncomingId,
-      );
-      if (!pi) {
-        throw Error("peer pull payment not found anymore");
-      }
-      if (pi.status === PeerPullDebitRecordStatus.PendingDeposit) {
-        pi.status = PeerPullDebitRecordStatus.DonePaid;
-      }
-      await tx.peerPullPaymentIncoming.put(pi);
-    });
-
-  return {
-    type: OperationAttemptResultType.Finished,
-    result: undefined,
-  };
-}
-
-export async function confirmPeerPullDebit(
-  ws: InternalWalletState,
-  req: ConfirmPeerPullDebitRequest,
-): Promise<AcceptPeerPullPaymentResponse> {
-  const peerPullInc = await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadOnly(async (tx) => {
-      return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId);
-    });
-
-  if (!peerPullInc) {
-    throw Error(
-      `can't accept unknown incoming p2p pull payment 
(${req.peerPullPaymentIncomingId})`,
-    );
-  }
-
-  const instructedAmount = Amounts.parseOrThrow(
-    peerPullInc.contractTerms.amount,
-  );
-
-  const coinSelRes = await selectPeerCoins(ws, instructedAmount);
-  logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
-
-  if (coinSelRes.type !== "success") {
-    throw TalerError.fromDetail(
-      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
-      {
-        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
-      },
-    );
-  }
-
-  const sel = coinSelRes.result;
-
-  const totalAmount = await getTotalPeerPaymentCost(
-    ws,
-    coinSelRes.result.coins,
-  );
-
-  const ppi = await ws.db
-    .mktx((x) => [
-      x.exchanges,
-      x.coins,
-      x.denominations,
-      x.refreshGroups,
-      x.peerPullPaymentIncoming,
-      x.coinAvailability,
-    ])
-    .runReadWrite(async (tx) => {
-      await spendCoins(ws, tx, {
-        // allocationId: 
`txn:peer-pull-debit:${req.peerPullPaymentIncomingId}`,
-        allocationId: constructTransactionIdentifier({
-          tag: TransactionType.PeerPullDebit,
-          peerPullPaymentIncomingId: req.peerPullPaymentIncomingId,
-        }),
-        coinPubs: sel.coins.map((x) => x.coinPub),
-        contributions: sel.coins.map((x) =>
-          Amounts.parseOrThrow(x.contribution),
-        ),
-        refreshReason: RefreshReason.PayPeerPull,
-      });
-
-      const pi = await tx.peerPullPaymentIncoming.get(
-        req.peerPullPaymentIncomingId,
-      );
-      if (!pi) {
-        throw Error();
-      }
-      if (pi.status === PeerPullDebitRecordStatus.DialogProposed) {
-        pi.status = PeerPullDebitRecordStatus.PendingDeposit;
-        pi.coinSel = {
-          coinPubs: sel.coins.map((x) => x.coinPub),
-          contributions: sel.coins.map((x) => x.contribution),
-          totalCost: Amounts.stringify(totalAmount),
-        };
-      }
-      await tx.peerPullPaymentIncoming.put(pi);
-      return pi;
-    });
-
-  await runOperationWithErrorReporting(
-    ws,
-    TaskIdentifiers.forPeerPullPaymentDebit(ppi),
-    async () => {
-      return processPeerPullDebit(ws, ppi.peerPullPaymentIncomingId);
-    },
-  );
-
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullDebit,
-    peerPullPaymentIncomingId: req.peerPullPaymentIncomingId,
-  });
-
-  return {
-    transactionId,
-  };
-}
-
-/**
- * Look up information about an incoming peer pull payment.
- * Store the results in the wallet DB.
- */
-export async function preparePeerPullDebit(
-  ws: InternalWalletState,
-  req: PreparePeerPullDebitRequest,
-): Promise<PreparePeerPullDebitResponse> {
-  const uri = parsePayPullUri(req.talerUri);
-
-  if (!uri) {
-    throw Error("got invalid taler://pay-pull URI");
-  }
-
-  const existingPullIncomingRecord = await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadOnly(async (tx) => {
-      return tx.peerPullPaymentIncoming.indexes.byExchangeAndContractPriv.get([
-        uri.exchangeBaseUrl,
-        uri.contractPriv,
-      ]);
-    });
-
-  if (existingPullIncomingRecord) {
-    return {
-      amount: existingPullIncomingRecord.contractTerms.amount,
-      amountRaw: existingPullIncomingRecord.contractTerms.amount,
-      amountEffective: existingPullIncomingRecord.totalCostEstimated,
-      contractTerms: existingPullIncomingRecord.contractTerms,
-      peerPullPaymentIncomingId:
-        existingPullIncomingRecord.peerPullPaymentIncomingId,
-      transactionId: constructTransactionIdentifier({
-        tag: TransactionType.PeerPullDebit,
-        peerPullPaymentIncomingId:
-          existingPullIncomingRecord.peerPullPaymentIncomingId,
-      }),
-    };
-  }
-
-  const exchangeBaseUrl = uri.exchangeBaseUrl;
-  const contractPriv = uri.contractPriv;
-  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
-
-  const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
-
-  const contractHttpResp = await ws.http.get(getContractUrl.href);
-
-  const contractResp = await readSuccessResponseJsonOrThrow(
-    contractHttpResp,
-    codecForExchangeGetContractResponse(),
-  );
-
-  const pursePub = contractResp.purse_pub;
-
-  const dec = await ws.cryptoApi.decryptContractForDeposit({
-    ciphertext: contractResp.econtract,
-    contractPriv: contractPriv,
-    pursePub: pursePub,
-  });
-
-  const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
-
-  const purseHttpResp = await ws.http.get(getPurseUrl.href);
-
-  const purseStatus = await readSuccessResponseJsonOrThrow(
-    purseHttpResp,
-    codecForExchangePurseStatus(),
-  );
-
-  const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32));
-
-  let contractTerms: PeerContractTerms;
-
-  if (dec.contractTerms) {
-    contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
-    // FIXME: Check that the purseStatus balance matches contract terms amount
-  } else {
-    // FIXME: In this case, where do we get the purse expiration from?!
-    // https://bugs.gnunet.org/view.php?id=7706
-    throw Error("pull payments without contract terms not supported yet");
-  }
-
-  // FIXME: Why don't we compute the totalCost here?!
-
-  const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
-
-  const coinSelRes = await selectPeerCoins(ws, instructedAmount);
-  logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
-
-  if (coinSelRes.type !== "success") {
-    throw TalerError.fromDetail(
-      TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
-      {
-        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
-      },
-    );
-  }
-
-  const totalAmount = await getTotalPeerPaymentCost(
-    ws,
-    coinSelRes.result.coins,
-  );
-
-  await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      await tx.peerPullPaymentIncoming.add({
-        peerPullPaymentIncomingId,
-        contractPriv: contractPriv,
-        exchangeBaseUrl: exchangeBaseUrl,
-        pursePub: pursePub,
-        timestampCreated: TalerPreciseTimestamp.now(),
-        contractTerms,
-        status: PeerPullDebitRecordStatus.DialogProposed,
-        totalCostEstimated: Amounts.stringify(totalAmount),
-      });
-    });
-
-  return {
-    amount: contractTerms.amount,
-    amountEffective: Amounts.stringify(totalAmount),
-    amountRaw: contractTerms.amount,
-    contractTerms: contractTerms,
-    peerPullPaymentIncomingId,
-    transactionId: constructTransactionIdentifier({
-      tag: TransactionType.PeerPullDebit,
-      peerPullPaymentIncomingId: peerPullPaymentIncomingId,
-    }),
-  };
-}
-
-export async function queryPurseForPeerPullCredit(
-  ws: InternalWalletState,
-  pullIni: PeerPullPaymentInitiationRecord,
-  cancellationToken: CancellationToken,
-): Promise<LongpollResult> {
-  const purseDepositUrl = new URL(
-    `purses/${pullIni.pursePub}/deposit`,
-    pullIni.exchangeBaseUrl,
-  );
-  purseDepositUrl.searchParams.set("timeout_ms", "30000");
-  logger.info(`querying purse status via ${purseDepositUrl.href}`);
-  const resp = await ws.http.get(purseDepositUrl.href, {
-    timeout: { d_ms: 60000 },
-    cancellationToken,
-  });
-
-  logger.info(`purse status code: HTTP ${resp.status}`);
-
-  const result = await readSuccessResponseJsonOrErrorCode(
-    resp,
-    codecForExchangePurseStatus(),
-  );
-
-  if (result.isError) {
-    logger.info(`got purse status error, 
EC=${result.talerErrorResponse.code}`);
-    if (resp.status === 404) {
-      return { ready: false };
-    } else {
-      throwUnexpectedRequestError(resp, result.talerErrorResponse);
-    }
-  }
-
-  if (!result.response.deposit_timestamp) {
-    logger.info("purse not ready yet (no deposit)");
-    return { ready: false };
-  }
-
-  const reserve = await ws.db
-    .mktx((x) => [x.reserves])
-    .runReadOnly(async (tx) => {
-      return await tx.reserves.get(pullIni.mergeReserveRowId);
-    });
-
-  if (!reserve) {
-    throw Error("reserve for peer pull credit not found in wallet DB");
-  }
-
-  await internalCreateWithdrawalGroup(ws, {
-    amount: Amounts.parseOrThrow(pullIni.amount),
-    wgInfo: {
-      withdrawalType: WithdrawalRecordType.PeerPullCredit,
-      contractTerms: pullIni.contractTerms,
-      contractPriv: pullIni.contractPriv,
-    },
-    forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
-    exchangeBaseUrl: pullIni.exchangeBaseUrl,
-    reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
-    reserveKeyPair: {
-      priv: reserve.reservePriv,
-      pub: reserve.reservePub,
-    },
-  });
-
-  await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const finPi = await tx.peerPullPaymentInitiations.get(pullIni.pursePub);
-      if (!finPi) {
-        logger.warn("peerPullPaymentInitiation not found anymore");
-        return;
-      }
-      if (finPi.status === PeerPullPaymentInitiationStatus.PendingReady) {
-        finPi.status = PeerPullPaymentInitiationStatus.DonePurseDeposited;
-      }
-      await tx.peerPullPaymentInitiations.put(finPi);
-    });
-  return {
-    ready: true,
-  };
-}
-
-export async function processPeerPullCredit(
-  ws: InternalWalletState,
-  pursePub: string,
-): Promise<OperationAttemptResult> {
-  const pullIni = await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations])
-    .runReadOnly(async (tx) => {
-      return tx.peerPullPaymentInitiations.get(pursePub);
-    });
-  if (!pullIni) {
-    throw Error("peer pull payment initiation not found in database");
-  }
-
-  const retryTag = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullCredit,
-    pursePub,
-  });
-
-  // We're already running!
-  if (ws.activeLongpoll[retryTag]) {
-    logger.info("peer-pull-credit already in long-polling, returning!");
-    return {
-      type: OperationAttemptResultType.Longpoll,
-    };
-  }
-
-  logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
-
-  switch (pullIni.status) {
-    case PeerPullPaymentInitiationStatus.DonePurseDeposited: {
-      // We implement this case so that the "retry" action on a 
peer-pull-credit transaction
-      // also retries the withdrawal task.
-
-      logger.warn(
-        "peer pull payment initiation is already finished, retrying 
withdrawal",
-      );
-
-      const withdrawalGroupId = pullIni.withdrawalGroupId;
-
-      if (withdrawalGroupId) {
-        const taskId = constructTaskIdentifier({
-          tag: PendingTaskType.Withdraw,
-          withdrawalGroupId,
-        });
-        stopLongpolling(ws, taskId);
-        await resetOperationTimeout(ws, taskId);
-        await runOperationWithErrorReporting(ws, taskId, () =>
-          processWithdrawalGroup(ws, withdrawalGroupId),
-        );
-      }
-      return {
-        type: OperationAttemptResultType.Finished,
-        result: undefined,
-      };
-    }
-    case PeerPullPaymentInitiationStatus.PendingReady:
-      runLongpollAsync(ws, retryTag, async (cancellationToken) =>
-        queryPurseForPeerPullCredit(ws, pullIni, cancellationToken),
-      );
-      logger.trace(
-        "returning early from processPeerPullCredit for long-polling in 
background",
-      );
-      return {
-        type: OperationAttemptResultType.Longpoll,
-      };
-    case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: {
-      const transactionId = constructTransactionIdentifier({
-        tag: TransactionType.PeerPullCredit,
-        pursePub: pullIni.pursePub,
-      });
-      if (pullIni.kycInfo) {
-        await checkWithdrawalKycStatus(
-          ws,
-          pullIni.exchangeBaseUrl,
-          transactionId,
-          pullIni.kycInfo,
-          "individual",
-        );
-      }
-      break;
-    }
-    case PeerPullPaymentInitiationStatus.PendingCreatePurse:
-      break;
-    default:
-      throw Error(`unknown PeerPullPaymentInitiationStatus ${pullIni.status}`);
-  }
-
-  const mergeReserve = await ws.db
-    .mktx((x) => [x.reserves])
-    .runReadOnly(async (tx) => {
-      return tx.reserves.get(pullIni.mergeReserveRowId);
-    });
-
-  if (!mergeReserve) {
-    throw Error("merge reserve for peer pull payment not found in database");
-  }
-
-  const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
-
-  const reservePayto = talerPaytoFromExchangeReserve(
-    pullIni.exchangeBaseUrl,
-    mergeReserve.reservePub,
-  );
-
-  const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
-    contractPriv: pullIni.contractPriv,
-    contractPub: pullIni.contractPub,
-    contractTerms: pullIni.contractTerms,
-    pursePriv: pullIni.pursePriv,
-    pursePub: pullIni.pursePub,
-  });
-
-  const purseExpiration = pullIni.contractTerms.purse_expiration;
-  const sigRes = await ws.cryptoApi.signReservePurseCreate({
-    contractTermsHash: pullIni.contractTermsHash,
-    flags: WalletAccountMergeFlags.CreateWithPurseFee,
-    mergePriv: pullIni.mergePriv,
-    mergeTimestamp: TalerPreciseTimestamp.round(pullIni.mergeTimestamp),
-    purseAmount: pullIni.contractTerms.amount,
-    purseExpiration: purseExpiration,
-    purseFee: purseFee,
-    pursePriv: pullIni.pursePriv,
-    pursePub: pullIni.pursePub,
-    reservePayto,
-    reservePriv: mergeReserve.reservePriv,
-  });
-
-  const reservePurseReqBody: ExchangeReservePurseRequest = {
-    merge_sig: sigRes.mergeSig,
-    merge_timestamp: TalerPreciseTimestamp.round(pullIni.mergeTimestamp),
-    h_contract_terms: pullIni.contractTermsHash,
-    merge_pub: pullIni.mergePub,
-    min_age: 0,
-    purse_expiration: purseExpiration,
-    purse_fee: purseFee,
-    purse_pub: pullIni.pursePub,
-    purse_sig: sigRes.purseSig,
-    purse_value: pullIni.contractTerms.amount,
-    reserve_sig: sigRes.accountSig,
-    econtract: econtractResp.econtract,
-  };
-
-  logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
-
-  const reservePurseMergeUrl = new URL(
-    `reserves/${mergeReserve.reservePub}/purse`,
-    pullIni.exchangeBaseUrl,
-  );
-
-  const httpResp = await ws.http.postJson(
-    reservePurseMergeUrl.href,
-    reservePurseReqBody,
-  );
-
-  if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
-    const respJson = await httpResp.json();
-    const kycPending = codecForWalletKycUuid().decode(respJson);
-    logger.info(`kyc uuid response: ${j2s(kycPending)}`);
-
-    await ws.db
-      .mktx((x) => [x.peerPullPaymentInitiations])
-      .runReadWrite(async (tx) => {
-        const peerIni = await tx.peerPullPaymentInitiations.get(pursePub);
-        if (!peerIni) {
-          return;
-        }
-        peerIni.kycInfo = {
-          paytoHash: kycPending.h_payto,
-          requirementRow: kycPending.requirement_row,
-        };
-        peerIni.status =
-          PeerPullPaymentInitiationStatus.PendingMergeKycRequired;
-        await tx.peerPullPaymentInitiations.put(peerIni);
-      });
-    return {
-      type: OperationAttemptResultType.Pending,
-      result: undefined,
-    };
-  }
-
-  const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
-
-  logger.info(`reserve merge response: ${j2s(resp)}`);
-
-  await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pi2 = await tx.peerPullPaymentInitiations.get(pursePub);
-      if (!pi2) {
-        return;
-      }
-      pi2.status = PeerPullPaymentInitiationStatus.PendingReady;
-      await tx.peerPullPaymentInitiations.put(pi2);
-    });
-
-  return {
-    type: OperationAttemptResultType.Finished,
-    result: undefined,
-  };
-}
-
-/**
- * Find a preferred exchange based on when we withdrew last from this exchange.
- */
-async function getPreferredExchangeForCurrency(
-  ws: InternalWalletState,
-  currency: string,
-): Promise<string | undefined> {
-  // Find an exchange with the matching currency.
-  // Prefer exchanges with the most recent withdrawal.
-  const url = await ws.db
-    .mktx((x) => [x.exchanges])
-    .runReadOnly(async (tx) => {
-      const exchanges = await tx.exchanges.iter().toArray();
-      let candidate = undefined;
-      for (const e of exchanges) {
-        if (e.detailsPointer?.currency !== currency) {
-          continue;
-        }
-        if (!candidate) {
-          candidate = e;
-          continue;
-        }
-        if (candidate.lastWithdrawal && !e.lastWithdrawal) {
-          continue;
-        }
-        if (candidate.lastWithdrawal && e.lastWithdrawal) {
-          if (
-            AbsoluteTime.cmp(
-              AbsoluteTime.fromPreciseTimestamp(e.lastWithdrawal),
-              AbsoluteTime.fromPreciseTimestamp(candidate.lastWithdrawal),
-            ) > 0
-          ) {
-            candidate = e;
-          }
-        }
-      }
-      if (candidate) {
-        return candidate.baseUrl;
-      }
-      return undefined;
-    });
-  return url;
-}
-
-/**
- * Check fees and available exchanges for a peer push payment initiation.
- */
-export async function checkPeerPullPaymentInitiation(
-  ws: InternalWalletState,
-  req: CheckPeerPullCreditRequest,
-): Promise<CheckPeerPullCreditResponse> {
-  // FIXME: We don't support exchanges with purse fees yet.
-  // Select an exchange where we have money in the specified currency
-  // FIXME: How do we handle regional currency scopes here? Is it an 
additional input?
-
-  logger.trace("checking peer-pull-credit fees");
-
-  const currency = Amounts.currencyOf(req.amount);
-  let exchangeUrl;
-  if (req.exchangeBaseUrl) {
-    exchangeUrl = req.exchangeBaseUrl;
-  } else {
-    exchangeUrl = await getPreferredExchangeForCurrency(ws, currency);
-  }
-
-  if (!exchangeUrl) {
-    throw Error("no exchange found for initiating a peer pull payment");
-  }
-
-  logger.trace(`found ${exchangeUrl} as preferred exchange`);
-
-  const wi = await getExchangeWithdrawalInfo(
-    ws,
-    exchangeUrl,
-    Amounts.parseOrThrow(req.amount),
-    undefined,
-  );
-
-  logger.trace(`got withdrawal info`);
-
-  return {
-    exchangeBaseUrl: exchangeUrl,
-    amountEffective: wi.withdrawalAmountEffective,
-    amountRaw: req.amount,
-  };
-}
-
-/**
- * Initiate a peer pull payment.
- */
-export async function initiatePeerPullPayment(
-  ws: InternalWalletState,
-  req: InitiatePeerPullCreditRequest,
-): Promise<InitiatePeerPullCreditResponse> {
-  const currency = Amounts.currencyOf(req.partialContractTerms.amount);
-  let maybeExchangeBaseUrl: string | undefined;
-  if (req.exchangeBaseUrl) {
-    maybeExchangeBaseUrl = req.exchangeBaseUrl;
-  } else {
-    maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency);
-  }
-
-  if (!maybeExchangeBaseUrl) {
-    throw Error("no exchange found for initiating a peer pull payment");
-  }
-
-  const exchangeBaseUrl = maybeExchangeBaseUrl;
-
-  await updateExchangeFromUrl(ws, exchangeBaseUrl);
-
-  const mergeReserveInfo = await getMergeReserveInfo(ws, {
-    exchangeBaseUrl: exchangeBaseUrl,
-  });
-
-  const mergeTimestamp = TalerPreciseTimestamp.now();
-
-  const pursePair = await ws.cryptoApi.createEddsaKeypair({});
-  const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-
-  const contractTerms = req.partialContractTerms;
-
-  const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
-  const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
-
-  const withdrawalGroupId = encodeCrock(getRandomBytes(32));
-
-  const mergeReserveRowId = mergeReserveInfo.rowId;
-  checkDbInvariant(!!mergeReserveRowId);
-
-  const wi = await getExchangeWithdrawalInfo(
-    ws,
-    exchangeBaseUrl,
-    Amounts.parseOrThrow(req.partialContractTerms.amount),
-    undefined,
-  );
-
-  await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations, x.contractTerms])
-    .runReadWrite(async (tx) => {
-      await tx.peerPullPaymentInitiations.put({
-        amount: req.partialContractTerms.amount,
-        contractTermsHash: hContractTerms,
-        exchangeBaseUrl: exchangeBaseUrl,
-        pursePriv: pursePair.priv,
-        pursePub: pursePair.pub,
-        mergePriv: mergePair.priv,
-        mergePub: mergePair.pub,
-        status: PeerPullPaymentInitiationStatus.PendingCreatePurse,
-        contractTerms: contractTerms,
-        mergeTimestamp,
-        mergeReserveRowId: mergeReserveRowId,
-        contractPriv: contractKeyPair.priv,
-        contractPub: contractKeyPair.pub,
-        withdrawalGroupId,
-        estimatedAmountEffective: wi.withdrawalAmountEffective,
-      });
-      await tx.contractTerms.put({
-        contractTermsRaw: contractTerms,
-        h: hContractTerms,
-      });
-    });
-
-  // FIXME: Should we somehow signal to the client
-  // whether purse creation has failed, or does the client/
-  // check this asynchronously from the transaction status?
-
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullCredit,
-    pursePub: pursePair.pub,
-  });
-
-  await runOperationWithErrorReporting(ws, taskId, async () => {
-    return processPeerPullCredit(ws, pursePair.pub);
-  });
-
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullCredit,
-    pursePub: pursePair.pub,
-  });
-
-  return {
-    talerUri: constructPayPullUri({
-      exchangeBaseUrl: exchangeBaseUrl,
-      contractPriv: contractKeyPair.priv,
-    }),
-    transactionId,
-  };
-}
-
-export function computePeerPushDebitTransactionState(
-  ppiRecord: PeerPushPaymentInitiationRecord,
-): TransactionState {
-  switch (ppiRecord.status) {
-    case PeerPushPaymentInitiationStatus.PendingCreatePurse:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.CreatePurse,
-      };
-    case PeerPushPaymentInitiationStatus.PendingReady:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.Ready,
-      };
-    case PeerPushPaymentInitiationStatus.Aborted:
-      return {
-        major: TransactionMajorState.Aborted,
-      };
-    case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
-      return {
-        major: TransactionMajorState.Aborting,
-        minor: TransactionMinorState.DeletePurse,
-      };
-    case PeerPushPaymentInitiationStatus.AbortingRefresh:
-      return {
-        major: TransactionMajorState.Aborting,
-        minor: TransactionMinorState.Refresh,
-      };
-    case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-      return {
-        major: TransactionMajorState.SuspendedAborting,
-        minor: TransactionMinorState.DeletePurse,
-      };
-    case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
-      return {
-        major: TransactionMajorState.SuspendedAborting,
-        minor: TransactionMinorState.Refresh,
-      };
-    case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.CreatePurse,
-      };
-    case PeerPushPaymentInitiationStatus.SuspendedReady:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.Ready,
-      };
-    case PeerPushPaymentInitiationStatus.Done:
-      return {
-        major: TransactionMajorState.Done,
-      };
-    case PeerPushPaymentInitiationStatus.Failed:
-      return {
-        major: TransactionMajorState.Failed,
-      };
-  }
-}
-
-export function computePeerPushDebitTransactionActions(
-  ppiRecord: PeerPushPaymentInitiationRecord,
-): TransactionAction[] {
-  switch (ppiRecord.status) {
-    case PeerPushPaymentInitiationStatus.PendingCreatePurse:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPushPaymentInitiationStatus.PendingReady:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPushPaymentInitiationStatus.Aborted:
-      return [TransactionAction.Delete];
-    case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
-      return [TransactionAction.Suspend, TransactionAction.Fail];
-    case PeerPushPaymentInitiationStatus.AbortingRefresh:
-      return [TransactionAction.Suspend, TransactionAction.Fail];
-    case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-      return [TransactionAction.Resume, TransactionAction.Fail];
-    case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
-      return [TransactionAction.Resume, TransactionAction.Fail];
-    case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
-      return [TransactionAction.Resume, TransactionAction.Abort];
-    case PeerPushPaymentInitiationStatus.SuspendedReady:
-      return [TransactionAction.Suspend, TransactionAction.Abort];
-    case PeerPushPaymentInitiationStatus.Done:
-      return [TransactionAction.Delete];
-    case PeerPushPaymentInitiationStatus.Failed:
-      return [TransactionAction.Delete];
-  }
-}
-
-export async function abortPeerPushDebitTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushDebit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushDebit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
-      if (!pushDebitRec) {
-        logger.warn(`peer push debit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
-      switch (pushDebitRec.status) {
-        case PeerPushPaymentInitiationStatus.PendingReady:
-        case PeerPushPaymentInitiationStatus.SuspendedReady:
-          newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
-          break;
-        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
-        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
-          // Network request might already be in-flight!
-          newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
-          break;
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-        case PeerPushPaymentInitiationStatus.AbortingRefresh:
-        case PeerPushPaymentInitiationStatus.Done:
-        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
-        case PeerPushPaymentInitiationStatus.Aborted:
-          // Do nothing
-          break;
-        case PeerPushPaymentInitiationStatus.Failed:
-          break;
-        default:
-          assertUnreachable(pushDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        pushDebitRec.status = newStatus;
-        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        await tx.peerPushPaymentInitiations.put(pushDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPushDebitTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushDebit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushDebit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
-      if (!pushDebitRec) {
-        logger.warn(`peer push debit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
-      switch (pushDebitRec.status) {
-        case PeerPushPaymentInitiationStatus.AbortingRefresh:
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
-          // FIXME: We also need to abort the refresh group!
-          newStatus = PeerPushPaymentInitiationStatus.Aborted;
-          break;
-        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-          newStatus = PeerPushPaymentInitiationStatus.Aborted;
-          break;
-        case PeerPushPaymentInitiationStatus.PendingReady:
-        case PeerPushPaymentInitiationStatus.SuspendedReady:
-        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
-        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
-        case PeerPushPaymentInitiationStatus.Done:
-        case PeerPushPaymentInitiationStatus.Aborted:
-        case PeerPushPaymentInitiationStatus.Failed:
-          // Do nothing
-          break;
-        default:
-          assertUnreachable(pushDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        pushDebitRec.status = newStatus;
-        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        await tx.peerPushPaymentInitiations.put(pushDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function suspendPeerPushDebitTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushDebit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushDebit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
-      if (!pushDebitRec) {
-        logger.warn(`peer push debit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
-      switch (pushDebitRec.status) {
-        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
-          newStatus = PeerPushPaymentInitiationStatus.SuspendedCreatePurse;
-          break;
-        case PeerPushPaymentInitiationStatus.AbortingRefresh:
-          newStatus = PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh;
-          break;
-        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
-          newStatus =
-            PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse;
-          break;
-        case PeerPushPaymentInitiationStatus.PendingReady:
-          newStatus = PeerPushPaymentInitiationStatus.SuspendedReady;
-          break;
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
-        case PeerPushPaymentInitiationStatus.SuspendedReady:
-        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
-        case PeerPushPaymentInitiationStatus.Done:
-        case PeerPushPaymentInitiationStatus.Aborted:
-        case PeerPushPaymentInitiationStatus.Failed:
-          // Do nothing
-          break;
-        default:
-          assertUnreachable(pushDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        pushDebitRec.status = newStatus;
-        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        await tx.peerPushPaymentInitiations.put(pushDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function suspendPeerPullDebitTransaction(
-  ws: InternalWalletState,
-  peerPullPaymentIncomingId: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
-        peerPullPaymentIncomingId,
-      );
-      if (!pullDebitRec) {
-        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
-        return;
-      }
-      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
-      switch (pullDebitRec.status) {
-        case PeerPullDebitRecordStatus.DialogProposed:
-          break;
-        case PeerPullDebitRecordStatus.DonePaid:
-          break;
-        case PeerPullDebitRecordStatus.PendingDeposit:
-          newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
-          break;
-        case PeerPullDebitRecordStatus.SuspendedDeposit:
-          break;
-        case PeerPullDebitRecordStatus.Aborted:
-          break;
-        case PeerPullDebitRecordStatus.AbortingRefresh:
-          newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
-          break;
-        case PeerPullDebitRecordStatus.Failed:
-          break;
-        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
-          break;
-        default:
-          assertUnreachable(pullDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        pullDebitRec.status = newStatus;
-        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        await tx.peerPullPaymentIncoming.put(pullDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortPeerPullDebitTransaction(
-  ws: InternalWalletState,
-  peerPullPaymentIncomingId: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
-        peerPullPaymentIncomingId,
-      );
-      if (!pullDebitRec) {
-        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
-        return;
-      }
-      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
-      switch (pullDebitRec.status) {
-        case PeerPullDebitRecordStatus.DialogProposed:
-          newStatus = PeerPullDebitRecordStatus.Aborted;
-          break;
-        case PeerPullDebitRecordStatus.DonePaid:
-          break;
-        case PeerPullDebitRecordStatus.PendingDeposit:
-          newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
-          break;
-        case PeerPullDebitRecordStatus.SuspendedDeposit:
-          break;
-        case PeerPullDebitRecordStatus.Aborted:
-          break;
-        case PeerPullDebitRecordStatus.AbortingRefresh:
-          break;
-        case PeerPullDebitRecordStatus.Failed:
-          break;
-        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
-          break;
-        default:
-          assertUnreachable(pullDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        pullDebitRec.status = newStatus;
-        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        await tx.peerPullPaymentIncoming.put(pullDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPullDebitTransaction(
-  ws: InternalWalletState,
-  peerPullPaymentIncomingId: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
-        peerPullPaymentIncomingId,
-      );
-      if (!pullDebitRec) {
-        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
-        return;
-      }
-      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
-      switch (pullDebitRec.status) {
-        case PeerPullDebitRecordStatus.DialogProposed:
-          newStatus = PeerPullDebitRecordStatus.Aborted;
-          break;
-        case PeerPullDebitRecordStatus.DonePaid:
-          break;
-        case PeerPullDebitRecordStatus.PendingDeposit:
-          break;
-        case PeerPullDebitRecordStatus.SuspendedDeposit:
-          break;
-        case PeerPullDebitRecordStatus.Aborted:
-          break;
-        case PeerPullDebitRecordStatus.Failed:
-          break;
-        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
-        case PeerPullDebitRecordStatus.AbortingRefresh:
-          // FIXME: abort underlying refresh!
-          newStatus = PeerPullDebitRecordStatus.Failed;
-          break;
-        default:
-          assertUnreachable(pullDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        pullDebitRec.status = newStatus;
-        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        await tx.peerPullPaymentIncoming.put(pullDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumePeerPullDebitTransaction(
-  ws: InternalWalletState,
-  peerPullPaymentIncomingId: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullDebit,
-    peerPullPaymentIncomingId,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pullDebitRec = await tx.peerPullPaymentIncoming.get(
-        peerPullPaymentIncomingId,
-      );
-      if (!pullDebitRec) {
-        logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
-        return;
-      }
-      let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
-      switch (pullDebitRec.status) {
-        case PeerPullDebitRecordStatus.DialogProposed:
-        case PeerPullDebitRecordStatus.DonePaid:
-        case PeerPullDebitRecordStatus.PendingDeposit:
-          break;
-        case PeerPullDebitRecordStatus.SuspendedDeposit:
-          newStatus = PeerPullDebitRecordStatus.PendingDeposit;
-          break;
-        case PeerPullDebitRecordStatus.Aborted:
-          break;
-        case PeerPullDebitRecordStatus.AbortingRefresh:
-          break;
-        case PeerPullDebitRecordStatus.Failed:
-          break;
-        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
-          newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
-          break;
-        default:
-          assertUnreachable(pullDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        pullDebitRec.status = newStatus;
-        const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
-        await tx.peerPullPaymentIncoming.put(pullDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  ws.workAvailable.trigger();
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function suspendPeerPushCreditTransaction(
-  ws: InternalWalletState,
-  peerPushPaymentIncomingId: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushCredit,
-    peerPushPaymentIncomingId,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushCredit,
-    peerPushPaymentIncomingId,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pushCreditRec = await tx.peerPushPaymentIncoming.get(
-        peerPushPaymentIncomingId,
-      );
-      if (!pushCreditRec) {
-        logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
-        return;
-      }
-      let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
-      switch (pushCreditRec.status) {
-        case PeerPushPaymentIncomingStatus.DialogProposed:
-        case PeerPushPaymentIncomingStatus.Done:
-        case PeerPushPaymentIncomingStatus.SuspendedMerge:
-        case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
-        case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
-          break;
-        case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
-          newStatus = PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired;
-          break;
-        case PeerPushPaymentIncomingStatus.PendingMerge:
-          newStatus = PeerPushPaymentIncomingStatus.SuspendedMerge;
-          break;
-        case PeerPushPaymentIncomingStatus.PendingWithdrawing:
-          // FIXME: Suspend internal withdrawal transaction!
-          newStatus = PeerPushPaymentIncomingStatus.SuspendedWithdrawing;
-          break;
-        case PeerPushPaymentIncomingStatus.Aborted:
-          break;
-        case PeerPushPaymentIncomingStatus.Failed:
-          break;
-        default:
-          assertUnreachable(pushCreditRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
-        pushCreditRec.status = newStatus;
-        const newTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
-        await tx.peerPushPaymentIncoming.put(pushCreditRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortPeerPushCreditTransaction(
-  ws: InternalWalletState,
-  peerPushPaymentIncomingId: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushCredit,
-    peerPushPaymentIncomingId,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushCredit,
-    peerPushPaymentIncomingId,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pushCreditRec = await tx.peerPushPaymentIncoming.get(
-        peerPushPaymentIncomingId,
-      );
-      if (!pushCreditRec) {
-        logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
-        return;
-      }
-      let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
-      switch (pushCreditRec.status) {
-        case PeerPushPaymentIncomingStatus.DialogProposed:
-          newStatus = PeerPushPaymentIncomingStatus.Aborted;
-          break;
-        case PeerPushPaymentIncomingStatus.Done:
-          break;
-        case PeerPushPaymentIncomingStatus.SuspendedMerge:
-        case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
-        case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
-          newStatus = PeerPushPaymentIncomingStatus.Aborted;
-          break;
-        case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
-          newStatus = PeerPushPaymentIncomingStatus.Aborted;
-          break;
-        case PeerPushPaymentIncomingStatus.PendingMerge:
-          newStatus = PeerPushPaymentIncomingStatus.Aborted;
-          break;
-        case PeerPushPaymentIncomingStatus.PendingWithdrawing:
-          newStatus = PeerPushPaymentIncomingStatus.Aborted;
-          break;
-        case PeerPushPaymentIncomingStatus.Aborted:
-          break;
-        case PeerPushPaymentIncomingStatus.Failed:
-          break;
-        default:
-          assertUnreachable(pushCreditRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
-        pushCreditRec.status = newStatus;
-        const newTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
-        await tx.peerPushPaymentIncoming.put(pushCreditRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPushCreditTransaction(
-  ws: InternalWalletState,
-  peerPushPaymentIncomingId: string,
-) {
-  // We don't have any "aborting" states!
-  throw Error("can't run cancel-aborting on peer-push-credit transaction");
-}
-
-export async function resumePeerPushCreditTransaction(
-  ws: InternalWalletState,
-  peerPushPaymentIncomingId: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushCredit,
-    peerPushPaymentIncomingId,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushCredit,
-    peerPushPaymentIncomingId,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentIncoming])
-    .runReadWrite(async (tx) => {
-      const pushCreditRec = await tx.peerPushPaymentIncoming.get(
-        peerPushPaymentIncomingId,
-      );
-      if (!pushCreditRec) {
-        logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
-        return;
-      }
-      let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
-      switch (pushCreditRec.status) {
-        case PeerPushPaymentIncomingStatus.DialogProposed:
-        case PeerPushPaymentIncomingStatus.Done:
-        case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
-        case PeerPushPaymentIncomingStatus.PendingMerge:
-        case PeerPushPaymentIncomingStatus.PendingWithdrawing:
-        case PeerPushPaymentIncomingStatus.SuspendedMerge:
-          newStatus = PeerPushPaymentIncomingStatus.PendingMerge;
-          break;
-        case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
-          newStatus = PeerPushPaymentIncomingStatus.PendingMergeKycRequired;
-          break;
-        case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
-          // FIXME: resume underlying "internal-withdrawal" transaction.
-          newStatus = PeerPushPaymentIncomingStatus.PendingWithdrawing;
-          break;
-        case PeerPushPaymentIncomingStatus.Aborted:
-          break;
-        case PeerPushPaymentIncomingStatus.Failed:
-          break;
-        default:
-          assertUnreachable(pushCreditRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
-        pushCreditRec.status = newStatus;
-        const newTxState = 
computePeerPushCreditTransactionState(pushCreditRec);
-        await tx.peerPushPaymentIncoming.put(pushCreditRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  ws.workAvailable.trigger();
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function suspendPeerPullCreditTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullCredit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullCredit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
-      if (!pullCreditRec) {
-        logger.warn(`peer pull credit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
-      switch (pullCreditRec.status) {
-        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
-          newStatus = PeerPullPaymentInitiationStatus.SuspendedCreatePurse;
-          break;
-        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
-          newStatus = 
PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired;
-          break;
-        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
-          newStatus = PeerPullPaymentInitiationStatus.SuspendedWithdrawing;
-          break;
-        case PeerPullPaymentInitiationStatus.PendingReady:
-          newStatus = PeerPullPaymentInitiationStatus.SuspendedReady;
-          break;
-        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
-          newStatus =
-            PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse;
-          break;
-        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
-        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
-        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
-        case PeerPullPaymentInitiationStatus.SuspendedReady:
-        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
-        case PeerPullPaymentInitiationStatus.Aborted:
-        case PeerPullPaymentInitiationStatus.Failed:
-        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-          break;
-        default:
-          assertUnreachable(pullCreditRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        pullCreditRec.status = newStatus;
-        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        await tx.peerPullPaymentInitiations.put(pullCreditRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortPeerPullCreditTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullCredit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullCredit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
-      if (!pullCreditRec) {
-        logger.warn(`peer pull credit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
-      switch (pullCreditRec.status) {
-        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
-        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
-          newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
-          break;
-        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
-          throw Error("can't abort anymore");
-        case PeerPullPaymentInitiationStatus.PendingReady:
-          newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
-          break;
-        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
-        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
-        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
-        case PeerPullPaymentInitiationStatus.SuspendedReady:
-        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
-        case PeerPullPaymentInitiationStatus.Aborted:
-        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
-        case PeerPullPaymentInitiationStatus.Failed:
-        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-          break;
-        default:
-          assertUnreachable(pullCreditRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        pullCreditRec.status = newStatus;
-        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        await tx.peerPullPaymentInitiations.put(pullCreditRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPullCreditTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullCredit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullCredit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
-      if (!pullCreditRec) {
-        logger.warn(`peer pull credit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
-      switch (pullCreditRec.status) {
-        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
-        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
-        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
-        case PeerPullPaymentInitiationStatus.PendingReady:
-        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
-        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
-        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
-        case PeerPullPaymentInitiationStatus.SuspendedReady:
-        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
-        case PeerPullPaymentInitiationStatus.Aborted:
-        case PeerPullPaymentInitiationStatus.Failed:
-          break;
-        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
-        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-          newStatus = PeerPullPaymentInitiationStatus.Failed;
-          break;
-        default:
-          assertUnreachable(pullCreditRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        pullCreditRec.status = newStatus;
-        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        await tx.peerPullPaymentInitiations.put(pullCreditRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumePeerPullCreditTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPullCredit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPullCredit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPullPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
-      if (!pullCreditRec) {
-        logger.warn(`peer pull credit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
-      switch (pullCreditRec.status) {
-        case PeerPullPaymentInitiationStatus.PendingCreatePurse:
-        case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
-        case PeerPullPaymentInitiationStatus.PendingWithdrawing:
-        case PeerPullPaymentInitiationStatus.PendingReady:
-        case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
-        case PeerPullPaymentInitiationStatus.DonePurseDeposited:
-        case PeerPullPaymentInitiationStatus.Failed:
-        case PeerPullPaymentInitiationStatus.Aborted:
-          break;
-        case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
-          newStatus = PeerPullPaymentInitiationStatus.PendingCreatePurse;
-          break;
-        case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
-          newStatus = PeerPullPaymentInitiationStatus.PendingMergeKycRequired;
-          break;
-        case PeerPullPaymentInitiationStatus.SuspendedReady:
-          newStatus = PeerPullPaymentInitiationStatus.PendingReady;
-          break;
-        case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
-          newStatus = PeerPullPaymentInitiationStatus.PendingWithdrawing;
-          break;
-        case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-          newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
-          break;
-        default:
-          assertUnreachable(pullCreditRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        pullCreditRec.status = newStatus;
-        const newTxState = 
computePeerPullCreditTransactionState(pullCreditRec);
-        await tx.peerPullPaymentInitiations.put(pullCreditRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  ws.workAvailable.trigger();
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumePeerPushDebitTransaction(
-  ws: InternalWalletState,
-  pursePub: string,
-) {
-  const taskId = constructTaskIdentifier({
-    tag: PendingTaskType.PeerPushDebit,
-    pursePub,
-  });
-  const transactionId = constructTransactionIdentifier({
-    tag: TransactionType.PeerPushDebit,
-    pursePub,
-  });
-  stopLongpolling(ws, taskId);
-  const transitionInfo = await ws.db
-    .mktx((x) => [x.peerPushPaymentInitiations])
-    .runReadWrite(async (tx) => {
-      const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
-      if (!pushDebitRec) {
-        logger.warn(`peer push debit ${pursePub} not found`);
-        return;
-      }
-      let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
-      switch (pushDebitRec.status) {
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-          newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
-          break;
-        case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
-          newStatus = PeerPushPaymentInitiationStatus.AbortingRefresh;
-          break;
-        case PeerPushPaymentInitiationStatus.SuspendedReady:
-          newStatus = PeerPushPaymentInitiationStatus.PendingReady;
-          break;
-        case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
-          newStatus = PeerPushPaymentInitiationStatus.PendingCreatePurse;
-          break;
-        case PeerPushPaymentInitiationStatus.PendingCreatePurse:
-        case PeerPushPaymentInitiationStatus.AbortingRefresh:
-        case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
-        case PeerPushPaymentInitiationStatus.PendingReady:
-        case PeerPushPaymentInitiationStatus.Done:
-        case PeerPushPaymentInitiationStatus.Aborted:
-        case PeerPushPaymentInitiationStatus.Failed:
-          // Do nothing
-          break;
-        default:
-          assertUnreachable(pushDebitRec.status);
-      }
-      if (newStatus != null) {
-        const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        pushDebitRec.status = newStatus;
-        const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
-        await tx.peerPushPaymentInitiations.put(pushDebitRec);
-        return {
-          oldTxState,
-          newTxState,
-        };
-      }
-      return undefined;
-    });
-  ws.workAvailable.trigger();
-  notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export function computePeerPushCreditTransactionState(
-  pushCreditRecord: PeerPushPaymentIncomingRecord,
-): TransactionState {
-  switch (pushCreditRecord.status) {
-    case PeerPushPaymentIncomingStatus.DialogProposed:
-      return {
-        major: TransactionMajorState.Dialog,
-        minor: TransactionMinorState.Proposed,
-      };
-    case PeerPushPaymentIncomingStatus.PendingMerge:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.Merge,
-      };
-    case PeerPushPaymentIncomingStatus.Done:
-      return {
-        major: TransactionMajorState.Done,
-      };
-    case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.KycRequired,
-      };
-    case PeerPushPaymentIncomingStatus.PendingWithdrawing:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.Withdraw,
-      };
-    case PeerPushPaymentIncomingStatus.SuspendedMerge:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.Merge,
-      };
-    case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.MergeKycRequired,
-      };
-    case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.Withdraw,
-      };
-    case PeerPushPaymentIncomingStatus.Aborted:
-      return {
-        major: TransactionMajorState.Aborted,
-      };
-    case PeerPushPaymentIncomingStatus.Failed:
-      return {
-        major: TransactionMajorState.Failed,
-      };
-    default:
-      assertUnreachable(pushCreditRecord.status);
-  }
-}
-
-export function computePeerPushCreditTransactionActions(
-  pushCreditRecord: PeerPushPaymentIncomingRecord,
-): TransactionAction[] {
-  switch (pushCreditRecord.status) {
-    case PeerPushPaymentIncomingStatus.DialogProposed:
-      return [];
-    case PeerPushPaymentIncomingStatus.PendingMerge:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPushPaymentIncomingStatus.Done:
-      return [TransactionAction.Delete];
-    case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPushPaymentIncomingStatus.PendingWithdrawing:
-      return [TransactionAction.Suspend, TransactionAction.Fail];
-    case PeerPushPaymentIncomingStatus.SuspendedMerge:
-      return [TransactionAction.Resume, TransactionAction.Abort];
-    case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
-      return [TransactionAction.Resume, TransactionAction.Abort];
-    case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
-      return [TransactionAction.Resume, TransactionAction.Fail];
-    case PeerPushPaymentIncomingStatus.Aborted:
-      return [TransactionAction.Delete];
-    case PeerPushPaymentIncomingStatus.Failed:
-      return [TransactionAction.Delete];
-    default:
-      assertUnreachable(pushCreditRecord.status);
-  }
-}
-
-export function computePeerPullCreditTransactionState(
-  pullCreditRecord: PeerPullPaymentInitiationRecord,
-): TransactionState {
-  switch (pullCreditRecord.status) {
-    case PeerPullPaymentInitiationStatus.PendingCreatePurse:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.CreatePurse,
-      };
-    case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.MergeKycRequired,
-      };
-    case PeerPullPaymentInitiationStatus.PendingReady:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.Ready,
-      };
-    case PeerPullPaymentInitiationStatus.DonePurseDeposited:
-      return {
-        major: TransactionMajorState.Done,
-      };
-    case PeerPullPaymentInitiationStatus.PendingWithdrawing:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.Withdraw,
-      };
-    case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.CreatePurse,
-      };
-    case PeerPullPaymentInitiationStatus.SuspendedReady:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.Ready,
-      };
-    case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.Withdraw,
-      };
-    case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.MergeKycRequired,
-      };
-    case PeerPullPaymentInitiationStatus.Aborted:
-      return {
-        major: TransactionMajorState.Aborted,
-      };
-    case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
-      return {
-        major: TransactionMajorState.Aborting,
-        minor: TransactionMinorState.DeletePurse,
-      };
-    case PeerPullPaymentInitiationStatus.Failed:
-      return {
-        major: TransactionMajorState.Failed,
-      };
-    case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-      return {
-        major: TransactionMajorState.Aborting,
-        minor: TransactionMinorState.DeletePurse,
-      };
-  }
-}
-
-export function computePeerPullCreditTransactionActions(
-  pullCreditRecord: PeerPullPaymentInitiationRecord,
-): TransactionAction[] {
-  switch (pullCreditRecord.status) {
-    case PeerPullPaymentInitiationStatus.PendingCreatePurse:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPullPaymentInitiationStatus.PendingReady:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPullPaymentInitiationStatus.DonePurseDeposited:
-      return [TransactionAction.Delete];
-    case PeerPullPaymentInitiationStatus.PendingWithdrawing:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
-      return [TransactionAction.Resume, TransactionAction.Abort];
-    case PeerPullPaymentInitiationStatus.SuspendedReady:
-      return [TransactionAction.Abort, TransactionAction.Resume];
-    case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
-      return [TransactionAction.Resume, TransactionAction.Fail];
-    case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
-      return [TransactionAction.Resume, TransactionAction.Fail];
-    case PeerPullPaymentInitiationStatus.Aborted:
-      return [TransactionAction.Delete];
-    case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
-      return [TransactionAction.Suspend, TransactionAction.Fail];
-    case PeerPullPaymentInitiationStatus.Failed:
-      return [TransactionAction.Delete];
-    case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
-      return [TransactionAction.Resume, TransactionAction.Fail];
-  }
-}
-
-export function computePeerPullDebitTransactionState(
-  pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionState {
-  switch (pullDebitRecord.status) {
-    case PeerPullDebitRecordStatus.DialogProposed:
-      return {
-        major: TransactionMajorState.Dialog,
-        minor: TransactionMinorState.Proposed,
-      };
-    case PeerPullDebitRecordStatus.PendingDeposit:
-      return {
-        major: TransactionMajorState.Pending,
-        minor: TransactionMinorState.Deposit,
-      };
-    case PeerPullDebitRecordStatus.DonePaid:
-      return {
-        major: TransactionMajorState.Done,
-      };
-    case PeerPullDebitRecordStatus.SuspendedDeposit:
-      return {
-        major: TransactionMajorState.Suspended,
-        minor: TransactionMinorState.Deposit,
-      };
-    case PeerPullDebitRecordStatus.Aborted:
-      return {
-        major: TransactionMajorState.Aborted,
-      };
-    case PeerPullDebitRecordStatus.AbortingRefresh:
-      return {
-        major: TransactionMajorState.Aborting,
-        minor: TransactionMinorState.Refresh,
-      };
-    case PeerPullDebitRecordStatus.Failed:
-      return {
-        major: TransactionMajorState.Failed,
-      };
-    case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
-      return {
-        major: TransactionMajorState.SuspendedAborting,
-        minor: TransactionMinorState.Refresh,
-      };
-  }
-}
-
-export function computePeerPullDebitTransactionActions(
-  pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionAction[] {
-  switch (pullDebitRecord.status) {
-    case PeerPullDebitRecordStatus.DialogProposed:
-      return [];
-    case PeerPullDebitRecordStatus.PendingDeposit:
-      return [TransactionAction.Abort, TransactionAction.Suspend];
-    case PeerPullDebitRecordStatus.DonePaid:
-      return [TransactionAction.Delete];
-    case PeerPullDebitRecordStatus.SuspendedDeposit:
-      return [TransactionAction.Resume, TransactionAction.Abort];
-    case PeerPullDebitRecordStatus.Aborted:
-      return [TransactionAction.Delete];
-    case PeerPullDebitRecordStatus.AbortingRefresh:
-      return [TransactionAction.Fail, TransactionAction.Suspend];
-    case PeerPullDebitRecordStatus.Failed:
-      return [TransactionAction.Delete];
-    case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
-      return [TransactionAction.Resume, TransactionAction.Fail];
-  }
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts 
b/packages/taler-wallet-core/src/operations/testing.ts
index ef5aa907d..238a5dc66 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -50,14 +50,10 @@ import { getBalances } from "./balance.js";
 import { checkLogicInvariant } from "../util/invariants.js";
 import { acceptWithdrawalFromUri } from "./withdraw.js";
 import { updateExchangeFromUrl } from "./exchanges.js";
-import {
-  confirmPeerPullDebit,
-  confirmPeerPushCredit,
-  initiatePeerPullPayment,
-  initiatePeerPushDebit,
-  preparePeerPullDebit,
-  preparePeerPushCredit,
-} from "./pay-peer.js";
+import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
+import { preparePeerPullDebit, confirmPeerPullDebit } from 
"./pay-peer-pull-debit.js";
+import { preparePeerPushCredit, confirmPeerPushCredit } from 
"./pay-peer-push-credit.js";
+import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
 
 const logger = new Logger("operations/testing.ts");
 
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts 
b/packages/taler-wallet-core/src/operations/transactions.ts
index a0da95799..1bd024d28 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -92,32 +92,6 @@ import {
   suspendPayMerchant,
   computePayMerchantTransactionActions,
 } from "./pay-merchant.js";
-import {
-  abortPeerPullCreditTransaction,
-  abortPeerPullDebitTransaction,
-  abortPeerPushCreditTransaction,
-  abortPeerPushDebitTransaction,
-  failPeerPullCreditTransaction,
-  failPeerPullDebitTransaction,
-  failPeerPushCreditTransaction,
-  failPeerPushDebitTransaction,
-  computePeerPullCreditTransactionState,
-  computePeerPullDebitTransactionState,
-  computePeerPushCreditTransactionState,
-  computePeerPushDebitTransactionState,
-  resumePeerPullCreditTransaction,
-  resumePeerPullDebitTransaction,
-  resumePeerPushCreditTransaction,
-  resumePeerPushDebitTransaction,
-  suspendPeerPullCreditTransaction,
-  suspendPeerPullDebitTransaction,
-  suspendPeerPushCreditTransaction,
-  suspendPeerPushDebitTransaction,
-  computePeerPushDebitTransactionActions,
-  computePeerPullDebitTransactionActions,
-  computePeerPullCreditTransactionActions,
-  computePeerPushCreditTransactionActions,
-} from "./pay-peer.js";
 import {
   abortRefreshGroup,
   failRefreshGroup,
@@ -143,6 +117,10 @@ import {
   suspendWithdrawalTransaction,
   computeWithdrawalTransactionActions,
 } from "./withdraw.js";
+import { computePeerPullCreditTransactionState, 
computePeerPullCreditTransactionActions, suspendPeerPullCreditTransaction, 
failPeerPullCreditTransaction, resumePeerPullCreditTransaction, 
abortPeerPullCreditTransaction } from "./pay-peer-pull-credit.js";
+import { computePeerPullDebitTransactionState, 
computePeerPullDebitTransactionActions, suspendPeerPullDebitTransaction, 
failPeerPullDebitTransaction, resumePeerPullDebitTransaction, 
abortPeerPullDebitTransaction } from "./pay-peer-pull-debit.js";
+import { computePeerPushCreditTransactionState, 
computePeerPushCreditTransactionActions, suspendPeerPushCreditTransaction, 
failPeerPushCreditTransaction, resumePeerPushCreditTransaction, 
abortPeerPushCreditTransaction } from "./pay-peer-push-credit.js";
+import { computePeerPushDebitTransactionState, 
computePeerPushDebitTransactionActions, suspendPeerPushDebitTransaction, 
failPeerPushDebitTransaction, resumePeerPushDebitTransaction, 
abortPeerPushDebitTransaction } from "./pay-peer-push-debit.js";
 
 const logger = new Logger("taler-wallet-core:transactions.ts");
 
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index df48c0e19..d0c34588b 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -63,8 +63,6 @@ import {
   codecForAddKnownBankAccounts,
   codecForAny,
   codecForApplyDevExperiment,
-  codecForApplyRefundFromPurchaseIdRequest,
-  codecForApplyRefundRequest,
   codecForCancelAbortingTransactionRequest,
   codecForCheckPeerPullPaymentRequest,
   codecForCheckPeerPushDebitRequest,
@@ -196,22 +194,29 @@ import {
   getContractTermsDetails,
   preparePayForUri,
   processPurchase,
+  startQueryRefund,
   startRefundQueryForUri,
 } from "./operations/pay-merchant.js";
 import {
   checkPeerPullPaymentInitiation,
-  checkPeerPushDebit,
-  confirmPeerPullDebit,
-  confirmPeerPushCredit,
   initiatePeerPullPayment,
-  initiatePeerPushDebit,
+  processPeerPullCredit,
+} from "./operations/pay-peer-pull-credit.js";
+import {
+  confirmPeerPullDebit,
   preparePeerPullDebit,
+} from "./operations/pay-peer-pull-debit.js";
+import {
+  confirmPeerPushCredit,
   preparePeerPushCredit,
-  processPeerPullCredit,
   processPeerPullDebit,
   processPeerPushCredit,
+} from "./operations/pay-peer-push-credit.js";
+import {
+  checkPeerPushDebit,
+  initiatePeerPushDebit,
   processPeerPushDebit,
-} from "./operations/pay-peer.js";
+} from "./operations/pay-peer-push-debit.js";
 import { getPendingOperations } from "./operations/pending.js";
 import {
   createRecoupGroup,
@@ -232,8 +237,8 @@ import {
 import { acceptTip, prepareTip, processTip } from "./operations/tip.js";
 import {
   abortTransaction,
-  failTransaction,
   deleteTransaction,
+  failTransaction,
   getTransactionById,
   getTransactions,
   parseTransactionIdentifier,
@@ -280,7 +285,6 @@ import {
   WalletCoreApiClient,
   WalletCoreResponseType,
 } from "./wallet-api-types.js";
-import { startQueryRefund } from "./operations/pay-merchant.js";
 
 const logger = new Logger("wallet.ts");
 

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