taler-typescript-core

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

commit aaa79dd33b884d362d6ec39b17baf1efb6f88b5b
parent 5d20ea43f4052abd9aeefeb7ff30aa386f76a931
Author: Florian Dold <florian@dold.me>
Date:   Tue,  2 Jun 2026 01:14:45 +0200

wallet-core: subtract prospective coin selections from available balance

Diffstat:
Mpackages/taler-wallet-core/src/balance.ts | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-wallet-core/src/db.ts | 6++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 21+++++++++++++++++++++
3 files changed, 126 insertions(+), 26 deletions(-)

diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -110,7 +110,8 @@ const logger = new Logger("operations/balance.ts"); interface WalletBalance { scopeInfo: ScopeInfo; - available: AmountJson; + availablePos: AmountJson; + availableNeg: AmountJson; pendingIncoming: AmountJson; pendingOutgoing: AmountJson; flagIncomingKyc: boolean; @@ -210,7 +211,8 @@ class BalancesStore { const zero = Amounts.zeroOfCurrency(currency); b = this.balanceStore[balanceKey] = { scopeInfo, - available: zero, + availablePos: zero, + availableNeg: zero, pendingIncoming: zero, pendingOutgoing: zero, flagIncomingConfirmation: false, @@ -261,7 +263,16 @@ class BalancesStore { amount: AmountLike, ): Promise<void> { const b = await this.initBalance(currency, exchangeBaseUrl); - b.available = Amounts.add(b.available, amount).amount; + b.availablePos = Amounts.add(b.availablePos, amount).amount; + } + + async subAvailable( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.availableNeg = Amounts.sub(b.availableNeg, amount).amount; } async addPendingIncoming( @@ -378,7 +389,9 @@ class BalancesStore { } balancesResponse.balances.push({ scopeInfo: v.scopeInfo, - available: Amounts.stringify(v.available), + available: Amounts.stringify( + Amounts.sub(v.availablePos, v.availableNeg).amount, + ), pendingIncoming: Amounts.stringify(v.pendingIncoming), pendingOutgoing: Amounts.stringify(v.pendingOutgoing), flags, @@ -571,14 +584,28 @@ export async function getBalancesInsideTransaction( await tx.peerPushDebit.indexes.byStatus .iter(keyRangeActive) .forEachAsync(async (ppdRecord) => { + const currency = Amounts.currencyOf(ppdRecord.amount); switch (ppdRecord.status) { + case PeerPushDebitStatus.PendingCreatePurse: + case PeerPushDebitStatus.SuspendedCreatePurse: { + if (ppdRecord.coinSel == null) { + await balanceStore.subAvailable( + currency, + ppdRecord.exchangeBaseUrl, + ppdRecord.totalCost, + ); + } + await balanceStore.addPendingOutgoing( + currency, + ppdRecord.exchangeBaseUrl, + ppdRecord.totalCost, + ); + break; + } case PeerPushDebitStatus.AbortingDeletePurse: case PeerPushDebitStatus.SuspendedAbortingDeletePurse: case PeerPushDebitStatus.PendingReady: - case PeerPushDebitStatus.SuspendedReady: - case PeerPushDebitStatus.PendingCreatePurse: - case PeerPushDebitStatus.SuspendedCreatePurse: { - const currency = Amounts.currencyOf(ppdRecord.amount); + case PeerPushDebitStatus.SuspendedReady: { await balanceStore.addPendingOutgoing( currency, ppdRecord.exchangeBaseUrl, @@ -654,12 +681,27 @@ export async function getBalancesInsideTransaction( await tx.peerPullDebit.indexes.byStatus .iter(keyRangeActive) .forEachAsync(async (rec) => { + const currency = Amounts.currencyOf(rec.amount); switch (rec.status) { case PeerPullDebitRecordStatus.PendingDeposit: - case PeerPullDebitRecordStatus.AbortingRefresh: - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: case PeerPullDebitRecordStatus.SuspendedDeposit: - const currency = Amounts.currencyOf(rec.amount); + // We're on a prospective coin selection, + // thus the amount of this transaction gets + // deducted from the available amount. + if (rec.coinSel == null) { + await balanceStore.subAvailable( + currency, + rec.exchangeBaseUrl, + rec.totalCostEstimated, + ); + } + break; + } + switch (rec.status) { + case PeerPullDebitRecordStatus.PendingDeposit: + case PeerPullDebitRecordStatus.SuspendedDeposit: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: { const amount = rec.coinSel?.totalCost ?? rec.amount; await balanceStore.addPendingOutgoing( currency, @@ -667,6 +709,7 @@ export async function getBalancesInsideTransaction( amount, ); break; + } } }); @@ -676,23 +719,38 @@ export async function getBalancesInsideTransaction( switch (rec.purchaseStatus) { case PurchaseStatus.SuspendedPaying: case PurchaseStatus.PendingPaying: - if (!rec.payInfo || !rec.payInfo.payCoinSelection?.coinPubs) { - break; + // We're on a prospective coin selection, + // thus the amount of this transaction gets + // deducted from the available amount. + if ( + rec.payInfo && + !rec.payInfo.payCoinSelection && + rec.payInfo.prospectivePayCoinSelection + ) { + const currency = Amounts.currencyOf(rec.payInfo.totalPayCost); + for (const pr of rec.payInfo.prospectivePayCoinSelection) + await balanceStore.subAvailable( + currency, + pr.contribution, + pr.exchangeBaseUrl, + ); } - // FIXME: This is pretty slow, cache? - const currency = Amounts.currencyOf(rec.payInfo.totalPayCost); - const sel = rec.payInfo.payCoinSelection; - for (let i = 0; i < sel.coinPubs.length; i++) { - const coinPub = sel.coinPubs[i]; - const coinRec = await tx.coins.get(coinPub); - if (!coinRec) { - continue; + if (rec.payInfo && rec.payInfo.payCoinSelection?.coinPubs) { + // FIXME: This is pretty slow, cache? + const currency = Amounts.currencyOf(rec.payInfo.totalPayCost); + const sel = rec.payInfo.payCoinSelection; + for (let i = 0; i < sel.coinPubs.length; i++) { + const coinPub = sel.coinPubs[i]; + const coinRec = await tx.coins.get(coinPub); + if (!coinRec) { + continue; + } + await balanceStore.addPendingOutgoing( + currency, + coinRec.exchangeBaseUrl, + sel.coinContributions[i], + ); } - await balanceStore.addPendingOutgoing( - currency, - coinRec.exchangeBaseUrl, - sel.coinContributions[i], - ); } } }); @@ -714,6 +772,21 @@ export async function getBalancesInsideTransaction( case DepositOperationStatus.SuspendedDepositKyc: case DepositOperationStatus.SuspendedDepositKycAuth: await balanceStore.setFlagOutgoingKyc(currency, e); + if (dgRecord.payCoinSelection == null) { + // We're on a prospective coin selection, + // thus the amount of this transaction gets + // deducted from the available amount. + const perExchange = dgRecord.infoPerExchange; + if (perExchange) { + for (const [e, v] of Object.entries(perExchange)) { + await balanceStore.subAvailable( + currency, + e, + v.amountEffective, + ); + } + } + } } switch (dgRecord.operationStatus) { case DepositOperationStatus.SuspendedAggregateKyc: diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -1516,6 +1516,7 @@ export interface PurchasePayInfo { * Undefined if payment is blocked by a pending refund. */ payCoinSelection?: DbCoinSelection; + /** * Undefined if payment is blocked by a pending refund. */ @@ -1523,6 +1524,11 @@ export interface PurchasePayInfo { payTokenSelection?: DbTokenSelection; + prospectivePayCoinSelection?: { + exchangeBaseUrl: string; + contribution: AmountString; + }[]; + /** * Token signatures from merchant. */ diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -86,6 +86,7 @@ import { PreparePayResult, PreparePayResultType, PreparePayTemplateRequest, + ProspectivePayCoinSelection, randomBytes, RefreshReason, RefundInfoShort, @@ -1523,6 +1524,23 @@ function setCoinSel(rec: PurchaseRecord, coinSel: PayCoinSelection): void { rec.exchanges.sort(); } +function setProspectiveCoinSel( + rec: PurchaseRecord, + coinSel: ProspectivePayCoinSelection, +): void { + checkLogicInvariant(!!rec.payInfo); + rec.payInfo.prospectivePayCoinSelection = coinSel.prospectiveCoins.map( + (x) => ({ + contribution: x.contribution, + exchangeBaseUrl: x.exchangeBaseUrl, + }), + ); + rec.exchanges = [ + ...new Set(coinSel.prospectiveCoins.map((x) => x.exchangeBaseUrl)), + ]; + rec.exchanges.sort(); +} + async function reselectCoinsTx( tx: WalletIndexedDbTransaction, ctx: PayMerchantTransactionContext, @@ -2935,6 +2953,9 @@ export async function confirmPay( if (selectCoinsResult.type === "success") { setCoinSel(p, selectCoinsResult.coinSel); } + if (selectCoinsResult.type === "prospective") { + setProspectiveCoinSel(p, selectCoinsResult.result); + } p.lastSessionId = sessionId; p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); p.purchaseStatus = PurchaseStatus.PendingPaying;