taler-typescript-core

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

commit 4c39df6c6488945275ecfc41bc1a8ad5b6bf80c8
parent 428d18b067b903e63313a4a4fe20e858b90bf329
Author: Florian Dold <florian@dold.me>
Date:   Tue,  8 Jul 2025 19:32:53 +0200

wallet-core: DD64 for deposit

Diffstat:
Mpackages/taler-wallet-core/src/db.ts | 5+++++
Mpackages/taler-wallet-core/src/deposits.ts | 308+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/taler-wallet-core/src/kyc.ts | 35+++++++++++++++++++++++++++++++++++
3 files changed, 216 insertions(+), 132 deletions(-)

diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -2082,6 +2082,11 @@ export interface DepositKycInfo { accessToken?: string; paytoHash: string; exchangeBaseUrl: string; + lastCheckStatus?: number | undefined; + lastCheckCode?: number | undefined; + lastRuleGen?: number | undefined; + lastAmlReview?: boolean | undefined; + lastDeny?: DbPreciseTimestamp | undefined; } export interface TombstoneRecord { diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -23,6 +23,7 @@ */ import { AbsoluteTime, + AccountLimit, AmountJson, AmountString, Amounts, @@ -79,6 +80,7 @@ import { parsePaytoUri, } from "@gnu-taler/taler-util"; import { + HttpResponse, readResponseJsonOrThrow, readSuccessResponseJsonOrThrow, readTalerErrorResponse, @@ -122,7 +124,12 @@ import { getScopeForAllExchanges, } from "./exchanges.js"; import { EddsaKeyPairStrings } from "./index.js"; -import { checkDepositHardLimitExceeded, getDepositLimitInfo } from "./kyc.js"; +import { + LimitCheckResult, + checkDepositHardLimitExceeded, + checkLimit, + getDepositLimitInfo, +} from "./kyc.js"; import { extractContractData, generateDepositPermissions, @@ -1064,7 +1071,15 @@ async function processDepositGroupPendingKyc( logger.info( `deposit group is in pending(kyc), but trying deposit anyway after two minutes since last attempt`, ); - return processDepositGroupPendingDeposit(wex, depositGroup); + switch (depositGroup.operationStatus) { + case DepositOperationStatus.PendingDepositKyc: + case DepositOperationStatus.PendingDeposit: + return await processDepositGroupPendingDeposit(wex, depositGroup); + case DepositOperationStatus.PendingAggregateKyc: + return await processDepositGroupTrack(wex, depositGroup); + default: + return TaskRunResult.backoff(); + } } const kycInfo = depositGroup.kycInfo; @@ -1077,69 +1092,141 @@ async function processDepositGroupPendingKyc( 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, ); - url.searchParams.set("lpt", "3"); - logger.info(`kyc url ${url.href}`); - const kycStatusRes = await cancelableLongPoll(wex, url, { - headers: { - ["Account-Owner-Signature"]: sigResp.sig, - ["Account-Owner-Pub"]: depositGroup.merchantPub, - }, - }); + + 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}`); + + 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}`, ); - if ( - kycStatusRes.status === HttpStatusCode.Ok || - kycStatusRes.status === HttpStatusCode.NoContent - ) { - const transitionInfo = await wex.db.runReadWriteTx( - { storeNames: ["depositGroups", "transactionsMeta"] }, - async (tx) => { - const newDg = await tx.depositGroups.get(depositGroupId); - if (!newDg) { - return; - } - const oldTxState = computeDepositTransactionStatus(newDg); - switch (newDg.operationStatus) { - case DepositOperationStatus.PendingAggregateKyc: - newDg.operationStatus = DepositOperationStatus.FinalizingTrack; - break; - case DepositOperationStatus.PendingDepositKyc: - newDg.operationStatus = DepositOperationStatus.PendingDeposit; - break; - default: - return; - } - await tx.depositGroups.put(newDg); - await ctx.updateTransactionMeta(tx); - const newTxState = computeDepositTransactionStatus(newDg); - applyNotifyTransition(tx.notify, ctx.transactionId, { - oldTxState, - newTxState, - balanceEffect: BalanceEffect.Any, - }); - }, - ); - } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - const statusResp = await readResponseJsonOrThrow( - kycStatusRes, - codecForAccountKycStatus(), - ); - logger.info(`kyc still pending (HTTP 202): ${j2s(statusResp)}`); - return TaskRunResult.longpollReturnedPending(); - } else { - throwUnexpectedRequestError( - kycStatusRes, - await readTalerErrorResponse(kycStatusRes), - ); + 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`); + return TaskRunResult.backoff(); } - return TaskRunResult.backoff(); + + let exposedLimits: AccountLimit[] | undefined = undefined; + + 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; + } + + 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; + } + } + + // Now store the result. + + return await wex.db.runReadWriteTx( + { storeNames: ["depositGroups", "transactionsMeta"] }, + async (tx) => { + const newDg = await tx.depositGroups.get(depositGroupId); + if (!newDg) { + return TaskRunResult.finished(); + } + const oldTxState = computeDepositTransactionStatus(newDg); + switch (newDg.operationStatus) { + case DepositOperationStatus.PendingAggregateKyc: + break; + case DepositOperationStatus.PendingDepositKyc: + break; + default: + return TaskRunResult.backoff(); + } + newDg.kycInfo = kycInfo; + await tx.depositGroups.put(newDg); + await ctx.updateTransactionMeta(tx); + const newTxState = computeDepositTransactionStatus(newDg); + applyNotifyTransition(tx.notify, ctx.transactionId, { + oldTxState, + newTxState, + balanceEffect: BalanceEffect.Any, + }); + return TaskRunResult.progress(); + }, + ); } async function processDepositGroupPendingKycAuth( @@ -1327,83 +1414,40 @@ async function transitionToKycRequired( const ctx = new DepositTransactionContext(wex, depositGroupId); - const sigResp = await wex.cryptoApi.signWalletKycAuth({ - accountPriv: depositGroup.merchantPriv, - accountPub: depositGroup.merchantPub, - }); - - const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl); - logger.info(`kyc-check url ${url.href}`); - logger.info(`account owner pub: ${depositGroup.merchantPub}`); - const kycStatusResp = await cancelableFetch(wex, url, { - headers: { - ["Account-Owner-Signature"]: sigResp.sig, - ["Account-Owner-Pub"]: depositGroup.merchantPub, + await wex.db.runReadWriteTx( + { storeNames: ["depositGroups", "transactionsMeta"] }, + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + return undefined; + } + const oldTxState = computeDepositTransactionStatus(dg); + switch (dg.operationStatus) { + case DepositOperationStatus.LegacyPendingTrack: + case DepositOperationStatus.FinalizingTrack: + dg.operationStatus = DepositOperationStatus.PendingAggregateKyc; + break; + case DepositOperationStatus.PendingDeposit: + dg.operationStatus = DepositOperationStatus.PendingDepositKyc; + break; + default: + return; + } + dg.kycInfo = { + exchangeBaseUrl: exchangeUrl, + paytoHash: kycPaytoHash, + }; + await tx.depositGroups.put(dg); + await ctx.updateTransactionMeta(tx); + const newTxState = computeDepositTransactionStatus(dg); + applyNotifyTransition(tx.notify, ctx.transactionId, { + oldTxState, + newTxState, + balanceEffect: BalanceEffect.Any, + }); }, - }); - logger.trace(`response status of initial kyc-check: ${kycStatusResp.status}`); - - switch (kycStatusResp.status) { - case HttpStatusCode.Ok: { - logger.warn("kyc requested, but already fulfilled"); - return TaskRunResult.backoff(); - } - case HttpStatusCode.Conflict: { - return await transitionToKycAuthRequired( - wex, - depositGroup, - kycPaytoHash, - exchangeUrl, - ); - } - case HttpStatusCode.Accepted: { - const statusResp = await readResponseJsonOrThrow( - kycStatusResp, - codecForAccountKycStatus(), - ); - const transitionInfo = await wex.db.runReadWriteTx( - { storeNames: ["depositGroups", "transactionsMeta"] }, - async (tx) => { - const dg = await tx.depositGroups.get(depositGroupId); - if (!dg) { - return undefined; - } - const oldTxState = computeDepositTransactionStatus(dg); - switch (dg.operationStatus) { - case DepositOperationStatus.LegacyPendingTrack: - case DepositOperationStatus.FinalizingTrack: - dg.operationStatus = DepositOperationStatus.PendingAggregateKyc; - break; - case DepositOperationStatus.PendingDeposit: - dg.operationStatus = DepositOperationStatus.PendingDepositKyc; - break; - default: - return; - } - dg.kycInfo = { - exchangeBaseUrl: exchangeUrl, - paytoHash: kycPaytoHash, - accessToken: statusResp.access_token, - }; - await tx.depositGroups.put(dg); - await ctx.updateTransactionMeta(tx); - const newTxState = computeDepositTransactionStatus(dg); - applyNotifyTransition(tx.notify, ctx.transactionId, { - oldTxState, - newTxState, - balanceEffect: BalanceEffect.Any, - }); - }, - ); - return TaskRunResult.progress(); - } - default: { - throwUnexpectedRequestError( - kycStatusResp, - await readTalerErrorResponse(kycStatusResp), - ); - } - } + ); + return TaskRunResult.progress(); } async function transitionToKycAuthRequired( @@ -1416,7 +1460,7 @@ async function transitionToKycAuthRequired( const ctx = new DepositTransactionContext(wex, depositGroupId); - const transitionInfo = await wex.db.runReadWriteTx( + await wex.db.runReadWriteTx( { storeNames: ["depositGroups", "transactionsMeta"] }, async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts @@ -15,6 +15,7 @@ */ import { + AccountLimit, AmountJson, AmountLike, Amounts, @@ -250,3 +251,37 @@ export function getWithdrawalLimitInfo( kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, }; } + +export enum LimitCheckResult { + Allowed = 0, + DeniedVerboten = 1, + DeniedSoft = 3, +} + +export async function checkLimit( + rules: AccountLimit[], + operation: string, + amount: AmountLike, +): Promise<LimitCheckResult> { + let applicableLimit: AccountLimit | undefined; + for (const rule of rules) { + // Check if a rule applies and is more specific + // (smaller threshold) + // than the previously handled rule (if any). + if ( + rule.operation_type === operation && + Amounts.cmp(amount, rule.threshold) >= 0 && + (applicableLimit == null || + Amounts.cmp(rule.threshold, applicableLimit.threshold) <= 0) + ) { + applicableLimit = rule; + } + } + if (applicableLimit == null) { + return LimitCheckResult.Allowed; + } + if (applicableLimit.soft_limit) { + return LimitCheckResult.DeniedSoft; + } + return LimitCheckResult.DeniedVerboten; +}