/* This file is part of GNU Taler (C) 2019 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 */ /** * Imports. */ import { AbsoluteTime, Amounts, j2s, Logger, NotificationType, OrderShortInfo, PeerContractTerms, RefundInfoShort, RefundPaymentInfo, stringifyPayPullUri, stringifyPayPushUri, TalerErrorCode, TalerProtocolTimestamp, Transaction, TransactionByIdRequest, TransactionIdStr, TransactionMajorState, TransactionsRequest, TransactionsResponse, TransactionState, TransactionType, WithdrawalType, } from "@gnu-taler/taler-util"; import { DepositElementStatus, DepositGroupRecord, ExchangeDetailsRecord, OperationRetryRecord, PeerPullPaymentIncomingRecord, PeerPullPaymentIncomingStatus, PeerPullPaymentInitiationRecord, PeerPushPaymentIncomingRecord, PeerPushPaymentIncomingStatus, PeerPushPaymentInitiationRecord, PurchaseRecord, PurchaseStatus, RefreshGroupRecord, RefreshOperationStatus, RefundGroupRecord, TipRecord, WalletContractData, WithdrawalGroupRecord, WithdrawalGroupStatus, WithdrawalRecordType, } from "../db.js"; import { GetReadOnlyAccess, WalletStoresV1 } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { PendingTaskType } from "../pending-types.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { constructTaskIdentifier, TaskIdentifiers } from "../util/retries.js"; import { resetOperationTimeout, TombstoneTag } from "./common.js"; import { abortDepositGroup, cancelAbortingDepositGroup, computeDepositTransactionStatus, deleteDepositGroup, resumeDepositGroup, suspendDepositGroup, } from "./deposits.js"; import { getExchangeDetails } from "./exchanges.js"; import { abortPayMerchant, computePayMerchantTransactionState, computeRefundTransactionState, expectProposalDownload, extractContractData, } from "./pay-merchant.js"; import { computePeerPullCreditTransactionState, computePeerPullDebitTransactionState, computePeerPushCreditTransactionState, computePeerPushDebitTransactionState, } from "./pay-peer.js"; import { computeRefreshTransactionState } from "./refresh.js"; import { computeTipTransactionStatus } from "./tip.js"; import { abortWithdrawalTransaction, augmentPaytoUrisForWithdrawal, cancelAbortingWithdrawalTransaction, computeWithdrawalTransactionStatus, } from "./withdraw.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); function shouldSkipCurrency( transactionsRequest: TransactionsRequest | undefined, currency: string, ): boolean { if (!transactionsRequest?.currency) { return false; } return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase(); } function shouldSkipSearch( transactionsRequest: TransactionsRequest | undefined, fields: string[], ): boolean { if (!transactionsRequest?.search) { return false; } const needle = transactionsRequest.search.trim(); for (const f of fields) { if (f.indexOf(needle) >= 0) { return false; } } return true; } /** * Fallback order of transactions that have the same timestamp. */ const txOrder: { [t in TransactionType]: number } = { [TransactionType.Withdrawal]: 1, [TransactionType.Tip]: 2, [TransactionType.Payment]: 3, [TransactionType.PeerPullCredit]: 4, [TransactionType.PeerPullDebit]: 5, [TransactionType.PeerPushCredit]: 6, [TransactionType.PeerPushDebit]: 7, [TransactionType.Refund]: 8, [TransactionType.Deposit]: 9, [TransactionType.Refresh]: 10, [TransactionType.Tip]: 11, [TransactionType.InternalWithdrawal]: 12, }; export async function getTransactionById( ws: InternalWalletState, req: TransactionByIdRequest, ): Promise { const parsedTx = parseTransactionIdentifier(req.transactionId); if (!parsedTx) { throw Error("invalid transaction ID"); } switch (parsedTx.tag) { case TransactionType.Withdrawal: { const withdrawalGroupId = parsedTx.withdrawalGroupId; return await ws.db .mktx((x) => [ x.withdrawalGroups, x.exchangeDetails, x.exchanges, x.operationRetries, ]) .runReadWrite(async (tx) => { const withdrawalGroupRecord = await tx.withdrawalGroups.get( withdrawalGroupId, ); if (!withdrawalGroupRecord) throw Error("not found"); const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); if ( withdrawalGroupRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated ) { return buildTransactionForBankIntegratedWithdraw( withdrawalGroupRecord, ort, ); } const exchangeDetails = await getExchangeDetails( tx, withdrawalGroupRecord.exchangeBaseUrl, ); if (!exchangeDetails) throw Error("not exchange details"); return buildTransactionForManualWithdraw( withdrawalGroupRecord, exchangeDetails, ort, ); }); } case TransactionType.Payment: { const proposalId = parsedTx.proposalId; return await ws.db .mktx((x) => [ x.purchases, x.tombstones, x.operationRetries, x.contractTerms, ]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) throw Error("not found"); const download = await expectProposalDownload(ws, purchase, tx); const contractData = download.contractData; const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); return buildTransactionForPurchase( purchase, contractData, [], // FIXME: Add refunds from refund group records here. payRetryRecord, ); }); } case TransactionType.Refresh: { // FIXME: We should return info about the refresh here! throw Error(`no tx for refresh`); } case TransactionType.Tip: { const tipId = parsedTx.walletTipId; return await ws.db .mktx((x) => [x.tips, x.operationRetries]) .runReadWrite(async (tx) => { const tipRecord = await tx.tips.get(tipId); if (!tipRecord) throw Error("not found"); const retries = await tx.operationRetries.get( TaskIdentifiers.forTipPickup(tipRecord), ); return buildTransactionForTip(tipRecord, retries); }); } case TransactionType.Deposit: { const depositGroupId = parsedTx.depositGroupId; return await ws.db .mktx((x) => [x.depositGroups, x.operationRetries]) .runReadWrite(async (tx) => { const depositRecord = await tx.depositGroups.get(depositGroupId); if (!depositRecord) throw Error("not found"); const retries = await tx.operationRetries.get( TaskIdentifiers.forDeposit(depositRecord), ); return buildTransactionForDeposit(depositRecord, retries); }); } case TransactionType.Refund: { return await ws.db .mktx((x) => [x.refundGroups, x.contractTerms, x.purchases]) .runReadOnly(async (tx) => { const refundRecord = await tx.refundGroups.get( parsedTx.refundGroupId, ); if (!refundRecord) { throw Error("not found"); } const contractData = await lookupMaybeContractData( tx, refundRecord?.proposalId, ); return buildTransactionForRefund(refundRecord, contractData); }); } case TransactionType.PeerPullDebit: { return await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadWrite(async (tx) => { const debit = await tx.peerPullPaymentIncoming.get( parsedTx.peerPullPaymentIncomingId, ); if (!debit) throw Error("not found"); return buildTransactionForPullPaymentDebit(debit); }); } case TransactionType.PeerPushDebit: { return await ws.db .mktx((x) => [x.peerPushPaymentInitiations, x.contractTerms]) .runReadWrite(async (tx) => { const debit = await tx.peerPushPaymentInitiations.get( parsedTx.pursePub, ); if (!debit) throw Error("not found"); const ct = await tx.contractTerms.get(debit.contractTermsHash); checkDbInvariant(!!ct); return buildTransactionForPushPaymentDebit( debit, ct.contractTermsRaw, ); }); } case TransactionType.PeerPushCredit: { const peerPushPaymentIncomingId = parsedTx.peerPushPaymentIncomingId; return await ws.db .mktx((x) => [ x.peerPushPaymentIncoming, x.contractTerms, x.withdrawalGroups, x.operationRetries, ]) .runReadWrite(async (tx) => { const pushInc = await tx.peerPushPaymentIncoming.get( peerPushPaymentIncomingId, ); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); checkDbInvariant(!!ct); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; if (pushInc.withdrawalGroupId) { wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId); if (wg) { const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg); wgOrt = await tx.operationRetries.get(withdrawalOpId); } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc); let pushIncOrt = await tx.operationRetries.get(pushIncOpId); return buildTransactionForPeerPushCredit( pushInc, pushIncOrt, ct.contractTermsRaw, wg, wgOrt, ); }); } case TransactionType.PeerPullCredit: { const pursePub = parsedTx.pursePub; return await ws.db .mktx((x) => [ x.peerPullPaymentInitiations, x.contractTerms, x.withdrawalGroups, x.operationRetries, ]) .runReadWrite(async (tx) => { const pushInc = await tx.peerPullPaymentInitiations.get(pursePub); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); checkDbInvariant(!!ct); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; if (pushInc.withdrawalGroupId) { wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId); if (wg) { const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg); wgOrt = await tx.operationRetries.get(withdrawalOpId); } } const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pushInc); let pushIncOrt = await tx.operationRetries.get(pushIncOpId); return buildTransactionForPeerPullCredit( pushInc, pushIncOrt, ct.contractTermsRaw, wg, wgOrt, ); }); } } } function buildTransactionForPushPaymentDebit( pi: PeerPushPaymentInitiationRecord, contractTerms: PeerContractTerms, ort?: OperationRetryRecord, ): Transaction { return { type: TransactionType.PeerPushDebit, txState: computePeerPushDebitTransactionState(pi), amountEffective: pi.totalCost, amountRaw: pi.amount, exchangeBaseUrl: pi.exchangeBaseUrl, info: { expiration: contractTerms.purse_expiration, summary: contractTerms.summary, }, timestamp: pi.timestampCreated, talerUri: stringifyPayPushUri({ exchangeBaseUrl: pi.exchangeBaseUrl, contractPriv: pi.contractPriv, }), transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub: pi.pursePub, }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; } function buildTransactionForPullPaymentDebit( pi: PeerPullPaymentIncomingRecord, ort?: OperationRetryRecord, ): Transaction { return { type: TransactionType.PeerPullDebit, txState: computePeerPullDebitTransactionState(pi), amountEffective: pi.coinSel?.totalCost ? pi.coinSel?.totalCost : Amounts.stringify(pi.contractTerms.amount), amountRaw: Amounts.stringify(pi.contractTerms.amount), exchangeBaseUrl: pi.exchangeBaseUrl, info: { expiration: pi.contractTerms.purse_expiration, summary: pi.contractTerms.summary, }, timestamp: pi.timestampCreated, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId: pi.peerPullPaymentIncomingId, }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; } function buildTransactionForPeerPullCredit( pullCredit: PeerPullPaymentInitiationRecord, pullCreditOrt: OperationRetryRecord | undefined, peerContractTerms: PeerContractTerms, wsr: WithdrawalGroupRecord | undefined, wsrOrt: OperationRetryRecord | undefined, ): Transaction { if (wsr) { if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) { throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`); } /** * FIXME: this should be handled in the withdrawal process. * PeerPull withdrawal fails until reserve have funds but it is not * an error from the user perspective. */ const silentWithdrawalErrorForInvoice = wsrOrt?.lastError && wsrOrt.lastError.code === TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE && Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => { return ( e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR && e.httpStatusCode === 409 ); }); return { type: TransactionType.PeerPullCredit, txState: computePeerPullCreditTransactionState(pullCredit), amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), amountRaw: Amounts.stringify(wsr.instructedAmount), exchangeBaseUrl: wsr.exchangeBaseUrl, // Old transactions don't have it! timestamp: pullCredit.mergeTimestamp ?? TalerProtocolTimestamp.now(), info: { expiration: wsr.wgInfo.contractTerms.purse_expiration, summary: wsr.wgInfo.contractTerms.summary, }, talerUri: stringifyPayPullUri({ exchangeBaseUrl: wsr.exchangeBaseUrl, contractPriv: wsr.wgInfo.contractPriv, }), transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullCredit.pursePub, }), ...(wsrOrt?.lastError ? { error: silentWithdrawalErrorForInvoice ? undefined : wsrOrt.lastError, } : {}), }; } return { type: TransactionType.PeerPullCredit, txState: computePeerPullCreditTransactionState(pullCredit), amountEffective: Amounts.stringify(pullCredit.estimatedAmountEffective), amountRaw: Amounts.stringify(peerContractTerms.amount), exchangeBaseUrl: pullCredit.exchangeBaseUrl, // Old transactions don't have it! timestamp: pullCredit.mergeTimestamp ?? TalerProtocolTimestamp.now(), info: { expiration: peerContractTerms.purse_expiration, summary: peerContractTerms.summary, }, talerUri: stringifyPayPullUri({ exchangeBaseUrl: pullCredit.exchangeBaseUrl, contractPriv: pullCredit.contractPriv, }), transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullCredit.pursePub, }), ...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}), }; } function buildTransactionForPeerPushCredit( pushInc: PeerPushPaymentIncomingRecord, pushOrt: OperationRetryRecord | undefined, peerContractTerms: PeerContractTerms, wsr: WithdrawalGroupRecord | undefined, wsrOrt: OperationRetryRecord | undefined, ): Transaction { if (wsr) { if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) { throw Error("invalid withdrawal group type for push payment credit"); } return { type: TransactionType.PeerPushCredit, txState: computePeerPushCreditTransactionState(pushInc), amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), amountRaw: Amounts.stringify(wsr.instructedAmount), exchangeBaseUrl: wsr.exchangeBaseUrl, info: { expiration: wsr.wgInfo.contractTerms.purse_expiration, summary: wsr.wgInfo.contractTerms.summary, }, timestamp: wsr.timestampStart, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId: pushInc.peerPushPaymentIncomingId, }), ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}), }; } return { type: TransactionType.PeerPushCredit, txState: computePeerPushCreditTransactionState(pushInc), // FIXME: This is wrong, needs to consider fees! amountEffective: Amounts.stringify(peerContractTerms.amount), amountRaw: Amounts.stringify(peerContractTerms.amount), exchangeBaseUrl: pushInc.exchangeBaseUrl, info: { expiration: peerContractTerms.purse_expiration, summary: peerContractTerms.summary, }, timestamp: pushInc.timestamp, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId: pushInc.peerPushPaymentIncomingId, }), ...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}), }; } function buildTransactionForBankIntegratedWithdraw( wgRecord: WithdrawalGroupRecord, ort?: OperationRetryRecord, ): Transaction { if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) throw Error(""); return { type: TransactionType.Withdrawal, txState: computeWithdrawalTransactionStatus(wgRecord), amountEffective: Amounts.stringify(wgRecord.denomsSel.totalCoinValue), amountRaw: Amounts.stringify(wgRecord.instructedAmount), withdrawalDetails: { type: WithdrawalType.TalerBankIntegrationApi, confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false, reservePub: wgRecord.reservePub, bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl, reserveIsReady: wgRecord.status === WithdrawalGroupStatus.Finished || wgRecord.status === WithdrawalGroupStatus.PendingReady, }, exchangeBaseUrl: wgRecord.exchangeBaseUrl, timestamp: wgRecord.timestampStart, transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId: wgRecord.withdrawalGroupId, }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; } function buildTransactionForManualWithdraw( withdrawalGroup: WithdrawalGroupRecord, exchangeDetails: ExchangeDetailsRecord, ort?: OperationRetryRecord, ): Transaction { if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual) throw Error(""); const plainPaytoUris = exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; const exchangePaytoUris = augmentPaytoUrisForWithdrawal( plainPaytoUris, withdrawalGroup.reservePub, withdrawalGroup.instructedAmount, ); return { type: TransactionType.Withdrawal, txState: computeWithdrawalTransactionStatus(withdrawalGroup), amountEffective: Amounts.stringify( withdrawalGroup.denomsSel.totalCoinValue, ), amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount), withdrawalDetails: { type: WithdrawalType.ManualTransfer, reservePub: withdrawalGroup.reservePub, exchangePaytoUris, reserveIsReady: withdrawalGroup.status === WithdrawalGroupStatus.Finished || withdrawalGroup.status === WithdrawalGroupStatus.PendingReady, }, exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl, timestamp: withdrawalGroup.timestampStart, transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId: withdrawalGroup.withdrawalGroupId, }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; } function buildTransactionForRefund( refundRecord: RefundGroupRecord, maybeContractData: WalletContractData | undefined, ): Transaction { let paymentInfo: RefundPaymentInfo | undefined = undefined; if (maybeContractData) { paymentInfo = { merchant: maybeContractData.merchant, summary: maybeContractData.summary, summary_i18n: maybeContractData.summaryI18n, }; } return { type: TransactionType.Refund, amountEffective: refundRecord.amountEffective, amountRaw: refundRecord.amountRaw, refundedTransactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: refundRecord.proposalId, }), timestamp: refundRecord.timestampCreated, transactionId: constructTransactionIdentifier({ tag: TransactionType.Refund, refundGroupId: refundRecord.refundGroupId, }), txState: computeRefundTransactionState(refundRecord), paymentInfo, }; } function buildTransactionForRefresh( refreshGroupRecord: RefreshGroupRecord, ort?: OperationRetryRecord, ): Transaction { const inputAmount = Amounts.sumOrZero( refreshGroupRecord.currency, refreshGroupRecord.inputPerCoin, ).amount; const outputAmount = Amounts.sumOrZero( refreshGroupRecord.currency, refreshGroupRecord.estimatedOutputPerCoin, ).amount; return { type: TransactionType.Refresh, txState: computeRefreshTransactionState(refreshGroupRecord), refreshReason: refreshGroupRecord.reason, amountEffective: Amounts.stringify( Amounts.zeroOfCurrency(refreshGroupRecord.currency), ), amountRaw: Amounts.stringify( Amounts.zeroOfCurrency(refreshGroupRecord.currency), ), refreshInputAmount: Amounts.stringify(inputAmount), refreshOutputAmount: Amounts.stringify(outputAmount), originatingTransactionId: refreshGroupRecord.reasonDetails?.originatingTransactionId, timestamp: refreshGroupRecord.timestampCreated, transactionId: constructTransactionIdentifier({ tag: TransactionType.Refresh, refreshGroupId: refreshGroupRecord.refreshGroupId, }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; } function buildTransactionForDeposit( dg: DepositGroupRecord, ort?: OperationRetryRecord, ): Transaction { let deposited = true; for (const d of dg.depositedPerCoin) { if (!d) { deposited = false; } } return { type: TransactionType.Deposit, txState: computeDepositTransactionStatus(dg), amountRaw: Amounts.stringify(dg.effectiveDepositAmount), amountEffective: Amounts.stringify(dg.totalPayCost), timestamp: dg.timestampCreated, targetPaytoUri: dg.wire.payto_uri, wireTransferDeadline: dg.contractTermsRaw.wire_transfer_deadline, transactionId: constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId: dg.depositGroupId, }), wireTransferProgress: (100 * dg.transactionPerCoin.reduce( (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), 0, )) / dg.transactionPerCoin.length, depositGroupId: dg.depositGroupId, trackingState: Object.values(dg.trackingState ?? {}), deposited, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } function buildTransactionForTip( tipRecord: TipRecord, ort?: OperationRetryRecord, ): Transaction { checkLogicInvariant(!!tipRecord.acceptedTimestamp); return { type: TransactionType.Tip, txState: computeTipTransactionStatus(tipRecord), amountEffective: Amounts.stringify(tipRecord.tipAmountEffective), amountRaw: Amounts.stringify(tipRecord.tipAmountRaw), timestamp: tipRecord.acceptedTimestamp, transactionId: constructTransactionIdentifier({ tag: TransactionType.Tip, walletTipId: tipRecord.walletTipId, }), merchantBaseUrl: tipRecord.merchantBaseUrl, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } async function lookupMaybeContractData( tx: GetReadOnlyAccess<{ purchases: typeof WalletStoresV1.purchases; contractTerms: typeof WalletStoresV1.contractTerms; }>, proposalId: string, ): Promise { let contractData: WalletContractData | undefined = undefined; const purchaseTx = await tx.purchases.get(proposalId); if (purchaseTx && purchaseTx.download) { const download = purchaseTx.download; const contractTermsRecord = await tx.contractTerms.get( download.contractTermsHash, ); if (!contractTermsRecord) { return; } contractData = extractContractData( contractTermsRecord?.contractTermsRaw, download.contractTermsHash, download.contractTermsMerchantSig, ); } return contractData; } async function buildTransactionForPurchase( purchaseRecord: PurchaseRecord, contractData: WalletContractData, refundsInfo: RefundGroupRecord[], ort?: OperationRetryRecord, ): Promise { const zero = Amounts.zeroOfAmount(contractData.amount); const info: OrderShortInfo = { merchant: contractData.merchant, orderId: contractData.orderId, products: contractData.products, summary: contractData.summary, summary_i18n: contractData.summaryI18n, contractTermsHash: contractData.contractTermsHash, }; if (contractData.fulfillmentUrl !== "") { info.fulfillmentUrl = contractData.fulfillmentUrl; } const refunds: RefundInfoShort[] = []; const timestamp = purchaseRecord.timestampAccept; checkDbInvariant(!!timestamp); checkDbInvariant(!!purchaseRecord.payInfo); return { type: TransactionType.Payment, txState: computePayMerchantTransactionState(purchaseRecord), amountRaw: Amounts.stringify(contractData.amount), amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost), totalRefundRaw: Amounts.stringify(zero), // FIXME! totalRefundEffective: Amounts.stringify(zero), // FIXME! refundPending: purchaseRecord.refundAmountAwaiting === undefined ? undefined : Amounts.stringify(purchaseRecord.refundAmountAwaiting), refunds, posConfirmation: purchaseRecord.posConfirmation, timestamp, transactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: purchaseRecord.proposalId, }), proposalId: purchaseRecord.proposalId, info, refundQueryActive: purchaseRecord.purchaseStatus === PurchaseStatus.QueryingRefund, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } /** * Retrieve the full event history for this wallet. */ export async function getTransactions( ws: InternalWalletState, transactionsRequest?: TransactionsRequest, ): Promise { const transactions: Transaction[] = []; await ws.db .mktx((x) => [ x.coins, x.denominations, x.depositGroups, x.exchangeDetails, x.exchanges, x.operationRetries, x.peerPullPaymentIncoming, x.peerPushPaymentInitiations, x.peerPushPaymentIncoming, x.peerPullPaymentInitiations, x.planchets, x.purchases, x.contractTerms, x.recoupGroups, x.tips, x.tombstones, x.withdrawalGroups, x.refreshGroups, x.refundGroups, ]) .runReadOnly(async (tx) => { tx.peerPushPaymentInitiations.iter().forEachAsync(async (pi) => { const amount = Amounts.parseOrThrow(pi.amount); if (shouldSkipCurrency(transactionsRequest, amount.currency)) { return; } if (shouldSkipSearch(transactionsRequest, [])) { return; } const ct = await tx.contractTerms.get(pi.contractTermsHash); checkDbInvariant(!!ct); transactions.push( buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw), ); }); tx.peerPullPaymentIncoming.iter().forEachAsync(async (pi) => { const amount = Amounts.parseOrThrow(pi.contractTerms.amount); if (shouldSkipCurrency(transactionsRequest, amount.currency)) { return; } if (shouldSkipSearch(transactionsRequest, [])) { return; } if ( pi.status !== PeerPullPaymentIncomingStatus.Accepted && pi.status !== PeerPullPaymentIncomingStatus.Paid ) { return; } transactions.push(buildTransactionForPullPaymentDebit(pi)); }); tx.peerPushPaymentIncoming.iter().forEachAsync(async (pi) => { if (!pi.currency) { // Legacy transaction return; } if (shouldSkipCurrency(transactionsRequest, pi.currency)) { return; } if (shouldSkipSearch(transactionsRequest, [])) { return; } if (pi.status === PeerPushPaymentIncomingStatus.Proposed) { // We don't report proposed push credit transactions, user needs // to scan URI again and confirm to see it. return; } const ct = await tx.contractTerms.get(pi.contractTermsHash); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; if (pi.withdrawalGroupId) { wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId); if (wg) { const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg); wgOrt = await tx.operationRetries.get(withdrawalOpId); } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi); let pushIncOrt = await tx.operationRetries.get(pushIncOpId); checkDbInvariant(!!ct); transactions.push( buildTransactionForPeerPushCredit( pi, pushIncOrt, ct.contractTermsRaw, wg, wgOrt, ), ); }); tx.peerPullPaymentInitiations.iter().forEachAsync(async (pi) => { const currency = Amounts.currencyOf(pi.amount); if (shouldSkipCurrency(transactionsRequest, currency)) { return; } if (shouldSkipSearch(transactionsRequest, [])) { return; } const ct = await tx.contractTerms.get(pi.contractTermsHash); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; if (pi.withdrawalGroupId) { wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId); if (wg) { const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg); wgOrt = await tx.operationRetries.get(withdrawalOpId); } } const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi); let pushIncOrt = await tx.operationRetries.get(pushIncOpId); checkDbInvariant(!!ct); transactions.push( buildTransactionForPeerPullCredit( pi, pushIncOrt, ct.contractTermsRaw, wg, wgOrt, ), ); }); tx.refundGroups.iter().forEachAsync(async (refundGroup) => { const currency = Amounts.currencyOf(refundGroup.amountRaw); if (shouldSkipCurrency(transactionsRequest, currency)) { return; } const contractData = await lookupMaybeContractData( tx, refundGroup.proposalId, ); transactions.push(buildTransactionForRefund(refundGroup, contractData)); }); tx.refreshGroups.iter().forEachAsync(async (rg) => { if (shouldSkipCurrency(transactionsRequest, rg.currency)) { return; } let required = false; const opId = TaskIdentifiers.forRefresh(rg); if (transactionsRequest?.includeRefreshes) { required = true; } else if (rg.operationStatus !== RefreshOperationStatus.Finished) { const ort = await tx.operationRetries.get(opId); if (ort) { required = true; } } if (required) { const ort = await tx.operationRetries.get(opId); transactions.push(buildTransactionForRefresh(rg, ort)); } }); tx.withdrawalGroups.iter().forEachAsync(async (wsr) => { if ( shouldSkipCurrency( transactionsRequest, Amounts.currencyOf(wsr.rawWithdrawalAmount), ) ) { return; } if (shouldSkipSearch(transactionsRequest, [])) { return; } const opId = TaskIdentifiers.forWithdrawal(wsr); const ort = await tx.operationRetries.get(opId); switch (wsr.wgInfo.withdrawalType) { case WithdrawalRecordType.PeerPullCredit: // Will be reported by the corresponding p2p transaction. // FIXME: If this is an orphan withdrawal, still report it as a withdrawal! // FIXME: Still report if requested with verbose option? return; case WithdrawalRecordType.PeerPushCredit: // Will be reported by the corresponding p2p transaction. // FIXME: If this is an orphan withdrawal, still report it as a withdrawal! // FIXME: Still report if requested with verbose option? return; case WithdrawalRecordType.BankIntegrated: transactions.push( buildTransactionForBankIntegratedWithdraw(wsr, ort), ); return; case WithdrawalRecordType.BankManual: { const exchangeDetails = await getExchangeDetails( tx, wsr.exchangeBaseUrl, ); if (!exchangeDetails) { // FIXME: report somehow return; } transactions.push( buildTransactionForManualWithdraw(wsr, exchangeDetails, ort), ); return; } case WithdrawalRecordType.Recoup: // FIXME: Do we also report a transaction here? return; } }); tx.depositGroups.iter().forEachAsync(async (dg) => { const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount); if (shouldSkipCurrency(transactionsRequest, amount.currency)) { return; } const opId = TaskIdentifiers.forDeposit(dg); const retryRecord = await tx.operationRetries.get(opId); transactions.push(buildTransactionForDeposit(dg, retryRecord)); }); tx.purchases.iter().forEachAsync(async (purchase) => { const download = purchase.download; if (!download) { return; } if (!purchase.payInfo) { return; } if (shouldSkipCurrency(transactionsRequest, download.currency)) { return; } const contractTermsRecord = await tx.contractTerms.get( download.contractTermsHash, ); if (!contractTermsRecord) { return; } if ( shouldSkipSearch(transactionsRequest, [ contractTermsRecord?.contractTermsRaw?.summary || "", ]) ) { return; } const contractData = extractContractData( contractTermsRecord?.contractTermsRaw, download.contractTermsHash, download.contractTermsMerchantSig, ); const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); transactions.push( await buildTransactionForPurchase( purchase, contractData, [], // FIXME! payRetryRecord, ), ); }); tx.tips.iter().forEachAsync(async (tipRecord) => { if ( shouldSkipCurrency( transactionsRequest, Amounts.parseOrThrow(tipRecord.tipAmountRaw).currency, ) ) { return; } if (!tipRecord.acceptedTimestamp) { return; } const opId = TaskIdentifiers.forTipPickup(tipRecord); const retryRecord = await tx.operationRetries.get(opId); transactions.push(buildTransactionForTip(tipRecord, retryRecord)); }); }); // One-off checks, because of a bug where the wallet previously // did not migrate the DB correctly and caused these amounts // to be missing sometimes. for (let tx of transactions) { if (!tx.amountEffective) { logger.warn(`missing amountEffective in ${j2s(tx)}`); } if (!tx.amountRaw) { logger.warn(`missing amountRaw in ${j2s(tx)}`); } if (!tx.timestamp) { logger.warn(`missing timestamp in ${j2s(tx)}`); } } const isPending = (x: Transaction) => x.txState.major === TransactionMajorState.Pending || x.txState.major === TransactionMajorState.Aborting || x.txState.major === TransactionMajorState.Dialog; const txPending = transactions.filter((x) => isPending(x)); const txNotPending = transactions.filter((x) => !isPending(x)); const txCmp = (h1: Transaction, h2: Transaction) => { const tsCmp = AbsoluteTime.cmp( AbsoluteTime.fromTimestamp(h1.timestamp), AbsoluteTime.fromTimestamp(h2.timestamp), ); if (tsCmp === 0) { return Math.sign(txOrder[h1.type] - txOrder[h2.type]); } return tsCmp; }; txPending.sort(txCmp); txNotPending.sort(txCmp); return { transactions: [...txNotPending, ...txPending] }; } export type ParsedTransactionIdentifier = | { tag: TransactionType.Deposit; depositGroupId: string } | { tag: TransactionType.Payment; proposalId: string } | { tag: TransactionType.PeerPullDebit; peerPullPaymentIncomingId: string } | { tag: TransactionType.PeerPullCredit; pursePub: string } | { tag: TransactionType.PeerPushCredit; peerPushPaymentIncomingId: string } | { tag: TransactionType.PeerPushDebit; pursePub: string } | { tag: TransactionType.Refresh; refreshGroupId: string } | { tag: TransactionType.Refund; refundGroupId: string } | { tag: TransactionType.Tip; walletTipId: string } | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }; export function constructTransactionIdentifier( pTxId: ParsedTransactionIdentifier, ): TransactionIdStr { switch (pTxId.tag) { case TransactionType.Deposit: return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr; case TransactionType.Payment: return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr; case TransactionType.PeerPullCredit: return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr; case TransactionType.PeerPullDebit: return `txn:${pTxId.tag}:${pTxId.peerPullPaymentIncomingId}` as TransactionIdStr; case TransactionType.PeerPushCredit: return `txn:${pTxId.tag}:${pTxId.peerPushPaymentIncomingId}` as TransactionIdStr; case TransactionType.PeerPushDebit: return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr; case TransactionType.Refresh: return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr; case TransactionType.Refund: return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr; case TransactionType.Tip: return `txn:${pTxId.tag}:${pTxId.walletTipId}` as TransactionIdStr; case TransactionType.Withdrawal: return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr; default: assertUnreachable(pTxId); } } /** * Parse a transaction identifier string into a typed, structured representation. */ export function parseTransactionIdentifier( transactionId: string, ): ParsedTransactionIdentifier | undefined { const txnParts = transactionId.split(":"); if (txnParts.length < 3) { throw Error("id should have al least 3 parts separated by ':'"); } const [prefix, type, ...rest] = txnParts; switch (type) { case TransactionType.Deposit: return { tag: TransactionType.Deposit, depositGroupId: rest[0] }; case TransactionType.Payment: return { tag: TransactionType.Payment, proposalId: rest[0] }; case TransactionType.PeerPullCredit: return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] }; case TransactionType.PeerPullDebit: return { tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId: rest[0], }; case TransactionType.PeerPushCredit: return { tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId: rest[0], }; case TransactionType.PeerPushDebit: return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] }; case TransactionType.Refresh: return { tag: TransactionType.Refresh, refreshGroupId: rest[0] }; case TransactionType.Refund: return { tag: TransactionType.Refund, refundGroupId: rest[0], }; case TransactionType.Tip: return { tag: TransactionType.Tip, walletTipId: rest[0], }; case TransactionType.Withdrawal: return { tag: TransactionType.Withdrawal, withdrawalGroupId: rest[0], }; default: return undefined; } } export function stopLongpolling(ws: InternalWalletState, taskId: string) { const longpoll = ws.activeLongpoll[taskId]; if (longpoll) { logger.info(`cancelling long-polling for ${taskId}`); longpoll.cancel(); delete ws.activeLongpoll[taskId]; } } /** * Immediately retry the underlying operation * of a transaction. */ export async function retryTransaction( ws: InternalWalletState, transactionId: string, ): Promise { logger.info(`resetting retry timeout for ${transactionId}`); const parsedTx = parseTransactionIdentifier(transactionId); if (!parsedTx) { throw Error("invalid transaction identifier"); } // FIXME: We currently don't cancel active long-polling tasks here. switch (parsedTx.tag) { case TransactionType.PeerPullCredit: { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub: parsedTx.pursePub, }); await resetOperationTimeout(ws, taskId); stopLongpolling(ws, taskId); break; } case TransactionType.Deposit: { const taskId = constructTaskIdentifier({ tag: PendingTaskType.Deposit, depositGroupId: parsedTx.depositGroupId, }); await resetOperationTimeout(ws, taskId); stopLongpolling(ws, taskId); break; } case TransactionType.Withdrawal: { // FIXME: Abort current long-poller! const taskId = constructTaskIdentifier({ tag: PendingTaskType.Withdraw, withdrawalGroupId: parsedTx.withdrawalGroupId, }); await resetOperationTimeout(ws, taskId); stopLongpolling(ws, taskId); break; } case TransactionType.Payment: { const taskId = constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId: parsedTx.proposalId, }); await resetOperationTimeout(ws, taskId); stopLongpolling(ws, taskId); break; } case TransactionType.Tip: { const taskId = constructTaskIdentifier({ tag: PendingTaskType.TipPickup, walletTipId: parsedTx.walletTipId, }); await resetOperationTimeout(ws, taskId); stopLongpolling(ws, taskId); break; } case TransactionType.Refresh: { const taskId = constructTaskIdentifier({ tag: PendingTaskType.Refresh, refreshGroupId: parsedTx.refreshGroupId, }); await resetOperationTimeout(ws, taskId); stopLongpolling(ws, taskId); break; } default: break; } } /** * Suspends a pending transaction, stopping any associated network activities, * but with a chance of trying again at a later time. This could be useful if * a user needs to save battery power or bandwidth and an operation is expected * to take longer (such as a backup, recovery or very large withdrawal operation). */ export async function suspendTransaction( ws: InternalWalletState, transactionId: string, ): Promise { const tx = parseTransactionIdentifier(transactionId); if (!tx) { throw Error("invalid transaction ID"); } switch (tx.tag) { case TransactionType.Deposit: await suspendDepositGroup(ws, tx.depositGroupId); return; default: logger.warn(`unable to suspend transaction of type '${tx.tag}'`); } } export async function cancelAbortingTransaction( ws: InternalWalletState, transactionId: string, ): Promise { const tx = parseTransactionIdentifier(transactionId); if (!tx) { throw Error("invalid transaction ID"); } switch (tx.tag) { case TransactionType.Deposit: await cancelAbortingDepositGroup(ws, tx.depositGroupId); return; case TransactionType.Withdrawal: await cancelAbortingWithdrawalTransaction(ws, tx.withdrawalGroupId); return; default: logger.warn(`unable to suspend transaction of type '${tx.tag}'`); } } /** * Resume a suspended transaction. */ export async function resumeTransaction( ws: InternalWalletState, transactionId: string, ): Promise { const tx = parseTransactionIdentifier(transactionId); if (!tx) { throw Error("invalid transaction ID"); } switch (tx.tag) { case TransactionType.Deposit: await resumeDepositGroup(ws, tx.depositGroupId); return; default: logger.warn(`unable to resume transaction of type '${tx.tag}'`); } } /** * Permanently delete a transaction based on the transaction ID. */ export async function deleteTransaction( ws: InternalWalletState, transactionId: string, ): Promise { const parsedTx = parseTransactionIdentifier(transactionId); if (!parsedTx) { throw Error("invalid transaction ID"); } switch (parsedTx.tag) { case TransactionType.PeerPushCredit: { const peerPushPaymentIncomingId = parsedTx.peerPushPaymentIncomingId; await ws.db .mktx((x) => [ x.withdrawalGroups, x.peerPushPaymentIncoming, x.tombstones, ]) .runReadWrite(async (tx) => { const pushInc = await tx.peerPushPaymentIncoming.get( peerPushPaymentIncomingId, ); if (!pushInc) { return; } if (pushInc.withdrawalGroupId) { const withdrawalGroupId = pushInc.withdrawalGroupId; const withdrawalGroupRecord = await tx.withdrawalGroups.get( withdrawalGroupId, ); if (withdrawalGroupRecord) { await tx.withdrawalGroups.delete(withdrawalGroupId); await tx.tombstones.put({ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId, }); } } await tx.peerPushPaymentIncoming.delete(peerPushPaymentIncomingId); await tx.tombstones.put({ id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushPaymentIncomingId, }); }); return; } case TransactionType.PeerPullCredit: { const pursePub = parsedTx.pursePub; await ws.db .mktx((x) => [ x.withdrawalGroups, x.peerPullPaymentInitiations, x.tombstones, ]) .runReadWrite(async (tx) => { const pullIni = await tx.peerPullPaymentInitiations.get(pursePub); if (!pullIni) { return; } if (pullIni.withdrawalGroupId) { const withdrawalGroupId = pullIni.withdrawalGroupId; const withdrawalGroupRecord = await tx.withdrawalGroups.get( withdrawalGroupId, ); if (withdrawalGroupRecord) { await tx.withdrawalGroups.delete(withdrawalGroupId); await tx.tombstones.put({ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId, }); } } await tx.peerPullPaymentInitiations.delete(pursePub); await tx.tombstones.put({ id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub, }); }); return; } case TransactionType.Withdrawal: { const withdrawalGroupId = parsedTx.withdrawalGroupId; await ws.db .mktx((x) => [x.withdrawalGroups, x.tombstones]) .runReadWrite(async (tx) => { const withdrawalGroupRecord = await tx.withdrawalGroups.get( withdrawalGroupId, ); if (withdrawalGroupRecord) { await tx.withdrawalGroups.delete(withdrawalGroupId); await tx.tombstones.put({ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId, }); return; } }); return; } case TransactionType.Payment: { const proposalId = parsedTx.proposalId; await ws.db .mktx((x) => [x.purchases, x.tombstones]) .runReadWrite(async (tx) => { let found = false; const purchase = await tx.purchases.get(proposalId); if (purchase) { found = true; await tx.purchases.delete(proposalId); } if (found) { await tx.tombstones.put({ id: TombstoneTag.DeletePayment + ":" + proposalId, }); } }); return; } case TransactionType.Refresh: { const refreshGroupId = parsedTx.refreshGroupId; await ws.db .mktx((x) => [x.refreshGroups, x.tombstones]) .runReadWrite(async (tx) => { const rg = await tx.refreshGroups.get(refreshGroupId); if (rg) { await tx.refreshGroups.delete(refreshGroupId); await tx.tombstones.put({ id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId, }); } }); return; } case TransactionType.Tip: { const tipId = parsedTx.walletTipId; await ws.db .mktx((x) => [x.tips, x.tombstones]) .runReadWrite(async (tx) => { const tipRecord = await tx.tips.get(tipId); if (tipRecord) { await tx.tips.delete(tipId); await tx.tombstones.put({ id: TombstoneTag.DeleteTip + ":" + tipId, }); } }); return; } case TransactionType.Deposit: { const depositGroupId = parsedTx.depositGroupId; await deleteDepositGroup(ws, depositGroupId); return; } case TransactionType.Refund: { const refundGroupId = parsedTx.refundGroupId; await ws.db .mktx((x) => [x.refundGroups, x.tombstones, x.refundItems]) .runReadWrite(async (tx) => { const refundRecord = await tx.refundGroups.get(refundGroupId); if (!refundRecord) { return; } await tx.refundGroups.delete(refundGroupId); await tx.tombstones.put({ id: transactionId }); // FIXME: Also tombstone the refund items, so that they won't reappear. }); return; } case TransactionType.PeerPullDebit: { const peerPullPaymentIncomingId = parsedTx.peerPullPaymentIncomingId; await ws.db .mktx((x) => [x.peerPullPaymentIncoming, x.tombstones]) .runReadWrite(async (tx) => { const debit = await tx.peerPullPaymentIncoming.get( peerPullPaymentIncomingId, ); if (debit) { await tx.peerPullPaymentIncoming.delete(peerPullPaymentIncomingId); await tx.tombstones.put({ id: transactionId }); } }); return; } case TransactionType.PeerPushDebit: { const pursePub = parsedTx.pursePub; await ws.db .mktx((x) => [x.peerPushPaymentInitiations, x.tombstones]) .runReadWrite(async (tx) => { const debit = await tx.peerPushPaymentInitiations.get(pursePub); if (debit) { await tx.peerPushPaymentInitiations.delete(pursePub); await tx.tombstones.put({ id: transactionId }); } }); return; } } } export async function abortTransaction( ws: InternalWalletState, transactionId: string, ): Promise { const txId = parseTransactionIdentifier(transactionId); if (!txId) { throw Error("invalid transaction identifier"); } switch (txId.tag) { case TransactionType.Payment: { await abortPayMerchant(ws, txId.proposalId); break; } case TransactionType.Withdrawal: { await abortWithdrawalTransaction(ws, txId.withdrawalGroupId); break; } case TransactionType.Deposit: await abortDepositGroup(ws, txId.depositGroupId); break; default: { const unknownTxType: any = txId.tag; throw Error( `can't abort a '${unknownTxType}' transaction: not yet implemented`, ); } } } export interface TransitionInfo { oldTxState: TransactionState; newTxState: TransactionState; } /** * Notify of a state transition if necessary. */ export function notifyTransition( ws: InternalWalletState, transactionId: string, ti: TransitionInfo | undefined, ): void { if ( ti && !( ti.oldTxState.major === ti.newTxState.major && ti.oldTxState.minor === ti.newTxState.minor ) ) { ws.notify({ type: NotificationType.TransactionStateTransition, oldTxState: ti.oldTxState, newTxState: ti.newTxState, transactionId, }); } }