taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-peer-abort-before-create.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/http-client/exchange-client.ts | 6+++++-
Mpackages/taler-wallet-core/src/purse-deposit-reconciliation.test.ts | 117++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/purse-deposit-reconciliation.ts | 19+++++++++++++++++++
Mpackages/taler-wallet-core/src/refresh.ts | 2+-
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);