taler-typescript-core

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

commit 54ead87bbea0d1c8a8ba49ab9f3e345161153de0
parent 4c39df6c6488945275ecfc41bc1a8ad5b6bf80c8
Author: Florian Dold <florian@dold.me>
Date:   Tue,  8 Jul 2025 20:59:42 +0200

wallet-core: factor out generic DD64 algo

Allows shared implementation with other transactions.

Diffstat:
Mpackages/taler-wallet-core/src/deposits.ts | 155+++++++++++++++++--------------------------------------------------------------
Mpackages/taler-wallet-core/src/kyc.ts | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 219 insertions(+), 122 deletions(-)

diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -23,7 +23,6 @@ */ import { AbsoluteTime, - AccountLimit, AmountJson, AmountString, Amounts, @@ -67,7 +66,6 @@ import { canonicalJson, checkDbInvariant, checkLogicInvariant, - codecForAccountKycStatus, codecForBatchDepositSuccess, codecForLegitimizationNeededResponse, codecForTackTransactionAccepted, @@ -80,7 +78,6 @@ import { parsePaytoUri, } from "@gnu-taler/taler-util"; import { - HttpResponse, readResponseJsonOrThrow, readSuccessResponseJsonOrThrow, readTalerErrorResponse, @@ -125,10 +122,11 @@ import { } from "./exchanges.js"; import { EddsaKeyPairStrings } from "./index.js"; import { - LimitCheckResult, + GenericKycStatusReq, checkDepositHardLimitExceeded, - checkLimit, getDepositLimitInfo, + isKycOperationDue, + runKycCheckAlgo, } from "./kyc.js"; import { extractContractData, @@ -1059,15 +1057,27 @@ async function processDepositGroupPendingKyc( const { depositGroupId } = depositGroup; const ctx = new DepositTransactionContext(wex, depositGroupId); - if ( - depositGroup.timestampLastDepositAttempt == null || - AbsoluteTime.isExpired( - AbsoluteTime.addDuration( - timestampAbsoluteFromDb(depositGroup.timestampLastDepositAttempt), - Duration.fromSpec({ minutes: 2 }), - ), - ) - ) { + const maybeKycInfo = depositGroup.kycInfo; + + let myKycState: GenericKycStatusReq | undefined; + + if (maybeKycInfo) { + myKycState = { + accountPriv: depositGroup.merchantPriv, + accountPub: depositGroup.merchantPub, + amount: depositGroup.amount, + operation: "DEPOSIT", + exchangeBaseUrl: maybeKycInfo.exchangeBaseUrl, + paytoHash: maybeKycInfo.paytoHash, + lastAmlReview: maybeKycInfo.lastAmlReview, + lastCheckCode: maybeKycInfo.lastCheckCode, + lastCheckStatus: maybeKycInfo.lastCheckStatus, + lastDeny: maybeKycInfo.lastDeny, + lastRuleGen: maybeKycInfo.lastRuleGen, + }; + } + + if (myKycState == null || isKycOperationDue(myKycState)) { logger.info( `deposit group is in pending(kyc), but trying deposit anyway after two minutes since last attempt`, ); @@ -1081,121 +1091,22 @@ async function processDepositGroupPendingKyc( return TaskRunResult.backoff(); } } - const kycInfo = depositGroup.kycInfo; - - if (!kycInfo) { - throw Error("invalid DB state, in pending(kyc), but no kycInfo present"); - } - - const sigResp = await wex.cryptoApi.signWalletKycAuth({ - accountPriv: depositGroup.merchantPriv, - accountPub: depositGroup.merchantPub, - }); - - const headers = { - ["Account-Owner-Signature"]: sigResp.sig, - ["Account-Owner-Pub"]: depositGroup.merchantPub, - }; - - const url = new URL( - `kyc-check/${kycInfo.paytoHash}`, - kycInfo.exchangeBaseUrl, - ); - - let doLongpoll: boolean; - - if (kycInfo.lastCheckCode == null) { - doLongpoll = false; - } else if ( - kycInfo.lastCheckStatus === HttpStatusCode.Forbidden || - kycInfo.lastCheckCode === HttpStatusCode.Conflict - ) { - doLongpoll = true; - url.searchParams.set("lpt", "1"); - } else if (kycInfo.lastAmlReview) { - doLongpoll = true; - url.searchParams.set("lpt", "1"); - if (kycInfo.lastRuleGen != null) { - url.searchParams.set("min_rule", `${kycInfo.lastRuleGen}`); - } - } else { - doLongpoll = true; - if (kycInfo.lastRuleGen != null) { - url.searchParams.set("min_rule", `${kycInfo.lastRuleGen}`); - } - } - logger.info(`kyc url ${url.href}, longpoll=${doLongpoll}`); + const algoRes = await runKycCheckAlgo(wex, myKycState); - let kycStatusRes: HttpResponse; - - if (doLongpoll) { - kycStatusRes = await cancelableLongPoll(wex, url, { - headers, - }); - } else { - kycStatusRes = await cancelableFetch(wex, url, { - headers, - }); - } - - logger.trace( - `request to ${kycStatusRes.requestUrl} returned status ${kycStatusRes.status}`, - ); - - const respJson = await kycStatusRes.json(); - - const sameResp = - kycStatusRes.status === kycInfo.lastCheckStatus && - respJson.code === kycInfo.lastCheckCode && - respJson.rule_gen === kycInfo.lastRuleGen; - - kycInfo.lastCheckStatus = kycStatusRes.status; - kycInfo.lastCheckCode = respJson.code; - kycInfo.lastRuleGen = respJson.rule_gen; - - if (sameResp) { - logger.trace(`kyc-check response didn't change, retrying with back-off`); + if (!algoRes) { return TaskRunResult.backoff(); } - let exposedLimits: AccountLimit[] | undefined = undefined; + checkLogicInvariant(!!maybeKycInfo); - switch (kycStatusRes.status) { - case HttpStatusCode.NoContent: - kycInfo.lastDeny = undefined; - break; - case HttpStatusCode.Ok: { - kycInfo.lastDeny = undefined; - break; - } - case HttpStatusCode.Accepted: - const resp = await readSuccessResponseJsonOrThrow( - kycStatusRes, - codecForAccountKycStatus(), - ); - exposedLimits = resp.limits; - break; - case HttpStatusCode.Forbidden: - // FIXME: Check if we know the key that the exchange - // claims as the current account pub for KYC. - break; - } + const kycInfo = maybeKycInfo; - if (exposedLimits) { - switch (await checkLimit(exposedLimits, "DEPOSIT", depositGroup.amount)) { - case LimitCheckResult.Allowed: - kycInfo.lastDeny = undefined; - break; - case LimitCheckResult.DeniedSoft: - kycInfo.lastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); - break; - case LimitCheckResult.DeniedVerboten: - // FIXME: This should transition the transaction to failed! - kycInfo.lastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); - break; - } - } + kycInfo.lastAmlReview = algoRes.lastAmlReview; + kycInfo.lastCheckStatus = algoRes.lastCheckStatus; + kycInfo.lastCheckCode = algoRes.lastCheckCode; + kycInfo.lastDeny = algoRes.lastDeny; + kycInfo.lastRuleGen = algoRes.lastRuleGen; // Now store the result. diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts @@ -15,19 +15,41 @@ */ import { + AbsoluteTime, AccountLimit, AmountJson, AmountLike, Amounts, AmountString, + codecForAccountKycStatus, + Duration, + HttpStatusCode, + Logger, + TalerPreciseTimestamp, } from "@gnu-taler/taler-util"; +import { + HttpResponse, + readSuccessResponseJsonOrThrow, +} from "@gnu-taler/taler-util/http"; +import { cancelableFetch, cancelableLongPoll } from "./common.js"; +import { + DbPreciseTimestamp, + timestampAbsoluteFromDb, + timestampPreciseToDb, +} from "./db.js"; import { ReadyExchangeSummary } from "./exchanges.js"; +import { WalletExecutionContext } from "./index.js"; /** * @fileoverview Helpers for KYC. * @author Florian Dold <dold@taler.net> */ +/** + * Logger. + */ +const logger = new Logger("kyc.ts"); + export interface SimpleLimitInfo { kycHardLimit: AmountString | undefined; kycSoftLimit: AmountString | undefined; @@ -285,3 +307,167 @@ export async function checkLimit( } return LimitCheckResult.DeniedVerboten; } + +export interface GenericKycStatusReq { + exchangeBaseUrl: string; + paytoHash: string; + accountPub: string; + accountPriv: string; + operation: string; + amount: AmountLike; + lastCheckStatus?: number | undefined; + lastCheckCode?: number | undefined; + lastRuleGen?: number | undefined; + lastAmlReview?: boolean | undefined; + lastDeny?: DbPreciseTimestamp | undefined; +} + +export interface GenericKycStatusResp { + accountPub: string; + accountPriv: string; + lastCheckStatus?: number | undefined; + lastCheckCode?: number | undefined; + lastRuleGen?: number | undefined; + lastAmlReview?: boolean | undefined; + lastDeny?: DbPreciseTimestamp | undefined; +} + +export function isKycOperationDue(st: GenericKycStatusReq): boolean { + return ( + st.lastDeny == null || + AbsoluteTime.isExpired( + AbsoluteTime.addDuration( + timestampAbsoluteFromDb(st.lastDeny), + Duration.fromSpec({ minutes: 2 }), + ), + ) + ); +} + +/** + * Run a single step of the kyc check algorithm. + * + * Returns the updated status if applicable or + * undefined if there was no progress/change + * and the kyc check algorithm should be re-executed + * with exponential back-off. + */ +export async function runKycCheckAlgo( + wex: WalletExecutionContext, + st: GenericKycStatusReq, +): Promise<GenericKycStatusResp | undefined> { + const sigResp = await wex.cryptoApi.signWalletKycAuth({ + accountPriv: st.accountPriv, + accountPub: st.accountPub, + }); + + const headers = { + ["Account-Owner-Signature"]: sigResp.sig, + ["Account-Owner-Pub"]: st.accountPub, + }; + + const url = new URL(`kyc-check/${st.paytoHash}`, st.exchangeBaseUrl); + + let doLongpoll: boolean; + + if (st.lastCheckCode == null) { + doLongpoll = false; + } else if ( + st.lastCheckStatus === HttpStatusCode.Forbidden || + st.lastCheckCode === HttpStatusCode.Conflict + ) { + doLongpoll = true; + url.searchParams.set("lpt", "1"); + } else if (st.lastAmlReview) { + doLongpoll = true; + url.searchParams.set("lpt", "1"); + if (st.lastRuleGen != null) { + url.searchParams.set("min_rule", `${st.lastRuleGen}`); + } + } else { + doLongpoll = true; + if (st.lastRuleGen != null) { + url.searchParams.set("min_rule", `${st.lastRuleGen}`); + } + } + + logger.info(`kyc url ${url.href}, longpoll=${doLongpoll}`); + + let kycStatusRes: HttpResponse; + + if (doLongpoll) { + kycStatusRes = await cancelableLongPoll(wex, url, { + headers, + }); + } else { + kycStatusRes = await cancelableFetch(wex, url, { + headers, + }); + } + + logger.trace( + `request to ${kycStatusRes.requestUrl} returned status ${kycStatusRes.status}`, + ); + + const respJson = await kycStatusRes.json(); + + const sameResp = + kycStatusRes.status === st.lastCheckStatus && + respJson.code === st.lastCheckCode && + respJson.rule_gen === st.lastRuleGen; + + if (sameResp) { + logger.trace(`kyc-check response didn't change, retrying with back-off`); + return undefined; + } + + const rst: GenericKycStatusResp = { + accountPriv: st.accountPriv, + accountPub: st.accountPub, + lastAmlReview: st.lastAmlReview, + lastCheckCode: st.lastCheckCode, + lastCheckStatus: st.lastCheckStatus, + lastDeny: st.lastDeny, + lastRuleGen: st.lastRuleGen, + }; + + let exposedLimits: AccountLimit[] | undefined = undefined; + + switch (kycStatusRes.status) { + case HttpStatusCode.NoContent: + rst.lastDeny = undefined; + break; + case HttpStatusCode.Ok: { + rst.lastDeny = undefined; + break; + } + case HttpStatusCode.Accepted: + const resp = await readSuccessResponseJsonOrThrow( + kycStatusRes, + codecForAccountKycStatus(), + ); + exposedLimits = resp.limits; + break; + case HttpStatusCode.Forbidden: + // FIXME: Check if we know the key that the exchange + // claims as the current account pub for KYC. + break; + } + + if (exposedLimits) { + switch (await checkLimit(exposedLimits, "DEPOSIT", st.amount)) { + case LimitCheckResult.Allowed: + rst.lastDeny = undefined; + break; + case LimitCheckResult.DeniedSoft: + rst.lastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); + break; + case LimitCheckResult.DeniedVerboten: + // FIXME: This should transition the transaction to failed! + rst.lastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); + break; + } + } + + return rst; +}