taler-typescript-core

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

commit 4dfd246f086a3cdde84a74184ba3d2deb436ce10
parent 96461ab529e5cfa07f100268681bb34aae41c0fb
Author: Florian Dold <florian@dold.me>
Date:   Tue,  8 Jul 2025 23:54:11 +0200

wallet-core: DD64 for withdrawal

Diffstat:
Mpackages/taler-wallet-core/src/db.ts | 6++++++
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 10++--------
Mpackages/taler-wallet-core/src/withdraw.ts | 188+++++++++++++++++++++++++++++++------------------------------------------------
3 files changed, 82 insertions(+), 122 deletions(-)

diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -1708,6 +1708,12 @@ export interface WithdrawalGroupRecord { kycAccessToken?: string; + kycLastCheckStatus?: number | undefined; + kycLastCheckCode?: number | undefined; + kycLastRuleGen?: number | undefined; + kycLastAmlReview?: boolean | undefined; + kycLastDeny?: DbPreciseTimestamp | undefined; + /** * Delay to wait until the next withdrawal attempt. * diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -20,7 +20,6 @@ import { AmountJson, Amounts, ExchangePurseStatus, - HttpStatusCode, NotificationType, SelectedProspectiveCoin, TalerProtocolTimestamp, @@ -28,7 +27,6 @@ import { TransactionMajorState, TransactionState, WalletNotification, - assertUnreachable, checkDbInvariant, } from "@gnu-taler/taler-util"; import { TransitionResultType } from "./common.js"; @@ -45,11 +43,7 @@ import { } from "./db.js"; import { getTotalRefreshCost } from "./refresh.js"; import { BalanceEffect, applyNotifyTransition } from "./transactions.js"; -import { - WalletExecutionContext, - getDenomInfo, - walletExchangeClient, -} from "./wallet.js"; +import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** * Get information about the coin selected for signatures. @@ -224,7 +218,7 @@ export async function recordCreate< const storeNames = opts.extraStores ? [...baseStore, ...opts.extraStores] : baseStore; - const transitionInfo = await ctx.wex.db.runReadWriteTx( + await ctx.wex.db.runReadWriteTx( { storeNames, label: opts.label }, async (tx) => { const oldTxState: TransactionState = { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -23,7 +23,6 @@ import { AbsoluteTime, AcceptManualWithdrawalResult, AcceptWithdrawalResponse, - AccountKycStatus, AgeRestriction, Amount, AmountJson, @@ -86,7 +85,7 @@ import { checkAccountRestriction, checkDbInvariant, checkLogicInvariant, - codecForAccountKycStatus, + checkProtocolInvariant, codecForBankWithdrawalOperationPostResponse, codecForBankWithdrawalOperationStatus, codecForCashinConversionResponse, @@ -178,8 +177,11 @@ import { markExchangeUsed, } from "./exchanges.js"; import { + GenericKycStatusReq, checkWithdrawalHardLimitExceeded, getWithdrawalLimitInfo, + isKycOperationDue, + runKycCheckAlgo, } from "./kyc.js"; import { DbAccess } from "./query.js"; import { @@ -1440,7 +1442,7 @@ interface WithdrawalBatchResult { * Transition a transaction from pending(ready) * into a pending(kyc|aml) state, in case KYC is required. */ -async function handleKycRequired( +async function transitionKycRequired( wex: WalletExecutionContext, withdrawalGroup: WithdrawalGroupRecord, resp: HttpResponse, @@ -1453,42 +1455,6 @@ async function handleKycRequired( codecForLegitimizationNeededResponse().decode(respJson); const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); - logger.info(`kyc uuid response: ${j2s(legiRequiredResp)}`); - const exchangeUrl = withdrawalGroup.exchangeBaseUrl; - const sigResp = await wex.cryptoApi.signWalletKycAuth({ - accountPriv: withdrawalGroup.reservePriv, - accountPub: withdrawalGroup.reservePub, - }); - const url = new URL(`kyc-check/${legiRequiredResp.h_payto}`, exchangeUrl); - logger.info(`kyc url ${url.href}`); - // We do not longpoll here, as this is the initial request to get information about the KYC. - const kycStatusRes = await cancelableFetch(wex, url, { - headers: { - ["Account-Owner-Signature"]: sigResp.sig, - ["Account-Owner-Pub"]: withdrawalGroup.reservePub, - }, - }); - let kycStatus: AccountKycStatus; - if ( - kycStatusRes.status === HttpStatusCode.Ok || - // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge - // remove after the exchange is fixed or clarified - kycStatusRes.status === HttpStatusCode.NoContent - ) { - logger.warn("kyc requested, but already fulfilled"); - return; - } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - kycStatus = await readSuccessResponseJsonOrThrow( - kycStatusRes, - codecForAccountKycStatus(), - ); - logger.info(`kyc status: ${j2s(kycStatus)}`); - } else { - throwUnexpectedRequestError( - kycStatusRes, - await readTalerErrorResponse(kycStatusRes), - ); - } await ctx.transition( { @@ -1513,7 +1479,7 @@ async function handleKycRequired( return TransitionResult.stay(); } wg2.kycPaytoHash = legiRequiredResp.h_payto; - wg2.kycAccessToken = kycStatus.access_token; + wg2.kycLastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); wg2.status = WithdrawalGroupStatus.PendingKyc; return TransitionResult.transition(wg2); }, @@ -1634,7 +1600,13 @@ async function processPlanchetExchangeLegacyBatchRequest( timeout: Duration.fromSpec({ seconds: 40 }), }); if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { - await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs); + await transitionKycRequired( + wex, + withdrawalGroup, + resp, + 0, + requestCoinIdxs, + ); return { batchResp: { ev_sigs: [] }, coinIdxs: [], @@ -1795,7 +1767,13 @@ async function processPlanchetExchangeBatchRequest( timeout: Duration.fromSpec({ seconds: 40 }), }); if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { - await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs); + await transitionKycRequired( + wex, + withdrawalGroup, + resp, + 0, + requestCoinIdxs, + ); return { batchResp: { ev_sigs: [] }, coinIdxs: [], @@ -2300,10 +2278,6 @@ async function processWithdrawalGroupAbortingBank( return TaskRunResult.finished(); } -const KYC_WITHDRAWAL_WAIT = Duration.fromTalerProtocolDuration( - Duration.toTalerProtocolDuration(Duration.fromSpec({ minutes: 10 })), -); - async function processWithdrawalGroupPendingKyc( wex: WalletExecutionContext, withdrawalGroup: WithdrawalGroupRecord, @@ -2316,77 +2290,63 @@ async function processWithdrawalGroupPendingKyc( if (!kycPaytoHash) { throw Error("no kyc info available in pending(kyc)"); } - const sigResp = await wex.cryptoApi.signWalletKycAuth({ - accountPriv: withdrawalGroup.reservePriv, - accountPub: withdrawalGroup.reservePub, - }); - const url = new URL( - `kyc-check/${kycPaytoHash}`, - withdrawalGroup.exchangeBaseUrl, + + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + checkDbInvariant( + !!exchangeBaseUrl, + "exchange base URL must be known for KYC", ); - url.searchParams.set("lpt", "3"); // wait for the KYC status to be OK - logger.info(`long-polling for withdrawal KYC status via ${url.href}`); - const kycStatusRes = await cancelableLongPoll(wex, url, { - headers: { - ["Account-Owner-Signature"]: sigResp.sig, - ["Account-Owner-Pub"]: withdrawalGroup.reservePub, - }, - }); + const accountPub = withdrawalGroup.reservePub; + const accountPriv = withdrawalGroup.reservePriv; - logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`); - if ( - kycStatusRes.status === HttpStatusCode.Ok || - kycStatusRes.status === HttpStatusCode.NoContent - ) { - await ctx.transition({}, async (rec) => { - if (!rec) { - return TransitionResult.stay(); - } - switch (rec.status) { - case WithdrawalGroupStatus.PendingKyc: { - delete rec.kycAccessToken; - delete rec.kycAccessToken; - rec.status = WithdrawalGroupStatus.PendingReady; - return TransitionResult.transition(rec); - } - default: - return TransitionResult.stay(); - } - }); - } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - logger.info("kyc not done yet, long-poll remains pending"); - // We know that KYC isn't done, but we don't know whether - // it's required for the withdrawal. It might only - // be required for a *different* operation. - // Thus we attempt withdrawal after a delay. - await ctx.transition({}, async (rec) => { - if (!rec) { - return TransitionResult.stay(); - } - switch (rec.status) { - case WithdrawalGroupStatus.PendingKyc: { - const start = AbsoluteTime.fromPreciseTimestamp( - timestampPreciseFromDb(rec.timestampStart), - ); - const end = AbsoluteTime.addDuration(start, KYC_WITHDRAWAL_WAIT); - if (AbsoluteTime.isExpired(end)) { - // KYC still required but maybe not for withdrawal operation - // try withdrawing just in case - rec.status = WithdrawalGroupStatus.PendingReady; - return TransitionResult.transition(rec); - } - // Try withdrawal again. - return TransitionResult.stay(); - } - default: - return TransitionResult.stay(); - } - }); - return TaskRunResult.progress(); - } else { - throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + let myKycState: GenericKycStatusReq | undefined; + + const amount = withdrawalGroup.rawWithdrawalAmount; + checkDbInvariant(!!amount, "amount must be known for KYC"); + + if (withdrawalGroup.kycPaytoHash) { + myKycState = { + accountPriv, + accountPub, + amount, + exchangeBaseUrl, + operation: "WITHDRAW", + paytoHash: withdrawalGroup.kycPaytoHash, + lastAmlReview: withdrawalGroup.kycLastAmlReview, + lastCheckCode: withdrawalGroup.kycLastCheckCode, + lastCheckStatus: withdrawalGroup.kycLastCheckStatus, + lastDeny: withdrawalGroup.kycLastDeny, + lastRuleGen: withdrawalGroup.kycLastRuleGen, + }; } - return TaskRunResult.backoff(); + + if (myKycState == null || isKycOperationDue(myKycState)) { + return processWithdrawalGroupPendingReady(wex, withdrawalGroup); + } + + const algoRes = await runKycCheckAlgo(wex, myKycState); + + if (!algoRes.updatedStatus) { + return algoRes.taskResult; + } + + const updatedStatus = algoRes.updatedStatus; + + checkProtocolInvariant(algoRes.requiresAuth != true); + + await ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + rec.kycLastAmlReview = updatedStatus.lastAmlReview; + rec.kycLastCheckStatus = updatedStatus.lastCheckStatus; + rec.kycLastCheckCode = updatedStatus.lastCheckCode; + rec.kycLastDeny = updatedStatus.lastDeny; + rec.kycLastRuleGen = updatedStatus.lastRuleGen; + return TransitionResult.transition(rec); + }); + + return algoRes.taskResult; } /**