taler-typescript-core

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

commit 45b02239ee9699b136d3aec56bdca86ec04cdc81
parent 62888395efbf15bf74b16f5ad63def7bf1688f13
Author: Florian Dold <dold@taler.net>
Date:   Mon, 10 Aug 2026 17:00:15 +0200

wallet-core: group KYC auth transfer options

The transferOptionsExt now has the same structure as the transferOptions
of a withdrawal.

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-tops-nexus-swt.ts | 4++++
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 14+++++++++++++-
Mpackages/taler-wallet-core/src/common.test.ts | 19++++++++++++++++++-
Mpackages/taler-wallet-core/src/common.ts | 15+++++++++++++--
Mpackages/taler-wallet-core/src/db-common.ts | 15++++++++++++++-
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 2+-
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 9+++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/exchanges.ts | 2++
9 files changed, 158 insertions(+), 16 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-tops-nexus-swt.ts b/packages/taler-harness/src/integrationtests/test-tops-nexus-swt.ts @@ -190,6 +190,10 @@ export async function runTopsNexusSwtTest(t: GlobalTestState) { t.assertTrue(txDet.type == TransactionType.Deposit); t.assertTrue((txDet.kycAuthTransferInfo?.transferOptions.length ?? 0) > 0); + t.assertTrue( + (txDet.kycAuthTransferInfo?.transferOptionsExt[0]?.transferOptions.length ?? + 0) > 0, + ); } runTopsNexusSwtTest.suites = ["tops", "libeufin"]; diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -379,19 +379,31 @@ export interface KycAuthTransferInfo { debitPaytoUri: string; /** - * Account public key that must be included in the subject. + * Account public key. + * + * Included in the transfer subject for some of the transfer options. */ accountPub: string; /** + * Options for making the KYC auth transfer, grouped by exchange credit + * account in the same format used for withdrawals. + */ + transferOptionsExt: WithdrawalExchangeAccountDetails[]; + + /** * Options for making the KYC auth transfer payment * to the exchange. + * + * @deprecated Use transferOptionsExt instead. */ transferOptions: TransferOption[]; /** * Validity of the transferOptions or undefined * if transferOptions does not expire. + * + * @deprecated Use the per-account expiry in transferOptionsExt instead. */ transferExpiry: TalerProtocolTimestamp | undefined; diff --git a/packages/taler-wallet-core/src/common.test.ts b/packages/taler-wallet-core/src/common.test.ts @@ -16,7 +16,8 @@ import { Duration } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; -import { getRetryDuration } from "./common.js"; +import { augmentTransferOptions, getRetryDuration } from "./common.js"; +import { KycAuthTransferOptionRaw } from "./db-common.js"; test("the retry delay grows but stays bounded", (t) => { const first = Duration.toMilliseconds(getRetryDuration(0)); @@ -36,3 +37,19 @@ test("the retry delay grows but stays bounded", (t) => { prev = ms; } }); + +test("augmenting transfer options does not expose internal metadata", () => { + const option: KycAuthTransferOptionRaw = { + type: "uri", + uri: "https://bank.example/transfer/123", + kycAuthAccountPaytoUri: "payto://iban/DE123", + kycAuthTransferExpiry: { t_s: 123 }, + }; + const result = augmentTransferOptions([option]); + assert.deepStrictEqual(result, [ + { + type: "uri", + uri: "https://bank.example/transfer/123", + }, + ]); +}); diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -1276,11 +1276,22 @@ export function augmentTransferOptions( for (const opt of options) { switch (opt.type) { case "ch-qr-bill": + res.push({ + type: "ch-qr-bill", + paytoUri: opt.paytoUri, + qrReferenceNumber: opt.qrReferenceNumber, + qrCodes: getQrCodesForPayto(opt.paytoUri), + }); + break; case "payto": - res.push({ ...opt, qrCodes: getQrCodesForPayto(opt.paytoUri) }); + res.push({ + type: "payto", + paytoUri: opt.paytoUri, + qrCodes: getQrCodesForPayto(opt.paytoUri), + }); break; case "uri": - res.push(opt); + res.push({ type: "uri", uri: opt.uri }); break; default: continue; diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -324,7 +324,7 @@ export interface WalletDepositGroup { failReason?: TalerErrorDetail; kycInfo?: WalletDepositKycInfo; - kycAuthTransferOptions?: TransferOptionRaw[]; + kycAuthTransferOptions?: KycAuthTransferOptionRaw[]; kycAuthTransferExpiry?: TalerProtocolTimestamp; // FIXME: Do we need this and should it be in this object store? @@ -334,6 +334,19 @@ export interface WalletDepositGroup { } /** + * KYC auth transfer option persisted in the legacy flat options field. + * + * The optional metadata lets us recover the per-account withdrawal-style + * representation without duplicating the exchange account details in the + * deposit record. Older records contain plain TransferOptionRaw values and + * remain valid. + */ +export type KycAuthTransferOptionRaw = TransferOptionRaw & { + kycAuthAccountPaytoUri?: string; + kycAuthTransferExpiry?: TalerProtocolTimestamp; +}; + +/** * Status of recoup operations that were grouped together. * * The remaining amount of involved coins should be set to zero diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -766,7 +766,7 @@ CREATE TABLE IF NOT EXISTS deposit_groups ( fail_reason TEXT, -- JSON: WalletDepositKycInfo kyc_info TEXT, - -- JSON: TransferOptionRaw[] + -- JSON: KycAuthTransferOptionRaw[] (legacy TransferOptionRaw[] is valid) kyc_auth_transfer_options TEXT, kyc_auth_transfer_expiry INTEGER, -- JSON: wire transfer tracking, keyed by signature diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -3323,6 +3323,15 @@ export const conformanceCases: ConformanceCase[] = [ name: "deposit group: round trip and active range", async run(t, runner) { const dg = makeDepositGroup("dg-1"); + dg.kycAuthTransferOptions = [ + { + type: "payto", + paytoUri: "payto://iban/DE2?amount=TESTKUDOS%3A0.01&message=legacy", + kycAuthAccountPaytoUri: "payto://iban/DE2", + kycAuthTransferExpiry: TalerProtocolTimestamp.fromSeconds(7777), + }, + ]; + dg.kycAuthTransferExpiry = TalerProtocolTimestamp.fromSeconds(7777); await runner.runReadWriteTx((tx) => tx.upsertDepositGroup(dg)); const got = await runner.runReadWriteTx((tx) => tx.getDepositGroup("dg-1"), diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -40,6 +40,7 @@ import { Exchange, ExchangeBatchDepositRequest, ExchangeRefundRequest, + ExchangeWireAccount, HttpStatusCode, KycAuthTransferInfo, Logger, @@ -64,8 +65,8 @@ import { TransactionMinorState, TransactionState, TransactionType, - TransferOptionRaw, URL, + WithdrawalExchangeAccountDetails, assertUnreachable, canonicalJson, checkDbInvariant, @@ -97,6 +98,7 @@ import { import { DepositElementStatus, DepositOperationStatus, + KycAuthTransferOptionRaw, RefreshOperationStatus, WalletDepositGroup, WalletDepositInfoPerExchange, @@ -160,6 +162,65 @@ import { augmentPaytoUrisForKycTransfer } from "./withdraw.js"; */ const logger = new Logger("deposits.ts"); +function normalizedPayto(paytoUri: string): string | undefined { + const parsed = Result.orUndefined( + Paytos.fromString(paytoUri, { allowUnsupported: true }), + ); + return parsed === undefined ? undefined : Paytos.toNormalizedString(parsed); +} + +function buildKycAuthTransferOptionsExt( + options: KycAuthTransferOptionRaw[], + accounts: ExchangeWireAccount[], + transferAmount: AmountString, + fallbackExpiry: TalerProtocolTimestamp | undefined, +): WithdrawalExchangeAccountDetails[] { + const usableAccounts = accounts.filter((acct) => !acct.conversion_url); + const onlyOneAccount = usableAccounts.length === 1; + const result: WithdrawalExchangeAccountDetails[] = []; + + for (const acct of usableAccounts) { + const normalizedAccount = normalizedPayto(acct.payto_uri); + const accountOptions = options.filter((option) => { + if (option.kycAuthAccountPaytoUri !== undefined) { + const normalizedOptionAccount = normalizedPayto( + option.kycAuthAccountPaytoUri, + ); + return ( + normalizedAccount !== undefined && + normalizedOptionAccount === normalizedAccount + ); + } + if (option.type === "uri") { + // Old records do not retain the account behind an opaque URI. It is + // unambiguous only when the exchange has one usable account. + return onlyOneAccount; + } + return ( + normalizedAccount !== undefined && + normalizedPayto(option.paytoUri) === normalizedAccount + ); + }); + const transferExpiry = + accountOptions.find( + (option) => option.kycAuthTransferExpiry !== undefined, + )?.kycAuthTransferExpiry ?? fallbackExpiry; + result.push({ + status: "ok", + paytoUri: acct.payto_uri, + transferAmount, + creditRestrictions: acct.credit_restrictions, + bankLabel: acct.bank_label, + priority: acct.priority, + transferOptions: augmentTransferOptions(accountOptions), + transferExpiry, + }); + } + + result.sort((x1, x2) => (x2.priority ?? 0) - (x1.priority ?? 0)); + return result; +} + export class DepositTransactionContext implements TransactionContext { readonly transactionId: TransactionIdStr; readonly taskId: TaskIdStr; @@ -264,6 +325,7 @@ export class DepositTransactionContext implements TransactionContext { } // FIXME: Query tiny amount from exchange. const amount: AmountString = `${dg.currency}:0.01`; + const rawTransferOptions = dg.kycAuthTransferOptions ?? []; kycAuthTransferInfo = { debitPaytoUri: dg.wire.payto_uri, accountPub: dg.merchantPub, @@ -273,11 +335,17 @@ export class DepositTransactionContext implements TransactionContext { dg.merchantPub, amount, ), - transferOptions: - dg.kycAuthTransferOptions == null - ? [] - : augmentTransferOptions(dg.kycAuthTransferOptions), + transferOptions: augmentTransferOptions(rawTransferOptions), transferExpiry: dg.kycAuthTransferExpiry, + transferOptionsExt: + exchangeWire === undefined + ? [] + : buildKycAuthTransferOptionsExt( + rawTransferOptions, + exchangeWire.wireInfo.accounts, + exchangeWire.tinyAmount, + dg.kycAuthTransferExpiry, + ), }; break; } @@ -1047,10 +1115,10 @@ async function getAuthTransferDetails( depositGroup: WalletDepositGroup, exchangeBaseUrl: string, ): Promise<{ - options: TransferOptionRaw[]; + options: KycAuthTransferOptionRaw[]; transferExpiry: TalerProtocolTimestamp | undefined; }> { - let options: TransferOptionRaw[] = []; + let options: KycAuthTransferOptionRaw[] = []; let transferExpiry: TalerProtocolTimestamp | undefined = undefined; const exch = await fetchFreshExchange(wex, exchangeBaseUrl); @@ -1063,7 +1131,13 @@ async function getAuthTransferDetails( transferAmount: exch.tinyAmount, isKyc: true, }); - options.push(...optRes.transferOptions); + options.push( + ...optRes.transferOptions.map((option) => ({ + ...option, + kycAuthAccountPaytoUri: acct.payto_uri, + kycAuthTransferExpiry: optRes.transferExpiry, + })), + ); if (!transferExpiry) { transferExpiry = optRes.transferExpiry; } else { @@ -1159,7 +1233,7 @@ async function processDepositGroupPendingKyc( logger.trace(`kyc check algo result: ${j2s(algoRes)}`); } - let options: TransferOptionRaw[] = []; + let options: KycAuthTransferOptionRaw[] = []; let transferExpiry: TalerProtocolTimestamp | undefined = undefined; if (algoRes.requiresAuth) { @@ -1353,7 +1427,7 @@ async function transitionToKycRequired( const ctx = new DepositTransactionContext(wex, depositGroupId); - let options: TransferOptionRaw[] = []; + let options: KycAuthTransferOptionRaw[] = []; let transferExpiry: TalerProtocolTimestamp | undefined = undefined; if (args.badKycAuth) { const res = await getAuthTransferDetails( diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -566,6 +566,7 @@ async function makeExchangeListItem( export interface ExchangeDetails { currency: string; + tinyAmount: AmountString; masterPublicKey: EddsaPublicKeyString; wireInfo: WireInfo; exchangeBaseUrl: string; @@ -586,6 +587,7 @@ export async function getExchangeDetailsInTx( } return { currency: det.currency, + tinyAmount: det.tinyAmount, masterPublicKey: det.masterPublicKey, wireInfo: det.wireInfo, exchangeBaseUrl: det.exchangeBaseUrl,