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