commit 65ba257cbbb81b6b4119fe7da814d7f3c3382fb8
parent 86dcddefc484172c92465a34b9ef21596625c970
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 13:54:35 +0200
wallet-core: recover P2P deposits for coins unknown to the exchange
Return the documented coin-history 404 to callers. During purse cleanup,
schedule a recovery refresh only for EXCHANGE_GENERIC_COIN_UNKNOWN.
Keep other errors and refresh conflict recovery strict.
Add rejection tests and isolate the pre-creation abort integration case,
using a whole coin so change refresh cannot mask the regression.
Issue: https://bugs.taler.net/n/7903
Diffstat:
6 files changed, 233 insertions(+), 3 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-peer-abort-before-create.ts b/packages/taler-harness/src/integrationtests/test-peer-abort-before-create.ts
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import {
+ AbsoluteTime,
+ Duration,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { GlobalTestState } from "../harness/harness.js";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+
+/** An undelivered purse deposit leaves coins unknown to the exchange. */
+export async function runPeerAbortBeforeCreateTest(t: GlobalTestState) {
+ const {
+ walletClient: wallet,
+ bankClient,
+ exchange,
+ } = await createSimpleTestkudosEnvironmentV3(
+ t,
+ makeNoFeeCoinConfig("TESTKUDOS"),
+ );
+ const withdrawal = await withdrawViaBankV3(t, {
+ walletClient: wallet,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await withdrawal.withdrawalFinishedCond;
+ // Keep background network tasks queued until the exchange is back.
+ await wallet.call(WalletApiOperation.HintNetworkAvailability, {
+ isNetworkAvailable: false,
+ });
+ await exchange.stop();
+ const { transactionId } = await wallet.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Abort before the exchange sees the coins",
+ // Use a whole coin so a change refresh cannot reveal it first.
+ amount: "TESTKUDOS:5.12",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ },
+ );
+ const quoted = await wallet.call(WalletApiOperation.GetTransactionById, {
+ transactionId,
+ });
+ await wallet.call(WalletApiOperation.AbortTransaction, { transactionId });
+ await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId,
+ txState: {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ },
+ });
+ await exchange.start();
+ await wallet.call(WalletApiOperation.HintNetworkAvailability, {
+ isNetworkAvailable: true,
+ });
+ await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId,
+ txState: { major: TransactionMajorState.Aborted },
+ });
+ await wallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ const settled = await wallet.call(WalletApiOperation.GetTransactionById, {
+ transactionId,
+ });
+ t.assertAmountEquals(settled.amountEffective, quoted.amountEffective);
+ t.assertTrue(settled.amountEffectiveFinal !== undefined);
+ t.assertAmountEquals(settled.amountEffectiveFinal!, "TESTKUDOS:0");
+ const { balances } = await wallet.call(WalletApiOperation.GetBalances, {});
+ t.assertDeepEqual(balances.length, 1);
+ t.assertAmountEquals(balances[0].available, "TESTKUDOS:20");
+ t.assertAmountEquals(balances[0].pendingIncoming, "TESTKUDOS:0");
+ t.assertAmountEquals(balances[0].pendingOutgoing, "TESTKUDOS:0");
+}
+runPeerAbortBeforeCreateTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -156,6 +156,7 @@ import { runPaymentTemplateTest } from "./test-payment-template.js";
import { runPaymentTransientTest } from "./test-payment-transient.js";
import { runPaymentTest } from "./test-payment.js";
import { runPaywallFlowTest } from "./test-paywall-flow.js";
+import { runPeerAbortBeforeCreateTest } from "./test-peer-abort-before-create.js";
import { runPeerAbortBalanceTest } from "./test-peer-abort-balance.js";
import { runPeerPullDebitPurseGoneTest } from "./test-peer-pull-debit-purse-gone.js";
import { runPeerPullLargeTest } from "./test-peer-pull-large.js";
@@ -353,6 +354,7 @@ const allTests: TestMainFunction[] = [
runWalletBalanceTest,
runPaywallFlowTest,
runPeerAbortBalanceTest,
+ runPeerAbortBeforeCreateTest,
runTransactionFinalAmountsTest,
runPeerPullDebitPurseGoneTest,
runPeerPullTest,
diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts
@@ -2017,7 +2017,9 @@ export class TalerExchangeHttpClient {
async getCoinHistory(
coinPub: string,
signature: string,
- ): Promise<OperationOk<CoinHistoryResponse>> {
+ ): Promise<
+ OperationOk<CoinHistoryResponse> | OperationFail<HttpStatusCode.NotFound>
+ > {
const resp = await this.fetch(`coins/${coinPub}/history`, {
headers: {
"Taler-Coin-History-Signature": signature,
@@ -2026,6 +2028,8 @@ export class TalerExchangeHttpClient {
switch (resp.status) {
case HttpStatusCode.Ok:
return opSuccessFromHttp(resp, codecForCoinHistoryResponse());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
default:
return opUnknownHttpFailure(resp);
}
diff --git a/packages/taler-wallet-core/src/purse-deposit-reconciliation.test.ts b/packages/taler-wallet-core/src/purse-deposit-reconciliation.test.ts
@@ -16,11 +16,20 @@
import {
AmountString,
+ CancellationToken,
+ HttpStatusCode,
+ TalerErrorCode,
CoinPurseDepositTransaction,
} from "@gnu-taler/taler-util";
import assert from "node:assert/strict";
import { test } from "node:test";
-import { classifyPurseDepositCoinHistory } from "./purse-deposit-reconciliation.js";
+import {
+ classifyPurseDepositCoinHistory,
+ reconcilePurgedPurseDeposits,
+} from "./purse-deposit-reconciliation.js";
+import { HttpRequestLibrary, HttpResponse } from "@gnu-taler/taler-util/http";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { WalletExecutionContext } from "./wallet.js";
const args: Parameters<typeof classifyPurseDepositCoinHistory>[1] = {
coinPub: "coin",
@@ -75,3 +84,109 @@ test("purse deposit history rejects mismatches and duplicates", () => {
/duplicate/,
);
});
+
+function unknownCoinContext(
+ status: number,
+ body: unknown,
+): WalletExecutionContext {
+ const http: HttpRequestLibrary = {
+ async fetch(url, options) {
+ assert.strictEqual(
+ url,
+ `${args.exchangeBaseUrl}coins/${args.coinPub}/history`,
+ );
+ assert.strictEqual(
+ options?.headers?.["Taler-Coin-History-Signature"],
+ "history-sig",
+ );
+ return {
+ requestUrl: url,
+ requestMethod: "GET",
+ status,
+ headers: {
+ get: (name: string) =>
+ name === "content-type" ? "application/json" : null,
+ } as HttpResponse["headers"],
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ bytes: async () => new Uint8Array(),
+ };
+ },
+ };
+ const tx = {
+ async getCoin() {
+ return { coinPub: args.coinPub, coinPriv: "coin-priv" };
+ },
+ } as unknown as WalletDbTransaction;
+ return {
+ http,
+ cancellationToken: CancellationToken.CONTINUE,
+ ws: {
+ longpollQueue: undefined,
+ denomInfoCache: {
+ async getOrPut() {
+ return { value: "TESTKUDOS:5" };
+ },
+ },
+ },
+ cryptoApi: {
+ async signCoinHistoryRequest(request: {
+ coinPub: string;
+ startOffset: number;
+ }) {
+ assert.strictEqual(request.coinPub, args.coinPub);
+ assert.strictEqual(request.startOffset, 0);
+ return { sig: "history-sig" };
+ },
+ },
+ async runWalletDbTx<T>(
+ f: (transaction: WalletDbTransaction) => Promise<T>,
+ ): Promise<T> {
+ return f(tx);
+ },
+ } as unknown as WalletExecutionContext;
+}
+
+const selection = {
+ coinPubs: [args.coinPub],
+ contributions: [args.contribution],
+};
+
+test("an unknown purse coin is scheduled for recovery without restoring it locally", async () => {
+ const wex = unknownCoinContext(HttpStatusCode.NotFound, {
+ code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN,
+ });
+ const result = await reconcilePurgedPurseDeposits(wex, {
+ ...args,
+ selection,
+ });
+ assert.deepStrictEqual(result.recoverable, [
+ { coinPub: args.coinPub, amount: args.contribution },
+ ]);
+ assert.strictEqual(result.committedCoinPubs.size, 0);
+});
+
+test("purse coin recovery rejects unrelated, malformed and transient errors", async () => {
+ for (const [status, body] of [
+ [
+ HttpStatusCode.NotFound,
+ { code: TalerErrorCode.EXCHANGE_GENERIC_PURSE_UNKNOWN },
+ ],
+ [HttpStatusCode.NotFound, {}],
+ [
+ HttpStatusCode.InternalServerError,
+ { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ ],
+ [
+ HttpStatusCode.Forbidden,
+ { code: TalerErrorCode.EXCHANGE_COIN_HISTORY_BAD_SIGNATURE },
+ ],
+ ] as const) {
+ await assert.rejects(
+ reconcilePurgedPurseDeposits(unknownCoinContext(status, body), {
+ ...args,
+ selection,
+ }),
+ );
+ }
+});
diff --git a/packages/taler-wallet-core/src/purse-deposit-reconciliation.ts b/packages/taler-wallet-core/src/purse-deposit-reconciliation.ts
@@ -19,8 +19,10 @@ import {
Amounts,
CoinPurseDepositTransaction,
CoinRefreshRequest,
+ HttpStatusCode,
TalerError,
TalerErrorCode,
+ throwUnexpectedResponse,
} from "@gnu-taler/taler-util";
import { checkDbInvariant } from "@gnu-taler/taler-util";
import { requireValidExchangeCoinHistory } from "./exchange-signatures.js";
@@ -125,6 +127,23 @@ export async function reconcilePurgedPurseDeposits(
coin.coinPub,
historySig.sig,
);
+ if (historyResp.case !== "ok") {
+ if (
+ historyResp.case === HttpStatusCode.NotFound &&
+ historyResp.detail?.code ===
+ TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN
+ ) {
+ // A selected coin whose deposit never reached the exchange has no
+ // history yet. Schedule recovery, but do not make the old coin fresh:
+ // only a successful refresh establishes the recovered balance.
+ recoverable.push({
+ coinPub: coin.coinPub,
+ amount: args.selection.contributions[i],
+ });
+ continue;
+ }
+ throwUnexpectedResponse(historyResp);
+ }
await requireValidExchangeCoinHistory(wex, {
exchangeBaseUrl: args.exchangeBaseUrl,
coinPub: coin.coinPub,
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -1354,7 +1354,7 @@ async function handleRefreshMeltConflict(
historySig.sig,
);
- const historyJson = historyResp.body;
+ const historyJson = succeedOrThrow(historyResp);
logger.info(`coin history: ${j2s(historyJson)}`);
const denomination = await ctx.wex.runWalletDbTx(async (tx) => {
const denom = await getDenomInfo(ctx.wex, tx, oldCoin);