/* 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 */ /** * @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 { 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 { 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 { 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 { 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 { 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, ): Promise { return this.transitionExtra( { extraStores: [], }, f, ); } async transitionExtra< StoreNameArray extends Array> = [], >( opts: { extraStores: StoreNameArray }, f: ( rec: PeerPullPaymentIncomingRecord, tx: DbReadWriteTransaction< typeof WalletStoresV1, ["peerPullDebit", ...StoreNameArray] >, ) => Promise, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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]; } }