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:
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) {