commit db1c9223ceaf07d4d7e2542644679f626c523972
parent 7a7361d34e3e97c5c0e574713da11f7ff764b44b
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:42 +0200
wallet-core: round-trip never timestamps and orphan coins
Diffstat:
6 files changed, 126 insertions(+), 25 deletions(-)
diff --git a/packages/taler-wallet-core/src/db-common.test.ts b/packages/taler-wallet-core/src/db-common.test.ts
@@ -0,0 +1,44 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerProtocolTimestamp } from "@gnu-taler/taler-util";
+import assert from "node:assert";
+import { test } from "node:test";
+import {
+ DbPreciseTimestamp,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db-common.js";
+
+test("database timestamps preserve the never sentinel", () => {
+ const precise = timestampPreciseFromDb(
+ timestampPreciseToDb({ t_s: "never" }),
+ );
+ const protocol = timestampProtocolFromDb(
+ timestampProtocolToDb(TalerProtocolTimestamp.never()),
+ );
+ assert.strictEqual(precise.t_s, "never");
+ assert.strictEqual(protocol.t_s, "never");
+});
+
+test("an optional precise timestamp preserves the Unix epoch", () => {
+ const epoch = timestampOptionalPreciseFromDb(0 as DbPreciseTimestamp);
+ assert.ok(epoch);
+ assert.strictEqual(epoch.t_s, 0);
+});
diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts
@@ -83,16 +83,19 @@ const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER;
export function timestampPreciseFromDb(
dbTs: DbPreciseTimestamp,
): TalerPreciseTimestamp {
+ if (dbTs >= DB_TIMESTAMP_FOREVER) {
+ return { t_s: "never" };
+ }
return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
}
export function timestampOptionalPreciseFromDb(
dbTs: DbPreciseTimestamp | undefined,
): TalerPreciseTimestamp | undefined {
- if (!dbTs) {
+ if (dbTs == null) {
return undefined;
}
- return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
+ return timestampPreciseFromDb(dbTs);
}
export function timestampPreciseToDb(
@@ -123,6 +126,9 @@ export function timestampProtocolToDb(
export function timestampProtocolFromDb(
stamp: DbProtocolTimestamp,
): TalerProtocolTimestamp {
+ if (stamp >= DB_TIMESTAMP_FOREVER) {
+ return TalerProtocolTimestamp.never();
+ }
return TalerProtocolTimestamp.fromSeconds(Math.floor(stamp / 1000000));
}
diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts
@@ -28,7 +28,9 @@ import assert from "node:assert";
import { test } from "node:test";
import {
+ CoinStatus,
DatabaseMaintenanceProgressNotification,
+ DenomKeyType,
encodeCrock,
getRandomBytes,
NotificationType,
@@ -37,12 +39,14 @@ import {
} from "@gnu-taler/taler-util";
import {
+ CoinSourceType,
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
PeerPushCreditStatus,
PurchaseStatus,
ReserveRecordStatus,
timestampPreciseToDb,
+ WalletCoin,
WalletPeerPushCredit,
WalletPurchase,
} from "./db-common.js";
@@ -68,6 +72,45 @@ const quietAsserts: ConformanceAsserts = {
},
};
+test("converter: preserves a legacy orphan coin without a master key", async () => {
+ const src = await makeIdbRunner();
+ const dst = await makeSqliteRunner();
+ const key = (): string => encodeCrock(getRandomBytes(32));
+ const hash = (): string => encodeCrock(getRandomBytes(64));
+ const coin: WalletCoin = {
+ coinPub: key(),
+ coinPriv: key(),
+ exchangeBaseUrl: "https://orphan.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(),
+ },
+ };
+ delete (coin as any).exchangeMasterPub;
+ await src.runReadWriteTx((tx) => tx.upsertCoin(coin));
+
+ try {
+ await convertWalletDb(src, dst);
+ const migrated = await dst.runReadWriteTx((tx) => tx.getCoin(coin.coinPub));
+ assert.ok(migrated);
+ assert.strictEqual(migrated.exchangeMasterPub, "");
+ } finally {
+ await src.close();
+ await dst.close();
+ }
+});
+
test("converter: IndexedDB to sqlite, populated by the conformance corpus", async () => {
const src = await makeIdbRunner();
const progress: WalletNotification[] = [];
diff --git a/packages/taler-wallet-core/src/db-converter.ts b/packages/taler-wallet-core/src/db-converter.ts
@@ -180,9 +180,12 @@ function normalizePlanchet(rec: WalletPlanchet): WalletPlanchet {
function normalizeCoin(rec: WalletCoin): WalletCoin {
const stripped = stripLegacy("coins")!(rec) as WalletCoin;
- return stripped.exchangeWithdrawValues === undefined
- ? ({ ...stripped, exchangeWithdrawValues: rsaWithdrawValues } as WalletCoin)
- : stripped;
+ return {
+ ...stripped,
+ exchangeMasterPub: stripped.exchangeMasterPub ?? "",
+ exchangeWithdrawValues:
+ stripped.exchangeWithdrawValues ?? rsaWithdrawValues,
+ };
}
function normalizeCoinAvailability(
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -1160,26 +1160,6 @@ export const conformanceCases: ConformanceCase[] = [
},
},
- {
- name: "notification sink exceptions do not fail committed work",
- async run(t, runner) {
- runner.setNotificationSink(() => {
- throw Error("host notification failure");
- });
- await runner.runReadWriteTx(async (tx) => {
- await tx.upsertConfig({
- key: ConfigRecordKey.TestLoopTx,
- value: 123,
- });
- tx.notify({ type: "balance-change" } as any);
- });
- const record = await runner.runReadWriteTx((tx) =>
- tx.getConfig(ConfigRecordKey.TestLoopTx),
- );
- t.equal(record?.value, 123);
- },
- },
-
// ------------------------------------------------------- delete semantics
{
diff --git a/packages/taler-wallet-core/src/dbtx.test.ts b/packages/taler-wallet-core/src/dbtx.test.ts
@@ -40,6 +40,7 @@ import {
SqliteTxControl,
SqliteWalletTransaction,
} from "./dbtx-sqlite.js";
+import { ConfigRecordKey } from "./db-common.js";
const logger = new Logger("dbtx.test.ts");
@@ -82,3 +83,27 @@ for (const makeRunner of runnerFactories) {
});
}
}
+
+for (const makeRunner of runnerFactories) {
+ test(`dbtx ${makeRunner.name}: notification sink exceptions do not fail committed work`, async () => {
+ const runner = await makeRunner();
+ try {
+ runner.setNotificationSink(() => {
+ throw Error("host notification failure");
+ });
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertConfig({
+ key: ConfigRecordKey.TestLoopTx,
+ value: 123,
+ });
+ tx.notify({ type: "balance-change" } as any);
+ });
+ const record = await runner.runReadWriteTx((tx) =>
+ tx.getConfig(ConfigRecordKey.TestLoopTx),
+ );
+ assert.strictEqual(record?.value, 123);
+ } finally {
+ await runner.close();
+ }
+ });
+}