/*
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,
});
}
}
}