taler-typescript-core

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

commit d850113896c6dd5046ed2a1476c15257b6fb2123
parent c3565764ed4e45c9dd963dd7f28081b4cfe0b9bd
Author: Florian Dold <florian@dold.me>
Date:   Tue,  2 Dec 2025 14:29:01 +0100

wallet-core: support disable_direct_deposit flag from exchange

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-exchange-features.ts | 15++++++++-------
Mpackages/taler-util/src/types-taler-exchange.ts | 10++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 8++++++++
Mpackages/taler-wallet-core/src/balance.ts | 29++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/coinSelection.ts | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/db.ts | 6++++++
Mpackages/taler-wallet-core/src/deposits.ts | 72++++++++++++++++++++----------------------------------------------------
Mpackages/taler-wallet-core/src/dev-experiments.ts | 5+++++
Mpackages/taler-wallet-core/src/exchanges.ts | 4++++
Mpackages/taler-wallet-core/src/wallet.ts | 2++
10 files changed, 146 insertions(+), 67 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-features.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-features.ts @@ -106,19 +106,17 @@ export async function runWalletExchangeFeaturesTest(t: GlobalTestState) { "shopping_url", "https://taler-shopping.example.com", ); + f.setString("exchange", "DISABLE_DIRECT_DEPOSIT", "yes"); }); await exchange.start({ skipGlobalFees: true, }); - const { walletClient, walletService } = await createWalletDaemonWithClient( - t, - { - name: "wallet", - persistent: true, - }, - ); + const { walletClient } = await createWalletDaemonWithClient(t, { + name: "wallet", + persistent: true, + }); console.log("setup done!"); @@ -133,6 +131,9 @@ export async function runWalletExchangeFeaturesTest(t: GlobalTestState) { const bal = await walletClient.call(WalletApiOperation.GetBalances, {}); console.log(j2s(bal)); + + t.assertDeepEqual(bal.balances[0].disableDirectDeposits, true); + t.assertDeepEqual(bal.balances[0].disablePeerPayments, true); } runWalletExchangeFeaturesTest.suites = ["wallet"]; diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -570,6 +570,15 @@ export interface ExchangeKeysResponse { // Optional, not present if not known or not configured. // @since protocol **v21**. tiny_amount?: Amount; + + // Set to TRUE if wallets should disable the direct deposit feature + // and deposits should only go via Taler merchant APIs. + // Mainly used for regional currency and event currency deployments + // where wallets are not eligible to deposit back into originating + // bank accounts and, because KYC is not enabled, wallets are thus + // likely to send money to nirvana instead of where users want it. + // @since in protocol **v30**. + disable_direct_deposit?: boolean; } export interface ExchangeMeltRequest { @@ -2581,6 +2590,7 @@ export const codecForExchangeKeysResponse = (): Codec<ExchangeKeysResponse> => .property("kyc_enabled", codecOptional(codecForBoolean())) .property("shopping_url", codecOptional(codecForString())) .property("tiny_amount", codecOptional(codecForAmountString())) + .property("disable_direct_deposit", codecOptional(codecForBoolean())) .property("bank_compliance_language", codecOptional(codecForString())) .property("open_banking_gateway", codecOptional(codecForURLString())) .deprecatedProperty("rewards_allowed") diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -371,6 +371,11 @@ export interface WalletBalance { * Are p2p payments disabled for this scope? */ disablePeerPayments?: boolean; + + /** + * Are wallet deposits enabled for this scope? + */ + disableDirectDeposits?: boolean; } export const codecForScopeInfoGlobal = (): Codec<ScopeInfoGlobal> => @@ -1757,6 +1762,8 @@ export interface ExchangeListItem { */ peerPaymentsDisabled: boolean; + directDepositsDisabled: boolean; + /** Set to true if this exchange doesn't charge any fees. */ noFees: boolean; @@ -1899,6 +1906,7 @@ export const codecForExchangeListItem = (): Codec<ExchangeListItem> => .property("noFees", codecForBoolean()) .property("peerPaymentsDisabled", codecForBoolean()) .property("bankComplianceLanguage", codecOptional(codecForString())) + .property("directDepositsDisabled", codecForBoolean()) .build("ExchangeListItem"); export const codecForExchangesListResponse = (): Codec<ExchangesListResponse> => diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -107,6 +107,7 @@ interface WalletBalance { flagIncomingConfirmation: boolean; flagOutgoingKyc: boolean; disablePeerPayments: boolean; + disableDirectDeposits: boolean; shoppingUrls: Set<string>; } @@ -207,6 +208,7 @@ class BalancesStore { flagIncomingKyc: false, flagOutgoingKyc: false, disablePeerPayments: false, + disableDirectDeposits: false, shoppingUrls: new Set(), }; } @@ -225,6 +227,14 @@ class BalancesStore { b.disablePeerPayments = true; } + async setDirectDepositsDisabled( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.disableDirectDeposits = true; + } + async addShoppingUrl( currency: string, exchangeBaseUrl: string, @@ -339,6 +349,12 @@ class BalancesStore { } else { shoppingUrls = [...v.shoppingUrls]; } + let disableDirectDeposits: boolean; + if (this.wex.ws.devExperimentState.flagDisableDirectDeposits) { + disableDirectDeposits = true; + } else { + disableDirectDeposits = v.disablePeerPayments; + } let disablePeerPayments: boolean; if (this.wex.ws.devExperimentState.flagDisablePeerPayments) { disablePeerPayments = true; @@ -353,6 +369,7 @@ class BalancesStore { flags, shoppingUrls, disablePeerPayments, + disableDirectDeposits, }); }); return balancesResponse; @@ -406,6 +423,12 @@ export async function getBalancesInsideTransaction( if (ex.peerPaymentsDisabled) { await balanceStore.setPeerPaymentsDisabled(det.currency, ex.baseUrl); } + if (ex.directDepositDisabled) { + await balanceStore.setDirectDepositsDisabled( + det.currency, + ex.baseUrl, + ); + } if (det.shoppingUrl) { await balanceStore.addShoppingUrl( det.currency, @@ -752,9 +775,9 @@ export async function getBalanceOfScope( return getBalancesInsideTransaction(wex, tx); }); - wbal.balances.find((w) =>{ - w.scopeInfo - }) + wbal.balances.find((w) => { + w.scopeInfo; + }); logger.trace("finished computing wallet balance"); return wbal; diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -37,6 +37,7 @@ import { checkLogicInvariant, CoinStatus, DenominationInfo, + Exchange, ExchangeGlobalFees, ForcedCoinSel, GetMaxDepositAmountRequest, @@ -972,10 +973,7 @@ async function selectPayCandidates( Record<string, AccountRestriction[]> > = {}; for (const exchange of exchanges) { - const exchangeDetails = await getExchangeDetailsInTx( - tx, - exchange.baseUrl, - ); + const exchangeDetails = await getExchangeDetailsInTx(tx, exchange.baseUrl); // Exchange has same currency if (exchangeDetails?.currency !== req.currency) { logger.shouldLogTrace() && @@ -1506,6 +1504,48 @@ function getMaxDepositAmountForAvailableCoins( }; } +export async function getExchangesForDeposit( + wex: WalletExecutionContext, + req: { restrictScope?: ScopeInfo; currency: string }, +): Promise<Exchange[]> { + logger.trace(`getting exchanges for deposit ${j2s(req)}`); + const exchangeInfos: Exchange[] = []; + await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + const allExchanges = await tx.exchanges.iter().toArray(); + for (const e of allExchanges) { + const details = await getExchangeDetailsInTx(tx, e.baseUrl); + if (!details) { + logger.trace(`skipping ${e.baseUrl}, no details`); + continue; + } + if (req.currency !== details.currency) { + logger.trace(`skipping ${e.baseUrl}, currency mismatch`); + continue; + } + if (e.directDepositDisabled) { + logger.trace(`skipping ${e.baseUrl}, wallet deposits disabled`); + continue; + } + if (req.restrictScope) { + const inScope = await checkExchangeInScopeTx( + tx, + e.baseUrl, + req.restrictScope, + ); + if (!inScope) { + continue; + } + } + exchangeInfos.push({ + master_pub: details.masterPublicKey, + priority: 1, + url: e.baseUrl, + }); + } + }); + return exchangeInfos; +} + /** * Only used for unit testing getMaxDepositAmountForAvailableCoins. */ @@ -1530,6 +1570,10 @@ export async function getMaxDepositAmount( }, async (tx): Promise<GetMaxDepositAmountResponse> => { let restrictWireMethod: string | undefined = undefined; + const exchangeInfos: Exchange[] = await getExchangesForDeposit(wex, { + currency: req.currency, + restrictScope: req.restrictScope, + }); if (req.depositPaytoUri) { const p = parsePaytoUri(req.depositPaytoUri); if (!p) { @@ -1539,7 +1583,15 @@ export async function getMaxDepositAmount( } const candidateRes = await selectPayCandidates(wex, tx, { currency: req.currency, - restrictExchanges: undefined, + restrictExchanges: { + auditors: [], + exchanges: exchangeInfos.map((x) => { + return { + exchangeBaseUrl: x.url, + exchangePub: x.master_pub, + }; + }), + }, restrictWireMethod, restrictScope: req.restrictScope, depositPaytoUri: req.depositPaytoUri, diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -795,6 +795,12 @@ export interface ExchangeEntryRecord { peerPaymentsDisabled?: boolean; /** + * Are direct deposits using this exchange disabled? + * Defaults to false. + */ + directDepositDisabled?: boolean; + + /** * Defaults to false. */ noFees?: boolean; diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -35,6 +35,7 @@ import { CreateDepositGroupResponse, DepositGroupFees, DepositTransactionTrackingState, + DownloadedContractData, Duration, Exchange, ExchangeBatchDepositRequest, @@ -47,7 +48,6 @@ import { MerchantContractVersion, NotificationType, RefreshReason, - ScopeInfo, SelectedProspectiveCoin, TalerError, TalerErrorCode, @@ -63,7 +63,6 @@ import { TransactionState, TransactionType, URL, - DownloadedContractData, WalletNotification, assertUnreachable, canonicalJson, @@ -87,7 +86,11 @@ import { readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; -import { selectPayCoins, selectPayCoinsInTx } from "./coinSelection.js"; +import { + getExchangesForDeposit, + selectPayCoins, + selectPayCoinsInTx, +} from "./coinSelection.js"; import { PendingTaskType, TaskIdStr, @@ -118,7 +121,6 @@ import { } from "./db.js"; import { ReadyExchangeSummary, - checkExchangeInScopeTx, fetchFreshExchange, getExchangeDetailsInTx, getExchangeWireFee, @@ -2025,44 +2027,6 @@ export async function checkDepositGroup( ); } -async function getExchangesForDeposit( - wex: WalletExecutionContext, - req: { restrictScope?: ScopeInfo; currency: string }, -): Promise<Exchange[]> { - logger.trace(`getting exchanges for deposit ${j2s(req)}`); - const exchangeInfos: Exchange[] = []; - await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { - const allExchanges = await tx.exchanges.iter().toArray(); - for (const e of allExchanges) { - const details = await getExchangeDetailsInTx(tx, e.baseUrl); - if (!details) { - logger.trace(`skipping ${e.baseUrl}, no details`); - continue; - } - if (req.currency !== details.currency) { - logger.trace(`skipping ${e.baseUrl}, currency mismatch`); - continue; - } - if (req.restrictScope) { - const inScope = await checkExchangeInScopeTx( - tx, - e.baseUrl, - req.restrictScope, - ); - if (!inScope) { - continue; - } - } - exchangeInfos.push({ - master_pub: details.masterPublicKey, - priority: 1, - url: e.baseUrl, - }); - } - }); - return exchangeInfos; -} - /** * Check if creating a deposit group is possible and calculate * the associated fees. @@ -2083,6 +2047,10 @@ export async function internalCheckDepositGroup( restrictScope: req.restrictScope, }); + if (exchangeInfos.length == 0) { + throw Error("no exchanges possible for deposit"); + } + const depositFeeLimit = amount; const payCoinSel = await selectPayCoins(wex, { @@ -2182,6 +2150,10 @@ export async function createDepositGroup( restrictScope: req.restrictScope, }); + if (exchangeInfos.length == 0) { + throw Error("no exchanges possible for deposit"); + } + const now = AbsoluteTime.now(); const wireDeadline = AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })), @@ -2303,13 +2275,15 @@ export async function createDepositGroup( contractTerms: contractTerms, contractTermsRaw: contractTerms, contractTermsHash, - } + }; if ( contractData.contractTerms.version !== undefined && contractData.contractTerms.version !== MerchantContractVersion.V0 ) { - throw Error(`unsupported contract version ${contractData.contractTerms.version}`); + throw Error( + `unsupported contract version ${contractData.contractTerms.version}`, + ); } const totalDepositCost = await getTotalPaymentCost(wex, currency, coins); @@ -2483,10 +2457,7 @@ async function getCounterpartyEffectiveDepositAmount( } for (const exchangeUrl of exchangeSet.values()) { - const exchangeDetails = await getExchangeDetailsInTx( - tx, - exchangeUrl, - ); + const exchangeDetails = await getExchangeDetailsInTx(tx, exchangeUrl); if (!exchangeDetails) { continue; } @@ -2551,10 +2522,7 @@ async function getTotalFeesForDepositAmount( } for (const exchangeUrl of exchangeSet.values()) { - const exchangeDetails = await getExchangeDetailsInTx( - tx, - exchangeUrl, - ); + const exchangeDetails = await getExchangeDetailsInTx(tx, exchangeUrl); if (!exchangeDetails) { continue; } diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts @@ -305,6 +305,11 @@ export async function applyDevExperiment( wex.ws.devExperimentState.flagDisablePeerPayments = getValFlag(parsedUri); return; } + case "flag-disable-direct-deposits": { + wex.ws.devExperimentState.flagDisableDirectDeposits = + getValFlag(parsedUri); + return; + } case "fake-shopping-url": { const url = parsedUri.query?.get("url") ?? undefined; wex.ws.devExperimentState.fakeShoppingUrl = url; diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -511,6 +511,7 @@ async function makeExchangeListItem( masterPub: exchangeDetails?.masterPublicKey, noFees, peerPaymentsDisabled: r.peerPaymentsDisabled ?? false, + directDepositsDisabled: r.directDepositDisabled ?? false, currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN", exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), @@ -923,6 +924,7 @@ export interface ExchangeKeysDownloadSuccessResult { hardLimits: AccountLimit[] | undefined; zeroLimits: ZeroLimitedOperation[] | undefined; bankComplianceLanguage: string | undefined; + directDepositDisabled: boolean | undefined; shoppingUrl: string | undefined; } @@ -1117,6 +1119,7 @@ async function downloadExchangeKeysInfo( bankComplianceLanguage: exchangeKeysResponseUnchecked.bank_compliance_language, shoppingUrl: exchangeKeysResponseUnchecked.shopping_url, + directDepositDisabled: exchangeKeysResponseUnchecked.disable_direct_deposit, }; return { type: "ok", @@ -2002,6 +2005,7 @@ export async function updateExchangeFromUrlHandler( }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; + r.directDepositDisabled = keysInfo.directDepositDisabled; switch (tosMeta.type) { case "not-found": r.tosCurrentEtag = undefined; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -2868,6 +2868,8 @@ export interface DevExperimentState { flagDisablePeerPayments?: boolean; + flagDisableDirectDeposits?: boolean; + blockPayResponse?: boolean; /** Migration test for confirmPay */