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:
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",