taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-exchange-management.ts | 12+++++++-----
Mpackages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts | 14++++++--------
Mpackages/taler-util/src/types-taler-wallet.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/taler-wallet-cli/src/index.ts | 2+-
Apackages/taler-wallet-core/src/diagnostics.test.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/diagnostics.ts | 438+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.test.ts | 34++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 53++++++++---------------------------------------------
Mpackages/taler-wallet-core/src/versions.ts | 2+-
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 18+++++++++++++-----
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;