taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/db-converter.test.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-wallet-core/src/db-indexeddb.ts | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
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); + } }); }