taler-typescript-core

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

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:
Apackages/taler-wallet-core/src/db-common.test.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-common.ts | 10++++++++--
Mpackages/taler-wallet-core/src/db-converter.test.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-converter.ts | 9++++++---
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 20--------------------
Mpackages/taler-wallet-core/src/dbtx.test.ts | 25+++++++++++++++++++++++++
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(); + } + }); +}