taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/transactions.test.ts | 226+++++++++++++++++--------------------------------------------------------------
Mpackages/taler-wallet-core/src/transactions.ts | 46++++++++++++++++++++++++++++++++--------------
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 {