taler-typescript-core

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

commit 60b868cfa3b037198dcfdaff6462ee5c4bea7bc8
parent 97a8a3a6c9c63194adbd14c90a148963cfbcb1a5
Author: Florian Dold <florian@dold.me>
Date:   Tue,  9 Dec 2025 13:28:24 +0100

wallet-core: fix computation of scopes in payment transaction

When the transaction is not yet confirmed, we now compute the scopes of
the transaction based on the exchanges offered by the merchant for the
payment.

Diffstat:
Mpackages/taler-wallet-core/src/db.ts | 2++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 529+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/taler-wallet-core/src/wallet.ts | 2+-
3 files changed, 288 insertions(+), 245 deletions(-)

diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -98,6 +98,7 @@ import { describeStoreV2, openDatabase, } from "./query.js"; +import { rematerializeTransactions } from "./transactions.js"; /** * This file contains the database schema of the Taler wallet together @@ -3844,6 +3845,7 @@ export const walletDbFixups: FixupDescription[] = [ } ]; + /** * Some old payment transactions didn't correctly * set the involved exchanges. diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -324,7 +324,10 @@ export class PayMerchantTransactionContext implements TransactionContext { const txState = computePayMerchantTransactionState(purchaseRec); - let scopes: ScopeInfo[]; + const scopes = await computePayMerchantTransactionScopesInTx( + tx, + purchaseRec, + ); let amountEffective: AmountString; if (!purchaseRec.payInfo) { @@ -335,24 +338,6 @@ export class PayMerchantTransactionContext implements TransactionContext { : Amounts.stringify(purchaseRec.payInfo.totalPayCost); } - if (purchaseRec.exchanges && purchaseRec.exchanges.length > 0) { - scopes = await getScopeForAllExchanges(tx, purchaseRec.exchanges); - } else if (purchaseRec.payInfo) { - // This should not be necessary, as we now track - // involved exchanges directly in the purchase. - const coinList = !purchaseRec.payInfo.payCoinSelection - ? [] - : purchaseRec.payInfo.payCoinSelection.coinPubs; - scopes = await getScopeForAllCoins(tx, coinList); - } else { - scopes = [ - { - type: ScopeType.Global, - currency: "UNKNOWN", - }, - ]; - } - let contractTerms: MerchantContractTerms | undefined; if (req?.includeContractTerms) { if (!this.wex.ws.config.features.enableV1Contracts) { @@ -595,6 +580,59 @@ export class PayMerchantTransactionContext implements TransactionContext { } } +async function computePayMerchantExchangesInTx( + tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>, + purchaseRecord: PurchaseRecord, +): Promise<string[]> { + if (purchaseRecord?.exchanges) { + return purchaseRecord.exchanges; + } else if (purchaseRecord != null && purchaseRecord.download != null) { + const contractTermsRec = await tx.contractTerms.get( + purchaseRecord.download.contractTermsHash, + ); + if (contractTermsRec) { + const contractTerms = codecForMerchantContractTerms().decode( + contractTermsRec.contractTermsRaw, + ); + return contractTerms.exchanges.map((x) => x.url); + } + } + return []; +} + +async function computePayMerchantTransactionScopesInTx( + tx: WalletDbAllStoresReadOnlyTransaction, + purchaseRec: PurchaseRecord, +): Promise<ScopeInfo[]> { + if (purchaseRec.exchanges && purchaseRec.exchanges.length > 0) { + return await getScopeForAllExchanges(tx, purchaseRec.exchanges); + } else if (purchaseRec.payInfo) { + // This should not be necessary, as we now track + // involved exchanges directly in the purchase. + const coinList = !purchaseRec.payInfo.payCoinSelection + ? [] + : purchaseRec.payInfo.payCoinSelection.coinPubs; + return await getScopeForAllCoins(tx, coinList); + } else if (purchaseRec.download != null) { + const contractTermsRec = await tx.contractTerms.get( + purchaseRec.download.contractTermsHash, + ); + if (contractTermsRec) { + const contractTerms = codecForMerchantContractTerms().decode( + contractTermsRec.contractTermsRaw, + ); + const exchanges = contractTerms.exchanges.map((x) => x.url); + return await getScopeForAllExchanges(tx, exchanges); + } + } + return [ + { + type: ScopeType.Global, + currency: "UNKNOWN", + }, + ]; +} + export class RefundTransactionContext implements TransactionContext { public transactionId: TransactionIdStr; public taskId: TaskIdStr | undefined = undefined; @@ -616,7 +654,13 @@ export class RefundTransactionContext implements TransactionContext { */ async updateTransactionMeta( tx: WalletDbReadWriteTransaction< - ["refundGroups", "purchases", "transactionsMeta"] + [ + "refundGroups", + "purchases", + "transactionsMeta", + "contractTerms", + "contractTerms", + ] >, ): Promise<void> { const refundRec = await tx.refundGroups.get(this.refundGroupId); @@ -625,12 +669,16 @@ export class RefundTransactionContext implements TransactionContext { return; } const purchaseRecord = await tx.purchases.get(refundRec.proposalId); + let exchanges: string[] = []; + if (purchaseRecord) { + exchanges = await computePayMerchantExchangesInTx(tx, purchaseRecord); + } await tx.transactionsMeta.put({ transactionId: this.transactionId, status: refundRec.status, timestamp: refundRec.timestampCreated, currency: Amounts.currencyOf(refundRec.amountEffective), - exchanges: purchaseRecord?.exchanges ?? [], + exchanges, }); } @@ -657,9 +705,19 @@ export class RefundTransactionContext implements TransactionContext { } const purchaseRecord = await tx.purchases.get(refundRecord.proposalId); - let scopes: ScopeInfo[] = []; - if (purchaseRecord && purchaseRecord.exchanges != null) { - scopes = await getScopeForAllExchanges(tx, purchaseRecord.exchanges); + let scopes: ScopeInfo[]; + if (purchaseRecord) { + scopes = await computePayMerchantTransactionScopesInTx( + tx, + purchaseRecord, + ); + } else { + scopes = [ + { + type: ScopeType.Global, + currency: "UNKNOWN", + }, + ]; } const txState = computeRefundTransactionState(refundRecord); @@ -697,6 +755,7 @@ export class RefundTransactionContext implements TransactionContext { "refundItems", "tombstones", "transactionsMeta", + "contractTerms", ], }, async (tx) => { @@ -716,6 +775,7 @@ export class RefundTransactionContext implements TransactionContext { "refundItems", "tombstones", "transactionsMeta", + "contractTerms", ] >, ): Promise<{ notifs: WalletNotification[] }> { @@ -4701,248 +4761,229 @@ async function storeRefunds( const currency = Amounts.currencyOf(amountRaw); - const result = await wex.db.runReadWriteTx( - { - storeNames: [ - "coinAvailability", - "coinHistory", - "coins", - "coins", - "denominations", - "denominations", - "purchases", - "refreshGroups", - "refreshSessions", - "refundGroups", - "refundItems", - "transactionsMeta", - ], - }, - async (tx) => { - const myPurchase = await tx.purchases.get(purchase.proposalId); - if (!myPurchase) { - logger.warn("purchase group not found anymore"); + const result = await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const myPurchase = await tx.purchases.get(purchase.proposalId); + if (!myPurchase) { + logger.warn("purchase group not found anymore"); + return; + } + let isAborting: boolean; + switch (myPurchase.purchaseStatus) { + case PurchaseStatus.PendingAcceptRefund: + isAborting = false; + break; + case PurchaseStatus.AbortingWithRefund: + isAborting = true; + break; + default: + logger.warn("wrong state, not accepting refund"); return; - } - let isAborting: boolean; - switch (myPurchase.purchaseStatus) { - case PurchaseStatus.PendingAcceptRefund: - isAborting = false; - break; - case PurchaseStatus.AbortingWithRefund: - isAborting = true; - break; - default: - logger.warn("wrong state, not accepting refund"); - return; - } + } - let newGroup: RefundGroupRecord | undefined = undefined; - // Pending, but not part of an aborted refund group. - let numPendingItemsTotal = 0; - const newGroupRefunds: RefundItemRecord[] = []; + let newGroup: RefundGroupRecord | undefined = undefined; + // Pending, but not part of an aborted refund group. + let numPendingItemsTotal = 0; + const newGroupRefunds: RefundItemRecord[] = []; - for (const rf of refunds) { - const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([ - rf.coin_pub, - rf.rtransaction_id, - ]); - let oldTxState: TransactionState | undefined = undefined; - if (oldItem) { - logger.info("already have refund in database"); - if (oldItem.status === RefundItemStatus.Done) { - continue; - } - if (rf.type === "success") { - oldItem.status = RefundItemStatus.Done; - } else { - if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { - oldItem.status = RefundItemStatus.Pending; - numPendingItemsTotal += 1; - } else { - oldItem.status = RefundItemStatus.Failed; - } - } - await tx.refundItems.put(oldItem); + for (const rf of refunds) { + const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([ + rf.coin_pub, + rf.rtransaction_id, + ]); + let oldTxState: TransactionState | undefined = undefined; + if (oldItem) { + logger.info("already have refund in database"); + if (oldItem.status === RefundItemStatus.Done) { + continue; + } + if (rf.type === "success") { + oldItem.status = RefundItemStatus.Done; } else { - // Put refund item into a new group! - if (!newGroup) { - newGroup = { - proposalId: purchase.proposalId, - refundGroupId: newRefundGroupId, - status: RefundGroupStatus.Pending, - timestampCreated: timestampPreciseToDb(now), - amountEffective: Amounts.stringify( - Amounts.zeroOfCurrency(currency), - ), - amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)), - }; - } - const status: RefundItemStatus = getItemStatus(rf); - const newItem: RefundItemRecord = { - coinPub: rf.coin_pub, - executionTime: timestampProtocolToDb(rf.execution_time), - obtainedTime: timestampPreciseToDb(now), - refundAmount: rf.refund_amount, - refundGroupId: newGroup.refundGroupId, - rtxid: rf.rtransaction_id, - status, - }; - if (status === RefundItemStatus.Pending) { + if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + oldItem.status = RefundItemStatus.Pending; numPendingItemsTotal += 1; + } else { + oldItem.status = RefundItemStatus.Failed; } - newGroupRefunds.push(newItem); - await tx.refundItems.put(newItem); } + await tx.refundItems.put(oldItem); + } else { + // Put refund item into a new group! + if (!newGroup) { + newGroup = { + proposalId: purchase.proposalId, + refundGroupId: newRefundGroupId, + status: RefundGroupStatus.Pending, + timestampCreated: timestampPreciseToDb(now), + amountEffective: Amounts.stringify( + Amounts.zeroOfCurrency(currency), + ), + amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)), + }; + } + const status: RefundItemStatus = getItemStatus(rf); + const newItem: RefundItemRecord = { + coinPub: rf.coin_pub, + executionTime: timestampProtocolToDb(rf.execution_time), + obtainedTime: timestampPreciseToDb(now), + refundAmount: rf.refund_amount, + refundGroupId: newGroup.refundGroupId, + rtxid: rf.rtransaction_id, + status, + }; + if (status === RefundItemStatus.Pending) { + numPendingItemsTotal += 1; + } + newGroupRefunds.push(newItem); + await tx.refundItems.put(newItem); } + } - // Now that we know all the refunds for the new refund group, - // we can compute the raw/effective amounts. - if (newGroup) { - const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); - const refreshCoins = await computeRefreshRequest( - wex, - tx, - newGroupRefunds, - ); - const outInfo = await calculateRefreshOutput( - wex, - tx, - currency, - refreshCoins, - ); - newGroup.amountEffective = Amounts.stringify( - Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount, - ); - newGroup.amountRaw = Amounts.stringify( - Amounts.sumOrZero(currency, amountsRaw).amount, - ); - const refundCtx = new RefundTransactionContext( - wex, - newGroup.refundGroupId, - ); - await tx.refundGroups.put(newGroup); + // Now that we know all the refunds for the new refund group, + // we can compute the raw/effective amounts. + if (newGroup) { + const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); + const refreshCoins = await computeRefreshRequest( + wex, + tx, + newGroupRefunds, + ); + const outInfo = await calculateRefreshOutput( + wex, + tx, + currency, + refreshCoins, + ); + newGroup.amountEffective = Amounts.stringify( + Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount, + ); + newGroup.amountRaw = Amounts.stringify( + Amounts.sumOrZero(currency, amountsRaw).amount, + ); + const refundCtx = new RefundTransactionContext( + wex, + newGroup.refundGroupId, + ); + await tx.refundGroups.put(newGroup); + await refundCtx.updateTransactionMeta(tx); + applyNotifyTransition(tx.notify, refundCtx.transactionId, { + oldTxState: { major: TransactionMajorState.None }, + newTxState: computeRefundTransactionState(newGroup), + balanceEffect: BalanceEffect.Any, + newStId: newGroup.status, + oldStId: 0, + }); + } + + const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll( + myPurchase.proposalId, + ); + + for (const refundGroup of refundGroups) { + const refundCtx = new RefundTransactionContext( + wex, + refundGroup.refundGroupId, + ); + switch (refundGroup.status) { + case RefundGroupStatus.Aborted: + case RefundGroupStatus.Expired: + case RefundGroupStatus.Failed: + case RefundGroupStatus.Done: + continue; + case RefundGroupStatus.Pending: + break; + default: + assertUnreachable(refundGroup.status); + } + const items = await tx.refundItems.indexes.byRefundGroupId.getAll([ + refundGroup.refundGroupId, + ]); + let numPending = 0; + let numFailed = 0; + for (const item of items) { + if (item.status === RefundItemStatus.Pending) { + numPending++; + } + if (item.status === RefundItemStatus.Failed) { + numFailed++; + } + } + const oldTxState: TransactionState = + computeRefundTransactionState(refundGroup); + const oldStId = refundGroup.status; + if (numPending === 0) { + // We're done for this refund group! + if (numFailed === 0) { + refundGroup.status = RefundGroupStatus.Done; + } else { + refundGroup.status = RefundGroupStatus.Failed; + } + await tx.refundGroups.put(refundGroup); await refundCtx.updateTransactionMeta(tx); + const refreshCoins = await computeRefreshRequest(wex, tx, items); + const newTxState: TransactionState = + computeRefundTransactionState(refundGroup); + const newStId = refundGroup.status; applyNotifyTransition(tx.notify, refundCtx.transactionId, { - oldTxState: { major: TransactionMajorState.None }, - newTxState: computeRefundTransactionState(newGroup), + oldTxState, + newTxState, balanceEffect: BalanceEffect.Any, - newStId: newGroup.status, - oldStId: 0, + newStId, + oldStId, }); - } - - const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll( - myPurchase.proposalId, - ); - - for (const refundGroup of refundGroups) { - const refundCtx = new RefundTransactionContext( + await createRefreshGroup( wex, - refundGroup.refundGroupId, + tx, + Amounts.currencyOf(amountRaw), + refreshCoins, + RefreshReason.Refund, + // Since refunds are really just pseudo-transactions, + // the originating transaction for the refresh is the payment transaction. + constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: myPurchase.proposalId, + }), ); - switch (refundGroup.status) { - case RefundGroupStatus.Aborted: - case RefundGroupStatus.Expired: - case RefundGroupStatus.Failed: - case RefundGroupStatus.Done: - continue; - case RefundGroupStatus.Pending: - break; - default: - assertUnreachable(refundGroup.status); - } - const items = await tx.refundItems.indexes.byRefundGroupId.getAll([ - refundGroup.refundGroupId, - ]); - let numPending = 0; - let numFailed = 0; - for (const item of items) { - if (item.status === RefundItemStatus.Pending) { - numPending++; - } - if (item.status === RefundItemStatus.Failed) { - numFailed++; - } - } - const oldTxState: TransactionState = - computeRefundTransactionState(refundGroup); - const oldStId = refundGroup.status; - if (numPending === 0) { - // We're done for this refund group! - if (numFailed === 0) { - refundGroup.status = RefundGroupStatus.Done; - } else { - refundGroup.status = RefundGroupStatus.Failed; - } - await tx.refundGroups.put(refundGroup); - await refundCtx.updateTransactionMeta(tx); - const refreshCoins = await computeRefreshRequest(wex, tx, items); - const newTxState: TransactionState = - computeRefundTransactionState(refundGroup); - const newStId = refundGroup.status; - applyNotifyTransition(tx.notify, refundCtx.transactionId, { - oldTxState, - newTxState, - balanceEffect: BalanceEffect.Any, - newStId, - oldStId, - }); - await createRefreshGroup( - wex, - tx, - Amounts.currencyOf(amountRaw), - refreshCoins, - RefreshReason.Refund, - // Since refunds are really just pseudo-transactions, - // the originating transaction for the refresh is the payment transaction. - constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: myPurchase.proposalId, - }), - ); - } } + } - const oldTxState = computePayMerchantTransactionState(myPurchase); - const oldStId = myPurchase.purchaseStatus; + const oldTxState = computePayMerchantTransactionState(myPurchase); + const oldStId = myPurchase.purchaseStatus; - const shouldCheckAutoRefund = - myPurchase.autoRefundDeadline && - !AbsoluteTime.isExpired( - AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(myPurchase.autoRefundDeadline), - ), - ); + const shouldCheckAutoRefund = + myPurchase.autoRefundDeadline && + !AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(myPurchase.autoRefundDeadline), + ), + ); - if (numPendingItemsTotal === 0) { - if (isAborting) { - myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; - } else if (shouldCheckAutoRefund) { - myPurchase.purchaseStatus = - PurchaseStatus.FinalizingQueryingAutoRefund; - } else { - myPurchase.purchaseStatus = PurchaseStatus.Done; - } - myPurchase.refundAmountAwaiting = undefined; + if (numPendingItemsTotal === 0) { + if (isAborting) { + myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; + } else if (shouldCheckAutoRefund) { + myPurchase.purchaseStatus = PurchaseStatus.FinalizingQueryingAutoRefund; + } else { + myPurchase.purchaseStatus = PurchaseStatus.Done; } - await tx.purchases.put(myPurchase); - await ctx.updateTransactionMeta(tx); - const newTxState = computePayMerchantTransactionState(myPurchase); + myPurchase.refundAmountAwaiting = undefined; + } + await tx.purchases.put(myPurchase); + await ctx.updateTransactionMeta(tx); + const newTxState = computePayMerchantTransactionState(myPurchase); - applyNotifyTransition(tx.notify, ctx.transactionId, { - oldTxState, - newTxState, - balanceEffect: BalanceEffect.Any, - newStId: myPurchase.purchaseStatus, - oldStId, - }); + applyNotifyTransition(tx.notify, ctx.transactionId, { + oldTxState, + newTxState, + balanceEffect: BalanceEffect.Any, + newStId: myPurchase.purchaseStatus, + oldStId, + }); - return { - numPendingItemsTotal, - }; - }, - ); + return { + numPendingItemsTotal, + }; + }); if (!result) { return TaskRunResult.finished(); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -553,7 +553,7 @@ async function fillDefaults(wex: WalletExecutionContext): Promise<void> { /** * Incremented each time we want to re-materialize transactions. */ -const MATERIALIZED_TRANSACTIONS_VERSION = 1; +const MATERIALIZED_TRANSACTIONS_VERSION = 2; async function migrateMaterializedTransactions( wex: WalletExecutionContext,