taler-typescript-core

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

commit 2e7f87c83da85b183ad8005c09ac80d30b1555f8
parent 625d6b6bbc40d1f82a80b95abed2a1e7a506830f
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:51 +0200

wallet-core: protect recoverable transaction value

Diffstat:
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 3+++
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 12+++++++-----
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 11+++++++----
Mpackages/taler-wallet-core/src/refresh.test.ts | 43+++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/refresh.ts | 37++++++++-----------------------------
Mpackages/taler-wallet-core/src/transactions.test.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/transactions.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
7 files changed, 217 insertions(+), 64 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -568,6 +568,9 @@ export class PeerPullDebitTransactionContext implements TransactionContext { switch (rec.status) { case PeerPullDebitRecordStatus.SuspendedDeposit: case PeerPullDebitRecordStatus.PendingDeposit: + // These states advertise Abort, which recovers every unconfirmed + // coin, but deliberately do not advertise Fail. + return; case PeerPullDebitRecordStatus.AbortingRefresh: case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: // FIXME: Should we also abort the corresponding refresh session?! diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -461,6 +461,11 @@ export class PeerPushCreditTransactionContext implements TransactionContext { return; } switch (rec.status) { + case PeerPushCreditStatus.PendingWithdrawing: + case PeerPushCreditStatus.SuspendedWithdrawing: + rec.status = PeerPushCreditStatus.Failed; + rec.failReason = reason; + break; case PeerPushCreditStatus.Done: case PeerPushCreditStatus.Aborted: case PeerPushCreditStatus.Failed: @@ -472,17 +477,14 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.DialogProposed: case PeerPushCreditStatus.PendingMergeKycRequired: case PeerPushCreditStatus.PendingMerge: - case PeerPushCreditStatus.PendingWithdrawing: case PeerPushCreditStatus.SuspendedMerge: case PeerPushCreditStatus.SuspendedMergeKycRequired: - case PeerPushCreditStatus.SuspendedWithdrawing: case PeerPushCreditStatus.PendingBalanceKycRequired: case PeerPushCreditStatus.SuspendedBalanceKycRequired: case PeerPushCreditStatus.PendingBalanceKycInit: case PeerPushCreditStatus.SuspendedBalanceKycInit: - rec.status = PeerPushCreditStatus.Failed; - rec.failReason = reason; - break; + // The current state does not advertise Fail. + return; default: assertUnreachable(rec.status); } diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -383,14 +383,17 @@ export class PeerPushDebitTransactionContext implements TransactionContext { return; } switch (rec.status) { - case PeerPushDebitStatus.AbortingDeletePurse: - case PeerPushDebitStatus.SuspendedAbortingDeletePurse: - case PeerPushDebitStatus.ExpiredDeletePurse: - case PeerPushDebitStatus.SuspendedExpiredDeletePurse: case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.SuspendedReady: case PeerPushDebitStatus.SuspendedCreatePurse: case PeerPushDebitStatus.PendingCreatePurse: + // Failure is not advertised while value can still be recovered by + // completing or aborting the purse operation. + return; + case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.ExpiredDeletePurse: + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: rec.status = PeerPushDebitStatus.Failed; rec.failReason = reason; break; diff --git a/packages/taler-wallet-core/src/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts @@ -13,15 +13,24 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, DenominationInfo } from "@gnu-taler/taler-util"; +import { + Amounts, + DenominationInfo, + TalerError, + TalerErrorCode, + TransactionAction, +} from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; import { classifyRecoveryRefresh, + computeRefreshTransactionActions, getTotalRefreshCostInternal, + RefreshTransactionContext, requireValidNorevealIndex, } from "./refresh.js"; -import { RefreshOperationStatus } from "./db-common.js"; +import { RefreshOperationStatus, WalletRefreshGroup } from "./db-common.js"; +import { WalletExecutionContext } from "./wallet.js"; test("melt noreveal index must be an integer inside kappa", () => { assert.doesNotThrow(() => requireValidNorevealIndex(0, 3)); @@ -50,6 +59,36 @@ test("only a finished recovery refresh counts as recovered", () => { assert.strictEqual(classifyRecoveryRefresh(undefined), "failed"); }); +test("a live refresh cannot be failed and lose its recovery path", () => { + for (const operationStatus of [ + RefreshOperationStatus.Pending, + RefreshOperationStatus.PendingRedenominate, + RefreshOperationStatus.Suspended, + RefreshOperationStatus.SuspendedRedenominate, + ]) { + const actions = computeRefreshTransactionActions({ + operationStatus, + } as WalletRefreshGroup); + assert.ok(!actions.includes(TransactionAction.Fail)); + } + + const ctx = new RefreshTransactionContext( + {} as WalletExecutionContext, + "refresh-group", + ); + assert.throws( + () => ctx.userFailTransaction(), + (error: unknown) => { + assert.ok(error instanceof TalerError); + assert.strictEqual( + error.errorDetail.code, + TalerErrorCode.WALLET_TRANSACTION_ACTION_UNSUPPORTED, + ); + return true; + }, + ); +}); + test("an impossible refresh costs the full remaining amount", () => { const amountLeft = Amounts.parseOrThrow("TESTKUDOS:4"); const refreshedDenom = { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -333,29 +333,12 @@ export class RefreshTransactionContext implements TransactionContext { }); } - async userFailTransaction(reason?: TalerErrorDetail): Promise<void> { - await this.wex.runWalletDbTx(async (tx) => { - const [rec, h] = await this.getRecordHandle(tx); - if (!rec) { - return; - } - switch (rec.operationStatus) { - case RefreshOperationStatus.Finished: - case RefreshOperationStatus.Failed: - break; - case RefreshOperationStatus.Pending: - case RefreshOperationStatus.PendingRedenominate: - case RefreshOperationStatus.SuspendedRedenominate: - case RefreshOperationStatus.Suspended: { - rec.operationStatus = RefreshOperationStatus.Failed; - rec.failReason = reason; - await h.update(rec, "user-fail"); - break; - } - default: - assertUnreachable(rec.operationStatus); - } - }); + userFailTransaction(): Promise<void> { + throw makeTransactionActionUnsupportedError( + this.transactionId, + "fail", + "refresh transactions cannot be failed because their input value must remain recoverable", + ); } } @@ -2239,14 +2222,10 @@ export function computeRefreshTransactionActions( return [TransactionAction.Delete]; case RefreshOperationStatus.PendingRedenominate: case RefreshOperationStatus.Pending: - return [ - TransactionAction.Retry, - TransactionAction.Suspend, - TransactionAction.Fail, - ]; + return [TransactionAction.Retry, TransactionAction.Suspend]; case RefreshOperationStatus.SuspendedRedenominate: case RefreshOperationStatus.Suspended: - return [TransactionAction.Resume, TransactionAction.Fail]; + return [TransactionAction.Resume]; } } diff --git a/packages/taler-wallet-core/src/transactions.test.ts b/packages/taler-wallet-core/src/transactions.test.ts @@ -23,10 +23,15 @@ import { import assert from "node:assert"; import { test } from "node:test"; import { + abortTransaction, constructTransactionIdentifier, deleteTransaction, + failTransaction, ParsedTransactionIdentifier, parseTransactionIdentifier, + resumeTransaction, + retryTransaction, + suspendTransaction, } from "./transactions.js"; import { PeerPushDebitStatus, @@ -81,7 +86,13 @@ test("a malformed transaction identifier is rejected", (t) => { assert.strictEqual(parseTransactionIdentifier(""), undefined); }); -test("deleting a transaction is rejected when delete is not advertised", async () => { +function makePendingPeerPushDebitWallet(): { + wex: WalletExecutionContext; + transactionId: string; + getRecord: () => WalletPeerPushDebit | undefined; + wasStopped: () => boolean; + wasReset: () => boolean; +} { const purseExpiration = TalerProtocolTimestamp.fromSeconds(2_000_000_000); const contractTermsHash = "contract-terms-hash"; let record: WalletPeerPushDebit | undefined = { @@ -128,6 +139,7 @@ test("deleting a transaction is rejected when delete is not advertised", async ( notify(): void {}, } as unknown as WalletDbTransaction; let stopped = false; + let reset = false; const wex = { async runWalletDbTx<T>( f: (tx: WalletDbTransaction) => Promise<T>, @@ -138,15 +150,51 @@ test("deleting a transaction is rejected when delete is not advertised", async ( stopShepherdTask(): void { stopped = true; }, + resetTask(): void { + reset = true; + }, }, } as unknown as WalletExecutionContext; const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub: "purse-pub", }); + return { + wex, + transactionId, + getRecord: () => record, + wasStopped: () => stopped, + wasReset: () => reset, + }; +} + +test("deleting a transaction is rejected when delete is not advertised", async () => { + const fixture = makePendingPeerPushDebitWallet(); + + await assert.rejects( + deleteTransaction(fixture.wex, fixture.transactionId), + (error: unknown) => { + assert.ok(error instanceof TalerError); + assert.strictEqual( + error.errorDetail.code, + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + ); + return true; + }, + ); + assert.ok(fixture.getRecord(), "the transaction record must be retained"); + assert.strictEqual( + fixture.wasStopped(), + false, + "its recovery task must keep running", + ); +}); + +test("failing a transaction is rejected when fail is not advertised", async () => { + const fixture = makePendingPeerPushDebitWallet(); await assert.rejects( - deleteTransaction(wex, transactionId), + failTransaction(fixture.wex, fixture.transactionId), (error: unknown) => { assert.ok(error instanceof TalerError); assert.strictEqual( @@ -156,6 +204,47 @@ test("deleting a transaction is rejected when delete is not advertised", async ( return true; }, ); - assert.ok(record, "the transaction record must be retained"); - assert.strictEqual(stopped, false, "its recovery task must keep running"); + assert.strictEqual( + fixture.getRecord()?.status, + PeerPushDebitStatus.PendingCreatePurse, + "the transaction must remain recoverable", + ); + assert.strictEqual( + fixture.wasReset(), + false, + "the rejected failure must not alter its recovery task", + ); +}); + +test("all transaction action dispatchers reject unadvertised actions", async () => { + const fixture = makePendingPeerPushDebitWallet(); + const record = fixture.getRecord(); + assert.ok(record); + record.status = PeerPushDebitStatus.Done; + + for (const [action, dispatch] of [ + ["abort", abortTransaction], + ["fail", failTransaction], + ["resume", resumeTransaction], + ["retry", retryTransaction], + ["suspend", suspendTransaction], + ] as const) { + await assert.rejects( + dispatch(fixture.wex, fixture.transactionId), + (error: unknown) => { + assert.ok( + error instanceof TalerError, + `${action} returned wrong error`, + ); + assert.strictEqual( + error.errorDetail.code, + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + ); + return true; + }, + ); + } + assert.strictEqual(fixture.getRecord()?.status, PeerPushDebitStatus.Done); + assert.strictEqual(fixture.wasStopped(), false); + assert.strictEqual(fixture.wasReset(), false); }); diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -931,6 +931,7 @@ export async function retryTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise<void> { + await requireTransactionAction(wex, transactionId, TransactionAction.Retry); logger.info(`resetting retry timeout for ${transactionId}`); const taskId = maybeTaskFromTransaction(transactionId); if (taskId) { @@ -999,6 +1000,39 @@ async function getContextForTransaction( } /** + * Resolve a transaction and verify that its current public state advertises + * the requested user action. + * + * Transaction contexts still validate their own state transitions. This + * check is the common API boundary that keeps every action dispatcher aligned + * with the actions returned to clients. + */ +async function requireTransactionAction( + wex: WalletExecutionContext, + transactionId: string, + action: TransactionAction, +): Promise<TransactionContext> { + const ctx = await getContextForTransaction(wex, transactionId); + const transaction = await wex.runWalletDbTx(async (tx) => + ctx.lookupFullTransaction(tx), + ); + if (!transaction) { + throw makeTransactionNotFoundError(transactionId); + } + if (!transaction.txActions.includes(action)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: transaction.txState, + debugStateNum: transaction.stId, + }, + `transaction ${transactionId} does not allow ${action} in its current state`, + ); + } + return ctx; +} + +/** * Suspends a pending transaction, stopping any associated network activities, * but with a chance of trying again at a later time. This could be useful if * a user needs to save battery power or bandwidth and an operation is expected @@ -1008,7 +1042,11 @@ export async function suspendTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const ctx = await getContextForTransaction(wex, transactionId); + const ctx = await requireTransactionAction( + wex, + transactionId, + TransactionAction.Suspend, + ); await ctx.userSuspendTransaction(); } @@ -1016,7 +1054,11 @@ export async function failTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const ctx = await getContextForTransaction(wex, transactionId); + const ctx = await requireTransactionAction( + wex, + transactionId, + TransactionAction.Fail, + ); await ctx.userFailTransaction(); } @@ -1027,7 +1069,11 @@ export async function resumeTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const ctx = await getContextForTransaction(wex, transactionId); + const ctx = await requireTransactionAction( + wex, + transactionId, + TransactionAction.Resume, + ); await ctx.userResumeTransaction(); } @@ -1038,23 +1084,11 @@ export async function deleteTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const ctx = await getContextForTransaction(wex, transactionId); - const transaction = await wex.runWalletDbTx(async (tx) => - ctx.lookupFullTransaction(tx), + const ctx = await requireTransactionAction( + wex, + transactionId, + TransactionAction.Delete, ); - if (!transaction) { - throw makeTransactionNotFoundError(transactionId); - } - if (!transaction.txActions.includes(TransactionAction.Delete)) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, - { - txState: transaction.txState, - debugStateNum: transaction.stId, - }, - `transaction ${transactionId} cannot be deleted in its current state`, - ); - } await ctx.userDeleteTransaction(); if (ctx.taskId) { wex.taskScheduler.stopShepherdTask(ctx.taskId); @@ -1065,7 +1099,11 @@ export async function abortTransaction( wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const ctx = await getContextForTransaction(wex, transactionId); + const ctx = await requireTransactionAction( + wex, + transactionId, + TransactionAction.Abort, + ); await ctx.userAbortTransaction(); }