taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/coinSelection.ts | 192++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mpackages/taler-wallet-core/src/denominations.test.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/denominations.ts | 53++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/deposits.test.ts | 34++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
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.