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