taler-typescript-core

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

commit 56995ed270383bf3d7b1f4a1b7998d8fe809e734
parent 528a32fff9138b4dcb53dad3cdcb9bbdf24f2dbc
Author: Florian Dold <dold@taler.net>
Date:   Thu,  6 Aug 2026 20:32:15 +0200

wallet: collapse reserves that share a public key when converting

Databases written before the previous commit have several rows for the merge
reserve, and the native schema declares the public key unique; the row an
exchange entry references by row id is the one that has to survive.

Issue: https://bugs.taler.net/n/11718

Diffstat:
Mpackages/taler-wallet-core/src/db-converter.test.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-converter.ts | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 126 insertions(+), 1 deletion(-)

diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts @@ -27,6 +27,17 @@ import assert from "node:assert"; import { test } from "node:test"; +import { + encodeCrock, + getRandomBytes, + TalerPreciseTimestamp, +} from "@gnu-taler/taler-util"; + +import { + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + timestampPreciseToDb, +} from "./db-common.js"; import { SQLITE_BASELINE_SCHEMA } from "./db-sqlite-schema.js"; import { convertWalletDb } from "./db-converter.js"; import { conformanceCases } from "./dbtx-conformance-cases.js"; @@ -93,6 +104,66 @@ test("converter: sqlite to IndexedDB (reverse direction)", async () => { await dst.close(); }); +test("converter: reserve rows sharing a public key collapse into one", async () => { + // What an IndexedDB wallet that ever received a peer payment looks like: + // the merge reserve stored once by the exchange entry that points at it, + // and again by each peer-credit withdrawal group, under a fresh row id. + // The native schema declares the public key unique, so the conversion has + // to keep exactly the row the exchange entry references. + const src = await makeIdbRunner(); + const reservePub = encodeCrock(getRandomBytes(32)); + const reservePriv = encodeCrock(getRandomBytes(32)); + const rowIds = await src.runReadWriteTx(async (tx) => { + const ids = []; + for (let i = 0; i < 3; i++) { + ids.push(await tx.upsertReserve({ reservePub, reservePriv })); + } + // A second, genuinely different reserve, which must survive untouched. + ids.push( + await tx.upsertReserve({ + reservePub: encodeCrock(getRandomBytes(32)), + reservePriv: encodeCrock(getRandomBytes(32)), + }), + ); + return ids; + }); + // The exchange points at the *last* of the duplicates, so keeping the + // first would leave the entry referencing a row that no longer exists. + const referencedRowId = rowIds[2]; + await src.runReadWriteTx(async (tx) => { + await tx.upsertExchange({ + baseUrl: "https://exchange.example.com/", + detailsPointer: undefined, + entryStatus: ExchangeEntryDbRecordStatus.Preset, + updateStatus: ExchangeEntryDbUpdateStatus.Initial, + tosCurrentEtag: undefined, + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + lastUpdate: undefined, + nextUpdateStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + lastKeysEtag: undefined, + nextRefreshCheckStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + currentMergeReserveRowId: referencedRowId, + }); + }); + + const dst = await makeSqliteRunner(); + const report = await convertWalletDb(src, dst); + assert.strictEqual(report.copied.reserves, 2); + + const reserves = await dst.runReadWriteTx((tx) => tx.listAllReserves()); + assert.strictEqual(reserves.length, 2); + const kept = reserves.find((r) => r.reservePub === reservePub); + assert.strictEqual( + kept?.rowId, + referencedRowId, + "the reserve the exchange entry references was not the one kept", + ); + + await src.close(); + await dst.close(); +}); + test("converter: the copy plan covers every table in the schema", async () => { // An empty conversion still visits every step, so the report's keys are // the plan's coverage. Comparing them against the schema's table list @@ -107,6 +178,8 @@ test("converter: the copy plan covers every table in the schema", async () => { // table -> step that carries it, or the reason no step is needed. const coverage: Record<string, string> = { schema_migrations: "EXCLUDED: describes the schema, not wallet data", + idb_migration: + "EXCLUDED: describes where this file's data came from, not the data", purchase_exchanges: "purchases", // stored inside the purchase record config: "config", currency_info: "currencyInfo", diff --git a/packages/taler-wallet-core/src/db-converter.ts b/packages/taler-wallet-core/src/db-converter.ts @@ -33,6 +33,7 @@ import { Logger } from "@gnu-taler/taler-util"; +import { WalletReserve } from "./db-common.js"; import { WalletDbHandle } from "./dbtx-handle.js"; import { WalletDbTransaction } from "./dbtx.js"; @@ -109,6 +110,57 @@ function stripLegacy( } /** + * The reserves, one per reserve public key. + * + * The IndexedDB store is keyed by an auto-increment row id and has no unique + * index on the public key, so it accumulated one extra row per peer-credit + * withdrawal group created against an exchange whose merge reserve already + * existed: the same key pair, stored again under a new row id. The native + * schema declares the public key unique, which is what a reserve record + * actually is, so those rows have to go somewhere -- and they carry nothing + * the kept row does not. + * + * Which one is kept matters: an exchange entry references its merge reserve by + * row id, so the referenced row survives and the reference stays valid. With + * none of them referenced, the lowest row id wins, which is the one the wallet + * created first. + */ +async function listReservesByPub( + tx: WalletDbTransaction, +): Promise<WalletReserve[]> { + const reserves = await tx.listAllReserves(); + const referenced = new Set<number>(); + for (const ex of await tx.getExchanges()) { + if (ex.currentMergeReserveRowId != null) { + referenced.add(ex.currentMergeReserveRowId); + } + } + const byPub = new Map<string, WalletReserve>(); + for (const r of reserves) { + const kept = byPub.get(r.reservePub); + if (!kept) { + byPub.set(r.reservePub, r); + continue; + } + if (referenced.has(kept.rowId ?? -1)) { + continue; + } + if ( + referenced.has(r.rowId ?? -1) || + (r.rowId ?? Infinity) < (kept.rowId ?? Infinity) + ) { + byPub.set(r.reservePub, r); + } + } + if (byPub.size !== reserves.length) { + logger.info( + `collapsed ${reserves.length} reserve rows into ${byPub.size} reserves`, + ); + } + return [...byPub.values()]; +} + +/** * The copy plan, in groups. * * Each group runs in one destination transaction, and groups run in @@ -190,7 +242,7 @@ const COPY_PLAN: CopyStep[][] = [ // reserve by row id. step( "reserves", - (tx) => tx.listAllReserves(), + (tx) => listReservesByPub(tx), (tx, r) => tx.upsertReserve(r), ), ],