commit f86862aaad9af0275bd18f7051f82b61dcdb8fe4
parent bfaa412233c07b922bb147c7e115260988e9a3b6
Author: Florian Dold <dold@taler.net>
Date: Wed, 12 Aug 2026 16:57:43 +0200
wallet-core: classify test balances correctly
Diffstat:
5 files changed, 191 insertions(+), 20 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -659,7 +659,7 @@ export interface DonauSummaryItem {
export interface BalancesResponse {
/** Electronic cash balances, per currency scope. */
balances: WalletBalance[];
- /** Does the user have non-demo money? */
+ /** Does the user have money from an exchange other than demo or test? */
haveProdBalance: boolean;
/* Summary of donations, per donau/year/currency. */
donauSummary?: DonauSummaryItem[];
diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts
@@ -0,0 +1,153 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 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 along with GNU Taler;
+ see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import assert from "node:assert";
+import { test } from "node:test";
+
+import { ScopeType } from "@gnu-taler/taler-util";
+import { getBalancesInsideTransaction } from "./balance.js";
+import {
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ WalletExchangeDetails,
+ WalletExchangeEntry,
+} from "./db-common.js";
+import { WalletDbTransaction } from "./dbtx.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+function makeExchange(
+ baseUrl: string,
+ presetType?: string,
+): WalletExchangeEntry {
+ return {
+ baseUrl,
+ entryStatus: ExchangeEntryDbRecordStatus.Used,
+ updateStatus: ExchangeEntryDbUpdateStatus.Ready,
+ presetType,
+ } as WalletExchangeEntry;
+}
+
+function makeBalanceContext(exchanges: WalletExchangeEntry[]): {
+ wex: WalletExecutionContext;
+ tx: WalletDbTransaction;
+} {
+ const detailsByUrl = new Map(
+ exchanges.map((x) => [
+ x.baseUrl,
+ {
+ exchangeBaseUrl: x.baseUrl,
+ currency: "TESTKUDOS",
+ masterPublicKey: `master-pub-${x.baseUrl}`,
+ } as WalletExchangeDetails,
+ ]),
+ );
+ const tx = {
+ async getDonationSummaries() {
+ return [];
+ },
+ async getExchanges() {
+ return exchanges;
+ },
+ async getExchangeDetails(baseUrl: string) {
+ return detailsByUrl.get(baseUrl);
+ },
+ async getExchangeScopeInfo(baseUrl: string, currency: string) {
+ return { type: ScopeType.Exchange, currency, url: baseUrl };
+ },
+ async getCoinAvailabilities() {
+ return [];
+ },
+ async getActiveRefreshGroups() {
+ return [];
+ },
+ async getActiveWithdrawalGroups() {
+ return [];
+ },
+ async getActivePeerPushDebits() {
+ return [];
+ },
+ async getActivePeerPushCredits() {
+ return [];
+ },
+ async getActivePeerPullCredits() {
+ return [];
+ },
+ async getActivePeerPullDebits() {
+ return [];
+ },
+ async getActivePurchases() {
+ return [];
+ },
+ async getActiveDepositGroups() {
+ return [];
+ },
+ } as unknown as WalletDbTransaction;
+ const wex = {
+ ws: { devExperimentState: {} },
+ } as WalletExecutionContext;
+ return { wex, tx };
+}
+
+test("haveProdBalance classifies demo, test, and production exchanges", async () => {
+ const cases: Array<{
+ name: string;
+ exchanges: WalletExchangeEntry[];
+ expected: boolean;
+ }> = [
+ { name: "empty wallet", exchanges: [], expected: false },
+ {
+ name: "builtin test exchange without persisted type",
+ exchanges: [makeExchange("https://exchange.test.taler.net/")],
+ expected: false,
+ },
+ {
+ name: "builtin test exchange overrides stale production type",
+ exchanges: [makeExchange("https://exchange.test.taler.net/", "prod")],
+ expected: false,
+ },
+ {
+ name: "demo exchange",
+ exchanges: [makeExchange("https://exchange.demo.taler.net/")],
+ expected: false,
+ },
+ {
+ name: "builtin production exchange",
+ exchanges: [makeExchange("https://exchange.taler-ops.ch/")],
+ expected: true,
+ },
+ {
+ name: "unclassified custom exchange",
+ exchanges: [makeExchange("https://exchange.example/")],
+ expected: true,
+ },
+ {
+ name: "test and production exchanges",
+ exchanges: [
+ makeExchange("https://exchange.test.taler.net/"),
+ makeExchange("https://exchange.example/"),
+ ],
+ expected: true,
+ },
+ ];
+
+ for (const testCase of cases) {
+ const { wex, tx } = makeBalanceContext(testCase.exchanges);
+ const result = await getBalancesInsideTransaction(wex, tx);
+ assert.strictEqual(
+ result.haveProdBalance,
+ testCase.expected,
+ testCase.name,
+ );
+ }
+});
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -93,6 +93,7 @@ import {
WithdrawalRecordType,
WalletDonationSummary,
} from "./db-common.js";
+import { getEffectiveExchangeType } from "./builtin-exchanges.js";
import {} from "./db-indexeddb.js";
import { WalletDbTransaction } from "./dbtx.js";
import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
@@ -113,7 +114,6 @@ interface WalletBalance {
disablePeerPayments: boolean;
disableDirectDeposits: boolean;
shoppingUrls: Set<string>;
- isProd: boolean;
}
function computeRefreshGroupAvailableAmountForExchanges(
@@ -174,7 +174,6 @@ function getScopeSortingOrder(scopeInfo: ScopeInfo): number {
}
class BalancesStore {
- private nonDemoExchanges: Set<string> = new Set();
private exchangeScopeCache: Record<string, ScopeInfo> = {};
private balanceStore: Record<string, WalletBalance> = {};
private donauSummaryItems: DonauSummaryItem[] | undefined = undefined;
@@ -184,10 +183,6 @@ class BalancesStore {
private tx: WalletDbTransaction,
) {}
- setNonDemoExchange(baseUrl: string) {
- this.nonDemoExchanges.add(baseUrl);
- }
-
/**
* Add amount to a balance field, both for
* the slicing by exchange and currency.
@@ -235,10 +230,8 @@ class BalancesStore {
disablePeerPayments: false,
disableDirectDeposits: false,
shoppingUrls: new Set(),
- isProd: false,
};
}
- b.isProd = b.isProd || this.nonDemoExchanges.has(exchangeBaseUrl);
return this.balanceStore[balanceKey];
}
@@ -376,10 +369,10 @@ class BalancesStore {
});
}
- toBalancesResponse(): BalancesResponse {
+ toBalancesResponse(haveProdBalance: boolean): BalancesResponse {
const balancesResponse: BalancesResponse = {
balances: [],
- haveProdBalance: false,
+ haveProdBalance,
};
if (this.donauSummaryItems) {
@@ -427,9 +420,6 @@ class BalancesStore {
} else {
disablePeerPayments = v.disablePeerPayments;
}
- if (v.isProd) {
- balancesResponse.haveProdBalance = true;
- }
balancesResponse.balances.push({
scopeInfo: v.scopeInfo,
available: Amounts.stringify(v.available),
@@ -453,6 +443,7 @@ export async function getBalancesInsideTransaction(
tx: WalletDbTransaction,
): Promise<BalancesResponse> {
const balanceStore: BalancesStore = new BalancesStore(wex, tx);
+ let haveProdBalance = false;
const donationSummaries = await tx.getDonationSummaries();
for (const rec of donationSummaries) {
@@ -467,8 +458,12 @@ export async function getBalancesInsideTransaction(
) {
const det = await tx.getExchangeDetails(ex.baseUrl);
if (det) {
- if (ex.presetType == null || ex.presetType === "prod") {
- balanceStore.setNonDemoExchange(ex.baseUrl);
+ const exchangeType = getEffectiveExchangeType(
+ ex.baseUrl,
+ ex.presetType,
+ );
+ if (exchangeType !== "demo" && exchangeType !== "test") {
+ haveProdBalance = true;
}
await balanceStore.addZero(det.currency, ex.baseUrl);
if (ex.peerPaymentsDisabled) {
@@ -798,7 +793,7 @@ export async function getBalancesInsideTransaction(
}
}
- return balanceStore.toBalancesResponse();
+ return balanceStore.toBalancesResponse(haveProdBalance);
}
/**
diff --git a/packages/taler-wallet-core/src/builtin-exchanges.ts b/packages/taler-wallet-core/src/builtin-exchanges.ts
@@ -16,11 +16,13 @@
import { CurrencySpecification } from "@gnu-taler/taler-util";
+export type BuiltinExchangeType = "demo" | "prod" | "test";
+
export interface BuiltinExchange {
exchangeBaseUrl: string;
currencyHint: string;
currencySpec?: CurrencySpecification;
- type: "demo" | "prod" | "test";
+ type: BuiltinExchangeType;
versionAdded: number;
}
@@ -58,3 +60,17 @@ export const builtinExchanges: BuiltinExchange[] = [
},
},
];
+
+/**
+ * Return the exchange type from the builtin catalog when available, otherwise
+ * fall back to the type stored on the wallet's exchange entry.
+ */
+export function getEffectiveExchangeType(
+ exchangeBaseUrl: string,
+ presetType: string | undefined,
+): string | undefined {
+ return (
+ builtinExchanges.find((x) => x.exchangeBaseUrl === exchangeBaseUrl)?.type ??
+ presetType
+ );
+}
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -123,7 +123,11 @@ import {
HttpRequestLibrary,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
-import { builtinExchanges, type BuiltinExchange } from "./builtin-exchanges.js";
+import {
+ builtinExchanges,
+ getEffectiveExchangeType,
+ type BuiltinExchange,
+} from "./builtin-exchanges.js";
import {
PendingTaskType,
TaskIdStr,
@@ -3560,7 +3564,10 @@ export async function listWithdrawalExchangeCandidates(
if (req.presetOnly) {
return builtin?.type === "prod";
}
- const presetType = builtin?.type ?? record.presetType;
+ const presetType = getEffectiveExchangeType(
+ item.exchangeBaseUrl,
+ record.presetType,
+ );
if (
presetType === "demo" ||
presetType === "test" ||