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:
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: