commit e797e4e30f4a47175f17472b8f7ef3a29c8c97a4
parent 06c3aeca5da2c8904c2a4ae26aa21bf365a669fe
Author: Florian Dold <dold@taler.net>
Date: Tue, 1 Sep 2026 19:47:13 +0200
wallet-core: export privacy-scrubbed diagnostics reports
Diffstat:
10 files changed, 696 insertions(+), 77 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management.ts b/packages/taler-harness/src/integrationtests/test-exchange-management.ts
@@ -65,11 +65,13 @@ export async function runExchangeManagementTest(
t.assertDeepEqual(exchangesListResult3.exchanges.length, 0);
- const diag = await walletClient.call(WalletApiOperation.GetDiagnostics, {});
- t.assertDeepEqual(diag.idbObjectStoreCounts?.exchanges, 0);
- t.assertDeepEqual(diag.idbObjectStoreCounts?.denominations, 0);
- t.assertDeepEqual(diag.idbObjectStoreCounts?.denominationFamilies, 0);
- t.assertDeepEqual(diag.idbObjectStoreCounts?.exchangeSignKeys, 0);
+ const diag = JSON.parse(
+ await walletClient.call(WalletApiOperation.GetDiagnostics, {}),
+ );
+ t.assertDeepEqual(diag.database.recordCounts.exchanges, 0);
+ t.assertDeepEqual(diag.database.recordCounts.denominations, 0);
+ t.assertDeepEqual(diag.database.recordCounts.denominationFamilies, 0);
+ t.assertDeepEqual(diag.database.recordCounts.exchangeSignKeys, 0);
// Check for regression: Can we re-add a deleted exchange?
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts
@@ -76,9 +76,8 @@ export async function runWalletExchangeMigrationTest(t: GlobalTestState) {
{},
);
- const diagBefore = await newTargetWallet.call(
- WalletApiOperation.GetDiagnostics,
- {},
+ const diagBefore = JSON.parse(
+ await newTargetWallet.call(WalletApiOperation.GetDiagnostics, {}),
);
await exchange.stop();
@@ -156,13 +155,12 @@ export async function runWalletExchangeMigrationTest(t: GlobalTestState) {
// old URL is invisible afterwards and the next /keys makes a second copy --
// leaving the denominations of live coins pointing at a family that a later
// purge of the old URL would cascade-delete.
- const diagAfter = await newTargetWallet.call(
- WalletApiOperation.GetDiagnostics,
- {},
+ const diagAfter = JSON.parse(
+ await newTargetWallet.call(WalletApiOperation.GetDiagnostics, {}),
);
t.assertDeepEqual(
- diagAfter.idbObjectStoreCounts?.["denominationFamilies"],
- diagBefore.idbObjectStoreCounts?.["denominationFamilies"],
+ diagAfter.database.recordCounts.denominationFamilies,
+ diagBefore.database.recordCounts.denominationFamilies,
);
const transactions = await newTargetWallet.call(
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -4113,20 +4113,60 @@ export interface TestingGetDenomStatsResponse {
numLost: number;
}
-export interface TestingGetDiagnosticsResponse {
- version: 0;
- /**
- * Statistics about the size of object stores.
- */
- idbObjectStoreCounts?: Record<string, number>;
- exchangeEntries: {
- exchangeBaseUrl: string;
- numDenoms: number;
- numWithdrawableDenoms: number;
- numCandidateWithdrawableDenoms: number;
- }[];
+export type DiagnosticsFormat = "json" | "yaml";
+
+export interface DiagnosticsFrontendInfo {
+ name: string;
+ version: string;
+ platform?: string;
}
+export interface GetDiagnosticsRequest {
+ /** Serialized output format. Defaults to JSON. */
+ format?: DiagnosticsFormat;
+
+ /** Maximum number of newest transactions to include. Defaults to 100. */
+ transactionLimit?: number;
+
+ /** Information supplied by the wallet frontend invoking wallet-core. */
+ frontendInfo?: DiagnosticsFrontendInfo;
+}
+
+/**
+ * A serialized diagnostics report in the requested format.
+ *
+ * Returning the report as a string lets clients save it without duplicating
+ * formatters or depending on the report's internal schema.
+ */
+export type GetDiagnosticsResponse = string;
+
+export const codecForGetDiagnosticsRequest = (): Codec<GetDiagnosticsRequest> =>
+ buildCodecForObject<GetDiagnosticsRequest>()
+ .property(
+ "format",
+ codecOptional(
+ codecForEither(
+ codecForConstString("json"),
+ codecForConstString("yaml"),
+ ),
+ ),
+ )
+ .property("transactionLimit", codecOptional(codecForNumber()))
+ .property(
+ "frontendInfo",
+ codecOptional(
+ buildCodecForObject<DiagnosticsFrontendInfo>()
+ .property("name", codecForString())
+ .property("version", codecForString())
+ .property("platform", codecOptional(codecForString()))
+ .build("DiagnosticsFrontendInfo"),
+ ),
+ )
+ .build("GetDiagnosticsRequest");
+
+/** @deprecated Use {@link GetDiagnosticsResponse}. */
+export type TestingGetDiagnosticsResponse = GetDiagnosticsResponse;
+
export interface TestingGetFlightRecordsResponse {
flightRecords: FlightRecordEntry[];
}
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -3616,7 +3616,7 @@ advancedCli
WalletApiOperation.GetDiagnostics,
{},
);
- console.log(j2s(diagResp));
+ process.stdout.write(diagResp);
});
});
diff --git a/packages/taler-wallet-core/src/diagnostics.test.ts b/packages/taler-wallet-core/src/diagnostics.test.ts
@@ -0,0 +1,136 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import {
+ ScopeType,
+ TalerErrorCode,
+ TransactionAction,
+ TransactionMajorState,
+ TransactionType,
+ type Transaction,
+} from "@gnu-taler/taler-util";
+import assert from "node:assert";
+import { test } from "node:test";
+import {
+ makeDiagnosticTransactionSection,
+ redactDiagnosticBankAccount,
+ scrubDiagnosticTransactions,
+} from "./diagnostics.js";
+
+const secret = "MUST-NOT-APPEAR-IN-DIAGNOSTICS";
+
+function commonTransaction(
+ type: TransactionType,
+ transactionId: string,
+): Record<string, unknown> {
+ return {
+ transactionId,
+ type,
+ timestamp: { t_s: 1_700_000_000 },
+ scopes: [
+ {
+ type: ScopeType.Exchange,
+ currency: "KUDOS",
+ url: "https://exchange.example/",
+ },
+ ],
+ txState: { major: TransactionMajorState.Pending },
+ stId: 123,
+ txActions: [TransactionAction.Retry],
+ amountRaw: "KUDOS:1",
+ amountEffective: "KUDOS:0.9",
+ };
+}
+
+test("transaction diagnostics use a strict privacy whitelist", () => {
+ const paymentId = `txn:payment:${secret}`;
+ const refundId = `txn:refund:${secret}-refund`;
+ const payment = {
+ ...commonTransaction(TransactionType.Payment, paymentId),
+ localTransactionId: "#payment:42",
+ info: {
+ summary: secret,
+ merchant: { name: secret },
+ fulfillmentUrl: `https://${secret}.example/`,
+ },
+ contractTerms: { private: secret },
+ refunds: [{ transactionId: refundId }],
+ error: {
+ code: TalerErrorCode.WALLET_NETWORK_ERROR,
+ requestUrl: `https://${secret}.example/`,
+ innerError: { code: TalerErrorCode.GENERIC_TIMEOUT, hint: secret },
+ },
+ kycAccessToken: secret,
+ } as unknown as Transaction;
+ const refund = {
+ ...commonTransaction(TransactionType.Refund, refundId),
+ refundedTransactionId: paymentId,
+ paymentInfo: { summary: secret, merchant: { name: secret } },
+ } as unknown as Transaction;
+
+ const scrubbed = scrubDiagnosticTransactions([payment, refund]);
+ assert.deepStrictEqual(scrubbed[0].relatedTransactions, [
+ { type: "refund", transactionId: "transaction-2" },
+ ]);
+ assert.deepStrictEqual(scrubbed[1].relatedTransactions, [
+ { type: "refund-of", transactionId: "transaction-1" },
+ ]);
+ assert.strictEqual(scrubbed[0].localTransactionId, "#payment:42");
+ assert.deepStrictEqual(
+ scrubbed[0].errorCodes,
+ [TalerErrorCode.GENERIC_TIMEOUT, TalerErrorCode.WALLET_NETWORK_ERROR].sort(
+ (a, b) => a - b,
+ ),
+ );
+ const serialized = JSON.stringify(scrubbed);
+ assert.ok(!serialized.includes(secret));
+ assert.ok(!serialized.includes("contractTerms"));
+ assert.ok(!serialized.includes("kycAccessToken"));
+ assert.ok(!serialized.includes("requestUrl"));
+});
+
+test("transaction diagnostics report their bound and truncation", () => {
+ const transactions = ["one", "two"].map(
+ (id) =>
+ commonTransaction(
+ TransactionType.Deposit,
+ `txn:deposit:${id}`,
+ ) as unknown as Transaction,
+ );
+ const section = makeDiagnosticTransactionSection(transactions, 1);
+ assert.strictEqual(section.limit, 1);
+ assert.strictEqual(section.returned, 1);
+ assert.strictEqual(section.truncated, true);
+ assert.strictEqual(section.entries.length, 1);
+});
+
+test("bank account diagnostics retain only a six-character suffix", () => {
+ assert.deepStrictEqual(
+ redactDiagnosticBankAccount(
+ `payto://iban/CH9300762011623852957?receiver-name=${secret}`,
+ ),
+ {
+ paytoType: "iban",
+ accountSuffix: "852957",
+ },
+ );
+ assert.deepStrictEqual(
+ redactDiagnosticBankAccount(
+ `payto://x-taler-bank/bank.example/alice-${secret}?receiver-name=${secret}`,
+ ),
+ {
+ paytoType: "x-taler-bank",
+ provider: "https://bank.example/",
+ accountSuffix: "OSTICS",
+ },
+ );
+ const malformed = redactDiagnosticBankAccount(
+ `not-a-payto/${secret}?receiver-name=${secret}`,
+ );
+ assert.strictEqual(malformed.accountSuffix, "OSTICS");
+ assert.ok(!JSON.stringify(malformed).includes("receiver-name"));
+});
diff --git a/packages/taler-wallet-core/src/diagnostics.ts b/packages/taler-wallet-core/src/diagnostics.ts
@@ -0,0 +1,438 @@
+/*
+ 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.
+
+ SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import {
+ AbsoluteTime,
+ CoinStatus,
+ DiagnosticsFrontendInfo,
+ GetDiagnosticsRequest,
+ PaytoType,
+ Paytos,
+ Result,
+ ScopeType,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ Transaction,
+ TransactionAction,
+ TransactionState,
+ TransactionType,
+ WalletCoreVersion,
+ stringifyYaml,
+} from "@gnu-taler/taler-util";
+import {
+ getExchangeEntryStatusFromRecord,
+ getExchangeTosStatusFromRecord,
+ getExchangeUpdateStatusFromRecord,
+} from "./common.js";
+import { denomRefKey } from "./wallet.js";
+import { WalletExecutionContext } from "./wallet.js";
+import {
+ isCandidateWithdrawableDenomRec,
+ isWithdrawableDenom,
+} from "./denominations.js";
+import { getAllDenominationsForExchange } from "./exchanges.js";
+import { timestampOptionalPreciseFromDb } from "./db/timestamps.js";
+import { getTransactionsV2 } from "./transactions.js";
+
+export const DEFAULT_DIAGNOSTICS_TRANSACTION_LIMIT = 100;
+
+interface DiagnosticsTransactionRelation {
+ type: "originated-by" | "refund" | "refund-of" | "repurchase-of";
+ transactionId: string;
+}
+
+export interface DiagnosticsTransaction {
+ reportTransactionId: string;
+ localTransactionId?: string;
+ type: TransactionType;
+ timestamp: string;
+ txState: TransactionState;
+ stId: number;
+ txActions: TransactionAction[];
+ amountRaw: string;
+ amountEffective: string;
+ exchangeBaseUrls: string[];
+ errorCodes: number[];
+ relatedTransactions?: DiagnosticsTransactionRelation[];
+}
+
+interface DiagnosticsExchange {
+ exchangeBaseUrl: string;
+ currency: string;
+ source?: string;
+ exchangeEntryStatus: string;
+ exchangeUpdateStatus: string;
+ tosStatus: string;
+ lastUpdate?: string;
+ lastWithdrawal?: string;
+ peerPaymentsDisabled: boolean;
+ directDepositsDisabled: boolean;
+ noFees: boolean;
+ numDenoms: number;
+ numWithdrawableDenoms: number;
+ numCandidateWithdrawableDenoms: number;
+ errorCodes: number[];
+}
+
+interface DiagnosticsCoinGroup {
+ exchangeBaseUrl: string;
+ denominationValue: string;
+ maxAge: number;
+ status: CoinStatus;
+ visible: boolean;
+ count: number;
+}
+
+interface DiagnosticsBankAccount {
+ paytoType: string;
+ provider?: string;
+ accountSuffix: string;
+ currencies?: string[];
+ kycCompleted: boolean;
+}
+
+export interface WalletDiagnosticsReportV1 {
+ formatVersion: 1;
+ generatedAt: string;
+ walletCoreVersion: WalletCoreVersion;
+ frontendInfo?: DiagnosticsFrontendInfo;
+ database: {
+ backend: string;
+ recordCounts: Record<string, number>;
+ };
+ exchanges: DiagnosticsExchange[];
+ coins: {
+ total: number;
+ groups: DiagnosticsCoinGroup[];
+ };
+ bankAccounts: DiagnosticsBankAccount[];
+ transactions: {
+ limit: number;
+ returned: number;
+ truncated: boolean;
+ entries: DiagnosticsTransaction[];
+ };
+}
+
+function toIsoTimestamp(
+ timestamp: Parameters<typeof AbsoluteTime.fromPreciseTimestamp>[0],
+): string {
+ return AbsoluteTime.toIsoString(AbsoluteTime.fromPreciseTimestamp(timestamp));
+}
+
+function optionalDbTimestampToIso(
+ timestamp: Parameters<typeof timestampOptionalPreciseFromDb>[0],
+): string | undefined {
+ const precise = timestampOptionalPreciseFromDb(timestamp);
+ return precise ? toIsoTimestamp(precise) : undefined;
+}
+
+function collectErrorCodes(
+ ...details: Array<TalerErrorDetail | undefined>
+): number[] {
+ const result = new Set<number>();
+ const seen = new Set<unknown>();
+ const visit = (value: unknown): void => {
+ if (value === null || typeof value !== "object" || seen.has(value)) return;
+ seen.add(value);
+ if (
+ "code" in value &&
+ typeof (value as { code?: unknown }).code === "number"
+ ) {
+ result.add((value as { code: number }).code);
+ }
+ for (const nested of Object.values(value)) visit(nested);
+ };
+ for (const detail of details) visit(detail);
+ return [...result].sort((a, b) => a - b);
+}
+
+function transactionExchangeBaseUrls(tx: Transaction): string[] {
+ const urls = new Set<string>();
+ for (const scope of tx.scopes) {
+ if (
+ scope.type === ScopeType.Exchange ||
+ scope.type === ScopeType.ExchangeLegacyKeys
+ ) {
+ urls.add(scope.url);
+ }
+ }
+ if ("exchangeBaseUrl" in tx && typeof tx.exchangeBaseUrl === "string") {
+ urls.add(tx.exchangeBaseUrl);
+ }
+ return [...urls].sort();
+}
+
+function rawTransactionRelations(tx: Transaction): Array<{
+ type: DiagnosticsTransactionRelation["type"];
+ transactionId: string;
+}> {
+ switch (tx.type) {
+ case TransactionType.Payment:
+ return [
+ ...(tx.repurchaseTransactionId
+ ? [
+ {
+ type: "repurchase-of" as const,
+ transactionId: tx.repurchaseTransactionId,
+ },
+ ]
+ : []),
+ ...tx.refunds.map((refund) => ({
+ type: "refund" as const,
+ transactionId: refund.transactionId,
+ })),
+ ];
+ case TransactionType.Refund:
+ return [{ type: "refund-of", transactionId: tx.refundedTransactionId }];
+ case TransactionType.Refresh:
+ return tx.originatingTransactionId
+ ? [
+ {
+ type: "originated-by",
+ transactionId: tx.originatingTransactionId,
+ },
+ ]
+ : [];
+ default:
+ return [];
+ }
+}
+
+export function scrubDiagnosticTransactions(
+ transactions: Transaction[],
+): DiagnosticsTransaction[] {
+ const reportIds = new Map(
+ transactions.map((tx, index) => [
+ tx.transactionId,
+ `transaction-${index + 1}`,
+ ]),
+ );
+ return transactions.map((tx) => {
+ const relations = rawTransactionRelations(tx).flatMap((relation) => {
+ const reportId = reportIds.get(
+ relation.transactionId as Transaction["transactionId"],
+ );
+ return reportId ? [{ ...relation, transactionId: reportId }] : [];
+ });
+ return {
+ reportTransactionId: reportIds.get(tx.transactionId)!,
+ ...(tx.localTransactionId
+ ? { localTransactionId: tx.localTransactionId }
+ : undefined),
+ type: tx.type,
+ timestamp: toIsoTimestamp(tx.timestamp),
+ txState: tx.txState,
+ stId: tx.stId,
+ txActions: tx.txActions,
+ amountRaw: tx.amountRaw,
+ amountEffective: tx.amountEffective,
+ exchangeBaseUrls: transactionExchangeBaseUrls(tx),
+ errorCodes: collectErrorCodes(tx.error, tx.abortReason, tx.failReason),
+ ...(relations.length > 0
+ ? { relatedTransactions: relations }
+ : undefined),
+ };
+ });
+}
+
+export function makeDiagnosticTransactionSection(
+ transactions: Transaction[],
+ limit: number,
+): WalletDiagnosticsReportV1["transactions"] {
+ const included = transactions.slice(0, limit);
+ return {
+ limit,
+ returned: included.length,
+ truncated: transactions.length > limit,
+ entries: scrubDiagnosticTransactions(included),
+ };
+}
+
+export function redactDiagnosticBankAccount(paytoUri: string): {
+ paytoType: string;
+ provider?: string;
+ accountSuffix: string;
+} {
+ const parsed = Result.orUndefined(Paytos.fromString(paytoUri));
+ if (!parsed) {
+ const withoutQuery = paytoUri.split("?", 1)[0];
+ const targetMatch = /^payto:\/\/([^/]+)/i.exec(withoutQuery);
+ return {
+ paytoType: targetMatch?.[1] ?? "malformed",
+ accountSuffix: withoutQuery.split("/").at(-1)?.slice(-6) ?? "",
+ };
+ }
+ let account = parsed.normalizedPath;
+ let provider: string | undefined;
+ switch (parsed.targetType) {
+ case PaytoType.IBAN:
+ account = parsed.iban;
+ break;
+ case PaytoType.TalerBank:
+ case PaytoType.Cyclos:
+ account = parsed.account;
+ provider = parsed.url;
+ break;
+ case PaytoType.Bitcoin:
+ case PaytoType.Ethereum:
+ account = parsed.address;
+ break;
+ }
+ return {
+ paytoType: parsed.targetType ?? "unsupported",
+ ...(provider ? { provider } : undefined),
+ accountSuffix: account.slice(-6),
+ };
+}
+
+export async function buildWalletDiagnosticsReport(
+ wex: WalletExecutionContext,
+ walletCoreVersion: WalletCoreVersion,
+ request: GetDiagnosticsRequest,
+): Promise<WalletDiagnosticsReportV1> {
+ const limit =
+ request.transactionLimit ?? DEFAULT_DIAGNOSTICS_TRANSACTION_LIMIT;
+ if (
+ !Number.isSafeInteger(limit) ||
+ limit < 0 ||
+ limit >= Number.MAX_SAFE_INTEGER
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "transactionLimit" },
+ "transactionLimit must be a non-negative safe integer smaller than Number.MAX_SAFE_INTEGER",
+ );
+ }
+
+ const transactionResponse = await getTransactionsV2(wex, {
+ includeAll: true,
+ limit: -(limit + 1),
+ });
+
+ const databaseReport = await wex.runWalletDbTx(async (tx) => {
+ const counts = await tx.getRecordCounts();
+ const exchanges: DiagnosticsExchange[] = [];
+ for (const exchange of await tx.getExchanges()) {
+ const details = await tx.getExchangeDetails(exchange.baseUrl);
+ const denoms = await getAllDenominationsForExchange(tx, exchange.baseUrl);
+ exchanges.push({
+ exchangeBaseUrl: exchange.baseUrl,
+ currency: details?.currency ?? exchange.presetCurrencyHint ?? "UNKNOWN",
+ ...(exchange.source ? { source: exchange.source } : undefined),
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(exchange),
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(exchange),
+ tosStatus: getExchangeTosStatusFromRecord(exchange),
+ ...(exchange.lastUpdate
+ ? { lastUpdate: optionalDbTimestampToIso(exchange.lastUpdate) }
+ : undefined),
+ ...(exchange.lastWithdrawal
+ ? {
+ lastWithdrawal: optionalDbTimestampToIso(exchange.lastWithdrawal),
+ }
+ : undefined),
+ peerPaymentsDisabled: exchange.peerPaymentsDisabled ?? false,
+ directDepositsDisabled: exchange.directDepositDisabled ?? false,
+ noFees: exchange.noFees ?? false,
+ numDenoms: denoms.length,
+ numWithdrawableDenoms: denoms.filter(isWithdrawableDenom).length,
+ numCandidateWithdrawableDenoms: denoms.filter(
+ isCandidateWithdrawableDenomRec,
+ ).length,
+ errorCodes: collectErrorCodes(exchange.unavailableReason),
+ });
+ }
+
+ const coins = await tx.listAllCoins();
+ const denominations = await tx.getDenominationsByRefs(coins);
+ const denominationsByRef = new Map(
+ denominations.map((denom) => [denomRefKey(denom), denom]),
+ );
+ const coinGroups = new Map<string, DiagnosticsCoinGroup>();
+ for (const coin of coins) {
+ const denomination = denominationsByRef.get(denomRefKey(coin));
+ const denominationValue = denomination?.value ?? "UNKNOWN";
+ const group: DiagnosticsCoinGroup = {
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ denominationValue,
+ maxAge: coin.maxAge,
+ status: coin.status,
+ visible: !!coin.visible,
+ count: 0,
+ };
+ const key = JSON.stringify(group);
+ const existing = coinGroups.get(key);
+ if (existing) existing.count++;
+ else coinGroups.set(key, { ...group, count: 1 });
+ }
+
+ const bankAccounts: DiagnosticsBankAccount[] = [];
+ for (const account of await tx.listBankAccounts()) {
+ bankAccounts.push({
+ ...redactDiagnosticBankAccount(account.paytoUri),
+ ...(account.currencies
+ ? { currencies: account.currencies }
+ : undefined),
+ kycCompleted: account.kycCompleted,
+ });
+ }
+
+ return {
+ counts,
+ exchanges: exchanges.sort((a, b) =>
+ a.exchangeBaseUrl.localeCompare(b.exchangeBaseUrl),
+ ),
+ coins,
+ coinGroups: [...coinGroups.values()].sort((a, b) =>
+ JSON.stringify(a).localeCompare(JSON.stringify(b)),
+ ),
+ bankAccounts,
+ };
+ });
+
+ return {
+ formatVersion: 1,
+ generatedAt: new Date().toISOString(),
+ walletCoreVersion,
+ ...(request.frontendInfo
+ ? { frontendInfo: request.frontendInfo }
+ : undefined),
+ database: {
+ backend: wex.ws.db.name,
+ recordCounts: { ...databaseReport.counts },
+ },
+ exchanges: databaseReport.exchanges,
+ coins: {
+ total: databaseReport.coins.length,
+ groups: databaseReport.coinGroups,
+ },
+ bankAccounts: databaseReport.bankAccounts,
+ transactions: makeDiagnosticTransactionSection(
+ transactionResponse.transactions,
+ limit,
+ ),
+ };
+}
+
+export async function getSerializedWalletDiagnostics(
+ wex: WalletExecutionContext,
+ walletCoreVersion: WalletCoreVersion,
+ request: GetDiagnosticsRequest,
+): Promise<string> {
+ const report = await buildWalletDiagnosticsReport(
+ wex,
+ walletCoreVersion,
+ request,
+ );
+ if (request.format === "yaml") return stringifyYaml(report);
+ return `${JSON.stringify(report, undefined, 2)}\n`;
+}
diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts
@@ -209,6 +209,40 @@ for (const [expectedBackend, makeRunner] of backendCases) {
{ migrated: false, databaseBackend: "sqlite" },
);
}
+ const diagnosticsJson = await wallet.client.call(
+ WalletApiOperation.GetDiagnostics,
+ {
+ frontendInfo: {
+ name: "test-frontend",
+ version: "1.2.3",
+ platform: expectedBackend,
+ },
+ },
+ );
+ assert.strictEqual(typeof diagnosticsJson, "string");
+ const diagnostics = JSON.parse(diagnosticsJson);
+ assert.strictEqual(diagnostics.formatVersion, 1);
+ assert.strictEqual(diagnostics.database.backend, expectedBackend);
+ assert.strictEqual(diagnostics.transactions.limit, 100);
+ assert.strictEqual(diagnostics.transactions.returned, 0);
+ assert.strictEqual(diagnostics.transactions.truncated, false);
+ assert.strictEqual(diagnostics.frontendInfo.name, "test-frontend");
+
+ const diagnosticsYaml = await wallet.client.call(
+ WalletApiOperation.GetDiagnostics,
+ { format: "yaml", transactionLimit: 0 },
+ );
+ assert.match(diagnosticsYaml, /^"formatVersion": 1$/m);
+ assert.match(diagnosticsYaml, /^ {2}"limit": 0$/m);
+
+ await assert.rejects(
+ wallet.client.call(WalletApiOperation.GetDiagnostics, {
+ transactionLimit: -1,
+ }),
+ (error: unknown) =>
+ error instanceof TalerError &&
+ error.errorDetail.code === TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ );
await wallet.client.call(WalletApiOperation.Shutdown, {});
await wallet.client.call(WalletApiOperation.Shutdown, {});
await assert.rejects(
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -75,6 +75,8 @@ import {
GetDepositWireTypesForCurrencyResponse,
GetDepositWireTypesRequest,
GetDepositWireTypesResponse,
+ GetDiagnosticsRequest,
+ GetDiagnosticsResponse,
GetExchangeTosRequest,
GetExchangeTosResult,
GetPerformanceStatsRequest,
@@ -128,7 +130,6 @@ import {
TestingCorruptWithdrawalCoinSelRequest,
TestingGetDenomStatsRequest,
TestingGetDenomStatsResponse,
- TestingGetDiagnosticsResponse,
TestingGetFlightRecordsResponse,
TestingGetReserveHistoryRequest,
TestingSetTimetravelRequest,
@@ -195,6 +196,7 @@ import {
codecForGetDefaultExchangesRequest,
codecForGetDepositWireTypesForCurrencyRequest,
codecForGetDepositWireTypesRequest,
+ codecForGetDiagnosticsRequest,
codecForGetDonauStatementsRequest,
codecForGetExchangeEntryByUrlRequest,
codecForGetExchangeResourcesRequest,
@@ -292,11 +294,8 @@ import { walletDbFixups } from "./db/indexeddb/fixups.js";
import { IdbWalletDbHandle } from "./db/indexeddb/handle.js";
import { getWalletDbDumpBackend } from "./db/migration/import.js";
import { WalletDbTransaction } from "./db/transaction.js";
-import {
- isCandidateWithdrawableDenomRec,
- isWithdrawableDenom,
-} from "./denominations.js";
import { checkDepositGroup, createDepositGroup } from "./deposits.js";
+import { getSerializedWalletDiagnostics } from "./diagnostics.js";
import { applyDevExperiment } from "./dev-experiments.js";
import {
handleGetDonau,
@@ -2316,45 +2315,9 @@ export async function handleTestingCorruptWithdrawalCoinSel(
export async function handleGetDiagnostics(
wex: WalletExecutionContext,
- req: EmptyObject,
-): Promise<TestingGetDiagnosticsResponse> {
- const cnt: Record<string, number> = {};
- const exchangeEntries: TestingGetDiagnosticsResponse["exchangeEntries"] = [];
- await wex.runWalletDbTx(async (tx) => {
- const counts = await tx.getRecordCounts();
- cnt["coinAvailability"] = counts.coinAvailability;
- cnt["coins"] = counts.coins;
- cnt["denominationFamilies"] = counts.denominationFamilies;
- cnt["denominations"] = counts.denominations;
- cnt["exchangeDetails"] = counts.exchangeDetails;
- cnt["exchangeSignKeys"] = counts.exchangeSignKeys;
- cnt["exchanges"] = counts.exchanges;
- for (const exch of await tx.getExchanges()) {
- const denoms = await getAllDenominationsForExchange(tx, exch.baseUrl);
- let numWithdrawableDenoms = 0;
- let numCandidateWithdrawableDenoms = 0;
- for (let i = 0; i < denoms.length; i++) {
- const d = denoms[i];
- if (isWithdrawableDenom(d)) {
- numWithdrawableDenoms++;
- }
- if (isCandidateWithdrawableDenomRec(d)) {
- numCandidateWithdrawableDenoms++;
- }
- }
- exchangeEntries.push({
- exchangeBaseUrl: exch.baseUrl,
- numDenoms: denoms.length,
- numCandidateWithdrawableDenoms,
- numWithdrawableDenoms,
- });
- }
- });
- return {
- version: 0,
- idbObjectStoreCounts: cnt,
- exchangeEntries,
- };
+ req: GetDiagnosticsRequest,
+): Promise<GetDiagnosticsResponse> {
+ return getSerializedWalletDiagnostics(wex, await handleGetVersion(wex), req);
}
export async function handleTestingWaitExchangeReady(
@@ -2428,7 +2391,7 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
handler: handleGetFlightRecords,
},
[WalletApiOperation.GetDiagnostics]: {
- codec: codecForEmptyObject(),
+ codec: codecForGetDiagnosticsRequest(),
handler: handleGetDiagnostics,
},
[WalletApiOperation.ConvertIbanAccountFieldToPayto]: {
diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts
@@ -48,7 +48,7 @@ export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0";
/**
* Libtool version of the wallet-core API.
*/
-export const WALLET_CORE_API_PROTOCOL_VERSION = "9:0:1";
+export const WALLET_CORE_API_PROTOCOL_VERSION = "10:0:0";
/**
* Libtool rules:
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -102,6 +102,8 @@ import {
GetDepositWireTypesForCurrencyResponse,
GetDepositWireTypesRequest,
GetDepositWireTypesResponse,
+ GetDiagnosticsRequest,
+ GetDiagnosticsResponse,
GetDonauResponse,
GetDonauStatementsRequest,
GetDonauStatementsResponse,
@@ -197,7 +199,6 @@ import {
TestingCorruptWithdrawalCoinSelRequest,
TestingGetDenomStatsRequest,
TestingGetDenomStatsResponse,
- TestingGetDiagnosticsResponse,
TestingGetFlightRecordsResponse,
TestingGetReserveHistoryRequest,
TestingPlanMigrateExchangeBaseUrlRequest,
@@ -1681,12 +1682,15 @@ export type TestingRunFixupOp = {
response: EmptyObject;
};
-export type TestingGetDiagnosticsOp = {
+export type GetDiagnosticsOp = {
op: WalletApiOperation.GetDiagnostics;
- request: EmptyObject;
- response: TestingGetDiagnosticsResponse;
+ request: GetDiagnosticsRequest;
+ response: GetDiagnosticsResponse;
};
+/** @deprecated Use {@link GetDiagnosticsOp}. */
+export type TestingGetDiagnosticsOp = GetDiagnosticsOp;
+
export type TestingGetFlightRecordsOp = {
op: WalletApiOperation.TestingGetFlightRecords;
request: EmptyObject;
@@ -1733,6 +1737,10 @@ export type ForceRefreshOp = {
* to be an exhaustive catalogue of everything an operation can raise.
*/
export const walletApiExpectedErrors = {
+ [WalletApiOperation.GetDiagnostics]: [
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ ],
+
// --- Transactions -------------------------------------------------------
[WalletApiOperation.GetTransactionById]: [
@@ -2166,7 +2174,7 @@ export type WalletOperations = {
[WalletApiOperation.SendTalerUriMailboxMessage]: SendTalerUriMailboxMessageOp;
[WalletApiOperation.ConvertIbanAccountFieldToPayto]: ConvertIbanAccountFieldToPaytoOp;
[WalletApiOperation.ConvertIbanPaytoToAccountField]: ConvertIbanPaytoToAccountFieldOp;
- [WalletApiOperation.GetDiagnostics]: TestingGetDiagnosticsOp;
+ [WalletApiOperation.GetDiagnostics]: GetDiagnosticsOp;
[WalletApiOperation.TestingGetPerformanceStats]: GetPerformanceStatsOp;
[WalletApiOperation.TestingGetFlightRecords]: TestingGetFlightRecordsOp;
[WalletApiOperation.GetDefaultExchanges]: GetDefaultExchangesOp;