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:
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);
}