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