taler-typescript-core

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

commit ee49c83596184ee2243d00a2163971ee5b37180b
parent 0ea6d7f07beba25ba2f774ec9bc625bb75cff2a9
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:50 +0200

wallet-core: recover value after hard KYC limits

Diffstat:
Mpackages/taler-util/src/ReserveStatus.ts | 18+++++++++++++++++-
Mpackages/taler-util/src/http-client/exchange-client.ts | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Apackages/taler-util/src/http-client/exchange-reserve-close.test.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 1+
Mpackages/taler-wallet-core/src/balance.ts | 3+++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 17+++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-common.ts | 14++++++++++++++
Mpackages/taler-wallet-core/src/deposits.test.ts | 28++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 55++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/kyc.test.ts | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/kyc.ts | 36++++++++++++++++++++++++++++--------
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.test.ts | 31+++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Apackages/taler-wallet-core/src/pay-peer-push-credit.test.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/withdraw.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/withdraw.ts | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
18 files changed, 816 insertions(+), 25 deletions(-)

diff --git a/packages/taler-util/src/ReserveStatus.ts b/packages/taler-util/src/ReserveStatus.ts @@ -28,7 +28,8 @@ import { codecForString, codecOptional, } from "./codec.js"; -import { AmountString } from "./types-taler-common.js"; +import { TalerProtocolTimestamp } from "./time.js"; +import { AmountString, EddsaSignatureString } from "./types-taler-common.js"; /** * Status of a reserve. @@ -56,3 +57,18 @@ export const codecForReserveStatus = (): Codec<ReserveStatus> => .property("balance", codecForAmountString()) .property("last_origin", codecOptional(codecForString())) .build("ReserveStatus"); + +export interface ReserveCloseRequest { + reserve_sig: EddsaSignatureString; + request_timestamp: TalerProtocolTimestamp; + payto_uri?: string; +} + +export interface ReserveCloseResponse { + wire_amount: AmountString; +} + +export const codecForReserveCloseResponse = (): Codec<ReserveCloseResponse> => + buildCodecForObject<ReserveCloseResponse>() + .property("wire_amount", codecForAmountString()) + .build("ReserveCloseResponse"); diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -131,7 +131,13 @@ import { codecForTackTransactionAccepted, codecForTackTransactionWired, } from "../types-taler-exchange.js"; -import { ReserveStatus, codecForReserveStatus } from "../ReserveStatus.js"; +import { + ReserveCloseRequest, + ReserveCloseResponse, + ReserveStatus, + codecForReserveCloseResponse, + codecForReserveStatus, +} from "../ReserveStatus.js"; import { CacheEvictor, addLongPollingParam, @@ -1689,6 +1695,46 @@ export class TalerExchangeHttpClient { } /** + * https://docs.taler.net/core/api-exchange.html#post--reserves-$RESERVE_PUB-close + */ + async closeReserve( + reservePub: string, + body: ReserveCloseRequest, + ): Promise< + | OperationOk<ReserveCloseResponse> + | OperationFail<HttpStatusCode.BadRequest> + | OperationFail<HttpStatusCode.Forbidden> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Conflict> + | OperationAlternative< + HttpStatusCode.UnavailableForLegalReasons, + LegitimizationNeededResponse + > + > { + const resp = await this.fetch(`reserves/${reservePub}/close`, { + method: "POST", + body, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForReserveCloseResponse()); + case HttpStatusCode.UnavailableForLegalReasons: + return opKnownAlternativeHttpFailure( + resp, + resp.status, + codecForLegitimizationNeededResponse(), + ); + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** * https://docs.taler.net/core/api-exchange.html#get--reserves-$RESERVE_PUB-history */ async getReserveHistory( diff --git a/packages/taler-util/src/http-client/exchange-reserve-close.test.ts b/packages/taler-util/src/http-client/exchange-reserve-close.test.ts @@ -0,0 +1,44 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import assert from "node:assert"; +import { test } from "node:test"; +import { FakeHttpLib, ok } from "../http-fake.js"; +import { TalerProtocolTimestamp } from "../time.js"; +import { ReserveCloseRequest } from "../ReserveStatus.js"; +import { TalerExchangeHttpClient } from "./exchange-client.js"; + +test("closeReserve posts a return-to-origin request", async () => { + const http = new FakeHttpLib().on( + "POST", + "/reserves/reserve-pub/close", + ok({ wire_amount: "TESTKUDOS:3" }), + ); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: http, + }); + const request = { + reserve_sig: "reserve-signature", + request_timestamp: TalerProtocolTimestamp.fromSeconds(1_760_000_000), + } as ReserveCloseRequest; + + const result = await client.closeReserve("reserve-pub", request); + + assert.strictEqual(result.case, "ok"); + if (result.case !== "ok") return; + assert.deepStrictEqual(result.body, { wire_amount: "TESTKUDOS:3" }); + assert.deepStrictEqual(http.lastRequest?.body, request); +}); diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -239,6 +239,7 @@ export enum TransactionMinorState { Exchange = "exchange", ExchangeWaitReserve = "exchange-wait-reserve", KycAuthRequired = "kyc-auth", + KycHardLimit = "kyc-hard-limit", KycInit = "kyc-init", KycRequired = "kyc", Merge = "merge", diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -561,6 +561,9 @@ export async function getBalancesInsideTransaction( case WithdrawalGroupStatus.AbortedUserRefused: case WithdrawalGroupStatus.DialogProposed: case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FinalizingKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: // Does not count as pendingIncoming continue; case WithdrawalGroupStatus.PendingReady: diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -125,6 +125,23 @@ test("slate creation reads outputs from the selected choice", () => { ]); }); +test("reserve-close signatures cover return-to-origin requests", async () => { + const reserve = createEddsaKeyPair(); + const requestTimestamp = t(1_760_000_000); + const result = await nativeCryptoR.signReserveClose(nativeCryptoR, { + reservePriv: encodeCrock(reserve.eddsaPriv), + requestTimestamp, + }); + const signed = buildSigPS(TalerSignaturePurpose.WALLET_RESERVE_CLOSE) + .put(timestampRoundedToBuffer(requestTimestamp)) + .put(new Uint8Array(64)) + .build(); + assert.strictEqual( + result.sig, + encodeCrock(eddsaSign(signed, reserve.eddsaPriv)), + ); +}); + test("melt confirmation binds the refresh commitment and noreveal index", async () => { const refreshCommitment = encodeCrock(new Uint8Array(64).fill(7)); const norevealIndex = 1; diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -378,6 +378,10 @@ export interface TalerCryptoInterface { req: SignReserveHistoryReqRequest, ): Promise<SignReserveHistoryReqResponse>; + signReserveClose( + req: SignReserveCloseRequest, + ): Promise<SignReserveCloseResponse>; + signPurseDeposits( req: SignPurseDepositsRequest, ): Promise<SignPurseDepositsResponse>; @@ -687,6 +691,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignReserveHistoryReqResponse> { throw new Error("Function not implemented."); }, + signReserveClose: function ( + req: SignReserveCloseRequest, + ): Promise<SignReserveCloseResponse> { + throw new Error("Function not implemented."); + }, signWalletAccountSetup: function ( req: SignWalletAccountSetupRequest, ): Promise<SignWalletAccountSetupResponse> { @@ -813,6 +822,15 @@ export interface SignReserveHistoryReqResponse { sig: string; } +export interface SignReserveCloseRequest { + reservePriv: string; + requestTimestamp: TalerProtocolTimestamp; +} + +export interface SignReserveCloseResponse { + sig: EddsaSignatureString; +} + export interface SpendCoinDetails { coinPub: string; coinPriv: string; @@ -3012,6 +3030,24 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { sig: sigResp.sig, }; }, + async signReserveClose( + tci: TalerCryptoInterfaceR, + req: SignReserveCloseRequest, + ): Promise<SignReserveCloseResponse> { + // Omitting payto_uri means returning the reserve to its origin. The + // signed full-payto hash is all-zero for that form of the request. + const reserveCloseBlob = buildSigPS( + TalerSignaturePurpose.WALLET_RESERVE_CLOSE, + ) + .put(timestampRoundedToBuffer(req.requestTimestamp)) + .put(new Uint8Array(64)) + .build(); + const sigResp = await tci.eddsaSign(tci, { + msg: encodeCrock(reserveCloseBlob), + priv: req.reservePriv, + }); + return { sig: sigResp.sig }; + }, async signWalletAccountSetup( tci: TalerCryptoInterfaceR, req: SignWalletAccountSetupRequest, diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -1882,6 +1882,9 @@ export enum WithdrawalGroupStatus { AbortingBank = 0x0103_0001, SuspendedAbortingBank = 0x0113_0001, + /** Closing a funded reserve after KYC reports an unraisable hard limit. */ + FinalizingKycHardLimit = 0x0200_0000, + /** * The corresponding withdraw record has been created. * No further processing is done, unless explicitly requested @@ -1896,6 +1899,9 @@ export enum WithdrawalGroupStatus { FailedAbortingBank = 0x0501_0002, + FailedKycHardLimit = 0x0501_0003, + FailedKycHardLimitRecovery = 0x0501_0004, + /** * Aborted in a state where we were supposed to * talk to the exchange. Money might have been @@ -2267,8 +2273,12 @@ export enum PeerPullPaymentCreditStatus { AbortingDeletePurse = 0x0103_0000, SuspendedAbortingDeletePurse = 0x0113_0000, + /** Deleting the purse after an unraisable merge hard limit. */ + FinalizingKycHardLimit = 0x0200_0000, + Done = 0x0500_0000, Failed = 0x0501_0000, + FailedKycHardLimit = 0x0501_0001, Expired = 0x0502_0000, Aborted = 0x0503_0000, } @@ -2295,9 +2305,13 @@ export enum PeerPushCreditStatus { DialogProposed = 0x0101_0000, + /** Waiting for the rejected purse to expire and refund its payer. */ + FinalizingKycHardLimit = 0x0200_0000, + Done = 0x0500_0000, Aborted = 0x0503_0000, Failed = 0x0501_0000, + FailedKycHardLimit = 0x0501_0001, Expired = 0x0502_0000, } diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts @@ -16,6 +16,7 @@ import { HttpStatusCode, + TalerErrorCode, TransactionMajorState, TransactionMinorState, } from "@gnu-taler/taler-util"; @@ -182,3 +183,30 @@ test("deposit abort terminal states expose precise transaction minors", () => { minor: TransactionMinorState.DepositAbortRefundFailed, }); }); + +test("hard-KYC deposit cleanup remains finalizing until recovery completes", () => { + const hardLimitReason = { + code: TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + } as any; + assert.deepStrictEqual( + computeDepositTransactionStatus({ + operationStatus: DepositOperationStatus.Aborting, + abortReason: hardLimitReason, + } as WalletDepositGroup), + { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }, + ); + assert.deepStrictEqual( + computeDepositTransactionStatus({ + operationStatus: DepositOperationStatus.AbortedDeposit, + abortReason: hardLimitReason, + } as WalletDepositGroup), + { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }, + ); +}); diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -139,6 +139,7 @@ import { checkDepositHardLimitExceeded, getDepositLimitInfo, isKycOperationDue, + makeKycHardLimitError, runKycCheckAlgo, } from "./kyc.js"; import { @@ -810,10 +811,23 @@ export function computeDepositTransactionStatus( major: TransactionMajorState.Suspended, }; case DepositOperationStatus.Aborting: + if (isDepositHardKycFailure(dg)) { + return { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }; + } return { major: TransactionMajorState.Aborting, }; case DepositOperationStatus.AbortedDeposit: + if (isDepositHardKycFailure(dg)) { + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }; + } return { major: TransactionMajorState.Aborted, minor: TransactionMinorState.DepositAbortRecovered, @@ -844,6 +858,12 @@ export function computeDepositTransactionStatus( minor: TransactionMinorState.Track, }; case DepositOperationStatus.SuspendedAborting: + if (isDepositHardKycFailure(dg)) { + return { + major: TransactionMajorState.SuspendedFinalizing, + minor: TransactionMinorState.KycHardLimit, + }; + } return { major: TransactionMajorState.SuspendedAborting, }; @@ -922,6 +942,9 @@ export function computeDepositTransactionActions( case DepositOperationStatus.SuspendedDeposit: return [TransactionAction.Resume]; case DepositOperationStatus.Aborting: + if (isDepositHardKycFailure(dg)) { + return [TransactionAction.Retry]; + } return [ TransactionAction.Retry, TransactionAction.Fail, @@ -994,6 +1017,12 @@ function isDepositAbortState(status: DepositOperationStatus): boolean { } } +function isDepositHardKycFailure(depositGroup: WalletDepositGroup): boolean { + return ( + depositGroup.abortReason?.code === TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED + ); +} + /** * Classify the financial result of a deposit abort. * @@ -1147,11 +1176,14 @@ async function finalizeDepositAbort( depositGroup.timestampFinished = timestampPreciseToDb( TalerPreciseTimestamp.now(), ); - depositGroup.failReason = describeDepositAbortFailure( + const outcomeFailure = describeDepositAbortFailure( outcome, depositGroup.statusPerCoin ?? [], depositGroup.abortRefreshGroupId, ); + if (outcomeFailure) { + depositGroup.failReason = outcomeFailure; + } await h.update(depositGroup, cause); } @@ -1570,6 +1602,9 @@ async function processDepositGroupPendingKyc( kycInfo.accessToken = algoRes.updatedStatus.accessToken; kycInfo.lastBadKycAuth = algoRes.updatedStatus.lastBadKycAuth; const requiresAuth = algoRes.requiresAuth; + const hardLimitReason = algoRes.hardLimitExceeded + ? makeKycHardLimitError(myKycState) + : undefined; if (logger.shouldLogTrace()) { logger.trace(`kyc check algo result: ${j2s(algoRes)}`); @@ -1578,7 +1613,7 @@ async function processDepositGroupPendingKyc( let options: KycAuthTransferOptionRaw[] = []; let transferExpiry: TalerProtocolTimestamp | undefined = undefined; - if (algoRes.requiresAuth) { + if (algoRes.requiresAuth && !hardLimitReason) { const res = await getAuthTransferDetails( wex, depositGroup, @@ -1595,6 +1630,21 @@ async function processDepositGroupPendingKyc( if (!newDg) { return TaskRunResult.finished(); } + newDg.kycInfo = kycInfo; + if (hardLimitReason) { + switch (newDg.operationStatus) { + case DepositOperationStatus.PendingAggregateKyc: + case DepositOperationStatus.PendingDepositKyc: + case DepositOperationStatus.PendingDepositKycAuth: + newDg.operationStatus = DepositOperationStatus.Aborting; + newDg.abortReason = hardLimitReason; + newDg.failReason = hardLimitReason; + await h.update(newDg, "kyc-hard-limit-recovery"); + return TaskRunResult.progress(); + default: + return TaskRunResult.backoff(); + } + } switch (newDg.operationStatus) { case DepositOperationStatus.PendingAggregateKyc: if (requiresAuth) { @@ -1619,7 +1669,6 @@ async function processDepositGroupPendingKyc( default: return TaskRunResult.backoff(); } - newDg.kycInfo = kycInfo; await h.update(newDg, "kyc-status"); return algoRes.taskResult; }); diff --git a/packages/taler-wallet-core/src/kyc.test.ts b/packages/taler-wallet-core/src/kyc.test.ts @@ -17,11 +17,19 @@ import { AccountLimit, AmountString, Duration, + HttpStatusCode, LimitOperationType, } from "@gnu-taler/taler-util"; +import { HeadersImpl, HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import assert from "node:assert"; import { test } from "node:test"; -import { checkLimit, LimitCheckResult } from "./kyc.js"; +import { + checkLimit, + GenericKycStatusReq, + LimitCheckResult, + runKycCheckAlgo, +} from "./kyc.js"; +import { WalletExecutionContext } from "./wallet.js"; function limit( threshold: AmountString, @@ -115,3 +123,61 @@ test("a missing soft_limit means the rule is hard", async (t) => { LimitCheckResult.DeniedVerboten, ); }); + +test("an unchanged hard denial still reaches the owning transaction", async () => { + const body = { + aml_review: false, + rule_gen: 7, + access_token: "access-token", + limits: [limit("KUDOS:5" as AmountString, false)], + }; + const http: HttpRequestLibrary = { + async fetch(url, opt) { + return { + requestMethod: opt?.method ?? "GET", + requestUrl: url, + status: HttpStatusCode.Accepted, + headers: new HeadersImpl(), + async bytes() { + return new TextEncoder().encode(JSON.stringify(body)); + }, + async json() { + return body; + }, + async text() { + return JSON.stringify(body); + }, + }; + }, + }; + const wex = { + http, + ws: { longpollQueue: undefined }, + cryptoApi: { + async signWalletKycAuth() { + return { sig: "account-signature" }; + }, + }, + } as unknown as WalletExecutionContext; + const state: GenericKycStatusReq = { + accountPriv: "account-private-key", + accountPub: "account-public-key", + amount: "KUDOS:10", + exchangeBaseUrl: "https://exchange.example/", + operation: LimitOperationType.withdraw, + paytoHash: "payto-hash", + lastCheckStatus: HttpStatusCode.Accepted, + lastRuleGen: 7, + lastAmlReview: false, + haveAccessToken: true, + }; + + const result = await runKycCheckAlgo(wex, state); + + assert.strictEqual(result.hardLimitExceeded, true); + assert.ok(result.updatedStatus); + assert.strictEqual( + result.updatedStatus.lastCheckStatus, + HttpStatusCode.Accepted, + ); +}); diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts @@ -24,7 +24,10 @@ import { Duration, HttpStatusCode, Logger, + TalerErrorCode, + TalerErrorDetail, TalerPreciseTimestamp, + makeErrorDetail, } from "@gnu-taler/taler-util"; import { TaskRunResult } from "./common.js"; import { @@ -331,6 +334,8 @@ export interface GenericKycStatusResp { /** If no updated status is present, finish the task with this status. */ taskResult: TaskRunResult; requiresAuth?: boolean; + /** The operation exceeds a limit that KYC cannot raise. */ + hardLimitExceeded?: boolean; updatedStatus?: { accessToken?: string; lastCheckStatus?: number | undefined; @@ -347,6 +352,19 @@ export interface GenericKycStatusResp { }; } +export function makeKycHardLimitError( + st: GenericKycStatusReq, +): TalerErrorDetail { + return makeErrorDetail( + TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + { + exchangeBaseUrl: st.exchangeBaseUrl, + requestedAmount: Amounts.stringify(st.amount), + }, + `the ${st.operation.toLowerCase()} operation exceeds an exchange hard limit`, + ); +} + export function isKycOperationDue(st: GenericKycStatusReq): boolean { return ( st.lastDeny == null || @@ -445,13 +463,6 @@ export async function runKycCheckAlgo( code === st.lastCheckCode && ruleGen === st.lastRuleGen; - if (sameResp) { - logger.trace(`kyc-check response didn't change, retrying with back-off`); - return { - taskResult: TaskRunResult.backoff(), - }; - } - const updatedStatus: GenericKycStatusResp["updatedStatus"] = { lastAmlReview: amlReview, lastCheckCode: code, @@ -512,13 +523,22 @@ export async function runKycCheckAlgo( ); break; case LimitCheckResult.DeniedVerboten: - // FIXME: This should transition the transaction to failed! updatedStatus.lastDeny = timestampPreciseToDb( TalerPreciseTimestamp.now(), ); + rst.hardLimitExceeded = true; break; } } + // A repeated hard denial must still reach the owning state machine after a + // restart. Other unchanged responses retain the normal back-off behavior. + if (sameResp && !rst.hardLimitExceeded) { + logger.trace(`kyc-check response didn't change, retrying with back-off`); + return { + taskResult: TaskRunResult.backoff(), + }; + } + return rst; } diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts @@ -17,6 +17,8 @@ import { TalerProtocolTimestamp, TransactionAction, + TransactionMajorState, + TransactionMinorState, } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; @@ -27,6 +29,7 @@ import { import { WalletDbTransaction } from "./dbtx.js"; import { computePeerPullCreditTransactionActions, + computePeerPullCreditTransactionState, PeerPullCreditTransactionContext, statusAfterPullCreditDeleteConflict, } from "./pay-peer-pull-credit.js"; @@ -121,3 +124,31 @@ test("delete conflict continues a pull credit whose purse was merged", () => { PeerPullPaymentCreditStatus.Aborted, ); }); + +test("pull-credit hard-KYC cleanup has explicit finalizing and failed states", () => { + assert.deepStrictEqual( + computePeerPullCreditTransactionState( + makeRecord(PeerPullPaymentCreditStatus.FinalizingKycHardLimit), + ), + { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }, + ); + assert.deepStrictEqual( + computePeerPullCreditTransactionState( + makeRecord(PeerPullPaymentCreditStatus.FailedKycHardLimit), + ), + { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }, + ); + assert.deepStrictEqual( + computePeerPullCreditTransactionActions( + makeRecord(PeerPullPaymentCreditStatus.FinalizingKycHardLimit), + ), + [TransactionAction.Retry], + ); +}); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -95,6 +95,7 @@ import { GenericKycStatusReq, checkPeerCreditHardLimitExceeded, isKycOperationDue, + makeKycHardLimitError, runKycCheckAlgo, } from "./kyc.js"; import { @@ -365,7 +366,9 @@ export class PeerPullCreditTransactionContext implements TransactionContext { case PeerPullPaymentCreditStatus.Done: case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.FailedKycHardLimit: case PeerPullPaymentCreditStatus.Expired: + case PeerPullPaymentCreditStatus.FinalizingKycHardLimit: return; default: assertUnreachable(rec.status); @@ -409,7 +412,9 @@ export class PeerPullCreditTransactionContext implements TransactionContext { case PeerPullPaymentCreditStatus.SuspendedWithdrawing: case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.FailedKycHardLimit: case PeerPullPaymentCreditStatus.Expired: + case PeerPullPaymentCreditStatus.FinalizingKycHardLimit: case PeerPullPaymentCreditStatus.PendingBalanceKycRequired: case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired: case PeerPullPaymentCreditStatus.PendingBalanceKycInit: @@ -444,8 +449,10 @@ export class PeerPullCreditTransactionContext implements TransactionContext { case PeerPullPaymentCreditStatus.AbortingDeletePurse: case PeerPullPaymentCreditStatus.Done: case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.FailedKycHardLimit: case PeerPullPaymentCreditStatus.Expired: case PeerPullPaymentCreditStatus.Aborted: + case PeerPullPaymentCreditStatus.FinalizingKycHardLimit: return; case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit: rec.status = PeerPullPaymentCreditStatus.PendingBalanceKycInit; @@ -509,8 +516,10 @@ export class PeerPullCreditTransactionContext implements TransactionContext { case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.AbortingDeletePurse: case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.FailedKycHardLimit: case PeerPullPaymentCreditStatus.Expired: case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: + case PeerPullPaymentCreditStatus.FinalizingKycHardLimit: return; default: assertUnreachable(rec.status); @@ -657,6 +666,9 @@ async function processPendingMergeKycRequired( const updatedStatus = algoRes.updatedStatus; checkProtocolInvariant(algoRes.requiresAuth != true); + const hardLimitReason = algoRes.hardLimitExceeded + ? makeKycHardLimitError(myKycState) + : undefined; await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); @@ -669,10 +681,17 @@ async function processPendingMergeKycRequired( rec.kycLastDeny = updatedStatus.lastDeny; rec.kycLastRuleGen = updatedStatus.lastRuleGen; rec.kycAccessToken = updatedStatus.accessToken; + if ( + hardLimitReason && + rec.status === PeerPullPaymentCreditStatus.PendingMergeKycRequired + ) { + rec.status = PeerPullPaymentCreditStatus.FinalizingKycHardLimit; + rec.failReason = hardLimitReason; + } await h.update(rec, "merge-kyc-status"); }); - return algoRes.taskResult; + return hardLimitReason ? TaskRunResult.progress() : algoRes.taskResult; } async function processPeerPullCreditAbortingDeletePurse( @@ -686,14 +705,23 @@ async function processPeerPullCreditAbortingDeletePurse( pursePriv, }); const resp = await exchangeClient.deletePurse(pursePub, sigResp.sig); - let completionStatus = PeerPullPaymentCreditStatus.Aborted; + const hardLimitRecovery = + peerPullIni.status === PeerPullPaymentCreditStatus.FinalizingKycHardLimit; + let completionStatus = hardLimitRecovery + ? PeerPullPaymentCreditStatus.FailedKycHardLimit + : PeerPullPaymentCreditStatus.Aborted; + let completionReason: TalerErrorDetail | undefined; switch (resp.case) { case "ok": case HttpStatusCode.NotFound: break; case HttpStatusCode.Forbidden: - await ctx.failTransaction(peerPullIni.status, resp.detail); - return TaskRunResult.finished(); + if (!hardLimitRecovery) { + await ctx.failTransaction(peerPullIni.status, resp.detail); + return TaskRunResult.finished(); + } + completionReason = resp.detail; + break; case HttpStatusCode.Conflict: { const statusResp = await exchangeClient.getPurseStatusAtMerge(pursePub); switch (statusResp.case) { @@ -724,10 +752,16 @@ async function processPeerPullCreditAbortingDeletePurse( await wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); - if (rec?.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) { + if ( + rec?.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse && + rec?.status !== PeerPullPaymentCreditStatus.FinalizingKycHardLimit + ) { return; } rec.status = completionStatus; + if (completionReason) { + rec.failReason = completionReason; + } if (completionStatus === PeerPullPaymentCreditStatus.PendingReady) { delete rec.abortReason; } @@ -735,7 +769,9 @@ async function processPeerPullCreditAbortingDeletePurse( rec, completionStatus === PeerPullPaymentCreditStatus.PendingReady ? "abort-purse-merged" - : "aborting-delete-purse", + : hardLimitRecovery + ? "kyc-hard-limit-delete-purse" + : "aborting-delete-purse", ); }); return completionStatus === PeerPullPaymentCreditStatus.PendingReady @@ -745,7 +781,9 @@ async function processPeerPullCreditAbortingDeletePurse( export function statusAfterPullCreditDeleteConflict( purse: ExchangePurseStatus, -): PeerPullPaymentCreditStatus.PendingReady | PeerPullPaymentCreditStatus.Aborted { +): + | PeerPullPaymentCreditStatus.PendingReady + | PeerPullPaymentCreditStatus.Aborted { return isPurseMerged(purse) ? PeerPullPaymentCreditStatus.PendingReady : PeerPullPaymentCreditStatus.Aborted; @@ -777,6 +815,12 @@ async function processPeerPullCreditWithdrawing( rec.status = PeerPullPaymentCreditStatus.Done; await h.update(rec, "withdrawing-done"); return TaskRunResult.finished(); + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: + rec.status = PeerPullPaymentCreditStatus.FailedKycHardLimit; + rec.failReason = wg.failReason; + await h.update(rec, "withdrawing-kyc-hard-limit"); + return TaskRunResult.finished(); case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.AbortedOtherWallet: @@ -803,6 +847,7 @@ async function processPeerPullCreditWithdrawing( case WithdrawalGroupStatus.SuspendedRedenominate: case WithdrawalGroupStatus.SuspendedRegisteringBank: case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.FinalizingKycHardLimit: break; } return TaskRunResult.backoff(); @@ -994,6 +1039,7 @@ export async function processPeerPullCredit( case PeerPullPaymentCreditStatus.PendingCreatePurse: return await processPeerPullCreditCreatePurse(wex, pullIni); case PeerPullPaymentCreditStatus.AbortingDeletePurse: + case PeerPullPaymentCreditStatus.FinalizingKycHardLimit: return await processPeerPullCreditAbortingDeletePurse(wex, pullIni); case PeerPullPaymentCreditStatus.PendingWithdrawing: return await processPeerPullCreditWithdrawing(wex, pullIni); @@ -1002,6 +1048,7 @@ export async function processPeerPullCredit( return await processPeerPullCreditBalanceKyc(ctx, pullIni); case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.FailedKycHardLimit: case PeerPullPaymentCreditStatus.Expired: case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired: case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: @@ -1400,6 +1447,17 @@ export function computePeerPullCreditTransactionState( return { major: TransactionMajorState.Done, }; + case PeerPullPaymentCreditStatus.FinalizingKycHardLimit: + return { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }; + case PeerPullPaymentCreditStatus.FailedKycHardLimit: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }; case PeerPullPaymentCreditStatus.PendingWithdrawing: return { major: TransactionMajorState.Pending, @@ -1491,6 +1549,10 @@ export function computePeerPullCreditTransactionActions( ]; case PeerPullPaymentCreditStatus.Done: return [TransactionAction.Delete]; + case PeerPullPaymentCreditStatus.FinalizingKycHardLimit: + return [TransactionAction.Retry]; + case PeerPullPaymentCreditStatus.FailedKycHardLimit: + return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.PendingWithdrawing: return [TransactionAction.Retry, TransactionAction.Suspend]; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.test.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.test.ts @@ -0,0 +1,72 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import { + TransactionAction, + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { PeerPushCreditStatus, WalletPeerPushCredit } from "./db-common.js"; +import { + computePeerPushCreditTransactionActions, + computePeerPushCreditTransactionState, +} from "./pay-peer-push-credit.js"; + +function makeRecord(status: PeerPushCreditStatus): WalletPeerPushCredit { + return { + peerPushCreditId: "push-credit-id", + exchangeBaseUrl: "https://exchange.example/", + pursePub: "purse-pub", + mergePriv: "merge-priv", + contractPriv: "contract-priv", + timestamp: 1 as any, + estimatedAmountEffective: "TESTKUDOS:1", + contractTermsHash: "contract-hash", + status, + withdrawalGroupId: undefined, + currency: "TESTKUDOS", + }; +} + +test("push-credit hard-KYC cleanup has explicit finalizing and failed states", () => { + assert.deepStrictEqual( + computePeerPushCreditTransactionState( + makeRecord(PeerPushCreditStatus.FinalizingKycHardLimit), + ), + { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }, + ); + assert.deepStrictEqual( + computePeerPushCreditTransactionState( + makeRecord(PeerPushCreditStatus.FailedKycHardLimit), + ), + { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }, + ); + assert.deepStrictEqual( + computePeerPushCreditTransactionActions( + makeRecord(PeerPushCreditStatus.FinalizingKycHardLimit), + ), + [TransactionAction.Retry], + ); +}); diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -98,6 +98,7 @@ import { checkPeerCreditHardLimitExceeded, getPeerCreditLimitInfo, isKycOperationDue, + makeKycHardLimitError, runKycCheckAlgo, } from "./kyc.js"; import { @@ -329,8 +330,10 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.SuspendedBalanceKycRequired: case PeerPushCreditStatus.SuspendedBalanceKycInit: case PeerPushCreditStatus.Failed: + case PeerPushCreditStatus.FailedKycHardLimit: case PeerPushCreditStatus.Aborted: case PeerPushCreditStatus.Expired: + case PeerPushCreditStatus.FinalizingKycHardLimit: return; case PeerPushCreditStatus.PendingBalanceKycRequired: rec.status = PeerPushCreditStatus.SuspendedBalanceKycRequired; @@ -364,8 +367,10 @@ export class PeerPushCreditTransactionContext implements TransactionContext { } switch (rec.status) { case PeerPushCreditStatus.Failed: + case PeerPushCreditStatus.FailedKycHardLimit: case PeerPushCreditStatus.Aborted: case PeerPushCreditStatus.Done: + case PeerPushCreditStatus.FinalizingKycHardLimit: return; case PeerPushCreditStatus.SuspendedMerge: case PeerPushCreditStatus.DialogProposed: @@ -405,7 +410,9 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.Done: case PeerPushCreditStatus.Aborted: case PeerPushCreditStatus.Failed: + case PeerPushCreditStatus.FailedKycHardLimit: case PeerPushCreditStatus.Expired: + case PeerPushCreditStatus.FinalizingKycHardLimit: return; case PeerPushCreditStatus.SuspendedMerge: rec.status = PeerPushCreditStatus.PendingMerge; @@ -457,7 +464,9 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.Done: case PeerPushCreditStatus.Aborted: case PeerPushCreditStatus.Failed: + case PeerPushCreditStatus.FailedKycHardLimit: case PeerPushCreditStatus.Expired: + case PeerPushCreditStatus.FinalizingKycHardLimit: // Already in a final state. return; case PeerPushCreditStatus.DialogProposed: @@ -785,6 +794,9 @@ async function processPeerPushDebitMergeKyc( const updatedStatus = algoRes.updatedStatus; checkProtocolInvariant(algoRes.requiresAuth != true); + const hardLimitReason = algoRes.hardLimitExceeded + ? makeKycHardLimitError(myKycState) + : undefined; await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); @@ -797,10 +809,73 @@ async function processPeerPushDebitMergeKyc( rec.kycLastDeny = updatedStatus.lastDeny; rec.kycLastRuleGen = updatedStatus.lastRuleGen; rec.kycAccessToken = updatedStatus.accessToken; + if ( + hardLimitReason && + rec.status === PeerPushCreditStatus.PendingMergeKycRequired + ) { + rec.status = PeerPushCreditStatus.FinalizingKycHardLimit; + rec.failReason = hardLimitReason; + } await h.update(rec, "merge-kyc-status"); }); - return algoRes.taskResult; + return hardLimitReason ? TaskRunResult.progress() : algoRes.taskResult; +} + +async function processPeerPushCreditKycHardLimitRecovery( + wex: WalletExecutionContext, + peerInc: WalletPeerPushCredit, +): Promise<TaskRunResult> { + const exchangeClient = walletExchangeClient(peerInc.exchangeBaseUrl, wex); + const statusResp = await exchangeClient.getPurseStatusAtMerge( + peerInc.pursePub, + true, + ); + let nextStatus: PeerPushCreditStatus | undefined; + switch (statusResp.case) { + case "ok": + await requireValidExchangePurseStatus( + wex, + peerInc.exchangeBaseUrl, + statusResp.body, + ); + if (isPurseMerged(statusResp.body)) { + // A merge committed despite the earlier hard-limit response. Resume + // the idempotent merge path so custody moves to a tracked withdrawal + // group, whose own hard-limit recovery can close the reserve. + nextStatus = PeerPushCreditStatus.PendingMerge; + } + break; + case HttpStatusCode.Gone: + case HttpStatusCode.NotFound: + // Unmerged purse value is refunded to its depositing coins when the + // purse expires or is deleted by its owner. + nextStatus = PeerPushCreditStatus.FailedKycHardLimit; + break; + default: + assertUnreachable(statusResp); + } + if (!nextStatus) { + return TaskRunResult.backoff(); + } + const ctx = new PeerPushCreditTransactionContext( + wex, + peerInc.peerPushCreditId, + ); + await wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (rec?.status !== PeerPushCreditStatus.FinalizingKycHardLimit) { + return; + } + rec.status = nextStatus; + if (nextStatus === PeerPushCreditStatus.PendingMerge) { + delete rec.failReason; + } + await h.update(rec, "kyc-hard-limit-purse-recovery"); + }); + return nextStatus === PeerPushCreditStatus.PendingMerge + ? TaskRunResult.progress() + : TaskRunResult.finished(); } async function transitionPeerPushCreditKycRequired( @@ -1086,8 +1161,23 @@ async function processPendingWithdrawing( case WithdrawalGroupStatus.AbortingBank: case WithdrawalGroupStatus.FailedAbortingBank: case WithdrawalGroupStatus.FailedBankAborted: - ppi.status = PeerPushCreditStatus.Failed; - await h.update(ppi, "withdrawal-failed"); + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: + if ( + wg.status === WithdrawalGroupStatus.FailedKycHardLimit || + wg.status === WithdrawalGroupStatus.FailedKycHardLimitRecovery + ) { + ppi.status = PeerPushCreditStatus.FailedKycHardLimit; + ppi.failReason = wg.failReason; + } else { + ppi.status = PeerPushCreditStatus.Failed; + } + await h.update( + ppi, + ppi.status === PeerPushCreditStatus.FailedKycHardLimit + ? "withdrawal-kyc-hard-limit" + : "withdrawal-failed", + ); return TaskRunResult.finished(); case WithdrawalGroupStatus.Done: ppi.status = PeerPushCreditStatus.Done; @@ -1107,6 +1197,7 @@ async function processPendingWithdrawing( case WithdrawalGroupStatus.SuspendedRedenominate: case WithdrawalGroupStatus.SuspendedRegisteringBank: case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.FinalizingKycHardLimit: return TaskRunResult.backoff(); case WithdrawalGroupStatus.PendingBalanceKyc: case WithdrawalGroupStatus.PendingBalanceKycInit: @@ -1251,6 +1342,8 @@ export async function processPeerPushCredit( throw Error("invalid state, kycPaytoHash required"); } return processPeerPushDebitMergeKyc(wex, peerInc, contractTerms); + case PeerPushCreditStatus.FinalizingKycHardLimit: + return processPeerPushCreditKycHardLimitRecovery(wex, peerInc); case PeerPushCreditStatus.PendingMerge: return processPendingMerge(wex, peerInc, contractTerms); case PeerPushCreditStatus.PendingWithdrawing: @@ -1462,6 +1555,17 @@ export function computePeerPushCreditTransactionState( return { major: TransactionMajorState.Done, }; + case PeerPushCreditStatus.FinalizingKycHardLimit: + return { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }; + case PeerPushCreditStatus.FailedKycHardLimit: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }; case PeerPushCreditStatus.PendingMergeKycRequired: if (pushCreditRecord.kycAccessToken) { return { @@ -1555,6 +1659,10 @@ export function computePeerPushCreditTransactionActions( ]; case PeerPushCreditStatus.Done: return [TransactionAction.Delete]; + case PeerPushCreditStatus.FinalizingKycHardLimit: + return [TransactionAction.Retry]; + case PeerPushCreditStatus.FailedKycHardLimit: + return [TransactionAction.Delete]; case PeerPushCreditStatus.PendingMergeKycRequired: return [ TransactionAction.Retry, diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts @@ -20,6 +20,9 @@ import { Amounts, DenomKeyType, Duration, + HttpStatusCode, + TransactionMajorState, + TransactionMinorState, } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; @@ -31,6 +34,8 @@ import { import { WalletDenomination } from "./db-common.js"; import { selectWithdrawalDenominations } from "./denomSelection.js"; import { + classifyWithdrawalKycHardLimitRecovery, + computeWithdrawalTransactionStatus, isWithdrawalFinalStatus, requireWithdrawalBatchCardinality, } from "./withdraw.js"; @@ -40,6 +45,8 @@ test("every terminal withdrawal status ends final-state waits", () => { WithdrawalGroupStatus.Done, WithdrawalGroupStatus.FailedBankAborted, WithdrawalGroupStatus.FailedAbortingBank, + WithdrawalGroupStatus.FailedKycHardLimit, + WithdrawalGroupStatus.FailedKycHardLimitRecovery, WithdrawalGroupStatus.AbortedExchange, WithdrawalGroupStatus.AbortedBank, WithdrawalGroupStatus.AbortedUserRefused, @@ -55,6 +62,44 @@ test("every terminal withdrawal status ends final-state waits", () => { isWithdrawalFinalStatus(WithdrawalGroupStatus.SuspendedReady), false, ); + assert.strictEqual( + isWithdrawalFinalStatus(WithdrawalGroupStatus.FinalizingKycHardLimit), + false, + ); +}); + +test("hard-KYC withdrawal recovery distinguishes returned and stranded reserves", () => { + assert.strictEqual( + classifyWithdrawalKycHardLimitRecovery(HttpStatusCode.Ok), + WithdrawalGroupStatus.FailedKycHardLimit, + ); + assert.strictEqual( + classifyWithdrawalKycHardLimitRecovery(HttpStatusCode.NotFound), + WithdrawalGroupStatus.FailedKycHardLimitRecovery, + ); + assert.strictEqual( + classifyWithdrawalKycHardLimitRecovery(HttpStatusCode.Conflict), + WithdrawalGroupStatus.FailedKycHardLimitRecovery, + ); + assert.deepStrictEqual( + computeWithdrawalTransactionStatus({ + status: WithdrawalGroupStatus.FinalizingKycHardLimit, + } as any), + { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }, + ); + assert.deepStrictEqual( + computeWithdrawalTransactionStatus({ + status: WithdrawalGroupStatus.FailedKycHardLimitRecovery, + } as any), + { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }, + ); }); test("withdrawal batches require one signature per planchet", () => { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -183,6 +183,7 @@ import { checkWithdrawalHardLimitExceeded, getWithdrawalLimitInfo, isKycOperationDue, + makeKycHardLimitError, runKycCheckAlgo, } from "./kyc.js"; import { @@ -574,6 +575,10 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.PendingKyc: newStatus = WithdrawalGroupStatus.SuspendedKyc; break; + case WithdrawalGroupStatus.FinalizingKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: + return; default: logger.warn( `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`, @@ -626,6 +631,9 @@ export class WithdrawTransactionContext implements TransactionContext { newStatus = WithdrawalGroupStatus.AbortedUserRefused; break; case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FinalizingKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: case WithdrawalGroupStatus.FailedBankAborted: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.AbortedBank: @@ -670,6 +678,10 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.SuspendedKyc: newStatus = WithdrawalGroupStatus.PendingKyc; break; + case WithdrawalGroupStatus.FinalizingKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: + return; default: logger.warn( `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`, @@ -736,6 +748,18 @@ export function computeWithdrawalTransactionStatus( return { major: TransactionMajorState.Done, }; + case WithdrawalGroupStatus.FinalizingKycHardLimit: + return { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.KycHardLimit, + working: true, + }; + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.KycHardLimit, + }; case WithdrawalGroupStatus.PendingRegisteringBank: return { major: TransactionMajorState.Pending, @@ -881,6 +905,11 @@ export function computeWithdrawalTransactionActions( return [TransactionAction.Delete]; case WithdrawalGroupStatus.Done: return [TransactionAction.Delete]; + case WithdrawalGroupStatus.FinalizingKycHardLimit: + return [TransactionAction.Retry]; + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: + return [TransactionAction.Delete]; case WithdrawalGroupStatus.PendingRegisteringBank: return [ TransactionAction.Retry, @@ -2423,6 +2452,9 @@ async function processWithdrawalGroupPendingKyc( const updatedStatus = algoRes.updatedStatus; checkProtocolInvariant(algoRes.requiresAuth != true); + const hardLimitReason = algoRes.hardLimitExceeded + ? makeKycHardLimitError(myKycState) + : undefined; await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); @@ -2435,10 +2467,103 @@ async function processWithdrawalGroupPendingKyc( rec.kycLastDeny = updatedStatus.lastDeny; rec.kycLastRuleGen = updatedStatus.lastRuleGen; rec.kycAccessToken = updatedStatus.accessToken; + if ( + hardLimitReason && + rec.status === WithdrawalGroupStatus.PendingKyc + ) { + rec.status = WithdrawalGroupStatus.FinalizingKycHardLimit; + rec.failReason = hardLimitReason; + } await h.update(rec, "kyc-status"); }); - return algoRes.taskResult; + return hardLimitReason ? TaskRunResult.progress() : algoRes.taskResult; +} + +async function processWithdrawalKycHardLimitRecovery( + wex: WalletExecutionContext, + withdrawalGroup: WalletWithdrawalGroup, +): Promise<TaskRunResult> { + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "hard-limit recovery requires an exchange", + ); + checkDbInvariant( + withdrawalGroup.rawWithdrawalAmount !== undefined, + "hard-limit recovery requires a raw withdrawal amount", + ); + const requestedAmount = withdrawalGroup.rawWithdrawalAmount; + const requestTimestamp = TalerProtocolTimestamp.now(); + const sig = await wex.cryptoApi.signReserveClose({ + requestTimestamp, + reservePriv: withdrawalGroup.reservePriv, + }); + const exchangeClient = walletExchangeClient( + withdrawalGroup.exchangeBaseUrl, + wex, + ); + const closeResp = await exchangeClient.closeReserve( + withdrawalGroup.reservePub, + { + request_timestamp: requestTimestamp, + reserve_sig: sig.sig, + }, + ); + + const finalStatus = classifyWithdrawalKycHardLimitRecovery( + closeResp.response.status, + ); + const recoveryFailure = + finalStatus === WithdrawalGroupStatus.FailedKycHardLimitRecovery + ? makeErrorDetail( + TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + { + exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl, + requestedAmount, + }, + `withdrawal exceeded a hard KYC limit and its reserve could not be returned to the origin account (HTTP ${closeResp.response.status})`, + ) + : undefined; + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + await wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (rec?.status !== WithdrawalGroupStatus.FinalizingKycHardLimit) { + return; + } + rec.status = finalStatus; + rec.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + if (recoveryFailure) { + rec.failReason = recoveryFailure; + } + await h.update(rec, "kyc-hard-limit-recovery"); + }); + return TaskRunResult.finished(); +} + +export function classifyWithdrawalKycHardLimitRecovery( + httpStatus: number, +): + | WithdrawalGroupStatus.FailedKycHardLimit + | WithdrawalGroupStatus.FailedKycHardLimitRecovery { + switch (httpStatus) { + case HttpStatusCode.Ok: + // The exchange accepted return-to-origin. The response's wire amount + // may be zero when the closing fee consumes the remaining balance, but + // the reserve has still been closed deliberately. + return WithdrawalGroupStatus.FailedKycHardLimit; + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + case HttpStatusCode.UnavailableForLegalReasons: + return WithdrawalGroupStatus.FailedKycHardLimitRecovery; + default: + throw Error(`unexpected reserve-close status ${httpStatus}`); + } } /** @@ -2933,6 +3058,8 @@ export async function processWithdrawalGroup( return await processReserveBankStatus(wex, withdrawalGroupId); case WithdrawalGroupStatus.PendingKyc: return await processWithdrawalGroupPendingKyc(wex, withdrawalGroup); + case WithdrawalGroupStatus.FinalizingKycHardLimit: + return await processWithdrawalKycHardLimitRecovery(wex, withdrawalGroup); case WithdrawalGroupStatus.PendingReady: // Continue with the actual withdrawal! return await processWithdrawalGroupPendingReady(wex, withdrawalGroup); @@ -2958,6 +3085,8 @@ export async function processWithdrawalGroup( case WithdrawalGroupStatus.SuspendedBalanceKycInit: case WithdrawalGroupStatus.Done: case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: case WithdrawalGroupStatus.AbortedUserRefused: case WithdrawalGroupStatus.AbortedOtherWallet: case WithdrawalGroupStatus.SuspendedRedenominate: @@ -4608,11 +4737,15 @@ export function isWithdrawalFinalStatus( case WithdrawalGroupStatus.Done: case WithdrawalGroupStatus.FailedBankAborted: case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.FailedKycHardLimit: + case WithdrawalGroupStatus.FailedKycHardLimitRecovery: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.AbortedUserRefused: case WithdrawalGroupStatus.AbortedOtherWallet: return true; + case WithdrawalGroupStatus.FinalizingKycHardLimit: + return false; default: assertUnreachable(status); }