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:
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;
+}