taler-typescript-core

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

commit bb9da11eb1070ea248b21e1d9026e00794797b57
parent 41ba20b662b89e5b94c4f6394132cf925ad77fc3
Author: Florian Dold <florian@dold.me>
Date:   Tue, 14 Jul 2026 12:10:08 +0200

wallet-core: MVP support for progressToken

Diffstat:
Mpackages/taler-util/src/notifications.ts | 19+++++++++++++++----
Mpackages/taler-util/src/types-taler-wallet.ts | 27+++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 21+++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 22++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-wallet-webextension/src/components/WalletActivity.tsx | 10++++++----
6 files changed, 173 insertions(+), 19 deletions(-)

diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -22,7 +22,7 @@ /** * Imports. */ -import { AbsoluteTime } from "./time.js"; +import { AbsoluteTime, TalerProtocolDuration } from "./time.js"; import { TransactionState } from "./types-taler-wallet-transactions.js"; import { ContactEntry, @@ -45,6 +45,7 @@ export enum NotificationType { Idle = "idle", TaskObservabilityEvent = "task-observability-event", RequestObservabilityEvent = "request-observability-event", + RequestProgress = "request-progress", } export interface ErrorInfoSummary { @@ -208,13 +209,22 @@ export interface TaskProgressNotification { event: ObservabilityEvent; } -export interface RequestProgressNotification { +export interface RequestObservabilityEventNotification { type: NotificationType.RequestObservabilityEvent; requestId: string; operation: string; event: ObservabilityEvent; } +export interface RequestProgressNotification { + type: NotificationType.RequestProgress; + progressToken: string; + operation: string; + error: TalerErrorDetail; + nextRetryDelay: TalerProtocolDuration; + retryCounter: number; +} + export enum ObservabilityEventType { HttpFetchStart = "http-fetch-start", HttpFetchFinishError = "http-fetch-finish-error", @@ -367,5 +377,6 @@ export type WalletNotification = | ExchangeStateTransitionNotification | TransactionStateTransitionNotification | TaskProgressNotification - | RequestProgressNotification - | IdleNotification; + | RequestObservabilityEventNotification + | IdleNotification + | RequestProgressNotification; diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -4086,12 +4086,15 @@ export interface PrepareWithdrawExchangeRequest { * A taler://withdraw-exchange URI. */ talerUri: string; + + progressToken?: string; } export const codecForPrepareWithdrawExchangeRequest = (): Codec<PrepareWithdrawExchangeRequest> => buildCodecForObject<PrepareWithdrawExchangeRequest>() .property("talerUri", codecForString()) + .property("progressToken", codecOptional(codecForString())) .build("PrepareWithdrawExchangeRequest"); export interface PrepareWithdrawExchangeResponse { @@ -4567,3 +4570,27 @@ export const codecForTestingCorruptWithdrawalCoinSelRequest = buildCodecForObject<TestingCorruptWithdrawalCoinSelRequest>() .property("transactionId", codecForTransactionIdStr()) .build("TestingCorruptWithdrawalCoinSelRequest"); + +export interface RetryProgressTokenNowRequest { + operation: string; + progressToken: string; +} + +export const codecForRetryProgressTokenNowRequest = + (): Codec<RetryProgressTokenNowRequest> => + buildCodecForObject<RetryProgressTokenNowRequest>() + .property("progressToken", codecForString()) + .property("operation", codecForString()) + .build("RetryProgressTokenNowRequest"); + +export interface CancelProgressTokenRequest { + operation: string; + progressToken: string; +} + +export const codecForCancelProgressToken = + (): Codec<CancelProgressTokenRequest> => + buildCodecForObject<CancelProgressTokenRequest>() + .property("progressToken", codecForString()) + .property("operation", codecForString()) + .build("CancelProgressTokenRequest"); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -184,6 +184,7 @@ import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { InternalWalletState, LegacyWalletTxHandle, + ProgressContext, WalletExecutionContext, walletExchangeClient, } from "./wallet.js"; @@ -1329,6 +1330,7 @@ export async function fetchFreshExchange( options: { forceUpdate?: boolean; noBail?: boolean; + progressContext?: ProgressContext; } = {}, ): Promise<ReadyExchangeSummary> { logger.trace(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`); @@ -1363,6 +1365,7 @@ export async function waitReadyExchange( expectedMasterPub?: string; noBail?: boolean; waitAutoRefresh?: boolean; + progressContext?: ProgressContext; } = {}, ): Promise<ReadyExchangeSummary> { logger.trace(`waiting for exchange ${exchangeBaseUrl} to become ready`); @@ -1481,6 +1484,23 @@ export async function waitReadyExchange( } if (!ready) { + if (options.progressContext != null && retryInfo != null) { + wex.ws.notify({ + type: NotificationType.RequestProgress, + progressToken: options.progressContext.progressToken, + operation: options.progressContext.operation, + error: retryInfo?.lastError ?? { + code: TalerErrorCode.GENERIC_TIMEOUT, + }, + retryCounter: retryInfo.retryInfo.retryCounter, + nextRetryDelay: Duration.toTalerProtocolDuration( + AbsoluteTime.difference( + AbsoluteTime.now(), + timestampAbsoluteFromDb(retryInfo.retryInfo.nextRetry), + ), + ), + }); + } return false; } @@ -1495,6 +1515,7 @@ export async function waitReadyExchange( logger.info( `exchange ${exchange.baseUrl} not ready, waiting for auto-refresh`, ); + // FIXME: Emit progress notification here. return false; } } diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -43,6 +43,7 @@ import { AmountResponse, ApplyDevExperimentRequest, BalancesResponse, + CancelProgressTokenRequest, CanonicalizeBaseUrlRequest, CanonicalizeBaseUrlResponse, CheckDepositRequest, @@ -164,6 +165,7 @@ import { RemoveGlobalCurrencyAuditorRequest, RemoveGlobalCurrencyExchangeRequest, Result, + RetryProgressTokenNowRequest, RetryTransactionRequest, RunFixupRequest, SendTalerUriMailboxMessageRequest, @@ -213,6 +215,10 @@ export enum WalletApiOperation { GetVersion = "getVersion", Shutdown = "shutdown", + // Generic request management + RetryProgressTokenNow = "retryProgressTokenNow", + CancelProgressToken = "cancelProgressToken", + // Balances GetBalances = "getBalances", GetBalanceDetail = "getBalanceDetail", @@ -459,6 +465,20 @@ export type HintNetworkAvailabilityOp = { response: EmptyObject; }; +// group: Generic request handling + +export type RetryProgressTokenNowOp = { + op: WalletApiOperation.RetryProgressTokenNow; + request: RetryProgressTokenNowRequest; + response: EmptyObject; +}; + +export type CancelProgressTokenOp = { + op: WalletApiOperation.CancelProgressToken; + request: CancelProgressTokenRequest; + response: EmptyObject; +}; + // group: Donau /** @@ -1790,6 +1810,8 @@ export type WalletOperations = { [WalletApiOperation.TestingGetFlightRecords]: TestingGetFlightRecordsOp; [WalletApiOperation.GetDefaultExchanges]: GetDefaultExchangesOp; [WalletApiOperation.TestingCorruptWithdrawalCoinSel]: TestingCorruptWithdrawalCoinSelOp; + [WalletApiOperation.CancelProgressToken]: CancelProgressTokenOp; + [WalletApiOperation.RetryProgressTokenNow]: RetryProgressTokenNowOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -46,6 +46,7 @@ import { AmountString, Amounts, AsyncCondition, + CancelProgressTokenRequest, CancellationToken, CanonicalizeBaseUrlRequest, CanonicalizeBaseUrlResponse, @@ -181,6 +182,7 @@ import { codecForAddMailboxMessageRequest, codecForAny, codecForApplyDevExperiment, + codecForCancelProgressToken, codecForCanonicalizeBaseUrlRequest, codecForCheckDepositRequest, codecForCheckPayTemplateRequest, @@ -248,6 +250,7 @@ import { codecForRemoveGlobalCurrencyAuditorRequest, codecForRemoveGlobalCurrencyExchangeRequest, codecForResumeTransaction, + codecForRetryProgressTokenNowRequest, codecForRetryTransactionRequest, codecForRunFixupRequest, codecForSendTalerUriMailboxMessageRequest, @@ -533,6 +536,11 @@ export function walletMerchantClient( ); } +export interface ProgressContext { + progressToken: string; + operation: string; +} + export const EXCHANGE_COINS_LOCK = "exchange-coins-lock"; export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock"; @@ -891,6 +899,34 @@ async function recoverStoredBackup( logger.info(`import done`); } +async function withMaybeProgressContext<T>( + wex: WalletExecutionContext, + op: string, + tok: string | undefined, + f: (pc?: ProgressContext) => Promise<T>, +): Promise<T> { + if (tok != null) { + const key = `${op}:${encodeURIComponent(tok)}`; + try { + const pc: ProgressContext = { + operation: op, + progressToken: tok, + }; + if (wex.cts) { + wex.ws.progressCancellationMap.set(key, wex.cts); + } else { + logger.warn(`no cancellation source for ${op}:${tok}`); + } + return await f(pc); + } finally { + wex.ws.progressCancellationMap.delete(key); + // Clean up progress token registration + } + } else { + return await f(undefined); + } +} + async function handlePrepareWithdrawExchange( wex: WalletExecutionContext, req: PrepareWithdrawExchangeRequest, @@ -900,17 +936,28 @@ async function handlePrepareWithdrawExchange( throw Error("expected a taler://withdraw-exchange URI"); } const exchangeBaseUrl = parsedUri.exchangeBaseUrl; - const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); - if (parsedUri.amount) { - const amt = Amounts.parseOrThrow(parsedUri.amount); - if (amt.currency !== exchange.currency) { - throw Error("mismatch of currency (URI vs exchange)"); - } - } - return { - exchangeBaseUrl, - amount: parsedUri.amount, - }; + + return await withMaybeProgressContext( + wex, + "prepareWithdrawExchange", + req.progressToken, + async (pc) => { + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, { + noBail: pc != null, + progressContext: pc, + }); + if (parsedUri.amount) { + const amt = Amounts.parseOrThrow(parsedUri.amount); + if (amt.currency !== exchange.currency) { + throw Error("mismatch of currency (URI vs exchange)"); + } + } + return { + exchangeBaseUrl, + amount: parsedUri.amount, + }; + }, + ); } async function handleRetryPendingNow( @@ -2147,6 +2194,20 @@ export async function handleGetPerformanceStats( }; } +export async function handleCancelRequest( + wex: WalletExecutionContext, + req: CancelProgressTokenRequest, +): Promise<EmptyObject> { + return {}; +} + +export async function handleRetryRequestNow( + wex: WalletExecutionContext, + req: CancelProgressTokenRequest, +): Promise<EmptyObject> { + return {}; +} + interface HandlerWithValidator<Tag extends WalletApiOperation> { codec: Codec<WalletCoreRequestType<Tag>>; handler: ( @@ -2156,6 +2217,14 @@ interface HandlerWithValidator<Tag extends WalletApiOperation> { } const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { + [WalletApiOperation.CancelProgressToken]: { + codec: codecForCancelProgressToken(), + handler: handleCancelRequest, + }, + [WalletApiOperation.RetryProgressTokenNow]: { + codec: codecForRetryProgressTokenNowRequest(), + handler: handleRetryRequestNow, + }, [WalletApiOperation.TestingWaitExchangeReady]: { codec: codecForTestingWaitExchangeReadyRequest(), handler: handleTestingWaitExchangeReady, @@ -3352,6 +3421,8 @@ export class InternalWalletState { clientCancellationMap: Map<string, CancellationToken.Source> = new Map(); + progressCancellationMap: Map<string, CancellationToken.Source> = new Map(); + longpollQueue = new LongpollQueue(); private loadingDb: boolean = false; diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -17,7 +17,7 @@ import { AbsoluteTime, NotificationType, ObservabilityEventType, - RequestProgressNotification, + RequestObservabilityEventNotification, TalerErrorCode, TalerErrorDetail, TaskProgressNotification, @@ -349,7 +349,7 @@ function ShowExchangeStateTransition({ events }: MoreInfoPRops): VNode { type ObservaNotifWithTime = ( | TaskProgressNotification - | RequestProgressNotification + | RequestObservabilityEventNotification ) & { when: AbsoluteTime; }; @@ -842,10 +842,10 @@ export function ObservabilityEventsTable(): VNode { }`; case NotificationType.RequestObservabilityEvent: return i18n.str`wallet.${ - (not.events[0] as RequestProgressNotification) + (not.events[0] as RequestObservabilityEventNotification) .operation }(${ - (not.events[0] as RequestProgressNotification) + (not.events[0] as RequestObservabilityEventNotification) .requestId })`; case NotificationType.BankAccountChange: @@ -858,6 +858,8 @@ export function ObservabilityEventsTable(): VNode { return i18n.str`Mailbox message added`; case NotificationType.MailboxMessageDeleted: return i18n.str`Mailbox message deleted`; + case NotificationType.RequestProgress: + return i18n.str`Request progress`; default: { assertUnreachable(not.type); }