commit b32819d2d026822074c410be98ca97c7cd0226fa
parent 2fb5aa6300834b5908630750c4c3d715a0fdb80c
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:41 +0200
wallet-core: enforce advertised transaction actions
Diffstat:
3 files changed, 113 insertions(+), 1 deletion(-)
diff --git a/packages/taler-wallet-core/src/transactions.test.ts b/packages/taler-wallet-core/src/transactions.test.ts
@@ -13,14 +13,29 @@
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 { TransactionType } from "@gnu-taler/taler-util";
+import {
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionType,
+} from "@gnu-taler/taler-util";
import assert from "node:assert";
import { test } from "node:test";
import {
constructTransactionIdentifier,
+ deleteTransaction,
ParsedTransactionIdentifier,
parseTransactionIdentifier,
} from "./transactions.js";
+import {
+ PeerPushDebitStatus,
+ timestampPreciseToDb,
+ timestampProtocolToDb,
+ WalletPeerPushDebit,
+} from "./db-common.js";
+import { WalletDbTransaction } from "./dbtx.js";
+import { WalletExecutionContext } from "./wallet.js";
const allIdentifiers: ParsedTransactionIdentifier[] = [
{ tag: TransactionType.Deposit, depositGroupId: "dg" },
@@ -65,3 +80,82 @@ test("a malformed transaction identifier is rejected", (t) => {
);
assert.strictEqual(parseTransactionIdentifier(""), undefined);
});
+
+test("deleting a transaction is rejected when delete is not advertised", async () => {
+ const purseExpiration = TalerProtocolTimestamp.fromSeconds(2_000_000_000);
+ const contractTermsHash = "contract-terms-hash";
+ let record: WalletPeerPushDebit | undefined = {
+ exchangeBaseUrl: "https://exchange.example/",
+ amount: "TESTKUDOS:1",
+ totalCost: "TESTKUDOS:1",
+ contractTermsHash,
+ pursePub: "purse-pub",
+ pursePriv: "purse-priv",
+ mergePub: "merge-pub",
+ mergePriv: "merge-priv",
+ contractPriv: "contract-priv",
+ contractPub: "contract-pub",
+ contractEncNonce: "contract-enc-nonce",
+ purseExpiration: timestampProtocolToDb(purseExpiration),
+ timestampCreated: timestampPreciseToDb(
+ TalerPreciseTimestamp.fromSeconds(1_000_000_000),
+ ),
+ status: PeerPushDebitStatus.PendingCreatePurse,
+ };
+ const tx = {
+ async getPeerPushDebit(): Promise<WalletPeerPushDebit | undefined> {
+ return record;
+ },
+ async getOperationRetry(): Promise<undefined> {
+ return undefined;
+ },
+ async getContractTerms() {
+ return {
+ h: contractTermsHash,
+ contractTermsRaw: {
+ purse_expiration: purseExpiration,
+ summary: "peer payment",
+ },
+ };
+ },
+ async getExchange(): Promise<undefined> {
+ return undefined;
+ },
+ async deletePeerPushDebit(): Promise<void> {
+ record = undefined;
+ },
+ async deleteTransactionMeta(): Promise<void> {},
+ notify(): void {},
+ } as unknown as WalletDbTransaction;
+ let stopped = false;
+ const wex = {
+ async runWalletDbTx<T>(
+ f: (tx: WalletDbTransaction) => Promise<T>,
+ ): Promise<T> {
+ return await f(tx);
+ },
+ taskScheduler: {
+ stopShepherdTask(): void {
+ stopped = true;
+ },
+ },
+ } as unknown as WalletExecutionContext;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: "purse-pub",
+ });
+
+ await assert.rejects(
+ deleteTransaction(wex, transactionId),
+ (error: unknown) => {
+ assert.ok(error instanceof TalerError);
+ assert.strictEqual(
+ error.errorDetail.code,
+ TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED,
+ );
+ return true;
+ },
+ );
+ assert.ok(record, "the transaction record must be retained");
+ assert.strictEqual(stopped, false, "its recovery task must keep running");
+});
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
@@ -32,6 +32,7 @@ import {
TalerError,
TalerErrorCode,
Transaction,
+ TransactionAction,
TransactionByIdRequest,
TransactionIdStr,
TransactionMajorState,
@@ -1038,6 +1039,22 @@ export async function deleteTransaction(
transactionId: string,
): Promise<void> {
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(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);
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -1752,6 +1752,7 @@ export const walletApiExpectedErrors = {
],
[WalletApiOperation.DeleteTransaction]: [
TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND,
+ TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED,
],
[WalletApiOperation.RetryTransaction]: [
TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND,