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:
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),
),
],