summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/pay-peer-pull-debit.ts')
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts1019
1 files changed, 1019 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
new file mode 100644
index 000000000..0355b58ad
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -0,0 +1,1019 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the peer-pull-debit transaction, i.e.
+ * paying for an invoice the wallet received from another wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AcceptPeerPullPaymentResponse,
+ Amounts,
+ CoinRefreshRequest,
+ ConfirmPeerPullDebitRequest,
+ ContractTermsUtil,
+ ExchangePurseDeposits,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ ObservabilityEventType,
+ PeerContractTerms,
+ PreparePeerPullDebitRequest,
+ PreparePeerPullDebitResponse,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ assertUnreachable,
+ checkLogicInvariant,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ parsePayPullUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ TransitionResultType,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ RefreshOperationStatus,
+ WalletStoresV1,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("pay-peer-pull-debit.ts");
+
+/**
+ * Common context for a peer-pull-debit transaction.
+ */
+export class PeerPullDebitTransactionContext implements TransactionContext {
+ wex: WalletExecutionContext;
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+ peerPullDebitId: string;
+
+ constructor(wex: WalletExecutionContext, peerPullDebitId: string) {
+ this.wex = wex;
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.peerPullDebitId = peerPullDebitId;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const transactionId = this.transactionId;
+ const ws = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "tombstones"] },
+ async (tx) => {
+ const debit = await tx.peerPullDebit.get(peerPullDebitId);
+ if (debit) {
+ await tx.peerPullDebit.delete(peerPullDebitId);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const taskId = this.taskId;
+ const transactionId = this.transactionId;
+ const wex = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pullDebitRec) {
+ logger.warn(`peer pull debit ${peerPullDebitId} not found`);
+ return;
+ }
+ let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+ switch (pullDebitRec.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ break;
+ case PeerPullDebitRecordStatus.Done:
+ 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.peerPullDebit.put(pullDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.Aborted:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.Failed:
+ case PeerPullDebitRecordStatus.DialogProposed:
+ case PeerPullDebitRecordStatus.Done:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ // FIXME: Should we also abort the corresponding refresh session?!
+ pi.status = PeerPullDebitRecordStatus.Failed;
+ return TransitionResultType.Transition;
+ default:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transitionExtra(
+ {
+ extraStores: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "coinAvailability",
+ ],
+ },
+ async (pi, tx) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ break;
+ default:
+ return TransitionResultType.Stay;
+ }
+ const currency = Amounts.currencyOf(pi.totalCostEstimated);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!pi.coinSel) {
+ throw Error("invalid db state");
+ }
+
+ for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: pi.coinSel.contributions[i],
+ coinPub: pi.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ ctx.wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPullDebit,
+ this.transactionId,
+ );
+
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ pi.abortRefreshGroupId = refresh.refreshGroupId;
+ return TransitionResultType.Transition;
+ },
+ );
+ }
+
+ async transition(
+ f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PeerPullPaymentIncomingRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["peerPullDebit", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ const wex = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", ...extraStores] },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
+ if (!pi) {
+ throw Error("peer pull payment not found anymore");
+ }
+ const oldTxState = computePeerPullDebitTransactionState(pi);
+ const res = await f(pi, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.peerPullDebit.put(pi);
+ const newTxState = computePeerPullDebitTransactionState(pi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, this.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+}
+
+async function handlePurseCreationConflict(
+ ctx: PeerPullDebitTransactionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const ws = ctx.wex;
+ const errResp = await readTalerErrorResponse(resp);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const sel = peerPullInc.coinSel;
+ if (!sel) {
+ throw Error("invalid state (coin selection expected)");
+ }
+
+ const repair: PreviousPayCoins = [];
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.push({
+ coinPub: sel.coinPubs[i],
+ contribution: Amounts.parseOrThrow(sel.contributions[i]),
+ });
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(ws, {
+ instructedAmount,
+ repair,
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "prospective":
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending (blocked on refresh)",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => {
+ const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.SuspendedDeposit: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPullDebit.put(myPpi);
+ });
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPullDebitPendingDeposit(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const ctx = new PeerPullDebitTransactionContext(
+ wex,
+ peerPullInc.peerPullDebitId,
+ );
+
+ const pursePub = peerPullInc.pursePub;
+
+ const coinSel = peerPullInc.coinSel;
+
+ if (!coinSel) {
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient balance (locked behind refresh)");
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ return false;
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return false;
+ }
+ if (pi.coinSel) {
+ return false;
+ }
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ await tx.peerPullDebit.put(pi);
+ return true;
+ },
+ );
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPullInc.exchangeBaseUrl,
+ );
+
+ // FIXME: We could skip batches that we've already submitted.
+
+ const coins = await queryCoinInfosForSelection(wex, coinSel);
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < coins.length; i += maxBatchSize) {
+ const batchSize = Math.min(maxBatchSize, coins.length - i);
+
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`,
+ });
+
+ const batchCoins = coins.slice(i, i + batchSize);
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+ pursePub: peerPullInc.pursePub,
+ coins: batchCoins,
+ });
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
+ }
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok: {
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForAny(),
+ );
+ logger.trace(`purse deposit response: ${j2s(resp)}`);
+ continue;
+ }
+ case HttpStatusCode.Gone: {
+ await ctx.abortTransaction();
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.Conflict: {
+ return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ }
+
+ // All batches succeeded, we can transition!
+
+ await ctx.transition(async (r) => {
+ if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return TransitionResultType.Stay;
+ }
+ r.status = PeerPullDebitRecordStatus.Done;
+ return TransitionResultType.Transition;
+ });
+ return TaskRunResult.finished();
+}
+
+async function processPeerPullDebitAbortingRefresh(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "refreshGroups"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPullDebitRecordStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPullDebitRecordStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPullDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPullDebitTransactionState(newDg);
+ await tx.peerPullDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullDebit(
+ wex: WalletExecutionContext,
+ peerPullDebitId: string,
+): Promise<TaskRunResult> {
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+ if (!peerPullInc) {
+ throw Error("peer pull debit not found");
+ }
+
+ switch (peerPullInc.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return await processPeerPullDebitPendingDeposit(wex, peerPullInc);
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return await processPeerPullDebitAbortingRefresh(wex, peerPullInc);
+ }
+ return TaskRunResult.finished();
+}
+
+export async function confirmPeerPullDebit(
+ wex: WalletExecutionContext,
+ req: ConfirmPeerPullDebitRequest,
+): Promise<AcceptPeerPullPaymentResponse> {
+ let peerPullDebitId: string;
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
+ throw Error("invalid peer-pull-debit transaction identifier");
+ }
+ peerPullDebitId = parsedTx.peerPullDebitId;
+
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+
+ if (!peerPullInc) {
+ throw Error(
+ `can't accept unknown incoming p2p pull payment (${req.transactionId})`,
+ );
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ throw Error();
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) {
+ return;
+ }
+ if (coinSelRes.type == "success") {
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ }
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ await tx.peerPullDebit.put(pi);
+ },
+ );
+
+ const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
+
+ const transactionId = ctx.transactionId;
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId,
+ };
+}
+
+/**
+ * Look up information about an incoming peer pull payment.
+ * Store the results in the wallet DB.
+ */
+export async function preparePeerPullDebit(
+ wex: WalletExecutionContext,
+ req: PreparePeerPullDebitRequest,
+): Promise<PreparePeerPullDebitResponse> {
+ const uri = parsePayPullUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-pull URI");
+ }
+
+ const existing = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
+ const peerPullDebitRecord =
+ await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!peerPullDebitRecord) {
+ return;
+ }
+ const contractTerms = await tx.contractTerms.get(
+ peerPullDebitRecord.contractTermsHash,
+ );
+ if (!contractTerms) {
+ return;
+ }
+ return { peerPullDebitRecord, contractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.peerPullDebitRecord.amount,
+ amountRaw: existing.peerPullDebitRecord.amount,
+ amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
+ contractTerms: existing.contractTerms.contractTermsRaw,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ }),
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await wex.cryptoApi.decryptContractForDeposit({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ const peerPullDebitId = 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");
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ // FIXME: Why don't we compute the totalCost here?!
+
+ const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: contractTerms,
+ }),
+ await tx.peerPullDebit.add({
+ peerPullDebitId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePub: pursePub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ amount: contractTerms.amount,
+ status: PeerPullDebitRecordStatus.DialogProposed,
+ totalCostEstimated: Amounts.stringify(totalAmount),
+ });
+ },
+ );
+
+ return {
+ amount: contractTerms.amount,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: contractTerms.amount,
+ contractTerms: contractTerms,
+ peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: peerPullDebitId,
+ }),
+ };
+}
+
+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.Done:
+ 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.Done:
+ 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];
+ }
+}