commit 1f8e44cdc6543faad6af7415546d4be7c50b1235
parent 84277a208445ef67e869cd3c43fa1e9989477806
Author: Florian Dold <dold@taler.net>
Date: Sat, 22 Aug 2026 14:13:06 +0200
wallet-core: resolve bank withdrawal references
Diffstat:
2 files changed, 80 insertions(+), 192 deletions(-)
diff --git a/packages/taler-wallet-core/src/transactions.test.ts b/packages/taler-wallet-core/src/transactions.test.ts
@@ -13,34 +13,18 @@
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 {
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionType,
-} from "@gnu-taler/taler-util";
+import { TransactionType } from "@gnu-taler/taler-util";
import assert from "node:assert";
import { test } from "node:test";
import {
- abortTransaction,
constructTransactionIdentifier,
- deleteTransaction,
- failTransaction,
ParsedTransactionIdentifier,
parseTransactionIdentifier,
- resumeTransaction,
- retryTransaction,
- suspendTransaction,
+ resolveTransactionReference,
} from "./transactions.js";
-import {
- PeerPushDebitStatus,
- timestampPreciseToDb,
- timestampProtocolToDb,
- WalletPeerPushDebit,
-} from "./db/records.js";
-import { WalletDbTransaction } from "./db/transaction.js";
-import { WalletExecutionContext } from "./wallet.js";
+import type { WalletWithdrawalGroup } from "./db/records.js";
+import type { WalletDbTransaction } from "./db/transaction.js";
+import type { WalletExecutionContext } from "./wallet.js";
const allIdentifiers: ParsedTransactionIdentifier[] = [
{ tag: TransactionType.Deposit, depositGroupId: "dg" },
@@ -57,6 +41,23 @@ const allIdentifiers: ParsedTransactionIdentifier[] = [
{ tag: TransactionType.DenomLoss, denomLossEventId: "dl" },
];
+function withdrawalReferenceContext(
+ groups: Array<Pick<WalletWithdrawalGroup, "withdrawalGroupId" | "reservePub">>,
+): WalletExecutionContext {
+ const tx = {
+ async listAllWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> {
+ return groups as WalletWithdrawalGroup[];
+ },
+ } as WalletDbTransaction;
+ return {
+ async runWalletDbTx<T>(
+ f: (walletTx: WalletDbTransaction) => Promise<T>,
+ ): Promise<T> {
+ return await f(tx);
+ },
+ } as WalletExecutionContext;
+}
+
test("every constructed transaction identifier parses back", (t) => {
for (const pTxId of allIdentifiers) {
const txId = constructTransactionIdentifier(pTxId);
@@ -78,173 +79,42 @@ test("a malformed transaction identifier is rejected", (t) => {
// an exception or a missing value.
assert.strictEqual(parseTransactionIdentifier("txn:deposit"), undefined);
assert.strictEqual(parseTransactionIdentifier("nottxn:deposit:x"), undefined);
- assert.strictEqual(parseTransactionIdentifier("txn:payment:"), undefined);
- assert.strictEqual(
- parseTransactionIdentifier("txn:payment:proposal:trailing"),
- undefined,
- );
assert.strictEqual(parseTransactionIdentifier(""), undefined);
});
-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 = {
- 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;
- let reset = false;
- const wex = {
- async runWalletDbTx<T>(
- f: (tx: WalletDbTransaction) => Promise<T>,
- ): Promise<T> {
- return await f(tx);
- },
- taskScheduler: {
- 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();
+test("a bank withdrawal reference resolves by its embedded reserve public key", async () => {
+ const reservePub = "A".repeat(52);
+ const wex = withdrawalReferenceContext([
+ { withdrawalGroupId: "withdrawal-group", reservePub },
+ ]);
- 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.deepStrictEqual(
+ await resolveTransactionReference(wex, {
+ transactionReference: `bank-reference-${reservePub}-completed`,
+ }),
+ {
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: "withdrawal-group",
+ }),
},
);
- 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();
+test("an unknown or ambiguous bank withdrawal reference is not resolved", async () => {
+ const reservePubA = "A".repeat(52);
+ const reservePubB = "B".repeat(52);
+ const wex = withdrawalReferenceContext([
+ { withdrawalGroupId: "withdrawal-a", reservePub: reservePubA },
+ { withdrawalGroupId: "withdrawal-b", reservePub: reservePubB },
+ ]);
await assert.rejects(
- failTransaction(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.strictEqual(
- fixture.getRecord()?.status,
- PeerPushDebitStatus.PendingCreatePurse,
- "the transaction must remain recoverable",
+ resolveTransactionReference(wex, { transactionReference: "unknown" }),
);
- assert.strictEqual(
- fixture.wasReset(),
- false,
- "the rejected failure must not alter its recovery task",
+ await assert.rejects(
+ resolveTransactionReference(wex, {
+ transactionReference: `${reservePubA}-${reservePubB}`,
+ }),
);
});
-
-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
@@ -279,24 +279,42 @@ export async function resolveTransactionReference(
return { transactionId: ref as TransactionIdStr };
}
const match = /^#([^:]+):([^:]+)$/.exec(ref);
- if (
- match == null ||
- !Object.values(TransactionType).includes(match[1] as TransactionType)
- ) {
- throw makeInvalidTransactionIdError(ref);
+ if (match != null) {
+ if (!Object.values(TransactionType).includes(match[1] as TransactionType)) {
+ throw makeInvalidTransactionIdError(ref);
+ }
+ const [, transactionType, localIdent] = match;
+ const transactionId = await wex.runWalletDbTx((tx) =>
+ tx.getTransactionIdByLocalIdentifier(transactionType, localIdent),
+ );
+ if (transactionId == null) {
+ throw makeTransactionNotFoundError(ref);
+ }
+ const parsed = parseTransactionIdentifier(transactionId);
+ if (parsed?.tag !== transactionType) {
+ throw Error("local transaction identifier has inconsistent type");
+ }
+ return { transactionId: transactionId as TransactionIdStr };
}
- const [, transactionType, localIdent] = match;
- const transactionId = await wex.runWalletDbTx((tx) =>
- tx.getTransactionIdByLocalIdentifier(transactionType, localIdent),
+
+ // LSD 0006 withdrawal-transfer-result references are bank-generated opaque
+ // strings that contain the reserve public key. The status carried next to
+ // the reference is deliberately not applied here: it is only a UI hint and
+ // wallet-core's transaction state remains authoritative.
+ const withdrawalMatches = await wex.runWalletDbTx(async (tx) =>
+ (await tx.listAllWithdrawalGroups()).filter((wg) =>
+ ref.includes(wg.reservePub),
+ ),
);
- if (transactionId == null) {
+ if (withdrawalMatches.length !== 1) {
throw makeTransactionNotFoundError(ref);
}
- const parsed = parseTransactionIdentifier(transactionId);
- if (parsed?.tag !== transactionType) {
- throw Error("local transaction identifier has inconsistent type");
- }
- return { transactionId: transactionId as TransactionIdStr };
+ return {
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalMatches[0].withdrawalGroupId,
+ }),
+ };
}
export function isUnsuccessfulTransaction(state: TransactionState): boolean {