taler-typescript-core

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

commit b14adbb9d95c074897fac80cd9e252996785f46f
parent 9e69d206f57e4adbe2617cc2a2f7e53f094cc47b
Author: Florian Dold <dold@taler.net>
Date:   Sun,  9 Aug 2026 23:29:55 +0200

wallet-core: canonicalize legacy data during DB migration

Diffstat:
Mpackages/taler-wallet-core/src/db-converter.test.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-converter.ts | 36+++++++++++++++++++++++++++++++++---
2 files changed, 113 insertions(+), 3 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts @@ -36,7 +36,11 @@ import { import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, + PeerPushCreditStatus, + PurchaseStatus, timestampPreciseToDb, + WalletPeerPushCredit, + WalletPurchase, } from "./db-common.js"; import { SQLITE_BASELINE_SCHEMA } from "./db-sqlite-schema.js"; import { convertWalletDb } from "./db-converter.js"; @@ -138,6 +142,82 @@ test("converter: discards the legacy exchange update retry counter", async () => await dst.close(); }); +test("converter: canonicalises a legacy purchase with empty exchanges", async () => { + // Old IndexedDB wallets could persist an explicit empty array here. The + // native representation uses a junction table, where no rows means the + // optional field is absent, so migration must accept this canonicalisation. + const purchase: WalletPurchase = { + proposalId: "empty-exchanges", + orderId: "order-empty-exchanges", + merchantBaseUrl: "https://merchant.example/", + claimToken: undefined, + downloadSessionId: undefined, + repurchaseProposalId: undefined, + purchaseStatus: PurchaseStatus.PendingDownloadingProposal, + noncePriv: encodeCrock(getRandomBytes(32)), + noncePub: encodeCrock(getRandomBytes(32)), + secretSeed: undefined, + download: undefined, + payInfo: undefined, + exchanges: [], + timestampFirstSuccessfulPay: undefined, + merchantPaySig: undefined, + posConfirmation: undefined, + shared: false, + timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + timestampAccept: undefined, + timestampLastRefundStatus: undefined, + lastSessionId: undefined, + autoRefundDeadline: undefined, + refundAmountAwaiting: undefined, + }; + const src = await makeIdbRunner(); + await src.runReadWriteTx((tx) => tx.upsertPurchase(purchase)); + + const dst = await makeSqliteRunner(); + const report = await convertWalletDb(src, dst); + assert.strictEqual(report.copied.purchases, 1); + const migrated = await dst.runReadWriteTx((tx) => + tx.getPurchase(purchase.proposalId), + ); + assert.ok(migrated); + assert.ok(!("exchanges" in migrated)); + + await src.close(); + await dst.close(); +}); + +test("converter: canonicalises a lowercase peer-push contract private key", async () => { + // Crockford encoding is case-insensitive. A legacy IndexedDB record with + // lowercase data must compare equal to the native BLOB's uppercase form. + const contractPriv = encodeCrock(getRandomBytes(32)).toLowerCase(); + const credit: WalletPeerPushCredit = { + peerPushCreditId: "lowercase-contract-private-key", + exchangeBaseUrl: "https://exchange.example/", + currency: "TESTKUDOS", + pursePub: encodeCrock(getRandomBytes(32)), + mergePriv: encodeCrock(getRandomBytes(32)), + contractPriv, + timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + estimatedAmountEffective: "TESTKUDOS:1", + contractTermsHash: encodeCrock(getRandomBytes(64)), + status: PeerPushCreditStatus.PendingMerge, + withdrawalGroupId: undefined, + }; + const src = await makeIdbRunner(); + await src.runReadWriteTx((tx) => tx.upsertPeerPushCredit(credit)); + + const dst = await makeSqliteRunner(); + await convertWalletDb(src, dst); + const migrated = await dst.runReadWriteTx((tx) => + tx.getPeerPushCredit(credit.peerPushCreditId), + ); + assert.strictEqual(migrated?.contractPriv, contractPriv.toUpperCase()); + + await src.close(); + 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, diff --git a/packages/taler-wallet-core/src/db-converter.ts b/packages/taler-wallet-core/src/db-converter.ts @@ -33,7 +33,7 @@ import { Logger } from "@gnu-taler/taler-util"; -import { WalletReserve } from "./db-common.js"; +import { WalletPurchase, WalletReserve } from "./db-common.js"; import { WalletDbHandle } from "./dbtx-handle.js"; import { WalletDbTransaction } from "./dbtx.js"; @@ -44,8 +44,8 @@ const logger = new Logger("db-converter.ts"); * * `read` enumerates every record through the DAL; `write` stores one. * `normalize` is applied before the verification comparison, for the few - * stores where the destination legitimately assigns a fresh value (the - * global-currency ids, which nothing else references). + * stores where the destination deliberately canonicalises a representation + * (for example global-currency ids, which nothing else references). */ interface CopyStep { name: string; @@ -112,6 +112,31 @@ function stripLegacy( } /** + * The IndexedDB schema allowed old wallet versions to persist an empty + * `exchanges` array. The native schema represents that field with rows in + * `purchase_exchanges`, so both an absent field and an empty array have zero + * rows and consequently read back as absent. The field is optional and the + * wallet itself does not create empty arrays, making absent the native + * canonical form. + */ +function normalizePurchase(rec: WalletPurchase): unknown { + if (rec.exchanges?.length !== 0) { + return rec; + } + const { exchanges: _exchanges, ...rest } = rec; + return rest; +} + +/** + * Crockford encoding is case-insensitive. IndexedDB records from an older + * wallet may contain this capability key in lowercase, whereas the native + * BLOB representation always decodes it in its canonical uppercase form. + */ +function normalizeContractPriv<T extends { contractPriv: string }>(rec: T): T { + return { ...rec, contractPriv: rec.contractPriv.toUpperCase() }; +} + +/** * The reserves, one per reserve public key. * * The IndexedDB store is keyed by an auto-increment row id and has no unique @@ -302,6 +327,7 @@ const COPY_PLAN: CopyStep[][] = [ "purchases", (tx) => tx.listAllPurchases(), (tx, r) => tx.upsertPurchase(r), + normalizePurchase, ), step( "refreshGroups", @@ -399,21 +425,25 @@ const COPY_PLAN: CopyStep[][] = [ "peerPushDebit", (tx) => tx.listAllPeerPushDebits(), (tx, r) => tx.upsertPeerPushDebit(r), + normalizeContractPriv, ), step( "peerPushCredit", (tx) => tx.listAllPeerPushCredits(), (tx, r) => tx.upsertPeerPushCredit(r), + normalizeContractPriv, ), step( "peerPullDebit", (tx) => tx.listAllPeerPullDebits(), (tx, r) => tx.upsertPeerPullDebit(r), + normalizeContractPriv, ), step( "peerPullCredit", (tx) => tx.listAllPeerPullCredits(), (tx, r) => tx.upsertPeerPullCredit(r), + normalizeContractPriv, ), step( "donationSummaries",