commit 64d93b7b2719bba72cc3ec440b69a02202fb7cc8
parent d6d3104341b2c2f77c4f571726a55b1004c3acc1
Author: Florian Dold <dold@taler.net>
Date: Fri, 4 Sep 2026 01:00:59 +0200
wallet-core: fix coin selection and deposit state updates
Respect contribution limits and validate currencies and fees.
Preserve newer states during abort or revocation races and stop
retrying permanent deposit failures.
Diffstat:
6 files changed, 578 insertions(+), 51 deletions(-)
diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts
@@ -23,6 +23,7 @@ import {
DenomKeyType,
DenominationPubKey,
Duration,
+ ForcedCoinSel,
TalerError,
TalerErrorCode,
TalerProtocolTimestamp,
@@ -46,6 +47,7 @@ import {
testing_getMaxPeerPushDebitAmountForAvailableCoins,
testing_makeBalanceSnapshot,
testing_selectGreedy,
+ testing_selectForced,
} from "./coinSelection.js";
import {
ExchangeEntryDbRecordStatus,
@@ -378,6 +380,195 @@ function createCandidates(
});
}
+function makePaymentTally(amount: AmountString): CoinSelectionTally {
+ const payment = Amounts.parseOrThrow(amount);
+ const zero = Amounts.zeroOfCurrency(payment.currency);
+ return {
+ amountPayRemaining: payment,
+ amountDepositFeeLimitRemaining: zero,
+ customerDepositFees: zero,
+ customerWireFees: zero,
+ totalDepositFees: zero,
+ wireFeeCoveredForExchange: new Set(),
+ };
+}
+
+test("forced selection uses contributions instead of denomination values", () => {
+ const candidates = createCandidates([
+ {
+ amount: "LOCAL:2" as AmountString,
+ depositFee: "LOCAL:0" as AmountString,
+ numAvailable: 3,
+ fromExchange: "https://exchange.example/",
+ fromMasterPub: "master",
+ },
+ ]);
+ const tally = makePaymentTally("LOCAL:3" as AmountString);
+ const req = {
+ contractTermsAmount: Amounts.parseOrThrow("LOCAL:3"),
+ depositFeeLimit: Amounts.zeroOfCurrency("LOCAL"),
+ restrictExchanges: undefined,
+ restrictWireMethod: "iban",
+ forcedSelection: {
+ coins: Array.from({ length: 3 }, () => ({
+ value: "LOCAL:2" as AmountString,
+ contribution: "LOCAL:1" as AmountString,
+ })),
+ },
+ };
+
+ const result = testing_selectForced(req, candidates, tally, {});
+
+ assert.deepStrictEqual(
+ Object.values(result ?? {}).flatMap((x) => x.contributions),
+ Array.from({ length: 3 }, () => Amounts.parseOrThrow("LOCAL:1")),
+ );
+ assert.ok(Amounts.isZero(tally.amountPayRemaining));
+});
+
+test("forced selection searches same-value denominations for matching fees", () => {
+ const candidates = createCandidates([
+ {
+ amount: "LOCAL:2" as AmountString,
+ depositFee: "LOCAL:0.2" as AmountString,
+ numAvailable: 1,
+ fromExchange: "https://cheap.example/",
+ fromMasterPub: "cheap-master",
+ },
+ {
+ amount: "LOCAL:2" as AmountString,
+ depositFee: "LOCAL:0.5" as AmountString,
+ numAvailable: 1,
+ fromExchange: "https://exact.example/",
+ fromMasterPub: "exact-master",
+ },
+ ]);
+ const tally = makePaymentTally("LOCAL:1" as AmountString);
+ const result = testing_selectForced(
+ {
+ contractTermsAmount: Amounts.parseOrThrow("LOCAL:1"),
+ depositFeeLimit: Amounts.zeroOfCurrency("LOCAL"),
+ restrictExchanges: undefined,
+ restrictWireMethod: "iban",
+ forcedSelection: {
+ coins: [
+ {
+ value: "LOCAL:2" as AmountString,
+ contribution: "LOCAL:1.5" as AmountString,
+ },
+ ],
+ },
+ },
+ candidates,
+ tally,
+ {},
+ );
+ assert.deepStrictEqual(
+ Object.values(result ?? {}).map((x) => x.exchangeBaseUrl),
+ ["https://exact.example/"],
+ );
+ assert.strictEqual(candidates[0].numAvailable, 1);
+ assert.strictEqual(candidates[1].numAvailable, 0);
+});
+
+test("rejected forced selection does not consume candidates or mutate tally", () => {
+ const candidates = createCandidates([
+ {
+ amount: "LOCAL:2" as AmountString,
+ depositFee: "LOCAL:0.2" as AmountString,
+ numAvailable: 1,
+ fromExchange: "https://exchange.example/",
+ fromMasterPub: "master",
+ },
+ ]);
+ const tally = makePaymentTally("LOCAL:1" as AmountString);
+ assert.throws(() =>
+ testing_selectForced(
+ {
+ contractTermsAmount: Amounts.parseOrThrow("LOCAL:1"),
+ depositFeeLimit: Amounts.zeroOfCurrency("LOCAL"),
+ restrictExchanges: undefined,
+ restrictWireMethod: "iban",
+ forcedSelection: {
+ coins: [
+ {
+ value: "LOCAL:2" as AmountString,
+ contribution: "LOCAL:1.1" as AmountString,
+ },
+ ],
+ },
+ },
+ candidates,
+ tally,
+ {},
+ ),
+ );
+ assert.strictEqual(candidates[0].numAvailable, 1);
+ assert.strictEqual(Amounts.stringify(tally.amountPayRemaining), "LOCAL:1");
+ assert.ok(Amounts.isZero(tally.customerDepositFees));
+});
+
+test("forced selection rejects invalid contribution totals and currencies", () => {
+ const makeCandidates = () =>
+ createCandidates([
+ {
+ amount: "LOCAL:2" as AmountString,
+ depositFee: "LOCAL:0.1" as AmountString,
+ numAvailable: 3,
+ fromExchange: "https://exchange.example/",
+ fromMasterPub: "master",
+ },
+ ]);
+ const baseReq = {
+ contractTermsAmount: Amounts.parseOrThrow("LOCAL:1"),
+ depositFeeLimit: Amounts.zeroOfCurrency("LOCAL"),
+ restrictExchanges: undefined,
+ restrictWireMethod: "iban",
+ };
+ const rejects = (coins: ForcedCoinSel["coins"]) => {
+ assert.throws(() =>
+ testing_selectForced(
+ { ...baseReq, forcedSelection: { coins } },
+ makeCandidates(),
+ makePaymentTally("LOCAL:1" as AmountString),
+ {},
+ ),
+ );
+ };
+
+ rejects([]);
+ rejects([
+ {
+ value: "LOCAL:2" as AmountString,
+ contribution: "LOCAL:0.5" as AmountString,
+ },
+ ]);
+ rejects([
+ {
+ value: "LOCAL:2" as AmountString,
+ contribution: "LOCAL:1.2" as AmountString,
+ },
+ ]);
+ rejects([
+ {
+ value: "OTHER:2" as AmountString,
+ contribution: "OTHER:1" as AmountString,
+ },
+ ]);
+ rejects([
+ {
+ value: "LOCAL:2" as AmountString,
+ contribution: "LOCAL:0.05" as AmountString,
+ },
+ ]);
+ rejects([
+ {
+ value: "LOCAL:2" as AmountString,
+ contribution: "LOCAL:2.1" as AmountString,
+ },
+ ]);
+});
+
test("p2p: regression STATER", (t) => {
const candidates = [
{
@@ -1052,10 +1243,10 @@ test("coins of one denomination under two master keys stay separate", (t) => {
// the two coins must not be folded into one entry.
const entries = Object.values(coins);
assert.strictEqual(entries.length, 2);
- assert.deepStrictEqual(
- entries.map((e) => e.exchangeMasterPub).sort(),
- ["NEWKEY", "OLDKEY"],
- );
+ assert.deepStrictEqual(entries.map((e) => e.exchangeMasterPub).sort(), [
+ "NEWKEY",
+ "OLDKEY",
+ ]);
for (const e of entries) {
assert.deepStrictEqual(e.contributions, [Amounts.parseOrThrow("LOCAL:1")]);
}
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
@@ -62,6 +62,8 @@ import {
ScopeType,
SelectedCoin,
strcmp,
+ TalerError,
+ TalerErrorCode,
TalerProtocolTimestamp,
WireInfo,
} from "@gnu-taler/taler-util";
@@ -312,7 +314,12 @@ async function internalSelectPayCoins(
let selectedDenom: SelResult | undefined;
if (req.forcedSelection) {
- selectedDenom = selectForced(req, candidateDenoms);
+ selectedDenom = selectForced(
+ req,
+ candidateDenoms,
+ tally,
+ wireFeesPerExchange,
+ );
} else {
// FIXME: Here, we should select coins in a smarter way.
// Instead of always spending the next-largest coin,
@@ -1762,49 +1769,170 @@ function reduceSelectionFees(
function selectForced(
req: SelectPayCoinRequestNg,
candidateDenoms: AvailableCoinsOfDenom[],
+ tally: CoinSelectionTally,
+ wireFeesPerExchange: Record<string, AmountJson>,
): SelResult | undefined {
- const selectedDenom: SelResult = {};
-
const forcedSelection = req.forcedSelection;
checkLogicInvariant(!!forcedSelection);
- for (const forcedCoin of forcedSelection.coins) {
- let found = false;
- for (const aci of candidateDenoms) {
- if (aci.numAvailable <= 0) {
- continue;
- }
- if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
- aci.numAvailable--;
- const avKey = makeAvailabilityKey(
- aci.exchangeBaseUrl,
- aci.exchangeMasterPub,
- aci.denomPubHash,
- aci.maxAge,
- );
- let sd = selectedDenom[avKey];
- if (!sd) {
- sd = {
- contributions: [],
- denomPubHash: aci.denomPubHash,
- exchangeBaseUrl: aci.exchangeBaseUrl,
- exchangeMasterPub: aci.exchangeMasterPub,
- maxAge: aci.maxAge,
- };
+ const currency = req.contractTermsAmount.currency;
+ if (forcedSelection.coins.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "forcedCoinSel" },
+ "forced coin selection must not be empty",
+ );
+ }
+
+ const parsedForced = forcedSelection.coins.map((forcedCoin) => {
+ const forcedValue = Amounts.parseOrThrow(forcedCoin.value);
+ const contribution = Amounts.parseOrThrow(forcedCoin.contribution);
+ if (
+ forcedValue.currency !== currency ||
+ contribution.currency !== currency
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "forcedCoinSel" },
+ "forced coin value and contribution must use the payment currency",
+ );
+ }
+ if (
+ Amounts.isZero(contribution) ||
+ Amounts.cmp(contribution, forcedValue) > 0
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "forcedCoinSel" },
+ "forced coin contribution must be positive and not exceed its value",
+ );
+ }
+ return { forcedValue, contribution };
+ });
+
+ const optionIndexes = parsedForced.map(({ forcedValue, contribution }) => {
+ const matchingValue = candidateDenoms
+ .map((candidate, index) => ({ candidate, index }))
+ .filter(
+ ({ candidate }) =>
+ candidate.numAvailable > 0 &&
+ Amounts.cmp(candidate.value, forcedValue) === 0,
+ );
+ if (matchingValue.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "forcedCoinSel" },
+ `no available coin has forced value ${Amounts.stringify(forcedValue)}`,
+ );
+ }
+ const usable = matchingValue.filter(
+ ({ candidate }) => Amounts.cmp(contribution, candidate.feeDeposit) >= 0,
+ );
+ if (usable.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "forcedCoinSel" },
+ "forced coin contribution must cover its deposit fee",
+ );
+ }
+ usable.sort(({ candidate: a }, { candidate: b }) =>
+ Amounts.cmp(a.feeDeposit, b.feeDeposit),
+ );
+ return usable.map(({ index }) => index);
+ });
+
+ const used = candidateDenoms.map(() => 0);
+ const chosen: number[] = [];
+ let finalTally: CoinSelectionTally | undefined;
+ let visited = 0;
+ const search = (position: number): boolean => {
+ if (++visited > 100_000) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "forcedCoinSel" },
+ "forced coin selection is too ambiguous",
+ );
+ }
+ if (position < parsedForced.length) {
+ for (const candidateIndex of optionIndexes[position]) {
+ if (
+ used[candidateIndex] >= candidateDenoms[candidateIndex].numAvailable
+ ) {
+ continue;
}
- sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
- selectedDenom[avKey] = sd;
- found = true;
- break;
+ used[candidateIndex]++;
+ chosen.push(candidateIndex);
+ if (search(position + 1)) {
+ return true;
+ }
+ chosen.pop();
+ used[candidateIndex]--;
}
+ return false;
}
- if (!found) {
- throw Error("can't find coin for forced coin selection");
+ const trial = cloneTally(tally);
+ let totalContribution = Amounts.zeroOfCurrency(currency);
+ for (let i = 0; i < chosen.length; i++) {
+ const candidate = candidateDenoms[chosen[i]];
+ tallyFees(
+ trial,
+ wireFeesPerExchange,
+ candidate.exchangeBaseUrl,
+ Amounts.parseOrThrow(candidate.feeDeposit),
+ );
+ totalContribution = Amounts.add(
+ totalContribution,
+ parsedForced[i].contribution,
+ ).amount;
+ }
+ if (Amounts.cmp(totalContribution, trial.amountPayRemaining) !== 0) {
+ return false;
}
+ trial.amountPayRemaining = Amounts.zeroOfCurrency(currency);
+ finalTally = trial;
+ return true;
+ };
+
+ if (!search(0) || !finalTally) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "forcedCoinSel" },
+ "forced coin contributions do not exactly cover the payment and customer fees",
+ );
+ }
+
+ Object.assign(tally, finalTally);
+ const selectedDenom: SelResult = {};
+ for (let i = 0; i < chosen.length; i++) {
+ const aci = candidateDenoms[chosen[i]];
+ aci.numAvailable--;
+ const avKey = makeAvailabilityKey(
+ aci.exchangeBaseUrl,
+ aci.exchangeMasterPub,
+ aci.denomPubHash,
+ aci.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = selectedDenom[avKey] = {
+ contributions: [],
+ denomPubHash: aci.denomPubHash,
+ exchangeBaseUrl: aci.exchangeBaseUrl,
+ exchangeMasterPub: aci.exchangeMasterPub,
+ maxAge: aci.maxAge,
+ };
+ }
+ sd.contributions.push(parsedForced[i].contribution);
}
return selectedDenom;
}
+export function testing_selectForced(
+ ...args: Parameters<typeof selectForced>
+): ReturnType<typeof selectForced> {
+ return selectForced(...args);
+}
+
export interface SelectPayCoinRequestNg {
restrictExchanges: ExchangeRestrictionSpec | undefined;
restrictScope?: ScopeInfo;
diff --git a/packages/taler-wallet-core/src/denominations.test.ts b/packages/taler-wallet-core/src/denominations.test.ts
@@ -32,9 +32,82 @@ import {
createPairTimeline,
createTimeline,
selectBestForOverlappingDenominations,
+ validateDenoms,
} from "./denominations.js";
import { test } from "node:test";
import assert from "node:assert";
+import {
+ DenominationVerificationStatus,
+ WalletDenomination,
+ timestampProtocolToDb,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+function makeUnverifiedDenom(): WalletDenomination {
+ return {
+ currency: "TESTKUDOS",
+ value: "TESTKUDOS:1",
+ denomPub: {} as never,
+ denomPubHash: "denom-hash",
+ exchangeBaseUrl: "https://old.example/",
+ exchangeMasterPub: "master-pub",
+ fees: {
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ },
+ stampStart: timestampProtocolToDb({ t_s: 1 }),
+ stampExpireWithdraw: timestampProtocolToDb({ t_s: 2 }),
+ stampExpireDeposit: timestampProtocolToDb({ t_s: 3 }),
+ stampExpireLegal: timestampProtocolToDb({ t_s: 4 }),
+ masterSig: "master-signature",
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ isOffered: true,
+ isRevoked: false,
+ isLost: false,
+ } as WalletDenomination;
+}
+
+test("denomination verification patches only a still-matching current row", async () => {
+ const snapshot = makeUnverifiedDenom();
+ let current = structuredClone(snapshot);
+ const wex = {
+ cryptoApi: {
+ async isValidDenom() {
+ current.isRevoked = true;
+ current.exchangeBaseUrl = "https://new.example/";
+ return { valid: true };
+ },
+ },
+ ws: {
+ config: { testing: { insecureTrustExchange: false } },
+ denomInfoCache: { clear() {} },
+ },
+ async runWalletDbTx<T>(
+ f: (tx: WalletDbTransaction) => Promise<T>,
+ ): Promise<T> {
+ return f({
+ async getDenomination() {
+ return current;
+ },
+ async upsertDenomination(rec: WalletDenomination) {
+ current = rec;
+ },
+ } as unknown as WalletDbTransaction);
+ },
+ } as unknown as WalletExecutionContext;
+
+ await validateDenoms(wex, [snapshot]);
+
+ assert.strictEqual(current.isRevoked, true);
+ assert.strictEqual(current.exchangeBaseUrl, "https://new.example/");
+ assert.strictEqual(
+ current.verificationStatus,
+ DenominationVerificationStatus.VerifiedGood,
+ );
+});
/**
* Create some constants to be used as reference in the tests
diff --git a/packages/taler-wallet-core/src/denominations.ts b/packages/taler-wallet-core/src/denominations.ts
@@ -23,6 +23,7 @@ import {
Amounts,
AmountString,
assertUnreachable,
+ canonicalJson,
DenominationInfo,
Duration,
FeeDescription,
@@ -432,6 +433,20 @@ export async function isValidDenomRecord(
const denomBatchSize = 70;
+function denominationVerificationInput(d: WalletDenomination): string {
+ return canonicalJson({
+ denomPubHash: d.denomPubHash,
+ exchangeMasterPub: d.exchangeMasterPub,
+ fees: d.fees,
+ masterSig: d.masterSig,
+ stampExpireDeposit: d.stampExpireDeposit,
+ stampExpireLegal: d.stampExpireLegal,
+ stampExpireWithdraw: d.stampExpireWithdraw,
+ stampStart: d.stampStart,
+ value: d.value,
+ });
+}
+
export async function validateDenoms(
wex: WalletExecutionContext,
denoms: WalletDenomination[],
@@ -445,7 +460,12 @@ export async function validateDenoms(
let current = 0;
while (current < denoms.length) {
- const updatedDenoms: WalletDenomination[] = [];
+ const verificationResults: {
+ exchangeMasterPub: string;
+ denomPubHash: string;
+ input: string;
+ status: DenominationVerificationStatus;
+ }[] = [];
for (
let batchIdx = 0;
batchIdx < denomBatchSize && current < denoms.length;
@@ -470,26 +490,41 @@ export async function validateDenoms(
logger.trace(`Done validating ${denom.denomPubHash}`);
+ let status: DenominationVerificationStatus;
if (!valid) {
logger.warn(
`Signature check for denomination h=${denom.denomPubHash} failed`,
);
- denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
+ status = DenominationVerificationStatus.VerifiedBad;
} else {
- denom.verificationStatus =
- DenominationVerificationStatus.VerifiedGood;
+ status = DenominationVerificationStatus.VerifiedGood;
}
- updatedDenoms.push(denom);
+ verificationResults.push({
+ exchangeMasterPub: denom.exchangeMasterPub,
+ denomPubHash: denom.denomPubHash,
+ input: denominationVerificationInput(denom),
+ status,
+ });
}
}
- if (updatedDenoms.length > 0) {
+ if (verificationResults.length > 0) {
logger.trace("writing denomination batch to db");
await wex.runWalletDbTx(async (tx) => {
- for (let i = 0; i < updatedDenoms.length; i++) {
- const denom = updatedDenoms[i];
- await tx.upsertDenomination(denom);
+ for (const result of verificationResults) {
+ const currentDenom = await tx.getDenomination(result);
+ if (
+ currentDenom?.verificationStatus !==
+ DenominationVerificationStatus.Unverified ||
+ denominationVerificationInput(currentDenom) !== result.input
+ ) {
+ continue;
+ }
+ await tx.upsertDenomination({
+ ...currentDenom,
+ verificationStatus: result.status,
+ });
}
});
diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts
@@ -40,8 +40,42 @@ import {
reconstructDepositRefundRequests,
testing_getDepositTrackingTiming,
getDepositAbortRefreshAmount,
+ isDepositSubmissionState,
+ requireFiniteDepositWireDeadline,
} from "./deposits.js";
+test("direct deposits reject an infinite wire deadline before selection", () => {
+ assert.throws(
+ () => requireFiniteDepositWireDeadline(TalerProtocolTimestamp.never()),
+ (e: unknown) =>
+ e instanceof Error &&
+ "errorDetail" in e &&
+ (e as { errorDetail: { code: number } }).errorDetail.code ===
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ );
+ assert.doesNotThrow(() =>
+ requireFiniteDepositWireDeadline(TalerProtocolTimestamp.fromSeconds(10)),
+ );
+});
+
+test("only active deposit states can submit another batch", () => {
+ for (const status of [
+ DepositOperationStatus.PendingDeposit,
+ DepositOperationStatus.PendingDepositKyc,
+ DepositOperationStatus.PendingDepositKycAuth,
+ ]) {
+ assert.strictEqual(isDepositSubmissionState(status), true);
+ }
+ for (const status of [
+ DepositOperationStatus.SuspendedDeposit,
+ DepositOperationStatus.Aborting,
+ DepositOperationStatus.FinalizingTrack,
+ DepositOperationStatus.Finished,
+ ]) {
+ assert.strictEqual(isDepositSubmissionState(status), false);
+ }
+});
+
test("deposit tracking waits at a future wire deadline", () => {
const timing = testing_getDepositTrackingTiming(
TalerProtocolTimestamp.fromSeconds(3),
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -136,7 +136,7 @@ import { SignContractTermsHashResponse } from "./crypto/cryptoTypes.js";
import { WithdrawalGroupStatus } from "./db/records.js";
import {
GenericKycStatusReq,
- checkDepositHardLimitExceeded,
+ checkDepositHardLimitExceededPerExchange,
getDepositLimitInfo,
isKycOperationDue,
makeKycHardLimitError,
@@ -1592,7 +1592,9 @@ async function processDepositGroupPendingKyc(
myKycState = {
accountPriv: depositGroup.merchantPriv,
accountPub: depositGroup.merchantPub,
- amount: depositGroup.amount,
+ amount:
+ depositGroup.infoPerExchange?.[maybeKycInfo.exchangeBaseUrl]
+ ?.amountEffective ?? depositGroup.amount,
operation: "DEPOSIT",
exchangeBaseUrl: maybeKycInfo.exchangeBaseUrl,
paytoHash: maybeKycInfo.paytoHash,
@@ -2374,6 +2376,14 @@ async function submitDepositBatch(
});
}
+ const submissionStillAllowed = await wex.runWalletDbTx(async (tx) => {
+ const current = await tx.getDepositGroup(depositGroupId);
+ return !!current && isDepositSubmissionState(current.operationStatus);
+ });
+ if (!submissionStillAllowed) {
+ return TaskRunResult.progress();
+ }
+
// Check for cancellation before making network request.
wex.cancellationToken?.throwIfCancelled();
logger.info(`depositing to ${exchangeBaseUrl}`);
@@ -2398,6 +2408,24 @@ async function submitDepositBatch(
badKycAuth: kycLegiNeededResp.bad_kyc_auth ?? false,
});
}
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Forbidden:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.Gone:
+ case HttpStatusCode.PreconditionFailed:
+ case HttpStatusCode.PayloadTooLarge: {
+ await wex.runWalletDbTx(async (tx) => {
+ const [current, h] = await ctx.getRecordHandle(tx);
+ if (!current || !isDepositSubmissionState(current.operationStatus)) {
+ return;
+ }
+ current.operationStatus = DepositOperationStatus.Aborting;
+ current.abortReason = depositResp.detail;
+ await h.update(current, "deposit-permanent-rejection");
+ });
+ return TaskRunResult.progress();
+ }
}
const batchTotalWithoutFee = await getBatchDepositTotalWithoutFee(wex, coins);
@@ -2548,7 +2576,7 @@ async function processDepositGroupPendingDeposit(
await wex.runWalletDbTx(async (tx) => {
const [dg, h] = await ctx.getRecordHandle(tx);
- if (!dg) {
+ if (!dg || !isDepositSubmissionState(dg.operationStatus)) {
return undefined;
}
dg.operationStatus = DepositOperationStatus.FinalizingTrack;
@@ -2557,6 +2585,16 @@ async function processDepositGroupPendingDeposit(
return TaskRunResult.progress();
}
+export function isDepositSubmissionState(
+ status: DepositOperationStatus,
+): boolean {
+ return (
+ status === DepositOperationStatus.PendingDeposit ||
+ status === DepositOperationStatus.PendingDepositKyc ||
+ status === DepositOperationStatus.PendingDepositKycAuth
+ );
+}
+
/**
* Process a deposit group that is not in its final state yet.
*/
@@ -2781,6 +2819,8 @@ async function internalCreateDepositGroup(
const amount = Amounts.parseOrThrow(req.amount);
const currency = amount.currency;
+ requireFiniteDepositWireDeadline(req.wireDeadline);
+
const exchangeInfos: Exchange[] = await getExchangesForDeposit(wex, {
currency,
restrictScope: req.restrictScope,
@@ -2843,12 +2883,26 @@ async function internalCreateDepositGroup(
exchanges.push(await fetchFreshExchangeWithRetryNow(wex, exchangeBaseUrl));
}
- if (checkDepositHardLimitExceeded(exchanges, req.amount)) {
+ const contributionsByExchange = new Map<string, AmountJson>();
+ for (const coin of coins) {
+ const oldAmount =
+ contributionsByExchange.get(coin.exchangeBaseUrl) ??
+ Amounts.zeroOfAmount(coin.contribution);
+ contributionsByExchange.set(
+ coin.exchangeBaseUrl,
+ Amounts.add(oldAmount, coin.contribution).amount,
+ );
+ }
+ const hardLimitViolation = checkDepositHardLimitExceededPerExchange(
+ exchanges,
+ contributionsByExchange,
+ );
+ if (hardLimitViolation) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED,
{
- exchangeBaseUrl: exchanges[0]?.exchangeBaseUrl,
- requestedAmount: req.amount,
+ exchangeBaseUrl: hardLimitViolation.exchangeBaseUrl,
+ requestedAmount: hardLimitViolation.amount,
},
"deposit would exceed a hard limit of the exchange",
);
@@ -3044,6 +3098,18 @@ async function internalCreateDepositGroup(
};
}
+export function requireFiniteDepositWireDeadline(
+ wireDeadline: TalerProtocolTimestamp | undefined,
+): void {
+ if (wireDeadline?.t_s === "never") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "wireDeadline" },
+ "deposit wire deadline must be finite",
+ );
+ }
+}
+
/**
* Get the amount that will be deposited on the users bank
* account after depositing, not considering aggregation.