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