summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/transactions.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/transactions.ts')
-rw-r--r--packages/taler-wallet-core/src/transactions.ts2039
1 files changed, 2039 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
new file mode 100644
index 000000000..f6216d641
--- /dev/null
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -0,0 +1,2039 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * 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<Transaction> {
+ 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);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ 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);
+ 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);
+
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pushInc.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ return buildTransactionForPeerPushCredit(
+ pushInc,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPullCredit: {
+ const pursePub = parsedTx.pursePub;
+ return await 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);
+
+ 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);
+ 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,
+ wsr: WithdrawalGroupRecord | undefined,
+ wsrOrt: OperationRetryRecord | undefined,
+): Transaction {
+ if (wsr) {
+ if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
+ throw Error("invalid withdrawal group type for push payment credit");
+ }
+
+ const txState = computePeerPushCreditTransactionState(pushInc);
+ return {
+ type: TransactionType.PeerPushCredit,
+ txState,
+ txActions: computePeerPushCreditTransactionActions(pushInc),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wsr.instructedAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(wsr.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(
+ wgRecord: WithdrawalGroupRecord,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
+ throw Error("");
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgRecord.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(
+ withdrawalGroup: WithdrawalGroupRecord,
+ exchangeDetails: ExchangeWireDetails,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
+ throw Error("");
+
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(withdrawalGroup);
+
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(withdrawalGroup),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(
+ Amounts.zeroOfAmount(withdrawalGroup.instructedAmount),
+ )
+ : Amounts.stringify(withdrawalGroup.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails:
+ withdrawalGroup.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ withdrawalGroup.status === WithdrawalGroupStatus.Done ||
+ withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: withdrawalGroup.kycUrl,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForRefund(
+ refundRecord: RefundGroupRecord,
+ maybeContractData: WalletContractData | undefined,
+): Transaction {
+ let paymentInfo: RefundPaymentInfo | undefined = undefined;
+
+ if (maybeContractData) {
+ paymentInfo = {
+ merchant: maybeContractData.merchant,
+ summary: maybeContractData.summary,
+ summary_i18n: maybeContractData.summaryI18n,
+ };
+ }
+
+ 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<WalletContractData | undefined> {
+ let contractData: WalletContractData | undefined = undefined;
+ const purchaseTx = await tx.purchases.get(proposalId);
+ if (purchaseTx && purchaseTx.download) {
+ const download = purchaseTx.download;
+ const contractTermsRecord = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTermsRecord) {
+ return;
+ }
+ contractData = extractContractData(
+ contractTermsRecord?.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ );
+ }
+
+ return contractData;
+}
+
+async function buildTransactionForPurchase(
+ purchaseRecord: PurchaseRecord,
+ contractData: WalletContractData,
+ refundsInfo: RefundGroupRecord[],
+ ort?: OperationRetryRecord,
+): Promise<Transaction> {
+ const zero = Amounts.zeroOfAmount(contractData.amount);
+
+ const info: OrderShortInfo = {
+ merchant: contractData.merchant,
+ 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);
+ checkDbInvariant(!!purchaseRecord.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<TransactionWithdrawal | undefined> {
+ 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;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+}
+
+/**
+ * Retrieve the full event history for this wallet.
+ */
+export async function getTransactions(
+ wex: WalletExecutionContext,
+ transactionsRequest?: TransactionsRequest,
+): Promise<TransactionsResponse> {
+ 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);
+ 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);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ 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);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ 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) => {
+ 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:
+ transactions.push(
+ buildTransactionForBankIntegratedWithdraw(wsr, 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(
+ await 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<void> {
+ logger.info(`resetting retry timeout for ${transactionId}`);
+ const taskId = maybeTaskFromTransaction(transactionId);
+ if (taskId) {
+ wex.taskScheduler.resetTaskRetries(taskId);
+ }
+}
+
+async function getContextForTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<TransactionContext> {
+ 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<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.suspendTransaction();
+}
+
+export async function failTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.failTransaction();
+}
+
+/**
+ * Resume a suspended transaction.
+ */
+export async function resumeTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ 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<void> {
+ 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<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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<void>,
+): Promise<void> {
+ 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);
+ }
+}