commit 53931b5acadf0e136fc006fd043886496569499b
parent 852165ddc2070c61f1e43e395fe09de4bc35cfbf
Author: Florian Dold <florian@dold.me>
Date: Mon, 15 Jun 2026 18:27:01 +0200
wallet-core: support prepared transfers for KYC auth in deposits
Diffstat:
7 files changed, 245 insertions(+), 91 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -60,6 +60,7 @@ import {
TalerErrorDetail,
TransactionIdStr,
TransactionStateFilter,
+ TransferOption,
WithdrawalExchangeAccountDetails,
codecForScopeInfo,
} from "./types-taler-wallet.js";
@@ -332,16 +333,32 @@ export interface KycAuthTransferInfo {
accountPub: string;
/**
+ * Options for making the KYC auth transfer payment
+ * to the exchange.
+ */
+ transferOptions: TransferOption[];
+
+ /**
+ * Validity of the transferOptions or undefined
+ * if transferOptions does not expire.
+ */
+ transferExpiry: TalerProtocolTimestamp | undefined;
+
+ /**
* Amount that the exchange expects to be deposited.
*
* Usually corresponds to the TINY_AMOUNT configuration of the exchange,
* and thus is the smallest amount that can be transferred
* via a bank transfer.
+ *
+ * @deprecated Use transferOptions instead.
*/
amount: AmountString;
/**
* Possible target payto URIs.
+ *
+ * @deprecated Use transferOptions instead.
*/
creditPaytoUris: string[];
}
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -3954,7 +3954,7 @@ export interface WithdrawalExchangeAccountDetails {
* Depending on whether the (manual!) withdrawal is accepted or just
* being checked, this already includes the subject with the
* reserve public key.
- *
+ *
* Wallet UIs should never show instructions to send money to the exchange's payto
*/
paytoUri: string;
@@ -4041,6 +4041,30 @@ export interface TransferOptionSwissQrBill {
qrCodes: QrCodeSpec[];
}
+/**
+ * Raw transfer option, without QR code.
+ */
+export type TransferOptionRaw =
+ | TransferOptionRawPayto
+ | TransferOptionRawUri
+ | TransferOptionRawSwissQrBill;
+
+export interface TransferOptionRawPayto {
+ type: "payto";
+ paytoUri: string;
+}
+
+export interface TransferOptionRawUri {
+ type: "uri";
+ uri: string;
+}
+
+export interface TransferOptionRawSwissQrBill {
+ type: "ch-qr-bill";
+ paytoUri: string;
+ qrReferenceNumber: string;
+}
+
export interface PrepareWithdrawExchangeRequest {
/**
* A taler://withdraw-exchange URI.
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -18,6 +18,7 @@
import {
AbsoluteTime,
AmountJson,
+ AmountString,
Amounts,
AsyncFlag,
CoinRefreshRequest,
@@ -28,6 +29,7 @@ import {
ExchangeEntryStatus,
ExchangeTosStatus,
ExchangeUpdateStatus,
+ ExchangeWireAccount,
Logger,
ObservabilityEventType,
RefreshReason,
@@ -35,17 +37,27 @@ import {
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
+ TalerPreparedTransferHttpClient,
TalerProtocolTimestamp,
TombstoneIdStr,
Transaction,
TransactionIdStr,
TransactionMajorState,
TransactionState,
+ TransferOption,
+ TransferOptionRaw,
WalletNotification,
+ addPaytoQueryParams,
assertUnreachable,
checkDbInvariant,
checkLogicInvariant,
+ decodeCrock,
durationMul,
+ eddsaSign,
+ encodeCrock,
+ getQrCodesForPayto,
+ j2s,
+ succeedOrThrow,
} from "@gnu-taler/taler-util";
import { HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http";
import {
@@ -1141,3 +1153,122 @@ export async function getGenericRecordHandle<T>(
};
return [rec, { rec, update }];
}
+
+export interface TransferOptionsRawResult {
+ transferOptions: TransferOptionRaw[];
+ transferExpiry: TalerProtocolTimestamp;
+}
+
+export async function getTransferOptionsRaw(
+ wex: WalletExecutionContext,
+ req: {
+ acct: ExchangeWireAccount;
+ reservePub: string;
+ reservePriv: string;
+ transferAmount: AmountString;
+ },
+): Promise<TransferOptionsRawResult> {
+ const { acct, reservePub, reservePriv, transferAmount } = req;
+ if (!acct.prepared_transfer_url) {
+ let paytoUri = acct.payto_uri;
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ amount: Amounts.stringify(transferAmount),
+ });
+ if (reservePub != null) {
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ message: `Taler ${reservePub}`,
+ });
+ }
+ return {
+ transferOptions: [
+ {
+ type: "payto",
+ paytoUri,
+ },
+ ],
+ transferExpiry: TalerProtocolTimestamp.never(),
+ };
+ }
+ const options: TransferOption[] = [];
+ const prepClient = new TalerPreparedTransferHttpClient(
+ acct.prepared_transfer_url,
+ { httpClient: wex.http },
+ );
+ const mySig = eddsaSign(decodeCrock(reservePub), decodeCrock(reservePriv));
+ const resp = succeedOrThrow(
+ await prepClient.register({
+ credit_account: acct.payto_uri,
+ account_pub: reservePub,
+ alg: "EdDSA",
+ authorization_pub: reservePub,
+ authorization_sig: encodeCrock(mySig),
+ credit_amount: transferAmount,
+ recurrent: false,
+ type: "reserve",
+ }),
+ );
+ for (const opt of resp.subjects) {
+ switch (opt.type) {
+ case "SIMPLE": {
+ const myPayto = addPaytoQueryParams(acct.payto_uri, {
+ message: opt.subject,
+ amount: transferAmount,
+ });
+ options.push({
+ type: "payto",
+ paytoUri: myPayto,
+ qrCodes: getQrCodesForPayto(myPayto),
+ });
+ break;
+ }
+ case "URI": {
+ options.push({
+ type: "uri",
+ uri: opt.uri,
+ });
+ break;
+ }
+ case "CH_QR_BILL": {
+ const myPayto = addPaytoQueryParams(acct.payto_uri, {
+ "ch-qrr": opt.qr_reference_number,
+ amount: transferAmount,
+ });
+ options.push({
+ type: "ch-qr-bill",
+ paytoUri: myPayto,
+ qrReferenceNumber: opt.qr_reference_number,
+ qrCodes: getQrCodesForPayto(myPayto),
+ });
+ break;
+ }
+ default:
+ assertUnreachable;
+ logger.warn(`Unsupported transfer subject: ${j2s(opt)}`);
+ break;
+ }
+ }
+ return {
+ transferOptions: options,
+ transferExpiry: resp.expiration,
+ };
+}
+
+export function augmentTransferOptions(
+ options: TransferOptionRaw[],
+): TransferOption[] {
+ const res: TransferOption[] = [];
+ for (const opt of options) {
+ switch (opt.type) {
+ case "ch-qr-bill":
+ case "payto":
+ res.push({ ...opt, qrCodes: getQrCodesForPayto(opt.paytoUri) });
+ break;
+ case "uri":
+ res.push(opt);
+ break;
+ default:
+ continue;
+ }
+ }
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -71,6 +71,7 @@ import {
TokenIssuePublicKey,
TokenUseSig,
TransactionIdStr,
+ TransferOptionRaw,
UnblindedDenominationSignature,
WireInfo,
WithdrawalExchangeAccountDetails,
@@ -665,6 +666,8 @@ export interface ExchangeDetailsRecord {
*/
protocolVersionRange: string;
+ tinyAmount: AmountString;
+
reserveClosingDelay: TalerProtocolDuration;
shoppingUrl?: string;
@@ -2131,6 +2134,8 @@ export interface DepositGroupRecord {
failReason?: TalerErrorDetail;
kycInfo?: DepositKycInfo;
+ kycAuthTransferOptions?: TransferOptionRaw[];
+ kycAuthTransferExpiry?: TalerProtocolTimestamp;
// FIXME: Do we need this and should it be in this object store?
trackingState?: {
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -62,6 +62,7 @@ import {
TransactionMinorState,
TransactionState,
TransactionType,
+ TransferOptionRaw,
URL,
assertUnreachable,
canonicalJson,
@@ -96,11 +97,13 @@ import {
TaskIdStr,
TaskRunResult,
TransactionContext,
+ augmentTransferOptions,
cancelableFetch,
cancelableLongPoll,
constructTaskIdentifier,
genericWaitForState,
getGenericRecordHandle,
+ getTransferOptionsRaw,
runWithClientCancellation,
spendCoins,
} from "./common.js";
@@ -273,6 +276,11 @@ export class DepositTransactionContext implements TransactionContext {
dg.merchantPub,
amount,
),
+ transferOptions:
+ dg.kycAuthTransferOptions == null
+ ? []
+ : augmentTransferOptions(dg.kycAuthTransferOptions),
+ transferExpiry: dg.kycAuthTransferExpiry,
};
break;
}
@@ -1108,13 +1116,39 @@ async function processDepositGroupPendingKyc(
kycInfo.lastRuleGen = algoRes.updatedStatus.lastRuleGen;
kycInfo.accessToken = algoRes.updatedStatus.accessToken;
kycInfo.lastBadKycAuth = algoRes.updatedStatus.lastBadKycAuth;
-
const requiresAuth = algoRes.requiresAuth;
if (logger.shouldLogTrace()) {
logger.trace(`kyc check algo result: ${j2s(algoRes)}`);
}
+ let options: TransferOptionRaw[] = [];
+ let transferExpiry: TalerProtocolTimestamp | undefined = undefined;
+
+ if (algoRes.requiresAuth) {
+ const exch = await fetchFreshExchange(wex, kycInfo.exchangeBaseUrl);
+
+ for (const acct of exch.wireInfo.accounts) {
+ const optRes = await getTransferOptionsRaw(wex, {
+ acct,
+ reservePriv: depositGroup.merchantPriv,
+ reservePub: depositGroup.merchantPub,
+ transferAmount: exch.tinyAmount,
+ });
+ options.push(...optRes.transferOptions);
+ if (!transferExpiry) {
+ transferExpiry = optRes.transferExpiry;
+ } else {
+ transferExpiry = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.min(
+ AbsoluteTime.fromProtocolTimestamp(optRes.transferExpiry),
+ AbsoluteTime.fromProtocolTimestamp(transferExpiry),
+ ),
+ );
+ }
+ }
+ }
+
// Now store the result.
return await wex.runLegacyWalletDbTx(async (tx) => {
@@ -1130,6 +1164,8 @@ async function processDepositGroupPendingKyc(
break;
case DepositOperationStatus.PendingDepositKyc:
if (requiresAuth) {
+ newDg.kycAuthTransferOptions = options;
+ newDg.kycAuthTransferExpiry = transferExpiry;
newDg.operationStatus = DepositOperationStatus.PendingDepositKycAuth;
}
break;
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -1257,6 +1257,7 @@ export interface ReadyExchangeSummary {
tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
scopeInfo: ScopeInfo;
walletBalanceLimitWithoutKyc: AmountString[] | undefined;
+ tinyAmount: AmountString;
zeroLimits: ZeroLimitedOperation[];
hardLimits: AccountLimit[];
}
@@ -1531,6 +1532,7 @@ function constructReadyExchangeSummary(
tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
exchangeRec.tosAcceptedTimestamp,
),
+ tinyAmount: exchangeDetails.tinyAmount,
scopeInfo,
walletBalanceLimitWithoutKyc: exchangeDetails.walletBalanceLimits,
hardLimits: exchangeDetails.hardLimits ?? [],
@@ -1993,6 +1995,10 @@ export async function updateExchangeFromUrlHandler(
bankComplianceLanguage: keysInfo.bank_compliance_language,
shoppingUrl: keysInfo.shopping_url,
defaultPeerPushExpiration: keysInfo.default_p2p_push_expiration,
+ tinyAmount:
+ keysInfo.tiny_amount != null
+ ? keysInfo.tiny_amount
+ : `${keysInfo.currency}:0.01`,
};
r.noFees = noFees;
r.peerPaymentsDisabled = peerPaymentsDisabled;
@@ -2571,19 +2577,27 @@ export class DenomLossTransactionContext implements TransactionContext {
}
userAbortTransaction(): Promise<void> {
- throw new Error("Method not implemented - DenomLossTransactionContext.userAbortTransaction");
+ throw new Error(
+ "Method not implemented - DenomLossTransactionContext.userAbortTransaction",
+ );
}
userSuspendTransaction(): Promise<void> {
- throw new Error("Method not implemented - DenomLossTransactionContext.userSuspendTransaction");
+ throw new Error(
+ "Method not implemented - DenomLossTransactionContext.userSuspendTransaction",
+ );
}
userResumeTransaction(): Promise<void> {
- throw new Error("Method not implemented - DenomLossTransactionContext.userResumeTransaction");
+ throw new Error(
+ "Method not implemented - DenomLossTransactionContext.userResumeTransaction",
+ );
}
userFailTransaction(): Promise<void> {
- throw new Error("Method not implemented - DenomLossTransactionContext.userResumeTransaction");
+ throw new Error(
+ "Method not implemented - DenomLossTransactionContext.userResumeTransaction",
+ );
}
async userDeleteTransaction(): Promise<void> {
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -65,7 +65,6 @@ import {
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
- TalerPreparedTransferHttpClient,
TalerProtocolTimestamp,
TalerUriAction,
Transaction,
@@ -97,11 +96,8 @@ import {
codecForExchangeWithdrawResponse,
codecForLegitimizationNeededResponse,
codecForReserveStatus,
- decodeCrock,
- eddsaSign,
encodeCrock,
getErrorDetailFromException,
- getQrCodesForPayto,
getRandomBytes,
j2s,
makeErrorDetail,
@@ -125,12 +121,14 @@ import {
TaskRunResult,
TaskRunResultType,
TransactionContext,
+ augmentTransferOptions,
cancelableFetch,
cancelableLongPoll,
constructTaskIdentifier,
genericWaitForState,
genericWaitForStateVal,
getGenericRecordHandle,
+ getTransferOptionsRaw,
makeCoinAvailable,
makeCoinsVisible,
requireExchangeTosAcceptedOrThrow,
@@ -4134,7 +4132,6 @@ async function fetchAccount(
reservePub?: string,
reservePriv?: string,
): Promise<WithdrawalExchangeAccountDetails> {
- let paytoUri: string;
let transferAmount: AmountString | undefined;
let currencySpecification: CurrencySpecification | undefined = undefined;
if (acct.conversion_url != null) {
@@ -4157,7 +4154,6 @@ async function fetchAccount(
};
}
const resp = respOrErr.response;
- paytoUri = acct.payto_uri;
transferAmount = resp.amount_debit;
const configUrl = new URL("config", acct.conversion_url);
const configResp = await cancelableFetch(wex, configUrl);
@@ -4176,7 +4172,6 @@ async function fetchAccount(
const configParsed = configRespOrError.response;
currencySpecification = configParsed.fiat_currency_specification;
} else {
- paytoUri = acct.payto_uri;
transferAmount = Amounts.stringify(instructedAmount);
// fetch currency specification from DB
@@ -4188,89 +4183,21 @@ async function fetchAccount(
currencySpecification = resp.currencySpec;
}
}
- paytoUri = addPaytoQueryParams(paytoUri, {
- amount: Amounts.stringify(transferAmount),
- });
- if (reservePub != null) {
- paytoUri = addPaytoQueryParams(paytoUri, {
- message: `Taler ${reservePub}`,
- });
- }
- const options: TransferOption[] = [];
+ let options: TransferOption[] = [];
let transferExpiry: TalerProtocolTimestamp | undefined = undefined;
- if (
- reservePub != null &&
- reservePriv != null &&
- acct.prepared_transfer_url != null
- ) {
- const prepClient = new TalerPreparedTransferHttpClient(
- acct.prepared_transfer_url,
- { httpClient: wex.http },
- );
- const mySig = eddsaSign(decodeCrock(reservePub), decodeCrock(reservePriv));
- const resp = succeedOrThrow(
- await prepClient.register({
- credit_account: acct.payto_uri,
- account_pub: reservePub,
- alg: "EdDSA",
- authorization_pub: reservePub,
- authorization_sig: encodeCrock(mySig),
- credit_amount: transferAmount,
- recurrent: false,
- type: "reserve",
- }),
- );
- for (const opt of resp.subjects) {
- switch (opt.type) {
- case "SIMPLE": {
- const myPayto = addPaytoQueryParams(acct.payto_uri, {
- message: opt.subject,
- amount: transferAmount,
- });
- options.push({
- type: "payto",
- paytoUri: myPayto,
- qrCodes: getQrCodesForPayto(myPayto),
- });
- break;
- }
- case "URI": {
- options.push({
- type: "uri",
- uri: opt.uri,
- });
- break;
- }
- case "CH_QR_BILL": {
- const myPayto = addPaytoQueryParams(acct.payto_uri, {
- "ch-qrr": opt.qr_reference_number,
- amount: transferAmount,
- });
- options.push({
- type: "ch-qr-bill",
- paytoUri: myPayto,
- qrReferenceNumber: opt.qr_reference_number,
- qrCodes: getQrCodesForPayto(myPayto),
- });
- break;
- }
- default:
- assertUnreachable;
- logger.warn(`Unsupported transfer subject: ${j2s(opt)}`);
- break;
- }
- }
- transferExpiry = resp.expiration;
- } else {
- options.push({
- type: "payto",
- paytoUri,
- qrCodes: getQrCodesForPayto(paytoUri),
+ if (reservePub != null && reservePriv != null) {
+ const optRes = await getTransferOptionsRaw(wex, {
+ acct,
+ reservePriv,
+ reservePub,
+ transferAmount,
});
+ options = augmentTransferOptions(optRes.transferOptions);
+ transferExpiry = optRes.transferExpiry;
}
const acctInfo: WithdrawalExchangeAccountDetails = {
status: "ok",
- paytoUri,
+ paytoUri: acct.payto_uri,
transferAmount,
bankLabel: acct.bank_label,
priority: acct.priority,