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