/* 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, CoinRefreshRequest, ContractTermsUtil, HttpStatusCode, InitiatePeerPushDebitRequest, InitiatePeerPushDebitResponse, Logger, NotificationType, RefreshReason, TalerError, TalerErrorCode, TalerPreciseTimestamp, TalerProtocolTimestamp, TalerProtocolViolationError, TalerUriAction, TransactionAction, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, decodeCrock, encodeCrock, getRandomBytes, hash, j2s, stringifyTalerUri, } from "@gnu-taler/taler-util"; import { HttpResponse, readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; import { EncryptContractRequest } from "../crypto/cryptoTypes.js"; import { PeerPushPaymentInitiationRecord, PeerPushPaymentInitiationStatus, RefreshOperationStatus, createRefreshGroup, } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { PendingTaskType } from "../pending-types.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { TaskRunResult, TaskRunResultType, constructTaskIdentifier, runLongpollAsync, spendCoins, } from "./common.js"; import { codecForExchangePurseStatus, getTotalPeerPaymentCost, queryCoinInfosForSelection, } from "./pay-peer-common.js"; import { constructTransactionIdentifier, notifyTransition, stopLongpolling, } from "./transactions.js"; import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.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 handlePurseCreationConflict( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, resp: HttpResponse, ): Promise { const pursePub = peerPushInitiation.pursePub; const errResp = await readTalerErrorResponse(resp); if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { await failPeerPushDebitTransaction(ws, pursePub); 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(peerPushInitiation.amount); const sel = peerPushInitiation.coinSel; const repair: PeerCoinRepair = { coinPubs: [], contribs: [], exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, }; for (let i = 0; i < sel.coinPubs.length; i++) { if (sel.coinPubs[i] != brokenCoinPub) { repair.coinPubs.push(sel.coinPubs[i]); repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i])); } } const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); if (coinSelRes.type == "failure") { // FIXME: Details! throw Error( "insufficient balance to re-select coins to repair double spending", ); } await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const myPpi = await tx.peerPushPaymentInitiations.get( peerPushInitiation.pursePub, ); if (!myPpi) { return; } switch (myPpi.status) { case PeerPushPaymentInitiationStatus.PendingCreatePurse: case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: { const sel = coinSelRes.result; myPpi.coinSel = { coinPubs: sel.coins.map((x) => x.coinPub), contributions: sel.coins.map((x) => x.contribution), }; break; } default: return; } await tx.peerPushPaymentInitiations.put(myPpi); }); return TaskRunResult.finished(); } async function processPeerPushDebitCreateReserve( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, ): Promise { logger.info("processing peer-push-debit pending(create-reserve)"); 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 encryptContractRequest: EncryptContractRequest = { contractTerms: peerPushInitiation.contractTerms, mergePriv: peerPushInitiation.mergePriv, pursePriv: peerPushInitiation.pursePriv, pursePub: peerPushInitiation.pursePub, contractPriv: peerPushInitiation.contractPriv, contractPub: peerPushInitiation.contractPub, nonce: peerPushInitiation.contractEncNonce, }; logger.info(`encrypt contract request: ${j2s(encryptContractRequest)}`); const econtractResp = await ws.cryptoApi.encryptContractForMerge( encryptContractRequest, ); const econtractHash = encodeCrock( hash(decodeCrock(econtractResp.econtract.econtract)), ); logger.info(`econtract hash: ${econtractHash}`); const createPurseUrl = new URL( `purses/${peerPushInitiation.pursePub}/create`, peerPushInitiation.exchangeBaseUrl, ); const reqBody = { 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, }; logger.info(`request body: ${j2s(reqBody)}`); const httpResp = await ws.http.fetch(createPurseUrl.href, { method: "POST", body: reqBody, }); { const resp = await httpResp.json(); logger.info(`resp: ${j2s(resp)}`); } switch (httpResp.status) { case HttpStatusCode.Ok: break; case HttpStatusCode.Forbidden: { // FIXME: Store this error! await failPeerPushDebitTransaction(ws, pursePub); return TaskRunResult.finished(); } case HttpStatusCode.Conflict: { // Handle double-spending return handlePurseCreationConflict(ws, peerPushInitiation, httpResp); } default: { const errResp = await readTalerErrorResponse(httpResp); return { type: TaskRunResultType.Error, errorDetail: errResp, }; } } if (httpResp.status !== HttpStatusCode.Ok) { // FIXME: do proper error reporting throw Error("got error response from exchange"); } await transitionPeerPushDebitTransaction(ws, pursePub, { stFrom: PeerPushPaymentInitiationStatus.PendingCreatePurse, stTo: PeerPushPaymentInitiationStatus.PendingReady, }); return TaskRunResult.finished(); } async function processPeerPushDebitAbortingDeletePurse( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, ): Promise { const { pursePub, pursePriv } = peerPushInitiation; const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub, }); const sigResp = await ws.cryptoApi.signDeletePurse({ pursePriv, }); const purseUrl = new URL( `purses/${pursePub}`, peerPushInitiation.exchangeBaseUrl, ); const resp = await ws.http.fetch(purseUrl.href, { method: "DELETE", headers: { "taler-purse-signature": sigResp.sig, }, }); logger.info(`deleted purse with response status ${resp.status}`); const transitionInfo = await ws.db .mktx((x) => [ x.peerPushPaymentInitiations, x.refreshGroups, x.denominations, x.coinAvailability, x.coins, ]) .runReadWrite(async (tx) => { const ppiRec = await tx.peerPushPaymentInitiations.get(pursePub); if (!ppiRec) { return undefined; } if ( ppiRec.status !== PeerPushPaymentInitiationStatus.AbortingDeletePurse ) { return undefined; } const currency = Amounts.currencyOf(ppiRec.amount); const oldTxState = computePeerPushDebitTransactionState(ppiRec); const coinPubs: CoinRefreshRequest[] = []; for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) { coinPubs.push({ amount: ppiRec.coinSel.contributions[i], coinPub: ppiRec.coinSel.coinPubs[i], }); } const refresh = await createRefreshGroup( ws, tx, currency, coinPubs, RefreshReason.AbortPeerPushDebit, ); ppiRec.status = PeerPushPaymentInitiationStatus.AbortingRefresh; ppiRec.abortRefreshGroupId = refresh.refreshGroupId; await tx.peerPushPaymentInitiations.put(ppiRec); const newTxState = computePeerPushDebitTransactionState(ppiRec); return { oldTxState, newTxState, }; }); notifyTransition(ws, transactionId, transitionInfo); return TaskRunResult.pending(); } interface SimpleTransition { stFrom: PeerPushPaymentInitiationStatus; stTo: PeerPushPaymentInitiationStatus; } async function transitionPeerPushDebitTransaction( ws: InternalWalletState, pursePub: string, transitionSpec: SimpleTransition, ): 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 !== transitionSpec.stFrom) { return undefined; } const oldTxState = computePeerPushDebitTransactionState(ppiRec); ppiRec.status = transitionSpec.stTo; await tx.peerPushPaymentInitiations.put(ppiRec); const newTxState = computePeerPushDebitTransactionState(ppiRec); return { oldTxState, newTxState, }; }); notifyTransition(ws, transactionId, transitionInfo); } async function processPeerPushDebitAbortingRefresh( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, ): Promise { const pursePub = peerPushInitiation.pursePub; const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId; checkLogicInvariant(!!abortRefreshGroupId); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub: peerPushInitiation.pursePub, }); const transitionInfo = await ws.db .mktx((x) => [x.refreshGroups, x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId); let newOpState: PeerPushPaymentInitiationStatus | 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 = PeerPushPaymentInitiationStatus.Failed; } else { if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { newOpState = PeerPushPaymentInitiationStatus.Aborted; } else if ( refreshGroup.operationStatus === RefreshOperationStatus.Failed ) { newOpState = PeerPushPaymentInitiationStatus.Failed; } } if (newOpState) { const newDg = await tx.peerPushPaymentInitiations.get(pursePub); if (!newDg) { return; } const oldTxState = computePeerPushDebitTransactionState(newDg); newDg.status = newOpState; const newTxState = computePeerPushDebitTransactionState(newDg); await tx.peerPushPaymentInitiations.put(newDg); return { oldTxState, newTxState }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); // FIXME: Shouldn't this be finished in some cases?! return TaskRunResult.pending(); } /** * Process the "pending(ready)" state of a peer-push-debit transaction. */ async function processPeerPushDebitReady( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, ): Promise { logger.info("processing peer-push-debit pending(ready)"); const pursePub = peerPushInitiation.pursePub; const retryTag = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); runLongpollAsync(ws, retryTag, async (ct) => { const mergeUrl = new URL( `purses/${pursePub}/merge`, peerPushInitiation.exchangeBaseUrl, ); mergeUrl.searchParams.set("timeout_ms", "30000"); logger.info(`long-polling on purse status at ${mergeUrl.href}`); const resp = await ws.http.fetch(mergeUrl.href, { // timeout: getReserveRequestTimeout(withdrawalGroup), cancellationToken: ct, }); if (resp.status === HttpStatusCode.Ok) { const purseStatus = await readSuccessResponseJsonOrThrow( resp, codecForExchangePurseStatus(), ); const mergeTimestamp = purseStatus.merge_timestamp; logger.info(`got purse status ${j2s(purseStatus)}`); if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) { return { ready: false }; } else { await transitionPeerPushDebitTransaction( ws, peerPushInitiation.pursePub, { stFrom: PeerPushPaymentInitiationStatus.PendingReady, stTo: PeerPushPaymentInitiationStatus.Done, }, ); return { ready: true, }; } } else if (resp.status === HttpStatusCode.Gone) { await transitionPeerPushDebitTransaction( ws, peerPushInitiation.pursePub, { stFrom: PeerPushPaymentInitiationStatus.PendingReady, stTo: PeerPushPaymentInitiationStatus.Expired, }, ); return { ready: true, }; } else { logger.warn(`unexpected HTTP status for purse: ${resp.status}`); return { ready: false, }; } }); logger.trace( "returning early from peer-push-debit for long-polling in background", ); return { type: TaskRunResultType.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: TaskRunResultType.Longpoll, }; } switch (peerPushInitiation.status) { case PeerPushPaymentInitiationStatus.PendingCreatePurse: return processPeerPushDebitCreateReserve(ws, peerPushInitiation); case PeerPushPaymentInitiationStatus.PendingReady: return processPeerPushDebitReady(ws, peerPushInitiation); case PeerPushPaymentInitiationStatus.AbortingDeletePurse: return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation); case PeerPushPaymentInitiationStatus.AbortingRefresh: return processPeerPushDebitAbortingRefresh(ws, peerPushInitiation); default: { const txState = computePeerPushDebitTransactionState(peerPushInitiation); logger.warn( `not processing peer-push-debit transaction in state ${j2s(txState)}`, ); } } return TaskRunResult.finished(); } /** * 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, ); const pursePub = pursePair.pub; const transactionId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); const contractEncNonce = encodeCrock(getRandomBytes(24)); const transitionInfo = 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, }); const ppi: PeerPushPaymentInitiationRecord = { 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, contractEncNonce, coinSel: { coinPubs: sel.coins.map((x) => x.coinPub), contributions: sel.coins.map((x) => x.contribution), }, totalCost: Amounts.stringify(totalAmount), }; await tx.peerPushPaymentInitiations.add(ppi); await tx.contractTerms.put({ h: hContractTerms, contractTermsRaw: contractTerms, }); const newTxState = computePeerPushDebitTransactionState(ppi); return { oldTxState: { major: TransactionMajorState.None }, newTxState, }; }); notifyTransition(ws, transactionId, transitionInfo); ws.notify({ type: NotificationType.BalanceChange }); return { contractPriv: contractKeyPair.priv, mergePriv: mergePair.priv, pursePub: pursePair.pub, exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, talerUri: stringifyTalerUri({ type: TalerUriAction.PayPush, 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.Expired: 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: case PeerPushPaymentInitiationStatus.Expired: 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 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: What to do about the refresh group? newStatus = PeerPushPaymentInitiationStatus.Failed; break; case PeerPushPaymentInitiationStatus.AbortingDeletePurse: case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: case PeerPushPaymentInitiationStatus.PendingReady: case PeerPushPaymentInitiationStatus.SuspendedReady: case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: case PeerPushPaymentInitiationStatus.PendingCreatePurse: newStatus = PeerPushPaymentInitiationStatus.Failed; break; case PeerPushPaymentInitiationStatus.Done: case PeerPushPaymentInitiationStatus.Aborted: case PeerPushPaymentInitiationStatus.Failed: case PeerPushPaymentInitiationStatus.Expired: // 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: case PeerPushPaymentInitiationStatus.Expired: // 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: case PeerPushPaymentInitiationStatus.Expired: // 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, }; case PeerPushPaymentInitiationStatus.Expired: return { major: TransactionMajorState.Expired, }; } }