commit c698c74a021edf75cf51499a6c1c80e42b9f7383 parent c5d4f21c76c6102cc60f1818a34086b4ad19c7a1 Author: Florian Dold <dold@taler.net> Date: Tue, 1 Sep 2026 22:31:12 +0200 wallet-core: purge legacy exchange key sets Diffstat:
14 files changed, 533 insertions(+), 6 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -298,6 +298,12 @@ export interface TransactionCommon { transactionId: TransactionIdStr; /** + * The transaction produced funds under an exchange key set that the user + * subsequently purged from the wallet. + */ + legacy?: boolean; + + /** * Short identifier assigned by this wallet for local, human-facing use. * * It has the form `#${type}:${localIdent}`. It is intentionally not diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -2023,6 +2023,12 @@ export interface ExchangeListItem { source?: ExchangeEntrySource; masterPub: string | undefined; /** + * Master public keys this exchange URL used before its current key. + * + * The array is sorted and does not include {@link masterPub}. + */ + legacyMasterPubs: string[]; + /** * Set when the exchange changed its key set and the user has not confirmed * the change yet. Withdrawals are refused while this is present. */ @@ -2492,6 +2498,23 @@ export const codecForDeleteExchangeRequest = (): Codec<DeleteExchangeRequest> => .property("purge", codecOptional(codecForBoolean())) .build("DeleteExchangeRequest"); +export interface PurgeExchangeLegacyKeysRequest { + exchangeBaseUrl: string; + + /** + * Current master key observed by the client before confirming the purge. + * The operation fails atomically if the exchange has changed keys since. + */ + currentMasterPub: string; +} + +export const codecForPurgeExchangeLegacyKeysRequest = + (): Codec<PurgeExchangeLegacyKeysRequest> => + buildCodecForObject<PurgeExchangeLegacyKeysRequest>() + .property("exchangeBaseUrl", codecForCanonBaseUrl()) + .property("currentMasterPub", codecForString()) + .build("PurgeExchangeLegacyKeysRequest"); + export interface GetExchangeTosRequest { exchangeBaseUrl: string; acceptedFormat?: string[]; diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -594,6 +594,12 @@ export interface WalletWithdrawalGroup { */ withdrawalGroupId: string; + /** + * At least one coin produced by this withdrawal belonged to an exchange + * key set the user later purged. + */ + legacy?: boolean; + wgInfo: WgInfo; /** diff --git a/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts b/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts @@ -251,6 +251,31 @@ test("withdrawal reserve lookup migration adds its index", async () => { } }); +test("legacy withdrawal migration adds and backfills its marker", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((x) => x.version < 13), + ); + let columns = await queryAll(db, "PRAGMA table_info(withdrawal_groups)"); + assert.ok(!columns.some((x) => x.name === "legacy")); + await db.close(); + + db = await openRaw(path); + await initSqliteWalletDb(db); + columns = await queryAll(db, "PRAGMA table_info(withdrawal_groups)"); + const legacy = columns.find((x) => x.name === "legacy"); + assert.ok(legacy); + assert.strictEqual(legacy.notnull, 1); + assert.strictEqual(String(legacy.dflt_value), "0"); + await db.close(); + } finally { + cleanup(); + } +}); + test("peer capability migration deterministically removes legacy duplicates", 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 = 12; +export const SQLITE_SCHEMA_VERSION = 13; /** * Tables of the IndexedDB emulation, children before parents. @@ -1405,6 +1405,13 @@ export const schemaMigrations: SchemaMigration[] = [ "CREATE INDEX withdrawal_groups_by_reserve_pub ON withdrawal_groups (reserve_pub)", ], }, + { + version: 13, + name: "legacy-withdrawal-marker", + statements: [ + "ALTER TABLE withdrawal_groups ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0 CHECK (legacy IN (0, 1))", + ], + }, ]; /** Native tables that contain wallet records (not schema bookkeeping). */ diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.ts @@ -2353,6 +2353,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToWithdrawalGroup(row: ResultRow): WalletWithdrawalGroup { return { withdrawalGroupId: str(row.withdrawal_group_id), + ...(dbToBool(row.legacy) ? { legacy: true } : undefined), wgInfo: this.rowToWgInfo(row), secretSeed: dbToCrock(row.secret_seed), reservePub: dbToCrock(row.reserve_pub), @@ -2445,12 +2446,13 @@ export class SqliteWalletTransaction implements WalletDbTransaction { secret_seed, reserve_pub, reserve_priv, exchange_base_url, timestamp_start, timestamp_finish, status, restrict_age, instructed_amount, reserve_balance_amount, raw_withdrawal_amount, - effective_withdrawal_amount, denoms_sel, abort_reason, fail_reason + effective_withdrawal_amount, denoms_sel, abort_reason, fail_reason, + legacy ) VALUES ( $id, $wtype, $uri, $cpriv, $binfo, $eca, $ifa, $kph, $kat, $klcs, $klcc, $klrg, $klar, $kld, $kwd, $seed, $rpub, $rpriv, $url, $tstart, $tfinish, $status, $age, $ia, $rba, $rwa, $ewa, $ds, - $abort, $fail + $abort, $fail, $legacy ) ON CONFLICT(withdrawal_group_id) DO UPDATE SET withdrawal_type = excluded.withdrawal_type, @@ -2481,7 +2483,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { effective_withdrawal_amount = excluded.effective_withdrawal_amount, denoms_sel = excluded.denoms_sel, abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason`, + fail_reason = excluded.fail_reason, + legacy = excluded.legacy`, { id: rec.withdrawalGroupId, ...wg, @@ -2512,6 +2515,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ds: rec.denomsSel === undefined ? null : jsonToDb(rec.denomsSel), abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + legacy: boolToDb(rec.legacy ?? false), }, ); } diff --git a/packages/taler-wallet-core/src/db/testing/conformance-cases.ts b/packages/taler-wallet-core/src/db/testing/conformance-cases.ts @@ -2877,6 +2877,19 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "withdrawal group: legacy marker round trips", + async run(t, runner) { + const wg = makeWithdrawalGroup("wg-legacy"); + wg.legacy = true; + await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); + const got = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroup(wg.withdrawalGroupId), + ); + t.equal(got?.legacy, true); + }, + }, + + { name: "withdrawal group: switching variant clears the old variant's data", async run(t, runner) { // A bank-integrated group carries bankInfo and a URI; after switching diff --git a/packages/taler-wallet-core/src/exchanges.test.ts b/packages/taler-wallet-core/src/exchanges.test.ts @@ -16,10 +16,19 @@ import assert from "node:assert"; import { test } from "node:test"; -import { WalletCoin } from "./db/records.js"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { + CoinSourceType, + WalletCoin, + WalletExchangeDetails, + WalletWithdrawalGroup, +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { filterCoinsByExchangeMasterPub, + getLegacyMasterPubs, makeWireAccountValidationRequest, + purgeExchangeLegacyKeysInTx, } from "./exchanges.js"; test("denomination loss processing is scoped to one exchange master key", () => { @@ -71,3 +80,186 @@ test("wire validation covers both protocol v34 gateway URLs", () => { "https://transfer.example/", ); }); + +test("legacy exchange keys are sorted, deduplicated, and exclude the current key", () => { + const detail = (masterPublicKey: string) => + ({ masterPublicKey }) as WalletExchangeDetails; + assert.deepStrictEqual( + getLegacyMasterPubs( + [ + detail("legacy-b"), + detail("current"), + detail("legacy-a"), + detail("legacy-b"), + ], + "current", + ), + ["legacy-a", "legacy-b"], + ); +}); + +test("purging legacy keys removes key-scoped data and marks source withdrawals", async () => { + const exchangeBaseUrl = "https://exchange.example/"; + const currentMasterPub = "current-master"; + const legacyMasterPub = "legacy-master"; + const withdrawal = { + withdrawalGroupId: "withdrawal-1", + } as WalletWithdrawalGroup; + const deleted = { + coins: [] as string[], + availabilities: [] as string[], + denominations: [] as string[], + families: [] as number[], + details: [] as number[], + globals: [] as number[], + currencyScopes: [] as string[], + }; + const notifications: unknown[] = []; + const fakeTx = { + getExchange: async () => ({ + baseUrl: exchangeBaseUrl, + detailsPointer: { + masterPublicKey: currentMasterPub, + currency: "TESTKUDOS", + }, + }), + listExchangeDetailsByBaseUrl: async () => [ + { + rowId: 1, + exchangeBaseUrl, + masterPublicKey: currentMasterPub, + currency: "TESTKUDOS", + }, + { + rowId: 2, + exchangeBaseUrl, + masterPublicKey: legacyMasterPub, + currency: "TESTKUDOS", + }, + ], + getCoinsByExchange: async () => [ + { + coinPub: "legacy-coin", + exchangeBaseUrl, + exchangeMasterPub: legacyMasterPub, + coinSource: { + type: CoinSourceType.Withdraw, + withdrawalGroupId: withdrawal.withdrawalGroupId, + }, + }, + { + coinPub: "current-coin", + exchangeBaseUrl, + exchangeMasterPub: currentMasterPub, + coinSource: { type: CoinSourceType.Reward }, + }, + ], + getCoinAvailabilityByExchange: async () => [ + { + denomPubHash: "legacy-denom", + exchangeMasterPub: legacyMasterPub, + currency: "TESTKUDOS", + }, + { + denomPubHash: "current-denom", + exchangeMasterPub: currentMasterPub, + currency: "TESTKUDOS", + }, + ], + listAllDenominations: async () => [ + { + denomPubHash: "legacy-denom", + exchangeBaseUrl, + exchangeMasterPub: legacyMasterPub, + currency: "TESTKUDOS", + }, + { + denomPubHash: "current-denom", + exchangeBaseUrl, + exchangeMasterPub: currentMasterPub, + currency: "TESTKUDOS", + }, + ], + getDenominationFamiliesByExchange: async () => [ + { + denominationFamilySerial: 10, + familyParams: { + exchangeMasterPub: legacyMasterPub, + value: "TESTKUDOS:1", + }, + }, + { + denominationFamilySerial: 11, + familyParams: { + exchangeMasterPub: currentMasterPub, + value: "TESTKUDOS:1", + }, + }, + ], + listGlobalCurrencyExchanges: async () => [ + { + id: 20, + currency: "TESTKUDOS", + exchangeBaseUrl, + exchangeMasterPub: legacyMasterPub, + }, + ], + getWithdrawalGroup: async () => withdrawal, + upsertWithdrawalGroup: async (record: WalletWithdrawalGroup) => { + Object.assign(withdrawal, record); + }, + deleteCoinAvailability: async (record: { denomPubHash: string }) => { + deleted.availabilities.push(record.denomPubHash); + }, + deleteCoin: async (coinPub: string) => deleted.coins.push(coinPub), + deleteDenomination: async (record: { denomPubHash: string }) => { + deleted.denominations.push(record.denomPubHash); + }, + deleteDenominationFamily: async (serial: number) => { + deleted.families.push(serial); + }, + deleteExchangeDetails: async (rowId: number) => { + deleted.details.push(rowId); + }, + deleteGlobalCurrencyExchange: async (id: number) => { + deleted.globals.push(id); + }, + deleteCurrencyInfo: async (scope: { masterPub: string }) => { + deleted.currencyScopes.push(scope.masterPub); + }, + notify: (notification: unknown) => notifications.push(notification), + } as unknown as WalletDbTransaction; + + assert.equal( + await purgeExchangeLegacyKeysInTx(fakeTx, { + exchangeBaseUrl, + currentMasterPub, + }), + true, + ); + assert.equal(withdrawal.legacy, true); + assert.deepStrictEqual(deleted.coins, ["legacy-coin"]); + assert.deepStrictEqual(deleted.availabilities, ["legacy-denom"]); + assert.deepStrictEqual(deleted.denominations, ["legacy-denom"]); + assert.deepStrictEqual(deleted.families, [10]); + assert.deepStrictEqual(deleted.details, [2]); + assert.deepStrictEqual(deleted.globals, [20]); + assert.deepStrictEqual(deleted.currencyScopes, [legacyMasterPub]); + assert.equal(notifications.length, 1); +}); + +test("legacy purge rejects a stale current master key before deleting anything", async () => { + const fakeTx = { + getExchange: async () => ({ + detailsPointer: { masterPublicKey: "new-current" }, + }), + } as unknown as WalletDbTransaction; + await assert.rejects( + purgeExchangeLegacyKeysInTx(fakeTx, { + exchangeBaseUrl: "https://exchange.example/", + currentMasterPub: "old-current", + }), + (error: any) => + error?.errorDetail?.code === TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + ); +}); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -76,6 +76,7 @@ import { Logger, NotificationType, Paytos, + PurgeExchangeLegacyKeysRequest, Recoup, RefreshReason, ScopeInfo, @@ -145,6 +146,7 @@ import { } from "./common.js"; import { DbProtocolTimestamp, + CoinSourceType, DenomLossStatus, DenominationVerificationStatus, ExchangeMigrationReason, @@ -617,10 +619,16 @@ async function makeExchangeListItem( }; } + const legacyMasterPubs = getLegacyMasterPubs( + await tx.listExchangeDetailsByBaseUrl(r.baseUrl), + exchangeDetails?.masterPublicKey, + ); + const listItem: ExchangeListItem = { exchangeBaseUrl: r.baseUrl, source: getExchangeEntrySource(r), masterPub: exchangeDetails?.masterPublicKey, + legacyMasterPubs, ...(unconfirmedKeyChange ? { unconfirmedKeyChange } : undefined), noFees, peerPaymentsDisabled: r.peerPaymentsDisabled ?? false, @@ -664,6 +672,20 @@ async function makeExchangeListItem( return listItem; } +/** Return the stable, presentation-facing list of historical key sets. */ +export function getLegacyMasterPubs( + details: WalletExchangeDetails[], + currentMasterPub: string | undefined, +): string[] { + return [ + ...new Set( + details + .map((x) => x.masterPublicKey) + .filter((masterPub) => masterPub !== currentMasterPub), + ), + ].sort(); +} + export interface ExchangeDetails { currency: string; tinyAmount: AmountString; @@ -4505,6 +4527,184 @@ export async function deleteExchange( } } +/** + * Remove every key set previously used by an exchange URL. + * + * The caller supplies the current master public key as a stale-view guard: + * a rotation between confirmation and execution must not turn the key the + * user just reviewed into another key that is silently purged. + */ +export async function purgeExchangeLegacyKeys( + wex: WalletExecutionContext, + req: PurgeExchangeLegacyKeysRequest, +): Promise<void> { + const changed = await wex.runWalletDbTx(async (tx) => + purgeExchangeLegacyKeysInTx(tx, req), + ); + + if (changed) { + wex.ws.exchangeCache.clear(); + await wex.taskScheduler.reload(); + } +} + +export async function purgeExchangeLegacyKeysInTx( + tx: WalletDbTransaction, + req: PurgeExchangeLegacyKeysRequest, +): Promise<boolean> { + const exchange = await tx.getExchange(req.exchangeBaseUrl); + if (!exchange) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_ENTRY_NOT_FOUND, + { exchangeBaseUrl: req.exchangeBaseUrl }, + "no exchange entry for that base URL", + ); + } + const currentMasterPub = exchange.detailsPointer?.masterPublicKey; + if (!currentMasterPub) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "exchangeBaseUrl" }, + "the exchange does not have a current master public key", + ); + } + if (currentMasterPub !== req.currentMasterPub) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "currentMasterPub" }, + "the exchange master public key changed before legacy keys were purged", + ); + } + + const [details, coins, availabilities, denominations, families, globals] = + await Promise.all([ + tx.listExchangeDetailsByBaseUrl(req.exchangeBaseUrl), + tx.getCoinsByExchange(req.exchangeBaseUrl), + tx.getCoinAvailabilityByExchange(req.exchangeBaseUrl), + tx.listAllDenominations(), + tx.getDenominationFamiliesByExchange(req.exchangeBaseUrl), + tx.listGlobalCurrencyExchanges(), + ]); + const denominationsAtUrl = denominations.filter( + (d) => d.exchangeBaseUrl === req.exchangeBaseUrl, + ); + const globalsAtUrl = globals.filter( + (g) => g.exchangeBaseUrl === req.exchangeBaseUrl, + ); + const legacyMasterPubs = new Set<string>(); + for (const masterPub of [ + ...details.map((x) => x.masterPublicKey), + ...coins.map((x) => x.exchangeMasterPub), + ...availabilities.map((x) => x.exchangeMasterPub), + ...denominationsAtUrl.map((x) => x.exchangeMasterPub), + ...families.map((x) => x.familyParams.exchangeMasterPub), + ...globalsAtUrl.map((x) => x.exchangeMasterPub), + ]) { + if (masterPub !== currentMasterPub) { + legacyMasterPubs.add(masterPub); + } + } + if (legacyMasterPubs.size === 0) { + return false; + } + + const legacyCurrencies = new Map<string, Set<string>>(); + const rememberCurrency = (masterPub: string, currency: string): void => { + if (!legacyMasterPubs.has(masterPub)) { + return; + } + const currencies = legacyCurrencies.get(masterPub) ?? new Set<string>(); + currencies.add(currency); + legacyCurrencies.set(masterPub, currencies); + }; + for (const x of details) rememberCurrency(x.masterPublicKey, x.currency); + for (const x of availabilities) + rememberCurrency(x.exchangeMasterPub, x.currency); + for (const x of denominationsAtUrl) + rememberCurrency(x.exchangeMasterPub, x.currency); + for (const x of families) + rememberCurrency( + x.familyParams.exchangeMasterPub, + Amounts.currencyOf(x.familyParams.value), + ); + for (const x of globalsAtUrl) + rememberCurrency(x.exchangeMasterPub, x.currency); + + const withdrawalGroupIds = new Set<string>(); + for (const coin of coins) { + if ( + legacyMasterPubs.has(coin.exchangeMasterPub) && + coin.coinSource.type === CoinSourceType.Withdraw && + coin.coinSource.withdrawalGroupId + ) { + withdrawalGroupIds.add(coin.coinSource.withdrawalGroupId); + } + } + for (const withdrawalGroupId of withdrawalGroupIds) { + const withdrawal = await tx.getWithdrawalGroup(withdrawalGroupId); + if (withdrawal && !withdrawal.legacy) { + withdrawal.legacy = true; + await tx.upsertWithdrawalGroup(withdrawal); + } + } + + for (const availability of availabilities) { + if (legacyMasterPubs.has(availability.exchangeMasterPub)) { + await tx.deleteCoinAvailability(availability); + } + } + for (const coin of coins) { + if (legacyMasterPubs.has(coin.exchangeMasterPub)) { + await tx.deleteCoin(coin.coinPub); + } + } + for (const denomination of denominationsAtUrl) { + if (legacyMasterPubs.has(denomination.exchangeMasterPub)) { + await tx.deleteDenomination(denomination); + } + } + for (const family of families) { + if (!legacyMasterPubs.has(family.familyParams.exchangeMasterPub)) { + continue; + } + checkDbInvariant( + family.denominationFamilySerial != null, + "denominationFamilySerial", + ); + await tx.deleteDenominationFamily(family.denominationFamilySerial); + } + for (const detail of details) { + if (!legacyMasterPubs.has(detail.masterPublicKey)) { + continue; + } + checkDbInvariant(detail.rowId != null, "exchange details row ID"); + await tx.deleteExchangeDetails(detail.rowId); + } + for (const global of globalsAtUrl) { + if (!legacyMasterPubs.has(global.exchangeMasterPub)) { + continue; + } + checkDbInvariant(global.id != null, "global exchange row ID"); + await tx.deleteGlobalCurrencyExchange(global.id); + } + for (const [masterPub, currencies] of legacyCurrencies) { + for (const currency of currencies) { + await tx.deleteCurrencyInfo({ + type: ScopeType.ExchangeLegacyKeys, + currency, + url: req.exchangeBaseUrl, + masterPub, + }); + } + } + + tx.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: "purge-exchange-legacy-keys", + }); + return true; +} + export async function getExchangeResources( wex: WalletExecutionContext, exchangeBaseUrl: string, diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts @@ -641,6 +641,9 @@ function makeExchangeTestContext( async getCurrencyInfo(): Promise<undefined> { return undefined; }, + async listExchangeDetailsByBaseUrl(): Promise<[]> { + return []; + }, } as unknown as WalletDbTransaction; return { async runWalletDbTx<T>( diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -110,6 +110,7 @@ import { PerformanceTable, PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, + PurgeExchangeLegacyKeysRequest, RemoveGlobalCurrencyAuditorRequest, RemoveGlobalCurrencyExchangeRequest, Result, @@ -234,6 +235,7 @@ import { codecForPreparePeerPushCreditRequest, codecForPrepareRefundRequest, codecForPrepareWithdrawExchangeRequest, + codecForPurgeExchangeLegacyKeysRequest, codecForRemoveGlobalCurrencyAuditorRequest, codecForRemoveGlobalCurrencyExchangeRequest, codecForResumeTransaction, @@ -319,6 +321,7 @@ import { getExchangeDetailedInfo, getExchangeDetailsInTx, getExchangeResources, + purgeExchangeLegacyKeys, getExchangeTos, handleStartExchangeWalletKyc, handleTestingPlanMigrateExchangeBaseUrl, @@ -1864,6 +1867,14 @@ async function handleDeleteExchange( return {}; } +async function handlePurgeExchangeLegacyKeys( + wex: WalletExecutionContext, + req: PurgeExchangeLegacyKeysRequest, +): Promise<EmptyObject> { + await purgeExchangeLegacyKeys(wex, req); + return {}; +} + async function handleExportDbToFile( wex: WalletExecutionContext, req: ExportDbToFileRequest, @@ -3010,6 +3021,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForDeleteExchangeRequest(), handler: handleDeleteExchange, }, + [WalletApiOperation.PurgeExchangeLegacyKeys]: { + codec: codecForPurgeExchangeLegacyKeysRequest(), + handler: handlePurgeExchangeLegacyKeys, + }, [WalletApiOperation.GetExchangeResources]: { codec: codecForGetExchangeResourcesRequest(), handler: async (wex, req) => { diff --git a/packages/taler-wallet-core/src/wallet-api-types.test.ts b/packages/taler-wallet-core/src/wallet-api-types.test.ts @@ -16,7 +16,11 @@ import assert from "node:assert"; import { test } from "node:test"; -import { DeleteExchangeOp, WalletApiOperation } from "./wallet-api-types.js"; +import { + DeleteExchangeOp, + PurgeExchangeLegacyKeysOp, + WalletApiOperation, +} from "./wallet-api-types.js"; test("DeleteExchange operation type has the matching discriminant", () => { // This assignment is the regression check: it did not type-check while @@ -29,3 +33,16 @@ test("DeleteExchange operation type has the matching discriminant", () => { assert.strictEqual(operation.op, WalletApiOperation.DeleteExchange); }); + +test("PurgeExchangeLegacyKeys operation type has the matching discriminant", () => { + const operation: PurgeExchangeLegacyKeysOp = { + op: WalletApiOperation.PurgeExchangeLegacyKeys, + request: { + exchangeBaseUrl: "https://exchange.example/", + currentMasterPub: "current-master", + }, + response: {}, + }; + + assert.strictEqual(operation.op, WalletApiOperation.PurgeExchangeLegacyKeys); +}); diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -174,6 +174,7 @@ import { PrepareRefundRequest, PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, + PurgeExchangeLegacyKeysRequest, RemoveGlobalCurrencyAuditorRequest, RemoveGlobalCurrencyExchangeRequest, Result, @@ -289,6 +290,7 @@ export enum WalletApiOperation { GetExchangeResources = "getExchangeResources", CompleteExchangeBaseUrl = "completeExchangeBaseUrl", DeleteExchange = "deleteExchange", + PurgeExchangeLegacyKeys = "purgeExchangeLegacyKeys", ConfirmExchangeKeyChange = "confirmExchangeKeyChange", SetExchangeTosAccepted = "setExchangeTosAccepted", SetExchangeTosForgotten = "setExchangeTosForgotten", @@ -1241,6 +1243,13 @@ export type DeleteExchangeOp = { response: EmptyObject; }; +/** Purge every non-current key set retained for an exchange URL. */ +export type PurgeExchangeLegacyKeysOp = { + op: WalletApiOperation.PurgeExchangeLegacyKeys; + request: PurgeExchangeLegacyKeysRequest; + response: EmptyObject; +}; + export type GetCurrencySpecificationOp = { op: WalletApiOperation.GetCurrencySpecification; request: GetCurrencySpecificationRequest; @@ -1964,6 +1973,10 @@ export const walletApiExpectedErrors = { TalerErrorCode.WALLET_EXCHANGE_ENTRY_NOT_FOUND, TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED, ], + [WalletApiOperation.PurgeExchangeLegacyKeys]: [ + TalerErrorCode.WALLET_EXCHANGE_ENTRY_NOT_FOUND, + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + ], [WalletApiOperation.UpdateExchangeEntry]: [ TalerErrorCode.WALLET_EXCHANGE_ENTRY_NOT_FOUND, TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT, @@ -2125,6 +2138,7 @@ export type WalletOperations = { [WalletApiOperation.UpdateExchangeEntry]: UpdateExchangeEntryOp; [WalletApiOperation.PrepareWithdrawExchange]: PrepareWithdrawExchangeOp; [WalletApiOperation.DeleteExchange]: DeleteExchangeOp; + [WalletApiOperation.PurgeExchangeLegacyKeys]: PurgeExchangeLegacyKeysOp; [WalletApiOperation.GetExchangeResources]: GetExchangeResourcesOp; [WalletApiOperation.ListGlobalCurrencyAuditors]: ListGlobalCurrencyAuditorsOp; [WalletApiOperation.ListGlobalCurrencyExchanges]: ListGlobalCurrencyExchangesOp; diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -261,6 +261,7 @@ function buildTransactionForBankIntegratedWithdraw( const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); let txDetails: TransactionWithdrawal = { type: TransactionType.Withdrawal, + ...(wg.legacy ? { legacy: true } : undefined), txState, stId: wg.status, scopes, @@ -334,6 +335,7 @@ function buildTransactionForManualWithdraw( let txDetails: TransactionWithdrawal = { type: TransactionType.Withdrawal, + ...(wg.legacy ? { legacy: true } : undefined), stId: wg.status, txState, scopes,