taler-typescript-core

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

commit 6c7c66ff5cef6d88419298e6c945fd4a6d0f85ff
parent 85cbaaf254166b4da4ffa50e9e6c3411e6b13f5c
Author: Florian Dold <dold@taler.net>
Date:   Wed, 19 Aug 2026 15:27:03 +0200

wallet-core: expose deposit maximum diagnostics

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-account-restrictions.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-deposit-twice.ts | 9+++++++--
Mpackages/taler-harness/src/integrationtests/test-deposit.ts | 16++++++++++++++--
Mpackages/taler-util/src/types-taler-wallet.ts | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 247++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/coinSelection.ts | 300++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 8++++++++
7 files changed, 630 insertions(+), 86 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-account-restrictions.ts b/packages/taler-harness/src/integrationtests/test-account-restrictions.ts @@ -19,6 +19,7 @@ */ import { AmountString, + DepositEligibilityReasonType, j2s, Logger, NotificationType, @@ -99,11 +100,58 @@ export async function runAccountRestrictionsTest(t: GlobalTestState) { logger.info(`checkResp ${j2s(err)}`); + const invalidMaximum = await walletClient.call( + WalletApiOperation.GetMaxDepositAmount, + { + currency: "TESTKUDOS", + depositPaytoUri: "payto://x-taler-bank/localhost/bar-42", + }, + ); + t.assertAmountEquals(invalidMaximum.material.instructedAmount, "TESTKUDOS:0"); + t.assertAmountEquals( + invalidMaximum.available.instructedAmount, + "TESTKUDOS:0", + ); + const invalidDiagnostics = Object.values(invalidMaximum.exchangeDiagnostics); + t.assertDeepEqual(invalidDiagnostics.length, 1); + t.assertDeepEqual(invalidDiagnostics[0].reasons.length, 1); + const accountReason = invalidDiagnostics[0].reasons[0]; + t.assertDeepEqual( + accountReason.type, + DepositEligibilityReasonType.DepositAccountRestricted, + ); + if ( + accountReason.type !== DepositEligibilityReasonType.DepositAccountRestricted + ) { + throw Error("expected a deposit account restriction"); + } + t.assertTrue( + Object.values(accountReason.accountRestrictions) + .flat() + .some( + (restriction) => + restriction.type === "regex" && restriction.human_hint === "bla", + ), + ); + // Valid account await walletClient.call(WalletApiOperation.CheckDeposit, { amount: "TESTKUDOS:5", depositPaytoUri: "payto://x-taler-bank/localhost/foo-42", }); + const validMaximum = await walletClient.call( + WalletApiOperation.GetMaxDepositAmount, + { + currency: "TESTKUDOS", + depositPaytoUri: "payto://x-taler-bank/localhost/foo-42", + }, + ); + t.assertTrue( + Object.values(validMaximum.exchangeDiagnostics).every( + (diagnostics) => diagnostics.reasons.length === 0, + ), + ); + t.assertTrue(validMaximum.material.instructedAmount !== "TESTKUDOS:0"); } export async function myWithdrawViaBank( diff --git a/packages/taler-harness/src/integrationtests/test-deposit-twice.ts b/packages/taler-harness/src/integrationtests/test-deposit-twice.ts @@ -208,10 +208,15 @@ export async function runDepositTwiceTest(t: GlobalTestState) { ); console.log(`DEPOSIT : ${j2s(maxDepositResp)}`); - t.assertAmountEquals(maxDepositResp.effectiveAmount, "TESTKUDOS:10"); + t.assertAmountEquals( + maxDepositResp.material.instructedAmount, + "TESTKUDOS:10", + ); + t.assertAmountEquals(maxDepositResp.material.effectiveAmount, "TESTKUDOS:10"); // The effective amount is the amount removed from the wallet. The raw // amount is what reaches the bank account after the deposit fee. - t.assertAmountEquals(maxDepositResp.rawAmount, "TESTKUDOS:9.99"); + t.assertAmountEquals(maxDepositResp.material.rawAmount, "TESTKUDOS:9.99"); + t.assertDeepEqual(maxDepositResp.available, maxDepositResp.material); const secondDeposit = await bobWallet.call( WalletApiOperation.CreateDepositGroup, diff --git a/packages/taler-harness/src/integrationtests/test-deposit.ts b/packages/taler-harness/src/integrationtests/test-deposit.ts @@ -68,8 +68,20 @@ export async function runDepositTest(t: GlobalTestState) { }, ); - t.assertAmountEquals(maxDepositResp.rawAmount, "TESTKUDOS:19.72"); - t.assertAmountEquals(maxDepositResp.effectiveAmount, "TESTKUDOS:19.84"); + t.assertAmountEquals(maxDepositResp.material.rawAmount, "TESTKUDOS:19.72"); + t.assertAmountEquals( + maxDepositResp.material.instructedAmount, + "TESTKUDOS:19.84", + ); + t.assertAmountEquals( + maxDepositResp.material.effectiveAmount, + "TESTKUDOS:19.84", + ); + t.assertDeepEqual(maxDepositResp.available, maxDepositResp.material); + const exchangeDiagnostics = Object.values(maxDepositResp.exchangeDiagnostics); + t.assertDeepEqual(exchangeDiagnostics.length, 1); + t.assertDeepEqual(exchangeDiagnostics[0].reasons, []); + t.assertDeepEqual(exchangeDiagnostics[0].material, maxDepositResp.material); const depositGroupResult = await walletClient.client.call( WalletApiOperation.CreateDepositGroup, diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -292,6 +292,10 @@ export enum TransactionAmountMode { Raw = "raw", } +/** + * @deprecated Use {@link CheckDepositRequest} for a concrete instructed + * amount, or {@link GetMaxDepositAmountRequest} to query deposit limits. + */ export interface ConvertAmountRequest { amount: AmountString; type: TransactionAmountMode; @@ -319,6 +323,9 @@ export interface GetMaxDepositAmountRequest { /** * Target bank account to deposit into. + * + * When omitted, wire-method eligibility, account restrictions and wire + * fees cannot be reflected in the response. */ depositPaytoUri?: string; @@ -334,7 +341,7 @@ export const codecForGetMaxDepositAmountRequest = .property("currency", codecForString()) .property("depositPaytoUri", codecOptional(codecForString())) .property("restrictScope", codecOptional(codecForScopeInfo())) - .build("GetAmountRequest"); + .build("GetMaxDepositAmountRequest"); export interface GetMaxPeerPushDebitAmountRequest { currency: string; @@ -353,16 +360,76 @@ export const codecForGetMaxPeerPushDebitAmountRequest = .property("restrictScope", codecOptional(codecForScopeInfo())) .build("GetMaxPeerPushDebitRequest"); -export interface GetMaxDepositAmountResponse { +export enum DepositEligibilityReasonType { + DirectDepositDisabled = "direct-deposit-disabled", + ScopeRestricted = "scope-restricted", + WireMethodUnsupported = "wire-method-unsupported", + WireFeeUnavailable = "wire-fee-unavailable", + DepositAccountRestricted = "deposit-account-restricted", +} + +/** Reason why a ready, same-currency exchange cannot serve a deposit. */ +export type DepositEligibilityReason = + | { type: DepositEligibilityReasonType.DirectDepositDisabled } + | { + type: DepositEligibilityReasonType.ScopeRestricted; + scopeInfo: ScopeInfo; + } + | { + type: DepositEligibilityReasonType.WireMethodUnsupported; + wireMethod: string; + } + | { + type: DepositEligibilityReasonType.WireFeeUnavailable; + wireMethod: string; + } + | { + type: DepositEligibilityReasonType.DepositAccountRestricted; + wireMethod: string; + accountRestrictions: Record<string, AccountRestriction[]>; + }; + +/** Maximum amounts and fees for one coherent deposit coin selection. */ +export interface DepositMaximum { + /** Gross target amount passed to CheckDeposit or CreateDepositGroup. */ + instructedAmount: AmountString; + + /** + * Total balance effect on the wallet: instructed amount plus fees paid by + * the customer and the cost of refreshing any change. + */ effectiveAmount: AmountString; - rawAmount: AmountString; /** - * Account restrictions that affect the max deposit amount. + * Amount expected to reach the destination account: instructed amount + * minus fees covered by the counterparty. */ - depositRestrictions?: { - [exchangeBaseUrl: string]: { [paytoUri: string]: AccountRestriction[] }; - }; + rawAmount: AmountString; + + /** Total fees incurred by this deposit selection. */ + fees: DepositGroupFees; +} + +export interface DepositExchangeDiagnostics { + /** Maximum that can be deposited immediately. */ + material: DepositMaximum; + + /** Maximum including expected outputs of pending refresh operations. */ + available: DepositMaximum; + + /** Eligibility failures, in deterministic evaluation order. */ + reasons: DepositEligibilityReason[]; +} + +export interface GetMaxDepositAmountResponse { + /** Maximum that can be deposited immediately. */ + material: DepositMaximum; + + /** Maximum including expected outputs of pending refresh operations. */ + available: DepositMaximum; + + /** Eligibility and maximum amounts for every ready same-currency exchange. */ + exchangeDiagnostics: Record<string, DepositExchangeDiagnostics>; } export interface GetMaxPeerPushDebitAmountResponse { @@ -3485,7 +3552,7 @@ export interface CreateDepositGroupRequest { depositPaytoUri: string; /** - * Amount to deposit (effective amount). + * Instructed amount used for deposit coin selection. */ amount: AmountString; @@ -3523,9 +3590,10 @@ export interface CheckDepositRequest { depositPaytoUri: string; /** - * Amount that should be deposited. + * Instructed amount used for deposit coin selection. * - * Raw amount, fees will be added on top. + * CheckDepositResponse reports the resulting wallet cost and destination + * amount, which can differ because of fees and refresh change. */ amount: AmountString; diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -35,6 +35,7 @@ import { checkExchangeAccepted, emptyTallyForPeerPayment, findMatchingWire, + getMaxDepositAmount, testing_classifyMaximumFailure, testing_getBalanceAvailabilityReason, testing_getMaxDepositAmountForAvailableCoins, @@ -42,6 +43,16 @@ import { testing_makeBalanceSnapshot, testing_selectGreedy, } from "./coinSelection.js"; +import { + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + WalletCoinAvailability, + WalletDenomination, + WalletExchangeDetails, + WalletExchangeEntry, +} from "./db-common.js"; +import { WalletDbTransaction } from "./dbtx.js"; +import { WalletExecutionContext } from "./wallet.js"; const inTheDistantFuture = AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ hours: 1 })), @@ -447,8 +458,20 @@ test("deposit max 35", (t) => { }, }, ); - assert.strictEqual(Amounts.stringifyValue(result.rawAmount), "34.9"); - assert.strictEqual(Amounts.stringifyValue(result.effectiveAmount), "35"); + assert.strictEqual(Amounts.stringifyValue(result.total.rawAmount), "34.9"); + assert.strictEqual( + Amounts.stringifyValue(result.total.instructedAmount), + "35", + ); + assert.strictEqual( + Amounts.stringifyValue(result.total.effectiveAmount), + "35", + ); + assert.deepStrictEqual(result.total.fees, { + coin: "KUDOS:0.1", + wire: "KUDOS:0", + refresh: "KUDOS:0", + }); }); test("deposit max 35 with wirefee", (t) => { @@ -467,8 +490,193 @@ test("deposit max 35 with wirefee", (t) => { }, }, ); - assert.strictEqual(Amounts.stringifyValue(result.rawAmount), "33.9"); - assert.strictEqual(Amounts.stringifyValue(result.effectiveAmount), "35"); + assert.strictEqual(Amounts.stringifyValue(result.total.rawAmount), "33.9"); + assert.strictEqual( + Amounts.stringifyValue(result.total.effectiveAmount), + "35", + ); + assert.deepStrictEqual(result.total.fees, { + coin: "KUDOS:0.1", + wire: "KUDOS:1", + refresh: "KUDOS:0", + }); +}); + +test("deposit max accounts for fees once per participating exchange", () => { + const exchangeTwoCoin = defaultFeeConfig(kudos`5`, 1); + const exchangeThreeCoin = { + ...defaultFeeConfig(kudos`5`, 1), + exchangeBaseUrl: "3", + }; + const result = testing_getMaxDepositAmountForAvailableCoins( + { currency: "KUDOS" }, + { + coinAvailability: [exchangeTwoCoin, exchangeThreeCoin], + currentWireFeePerExchange: { + "2": kudos`0.5`, + "3": kudos`0.25`, + }, + }, + ); + + assert.deepStrictEqual(result.total, { + instructedAmount: "KUDOS:10", + effectiveAmount: "KUDOS:10", + rawAmount: "KUDOS:9.23", + fees: { + coin: "KUDOS:0.02", + wire: "KUDOS:0.75", + refresh: "KUDOS:0", + }, + }); + assert.strictEqual(result.byExchange["2"].rawAmount, "KUDOS:4.49"); + assert.strictEqual(result.byExchange["3"].rawAmount, "KUDOS:4.74"); +}); + +test("deposit max does not charge a wire fee for a zero-count exchange", () => { + const emptyExchangeCoin = { + ...defaultFeeConfig(kudos`10`, 0), + exchangeBaseUrl: "3", + }; + const result = testing_getMaxDepositAmountForAvailableCoins( + { currency: "KUDOS" }, + { + coinAvailability: [defaultFeeConfig(kudos`5`, 1), emptyExchangeCoin], + currentWireFeePerExchange: { + "2": kudos`1`, + "3": kudos`100`, + }, + }, + ); + + assert.strictEqual(result.total.rawAmount, "KUDOS:3.99"); + assert.strictEqual(result.total.fees.wire, "KUDOS:1"); + assert.strictEqual(result.byExchange["3"], undefined); +}); + +test("deposit max is zero when settlement fees exceed an exchange balance", () => { + const result = testing_getMaxDepositAmountForAvailableCoins( + { currency: "KUDOS" }, + { + coinAvailability: [defaultFeeConfig(kudos`1`, 1)], + currentWireFeePerExchange: { "2": kudos`2` }, + }, + ); + + assert.deepStrictEqual(result.total, { + instructedAmount: "KUDOS:0", + effectiveAmount: "KUDOS:0", + rawAmount: "KUDOS:0", + fees: { + coin: "KUDOS:0", + wire: "KUDOS:0", + refresh: "KUDOS:0", + }, + }); + assert.deepStrictEqual(result.byExchange["2"], result.total); +}); + +test("deposit available max includes pending-only refresh outputs", async () => { + const exchangeBaseUrl = "https://exchange.example/"; + const masterPublicKey = "master-pub"; + const exchange = { + baseUrl: exchangeBaseUrl, + detailsPointer: { + currency: "KUDOS", + masterPublicKey, + updateClock: 0, + }, + entryStatus: ExchangeEntryDbRecordStatus.Used, + updateStatus: ExchangeEntryDbUpdateStatus.Ready, + } as WalletExchangeEntry; + const details = { + exchangeBaseUrl, + currency: "KUDOS", + masterPublicKey, + auditors: [], + globalFees: [], + wireInfo: { accounts: [], feesForType: {} }, + } as unknown as WalletExchangeDetails; + const availability = { + currency: "KUDOS", + value: "KUDOS:5", + denomPubHash: "pending-denom", + exchangeBaseUrl, + exchangeMasterPub: masterPublicKey, + maxAge: 0, + freshCoinCount: 0, + visibleCoinCount: 0, + pendingRefreshOutputCount: 3, + } as WalletCoinAvailability; + const denomTemplate = defaultFeeConfig(kudos`5`, 0); + const denomination = { + currency: "KUDOS", + value: "KUDOS:5", + denomPub: denomTemplate.denomPub, + denomPubHash: availability.denomPubHash, + fees: { + feeDeposit: "KUDOS:0.01", + feeRefresh: "KUDOS:0.01", + feeRefund: "KUDOS:0.01", + feeWithdraw: "KUDOS:0.01", + }, + stampStart: 0, + stampExpireWithdraw: Number.MAX_SAFE_INTEGER, + stampExpireDeposit: Number.MAX_SAFE_INTEGER, + stampExpireLegal: Number.MAX_SAFE_INTEGER, + masterSig: "DUMMY", + verificationStatus: 0, + isOffered: true, + isRevoked: false, + exchangeBaseUrl, + exchangeMasterPub: masterPublicKey, + } as unknown as WalletDenomination; + const tx = { + async getExchanges() { + return [exchange]; + }, + async getExchange() { + return exchange; + }, + async getExchangeDetailsByPointer() { + return details; + }, + async getCoinAvailabilityByExchangeAndAgeRange() { + return []; + }, + async getCoinAvailabilityByExchange() { + return [availability]; + }, + async getDenomination() { + return denomination; + }, + } as unknown as WalletDbTransaction; + const wex = { + async runWalletDbTx<T>( + callback: (innerTx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return callback(tx); + }, + } as WalletExecutionContext; + + const result = await getMaxDepositAmount(wex, { currency: "KUDOS" }); + + assert.strictEqual(result.material.instructedAmount, "KUDOS:0"); + assert.deepStrictEqual(result.available, { + instructedAmount: "KUDOS:15", + effectiveAmount: "KUDOS:15", + rawAmount: "KUDOS:14.97", + fees: { + coin: "KUDOS:0.03", + wire: "KUDOS:0", + refresh: "KUDOS:0", + }, + }); + assert.deepStrictEqual(result.exchangeDiagnostics[exchangeBaseUrl], { + material: result.material, + available: result.available, + reasons: [], + }); }); test("deposit max repeated denom", (t) => { @@ -488,8 +696,8 @@ test("deposit max repeated denom", (t) => { }, }, ); - assert.strictEqual(Amounts.stringifyValue(result.rawAmount), "8.97"); - assert.strictEqual(Amounts.stringifyValue(result.effectiveAmount), "9"); + assert.strictEqual(Amounts.stringifyValue(result.total.rawAmount), "8.97"); + assert.strictEqual(Amounts.stringifyValue(result.total.effectiveAmount), "9"); }); test("demo: deposit max after withdraw raw 25", (t) => { @@ -511,8 +719,11 @@ test("demo: deposit max after withdraw raw 25", (t) => { }, }, ); - assert.strictEqual(Amounts.stringifyValue(result.effectiveAmount), "24.8"); - assert.strictEqual(Amounts.stringifyValue(result.rawAmount), "24.67"); + assert.strictEqual( + Amounts.stringifyValue(result.total.effectiveAmount), + "24.8", + ); + assert.strictEqual(Amounts.stringifyValue(result.total.rawAmount), "24.67"); // 8 x 0.1 // 2 x 0.2 @@ -544,8 +755,11 @@ test("demo: deposit max after withdraw raw 13", (t) => { }, }, ); - assert.strictEqual(Amounts.stringifyValue(result.effectiveAmount), "12.8"); - assert.strictEqual(Amounts.stringifyValue(result.rawAmount), "12.69"); + assert.strictEqual( + Amounts.stringifyValue(result.total.effectiveAmount), + "12.8", + ); + assert.strictEqual(Amounts.stringifyValue(result.total.rawAmount), "12.69"); // 8 x 0.1 // 1 x 0.2 @@ -988,10 +1202,13 @@ test("deposit max ignores coins that cost more to deposit than they are worth", ]), ); - assert.strictEqual(Amounts.stringifyValue(withoutDust.rawAmount), "4.99"); assert.strictEqual( - Amounts.stringifyValue(withDust.rawAmount), - Amounts.stringifyValue(withoutDust.rawAmount), + Amounts.stringifyValue(withoutDust.total.rawAmount), + "4.99", + ); + assert.strictEqual( + Amounts.stringifyValue(withDust.total.rawAmount), + Amounts.stringifyValue(withoutDust.total.rawAmount), ); }); @@ -1008,8 +1225,8 @@ test("deposit max is zero when only unspendable coins are available", (t) => { }, ); - assert.strictEqual(Amounts.stringifyValue(result.rawAmount), "0"); - assert.strictEqual(Amounts.stringifyValue(result.effectiveAmount), "0"); + assert.strictEqual(Amounts.stringifyValue(result.total.rawAmount), "0"); + assert.strictEqual(Amounts.stringifyValue(result.total.effectiveAmount), "0"); }); test("peer push debit max ignores coins that cost more to deposit than they are worth", (t) => { diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -41,6 +41,10 @@ import { CoinSelectionFailureReasonType, CoinStatus, DenominationInfo, + DepositEligibilityReason, + DepositEligibilityReasonType, + DepositExchangeDiagnostics, + DepositMaximum, Exchange, ExchangeGlobalFees, ForcedCoinSel, @@ -1881,7 +1885,6 @@ interface SelectPayCandidatesRequest { export interface PayCoinCandidates { coinAvailability: AvailableCoinsOfDenom[]; currentWireFeePerExchange: Record<string, AmountJson>; - depositRestrictions?: Record<string, Record<string, AccountRestriction[]>>; } async function selectPayCandidates( @@ -1896,10 +1899,6 @@ async function selectPayCandidates( const denoms: AvailableCoinsOfDenom[] = []; const exchanges = await tx.getExchanges(); const wfPerExchange: Record<string, AmountJson> = {}; - const depositRestrictions: Record< - string, - Record<string, AccountRestriction[]> - > = {}; for (const exchange of exchanges) { const exchangeDetails = await getExchangeDetailsInTx(tx, exchange.baseUrl); // Exchange has same currency @@ -1921,7 +1920,6 @@ async function selectPayCandidates( wfPerExchange[exchange.baseUrl] = wireMatch.wireFee; break; case "account-restricted": - depositRestrictions[exchange.baseUrl] = wireMatch.accountRestrictions; continue; case "wire-method-unsupported": case "wire-fee-unavailable": @@ -1962,11 +1960,22 @@ async function selectPayCandidates( ageLower = req.requiredMinimumAge; } - const myExchangeCoins = await tx.getCoinAvailabilityByExchangeAndAgeRange( - exchangeDetails.exchangeBaseUrl, - ageLower, - ageUpper, - ); + const myExchangeCoins = req.includePendingCoins + ? ( + await tx.getCoinAvailabilityByExchange( + exchangeDetails.exchangeBaseUrl, + ) + ).filter( + (x) => + x.maxAge >= ageLower && + x.maxAge <= ageUpper && + (x.freshCoinCount > 0 || (x.pendingRefreshOutputCount ?? 0) > 0), + ) + : await tx.getCoinAvailabilityByExchangeAndAgeRange( + exchangeDetails.exchangeBaseUrl, + ageLower, + ageUpper, + ); if (logger.shouldLogTrace()) { logger.trace( @@ -2035,7 +2044,6 @@ async function selectPayCandidates( return { coinAvailability: denoms, currentWireFeePerExchange: wfPerExchange, - depositRestrictions: depositRestrictions, }; } @@ -2310,16 +2318,51 @@ export async function selectPeerCoins( }); } +interface DepositMaximumComputation { + total: DepositMaximum; + byExchange: Record<string, DepositMaximum>; +} + +function zeroDepositMaximum(currency: string): DepositMaximum { + const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); + return { + instructedAmount: zero, + effectiveAmount: zero, + rawAmount: zero, + fees: { + coin: zero, + wire: zero, + refresh: zero, + }, + }; +} + +/** + * Calculate coherent full-drain deposit plans from the available candidates. + * + * A full drain produces no refresh change. Under the direct-deposit fee + * policy, the instructed amount is also the fee allowance. Thus all fees + * are deducted from the destination amount when they fit within the selected + * coin value. If they do not fit, no instructed amount using that exchange + * can make the selection executable. + */ function getMaxDepositAmountForAvailableCoins( req: GetMaxDepositAmountRequest, candidateRes: PayCoinCandidates, -): GetMaxDepositAmountResponse { - const wireFeeCoveredForExchange = new Set<string>(); +): DepositMaximumComputation { + interface ExchangeTotals { + effective: AmountJson; + coinFees: AmountJson; + } - let amountEffective = Amounts.zeroOfCurrency(req.currency); - let fees = Amounts.zeroOfCurrency(req.currency); + const zero = Amounts.zeroOfCurrency(req.currency); + const totalsByExchange = new Map<string, ExchangeTotals>(); for (const cc of candidateRes.coinAvailability) { + if (cc.numAvailable <= 0) { + continue; + } + // Don't count a coin if depositing it is more expensive than the amount // it would give the merchant. This is the same rule selectGreedy uses, // so counting it here would promise an amount that can't be selected. @@ -2327,36 +2370,146 @@ function getMaxDepositAmountForAvailableCoins( continue; } - if (!wireFeeCoveredForExchange.has(cc.exchangeBaseUrl)) { - const wireFee = - candidateRes.currentWireFeePerExchange[cc.exchangeBaseUrl]; - // Wire fee can be null if max deposit amount is computed - // without restricting the wire method. - if (wireFee != null) { - fees = Amounts.add(fees, wireFee).amount; - } - wireFeeCoveredForExchange.add(cc.exchangeBaseUrl); - } - - amountEffective = Amounts.add( - amountEffective, + const exchangeTotals = totalsByExchange.get(cc.exchangeBaseUrl) ?? { + effective: zero, + coinFees: zero, + }; + exchangeTotals.effective = Amounts.add( + exchangeTotals.effective, Amounts.mult(cc.value, cc.numAvailable).amount, ).amount; - - fees = Amounts.add( - fees, + exchangeTotals.coinFees = Amounts.add( + exchangeTotals.coinFees, Amounts.mult(cc.feeDeposit, cc.numAvailable).amount, ).amount; + totalsByExchange.set(cc.exchangeBaseUrl, exchangeTotals); } - return { - effectiveAmount: Amounts.stringify(amountEffective), - rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount), - ...(candidateRes.depositRestrictions != null && - Object.keys(candidateRes.depositRestrictions).length > 0 - ? { depositRestrictions: candidateRes.depositRestrictions } - : undefined), - }; + const byExchange: Record<string, DepositMaximum> = {}; + let total = zeroDepositMaximum(req.currency); + for (const [exchangeBaseUrl, exchangeTotals] of totalsByExchange) { + const wireFees = + candidateRes.currentWireFeePerExchange[exchangeBaseUrl] ?? zero; + const settlementFees = Amounts.add( + exchangeTotals.coinFees, + wireFees, + ).amount; + if (Amounts.cmp(settlementFees, exchangeTotals.effective) > 0) { + byExchange[exchangeBaseUrl] = zeroDepositMaximum(req.currency); + continue; + } + + const maximum: DepositMaximum = { + instructedAmount: Amounts.stringify(exchangeTotals.effective), + effectiveAmount: Amounts.stringify(exchangeTotals.effective), + rawAmount: Amounts.stringify( + Amounts.sub(exchangeTotals.effective, settlementFees).amount, + ), + fees: { + coin: Amounts.stringify(exchangeTotals.coinFees), + wire: Amounts.stringify(wireFees), + refresh: Amounts.stringify(zero), + }, + }; + byExchange[exchangeBaseUrl] = maximum; + total = { + instructedAmount: Amounts.stringify( + Amounts.add(total.instructedAmount, maximum.instructedAmount).amount, + ), + effectiveAmount: Amounts.stringify( + Amounts.add(total.effectiveAmount, maximum.effectiveAmount).amount, + ), + rawAmount: Amounts.stringify( + Amounts.add(total.rawAmount, maximum.rawAmount).amount, + ), + fees: { + coin: Amounts.stringify( + Amounts.add(total.fees.coin, maximum.fees.coin).amount, + ), + wire: Amounts.stringify( + Amounts.add(total.fees.wire, maximum.fees.wire).amount, + ), + refresh: Amounts.stringify( + Amounts.add(total.fees.refresh, maximum.fees.refresh).amount, + ), + }, + }; + } + + return { total, byExchange }; +} + +interface DepositExchangeEligibility { + masterPub: string; + reasons: DepositEligibilityReason[]; +} + +async function getDepositExchangeEligibilityInTx( + tx: WalletDbTransaction, + req: GetMaxDepositAmountRequest, + wireMethod: string | undefined, +): Promise<Record<string, DepositExchangeEligibility>> { + const result: Record<string, DepositExchangeEligibility> = {}; + for (const exchange of await tx.getExchanges()) { + const details = await getExchangeDetailsInTx(tx, exchange.baseUrl); + if (!details || details.currency !== req.currency) { + continue; + } + + const reasons: DepositEligibilityReason[] = []; + if (exchange.directDepositDisabled) { + reasons.push({ + type: DepositEligibilityReasonType.DirectDepositDisabled, + }); + } + if ( + req.restrictScope && + !(await checkExchangeInScopeTx(tx, exchange.baseUrl, req.restrictScope)) + ) { + reasons.push({ + type: DepositEligibilityReasonType.ScopeRestricted, + scopeInfo: req.restrictScope, + }); + } + if (wireMethod) { + const wireMatch = findMatchingWire( + wireMethod, + req.depositPaytoUri, + details, + ); + switch (wireMatch.status) { + case "match": + break; + case "account-restricted": + reasons.push({ + type: DepositEligibilityReasonType.DepositAccountRestricted, + wireMethod, + accountRestrictions: wireMatch.accountRestrictions, + }); + break; + case "wire-method-unsupported": + reasons.push({ + type: DepositEligibilityReasonType.WireMethodUnsupported, + wireMethod, + }); + break; + case "wire-fee-unavailable": + reasons.push({ + type: DepositEligibilityReasonType.WireFeeUnavailable, + wireMethod, + }); + break; + default: + assertUnreachable(wireMatch); + } + } + + result[exchange.baseUrl] = { + masterPub: details.masterPublicKey, + reasons, + }; + } + return result; } export async function getExchangesForDepositInTx( @@ -2429,36 +2582,69 @@ export async function getMaxDepositAmount( return await wex.runWalletDbTx( async (tx): Promise<GetMaxDepositAmountResponse> => { let restrictWireMethod: string | undefined = undefined; - const exchangeInfos: Exchange[] = await getExchangesForDepositInTx( - wex, - tx, - { - currency: req.currency, - restrictScope: req.restrictScope, - }, - ); if (req.depositPaytoUri) { const p = Paytos.parseOrThrow(req.depositPaytoUri); restrictWireMethod = p.targetType; } - const candidateRes = await selectPayCandidates(wex, tx, { + + const eligibility = await getDepositExchangeEligibilityInTx( + tx, + req, + restrictWireMethod, + ); + const eligibleExchanges = Object.entries(eligibility) + .filter(([, x]) => x.reasons.length === 0) + .map(([exchangeBaseUrl, x]) => ({ + exchangeBaseUrl, + exchangePub: x.masterPub, + })); + const candidateRequest = { currency: req.currency, restrictExchanges: { auditors: [], - exchanges: exchangeInfos.map((x) => { - return { - exchangeBaseUrl: x.url, - exchangePub: x.master_pub, - }; - }), + exchanges: eligibleExchanges, }, restrictWireMethod, restrictScope: req.restrictScope, depositPaytoUri: req.depositPaytoUri, requiredMinimumAge: undefined, + }; + const materialCandidates = await selectPayCandidates(wex, tx, { + ...candidateRequest, + includePendingCoins: false, + }); + const availableCandidates = await selectPayCandidates(wex, tx, { + ...candidateRequest, includePendingCoins: true, }); - return getMaxDepositAmountForAvailableCoins(req, candidateRes); + const material = getMaxDepositAmountForAvailableCoins( + req, + materialCandidates, + ); + const available = getMaxDepositAmountForAvailableCoins( + req, + availableCandidates, + ); + const exchangeDiagnostics: Record<string, DepositExchangeDiagnostics> = + {}; + for (const [exchangeBaseUrl, exchangeEligibility] of Object.entries( + eligibility, + )) { + exchangeDiagnostics[exchangeBaseUrl] = { + material: + material.byExchange[exchangeBaseUrl] ?? + zeroDepositMaximum(req.currency), + available: + available.byExchange[exchangeBaseUrl] ?? + zeroDepositMaximum(req.currency), + reasons: exchangeEligibility.reasons, + }; + } + return { + material: material.total, + available: available.total, + exchangeDiagnostics, + }; }, ); } @@ -2505,7 +2691,7 @@ export async function getMaxPeerPushDebitAmount( return await wex.runWalletDbTx( async (tx): Promise<GetMaxPeerPushDebitAmountResponse> => { - let result: GetMaxDepositAmountResponse | undefined = undefined; + let result: GetMaxPeerPushDebitAmountResponse | undefined = undefined; const currency = req.currency; const exchanges = await tx.getExchanges(); for (const exch of exchanges) { diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -318,6 +318,10 @@ export enum WalletApiOperation { CheckDeposit = "checkDeposit", CreateDepositGroup = "createDepositGroup", + /** + * @deprecated Use CheckDeposit for a concrete instructed amount, or + * GetMaxDepositAmount to query deposit limits. + */ ConvertDepositAmount = "convertDepositAmount", GetMaxDepositAmount = "getMaxDepositAmount", GetDepositWireTypes = "getDepositWireTypes", @@ -671,6 +675,10 @@ export type GetBalancesDetailOp = { response: PaymentBalanceDetails; }; +/** + * @deprecated Use {@link CheckDepositOp} for a concrete instructed amount, + * or {@link GetMaxDepositAmountOp} to query deposit limits. + */ export type ConvertDepositAmountOp = { op: WalletApiOperation.ConvertDepositAmount; request: ConvertAmountRequest;