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:
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));