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:
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;