taler-typescript-core

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

commit 1b31cd676ee3d765f19bd646a1d25a36405837b1
parent 64d93b7b2719bba72cc3ec440b69a02202fb7cc8
Author: Florian Dold <dold@taler.net>
Date:   Fri,  4 Sep 2026 01:01:06 +0200

wallet-core: fix KYC limits and withdrawal transitions

Apply account limits to the requested operation and rolling volume.
Preserve funded or KYC-blocked reserves and implement withdrawal
suspend, resume, fail, and retry actions.

Diffstat:
Mpackages/taler-wallet-core/src/kyc.test.ts | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/kyc.ts | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 3+++
Mpackages/taler-wallet-core/src/withdraw.test.ts | 121++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/withdraw.ts | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
5 files changed, 603 insertions(+), 45 deletions(-)

diff --git a/packages/taler-wallet-core/src/kyc.test.ts b/packages/taler-wallet-core/src/kyc.test.ts @@ -25,11 +25,13 @@ import assert from "node:assert"; import { test } from "node:test"; import { checkLimit, + checkDepositHardLimitExceededPerExchange, GenericKycStatusReq, LimitCheckResult, runKycCheckAlgo, } from "./kyc.js"; import { WalletExecutionContext } from "./wallet.js"; +import { ReadyExchangeSummary } from "./exchanges.js"; function limit( threshold: AmountString, @@ -70,6 +72,39 @@ test("only rules for the requested operation are considered", async (t) => { ); }); +test("generic transaction rules apply to every operation", async () => { + const rules = [ + { + ...limit("KUDOS:5" as AmountString, false), + operation_type: LimitOperationType.transaction, + }, + ]; + assert.strictEqual( + await checkLimit(rules, LimitOperationType.merge, "KUDOS:6"), + LimitCheckResult.DeniedVerboten, + ); +}); + +test("limit checks include timeframe volume and resulting balance", async () => { + const volumeRule = limit("KUDOS:10" as AmountString, false); + assert.strictEqual( + await checkLimit([volumeRule], LimitOperationType.withdraw, "KUDOS:4", { + getTimeframeVolume: () => "KUDOS:7", + }), + LimitCheckResult.DeniedVerboten, + ); + const balanceRule = { + ...limit("KUDOS:10" as AmountString, false), + operation_type: LimitOperationType.balance, + }; + assert.strictEqual( + await checkLimit([balanceRule], LimitOperationType.merge, "KUDOS:1", { + balanceAfterOperation: "KUDOS:11", + }), + LimitCheckResult.DeniedVerboten, + ); +}); + test("a breached soft limit alone can be raised by KYC", async (t) => { const rules = [limit("KUDOS:5" as AmountString, true)]; @@ -181,3 +216,82 @@ test("an unchanged hard denial still reaches the owning transaction", async () = HttpStatusCode.Accepted, ); }); + +test("limits returned with HTTP 200 are enforced", async () => { + const body = { + aml_review: false, + rule_gen: 8, + 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.Ok, + 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 result = await runKycCheckAlgo(wex, { + accountPriv: "account-private-key", + accountPub: "account-public-key", + amount: "KUDOS:10", + exchangeBaseUrl: "https://exchange.example/", + operation: LimitOperationType.withdraw, + paytoHash: "payto-hash", + haveAccessToken: true, + }); + assert.strictEqual(result.hardLimitExceeded, true); + assert.ok(result.updatedStatus?.lastDeny); +}); + +test("deposit hard limits apply to each exchange contribution", () => { + const makeExchange = ( + exchangeBaseUrl: string, + threshold: AmountString, + ): ReadyExchangeSummary => + ({ + exchangeBaseUrl, + hardLimits: [ + { + ...limit(threshold, false), + operation_type: LimitOperationType.deposit, + }, + ], + zeroLimits: [], + }) as unknown as ReadyExchangeSummary; + const violation = checkDepositHardLimitExceededPerExchange( + [ + makeExchange("https://small.example/", "KUDOS:2" as AmountString), + makeExchange("https://large.example/", "KUDOS:100" as AmountString), + ], + new Map([ + ["https://small.example/", "KUDOS:3"], + ["https://large.example/", "KUDOS:1"], + ]), + ); + assert.deepStrictEqual(violation, { + exchangeBaseUrl: "https://small.example/", + amount: "KUDOS:3", + }); +}); diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts @@ -32,6 +32,10 @@ import { import { TaskRunResult } from "./common.js"; import { DbPreciseTimestamp, + DepositOperationStatus, + PeerPullPaymentCreditStatus, + PeerPushCreditStatus, + WithdrawalGroupStatus, timestampAbsoluteFromDb, timestampPreciseToDb, } from "./db/records.js"; @@ -143,6 +147,7 @@ export function getSingleExchangeDepositLimitInfo( switch (lim.operation_type) { case "DEPOSIT": case "AGGREGATE": + case "TRANSACTION": // FIXME: This should consider past deposits and KYC checks kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); break; @@ -157,6 +162,7 @@ export function getSingleExchangeDepositLimitInfo( switch (lim.operation_type) { case "DEPOSIT": case "AGGREGATE": + case "TRANSACTION": kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); break; } @@ -179,6 +185,7 @@ export function getPeerCreditLimitInfo( switch (lim.operation_type) { case "BALANCE": case "MERGE": + case "TRANSACTION": // FIXME: This should consider past merges and KYC checks kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); break; @@ -193,6 +200,7 @@ export function getPeerCreditLimitInfo( switch (lim.operation_type) { case "BALANCE": case "MERGE": + case "TRANSACTION": kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); break; } @@ -237,6 +245,30 @@ export function checkDepositHardLimitExceeded( ); } +/** Check a multi-exchange deposit against what each exchange will receive. */ +export function checkDepositHardLimitExceededPerExchange( + exchanges: ReadyExchangeSummary[], + contributions: ReadonlyMap<string, AmountLike>, +): { exchangeBaseUrl: string; amount: AmountString } | undefined { + for (const exchange of exchanges) { + const contribution = contributions.get(exchange.exchangeBaseUrl); + if (!contribution) { + continue; + } + const limitInfo = getSingleExchangeDepositLimitInfo(exchange, contribution); + if ( + limitInfo.kycHardLimit !== undefined && + Amounts.cmp(limitInfo.kycHardLimit, contribution) < 0 + ) { + return { + exchangeBaseUrl: exchange.exchangeBaseUrl, + amount: Amounts.stringify(contribution), + }; + } + } + return undefined; +} + export function getWithdrawalLimitInfo( exchange: ReadyExchangeSummary, instructedAmount: AmountLike, @@ -248,6 +280,7 @@ export function getWithdrawalLimitInfo( switch (lim.operation_type) { case "BALANCE": case "WITHDRAW": + case "TRANSACTION": // FIXME: This should consider past withdrawals and KYC checks kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); break; @@ -262,6 +295,7 @@ export function getWithdrawalLimitInfo( switch (lim.operation_type) { case "BALANCE": case "WITHDRAW": + case "TRANSACTION": kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); break; } @@ -283,13 +317,35 @@ export async function checkLimit( rules: AccountLimit[], operation: string, amount: AmountLike, + context: { + /** Expected account balance after the operation, for BALANCE rules. */ + balanceAfterOperation?: AmountLike; + /** Amount already accumulated during each rule's timeframe. */ + getTimeframeVolume?: ( + rule: AccountLimit, + ) => AmountLike | Promise<AmountLike>; + } = {}, ): Promise<LimitCheckResult> { let applicableLimit: AccountLimit | undefined; for (const rule of rules) { - if ( - rule.operation_type !== operation || - Amounts.cmp(amount, rule.threshold) <= 0 + let exposure: AmountLike; + if (rule.operation_type === "BALANCE") { + if (context.balanceAfterOperation === undefined) { + continue; + } + exposure = context.balanceAfterOperation; + } else if ( + rule.operation_type === operation || + rule.operation_type === "TRANSACTION" ) { + const prior = context.getTimeframeVolume + ? await context.getTimeframeVolume(rule) + : Amounts.zeroOfAmount(amount); + exposure = Amounts.add(prior, amount).amount; + } else { + continue; + } + if (Amounts.cmp(exposure, rule.threshold) <= 0) { continue; } // A rule that cannot be raised by passing KYC decides the outcome even @@ -314,6 +370,109 @@ export async function checkLimit( return LimitCheckResult.DeniedVerboten; } +function isInsideLimitTimeframe( + timestamp: DbPreciseTimestamp, + rule: AccountLimit, +): boolean { + const duration = Duration.fromTalerProtocolDuration(rule.timeframe); + if (Duration.isForever(duration)) { + return true; + } + const now = AbsoluteTime.now(); + const then = timestampAbsoluteFromDb(timestamp); + if ( + now.t_ms === "never" || + then.t_ms === "never" || + duration.d_ms === "forever" + ) { + return false; + } + return then.t_ms >= now.t_ms - duration.d_ms; +} + +/** + * Accumulate operations already committed by this wallet for the account. + * The exchange remains authoritative, but using durable local records avoids + * treating every repeated operation as the first one in a timeframe. + */ +async function getLocalTimeframeVolume( + wex: WalletExecutionContext, + st: GenericKycStatusReq, + rule: AccountLimit, +): Promise<AmountJson> { + const currency = Amounts.currencyOf(st.amount); + return await wex.runWalletDbTx(async (tx) => { + const amounts: AmountLike[] = []; + if ( + rule.operation_type === "WITHDRAW" || + rule.operation_type === "TRANSACTION" + ) { + for (const wg of await tx.getWithdrawalGroupsByReservePubs([ + st.accountPub, + ])) { + if ( + wg.status === WithdrawalGroupStatus.Done && + wg.rawWithdrawalAmount != null && + isInsideLimitTimeframe(wg.timestampStart, rule) + ) { + amounts.push(wg.rawWithdrawalAmount); + } + } + } + if ( + rule.operation_type === "MERGE" || + rule.operation_type === "TRANSACTION" + ) { + const reserve = await tx.getReserveByReservePub(st.accountPub); + if (reserve?.rowId != null) { + for (const rec of await tx.listAllPeerPullCredits()) { + if ( + rec.mergeReserveRowId === reserve.rowId && + rec.withdrawalGroupId != null && + (rec.status === PeerPullPaymentCreditStatus.PendingWithdrawing || + rec.status === PeerPullPaymentCreditStatus.Done) && + isInsideLimitTimeframe(rec.mergeTimestamp, rule) + ) { + amounts.push(rec.amount); + } + } + for (const rec of await tx.listAllPeerPushCredits()) { + if ( + rec.mergeReserveRowId === reserve.rowId && + rec.withdrawalGroupId != null && + (rec.status === PeerPushCreditStatus.PendingWithdrawing || + rec.status === PeerPushCreditStatus.Done) && + isInsideLimitTimeframe(rec.timestamp, rule) + ) { + amounts.push(rec.estimatedAmountEffective); + } + } + } + } + if ( + rule.operation_type === "DEPOSIT" || + rule.operation_type === "AGGREGATE" || + rule.operation_type === "TRANSACTION" + ) { + for (const rec of await tx.listAllDepositGroups()) { + if ( + rec.merchantPub !== st.accountPub || + !isInsideLimitTimeframe(rec.timestampCreated, rule) || + (rec.operationStatus !== DepositOperationStatus.Finished && + rec.operationStatus !== DepositOperationStatus.FinishedAbortTooLate) + ) { + continue; + } + amounts.push( + rec.infoPerExchange?.[st.exchangeBaseUrl]?.amountEffective ?? + rec.amount, + ); + } + } + return Amounts.sumOrZero(currency, amounts).amount; + }); +} + export interface GenericKycStatusReq { readonly exchangeBaseUrl: string; readonly paytoHash: string; @@ -321,6 +480,10 @@ export interface GenericKycStatusReq { readonly accountPriv: string; readonly operation: string; readonly amount: AmountLike; + readonly balanceAfterOperation?: AmountLike; + readonly getTimeframeVolume?: ( + rule: AccountLimit, + ) => AmountLike | Promise<AmountLike>; readonly lastCheckStatus?: number | undefined; readonly lastCheckCode?: number | undefined; readonly lastRuleGen?: number | undefined; @@ -492,6 +655,7 @@ export async function runKycCheckAlgo( updatedStatus.lastRuleGen = kycRes.body.rule_gen; updatedStatus.lastDeny = undefined; updatedStatus.accessToken = kycRes.body.access_token; + exposedLimits = kycRes.body.limits; break; case HttpStatusCode.Accepted: updatedStatus.accessToken = kycRes.body.access_token; @@ -511,7 +675,15 @@ export async function runKycCheckAlgo( } if (exposedLimits) { - const checkRes = await checkLimit(exposedLimits, st.operation, st.amount); + const localVolumeProvider = + st.getTimeframeVolume ?? + (typeof wex.runWalletDbTx === "function" + ? (rule: AccountLimit) => getLocalTimeframeVolume(wex, st, rule) + : undefined); + const checkRes = await checkLimit(exposedLimits, st.operation, st.amount, { + balanceAfterOperation: st.balanceAfterOperation, + getTimeframeVolume: localVolumeProvider, + }); logger.trace(`limit check result: ${LimitCheckResult[checkRes]}`); switch (checkRes) { case LimitCheckResult.Allowed: diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -1843,6 +1843,7 @@ export const walletApiExpectedErrors = { [WalletApiOperation.GetChoicesForPayment]: [ TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, ], [WalletApiOperation.ConfirmPay]: [ TalerErrorCode.WALLET_PAY_MERCHANT_INSUFFICIENT_BALANCE, @@ -1938,10 +1939,12 @@ export const walletApiExpectedErrors = { [WalletApiOperation.CheckPeerPushDebit]: [ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, ], [WalletApiOperation.CheckPeerPushDebitV2]: [ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, ], [WalletApiOperation.InitiatePeerPushDebit]: [ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts @@ -21,6 +21,7 @@ import { DenomKeyType, Duration, HttpStatusCode, + TransactionAction, TransactionMajorState, TransactionMinorState, } from "@gnu-taler/taler-util"; @@ -29,17 +30,131 @@ import { test } from "node:test"; import { DenominationVerificationStatus, timestampProtocolToDb, + WalletDenomination, + WalletWithdrawalGroup, WithdrawalGroupStatus, } from "./db/records.js"; -import { WalletDenomination } from "./db/records.js"; import { selectWithdrawalDenominations } from "./denomSelection.js"; import { classifyWithdrawalKycHardLimitRecovery, + computeWithdrawalTransactionActions, computeWithdrawalTransactionStatus, isWithdrawalFinalStatus, requireWithdrawalBatchCardinality, withdrawalStatusAfterBankAbort, + WithdrawTransactionContext, } from "./withdraw.js"; +import { WalletDbTransaction } from "./db/transaction.js"; +import { WalletExecutionContext } from "./wallet.js"; + +async function runWithdrawalAction( + status: WithdrawalGroupStatus, + action: "suspend" | "resume" | "abort", +): Promise<WithdrawalGroupStatus> { + let record = { + withdrawalGroupId: "withdrawal-action-test", + status, + } as WalletWithdrawalGroup; + const tx = { + async getWithdrawalGroup() { + return record; + }, + async upsertWithdrawalGroup(updated: WalletWithdrawalGroup) { + record = updated; + }, + notify() {}, + } as unknown as WalletDbTransaction; + const wex = { + async runWalletDbTx<T>( + f: (transaction: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return f(tx); + }, + taskScheduler: { + stopShepherdTask() {}, + async resetTask() {}, + }, + } as unknown as WalletExecutionContext; + const ctx = new WithdrawTransactionContext(wex, record.withdrawalGroupId); + switch (action) { + case "suspend": + await ctx.userSuspendTransaction(); + break; + case "resume": + await ctx.userResumeTransaction(); + break; + case "abort": + await ctx.userAbortTransaction(); + break; + } + return record.status; +} + +test("advertised withdrawal actions perform their promised transitions", async () => { + const cases: [ + WithdrawalGroupStatus, + TransactionAction, + "suspend" | "resume" | "abort", + WithdrawalGroupStatus, + ][] = [ + [ + WithdrawalGroupStatus.PendingBalanceKyc, + TransactionAction.Suspend, + "suspend", + WithdrawalGroupStatus.SuspendedBalanceKyc, + ], + [ + WithdrawalGroupStatus.PendingBalanceKycInit, + TransactionAction.Suspend, + "suspend", + WithdrawalGroupStatus.SuspendedBalanceKycInit, + ], + [ + WithdrawalGroupStatus.PendingRedenominate, + TransactionAction.Suspend, + "suspend", + WithdrawalGroupStatus.SuspendedRedenominate, + ], + [ + WithdrawalGroupStatus.SuspendedBalanceKyc, + TransactionAction.Resume, + "resume", + WithdrawalGroupStatus.PendingBalanceKyc, + ], + [ + WithdrawalGroupStatus.SuspendedBalanceKycInit, + TransactionAction.Resume, + "resume", + WithdrawalGroupStatus.PendingBalanceKycInit, + ], + [ + WithdrawalGroupStatus.SuspendedRedenominate, + TransactionAction.Resume, + "resume", + WithdrawalGroupStatus.PendingRedenominate, + ], + ]; + for (const [from, advertised, action, to] of cases) { + assert.ok( + computeWithdrawalTransactionActions({ status: from } as never).includes( + advertised, + ), + ); + assert.strictEqual(await runWithdrawalAction(from, action), to); + } + for (const funded of [ + WithdrawalGroupStatus.PendingReady, + WithdrawalGroupStatus.PendingRedenominate, + WithdrawalGroupStatus.SuspendedReady, + WithdrawalGroupStatus.SuspendedRedenominate, + ]) { + assert.ok( + !computeWithdrawalTransactionActions({ + status: funded, + } as never).includes(TransactionAction.Abort), + ); + } +}); test("a bank abort answered with 409 keeps the withdrawal resumable", () => { assert.strictEqual( @@ -90,6 +205,10 @@ test("hard-KYC withdrawal recovery distinguishes returned and stranded reserves" WithdrawalGroupStatus.FailedKycHardLimit, ); assert.strictEqual( + classifyWithdrawalKycHardLimitRecovery(HttpStatusCode.BadRequest), + WithdrawalGroupStatus.FinalizingKycHardLimit, + ); + assert.strictEqual( classifyWithdrawalKycHardLimitRecovery(HttpStatusCode.NotFound), WithdrawalGroupStatus.FailedKycHardLimitRecovery, ); diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -99,12 +99,14 @@ import { checkProtocolInvariant, codecForExchangeLegacyWithdrawBatchResponse, codecForLegitimizationNeededResponse, + createHashContext, decodeCrock, deriveCsNonce, deriveCsWithdrawBlindingSeed, encodeCrock, getErrorDetailFromException, getRandomBytes, + hashCoinEv, hashCoinPub, j2s, makeErrorDetail, @@ -197,6 +199,7 @@ import { constructTransactionIdentifier, isUnsuccessfulTransaction, makeInvalidTransactionIdError, + makeTransactionActionUnsupportedError, makeTransactionNotFoundError, parseTransactionIdentifier, } from "./transactions.js"; @@ -552,6 +555,38 @@ export class WithdrawTransactionContext implements TransactionContext { return; } const planchets = await tx.getPlanchetsByGroup(rec.withdrawalGroupId); + const sourceCoins = await tx.getCoinsBySourceTransaction( + this.transactionId, + ); + const numCoins = rec.denomsSel?.selectedDenoms.reduce( + (n, x) => n + x.count, + 0, + ); + for (const coin of sourceCoins) { + if ( + coin.coinSource.type !== CoinSourceType.Withdraw || + coin.coinSource.hPlanchets != null + ) { + continue; + } + const hPlanchets = + numCoins == null + ? undefined + : reconstructWithdrawalBatchHPlanchets( + planchets, + coin.coinSource.coinIndex, + numCoins, + ); + if (!hPlanchets) { + throw makeTransactionActionUnsupportedError( + this.transactionId, + "delete", + "withdrawal source material is still needed for recoup", + ); + } + coin.coinSource.hPlanchets = hPlanchets; + await tx.upsertCoin(coin); + } for (const p of planchets) { await tx.deletePlanchet(p.coinPub); } @@ -586,6 +621,15 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.PendingKyc: newStatus = WithdrawalGroupStatus.SuspendedKyc; break; + case WithdrawalGroupStatus.PendingBalanceKyc: + newStatus = WithdrawalGroupStatus.SuspendedBalanceKyc; + break; + case WithdrawalGroupStatus.PendingBalanceKycInit: + newStatus = WithdrawalGroupStatus.SuspendedBalanceKycInit; + break; + case WithdrawalGroupStatus.PendingRedenominate: + newStatus = WithdrawalGroupStatus.SuspendedRedenominate; + break; case WithdrawalGroupStatus.FinalizingKycHardLimit: case WithdrawalGroupStatus.FailedKycHardLimit: case WithdrawalGroupStatus.FailedKycHardLimitRecovery: @@ -599,6 +643,7 @@ export class WithdrawTransactionContext implements TransactionContext { wg.status = newStatus; await h.update(wg, "suspend"); }); + this.wex.taskScheduler.stopShepherdTask(this.taskId); } async userAbortTransaction(reason?: TalerErrorDetail): Promise<void> { @@ -609,6 +654,13 @@ export class WithdrawTransactionContext implements TransactionContext { logger.warn(`withdrawal group ${withdrawalGroupId} not found`); return; } + if (wg.reserveBalanceAmount !== undefined) { + throw makeTransactionActionUnsupportedError( + this.transactionId, + "abort", + "the funded reserve must be recovered", + ); + } let newStatus: WithdrawalGroupStatus | undefined = undefined; switch (wg.status) { case WithdrawalGroupStatus.SuspendedRegisteringBank: @@ -631,8 +683,11 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.PendingReady: case WithdrawalGroupStatus.SuspendedRedenominate: case WithdrawalGroupStatus.PendingRedenominate: - newStatus = WithdrawalGroupStatus.SuspendedReady; - break; + throw makeTransactionActionUnsupportedError( + this.transactionId, + "abort", + "the funded reserve must be recovered", + ); case WithdrawalGroupStatus.SuspendedAbortingBank: case WithdrawalGroupStatus.AbortingBank: case WithdrawalGroupStatus.AbortedUserRefused: @@ -659,6 +714,7 @@ export class WithdrawTransactionContext implements TransactionContext { wg.status = newStatus; await h.update(wg, "abort"); }); + await this.wex.taskScheduler.resetTask(this.taskId); } async userResumeTransaction(): Promise<void> { @@ -689,6 +745,15 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.SuspendedKyc: newStatus = WithdrawalGroupStatus.PendingKyc; break; + case WithdrawalGroupStatus.SuspendedBalanceKyc: + newStatus = WithdrawalGroupStatus.PendingBalanceKyc; + break; + case WithdrawalGroupStatus.SuspendedBalanceKycInit: + newStatus = WithdrawalGroupStatus.PendingBalanceKycInit; + break; + case WithdrawalGroupStatus.SuspendedRedenominate: + newStatus = WithdrawalGroupStatus.PendingRedenominate; + break; case WithdrawalGroupStatus.FinalizingKycHardLimit: case WithdrawalGroupStatus.FailedKycHardLimit: case WithdrawalGroupStatus.FailedKycHardLimitRecovery: @@ -702,6 +767,7 @@ export class WithdrawTransactionContext implements TransactionContext { wg.status = newStatus; await h.update(wg, "resume"); }); + await this.wex.taskScheduler.resetTask(this.taskId); } async userFailTransaction(reason?: TalerErrorDetail): Promise<void> { @@ -911,6 +977,7 @@ export function computeWithdrawalTransactionStatus( export function computeWithdrawalTransactionActions( wgRecord: WalletWithdrawalGroup, ): TransactionAction[] { + const fundingObserved = wgRecord.reserveBalanceAmount !== undefined; switch (wgRecord.status) { case WithdrawalGroupStatus.FailedBankAborted: return [TransactionAction.Delete]; @@ -928,17 +995,15 @@ export function computeWithdrawalTransactionActions( TransactionAction.Abort, ]; case WithdrawalGroupStatus.PendingReady: - return [ - TransactionAction.Retry, - TransactionAction.Suspend, - TransactionAction.Abort, - ]; + return [TransactionAction.Retry, TransactionAction.Suspend]; case WithdrawalGroupStatus.PendingQueryingStatus: - return [ - TransactionAction.Retry, - TransactionAction.Suspend, - TransactionAction.Abort, - ]; + return fundingObserved + ? [TransactionAction.Retry, TransactionAction.Suspend] + : [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingWaitConfirmBank: return [ TransactionAction.Retry, @@ -954,29 +1019,31 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.SuspendedAbortingBank: return [TransactionAction.Resume, TransactionAction.Fail]; case WithdrawalGroupStatus.SuspendedQueryingStatus: - return [TransactionAction.Resume, TransactionAction.Abort]; + return fundingObserved + ? [TransactionAction.Resume] + : [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedRegisteringBank: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedWaitConfirmBank: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedReady: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [TransactionAction.Resume]; case WithdrawalGroupStatus.SuspendedRedenominate: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [TransactionAction.Resume]; case WithdrawalGroupStatus.PendingRedenominate: - return [ - TransactionAction.Suspend, - TransactionAction.Abort, - TransactionAction.Retry, - ]; + return [TransactionAction.Suspend, TransactionAction.Retry]; case WithdrawalGroupStatus.PendingKyc: - return [ - TransactionAction.Suspend, - TransactionAction.Retry, - TransactionAction.Abort, - ]; + return fundingObserved + ? [TransactionAction.Suspend, TransactionAction.Retry] + : [ + TransactionAction.Suspend, + TransactionAction.Retry, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.SuspendedKyc: - return [TransactionAction.Resume, TransactionAction.Abort]; + return fundingObserved + ? [TransactionAction.Resume] + : [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.FailedAbortingBank: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.AbortedBank: @@ -986,21 +1053,29 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.DialogProposed: return [TransactionAction.Abort]; case WithdrawalGroupStatus.PendingBalanceKyc: - return [ - TransactionAction.Suspend, - TransactionAction.Retry, - TransactionAction.Abort, - ]; + return fundingObserved + ? [TransactionAction.Suspend, TransactionAction.Retry] + : [ + TransactionAction.Suspend, + TransactionAction.Retry, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.SuspendedBalanceKyc: - return [TransactionAction.Resume, TransactionAction.Abort]; + return fundingObserved + ? [TransactionAction.Resume] + : [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingBalanceKycInit: - return [ - TransactionAction.Suspend, - TransactionAction.Retry, - TransactionAction.Abort, - ]; + return fundingObserved + ? [TransactionAction.Suspend, TransactionAction.Retry] + : [ + TransactionAction.Suspend, + TransactionAction.Retry, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.SuspendedBalanceKycInit: - return [TransactionAction.Resume, TransactionAction.Abort]; + return fundingObserved + ? [TransactionAction.Resume] + : [TransactionAction.Resume, TransactionAction.Abort]; } } @@ -1576,6 +1651,63 @@ interface WithdrawalRequestBatchArgs { interface WithdrawalBatchResult { coinIdxs: number[]; batchResp: ExchangeWithdrawResponse; + hPlanchets?: string; +} + +export function hashWithdrawalPlanchets( + coinEvs: CoinEnvelope[], + denomHashes: HashCode[], +): string { + checkLogicInvariant(coinEvs.length === denomHashes.length); + const hc = createHashContext(); + for (let i = 0; i < coinEvs.length; i++) { + hc.update(hashCoinEv(coinEvs[i], denomHashes[i])); + } + return encodeCrock(hc.finish()); +} + +/** Reconstruct the protocol-v26 commitment for a legacy source coin. */ +export function reconstructWithdrawalBatchHPlanchets( + planchets: WalletPlanchet[], + coinIndex: number, + numCoins: number, +): string | undefined { + const byIndex = new Map(planchets.map((x) => [x.coinIdx, x])); + const maxBatchSize = 64; + for (let start = 0; start < numCoins; ) { + let batchSize = maxBatchSize; + const first = byIndex.get(start); + if (first?.coinEv.cipher === DenomKeyType.ClauseSchnorr) { + batchSize = 1; + } else { + for (let i = start + 1; i < start + maxBatchSize && i < numCoins; i++) { + if (byIndex.get(i)?.coinEv.cipher === DenomKeyType.ClauseSchnorr) { + batchSize = i - start; + break; + } + } + } + const end = Math.min(start + batchSize, numCoins); + if (coinIndex >= start && coinIndex < end) { + const batch = planchets + .filter( + (x) => + x.coinIdx >= start && + x.coinIdx < end && + x.planchetStatus !== PlanchetStatus.AbortedReplaced, + ) + .sort((a, b) => a.coinIdx - b.coinIdx); + if (!batch.some((x) => x.coinIdx === coinIndex)) { + return undefined; + } + return hashWithdrawalPlanchets( + batch.map((x) => x.coinEv), + batch.map((x) => x.denomPubHash), + ); + } + start = end; + } + return undefined; } export function requireWithdrawalBatchCardinality( @@ -1923,6 +2055,7 @@ async function processPlanchetExchangeBatchRequest( ), ), }); + const hPlanchets = hashWithdrawalPlanchets(coinEvs, denomHashes); const batchReq: ExchangeWithdrawRequest = { cipher: "ED25519", @@ -1987,6 +2120,7 @@ async function processPlanchetExchangeBatchRequest( return { coinIdxs: requestCoinIdxs, batchResp: resp.body, + hPlanchets, }; } catch (e) { // Network error or unexpected response. @@ -2016,6 +2150,7 @@ async function verifyPlanchetSignature( wgContext: WithdrawalGroupStatusInfo, coinIdx: number, resp: BlindedDenominationSignature, + hPlanchets?: string, ): Promise<PlanchetVerification> { const withdrawalGroup = wgContext.wgRecord; checkDbInvariant( @@ -2102,6 +2237,11 @@ async function verifyPlanchetSignature( coinIndex: coinIdx, reservePub: withdrawalGroup.reservePub, withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + hPlanchets, + nonce: + planchet.coinEv.cipher === DenomKeyType.ClauseSchnorr + ? planchet.coinEv.cs_nonce + : undefined, }, sourceTransactionId: transactionId, maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, @@ -2573,6 +2713,7 @@ async function processWithdrawalGroupPendingKyc( accountPriv, accountPub, amount, + balanceAfterOperation: withdrawalGroup.reserveBalanceAmount ?? amount, exchangeBaseUrl, operation: "WITHDRAW", paytoHash: withdrawalGroup.kycPaytoHash, @@ -2656,6 +2797,9 @@ async function processWithdrawalKycHardLimitRecovery( const finalStatus = classifyWithdrawalKycHardLimitRecovery( closeResp.response.status, ); + if (finalStatus === WithdrawalGroupStatus.FinalizingKycHardLimit) { + return TaskRunResult.backoff(); + } const recoveryFailure = finalStatus === WithdrawalGroupStatus.FailedKycHardLimitRecovery ? makeErrorDetail( @@ -2690,6 +2834,7 @@ async function processWithdrawalKycHardLimitRecovery( export function classifyWithdrawalKycHardLimitRecovery( httpStatus: number, ): + | WithdrawalGroupStatus.FinalizingKycHardLimit | WithdrawalGroupStatus.FailedKycHardLimit | WithdrawalGroupStatus.FailedKycHardLimitRecovery { switch (httpStatus) { @@ -2699,6 +2844,10 @@ export function classifyWithdrawalKycHardLimitRecovery( // the reserve has still been closed deliberately. return WithdrawalGroupStatus.FailedKycHardLimit; case HttpStatusCode.BadRequest: + // Reserve-close's documented 400 response is excessive clock skew. + // A fresh request carries a fresh timestamp and can succeed once the + // local clock is corrected, so keep the recovery task live. + return WithdrawalGroupStatus.FinalizingKycHardLimit; case HttpStatusCode.Forbidden: case HttpStatusCode.NotFound: case HttpStatusCode.Conflict: @@ -3071,6 +3220,7 @@ async function processWithdrawalGroupPendingReady( wgContext, resp.coinIdxs[j], resp.batchResp.ev_sigs[j], + resp.hPlanchets, ), ); } @@ -3182,9 +3332,9 @@ async function startRedenomination( return await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); switch (rec?.status) { - case WithdrawalGroupStatus.PendingReady: - // The withdrawal request is also sent while waiting for KYC; a + // The withdrawal request is also sent while waiting for KYC, so a // denomination that expired meanwhile is replaced the same way. + case WithdrawalGroupStatus.PendingReady: case WithdrawalGroupStatus.PendingKyc: break; default: