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:
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 */