taler-typescript-core

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

commit 3b4abef14cc196415b106ddc12794a9b4e78aacc
parent ade2bece8c11877989c78ef5df98e3d7faa8c831
Author: Florian Dold <dold@taler.net>
Date:   Mon, 10 Aug 2026 00:13:59 +0200

wallet-cli: add actionable transaction listing

Diffstat:
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 25+++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/index.ts | 51++++++++++++++++++++++++++++++++++++++++++++++++---
Apackages/taler-wallet-cli/src/transactions-pretty.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/transactions-pretty.ts | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/txref.ts | 2+-
Mpackages/taler-wallet-core/src/db-converter.test.ts | 4++++
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 13+++++++++++++
Mpackages/taler-wallet-core/src/dbtx-indexeddb.ts | 16++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx.ts | 14++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 6++++++
Mpackages/taler-wallet-core/src/transactions.ts | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 14++++++++++++++
13 files changed, 559 insertions(+), 11 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -298,6 +298,16 @@ export interface TransactionCommon { // and for invoking actions on the transaction (e.g. deleting/hiding it from the history) transactionId: TransactionIdStr; + /** + * Short identifier assigned by this wallet for local, human-facing use. + * + * It has the form `txn#${type}#${localIdent}`. It is intentionally not + * portable: importing or merging a wallet can assign different local + * identifiers. Clients must use transactionId when they need a stable ID. + * Undefined when the active wallet backend does not support local IDs. + */ + localTransactionId?: string; + // the type of the transaction; different types might provide additional information type: TransactionType; @@ -975,6 +985,15 @@ export interface TransactionByIdRequest { includeContractTerms?: boolean; } +/** Resolve a wallet-local transaction identifier to its stable identifier. */ +export interface ResolveTransactionReferenceRequest { + transactionReference: string; +} + +export interface ResolveTransactionReferenceResponse { + transactionId: TransactionIdStr; +} + export const codecForTransactionByIdRequest = (): Codec<TransactionByIdRequest> => buildCodecForObject<TransactionByIdRequest>() @@ -982,6 +1001,12 @@ export const codecForTransactionByIdRequest = .property("includeContractTerms", codecOptional(codecForBoolean())) .build("TransactionByIdRequest"); +export const codecForResolveTransactionReferenceRequest = + (): Codec<ResolveTransactionReferenceRequest> => + buildCodecForObject<ResolveTransactionReferenceRequest>() + .property("transactionReference", codecForString()) + .build("ResolveTransactionReferenceRequest"); + export const codecForGetTransactionsV2Request = (): Codec<GetTransactionsV2Request> => buildCodecForObject<GetTransactionsV2Request>() diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -103,6 +103,7 @@ import { parseTxStateSpec, TX_STATE_SPEC_SYNTAX, } from "./waitspec.js"; +import { formatPrettyTransaction } from "./transactions-pretty.js"; import * as fs from "node:fs"; @@ -842,6 +843,14 @@ async function resolveTxRef( ): Promise<TransactionIdStr> { const ref = parseTxRef(arg); if (ref.kind === "id") { + if (ref.transactionId.startsWith("txn#")) { + const resolved = await ctx.client.call( + WalletApiOperation.ResolveTransactionReference, + { transactionReference: ref.transactionId }, + ); + console.error(`${ref.transactionId} -> ${resolved.transactionId}`); + return resolved.transactionId; + } return ref.transactionId; } // A negative limit returns the newest transactions first, so only the @@ -882,7 +891,19 @@ const transactionsCli = walletCli ", ", )}).`, }) - .flag("includeRefreshes", ["--include-refreshes"]); + .flag("includeRefreshes", ["--include-refreshes"]) + .flag("json", ["--json"], { + help: "Print JSON, even when stdout is a terminal.", + }) + .flag("pretty", ["--pretty"], { + help: "Print a human-readable transaction list, even when stdout is not a terminal.", + }) + .flag("verbose", ["-v", "--verbose"], { + help: "Include transaction IDs, times and diagnostic details in pretty output.", + }) + .flag("online", ["--online"], { + help: "Run the wallet task loop while listing transactions.", + }); // Default action transactionsCli.action(async (args) => { @@ -900,7 +921,7 @@ transactionsCli.action(async (args) => { await withWallet( args, { - lazyTaskLoop: true, + lazyTaskLoop: !args.transactions.online, }, async (wallet) => { const pending = await wallet.client.call( @@ -913,7 +934,31 @@ transactionsCli.action(async (args) => { | undefined, }, ); - console.log(JSON.stringify(pending, undefined, 2)); + // A terminal gets a useful overview, while pipes retain the stable + // machine-readable form. Either flag explicitly selects its format; + // --json wins if a caller accidentally passes both. + const pretty = + !args.transactions.json && + (args.transactions.pretty || process.stdout.isTTY === true); + if (!pretty) { + console.log(JSON.stringify(pending, undefined, 2)); + return; + } + if (pending.transactions.length === 0) { + console.log("No transactions."); + return; + } + const verbose = args.transactions.verbose || args.wallet.verbose; + const rendered = await Promise.all( + pending.transactions.map(async (tx) => + formatPrettyTransaction( + tx, + await continuationForTx(wallet, tx), + verbose, + ), + ), + ); + console.log(rendered.map((lines) => lines.join("\n")).join("\n\n")); }, ); }); diff --git a/packages/taler-wallet-cli/src/transactions-pretty.test.ts b/packages/taler-wallet-cli/src/transactions-pretty.test.ts @@ -0,0 +1,86 @@ +/* + 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 for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import { + Transaction, + TransactionMajorState, + TransactionMinorState, + TransactionType, +} from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { ContinuationKind } from "./continuation.js"; +import { formatPrettyTransaction } from "./transactions-pretty.js"; + +function makeTx(extra: Record<string, unknown> = {}): Transaction { + return { + transactionId: "txn:payment:example", + type: TransactionType.Payment, + timestamp: { t_s: 1 }, + scopes: [], + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + stId: 42, + txActions: [], + amountRaw: "KUDOS:5", + amountEffective: "KUDOS:5.01", + info: { + summary: "Lunch", + merchant: { name: "The Café" }, + }, + ...extra, + } as unknown as Transaction; +} + +test("pretty output leads a waiting user to continue the transaction", () => { + const lines = formatPrettyTransaction( + makeTx({ localTransactionId: "txn#payment#7" }), + { + kind: ContinuationKind.ConfirmPayment, + actionable: true, + automatic: true, + summary: "the merchant's offer is waiting to be accepted", + }, + ); + + assert.deepStrictEqual(lines, [ + "txn#payment#7 payment KUDOS:5.01 [dialog:proposed]", + " Lunch — The Café", + " Next: the merchant's offer is waiting to be accepted", + " Run: taler-wallet transactions continue txn#payment#7", + ]); +}); + +test("pretty output only adds diagnostic details with --verbose", () => { + const lines = formatPrettyTransaction( + makeTx(), + { + kind: ContinuationKind.Nothing, + actionable: false, + automatic: false, + summary: "the transaction is already done", + }, + true, + ); + + assert.ok(lines.includes(" ID: txn:payment:example")); + assert.ok(lines.includes(" Time: 1970-01-01T00:00:01.000Z")); + assert.ok(lines.includes(" Amount before fees: KUDOS:5")); + assert.ok(lines.includes(" Balance change: KUDOS:5.01")); + assert.ok(lines.includes(" Internal state ID: 42")); +}); diff --git a/packages/taler-wallet-cli/src/transactions-pretty.ts b/packages/taler-wallet-cli/src/transactions-pretty.ts @@ -0,0 +1,170 @@ +/* + 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 for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +/** + * @file + * Compact, useful rendering of the wallet's transaction history. + */ + +import { + AbsoluteTime, + summarizeTalerErrorDetail, + Transaction, + TransactionAction, + TransactionType, +} from "@gnu-taler/taler-util"; +import { Continuation } from "./continuation.js"; +import { formatTxState } from "./waitspec.js"; + +function descriptionOf(tx: Transaction): string | undefined { + switch (tx.type) { + case TransactionType.Payment: + return tx.info == null + ? undefined + : `${tx.info.summary} — ${tx.info.merchant.name}`; + case TransactionType.Refund: + return tx.paymentInfo?.summary; + case TransactionType.PeerPullCredit: + case TransactionType.PeerPullDebit: + case TransactionType.PeerPushCredit: + case TransactionType.PeerPushDebit: + return tx.info.summary; + case TransactionType.DenomLoss: + return tx.lossEventType; + case TransactionType.Refresh: + return `refresh of ${tx.refreshInputAmount}`; + case TransactionType.Deposit: + return "deposit to bank account"; + case TransactionType.Withdrawal: + return tx.exchangeBaseUrl == null + ? "withdrawal" + : `withdrawal from ${tx.exchangeBaseUrl}`; + case TransactionType.InternalWithdrawal: + return `withdrawal from ${tx.exchangeBaseUrl}`; + case TransactionType.Recoup: + return "funds recouped from an exchange"; + } +} + +function exchangeOf(tx: Transaction): string | undefined { + switch (tx.type) { + case TransactionType.Withdrawal: + case TransactionType.InternalWithdrawal: + case TransactionType.PeerPullCredit: + case TransactionType.PeerPullDebit: + case TransactionType.PeerPushCredit: + case TransactionType.PeerPushDebit: + case TransactionType.DenomLoss: + return tx.exchangeBaseUrl; + default: + return undefined; + } +} + +function printDetail(key: string, value: unknown): string { + return `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`; +} + +function actionCommand( + tx: Transaction, + continuation: Continuation, +): string | undefined { + if (continuation.actionable && continuation.automatic) { + return `taler-wallet transactions continue ${ + tx.localTransactionId ?? tx.transactionId + }`; + } + if (tx.txActions.includes(TransactionAction.Retry)) { + return `taler-wallet transactions retry ${ + tx.localTransactionId ?? tx.transactionId + }`; + } + return undefined; +} + +/** + * Render one transaction. Actionable transactions deliberately include both + * the explanation and a command that can be copied without relying on the + * current ordering or filters of the list. + */ +export function formatPrettyTransaction( + tx: Transaction, + continuation: Continuation, + verbose = false, +): string[] { + const lines = [ + `${tx.localTransactionId != null ? `${tx.localTransactionId} ` : ""}${ + tx.type + } ${tx.amountEffective} [${formatTxState(tx.txState)}]`, + ]; + const description = descriptionOf(tx); + if (description != null) { + lines.push(` ${description}`); + } + + if (continuation.actionable) { + lines.push(` Next: ${continuation.summary}`); + if (continuation.requiresTos) { + lines.push( + ` Before continuing: accept the terms of service of ${continuation.tosExchangeBaseUrl}.`, + ); + } + for (const [key, value] of Object.entries(continuation.details ?? {})) { + if (value != null) { + lines.push(` ${printDetail(key, value)}`); + } + } + } + + const command = actionCommand(tx, continuation); + if (command != null) { + lines.push(` Run: ${command}`); + } + + if (tx.error != null) { + lines.push(` Error: ${summarizeTalerErrorDetail(tx.error)}`); + } + if (tx.abortReason != null) { + lines.push(` Aborted: ${summarizeTalerErrorDetail(tx.abortReason)}`); + } + if (tx.failReason != null) { + lines.push(` Failed: ${summarizeTalerErrorDetail(tx.failReason)}`); + } + + if (!verbose) { + return lines; + } + + lines.push(` ID: ${tx.transactionId}`); + lines.push( + ` Time: ${AbsoluteTime.toIsoString( + AbsoluteTime.fromPreciseTimestamp(tx.timestamp), + )}`, + ); + lines.push(` Amount before fees: ${tx.amountRaw}`); + if (tx.amountRaw !== tx.amountEffective) { + lines.push(` Balance change: ${tx.amountEffective}`); + } + const exchange = exchangeOf(tx); + if (exchange != null) { + lines.push(` Exchange: ${exchange}`); + } + if (tx.txActions.length > 0) { + lines.push(` Available actions: ${tx.txActions.join(", ")}`); + } + lines.push(` Internal state ID: ${tx.stId}`); + return lines; +} diff --git a/packages/taler-wallet-cli/src/txref.ts b/packages/taler-wallet-cli/src/txref.ts @@ -33,7 +33,7 @@ import { CliUsageError } from "./waitspec.js"; * Syntax of a transaction reference, as shown in the command line help. */ export const TX_REF_SYNTAX = - "Either a transaction identifier, or '^N' for the N-th most recent" + + "Either a transaction identifier (including a local 'txn#TYPE#ID'), or '^N' for the N-th most recent" + " transaction ('^' means '^1')."; export type TxRef = diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts @@ -324,6 +324,10 @@ test("converter: the copy plan covers every table in the schema", async () => { peer_pull_debit: "peerPullDebit", peer_pull_credit: "peerPullCredit", transactions_meta: "transactionsMeta", + transaction_local_id_counters: + "EXCLUDED: local transaction identifiers are re-assigned on conversion", + transaction_local_ids: + "EXCLUDED: local transaction identifiers are re-assigned on conversion", exchanges: "exchanges", exchange_details: "exchangeDetails", exchange_sign_keys: "exchangeSignKeys", diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -953,6 +953,19 @@ CREATE INDEX IF NOT EXISTS transactions_meta_by_timestamp CREATE INDEX IF NOT EXISTS transactions_meta_by_status ON transactions_meta (status); +-- Local transaction identifiers deliberately live outside the materialized +-- view. Re-materializing transactions must not renumber user-facing IDs. +CREATE TABLE IF NOT EXISTS transaction_local_id_counters ( + transaction_type TEXT PRIMARY KEY, + next_ident INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS transaction_local_ids ( + transaction_id TEXT PRIMARY KEY, + transaction_type TEXT NOT NULL, + local_ident INTEGER NOT NULL, + UNIQUE (transaction_type, local_ident) +); + CREATE TABLE IF NOT EXISTS exchanges ( base_url TEXT PRIMARY KEY, preset_currency_hint TEXT, diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -269,6 +269,22 @@ export class IdbWalletTransaction implements WalletDbTransaction { }); } + async getLocalTransactionIdentifiers( + _transactionIds: string[], + ): Promise<Map<string, string>> { + // Do not add a store just for this feature: assigning a counter safely + // across IndexedDB transactions would require serialising every metadata + // update. Native SQLite can do this cheaply and atomically. + return new Map(); + } + + async getTransactionIdByLocalIdentifier( + _transactionType: string, + _localIdent: string, + ): Promise<string | undefined> { + return undefined; + } + async deleteTransactionMeta(transactionId: string): Promise<void> { const tx = this.tx; await tx.transactionsMeta.delete(transactionId); diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -2516,7 +2516,50 @@ export class SqliteWalletTransaction implements WalletDbTransaction { }; } + /** + * Allocate a stable, per-type local number once. The mapping is not tied + * to transactions_meta because that view is periodically rebuilt. + */ + private async ensureLocalTransactionIdentifier( + transactionId: string, + ): Promise<void> { + const existing = await this.first( + "SELECT 1 FROM transaction_local_ids WHERE transaction_id = $id", + { id: transactionId }, + ); + if (existing != null) { + return; + } + const [prefix, transactionType] = transactionId.split(":", 3); + if (prefix !== "txn" || transactionType == null || transactionType === "") { + throw Error(`invalid transaction identifier '${transactionId}'`); + } + await this.run( + "INSERT OR IGNORE INTO transaction_local_id_counters" + + " (transaction_type, next_ident) VALUES ($type, 1)", + { type: transactionType }, + ); + const counter = await this.first( + "SELECT next_ident FROM transaction_local_id_counters" + + " WHERE transaction_type = $type", + { type: transactionType }, + ); + const localIdent = num(counter?.next_ident); + await this.run( + "INSERT INTO transaction_local_ids" + + " (transaction_id, transaction_type, local_ident)" + + " VALUES ($id, $type, $localIdent)", + { id: transactionId, type: transactionType, localIdent }, + ); + await this.run( + "UPDATE transaction_local_id_counters SET next_ident = next_ident + 1" + + " WHERE transaction_type = $type", + { type: transactionType }, + ); + } + async upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void> { + await this.ensureLocalTransactionIdentifier(rec.transactionId); await this.run( `INSERT INTO transactions_meta ( transaction_id, timestamp, status, currency, exchanges @@ -2536,6 +2579,47 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ); } + async getLocalTransactionIdentifiers( + transactionIds: string[], + ): Promise<Map<string, string>> { + const result = new Map<string, string>(); + // SQLite commonly limits a statement to 999 bind parameters. Chunks keep + // a large transaction history to a handful of indexed lookups. + for (let start = 0; start < transactionIds.length; start += 500) { + const ids = transactionIds.slice(start, start + 500); + const params: Record<string, string> = {}; + const placeholders = ids.map((id, i) => { + const name = `id${i}`; + params[name] = id; + return `$${name}`; + }); + const rows = await this.all( + "SELECT transaction_id, local_ident FROM transaction_local_ids" + + ` WHERE transaction_id IN (${placeholders.join(", ")})`, + params, + ); + for (const row of rows) { + result.set(str(row.transaction_id), String(row.local_ident)); + } + } + return result; + } + + async getTransactionIdByLocalIdentifier( + transactionType: string, + localIdent: string, + ): Promise<string | undefined> { + // Integer comparison deliberately accepts the canonical decimal strings + // emitted by the wallet. Future local-ID schemes can use another + // backend without exposing that storage detail in the API. + const row = await this.first( + "SELECT transaction_id FROM transaction_local_ids" + + " WHERE transaction_type = $type AND local_ident = $localIdent", + { type: transactionType, localIdent }, + ); + return row == null ? undefined : str(row.transaction_id); + } + async deleteTransactionMeta(transactionId: string): Promise<void> { await this.run("DELETE FROM transactions_meta WHERE transaction_id = $id", { id: transactionId, diff --git a/packages/taler-wallet-core/src/dbtx.ts b/packages/taler-wallet-core/src/dbtx.ts @@ -242,6 +242,20 @@ export interface WalletDbTransaction { upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void>; /** + * Look up the locally assigned identifiers for transaction IDs in one + * batch. Backends without efficient local identifiers return an empty map. + */ + getLocalTransactionIdentifiers( + transactionIds: string[], + ): Promise<Map<string, string>>; + + /** Resolve one local transaction identifier, scoped by transaction type. */ + getTransactionIdByLocalIdentifier( + transactionType: string, + localIdent: string, + ): Promise<string | undefined>; + + /** * Delete the transaction metadata for a transaction. * * Called when the underlying transaction record no longer exists. diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -204,6 +204,7 @@ import { codecForGetPerformanceStatsRequest, codecForGetQrCodesForPaytoRequest, codecForGetTransactionsV2Request, + codecForResolveTransactionReferenceRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForHintNetworkAvailabilityRequest, @@ -407,6 +408,7 @@ import { getTransactionsV2, parseTransactionIdentifier, rematerializeTransactions, + resolveTransactionReference, restartAll as restartAllRunningTasks, resumeTransaction, retryAll, @@ -2586,6 +2588,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForTransactionByIdRequest(), handler: getTransactionById, }, + [WalletApiOperation.ResolveTransactionReference]: { + codec: codecForResolveTransactionReferenceRequest(), + handler: resolveTransactionReference, + }, [WalletApiOperation.AddExchange]: { codec: codecForAddExchangeRequest(), handler: handleAddExchange, diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -26,6 +26,8 @@ import { j2s, Logger, NotificationType, + ResolveTransactionReferenceRequest, + ResolveTransactionReferenceResponse, ScopeType, TalerError, TalerErrorCode, @@ -225,23 +227,76 @@ export async function getTransactionById( case TransactionType.PeerPullDebit: case TransactionType.Refund: { const ctx = await getContextForTransaction(wex, req.transactionId); - const txDetails = await wex.runWalletDbTx(async (tx) => - ctx.lookupFullTransaction(tx, { + const result = await wex.runWalletDbTx(async (tx) => ({ + txDetails: await ctx.lookupFullTransaction(tx, { includeContractTerms: req.includeContractTerms, }), - ); - if (!txDetails) { + localIdent: ( + await tx.getLocalTransactionIdentifiers([req.transactionId]) + ).get(req.transactionId), + })); + if (!result.txDetails) { throw TalerError.fromDetail( TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, { transactionId: req.transactionId }, `transaction ${req.transactionId} not found`, ); } - return txDetails; + return withLocalTransactionIdentifier( + result.txDetails, + result.localIdent, + ); } } } +function withLocalTransactionIdentifier( + tx: Transaction, + localIdent: string | undefined, +): Transaction { + if (localIdent == null) { + return tx; + } + return { + ...tx, + localTransactionId: `txn#${tx.type}#${localIdent}`, + } as Transaction; +} + +/** + * Resolve either the stable transaction ID or the backend's optional local + * identifier. A local ID is deliberately scoped by its type and never + * survives a wallet import or merge. + */ +export async function resolveTransactionReference( + wex: WalletExecutionContext, + req: ResolveTransactionReferenceRequest, +): Promise<ResolveTransactionReferenceResponse> { + const ref = req.transactionReference; + if (parseTransactionIdentifier(ref) != null) { + return { transactionId: ref as TransactionIdStr }; + } + const match = /^txn#([^#]+)#([^#]+)$/.exec(ref); + if ( + match == null || + !Object.values(TransactionType).includes(match[1] as TransactionType) + ) { + throw makeInvalidTransactionIdError(ref); + } + const [, transactionType, localIdent] = match; + const transactionId = await wex.runWalletDbTx((tx) => + tx.getTransactionIdByLocalIdentifier(transactionType, localIdent), + ); + if (transactionId == null) { + throw makeTransactionNotFoundError(ref); + } + const parsed = parseTransactionIdentifier(transactionId); + if (parsed?.tag !== transactionType) { + throw Error("local transaction identifier has inconsistent type"); + } + return { transactionId: transactionId as TransactionIdStr }; +} + export function isUnsuccessfulTransaction(state: TransactionState): boolean { return ( state.major === TransactionMajorState.Aborted || @@ -329,6 +384,9 @@ async function addFiltered( source: WalletTransactionMeta[], ): Promise<number> { let numAdded: number = 0; + const localIdentifiers = await tx.getLocalTransactionIdentifiers( + source.map((x) => x.transactionId), + ); for (const mtx of source) { if (req?.limit != null && target.length >= Math.abs(req.limit)) { break; @@ -345,7 +403,12 @@ async function addFiltered( continue; } numAdded += 1; - target.push(txDetails); + target.push( + withLocalTransactionIdentifier( + txDetails, + localIdentifiers.get(mtx.transactionId), + ), + ); } } return numAdded; @@ -511,6 +574,9 @@ export async function getTransactions( const allMetaTransactions = await tx.listTransactionMetaByStatus({ onlyActive, }); + const localIdentifiers = await tx.getLocalTransactionIdentifiers( + allMetaTransactions.map((x) => x.transactionId), + ); for (const metaTx of allMetaTransactions) { if ( shouldSkipCurrency( @@ -535,7 +601,12 @@ export async function getTransactions( if (!txDetails) { continue; } - transactions.push(txDetails); + transactions.push( + withLocalTransactionIdentifier( + txDetails, + localIdentifiers.get(metaTx.transactionId), + ), + ); } }); diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -123,6 +123,8 @@ import { GetQrCodesForPaytoRequest, GetQrCodesForPaytoResponse, GetTransactionsV2Request, + ResolveTransactionReferenceRequest, + ResolveTransactionReferenceResponse, GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForUriRequest, HintNetworkAvailabilityRequest, @@ -253,6 +255,7 @@ export enum WalletApiOperation { GetTransactions = "getTransactions", GetTransactionsV2 = "getTransactionsV2", GetTransactionById = "getTransactionById", + ResolveTransactionReference = "resolveTransactionReference", AbortTransaction = "abortTransaction", FailTransaction = "failTransaction", SuspendTransaction = "suspendTransaction", @@ -727,6 +730,12 @@ export type GetTransactionByIdOp = { response: Transaction; }; +export type ResolveTransactionReferenceOp = { + op: WalletApiOperation.ResolveTransactionReference; + request: ResolveTransactionReferenceRequest; + response: ResolveTransactionReferenceResponse; +}; + /** * Delete a transaction locally in the wallet. */ @@ -1715,6 +1724,10 @@ export const walletApiExpectedErrors = { TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, ], + [WalletApiOperation.ResolveTransactionReference]: [ + TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + ], [WalletApiOperation.AbortTransaction]: [ TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, TalerErrorCode.WALLET_TRANSACTION_ACTION_UNSUPPORTED, @@ -1992,6 +2005,7 @@ export type WalletOperations = { [WalletApiOperation.GetTransactionsV2]: GetTransactionsV2Op; [WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp; [WalletApiOperation.GetTransactionById]: GetTransactionByIdOp; + [WalletApiOperation.ResolveTransactionReference]: ResolveTransactionReferenceOp; [WalletApiOperation.GetActiveTasks]: GetActiveTasksOp; [WalletApiOperation.DumpCoins]: DumpCoinsOp; [WalletApiOperation.SetCoinSuspended]: SetCoinSuspendedOp;