taler-typescript-core

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

commit 070ab544379ec8ab909a9694ae39f29fc0d8e070
parent b565a984e84e2f578caf3e268539d6ae8ce3dc57
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 11:53:50 +0200

wallet-core: repair orphan coin histories

Diffstat:
Mpackages/taler-wallet-core/src/db/indexeddb/fixups.ts | 25+++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/migration/converter.test.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-core/src/db/indexeddb/fixups.ts b/packages/taler-wallet-core/src/db/indexeddb/fixups.ts @@ -61,6 +61,12 @@ export interface FixupDescription { * Fixups *must* be idempotent. */ export const walletDbFixups: FixupDescription[] = [ + // Exchange purging used to delete coins without deleting their histories, + // leaving rows that the sqlite foreign key cannot represent. + { + fn: fixup20260822OrphanCoinHistories, + name: "fixup20260822OrphanCoinHistories", + }, // Exchange purging used to delete the details row before querying an // array-valued index with a scalar key, so the related signing keys were // not found and survived without their parent. @@ -161,6 +167,25 @@ export const walletDbFixups: FixupDescription[] = [ ]; /** + * Delete coin histories whose coins were removed by the old IndexedDB + * exchange-purge implementation. + * + * Coin history is looked up through its coin and carries no independent + * wallet state. Removing an orphan therefore matches the ON DELETE CASCADE + * constraint used by the sqlite backend. + */ +async function fixup20260822OrphanCoinHistories( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.coinHistory.iter().forEachAsync(async (history) => { + if (await tx.coins.get(history.coinPub)) { + return; + } + await tx.coinHistory.delete(history.coinPub); + }); +} + +/** * Delete signing keys whose exchange details were removed by the old * IndexedDB exchange-purge implementation. * diff --git a/packages/taler-wallet-core/src/db/migration/converter.test.ts b/packages/taler-wallet-core/src/db/migration/converter.test.ts @@ -116,6 +116,91 @@ test("converter: preserves a legacy orphan coin without a master key", async () } }); +test("IndexedDB fixup removes orphan coin histories", async () => { + const src = await makeIdbRunner(); + const key = (): string => encodeCrock(getRandomBytes(32)); + const hash = (): string => encodeCrock(getRandomBytes(64)); + const coin = (coinPub: string): WalletCoin => ({ + coinPub, + coinPriv: key(), + exchangeBaseUrl: "https://history.example/", + exchangeMasterPub: key(), + denomPubHash: hash(), + denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: "signature" }, + blindingKey: key(), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + coinEvHash: hash(), + status: CoinStatus.Dormant, + maxAge: 0, + ageCommitmentProof: undefined, + coinSource: { + type: CoinSourceType.Withdraw, + withdrawalGroupId: "missing-withdrawal", + coinIndex: 0, + reservePub: key(), + }, + }); + + const orphanPubs = [key(), key()]; + const validPub = key(); + await src.runReadWriteTx(async (tx) => { + for (const coinPub of [...orphanPubs, validPub]) { + await tx.upsertCoin(coin(coinPub)); + await tx.upsertCoinHistory({ coinPub, history: [] }); + } + }); + + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, async (tx) => { + // Bypass the current DAL cascade to reproduce the legacy purge bug. + for (const coinPub of orphanPubs) { + await tx.coins.delete(coinPub); + } + await tx.fixups.delete("fixup20260822OrphanCoinHistories"); + }); + + const rejectedDst = await makeSqliteRunner(); + try { + await assert.rejects( + () => convertWalletDb(src, rejectedDst), + /coin history references missing coin/, + ); + } finally { + await rejectedDst.close(); + } + + await applyFixups(raw); + let repaired = await src.runReadWriteTx((tx) => tx.listAllCoinHistories()); + assert.deepStrictEqual( + repaired.map((x) => x.coinPub), + [validPub], + ); + + // Force a second application rather than merely exercising the marker. + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260822OrphanCoinHistories"), + ); + await applyFixups(raw); + repaired = await src.runReadWriteTx((tx) => tx.listAllCoinHistories()); + assert.deepStrictEqual( + repaired.map((x) => x.coinPub), + [validPub], + ); + + const dst = await makeSqliteRunner(); + try { + const report = await convertWalletDb(src, dst); + assert.strictEqual(report.copied.coinHistory, 1); + assert.strictEqual( + (await dst.runReadWriteTx((tx) => tx.listAllCoinHistories())).length, + 1, + ); + } finally { + await src.close(); + await dst.close(); + } +}); + test("IndexedDB fixup removes orphan exchange signing keys", async () => { const src = await makeIdbRunner(); const key = (): string => encodeCrock(getRandomBytes(32));