taler-typescript-core

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

commit fe41922a793b753b5ed7f6a46bf0404f7486642f
parent c55ade1e183080ec23c00bf351380f24540b0eb1
Author: Florian Dold <dold@taler.net>
Date:   Fri,  7 Aug 2026 02:54:39 +0200

wallet: migrate denomination families with the exchange base URL

Families are looked up by base URL, so those left behind at the old one were
invisible after a migration and the next /keys built a second complete set --
leaving the denominations of coins already held pointing at a family that a
later purge of the old URL would cascade-delete.

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts | 31+++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 77 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts @@ -52,6 +52,11 @@ export async function runWalletExchangeMigrationTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const diagBefore = await walletClient.call( + WalletApiOperation.GetDiagnostics, + {}, + ); + await exchange.stop(); // Exchange running on a different port. @@ -96,6 +101,32 @@ export async function runWalletExchangeMigrationTest(t: GlobalTestState) { t.assertDeepEqual(si.type, ScopeType.Exchange); t.assertDeepEqual(si.url, "http://myexchange.localhost:8181/"); + // Force a full /keys against the URL the entry now lives at. The migration + // itself returns before the denomination loop, so anything it failed to + // bring across only shows up on the next update. + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: exchange2.baseUrl, + force: true, + }); + await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { + exchangeBaseUrl: exchange2.baseUrl, + forceUpdate: true, + }); + + // The migration moves the entry, it does not duplicate what hangs off it. + // Denomination families are looked up by base URL, so one left behind at the + // old URL is invisible afterwards and the next /keys makes a second copy -- + // leaving the denominations of live coins pointing at a family that a later + // purge of the old URL would cascade-delete. + const diagAfter = await walletClient.call( + WalletApiOperation.GetDiagnostics, + {}, + ); + t.assertDeepEqual( + diagAfter.idbObjectStoreCounts?.["denominationFamilies"], + diagBefore.idbObjectStoreCounts?.["denominationFamilies"], + ); + const transactions = await walletClient.call( WalletApiOperation.GetTransactionsV2, {}, diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -4703,6 +4703,52 @@ export async function migrateExchange( } { + // Denomination families are looked up by base URL, so one left behind + // at the old URL is invisible at the new one: the next /keys finds no + // family for any denomination and creates a second, complete set. The + // denominations of coins already held keep pointing at the first set, + // which a later purge of the old URL would then cascade-delete. + const families = await tx.getDenominationFamiliesByExchange( + req.oldExchangeBaseUrl, + ); + for (const fam of families) { + const moved: WalletDenomFamilyParams = { + ...fam.familyParams, + exchangeBaseUrl: req.newExchangeBaseUrl, + }; + // Migrating onto an exchange the wallet already knows can produce two + // families with identical parameters. Keep the one already at the + // new URL and point this one's denominations at it, rather than + // storing a duplicate the next update would have to disambiguate. + const existing = await tx.getDenominationFamilyByParams(moved); + if ( + existing?.denominationFamilySerial != null && + existing.denominationFamilySerial !== fam.denominationFamilySerial + ) { + checkDbInvariant( + typeof fam.denominationFamilySerial === "number", + "denominationFamilySerial", + ); + for (const denom of await tx.getDenominationsByMasterPub( + moved.exchangeMasterPub, + )) { + if ( + denom.denominationFamilySerial === fam.denominationFamilySerial + ) { + denom.denominationFamilySerial = + existing.denominationFamilySerial; + await tx.upsertDenomination(denom); + } + } + await tx.deleteDenominationFamily(fam.denominationFamilySerial); + continue; + } + fam.familyParams = moved; + await tx.upsertDenominationFamily(fam); + } + } + + { const recs = await tx.listAllDenomLossEvents(); for (const rec of recs) { if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) {