aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts604
1 files changed, 604 insertions, 0 deletions
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];
+ }
+}