commit 656caa3c342394c2a1795b0cfd22b05478b4c012
parent 79f0e4637717d0b600343a366d7e8120b1ff3b36
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 17:45:49 +0200
wallet-core: repair metadata-free reserve duplicates
Diffstat:
2 files changed, 183 insertions(+), 32 deletions(-)
diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts
@@ -41,6 +41,7 @@ import {
ExchangeEntryDbUpdateStatus,
PeerPushCreditStatus,
PurchaseStatus,
+ ReserveRecordStatus,
timestampPreciseToDb,
WalletPeerPushCredit,
WalletPurchase,
@@ -416,23 +417,91 @@ test("IndexedDB fixup collapses only identical reserves and remaps references",
await dst.close();
});
-test("converter: remaining duplicate reserve public keys are rejected", async () => {
- // The IndexedDB fixup is responsible for safely deduplicating byte-for-byte
- // identical rows and remapping their references. If duplicates remain at
- // conversion time, selecting a winner would hide corruption.
+test("IndexedDB fixup retains the richer duplicate reserve", async () => {
const src = await makeIdbRunner();
const reservePub = encodeCrock(getRandomBytes(32));
const reservePriv = encodeCrock(getRandomBytes(32));
+ const ids = await src.runReadWriteTx(async (tx) => {
+ const plain = await tx.upsertReserve({ reservePub, reservePriv });
+ const rich = await tx.upsertReserve({
+ reservePub,
+ reservePriv,
+ status: ReserveRecordStatus.Done,
+ thresholdGranted: "TESTKUDOS:10",
+ amlReview: false,
+ });
+ await tx.upsertReserve({ reservePub, reservePriv });
+ 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: plain,
+ });
+ return { plain, rich };
+ });
+ const raw = await (src as IdbWalletDbHandle).rawAccess();
+ await raw.runAllStoresReadWriteTx({}, (tx) =>
+ tx.fixups.delete("fixup20260820DuplicateReserveMetadata"),
+ );
+ await applyFixups(raw);
+
await src.runReadWriteTx(async (tx) => {
- for (let i = 0; i < 3; i++) {
- await tx.upsertReserve({ reservePub, reservePriv });
- }
+ const matching = (await tx.listAllReserves()).filter(
+ (r) => r.reservePub === reservePub,
+ );
+ assert.strictEqual(matching.length, 1);
+ assert.strictEqual(matching[0].rowId, ids.rich);
+ assert.strictEqual(matching[0].status, ReserveRecordStatus.Done);
+ assert.strictEqual(matching[0].thresholdGranted, "TESTKUDOS:10");
+ assert.strictEqual(matching[0].amlReview, false);
+ assert.strictEqual(
+ (await tx.getExchange("https://exchange.example.com/"))
+ ?.currentMergeReserveRowId,
+ ids.rich,
+ );
+ });
+ const dst = await makeSqliteRunner();
+ await convertWalletDb(src, dst);
+ await src.close();
+ await dst.close();
+});
+
+test("converter: conflicting duplicate reserve metadata is rejected", async () => {
+ // The fixup must leave differing defined values untouched. Without a
+ // revision on either row, selecting a winner would hide corruption.
+ const src = await makeIdbRunner();
+ const reservePub = encodeCrock(getRandomBytes(32));
+ const reservePriv = encodeCrock(getRandomBytes(32));
+ await src.runReadWriteTx(async (tx) => {
+ await tx.upsertReserve({
+ reservePub,
+ reservePriv,
+ status: ReserveRecordStatus.PendingLegi,
+ });
+ await tx.upsertReserve({
+ reservePub,
+ reservePriv,
+ status: ReserveRecordStatus.Done,
+ });
// A second, genuinely different reserve, which must survive untouched.
await tx.upsertReserve({
reservePub: encodeCrock(getRandomBytes(32)),
reservePriv: encodeCrock(getRandomBytes(32)),
});
});
+ const raw = await (src as IdbWalletDbHandle).rawAccess();
+ await raw.runAllStoresReadWriteTx({}, (tx) =>
+ tx.fixups.delete("fixup20260820DuplicateReserveMetadata"),
+ );
+ await applyFixups(raw);
const dst = await makeSqliteRunner();
await assert.rejects(
@@ -441,7 +510,7 @@ test("converter: remaining duplicate reserve public keys are rejected", async ()
);
assert.strictEqual(
(await src.runReadWriteTx((tx) => tx.listAllReserves())).length,
- 4,
+ 3,
"refusal modified the source",
);
diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts
@@ -1540,6 +1540,14 @@ export interface FixupDescription {
* Fixups *must* be idempotent.
*/
export const walletDbFixups: FixupDescription[] = [
+ // A later repair for duplicate merge-reserve rows whose key material is
+ // identical but where only one row carries KYC metadata. This needs its
+ // own fixup marker: affected databases have already recorded the older,
+ // byte-identical-only repair below as complete.
+ {
+ fn: fixup20260820DuplicateReserveMetadata,
+ name: "fixup20260820DuplicateReserveMetadata",
+ },
// Clause-Schnorr support made this field explicit. Older RSA records imply
// the RSA defaults and remain valid after the field was introduced.
{
@@ -2092,8 +2100,40 @@ async function fixup20260720RefreshGroupRefundRequests(
});
}
+function canonicalFixupValue(value: any): any {
+ if (Array.isArray(value)) return value.map(canonicalFixupValue);
+ if (value !== null && typeof value === "object") {
+ return Object.fromEntries(
+ Object.keys(value)
+ .sort()
+ .map((key) => [key, canonicalFixupValue(value[key])]),
+ );
+ }
+ return value;
+}
+
+async function remapAndDeleteReserve(
+ tx: WalletIndexedDbTransaction,
+ droppedRowId: number,
+ retainedRowId: number,
+): Promise<void> {
+ await tx.exchanges.iter().forEachAsync(async (e) => {
+ if (e.currentMergeReserveRowId === droppedRowId) {
+ e.currentMergeReserveRowId = retainedRowId;
+ await tx.exchanges.put(e);
+ }
+ });
+ await tx.peerPullCredit.iter().forEachAsync(async (p) => {
+ if (p.mergeReserveRowId === droppedRowId) {
+ p.mergeReserveRowId = retainedRowId;
+ await tx.peerPullCredit.put(p);
+ }
+ });
+ await tx.reserves.delete(droppedRowId);
+}
+
/**
- * Remove duplicate reserve rows.
+ * Remove byte-identical duplicate reserve rows.
*
* An older wallet version inserted its merge reserve again on each update
* instead of upserting, leaving several rows with identical key material
@@ -2110,20 +2150,9 @@ async function fixup20260720DuplicateReserves(
tx: WalletIndexedDbTransaction,
): Promise<void> {
let kept: WalletReserve | undefined;
- const canonical = (value: any): any => {
- if (Array.isArray(value)) return value.map(canonical);
- if (value !== null && typeof value === "object") {
- return Object.fromEntries(
- Object.keys(value)
- .sort()
- .map((key) => [key, canonical(value[key])]),
- );
- }
- return value;
- };
const withoutRowId = (r: WalletReserve): string => {
const { rowId: _rowId, ...rest } = r;
- return JSON.stringify(canonical(rest));
+ return JSON.stringify(canonicalFixupValue(rest));
};
// The index groups equal public keys, so only the retained row for the
// current key is kept in memory. References are remapped immediately,
@@ -2139,19 +2168,72 @@ async function fixup20260720DuplicateReserves(
}
const droppedRowId = r.rowId;
const keptRowId = kept.rowId;
- await tx.exchanges.iter().forEachAsync(async (e) => {
- if (e.currentMergeReserveRowId === droppedRowId) {
- e.currentMergeReserveRowId = keptRowId;
- await tx.exchanges.put(e);
+ await remapAndDeleteReserve(tx, droppedRowId, keptRowId);
+ });
+}
+
+/**
+ * Collapse the metadata-free reserve duplicate created by peer-credit
+ * withdrawals before 528a32fff.
+ *
+ * Equal public and private keys identify the same reserve. It is safe to
+ * discard one row when all of its defined metadata is also present and equal
+ * in the other row: the surviving row then loses no information. Different
+ * defined values remain untouched, so the converter still refuses an
+ * ambiguous database for which there is no revision information to select a
+ * winner.
+ */
+async function fixup20260820DuplicateReserveMetadata(
+ tx: WalletIndexedDbTransaction,
+): Promise<void> {
+ const metadataIsSubset = (
+ subset: WalletReserve,
+ superset: WalletReserve,
+ ): boolean => {
+ for (const [key, value] of Object.entries(subset)) {
+ if (
+ key === "rowId" ||
+ key === "reservePub" ||
+ key === "reservePriv" ||
+ value === undefined
+ ) {
+ continue;
}
- });
- await tx.peerPullCredit.iter().forEachAsync(async (p) => {
- if (p.mergeReserveRowId === droppedRowId) {
- p.mergeReserveRowId = keptRowId;
- await tx.peerPullCredit.put(p);
+ if (
+ JSON.stringify(canonicalFixupValue(value)) !==
+ JSON.stringify(
+ canonicalFixupValue(
+ (superset as unknown as Record<string, unknown>)[key],
+ ),
+ )
+ ) {
+ return false;
}
- });
- await tx.reserves.delete(droppedRowId);
+ }
+ return true;
+ };
+
+ let retained: WalletReserve | undefined;
+ await tx.reserves.indexes.byReservePub.iter().forEachAsync(async (row) => {
+ if (row.rowId == null) return;
+ if (!retained || retained.reservePub !== row.reservePub) {
+ retained = row;
+ return;
+ }
+ if (retained.rowId == null || retained.reservePriv !== row.reservePriv) {
+ return;
+ }
+ const retainedIsSubset = metadataIsSubset(retained, row);
+ const rowIsSubset = metadataIsSubset(row, retained);
+ if (!retainedIsSubset && !rowIsSubset) {
+ return;
+ }
+ if (retainedIsSubset && !rowIsSubset) {
+ await remapAndDeleteReserve(tx, retained.rowId, row.rowId);
+ retained = row;
+ } else {
+ await remapAndDeleteReserve(tx, row.rowId, retained.rowId);
+ }
});
}