/* 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 { GlobalIDB, IDBKeyRange } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, Amounts, assertUnreachable, j2s, Logger, makeTalerErrorDetail, NotificationType, ScopeType, TalerErrorCode, Transaction, TransactionByIdRequest, TransactionIdStr, TransactionMajorState, TransactionsRequest, TransactionsResponse, TransactionState, TransactionType, } from "@gnu-taler/taler-util"; import { constructTaskIdentifier, PendingTaskType, TaskIdStr, TransactionContext, } from "./common.js"; import { OPERATION_STATUS_NONFINAL_FIRST, OPERATION_STATUS_NONFINAL_LAST, WalletDbAllStoresReadWriteTransaction, } from "./db.js"; import { DepositTransactionContext } from "./deposits.js"; import { DenomLossTransactionContext } from "./exchanges.js"; import { PayMerchantTransactionContext, RefundTransactionContext, } from "./pay-merchant.js"; import { PeerPullCreditTransactionContext } from "./pay-peer-pull-credit.js"; import { PeerPullDebitTransactionContext } from "./pay-peer-pull-debit.js"; import { PeerPushCreditTransactionContext } from "./pay-peer-push-credit.js"; import { PeerPushDebitTransactionContext } from "./pay-peer-push-debit.js"; import { RefreshTransactionContext } from "./refresh.js"; import type { WalletExecutionContext } from "./wallet.js"; import { WithdrawTransactionContext } from "./withdraw.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); function shouldSkipCurrency( transactionsRequest: TransactionsRequest | undefined, currency: string, exchangesInTransaction: string[], ): boolean { if (transactionsRequest?.scopeInfo) { const sameCurrency = Amounts.isSameCurrency( currency, transactionsRequest.scopeInfo.currency, ); switch (transactionsRequest.scopeInfo.type) { case ScopeType.Global: { return !sameCurrency; } case ScopeType.Exchange: { return ( !sameCurrency || (exchangesInTransaction.length > 0 && !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url)) ); } case ScopeType.Auditor: { // same currency and same auditor throw Error("filering balance in auditor scope is not implemented"); } default: assertUnreachable(transactionsRequest.scopeInfo); } } // FIXME: remove next release if (transactionsRequest?.currency) { return ( transactionsRequest.currency.toLowerCase() !== currency.toLowerCase() ); } return false; } /** * Fallback order of transactions that have the same timestamp. */ const txOrder: { [t in TransactionType]: number } = { [TransactionType.Withdrawal]: 1, [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.Recoup]: 11, [TransactionType.InternalWithdrawal]: 12, [TransactionType.DenomLoss]: 13, }; export async function getTransactionById( wex: WalletExecutionContext, req: TransactionByIdRequest, ): Promise { const parsedTx = parseTransactionIdentifier(req.transactionId); if (!parsedTx) { throw Error("invalid transaction ID"); } switch (parsedTx.tag) { case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: case TransactionType.DenomLoss: case TransactionType.Recoup: case TransactionType.PeerPushDebit: case TransactionType.PeerPushCredit: case TransactionType.Refresh: case TransactionType.PeerPullCredit: case TransactionType.Payment: case TransactionType.Deposit: case TransactionType.PeerPullDebit: case TransactionType.Refund: { const ctx = await getContextForTransaction(wex, req.transactionId); const txDetails = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => ctx.lookupFullTransaction(tx), ); if (!txDetails) { throw Error("transaction not found"); } return txDetails; } } } export function isUnsuccessfulTransaction(state: TransactionState): boolean { return ( state.major === TransactionMajorState.Aborted || state.major === TransactionMajorState.Expired || state.major === TransactionMajorState.Aborting || state.major === TransactionMajorState.Deleted || state.major === TransactionMajorState.Failed ); } /** * Retrieve the full event history for this wallet. */ export async function getTransactions( wex: WalletExecutionContext, transactionsRequest?: TransactionsRequest, ): Promise { const transactions: Transaction[] = []; let keyRange: IDBKeyRange | undefined = undefined; if (transactionsRequest?.filterByState === "nonfinal") { keyRange = GlobalIDB.KeyRange.bound( OPERATION_STATUS_NONFINAL_FIRST, OPERATION_STATUS_NONFINAL_LAST, ); } await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { const allMetaTransactions = await tx.transactionsMeta.indexes.byStatus.getAll(keyRange); for (const metaTx of allMetaTransactions) { if ( shouldSkipCurrency( transactionsRequest, metaTx.currency, metaTx.exchanges, ) ) { continue; } const parsedTx = parseTransactionIdentifier(metaTx.transactionId); if ( parsedTx?.tag === TransactionType.Refresh && !transactionsRequest?.includeRefreshes ) { continue; } const ctx = await getContextForTransaction(wex, metaTx.transactionId); const txDetails = await ctx.lookupFullTransaction(tx); if (!txDetails) { continue; } transactions.push(txDetails); } }); // 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; let sortSign: number; if (transactionsRequest?.sort == "descending") { sortSign = -1; } else { sortSign = 1; } const txCmp = (h1: Transaction, h2: Transaction) => { // Order transactions by timestamp. Newest transactions come first. const tsCmp = AbsoluteTime.cmp( AbsoluteTime.fromPreciseTimestamp(h1.timestamp), AbsoluteTime.fromPreciseTimestamp(h2.timestamp), ); // If the timestamp is exactly the same, order by transaction type. if (tsCmp === 0) { return Math.sign(txOrder[h1.type] - txOrder[h2.type]); } return sortSign * tsCmp; }; if (transactionsRequest?.sort === "stable-ascending") { transactions.sort(txCmp); return { transactions }; } const txPending = transactions.filter((x) => isPending(x)); const txNotPending = transactions.filter((x) => !isPending(x)); txPending.sort(txCmp); txNotPending.sort(txCmp); return { transactions: [...txPending, ...txNotPending] }; } /** * Re-create materialized transactions from scratch. * * Used for migrations. */ export async function rematerializeTransactions( wex: WalletExecutionContext, tx: WalletDbAllStoresReadWriteTransaction, ): Promise { logger.info("re-materializing transactions"); const allTxMeta = await tx.transactionsMeta.getAll(); for (const txMeta of allTxMeta) { await tx.transactionsMeta.delete(txMeta.transactionId); } await tx.peerPushDebit.iter().forEachAsync(async (x) => { const ctx = new PeerPushDebitTransactionContext(wex, x.pursePub); await ctx.updateTransactionMeta(tx); }); await tx.peerPushCredit.iter().forEachAsync(async (x) => { const ctx = new PeerPushCreditTransactionContext(wex, x.peerPushCreditId); await ctx.updateTransactionMeta(tx); }); await tx.peerPullCredit.iter().forEachAsync(async (x) => { const ctx = new PeerPullCreditTransactionContext(wex, x.pursePub); await ctx.updateTransactionMeta(tx); }); await tx.peerPullDebit.iter().forEachAsync(async (x) => { const ctx = new PeerPullDebitTransactionContext(wex, x.peerPullDebitId); await ctx.updateTransactionMeta(tx); }); await tx.refundGroups.iter().forEachAsync(async (x) => { const ctx = new RefundTransactionContext(wex, x.refundGroupId); await ctx.updateTransactionMeta(tx); }); await tx.refreshGroups.iter().forEachAsync(async (x) => { const ctx = new RefreshTransactionContext(wex, x.refreshGroupId); await ctx.updateTransactionMeta(tx); }); await tx.withdrawalGroups.iter().forEachAsync(async (x) => { const ctx = new WithdrawTransactionContext(wex, x.withdrawalGroupId); await ctx.updateTransactionMeta(tx); }); await tx.denomLossEvents.iter().forEachAsync(async (x) => { const ctx = new DenomLossTransactionContext(wex, x.denomLossEventId); await ctx.updateTransactionMeta(tx); }); await tx.depositGroups.iter().forEachAsync(async (x) => { const ctx = new DepositTransactionContext(wex, x.depositGroupId); await ctx.updateTransactionMeta(tx); }); await tx.purchases.iter().forEachAsync(async (x) => { const ctx = new PayMerchantTransactionContext(wex, x.proposalId); await ctx.updateTransactionMeta(tx); }); } export type ParsedTransactionIdentifier = | { tag: TransactionType.Deposit; depositGroupId: string } | { tag: TransactionType.Payment; proposalId: string } | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string } | { tag: TransactionType.PeerPullCredit; pursePub: string } | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string } | { tag: TransactionType.PeerPushDebit; pursePub: string } | { tag: TransactionType.Refresh; refreshGroupId: string } | { tag: TransactionType.Refund; refundGroupId: string } | { tag: TransactionType.Withdrawal; withdrawalGroupId: string } | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string } | { tag: TransactionType.Recoup; recoupGroupId: string } | { tag: TransactionType.DenomLoss; denomLossEventId: 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.peerPullDebitId}` as TransactionIdStr; case TransactionType.PeerPushCredit: return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` 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.Withdrawal: return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr; case TransactionType.InternalWithdrawal: return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr; case TransactionType.Recoup: return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr; case TransactionType.DenomLoss: return `txn:${pTxId.tag}:${pTxId.denomLossEventId}` 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; if (prefix != "txn") { throw Error("invalid transaction identifier"); } 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, peerPullDebitId: rest[0], }; case TransactionType.PeerPushCredit: return { tag: TransactionType.PeerPushCredit, peerPushCreditId: 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.Withdrawal: return { tag: TransactionType.Withdrawal, withdrawalGroupId: rest[0], }; case TransactionType.DenomLoss: return { tag: TransactionType.DenomLoss, denomLossEventId: rest[0], }; default: return undefined; } } function maybeTaskFromTransaction( transactionId: string, ): TaskIdStr | undefined { 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: return constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub: parsedTx.pursePub, }); case TransactionType.Deposit: return constructTaskIdentifier({ tag: PendingTaskType.Deposit, depositGroupId: parsedTx.depositGroupId, }); case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: return constructTaskIdentifier({ tag: PendingTaskType.Withdraw, withdrawalGroupId: parsedTx.withdrawalGroupId, }); case TransactionType.Payment: return constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId: parsedTx.proposalId, }); case TransactionType.Refresh: return constructTaskIdentifier({ tag: PendingTaskType.Refresh, refreshGroupId: parsedTx.refreshGroupId, }); case TransactionType.PeerPullDebit: return constructTaskIdentifier({ tag: PendingTaskType.PeerPullDebit, peerPullDebitId: parsedTx.peerPullDebitId, }); case TransactionType.PeerPushCredit: return constructTaskIdentifier({ tag: PendingTaskType.PeerPushCredit, peerPushCreditId: parsedTx.peerPushCreditId, }); case TransactionType.PeerPushDebit: return constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub: parsedTx.pursePub, }); case TransactionType.Refund: // Nothing to do for a refund transaction. return undefined; case TransactionType.Recoup: return constructTaskIdentifier({ tag: PendingTaskType.Recoup, recoupGroupId: parsedTx.recoupGroupId, }); case TransactionType.DenomLoss: // Nothing to do for denom loss return undefined; default: assertUnreachable(parsedTx); } } /** * Immediately retry the underlying operation * of a transaction. */ export async function retryTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise { logger.info(`resetting retry timeout for ${transactionId}`); const taskId = maybeTaskFromTransaction(transactionId); if (taskId) { await wex.taskScheduler.resetTaskRetries(taskId); } } /** * Reset the task retry counter for all tasks. */ export async function retryAll(wex: WalletExecutionContext): Promise { await wex.taskScheduler.ensureRunning(); const tasks = wex.taskScheduler.getActiveTasks(); for (const task of tasks) { await wex.taskScheduler.resetTaskRetries(task); } } /** * Restart all the running tasks. */ export async function restartAll(wex: WalletExecutionContext): Promise { await wex.taskScheduler.reload(); } async function getContextForTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise { const tx = parseTransactionIdentifier(transactionId); if (!tx) { throw Error("invalid transaction ID"); } switch (tx.tag) { case TransactionType.Deposit: return new DepositTransactionContext(wex, tx.depositGroupId); case TransactionType.Refresh: return new RefreshTransactionContext(wex, tx.refreshGroupId); case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: return new WithdrawTransactionContext(wex, tx.withdrawalGroupId); case TransactionType.Payment: return new PayMerchantTransactionContext(wex, tx.proposalId); case TransactionType.PeerPullCredit: return new PeerPullCreditTransactionContext(wex, tx.pursePub); case TransactionType.PeerPushDebit: return new PeerPushDebitTransactionContext(wex, tx.pursePub); case TransactionType.PeerPullDebit: return new PeerPullDebitTransactionContext(wex, tx.peerPullDebitId); case TransactionType.PeerPushCredit: return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId); case TransactionType.Refund: return new RefundTransactionContext(wex, tx.refundGroupId); case TransactionType.Recoup: //return new RecoupTransactionContext(ws, tx.recoupGroupId); throw new Error("not yet supported"); case TransactionType.DenomLoss: return new DenomLossTransactionContext(wex, tx.denomLossEventId); default: assertUnreachable(tx); } } /** * 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( wex: WalletExecutionContext, transactionId: string, ): Promise { const ctx = await getContextForTransaction(wex, transactionId); await ctx.suspendTransaction(); } export async function failTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise { const ctx = await getContextForTransaction(wex, transactionId); await ctx.failTransaction( makeTalerErrorDetail( TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, {}, ), ); } /** * Resume a suspended transaction. */ export async function resumeTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise { const ctx = await getContextForTransaction(wex, transactionId); await ctx.resumeTransaction(); } /** * Permanently delete a transaction based on the transaction ID. */ export async function deleteTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise { const ctx = await getContextForTransaction(wex, transactionId); await ctx.deleteTransaction(); if (ctx.taskId) { wex.taskScheduler.stopShepherdTask(ctx.taskId); } } export async function abortTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise { const ctx = await getContextForTransaction(wex, transactionId); await ctx.abortTransaction( makeTalerErrorDetail(TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, {}), ); } export interface TransitionInfo { oldTxState: TransactionState; newTxState: TransactionState; } /** * Notify of a state transition if necessary. */ export function notifyTransition( wex: WalletExecutionContext, transactionId: string, transitionInfo: TransitionInfo | undefined, experimentalUserData: any = undefined, ): void { if ( transitionInfo && !( transitionInfo.oldTxState.major === transitionInfo.newTxState.major && transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor ) ) { wex.ws.notify({ type: NotificationType.TransactionStateTransition, oldTxState: transitionInfo.oldTxState, newTxState: transitionInfo.newTxState, transactionId, experimentalUserData, }); // As a heuristic, we emit balance-change notifications // whenever the major state changes. // This sometimes emits more notifications than we need, // but makes it much more unlikely that we miss any. if (transitionInfo.newTxState.major !== transitionInfo.oldTxState.major) { wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, }); } } }