commit 5661413726424d507c3dc8413d81ec35426cefc5
parent 32846ca06070f8a3548a9fac60122eb7ca588864
Author: Florian Dold <dold@taler.net>
Date: Thu, 8 Oct 2026 15:58:11 +0200
wallet-core: report proposal claim retry errors to transaction waits
Expose the stored retry error before contract terms have been downloaded.
This lets waitTransactionState emit request-progress-error with the retry
counter and delay while a payment is in claim-proposal.
Diffstat:
2 files changed, 114 insertions(+), 1 deletion(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -17,6 +17,7 @@ import {
Amounts,
AmountString,
buildSigPS,
+ CancellationToken,
ContractTermsUtil,
decodeCrock,
eddsaGetPublic,
@@ -24,6 +25,7 @@ import {
encodeCrock,
HttpStatusCode,
MerchantContractVersion,
+ NotificationType,
RefreshReason,
SelectedCoin,
SignedTokenEnvelope,
@@ -38,6 +40,7 @@ import {
TimerHandle,
TransactionMajorState,
TransactionMinorState,
+ WalletNotification,
} from "@gnu-taler/taler-util";
import assert from "node:assert";
import { test } from "node:test";
@@ -55,6 +58,7 @@ import {
WalletSlate,
WalletToken,
timestampProtocolToDb,
+ timestampPreciseToDb,
} from "./db/records.js";
import { WalletDbTransaction } from "./db/transaction.js";
import {
@@ -94,6 +98,10 @@ import { makeIdbRunner, runnerFactories } from "./db/testing/runners.js";
import type { WalletExecutionContext } from "./wallet.js";
import { nativeCrypto } from "./crypto/cryptoImplementation.js";
import { HeadersImpl } from "@gnu-taler/taler-util/http";
+import { dispatchRequestInternal } from "./requests.js";
+import { getTransactionById } from "./transactions.js";
+import { WalletApiOperation } from "./wallet-api-types.js";
+import { TaskIdentifiers } from "./common.js";
interface ScheduledTimer {
due: number;
@@ -171,6 +179,111 @@ function claimPurchase(
}
for (const makeRunner of runnerFactories) {
+ test(`${makeRunner.name}: proposal claim errors reach transaction waits`, async (t) => {
+ const runner = await makeRunner();
+ const proposalId = "claim-progress-error";
+ const key = encodeCrock(new Uint8Array(32).fill(1));
+ const purchase = {
+ ...claimPurchase(proposalId),
+ noncePriv: key,
+ noncePub: key,
+ secretSeed: key,
+ };
+ const taskId = TaskIdentifiers.forPay(purchase);
+ const transactionId = `txn:payment:${proposalId}` as TransactionIdStr;
+ const notifications: WalletNotification[] = [];
+ const cts = CancellationToken.create();
+ const wex = {
+ cts,
+ cancellationToken: cts.token,
+ ws: {
+ initCalled: true,
+ progressMap: new Map(),
+ timerGroup: new TimerGroup(new ManualTimer()),
+ notify: (n: WalletNotification) => notifications.push(n),
+ addNotificationListener: () => () => {},
+ },
+ taskScheduler: { async ensureRunning() {} },
+ runWalletDbTx: <T>(f: (tx: WalletDbTransaction) => Promise<T>) =>
+ runner.runReadWriteTx(f),
+ } as unknown as WalletExecutionContext;
+ const error = {
+ code: TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ hint: "Unexpected HTTP status 500 in response",
+ requestUrl: "https://merchant.example/orders/order/claim",
+ requestMethod: "POST",
+ httpStatusCode: 500,
+ };
+ t.mock.method(Date.prototype, "getTime", () => 1000);
+ try {
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertPurchase(purchase);
+ await tx.upsertOperationRetry({
+ id: taskId,
+ lastError: error,
+ retryInfo: {
+ firstTry: timestampPreciseToDb(
+ TalerPreciseTimestamp.fromMilliseconds(0),
+ ),
+ nextRetry: timestampPreciseToDb(
+ TalerPreciseTimestamp.fromMilliseconds(6000),
+ ),
+ retryCounter: 3,
+ },
+ });
+ });
+ const transaction = await getTransactionById(wex, { transactionId });
+ assert.deepStrictEqual(transaction.error, error);
+ assert.strictEqual(
+ transaction.txState.minor,
+ TransactionMinorState.ClaimProposal,
+ );
+ for (const operation of [
+ WalletApiOperation.WaitTransactionState,
+ WalletApiOperation.TestingWaitTransactionState,
+ ]) {
+ const result = await dispatchRequestInternal(wex, operation, {
+ transactionId,
+ progressToken: "claim-progress",
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ },
+ });
+ assert.deepStrictEqual(result, {
+ matched: "target",
+ txState: transaction.txState,
+ stId: PurchaseStatus.PendingDownloadingProposal,
+ });
+ assert.deepStrictEqual(
+ notifications.filter(
+ (n) =>
+ n.type === NotificationType.RequestProgressError &&
+ n.operation === operation,
+ ),
+ [
+ {
+ type: NotificationType.RequestProgressError,
+ operation,
+ progressToken: "claim-progress",
+ error,
+ nextRetryDelay: { d_us: 5_000_000 },
+ retryCounter: 3,
+ },
+ ],
+ );
+ }
+ await runner.runReadWriteTx((tx) => tx.deleteOperationRetry(taskId));
+ assert.strictEqual(
+ (await getTransactionById(wex, { transactionId })).error,
+ undefined,
+ );
+ } finally {
+ wex.ws.timerGroup.stopCurrentAndFutureTimers();
+ await runner.close();
+ }
+ });
+
for (const scenario of [
{
name: "keeps monitoring before the deadline",
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -412,7 +412,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
transactionId: this.transactionId,
abortReason: purchaseRec.abortReason,
failReason: purchaseRec.failReason,
- error: undefined,
+ error: payRetryRec?.lastError,
info: undefined,
contractTerms: undefined,
refundQueryActive: false,