taler-typescript-core

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

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:
Mpackages/taler-util/src/types-taler-wallet.test.ts | 19++++++++++++-------
Mpackages/taler-util/src/types-taler-wallet.ts | 55++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/db-common.ts | 4++++
Mpackages/taler-wallet-core/src/db-sqlite-migrations.test.ts | 35+++++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 7++++++-
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 2++
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 9++++++++-
Mpackages/taler-wallet-core/src/exchanges.ts | 189++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/requests.test.ts | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/requests.ts | 22++++++++++++++++++++--
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 15++++++++++++---
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;