/*
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 } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
Amounts,
assertUnreachable,
checkDbInvariant,
DepositTransactionTrackingState,
j2s,
Logger,
NotificationType,
OrderShortInfo,
PeerContractTerms,
RefundInfoShort,
RefundPaymentInfo,
ScopeType,
stringifyPayPullUri,
stringifyPayPushUri,
TalerErrorCode,
TalerPreciseTimestamp,
Transaction,
TransactionAction,
TransactionByIdRequest,
TransactionIdStr,
TransactionMajorState,
TransactionRecordFilter,
TransactionsRequest,
TransactionsResponse,
TransactionState,
TransactionType,
TransactionWithdrawal,
WalletContractData,
WithdrawalTransactionByURIRequest,
WithdrawalType,
} from "@gnu-taler/taler-util";
import {
constructTaskIdentifier,
PendingTaskType,
TaskIdentifiers,
TaskIdStr,
TransactionContext,
} from "./common.js";
import {
DenomLossEventRecord,
DepositElementStatus,
DepositGroupRecord,
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
OperationRetryRecord,
PeerPullCreditRecord,
PeerPullDebitRecordStatus,
PeerPullPaymentIncomingRecord,
PeerPushCreditStatus,
PeerPushDebitRecord,
PeerPushDebitStatus,
PeerPushPaymentIncomingRecord,
PurchaseRecord,
PurchaseStatus,
RefreshGroupRecord,
RefreshOperationStatus,
RefundGroupRecord,
timestampPreciseFromDb,
timestampProtocolFromDb,
WalletDbReadOnlyTransaction,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "./db.js";
import {
computeDepositTransactionActions,
computeDepositTransactionStatus,
DepositTransactionContext,
} from "./deposits.js";
import {
computeDenomLossTransactionStatus,
DenomLossTransactionContext,
ExchangeWireDetails,
getExchangeWireDetailsInTx,
} from "./exchanges.js";
import {
computePayMerchantTransactionActions,
computePayMerchantTransactionState,
computeRefundTransactionState,
expectProposalDownload,
extractContractData,
PayMerchantTransactionContext,
RefundTransactionContext,
} from "./pay-merchant.js";
import {
computePeerPullCreditTransactionActions,
computePeerPullCreditTransactionState,
PeerPullCreditTransactionContext,
} from "./pay-peer-pull-credit.js";
import {
computePeerPullDebitTransactionActions,
computePeerPullDebitTransactionState,
PeerPullDebitTransactionContext,
} from "./pay-peer-pull-debit.js";
import {
computePeerPushCreditTransactionActions,
computePeerPushCreditTransactionState,
PeerPushCreditTransactionContext,
} from "./pay-peer-push-credit.js";
import {
computePeerPushDebitTransactionActions,
computePeerPushDebitTransactionState,
PeerPushDebitTransactionContext,
} from "./pay-peer-push-debit.js";
import {
computeRefreshTransactionActions,
computeRefreshTransactionState,
RefreshTransactionContext,
} from "./refresh.js";
import type { WalletExecutionContext } from "./wallet.js";
import {
augmentPaytoUrisForWithdrawal,
computeWithdrawalTransactionActions,
computeWithdrawalTransactionStatus,
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;
}
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.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: {
const withdrawalGroupId = parsedTx.withdrawalGroupId;
return await wex.db.runReadWriteTx(
{
storeNames: [
"withdrawalGroups",
"exchangeDetails",
"exchanges",
"operationRetries",
],
},
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);
const exchangeDetails =
withdrawalGroupRecord.exchangeBaseUrl === undefined
? undefined
: await getExchangeWireDetailsInTx(
tx,
withdrawalGroupRecord.exchangeBaseUrl,
);
// if (!exchangeDetails) throw Error("not exchange details");
if (
withdrawalGroupRecord.wgInfo.withdrawalType ===
WithdrawalRecordType.BankIntegrated
) {
return buildTransactionForBankIntegratedWithdraw(
withdrawalGroupRecord,
exchangeDetails,
ort,
);
}
checkDbInvariant(
exchangeDetails !== undefined,
"manual withdrawal without exchange",
);
return buildTransactionForManualWithdraw(
withdrawalGroupRecord,
exchangeDetails,
ort,
);
},
);
}
case TransactionType.DenomLoss: {
const rec = await wex.db.runReadOnlyTx(
{ storeNames: ["denomLossEvents"] },
async (tx) => {
return tx.denomLossEvents.get(parsedTx.denomLossEventId);
},
);
if (!rec) {
throw Error("denom loss record not found");
}
return buildTransactionForDenomLoss(rec);
}
case TransactionType.Recoup:
throw new Error("not yet supported");
case TransactionType.Payment: {
const proposalId = parsedTx.proposalId;
return await wex.db.runReadWriteTx(
{
storeNames: [
"purchases",
"tombstones",
"operationRetries",
"contractTerms",
"refundGroups",
],
},
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) throw Error("not found");
const download = await expectProposalDownload(wex, purchase, tx);
const contractData = download.contractData;
const payOpId = TaskIdentifiers.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
purchase.proposalId,
);
return buildTransactionForPurchase(
purchase,
contractData,
refunds,
payRetryRecord,
);
},
);
}
case TransactionType.Refresh: {
// FIXME: We should return info about the refresh here!;
const refreshGroupId = parsedTx.refreshGroupId;
return await wex.db.runReadOnlyTx(
{ storeNames: ["refreshGroups", "operationRetries"] },
async (tx) => {
const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
if (!refreshGroupRec) {
throw Error("not found");
}
const retries = await tx.operationRetries.get(
TaskIdentifiers.forRefresh(refreshGroupRec),
);
return buildTransactionForRefresh(refreshGroupRec, retries);
},
);
}
case TransactionType.Deposit: {
const depositGroupId = parsedTx.depositGroupId;
return await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "operationRetries"] },
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 wex.db.runReadOnlyTx(
{
storeNames: [
"refundGroups",
"purchases",
"operationRetries",
"contractTerms",
],
},
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 wex.db.runReadWriteTx(
{ storeNames: ["peerPullDebit", "contractTerms"] },
async (tx) => {
const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
if (!debit) throw Error("not found");
const contractTermsRec = await tx.contractTerms.get(
debit.contractTermsHash,
);
if (!contractTermsRec)
throw Error("contract terms for peer-pull-debit not found");
return buildTransactionForPullPaymentDebit(
debit,
contractTermsRec.contractTermsRaw,
);
},
);
}
case TransactionType.PeerPushDebit: {
return await wex.db.runReadWriteTx(
{ storeNames: ["peerPushDebit", "contractTerms"] },
async (tx) => {
const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
if (!debit) throw Error("not found");
const ct = await tx.contractTerms.get(debit.contractTermsHash);
checkDbInvariant(
!!ct,
`no contract terms for p2p push ${parsedTx.pursePub}`,
);
return buildTransactionForPushPaymentDebit(
debit,
ct.contractTermsRaw,
);
},
);
}
case TransactionType.PeerPushCredit: {
const peerPushCreditId = parsedTx.peerPushCreditId;
return await wex.db.runReadWriteTx(
{
storeNames: [
"peerPushCredit",
"contractTerms",
"withdrawalGroups",
"operationRetries",
],
},
async (tx) => {
const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushInc) throw Error("not found");
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
checkDbInvariant(
!!ct,
`no contract terms for p2p push ${peerPushCreditId}`,
);
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);
const pushIncOrt = await tx.operationRetries.get(pushIncOpId);
return buildTransactionForPeerPushCredit(
pushInc,
pushIncOrt,
ct.contractTermsRaw,
wg,
wgOrt,
);
},
);
}
case TransactionType.PeerPullCredit: {
const pursePub = parsedTx.pursePub;
return await wex.db.runReadWriteTx(
{
storeNames: [
"peerPullCredit",
"contractTerms",
"withdrawalGroups",
"operationRetries",
],
},
async (tx) => {
const pushInc = await tx.peerPullCredit.get(pursePub);
if (!pushInc) throw Error("not found");
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
checkDbInvariant(!!ct, `no contract terms for p2p push ${pursePub}`);
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: PeerPushDebitRecord,
contractTerms: PeerContractTerms,
ort?: OperationRetryRecord,
): Transaction {
let talerUri: string | undefined = undefined;
switch (pi.status) {
case PeerPushDebitStatus.PendingReady:
case PeerPushDebitStatus.SuspendedReady:
talerUri = stringifyPayPushUri({
exchangeBaseUrl: pi.exchangeBaseUrl,
contractPriv: pi.contractPriv,
});
}
const txState = computePeerPushDebitTransactionState(pi);
return {
type: TransactionType.PeerPushDebit,
txState,
txActions: computePeerPushDebitTransactionActions(pi),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost))
: pi.totalCost,
amountRaw: pi.amount,
exchangeBaseUrl: pi.exchangeBaseUrl,
info: {
expiration: contractTerms.purse_expiration,
summary: contractTerms.summary,
},
timestamp: timestampPreciseFromDb(pi.timestampCreated),
talerUri,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub: pi.pursePub,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForPullPaymentDebit(
pi: PeerPullPaymentIncomingRecord,
contractTerms: PeerContractTerms,
ort?: OperationRetryRecord,
): Transaction {
const txState = computePeerPullDebitTransactionState(pi);
return {
type: TransactionType.PeerPullDebit,
txState,
txActions: computePeerPullDebitTransactionActions(pi),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
: pi.coinSel?.totalCost
? pi.coinSel?.totalCost
: Amounts.stringify(pi.amount),
amountRaw: Amounts.stringify(pi.amount),
exchangeBaseUrl: pi.exchangeBaseUrl,
info: {
expiration: contractTerms.purse_expiration,
summary: contractTerms.summary,
},
timestamp: timestampPreciseFromDb(pi.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId: pi.peerPullDebitId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForPeerPullCredit(
pullCredit: PeerPullCreditRecord,
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
);
});
const txState = computePeerPullCreditTransactionState(pullCredit);
checkDbInvariant(wsr.instructedAmount !== undefined, "wg uninitialized");
checkDbInvariant(wsr.denomsSel !== undefined, "wg uninitialized");
checkDbInvariant(wsr.exchangeBaseUrl !== undefined, "wg uninitialized");
return {
type: TransactionType.PeerPullCredit,
txState,
txActions: computePeerPullCreditTransactionActions(pullCredit),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wsr.instructedAmount),
exchangeBaseUrl: wsr.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
talerUri: stringifyPayPullUri({
exchangeBaseUrl: wsr.exchangeBaseUrl,
contractPriv: wsr.wgInfo.contractPriv,
}),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullCredit.pursePub,
}),
kycUrl: pullCredit.kycUrl,
...(wsrOrt?.lastError
? {
error: silentWithdrawalErrorForInvoice
? undefined
: wsrOrt.lastError,
}
: {}),
};
}
const txState = computePeerPullCreditTransactionState(pullCredit);
return {
type: TransactionType.PeerPullCredit,
txState,
txActions: computePeerPullCreditTransactionActions(pullCredit),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
: Amounts.stringify(pullCredit.estimatedAmountEffective),
amountRaw: Amounts.stringify(peerContractTerms.amount),
exchangeBaseUrl: pullCredit.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
talerUri: stringifyPayPullUri({
exchangeBaseUrl: pullCredit.exchangeBaseUrl,
contractPriv: pullCredit.contractPriv,
}),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullCredit.pursePub,
}),
kycUrl: pullCredit.kycUrl,
...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
};
}
function buildTransactionForPeerPushCredit(
pushInc: PeerPushPaymentIncomingRecord,
pushOrt: OperationRetryRecord | undefined,
peerContractTerms: PeerContractTerms,
wg: WithdrawalGroupRecord | undefined,
wsrOrt: OperationRetryRecord | undefined,
): Transaction {
if (wg) {
if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
throw Error("invalid withdrawal group type for push payment credit");
}
checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized");
checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized");
checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized");
const txState = computePeerPushCreditTransactionState(pushInc);
return {
type: TransactionType.PeerPushCredit,
txState,
txActions: computePeerPushCreditTransactionActions(pushInc),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount))
: Amounts.stringify(wg.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wg.instructedAmount),
exchangeBaseUrl: wg.exchangeBaseUrl,
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: pushInc.peerPushCreditId,
}),
kycUrl: pushInc.kycUrl,
...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
};
}
const txState = computePeerPushCreditTransactionState(pushInc);
return {
type: TransactionType.PeerPushCredit,
txState,
txActions: computePeerPushCreditTransactionActions(pushInc),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
: // FIXME: This is wrong, needs to consider fees!
Amounts.stringify(peerContractTerms.amount),
amountRaw: Amounts.stringify(peerContractTerms.amount),
exchangeBaseUrl: pushInc.exchangeBaseUrl,
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
kycUrl: pushInc.kycUrl,
timestamp: timestampPreciseFromDb(pushInc.timestamp),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: pushInc.peerPushCreditId,
}),
...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
};
}
function buildTransactionForBankIntegratedWithdraw(
wg: WithdrawalGroupRecord,
exchangeDetails: ExchangeWireDetails | undefined,
ort?: OperationRetryRecord,
): TransactionWithdrawal {
if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("");
}
const instructedCurrency =
wg.instructedAmount === undefined
? undefined
: Amounts.currencyOf(wg.instructedAmount);
const currency = wg.wgInfo.bankInfo.currency ?? instructedCurrency;
checkDbInvariant(currency !== undefined, "wg uninitialized (missing currency)");
const txState = computeWithdrawalTransactionStatus(wg);
const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency));
return {
type: TransactionType.Withdrawal,
txState,
txActions: computeWithdrawalTransactionActions(wg),
exchangeBaseUrl: wg.exchangeBaseUrl,
amountEffective:
isUnsuccessfulTransaction(txState) || !wg.denomsSel
? zero
: Amounts.stringify(wg.denomsSel.totalCoinValue),
amountRaw: !wg.instructedAmount
? zero
: Amounts.stringify(wg.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: wg.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts,
reservePub: wg.reservePub,
bankConfirmationUrl: wg.wgInfo.bankInfo.confirmUrl,
reserveIsReady:
wg.status === WithdrawalGroupStatus.Done ||
wg.status === WithdrawalGroupStatus.PendingReady,
},
kycUrl: wg.kycUrl,
timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: wg.withdrawalGroupId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
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
);
}
function buildTransactionForManualWithdraw(
wg: WithdrawalGroupRecord,
exchangeDetails: ExchangeWireDetails,
ort?: OperationRetryRecord,
): TransactionWithdrawal {
if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
throw Error("");
const plainPaytoUris =
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized");
checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized");
checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized");
const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
plainPaytoUris,
wg.reservePub,
wg.instructedAmount,
);
const txState = computeWithdrawalTransactionStatus(wg);
return {
type: TransactionType.Withdrawal,
txState,
txActions: computeWithdrawalTransactionActions(wg),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount))
: Amounts.stringify(wg.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wg.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.ManualTransfer,
reservePub: wg.reservePub,
exchangePaytoUris,
exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts,
reserveIsReady:
wg.status === WithdrawalGroupStatus.Done ||
wg.status === WithdrawalGroupStatus.PendingReady,
},
kycUrl: wg.kycUrl,
exchangeBaseUrl: wg.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: wg.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,
};
}
const txState = computeRefundTransactionState(refundRecord);
return {
type: TransactionType.Refund,
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
: refundRecord.amountEffective,
amountRaw: refundRecord.amountRaw,
refundedTransactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: refundRecord.proposalId,
}),
timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Refund,
refundGroupId: refundRecord.refundGroupId,
}),
txState,
txActions: [],
paymentInfo,
};
}
function buildTransactionForRefresh(
refreshGroupRecord: RefreshGroupRecord,
ort?: OperationRetryRecord,
): Transaction {
const inputAmount = Amounts.sumOrZero(
refreshGroupRecord.currency,
refreshGroupRecord.inputPerCoin,
).amount;
const outputAmount = Amounts.sumOrZero(
refreshGroupRecord.currency,
refreshGroupRecord.expectedOutputPerCoin,
).amount;
const txState = computeRefreshTransactionState(refreshGroupRecord);
return {
type: TransactionType.Refresh,
txState,
txActions: computeRefreshTransactionActions(refreshGroupRecord),
refreshReason: refreshGroupRecord.reason,
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(inputAmount))
: Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount),
amountRaw: Amounts.stringify(
Amounts.zeroOfCurrency(refreshGroupRecord.currency),
),
refreshInputAmount: Amounts.stringify(inputAmount),
refreshOutputAmount: Amounts.stringify(outputAmount),
originatingTransactionId: refreshGroupRecord.originatingTransactionId,
timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Refresh,
refreshGroupId: refreshGroupRecord.refreshGroupId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForDenomLoss(rec: DenomLossEventRecord): Transaction {
const txState = computeDenomLossTransactionStatus(rec);
return {
type: TransactionType.DenomLoss,
txState,
txActions: [TransactionAction.Delete],
amountRaw: Amounts.stringify(rec.amount),
amountEffective: Amounts.stringify(rec.amount),
timestamp: timestampPreciseFromDb(rec.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.DenomLoss,
denomLossEventId: rec.denomLossEventId,
}),
lossEventType: rec.eventType,
exchangeBaseUrl: rec.exchangeBaseUrl,
};
}
function buildTransactionForDeposit(
dg: DepositGroupRecord,
ort?: OperationRetryRecord,
): Transaction {
let deposited = true;
if (dg.statusPerCoin) {
for (const d of dg.statusPerCoin) {
if (d == DepositElementStatus.DepositPending) {
deposited = false;
}
}
} else {
deposited = false;
}
const trackingState: DepositTransactionTrackingState[] = [];
for (const ts of Object.values(dg.trackingState ?? {})) {
trackingState.push({
amountRaw: ts.amountRaw,
timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
wireFee: ts.wireFee,
wireTransferId: ts.wireTransferId,
});
}
let wireTransferProgress = 0;
if (dg.statusPerCoin) {
wireTransferProgress =
(100 *
dg.statusPerCoin.reduce(
(prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
0,
)) /
dg.statusPerCoin.length;
}
const txState = computeDepositTransactionStatus(dg);
return {
type: TransactionType.Deposit,
txState,
txActions: computeDepositTransactionActions(dg),
amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
: Amounts.stringify(dg.totalPayCost),
timestamp: timestampPreciseFromDb(dg.timestampCreated),
targetPaytoUri: dg.wire.payto_uri,
wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId: dg.depositGroupId,
}),
wireTransferProgress,
depositGroupId: dg.depositGroupId,
trackingState,
deposited,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
async function lookupMaybeContractData(
tx: WalletDbReadOnlyTransaction<["purchases", "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;
}
function buildTransactionForPurchase(
purchaseRecord: PurchaseRecord,
contractData: WalletContractData,
refundsInfo: RefundGroupRecord[],
ort?: OperationRetryRecord,
): Transaction {
const zero = Amounts.zeroOfAmount(contractData.amount);
const info: OrderShortInfo = {
merchant: {
name: contractData.merchant.name,
address: contractData.merchant.address,
email: contractData.merchant.email,
jurisdiction: contractData.merchant.jurisdiction,
website: contractData.merchant.website,
},
orderId: contractData.orderId,
summary: contractData.summary,
summary_i18n: contractData.summaryI18n,
contractTermsHash: contractData.contractTermsHash,
};
if (contractData.fulfillmentUrl !== "") {
info.fulfillmentUrl = contractData.fulfillmentUrl;
}
const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
amountEffective: r.amountEffective,
amountRaw: r.amountRaw,
timestamp: TalerPreciseTimestamp.round(
timestampPreciseFromDb(r.timestampCreated),
),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Refund,
refundGroupId: r.refundGroupId,
}),
}));
const timestamp = purchaseRecord.timestampAccept;
checkDbInvariant(
!!timestamp,
`purchase ${purchaseRecord.orderId} without accepted time`,
);
checkDbInvariant(
!!purchaseRecord.payInfo,
`purchase ${purchaseRecord.orderId} without payinfo`,
);
const txState = computePayMerchantTransactionState(purchaseRecord);
return {
type: TransactionType.Payment,
txState,
txActions: computePayMerchantTransactionActions(purchaseRecord),
amountRaw: Amounts.stringify(contractData.amount),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(zero)
: 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: timestampPreciseFromDb(timestamp),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: purchaseRecord.proposalId,
}),
proposalId: purchaseRecord.proposalId,
info,
refundQueryActive:
purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
export async function getWithdrawalTransactionByUri(
wex: WalletExecutionContext,
request: WithdrawalTransactionByURIRequest,
): Promise {
return await wex.db.runReadWriteTx(
{
storeNames: [
"withdrawalGroups",
"exchangeDetails",
"exchanges",
"operationRetries",
],
},
async (tx) => {
const withdrawalGroupRecord =
await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
request.talerWithdrawUri,
);
if (!withdrawalGroupRecord) {
return undefined;
}
if (withdrawalGroupRecord.exchangeBaseUrl === undefined) {
// prepared and unconfirmed withdrawals are hidden
return undefined;
}
const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
const ort = await tx.operationRetries.get(opId);
const exchangeDetails = await getExchangeWireDetailsInTx(
tx,
withdrawalGroupRecord.exchangeBaseUrl,
);
if (!exchangeDetails) throw Error("not exchange details");
if (
withdrawalGroupRecord.wgInfo.withdrawalType ===
WithdrawalRecordType.BankIntegrated
) {
return buildTransactionForBankIntegratedWithdraw(
withdrawalGroupRecord,
exchangeDetails,
ort,
);
}
return buildTransactionForManualWithdraw(
withdrawalGroupRecord,
exchangeDetails,
ort,
);
},
);
}
/**
* Retrieve the full event history for this wallet.
*/
export async function getTransactions(
wex: WalletExecutionContext,
transactionsRequest?: TransactionsRequest,
): Promise {
const transactions: Transaction[] = [];
const filter: TransactionRecordFilter = {};
if (transactionsRequest?.filterByState) {
filter.onlyState = transactionsRequest.filterByState;
}
await wex.db.runReadOnlyTx(
{
storeNames: [
"coins",
"denominations",
"depositGroups",
"exchangeDetails",
"exchanges",
"operationRetries",
"peerPullDebit",
"peerPushDebit",
"peerPushCredit",
"peerPullCredit",
"planchets",
"purchases",
"contractTerms",
"recoupGroups",
"rewards",
"tombstones",
"withdrawalGroups",
"refreshGroups",
"refundGroups",
"denomLossEvents",
],
},
async (tx) => {
await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.amount);
const exchangesInTx = [pi.exchangeBaseUrl];
if (
shouldSkipCurrency(
transactionsRequest,
amount.currency,
exchangesInTx,
)
) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
const ct = await tx.contractTerms.get(pi.contractTermsHash);
checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`);
transactions.push(
buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
);
});
await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.amount);
const exchangesInTx = [pi.exchangeBaseUrl];
if (
shouldSkipCurrency(
transactionsRequest,
amount.currency,
exchangesInTx,
)
) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
if (
pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
pi.status !== PeerPullDebitRecordStatus.Done
) {
// FIXME: Why?!
return;
}
const contractTermsRec = await tx.contractTerms.get(
pi.contractTermsHash,
);
if (!contractTermsRec) {
return;
}
transactions.push(
buildTransactionForPullPaymentDebit(
pi,
contractTermsRec.contractTermsRaw,
),
);
});
await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
if (!pi.currency) {
// Legacy transaction
return;
}
const exchangesInTx = [pi.exchangeBaseUrl];
if (
shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)
) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
if (pi.status === PeerPushCreditStatus.DialogProposed) {
// 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);
const pushIncOrt = await tx.operationRetries.get(pushIncOpId);
checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`);
transactions.push(
buildTransactionForPeerPushCredit(
pi,
pushIncOrt,
ct.contractTermsRaw,
wg,
wgOrt,
),
);
});
await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
const currency = Amounts.currencyOf(pi.amount);
const exchangesInTx = [pi.exchangeBaseUrl];
if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
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);
const pushIncOrt = await tx.operationRetries.get(pushIncOpId);
checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`);
transactions.push(
buildTransactionForPeerPullCredit(
pi,
pushIncOrt,
ct.contractTermsRaw,
wg,
wgOrt,
),
);
});
await iterRecordsForRefund(tx, filter, async (refundGroup) => {
const currency = Amounts.currencyOf(refundGroup.amountRaw);
const exchangesInTx: string[] = [];
const p = await tx.purchases.get(refundGroup.proposalId);
if (!p || !p.payInfo || !p.payInfo.payCoinSelection) {
//refund with no payment
return;
}
// FIXME: This is very slow, should become obsolete with materialized transactions.
for (const cp of p.payInfo.payCoinSelection.coinPubs) {
const c = await tx.coins.get(cp);
if (c?.exchangeBaseUrl) {
exchangesInTx.push(c.exchangeBaseUrl);
}
}
if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
return;
}
const contractData = await lookupMaybeContractData(
tx,
refundGroup.proposalId,
);
transactions.push(buildTransactionForRefund(refundGroup, contractData));
});
await iterRecordsForRefresh(tx, filter, async (rg) => {
const exchangesInTx = rg.infoPerExchange
? Object.keys(rg.infoPerExchange)
: [];
if (
shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)
) {
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));
}
});
await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
if (
wsr.rawWithdrawalAmount === undefined ||
wsr.exchangeBaseUrl == undefined
) {
// skip prepared withdrawals which has not been confirmed
return;
}
const exchangesInTx = [wsr.exchangeBaseUrl];
if (
shouldSkipCurrency(
transactionsRequest,
Amounts.currencyOf(wsr.rawWithdrawalAmount),
exchangesInTx,
)
) {
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: {
const exchangeDetails = await getExchangeWireDetailsInTx(
tx,
wsr.exchangeBaseUrl,
);
if (!exchangeDetails) {
// FIXME: report somehow
return;
}
transactions.push(
buildTransactionForBankIntegratedWithdraw(
wsr,
exchangeDetails,
ort,
),
);
return;
}
case WithdrawalRecordType.BankManual: {
const exchangeDetails = await getExchangeWireDetailsInTx(
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;
}
});
await iterRecordsForDenomLoss(tx, filter, async (rec) => {
const amount = Amounts.parseOrThrow(rec.amount);
const exchangesInTx = [rec.exchangeBaseUrl];
if (
shouldSkipCurrency(
transactionsRequest,
amount.currency,
exchangesInTx,
)
) {
return;
}
transactions.push(buildTransactionForDenomLoss(rec));
});
await iterRecordsForDeposit(tx, filter, async (dg) => {
const amount = Amounts.parseOrThrow(dg.amount);
const exchangesInTx = dg.infoPerExchange
? Object.keys(dg.infoPerExchange)
: [];
if (
shouldSkipCurrency(
transactionsRequest,
amount.currency,
exchangesInTx,
)
) {
return;
}
const opId = TaskIdentifiers.forDeposit(dg);
const retryRecord = await tx.operationRetries.get(opId);
transactions.push(buildTransactionForDeposit(dg, retryRecord));
});
await iterRecordsForPurchase(tx, filter, async (purchase) => {
const download = purchase.download;
if (!download) {
return;
}
if (!purchase.payInfo) {
return;
}
const exchangesInTx: string[] = [];
for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) {
const c = await tx.coins.get(cp);
if (c?.exchangeBaseUrl) {
exchangesInTx.push(c.exchangeBaseUrl);
}
}
if (
shouldSkipCurrency(
transactionsRequest,
download.currency,
exchangesInTx,
)
) {
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);
const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
purchase.proposalId,
);
transactions.push(
buildTransactionForPurchase(
purchase,
contractData,
refunds,
payRetryRecord,
),
);
});
},
);
// 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] };
}
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);
}
}
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();
}
/**
* 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();
}
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,
});
}
}
/**
* Iterate refresh records based on a filter.
*/
async function iterRecordsForRefresh(
tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
filter: TransactionRecordFilter,
f: (r: RefreshGroupRecord) => Promise,
): Promise {
let refreshGroups: RefreshGroupRecord[];
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
RefreshOperationStatus.Pending,
RefreshOperationStatus.Suspended,
);
refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
} else {
refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
}
for (const r of refreshGroups) {
await f(r);
}
}
async function iterRecordsForWithdrawal(
tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
filter: TransactionRecordFilter,
f: (r: WithdrawalGroupRecord) => Promise,
): Promise {
let withdrawalGroupRecords: WithdrawalGroupRecord[];
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
withdrawalGroupRecords =
await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
} else {
withdrawalGroupRecords =
await tx.withdrawalGroups.indexes.byStatus.getAll();
}
for (const wgr of withdrawalGroupRecords) {
await f(wgr);
}
}
async function iterRecordsForDeposit(
tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
filter: TransactionRecordFilter,
f: (r: DepositGroupRecord) => Promise,
): Promise {
let dgs: DepositGroupRecord[];
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
} else {
dgs = await tx.depositGroups.indexes.byStatus.getAll();
}
for (const dg of dgs) {
await f(dg);
}
}
async function iterRecordsForDenomLoss(
tx: WalletDbReadOnlyTransaction<["denomLossEvents"]>,
filter: TransactionRecordFilter,
f: (r: DenomLossEventRecord) => Promise,
): Promise {
let dgs: DenomLossEventRecord[];
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange);
} else {
dgs = await tx.denomLossEvents.indexes.byStatus.getAll();
}
for (const dg of dgs) {
await f(dg);
}
}
async function iterRecordsForRefund(
tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
filter: TransactionRecordFilter,
f: (r: RefundGroupRecord) => Promise,
): Promise {
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
} else {
await tx.refundGroups.iter().forEachAsync(f);
}
}
async function iterRecordsForPurchase(
tx: WalletDbReadOnlyTransaction<["purchases"]>,
filter: TransactionRecordFilter,
f: (r: PurchaseRecord) => Promise,
): Promise {
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
} else {
await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
}
}
async function iterRecordsForPeerPullCredit(
tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
filter: TransactionRecordFilter,
f: (r: PeerPullCreditRecord) => Promise,
): Promise {
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
} else {
await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
}
}
async function iterRecordsForPeerPullDebit(
tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
filter: TransactionRecordFilter,
f: (r: PeerPullPaymentIncomingRecord) => Promise,
): Promise {
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
} else {
await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
}
}
async function iterRecordsForPeerPushDebit(
tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
filter: TransactionRecordFilter,
f: (r: PeerPushDebitRecord) => Promise,
): Promise {
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
} else {
await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
}
}
async function iterRecordsForPeerPushCredit(
tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
filter: TransactionRecordFilter,
f: (r: PeerPushPaymentIncomingRecord) => Promise,
): Promise {
if (filter.onlyState === "nonfinal") {
const keyRange = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
OPERATION_STATUS_ACTIVE_LAST,
);
await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
} else {
await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
}
}