commit 661cdaf81473a75abf4bd4f4e9a9ec0e566dd5b4
parent 54cd1fe607714e924cc621eefdc719c2a8f80137
Author: Florian Dold <dold@taler.net>
Date: Wed, 12 Aug 2026 12:41:34 +0200
wallet-core: add withdrawal exchange candidates
Diffstat:
11 files changed, 507 insertions(+), 41 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet.test.ts b/packages/taler-util/src/types-taler-wallet.test.ts
@@ -19,9 +19,21 @@ import assert from "node:assert";
import { test } from "node:test";
import {
codecForGetDefaultExchangesRequest,
+ codecForListWithdrawalExchangeCandidatesRequest,
codecForTestingWaitTransactionRequest,
matchTransactionState,
} from "./types-taler-wallet.js";
+
+test("default and candidate exchange requests share presetOnly", () => {
+ for (const codec of [
+ codecForGetDefaultExchangesRequest(),
+ codecForListWithdrawalExchangeCandidatesRequest(),
+ ]) {
+ assert.strictEqual(codec.decode({ presetOnly: true }).presetOnly, true);
+ assert.strictEqual(codec.decode({}).presetOnly, undefined);
+ assert.throws(() => codec.decode({ presetOnly: "yes" }));
+ }
+});
import {
isFinalTransactionState,
TransactionMajorState,
@@ -33,13 +45,6 @@ const pendingWithdraw = {
minor: TransactionMinorState.Withdraw,
};
-test("default exchange request codec accepts presetOnly", () => {
- const codec = codecForGetDefaultExchangesRequest();
- assert.strictEqual(codec.decode({ presetOnly: true }).presetOnly, true);
- assert.strictEqual(codec.decode({}).presetOnly, undefined);
- assert.throws(() => codec.decode({ presetOnly: "yes" }));
-});
-
test("state pattern without working flag ignores it", (t) => {
assert.strictEqual(
matchTransactionState(
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -1731,6 +1731,14 @@ export enum ExchangeEntryStatus {
Used = "used",
}
+/** How an exchange entry became known to the wallet. */
+export enum ExchangeEntrySource {
+ Builtin = "builtin",
+ User = "user",
+ Discovered = "discovered",
+ Unknown = "unknown",
+}
+
export const codecForExchangeEntryStatus = (): Codec<ExchangeEntryStatus> =>
codecForEither(
codecForConstString(ExchangeEntryStatus.Ephemeral),
@@ -1811,6 +1819,7 @@ export const codecForExchangeKeyChangeInfo = (): Codec<ExchangeKeyChangeInfo> =>
export interface ExchangeListItem {
exchangeBaseUrl: string;
+ source?: ExchangeEntrySource;
masterPub: string | undefined;
/**
* Set when the exchange changed its key set and the user has not confirmed
@@ -1855,6 +1864,9 @@ export interface ExchangeListItem {
lastUpdateTimestamp: TalerPreciseTimestamp | undefined;
+ /** Most recent successful withdrawal through this exchange. */
+ lastWithdrawal?: TalerPreciseTimestamp;
+
/**
* Information about the last error that occurred when trying
* to update the exchange info.
@@ -4748,10 +4760,11 @@ export enum FlightRecordEvent {
WithdrawalRedenominate = "withdrawal-redenominate",
}
+/**
+ * @deprecated Use {@link ListWithdrawalExchangeCandidatesRequest} instead.
+ */
export interface GetDefaultExchangesRequest {
- /**
- * Only return exchanges whose entry is still a preset entry.
- */
+ /** Only return exchanges whose entry is still a preset entry. */
presetOnly?: boolean;
}
@@ -4761,6 +4774,9 @@ export const codecForGetDefaultExchangesRequest =
.property("presetOnly", codecOptional(codecForBoolean()))
.build("GetDefaultExchangesRequest");
+/**
+ * @deprecated Use {@link ListWithdrawalExchangeCandidatesResponse} instead.
+ */
export interface GetDefaultExchangesResponse {
defaultExchanges: {
/**
@@ -4780,6 +4796,39 @@ export interface GetDefaultExchangesResponse {
}[];
}
+export type ListWithdrawalExchangeCandidatesRequest =
+ GetDefaultExchangesRequest;
+
+export const codecForListWithdrawalExchangeCandidatesRequest =
+ (): Codec<ListWithdrawalExchangeCandidatesRequest> =>
+ buildCodecForObject<ListWithdrawalExchangeCandidatesRequest>()
+ .property("presetOnly", codecOptional(codecForBoolean()))
+ .build("ListWithdrawalExchangeCandidatesRequest");
+
+export enum ExchangeRecommendationReason {
+ Preset = "preset",
+ UserAdded = "user-added",
+ PreviousWithdrawal = "previous-withdrawal",
+ PreviouslyUsed = "previously-used",
+}
+
+export interface WithdrawalExchangeCandidate {
+ /** A taler://withdraw-exchange URI for the exchange. */
+ talerUri: string;
+ exchangeBaseUrl: string;
+ currency: string;
+ currencySpec: CurrencySpecification;
+ exchangeEntryStatus: ExchangeEntryStatus;
+ exchangeUpdateStatus: ExchangeUpdateStatus;
+ source: ExchangeEntrySource;
+ recommendationReasons: ExchangeRecommendationReason[];
+ lastWithdrawal?: TalerPreciseTimestamp;
+}
+
+export interface ListWithdrawalExchangeCandidatesResponse {
+ candidates: WithdrawalExchangeCandidate[];
+}
+
export interface TestingCorruptWithdrawalCoinSelRequest {
transactionId: TransactionIdStr;
}
diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts
@@ -32,6 +32,7 @@ import {
TransferOptionRaw,
CoinRefreshRequest,
ExchangeRefundRequest,
+ ExchangeEntrySource,
RefreshReason,
WithdrawalExchangeAccountDetails,
CoinEnvelope,
@@ -1210,6 +1211,9 @@ export interface WalletExchangeEntry {
*/
presetType?: string;
+ /** How this exchange entry became known to the wallet. */
+ source?: ExchangeEntrySource;
+
/**
* When did we confirm the last withdrawal from this exchange?
*
diff --git a/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts b/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts
@@ -28,10 +28,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
-import {
- SchemaMigration,
- schemaMigrations,
-} from "./db-sqlite-schema.js";
+import { SchemaMigration, schemaMigrations } from "./db-sqlite-schema.js";
import { initSqliteWalletDb } from "./dbtx-sqlite.js";
/**
@@ -114,6 +111,36 @@ test("migration applies DDL and backfills existing rows", async () => {
}
});
+test("exchange source migration upgrades an existing native database", async () => {
+ const { path, cleanup } = withTempDb();
+ try {
+ let db = await openRaw(path);
+ await initSqliteWalletDb(
+ db,
+ schemaMigrations.filter((x) => x.version < 8),
+ );
+ let columns = await queryAll(db, "PRAGMA table_info(exchanges)");
+ assert.ok(!columns.some((x) => x.name === "source"));
+ await db.close();
+
+ db = await openRaw(path);
+ await initSqliteWalletDb(db);
+ columns = await queryAll(db, "PRAGMA table_info(exchanges)");
+ assert.ok(columns.some((x) => x.name === "source"));
+ const applied = await queryAll(
+ db,
+ "SELECT name FROM schema_migrations WHERE version = 8",
+ );
+ assert.deepStrictEqual(
+ applied.map((x) => x.name),
+ ["exchange-entry-source"],
+ );
+ await db.close();
+ } finally {
+ cleanup();
+ }
+});
+
test("a migration already recorded is not applied twice", async () => {
const { path, cleanup } = withTempDb();
try {
diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts
@@ -85,7 +85,7 @@
*
* Bump this when adding a migration to {@link schemaMigrations}.
*/
-export const SQLITE_SCHEMA_VERSION = 7;
+export const SQLITE_SCHEMA_VERSION = 8;
/**
* Tables of the IndexedDB emulation, children before parents.
@@ -1324,6 +1324,11 @@ export const schemaMigrations: SchemaMigration[] = [
"ALTER TABLE idb_migration ADD COLUMN cleanup_safe INTEGER NOT NULL DEFAULT 0 CHECK (cleanup_safe IN (0, 1))",
],
},
+ {
+ version: 8,
+ name: "exchange-entry-source",
+ statements: ["ALTER TABLE exchanges ADD COLUMN source TEXT"],
+ },
];
/** Native tables that contain wallet records (not schema bookkeeping). */
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -31,6 +31,7 @@ import {
MerchantContractTokenKind,
RefreshReason,
DenomKeyType,
+ ExchangeEntrySource,
TalerPreciseTimestamp,
TransactionIdStr,
TalerProtocolTimestamp,
@@ -253,6 +254,7 @@ function makeAvail(
function makeExchange(baseUrl: string): WalletExchangeEntry {
const ex: WalletExchangeEntry = {
baseUrl,
+ source: ExchangeEntrySource.Builtin,
detailsPointer: undefined,
entryStatus: ExchangeEntryDbRecordStatus.Preset,
updateStatus: ExchangeEntryDbUpdateStatus.Initial,
diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts
@@ -43,6 +43,7 @@ import {
WalletNotification,
ContactEntry,
CurrencySpecification,
+ ExchangeEntrySource,
MailboxConfiguration,
MailboxMessageRecord,
ScopeInfo,
@@ -1560,6 +1561,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
...(row.preset_type != null
? { presetType: str(row.preset_type) }
: undefined),
+ ...(row.source != null
+ ? { source: str(row.source) as ExchangeEntrySource }
+ : undefined),
...(row.last_withdrawal != null
? { lastWithdrawal: dbTimestamp(row.last_withdrawal) }
: undefined),
@@ -1615,6 +1619,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
await this.run(
`INSERT INTO exchanges (
base_url, preset_currency_hint, preset_currency_spec, preset_type,
+ source,
last_withdrawal, details_pointer_master_pub,
details_pointer_currency, details_pointer_update_clock,
entry_status, update_status, unavailable_reason,
@@ -1627,7 +1632,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
superseded_master_pub, superseded_currency,
superseded_first_seen, superseded_shares_denoms
) VALUES (
- $url, $pch, $pcs, $pt, $lw, $dpmp, $dpc, $dpuc, $es, $us, $ur,
+ $url, $pch, $pcs, $pt, $src, $lw, $dpmp, $dpc, $dpuc, $es, $us, $ur,
$cnu, $tce, $tae, $tat, $lu, $nus, $lke, $nrcs, $cmrri, $cap,
$capub, $ppd, $ddd, $nf, $smp, $sc, $sfs, $ssd
)
@@ -1635,6 +1640,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
preset_currency_hint = excluded.preset_currency_hint,
preset_currency_spec = excluded.preset_currency_spec,
preset_type = excluded.preset_type,
+ source = excluded.source,
last_withdrawal = excluded.last_withdrawal,
details_pointer_master_pub = excluded.details_pointer_master_pub,
details_pointer_currency = excluded.details_pointer_currency,
@@ -1670,6 +1676,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
? null
: jsonToDb(rec.presetCurrencySpec),
pt: rec.presetType ?? null,
+ src: rec.source ?? null,
lw: rec.lastWithdrawal ?? null,
dpmp: optCrockToDb(rec.detailsPointer?.masterPublicKey),
dpc: rec.detailsPointer?.currency ?? null,
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -45,12 +45,15 @@ import {
EmptyObject,
ExchangeAuditor,
ExchangeDetailedResponse,
+ ExchangeEntryStatus,
+ ExchangeEntrySource,
ExchangeEntryState,
ExchangeGlobalFees,
ExchangeKeysResponse,
ConfirmExchangeKeyChangeRequest,
ExchangeKeyChangeInfo,
ExchangeListItem,
+ ExchangeRecommendationReason,
ExchangeSignKeyJson,
ExchangeTosStatus,
ExchangeUpdateStatus,
@@ -62,10 +65,13 @@ import {
GetExchangeTosResult,
GlobalFees,
HttpStatusCode,
+ HostPortPath,
LegitimizationNeededResponse,
LibtoolVersion,
LimitOperationType,
ListExchangesRequest,
+ ListWithdrawalExchangeCandidatesRequest,
+ ListWithdrawalExchangeCandidatesResponse,
Logger,
NotificationType,
Paytos,
@@ -82,6 +88,8 @@ import {
TalerPreciseTimestamp,
TalerProtocolDuration,
TalerProtocolTimestamp,
+ TalerUriAction,
+ TalerUris,
TestingPlanMigrateExchangeBaseUrlRequest,
TestingWaitExchangeStateRequest,
TestingWaitWalletKycRequest,
@@ -462,6 +470,29 @@ function getKycStatusFromReserveStatus(
}
}
+/**
+ * Return persisted provenance, with conservative defaults for legacy records.
+ */
+export function getExchangeEntrySource(
+ r: WalletExchangeEntry,
+): ExchangeEntrySource {
+ if (r.source != null) {
+ return r.source;
+ }
+ if (
+ r.entryStatus === ExchangeEntryDbRecordStatus.Preset ||
+ r.presetType != null ||
+ r.presetCurrencyHint != null ||
+ r.presetCurrencySpec != null
+ ) {
+ return ExchangeEntrySource.Builtin;
+ }
+ if (r.entryStatus === ExchangeEntryDbRecordStatus.Ephemeral) {
+ return ExchangeEntrySource.Discovered;
+ }
+ return ExchangeEntrySource.Unknown;
+}
+
async function makeExchangeListItem(
wex: WalletExecutionContext,
tx: WalletDbTransaction,
@@ -521,6 +552,7 @@ async function makeExchangeListItem(
const listItem: ExchangeListItem = {
exchangeBaseUrl: r.baseUrl,
+ source: getExchangeEntrySource(r),
masterPub: exchangeDetails?.masterPublicKey,
...(unconfirmedKeyChange ? { unconfirmedKeyChange } : undefined),
noFees,
@@ -543,6 +575,7 @@ async function makeExchangeListItem(
paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
bankComplianceLanguage: exchangeDetails?.bankComplianceLanguage,
lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
+ lastWithdrawal: timestampOptionalPreciseFromDb(r.lastWithdrawal),
currencySpec,
scopeInfo,
};
@@ -927,6 +960,7 @@ export async function putPresetExchangeEntry(
if (!exchange) {
const r: WalletExchangeEntry = {
entryStatus: ExchangeEntryDbRecordStatus.Preset,
+ source: ExchangeEntrySource.Builtin,
updateStatus: ExchangeEntryDbUpdateStatus.Initial,
baseUrl: exchangeBaseUrl,
presetType: exchangeType,
@@ -974,6 +1008,7 @@ async function provideExchangeRecordInTx(
if (!exchange) {
const r: WalletExchangeEntry = {
entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
+ source: ExchangeEntrySource.Discovered,
updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
baseUrl: baseUrl,
detailsPointer: undefined,
@@ -3373,11 +3408,16 @@ export async function downloadExchangeInfo(
/**
* List all exchange entries known to the wallet.
*/
-export async function listExchanges(
+interface ExchangeListItemInternal {
+ item: ExchangeListItem;
+ record: WalletExchangeEntry;
+}
+
+async function listExchangeItemsInternal(
wex: WalletExecutionContext,
req: ListExchangesRequest,
-): Promise<ExchangesListResponse> {
- const exchanges: ExchangeListItem[] = [];
+): Promise<ExchangeListItemInternal[]> {
+ const exchanges: ExchangeListItemInternal[] = [];
await wex.runWalletDbTx(async (tx) => {
const exchangeRecords = await tx.getExchanges();
for (const exchangeRec of exchangeRecords) {
@@ -3425,10 +3465,135 @@ export async function listExchanges(
continue;
}
}
- exchanges.push(li);
+ exchanges.push({ item: li, record: exchangeRec });
+ }
+ });
+ return exchanges;
+}
+
+/**
+ * List all exchange entries known to the wallet.
+ */
+export async function listExchanges(
+ wex: WalletExecutionContext,
+ req: ListExchangesRequest,
+): Promise<ExchangesListResponse> {
+ const items = await listExchangeItemsInternal(wex, req);
+ return { exchanges: items.map((x) => x.item) };
+}
+
+function hasPresetMetadata(record: WalletExchangeEntry): boolean {
+ return (
+ record.source === ExchangeEntrySource.Builtin || record.presetType != null
+ );
+}
+
+/**
+ * List exchanges suitable for presentation in a withdrawal chooser.
+ */
+export async function listWithdrawalExchangeCandidates(
+ wex: WalletExecutionContext,
+ req: ListWithdrawalExchangeCandidatesRequest,
+): Promise<ListWithdrawalExchangeCandidatesResponse> {
+ const items = await listExchangeItemsInternal(wex, {
+ filterByType: "prod",
+ filterByExchangeEntryStatus: req.presetOnly
+ ? ExchangeEntryStatus.Preset
+ : undefined,
+ });
+ const candidates = items
+ .filter(
+ ({ item }) =>
+ item.exchangeEntryStatus !== ExchangeEntryStatus.Ephemeral &&
+ item.currency !== "UNKNOWN",
+ )
+ .map(({ item, record }) => {
+ const source = getExchangeEntrySource(record);
+ const recommendationReasons: ExchangeRecommendationReason[] = [];
+ if (hasPresetMetadata(record)) {
+ recommendationReasons.push(ExchangeRecommendationReason.Preset);
+ }
+ if (source === ExchangeEntrySource.User) {
+ recommendationReasons.push(ExchangeRecommendationReason.UserAdded);
+ }
+ if (item.lastWithdrawal != null) {
+ recommendationReasons.push(
+ ExchangeRecommendationReason.PreviousWithdrawal,
+ );
+ } else if (
+ item.exchangeEntryStatus === ExchangeEntryStatus.Used &&
+ source !== ExchangeEntrySource.User
+ ) {
+ recommendationReasons.push(ExchangeRecommendationReason.PreviouslyUsed);
+ }
+ return {
+ talerUri: TalerUris.stringify({
+ type: TalerUriAction.WithdrawExchange,
+ exchangeBaseUrl: item.exchangeBaseUrl as HostPortPath,
+ }),
+ exchangeBaseUrl: item.exchangeBaseUrl,
+ currency: item.currency,
+ currencySpec: item.currencySpec,
+ exchangeEntryStatus: item.exchangeEntryStatus,
+ exchangeUpdateStatus: item.exchangeUpdateStatus,
+ source,
+ recommendationReasons,
+ ...(item.lastWithdrawal != null
+ ? { lastWithdrawal: item.lastWithdrawal }
+ : undefined),
+ };
+ });
+
+ candidates.sort((a, b) => {
+ if (a.lastWithdrawal != null || b.lastWithdrawal != null) {
+ if (a.lastWithdrawal == null) return 1;
+ if (b.lastWithdrawal == null) return -1;
+ const byWithdrawal = AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(b.lastWithdrawal),
+ AbsoluteTime.fromPreciseTimestamp(a.lastWithdrawal),
+ );
+ if (byWithdrawal !== 0) return byWithdrawal;
}
+ const rank = (candidate: (typeof candidates)[number]): number => {
+ if (candidate.source === ExchangeEntrySource.User) return 0;
+ if (candidate.exchangeEntryStatus === ExchangeEntryStatus.Used) return 1;
+ if (
+ candidate.recommendationReasons.includes(
+ ExchangeRecommendationReason.Preset,
+ )
+ ) {
+ return 2;
+ }
+ return 3;
+ };
+ return (
+ rank(a) - rank(b) || a.exchangeBaseUrl.localeCompare(b.exchangeBaseUrl)
+ );
});
- return { exchanges };
+
+ if (wex.ws.devExperimentState.fakeDefaultExchangeDemo) {
+ candidates.push({
+ talerUri: TalerUris.stringify({
+ type: TalerUriAction.WithdrawExchange,
+ exchangeBaseUrl: "https://exchange.demo.taler.net/" as HostPortPath,
+ }),
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ currency: "KUDOS",
+ currencySpec: {
+ name: "Kudos",
+ common_amounts: ["KUDOS:5", "KUDOS:10", "KUDOS:25", "KUDOS:50"],
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: { "0": "ク" },
+ },
+ exchangeEntryStatus: ExchangeEntryStatus.Preset,
+ exchangeUpdateStatus: ExchangeUpdateStatus.Ready,
+ source: ExchangeEntrySource.Builtin,
+ recommendationReasons: [ExchangeRecommendationReason.Preset],
+ });
+ }
+ return { candidates };
}
/**
@@ -3471,6 +3636,20 @@ export async function markExchangeUsed(
}
}
+/** Record that the user explicitly chose to retain an exchange. */
+export async function markExchangeAddedByUser(
+ tx: WalletDbTransaction,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const exchange = await tx.getExchange(exchangeBaseUrl);
+ if (!exchange) {
+ return;
+ }
+ exchange.source = ExchangeEntrySource.User;
+ await tx.upsertExchange(exchange);
+ await markExchangeUsed(tx, exchangeBaseUrl);
+}
+
/**
* Get detailed information about the exchange including a timeline
* for the fees charged by the exchange.
diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts
@@ -18,16 +18,25 @@ import assert from "node:assert";
import { test } from "node:test";
import {
+ ExchangeEntrySource,
+ ExchangeRecommendationReason,
+ TalerPreciseTimestamp,
+} from "@gnu-taler/taler-util";
+
+import {
ConfigRecord,
ConfigRecordKey,
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
WalletExchangeEntry,
+ timestampPreciseToDb,
} from "./db-common.js";
import { WalletDbTransaction } from "./dbtx.js";
+import { markExchangeAddedByUser } from "./exchanges.js";
import {
handleGetDefaultExchanges,
handleHintApplicationResumed,
+ handleListWithdrawalExchangeCandidates,
} from "./requests.js";
import { WalletExecutionContext } from "./wallet.js";
@@ -106,13 +115,25 @@ test("application-resumed hint reports DB failures independently", async () => {
function makeExchangeEntry(
baseUrl: string,
entryStatus: ExchangeEntryDbRecordStatus,
+ options: {
+ source?: ExchangeEntrySource;
+ presetType?: "prod" | "demo";
+ lastWithdrawalSeconds?: number;
+ } = {},
): WalletExchangeEntry {
return {
baseUrl,
entryStatus,
updateStatus: ExchangeEntryDbUpdateStatus.Initial,
- presetType: "prod",
+ source: options.source,
+ presetType: options.presetType,
presetCurrencyHint: "TESTKUDOS",
+ lastWithdrawal:
+ options.lastWithdrawalSeconds == null
+ ? undefined
+ : timestampPreciseToDb(
+ TalerPreciseTimestamp.fromSeconds(options.lastWithdrawalSeconds),
+ ),
detailsPointer: undefined,
tosAcceptedEtag: undefined,
tosAcceptedTimestamp: undefined,
@@ -153,28 +174,168 @@ function makeExchangeTestContext(
} as WalletExecutionContext;
}
-test("default exchanges can be restricted to preset entries", async () => {
- const presetUrl = "https://preset.example/";
- const usedUrl = "https://used.example/";
- const ephemeralUrl = "https://ephemeral.example/";
+test("withdrawal candidates explain and rank recommendations", async () => {
const wex = makeExchangeTestContext([
- makeExchangeEntry(presetUrl, ExchangeEntryDbRecordStatus.Preset),
- makeExchangeEntry(usedUrl, ExchangeEntryDbRecordStatus.Used),
- makeExchangeEntry(ephemeralUrl, ExchangeEntryDbRecordStatus.Ephemeral),
+ makeExchangeEntry(
+ "https://preset.example/",
+ ExchangeEntryDbRecordStatus.Preset,
+ {
+ source: ExchangeEntrySource.Builtin,
+ presetType: "prod",
+ },
+ ),
+ makeExchangeEntry(
+ "https://legacy-used.example/",
+ ExchangeEntryDbRecordStatus.Used,
+ ),
+ makeExchangeEntry(
+ "https://user.example/",
+ ExchangeEntryDbRecordStatus.Used,
+ {
+ source: ExchangeEntrySource.User,
+ },
+ ),
+ makeExchangeEntry(
+ "https://recent.example/",
+ ExchangeEntryDbRecordStatus.Used,
+ {
+ source: ExchangeEntrySource.Discovered,
+ lastWithdrawalSeconds: 200,
+ },
+ ),
+ makeExchangeEntry(
+ "https://older.example/",
+ ExchangeEntryDbRecordStatus.Used,
+ {
+ source: ExchangeEntrySource.User,
+ lastWithdrawalSeconds: 100,
+ },
+ ),
+ makeExchangeEntry(
+ "https://demo.example/",
+ ExchangeEntryDbRecordStatus.Preset,
+ {
+ source: ExchangeEntrySource.Builtin,
+ presetType: "demo",
+ },
+ ),
+ makeExchangeEntry(
+ "https://ephemeral.example/",
+ ExchangeEntryDbRecordStatus.Ephemeral,
+ ),
]);
- const all = await handleGetDefaultExchanges(wex, {});
+ const result = await handleListWithdrawalExchangeCandidates(wex, {});
assert.deepStrictEqual(
- all.defaultExchanges.map((x) => x.talerUri),
+ result.candidates.map((x) => x.exchangeBaseUrl),
[
- "taler://withdraw-exchange/preset.example/",
- "taler://withdraw-exchange/used.example/",
+ "https://recent.example/",
+ "https://older.example/",
+ "https://user.example/",
+ "https://legacy-used.example/",
+ "https://preset.example/",
],
);
+ assert.deepStrictEqual(result.candidates[0].recommendationReasons, [
+ ExchangeRecommendationReason.PreviousWithdrawal,
+ ]);
+ assert.deepStrictEqual(result.candidates[2].recommendationReasons, [
+ ExchangeRecommendationReason.UserAdded,
+ ]);
+ assert.deepStrictEqual(result.candidates[3].recommendationReasons, [
+ ExchangeRecommendationReason.PreviouslyUsed,
+ ]);
+});
+
+test("candidate reasons can report both preset and explicitly added", async () => {
+ const wex = makeExchangeTestContext([
+ makeExchangeEntry(
+ "https://both.example/",
+ ExchangeEntryDbRecordStatus.Used,
+ {
+ source: ExchangeEntrySource.User,
+ presetType: "prod",
+ },
+ ),
+ ]);
+ const result = await handleListWithdrawalExchangeCandidates(wex, {});
+ assert.deepStrictEqual(result.candidates[0].recommendationReasons, [
+ ExchangeRecommendationReason.Preset,
+ ExchangeRecommendationReason.UserAdded,
+ ]);
+});
+
+test("explicitly adding an exchange records user provenance", async () => {
+ let exchange = makeExchangeEntry(
+ "https://added.example/",
+ ExchangeEntryDbRecordStatus.Ephemeral,
+ { source: ExchangeEntrySource.Discovered },
+ );
+ const tx = {
+ async getExchange(): Promise<WalletExchangeEntry> {
+ return exchange;
+ },
+ async upsertExchange(updated: WalletExchangeEntry): Promise<void> {
+ exchange = { ...updated };
+ },
+ notify(): void {},
+ } as unknown as WalletDbTransaction;
+
+ await markExchangeAddedByUser(tx, exchange.baseUrl);
+ assert.strictEqual(exchange.source, ExchangeEntrySource.User);
+ assert.strictEqual(exchange.entryStatus, ExchangeEntryDbRecordStatus.Used);
+});
+
+test("presetOnly is shared with the deprecated default exchange request", async () => {
+ const wex = makeExchangeTestContext([
+ makeExchangeEntry(
+ "https://preset.example/",
+ ExchangeEntryDbRecordStatus.Preset,
+ {
+ source: ExchangeEntrySource.Builtin,
+ presetType: "prod",
+ },
+ ),
+ makeExchangeEntry(
+ "https://used.example/",
+ ExchangeEntryDbRecordStatus.Used,
+ {
+ source: ExchangeEntrySource.User,
+ },
+ ),
+ ]);
- const presets = await handleGetDefaultExchanges(wex, { presetOnly: true });
+ const candidates = await handleListWithdrawalExchangeCandidates(wex, {
+ presetOnly: true,
+ });
assert.deepStrictEqual(
- presets.defaultExchanges.map((x) => x.talerUri),
- ["taler://withdraw-exchange/preset.example/"],
+ candidates.candidates.map((x) => x.exchangeBaseUrl),
+ ["https://preset.example/"],
);
+
+ let warning = "";
+ const originalWrite = process.stderr.write;
+ process.stderr.write = ((chunk: unknown) => {
+ warning += String(chunk);
+ return true;
+ }) as typeof process.stderr.write;
+ try {
+ const allLegacy = await handleGetDefaultExchanges(wex, {});
+ assert.deepStrictEqual(
+ allLegacy.defaultExchanges.map((x) => x.talerUri),
+ [
+ "taler://withdraw-exchange/preset.example/",
+ "taler://withdraw-exchange/used.example/",
+ ],
+ );
+ const legacy = await handleGetDefaultExchanges(wex, { presetOnly: true });
+ assert.deepStrictEqual(
+ legacy.defaultExchanges.map((x) => x.talerUri),
+ ["taler://withdraw-exchange/preset.example/"],
+ );
+ } finally {
+ process.stderr.write = originalWrite;
+ }
+ assert.match(warning, /getDefaultExchanges is deprecated/);
+ assert.match(warning, /listWithdrawalExchangeCandidates/);
});
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -98,6 +98,8 @@ import {
ListDiscountsResponse,
ListGlobalCurrencyAuditorsResponse,
ListGlobalCurrencyExchangesResponse,
+ ListWithdrawalExchangeCandidatesRequest,
+ ListWithdrawalExchangeCandidatesResponse,
ListSubscriptionsRequest,
ListSubscriptionsResponse,
Logger,
@@ -220,6 +222,7 @@ import {
codecForListBankAccounts,
codecForListDiscountsRequest,
codecForListExchangesRequest,
+ codecForListWithdrawalExchangeCandidatesRequest,
codecForListSubscriptionsRequest,
codecForMailboxBaseUrl,
codecForMailboxConfiguration,
@@ -326,8 +329,9 @@ import {
handleTestingWaitExchangeState,
handleTestingWaitExchangeWalletKyc,
listExchanges,
+ listWithdrawalExchangeCandidates,
lookupExchangeByUri,
- markExchangeUsed,
+ markExchangeAddedByUser,
resetExchangeRetries,
startUpdateExchangeEntry,
waitReadyExchange,
@@ -1223,7 +1227,7 @@ async function handleAddExchange(
// Thus, we mark it as "used".
if (!req.ephemeral) {
await wex.runWalletDbTx(async (tx) => {
- await markExchangeUsed(tx, exchangeBaseUrl);
+ await markExchangeAddedByUser(tx, exchangeBaseUrl);
});
}
return {
@@ -2245,6 +2249,9 @@ export async function handleGetDefaultExchanges(
wex: WalletExecutionContext,
req: GetDefaultExchangesRequest,
): Promise<GetDefaultExchangesResponse> {
+ logger.warn(
+ "getDefaultExchanges is deprecated; use listWithdrawalExchangeCandidates instead",
+ );
const defaultExchanges: GetDefaultExchangesResponse["defaultExchanges"] = [];
const myExchanges = await listExchanges(wex, {
filterByType: "prod",
@@ -2290,6 +2297,13 @@ export async function handleGetDefaultExchanges(
};
}
+export async function handleListWithdrawalExchangeCandidates(
+ wex: WalletExecutionContext,
+ req: ListWithdrawalExchangeCandidatesRequest,
+): Promise<ListWithdrawalExchangeCandidatesResponse> {
+ return await listWithdrawalExchangeCandidates(wex, req);
+}
+
export async function handleTestingCorruptWithdrawalCoinSel(
wex: WalletExecutionContext,
req: TestingCorruptWithdrawalCoinSelRequest,
@@ -2422,6 +2436,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForGetDefaultExchangesRequest(),
handler: handleGetDefaultExchanges,
},
+ [WalletApiOperation.ListWithdrawalExchangeCandidates]: {
+ codec: codecForListWithdrawalExchangeCandidatesRequest(),
+ handler: handleListWithdrawalExchangeCandidates,
+ },
[WalletApiOperation.TestingGetFlightRecords]: {
codec: codecForEmptyObject(),
handler: handleGetFlightRecords,
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -146,6 +146,8 @@ import {
ListDiscountsRequest,
ListDiscountsResponse,
ListExchangesRequest,
+ ListWithdrawalExchangeCandidatesRequest,
+ ListWithdrawalExchangeCandidatesResponse,
ListGlobalCurrencyAuditorsResponse,
ListGlobalCurrencyExchangesResponse,
ListSubscriptionsRequest,
@@ -276,6 +278,8 @@ export enum WalletApiOperation {
AddExchange = "addExchange",
ListExchanges = "listExchanges",
+ ListWithdrawalExchangeCandidates = "listWithdrawalExchangeCandidates",
+ /** @deprecated Use listWithdrawalExchangeCandidates instead. */
GetDefaultExchanges = "getDefaultExchanges",
GetExchangeEntryByUrl = "getExchangeEntryByUrl",
UpdateExchangeEntry = "updateExchangeEntry",
@@ -1176,15 +1180,19 @@ export type GetExchangeDetailedInfoOp = {
response: ExchangeDetailedResponse;
};
-/**
- * Get the current terms of a service of an exchange.
- */
+/** @deprecated Use {@link ListWithdrawalExchangeCandidatesOp} instead. */
export type GetDefaultExchangesOp = {
op: WalletApiOperation.GetDefaultExchanges;
request: GetDefaultExchangesRequest;
response: GetDefaultExchangesResponse;
};
+export type ListWithdrawalExchangeCandidatesOp = {
+ op: WalletApiOperation.ListWithdrawalExchangeCandidates;
+ request: ListWithdrawalExchangeCandidatesRequest;
+ response: ListWithdrawalExchangeCandidatesResponse;
+};
+
/**
* Get the current terms of a service of an exchange.
*/
@@ -2020,6 +2028,7 @@ export type WalletOperations = {
[WalletApiOperation.AcceptBankIntegratedWithdrawal]: AcceptBankIntegratedWithdrawalOp;
[WalletApiOperation.AcceptManualWithdrawal]: AcceptManualWithdrawalOp;
[WalletApiOperation.ListExchanges]: ListExchangesOp;
+ [WalletApiOperation.ListWithdrawalExchangeCandidates]: ListWithdrawalExchangeCandidatesOp;
[WalletApiOperation.AddExchange]: AddExchangeOp;
[WalletApiOperation.ListBankAccounts]: ListBankAccountsOp;
[WalletApiOperation.AddBankAccount]: AddBankAccountsOp;