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:
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,