/* 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 */ 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 { 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 { 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 { 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 { 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 { 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 { 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, }; } }