taler-typescript-core

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

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:
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 17+++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 26+++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/common.ts | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db.ts | 5+++++
Mpackages/taler-wallet-core/src/deposits.ts | 38+++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/exchanges.ts | 22++++++++++++++++++----
Mpackages/taler-wallet-core/src/withdraw.ts | 97++++++++++---------------------------------------------------------------------
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,