taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 9dd937f3a0b5e3bb87c669d8e9dda757b2982c17
parent c6f5d2f5b0bebe6e2b762e2f83f11f29a1ca09ed
Author: Florian Dold <florian@dold.me>
Date:   Mon,  8 Jul 2024 22:01:13 +0200

wallet-core: refactor transaction details / new transactionsMeta store

Diffstat:
Mpackages/taler-wallet-core/src/db.ts | 60+++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/taler-wallet-core/src/exchanges.ts | 4++--
Mpackages/taler-wallet-core/src/refresh.ts | 6+++---
Mpackages/taler-wallet-core/src/transactions.ts | 878++++++++++++++++++++++++++++---------------------------------------------------
Mpackages/taler-wallet-core/src/withdraw.ts | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
5 files changed, 529 insertions(+), 659 deletions(-)

diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -57,7 +57,6 @@ import { TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, - Transaction, TransactionIdStr, UnblindedSignature, WireInfo, @@ -155,7 +154,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 12; +export const WALLET_DB_MINOR_VERSION = 13; declare const symDbProtocolTimestamp: unique symbol; @@ -2335,13 +2334,26 @@ export interface GlobalCurrencyExchangeRecord { } /** - * Primary key: transactionItem.transactionId + * Metadata for a transaction. + * This object store is effectively a materialzed view of transactions gathered + * from various other object stores. + * + * Primary key: transactionId */ -export interface TransactionRecord { +export interface TransactionMetaRecord { /** - * Transaction item returned to the client. + * Transaction identifier. + * Also determines the type of the transaction. */ - transactionItem: Transaction; + transactionId: string; + + timestamp: DbPreciseTimestamp; + + /** + * Status of the transaction, matches the status enum of the + * transaction of the type determined by the transaction ID. + */ + status: number; /** * Exchanges involved in the transaction. @@ -2410,19 +2422,25 @@ export const WalletStoresV1 = { }), }, }), - transactions: describeStoreV2({ - recordCodec: passthroughCodec<TransactionRecord>(), - storeName: "transactions", - keyPath: "transactionItem.transactionId", - versionAdded: 7, + transactionsMeta: describeStoreV2({ + recordCodec: passthroughCodec<TransactionMetaRecord>(), + storeName: "transactionsMeta", + keyPath: "transactionId", + versionAdded: 13, indexes: { byCurrency: describeIndex("byCurrency", "currency", { - versionAdded: 7, + versionAdded: 13, }), byExchange: describeIndex("byExchange", "exchanges", { - versionAdded: 7, + versionAdded: 13, multiEntry: true, }), + byTimestamp: describeIndex("byTimestamp", "timestamp", { + versionAdded: 13, + }), + byStatus: describeIndex("byStatus", "status", { + versionAdded: 13, + }), }, }), currencyInfo: describeStoreV2({ @@ -2830,6 +2848,22 @@ export const WalletStoresV1 = { }), {}, ), + // Obsolete store, not used anymore + _obsolete_transactions: describeStoreV2({ + recordCodec: passthroughCodec<unknown>(), + storeName: "transactions", + keyPath: "transactionItem.transactionId", + versionAdded: 7, + indexes: { + byCurrency: describeIndex("byCurrency", "currency", { + versionAdded: 7, + }), + byExchange: describeIndex("byExchange", "exchanges", { + versionAdded: 7, + multiEntry: true, + }), + }, + }), }; export type WalletDbStoresArr = Array<StoreNames<typeof WalletStoresV1>>; diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -2506,7 +2506,7 @@ async function purgeExchange( [ "exchanges", "exchangeDetails", - "transactions", + "transactionsMeta", "coinAvailability", "coins", "denominations", @@ -2590,7 +2590,7 @@ export async function deleteExchange( storeNames: [ "exchanges", "exchangeDetails", - "transactions", + "transactionsMeta", "coinAvailability", "coins", "denominations", diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -130,7 +130,7 @@ async function updateRefreshTransaction( tx: WalletDbReadWriteTransaction< [ "refreshGroups", - "transactions", + "transactionsMeta", "operationRetries", "exchanges", "exchangeDetails", @@ -167,7 +167,7 @@ export class RefreshTransactionContext implements TransactionContext { tx: WalletDbReadWriteTransaction< [ "refreshGroups", - "transactions", + "transactionsMeta", "operationRetries", "exchanges", "exchangeDetails", @@ -178,7 +178,7 @@ export class RefreshTransactionContext implements TransactionContext { ): Promise<TransitionInfo | undefined> { const baseStores = [ "refreshGroups" as const, - "transactions" as const, + "transactionsMeta" as const, "operationRetries" as const, "exchanges" as const, "exchangeDetails" as const, diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -93,7 +93,6 @@ import { computeDenomLossTransactionStatus, DenomLossTransactionContext, ExchangeWireDetails, - getExchangeWireDetailsInTx, } from "./exchanges.js"; import { computePayMerchantTransactionActions, @@ -224,55 +223,17 @@ export async function getTransactionById( 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, - ); - }, + const ctx = new WithdrawTransactionContext( + wex, + parsedTx.withdrawalGroupId, ); + const res = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + return await ctx.lookupFullTransaction(tx); + }); + if (!res) { + throw Error("not found"); + } + return res; } case TransactionType.DenomLoss: { @@ -731,61 +692,6 @@ function buildTransactionForPeerPushCredit( }; } -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.externalConfirmation - ? undefined - : wg.wgInfo.bankInfo.confirmUrl, - externalConfirmation: wg.wgInfo.bankInfo.externalConfirmation, - 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 || @@ -796,56 +702,6 @@ export function isUnsuccessfulTransaction(state: TransactionState): boolean { ); } -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, @@ -1103,56 +959,24 @@ 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; - } - 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, + return await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + const withdrawalGroupRecord = + await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + request.talerWithdrawUri, ); - }, - ); + if (!withdrawalGroupRecord) { + return undefined; + } + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroupRecord.withdrawalGroupId, + ); + const dbTxn = await ctx.lookupFullTransaction(tx); + if (!dbTxn || dbTxn.type !== TransactionType.Withdrawal) { + return undefined; + } + return dbTxn; + }); } /** @@ -1169,399 +993,327 @@ export async function getTransactions( 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 wex.db.runAllStoresReadOnlyTx({}, 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; - } + 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; - } + const contractTermsRec = await tx.contractTerms.get(pi.contractTermsHash); + if (!contractTermsRec) { + return; + } - transactions.push( - buildTransactionForPullPaymentDebit( - pi, - contractTermsRec.contractTermsRaw, - ), - ); - }); + 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); - } + 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, - ), - ); - }); + } + 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); - } + 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, - ), - ); - }); + } + 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); + 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; - } + 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); - } + // 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)); - }); + 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) { + 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; - } 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); + } + if (required) { const ort = await tx.operationRetries.get(opId); + transactions.push(buildTransactionForRefresh(rg, ort)); + } + }); - 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; - } + 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; + } - transactions.push( - buildTransactionForBankIntegratedWithdraw( - wsr, - exchangeDetails, - ort, - ), - ); - return; - } + if (shouldSkipSearch(transactionsRequest, [])) { + return; + } - case WithdrawalRecordType.BankManual: { - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - wsr.exchangeBaseUrl, - ); - if (!exchangeDetails) { - // FIXME: report somehow - return; - } - transactions.push( - buildTransactionForManualWithdraw(wsr, exchangeDetails, ort), - ); + 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: + case WithdrawalRecordType.BankManual: { + const ctx = new WithdrawTransactionContext( + wex, + wsr.withdrawalGroupId, + ); + const dbTxn = await ctx.lookupFullTransaction(tx); + if (!dbTxn) { 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, - ) - ) { + transactions.push(dbTxn); 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, - ) - ) { + case WithdrawalRecordType.Recoup: + // FIXME: Do we also report a transaction here? return; - } - const opId = TaskIdentifiers.forDeposit(dg); - const retryRecord = await tx.operationRetries.get(opId); + } + }); - transactions.push(buildTransactionForDeposit(dg, retryRecord)); - }); + 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 iterRecordsForPurchase(tx, filter, async (purchase) => { - const download = purchase.download; - if (!download) { - return; - } - if (!purchase.payInfo) { - return; - } + 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); - 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); - } - } + transactions.push(buildTransactionForDeposit(dg, retryRecord)); + }); - 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; + 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); } + } - const contractData = extractContractData( - contractTermsRecord?.contractTermsRaw, - download.contractTermsHash, - download.contractTermsMerchantSig, - ); + 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 payOpId = TaskIdentifiers.forPay(purchase); - const payRetryRecord = await tx.operationRetries.get(payOpId); + const contractData = extractContractData( + contractTermsRecord?.contractTermsRaw, + download.contractTermsHash, + download.contractTermsMerchantSig, + ); - const refunds = await tx.refundGroups.indexes.byProposalId.getAll( - purchase.proposalId, - ); + const payOpId = TaskIdentifiers.forPay(purchase); + const payRetryRecord = await tx.operationRetries.get(payOpId); - transactions.push( - buildTransactionForPurchase( - purchase, - contractData, - refunds, - payRetryRecord, - ), - ); - }); - }, - ); + 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 diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -72,6 +72,7 @@ import { TransactionMinorState, TransactionState, TransactionType, + TransactionWithdrawal, URL, UnblindedSignature, WalletNotification, @@ -129,8 +130,10 @@ import { DenominationRecord, DenominationVerificationStatus, KycPendingInfo, + OperationRetryRecord, PlanchetRecord, PlanchetStatus, + WalletDbAllStoresReadOnlyTransaction, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, WalletDbStoresArr, @@ -149,6 +152,7 @@ import { } from "./denomSelection.js"; import { isWithdrawableDenom } from "./denominations.js"; import { + ExchangeWireDetails, ReadyExchangeSummary, fetchFreshExchange, getExchangePaytoUri, @@ -182,7 +186,7 @@ async function updateWithdrawalTransaction( tx: WalletDbReadWriteTransaction< [ "withdrawalGroups", - "transactions", + "transactionsMeta", "operationRetries", "exchanges", "exchangeDetails", @@ -191,12 +195,9 @@ async function updateWithdrawalTransaction( ): Promise<void> { const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId); if (!wgRecord) { - await tx.transactions.delete(ctx.transactionId); + await tx.transactionsMeta.delete(ctx.transactionId); return; } - const retryRecord = await tx.operationRetries.get(ctx.taskId); - - let transactionItem: Transaction; if ( !wgRecord.instructedAmount || @@ -208,32 +209,6 @@ async function updateWithdrawalTransaction( } if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) { - const txState = computeWithdrawalTransactionStatus(wgRecord); - transactionItem = { - 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: ctx.transactionId, - }; } else if ( wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual ) { @@ -245,62 +220,127 @@ async function updateWithdrawalTransaction( wgRecord.denomsSel !== undefined, "manual withdrawal without denoms can't be created", ); - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - wgRecord.exchangeBaseUrl, - ); - const plainPaytoUris = - exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; - - const exchangePaytoUris = augmentPaytoUrisForWithdrawal( - plainPaytoUris, - wgRecord.reservePub, - wgRecord.instructedAmount, - ); - - const txState = computeWithdrawalTransactionStatus(wgRecord); - - transactionItem = { - 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.ManualTransfer, - reservePub: wgRecord.reservePub, - exchangePaytoUris, - exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts, - reserveIsReady: - wgRecord.status === WithdrawalGroupStatus.Done || - wgRecord.status === WithdrawalGroupStatus.PendingReady, - }, - kycUrl: wgRecord.kycUrl, - exchangeBaseUrl: wgRecord.exchangeBaseUrl, - timestamp: timestampPreciseFromDb(wgRecord.timestampStart), - transactionId: ctx.transactionId, - }; } else { // FIXME: If this is an orphaned withdrawal for a p2p transaction, we // still might want to report the withdrawal. return; } - - if (retryRecord?.lastError) { - transactionItem.error = retryRecord.lastError; - } - - await tx.transactions.put({ + await tx.transactionsMeta.put({ + transactionId: ctx.transactionId, + status: wgRecord.status, + timestamp: wgRecord.timestampStart, currency: Amounts.currencyOf(wgRecord.instructedAmount), - transactionItem, exchanges: [wgRecord.exchangeBaseUrl], }); // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted? } +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.externalConfirmation + ? undefined + : wg.wgInfo.bankInfo.confirmUrl, + externalConfirmation: wg.wgInfo.bankInfo.externalConfirmation, + 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 } : {}), + }; +} + +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 } : {}), + }; +} + export class WithdrawTransactionContext implements TransactionContext { readonly transactionId: TransactionIdStr; readonly taskId: TaskIdStr; @@ -320,6 +360,50 @@ export class WithdrawTransactionContext implements TransactionContext { } /** + * Get the full transaction details for the transaction. + * + * Returns undefined if the transaction is in a state where we do not have a + * transaction item (e.g. if it was deleted). + */ + async lookupFullTransaction( + tx: WalletDbAllStoresReadOnlyTransaction, + ): Promise<Transaction | undefined> { + const withdrawalGroupRecord = await tx.withdrawalGroups.get( + this.withdrawalGroupId, + ); + if (!withdrawalGroupRecord) { + return undefined; + } + const ort = await tx.operationRetries.get(this.taskId); + const exchangeDetails = + withdrawalGroupRecord.exchangeBaseUrl === undefined + ? undefined + : await getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + if ( + withdrawalGroupRecord.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated + ) { + return buildTransactionForBankIntegratedWithdraw( + withdrawalGroupRecord, + exchangeDetails, + ort, + ); + } + checkDbInvariant( + exchangeDetails !== undefined, + "manual withdrawal without exchange", + ); + return buildTransactionForManualWithdraw( + withdrawalGroupRecord, + exchangeDetails, + ort, + ); + } + + /** * Transition a withdrawal transaction. * Extra object stores may be accessed during the transition. */ @@ -330,7 +414,7 @@ export class WithdrawTransactionContext implements TransactionContext { tx: WalletDbReadWriteTransaction< [ "withdrawalGroups", - "transactions", + "transactionsMeta", "operationRetries", "exchanges", "exchangeDetails", @@ -341,7 +425,7 @@ export class WithdrawTransactionContext implements TransactionContext { ): Promise<TransitionInfo | undefined> { const baseStores = [ "withdrawalGroups" as const, - "transactions" as const, + "transactionsMeta" as const, "operationRetries" as const, "exchanges" as const, "exchangeDetails" as const, @@ -3034,7 +3118,7 @@ export async function internalCreateWithdrawalGroup( "reserves", "exchanges", "exchangeDetails", - "transactions", + "transactionsMeta", "operationRetries", ], },