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:
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;
}
/**