commit 8b928a2b699a99ed856966a2754009826ca62ef9
parent be382b2eabb840f0bfa48cee04206b7c888c0a45
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:42 +0200
wallet-core: preserve SQLite routing and reject incompatible schemas
Diffstat:
4 files changed, 172 insertions(+), 3 deletions(-)
diff --git a/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts b/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts
@@ -28,7 +28,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
-import { SchemaMigration, schemaMigrations } from "./db-sqlite-schema.js";
+import {
+ SchemaMigration,
+ SQLITE_SCHEMA_VERSION,
+ schemaMigrations,
+} from "./db-sqlite-schema.js";
import { initSqliteWalletDb } from "./dbtx-sqlite.js";
/**
@@ -310,3 +314,50 @@ test("migration versions must strictly increase", async () => {
cleanup();
}
});
+
+test("a newer sqlite schema is rejected without being relabeled", async () => {
+ const { path, cleanup } = withTempDb();
+ try {
+ let db = await openRaw(path);
+ await initSqliteWalletDb(db);
+ const futureVersion = SQLITE_SCHEMA_VERSION + 1;
+ await db.exec(`PRAGMA user_version = ${futureVersion}`);
+ await db.close();
+
+ db = await openRaw(path);
+ await assert.rejects(
+ initSqliteWalletDb(db),
+ /newer.*schema|schema.*newer|version/i,
+ );
+ const version = await queryAll(db, "PRAGMA user_version");
+ assert.strictEqual(Number(version[0].user_version), futureVersion);
+ await db.close();
+ } finally {
+ cleanup();
+ }
+});
+
+test("a mismatched schema migration name is rejected", async () => {
+ const { path, cleanup } = withTempDb();
+ try {
+ let db = await openRaw(path);
+ await initSqliteWalletDb(db);
+ await (
+ await db.prepare(
+ "UPDATE schema_migrations SET name = 'impostor' WHERE version = 9",
+ )
+ ).run({});
+ await db.close();
+
+ db = await openRaw(path);
+ await assert.rejects(initSqliteWalletDb(db), /expected wallet-query-indexes/);
+ const rows = await queryAll(
+ db,
+ "SELECT name FROM schema_migrations WHERE version = 9",
+ );
+ assert.strictEqual(rows[0].name, "impostor");
+ await db.close();
+ } finally {
+ cleanup();
+ }
+});
diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts
@@ -85,7 +85,7 @@
*
* Bump this when adding a migration to {@link schemaMigrations}.
*/
-export const SQLITE_SCHEMA_VERSION = 9;
+export const SQLITE_SCHEMA_VERSION = 10;
/**
* Tables of the IndexedDB emulation, children before parents.
@@ -1339,6 +1339,16 @@ export const schemaMigrations: SchemaMigration[] = [
"CREATE INDEX coins_by_master_pub_denom_age_status_pub ON coins (exchange_master_pub, denom_pub_hash, max_age, status, coin_pub)",
],
},
+ {
+ version: 10,
+ name: "unique-peer-payment-capabilities",
+ statements: [
+ "DROP INDEX peer_push_credit_by_exchange_and_contract_priv",
+ "CREATE UNIQUE INDEX peer_push_credit_by_exchange_and_contract_priv ON peer_push_credit (exchange_base_url, contract_priv)",
+ "DROP INDEX peer_pull_debit_by_exchange_and_contract_priv",
+ "CREATE UNIQUE INDEX peer_pull_debit_by_exchange_and_contract_priv ON peer_pull_debit (exchange_base_url, contract_priv)",
+ ],
+ },
];
/** Native tables that contain wallet records (not schema bookkeeping). */
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -802,6 +802,11 @@ export const conformanceCases: ConformanceCase[] = [
);
t.equal(shared[0].denomPubHash, ckh("dph-a"));
t.equal(
+ shared[0].exchangeBaseUrl,
+ "https://e2/",
+ "upserting a denomination through a new URL must update its routing hint",
+ );
+ t.equal(
other.length,
1,
"the same hash under another key must be its own row",
@@ -3762,6 +3767,31 @@ export const conformanceCases: ConformanceCase[] = [
},
{
+ name: "peer push credit: exchange and contract private key are unique",
+ async run(t, runner) {
+ const first = makePeerPushCredit("ppc-unique-1");
+ first.contractPriv = ck("shared-push-contract-priv");
+ const duplicate = makePeerPushCredit("ppc-unique-2");
+ duplicate.contractPriv = first.contractPriv;
+ await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(first));
+ let rejected = false;
+ try {
+ await runner.runReadWriteTx((tx) =>
+ tx.upsertPeerPushCredit(duplicate),
+ );
+ } catch {
+ rejected = true;
+ }
+ t.ok(rejected, "a duplicate payment capability must be rejected");
+ const records = await runner.runReadWriteTx((tx) =>
+ tx.listAllPeerPushCredits(),
+ );
+ t.equal(records.length, 1, "the original payment must be retained");
+ t.equal(records[0].peerPushCreditId, first.peerPushCreditId);
+ },
+ },
+
+ {
name: "peer pull debit: round trip, contract-priv lookup, active range",
async run(t, runner) {
const rec = makePeerPullDebit("ppld-1");
@@ -3800,6 +3830,29 @@ export const conformanceCases: ConformanceCase[] = [
},
{
+ name: "peer pull debit: exchange and contract private key are unique",
+ async run(t, runner) {
+ const first = makePeerPullDebit("ppld-unique-1");
+ first.contractPriv = ck("shared-pull-contract-priv");
+ const duplicate = makePeerPullDebit("ppld-unique-2");
+ duplicate.contractPriv = first.contractPriv;
+ await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(first));
+ let rejected = false;
+ try {
+ await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(duplicate));
+ } catch {
+ rejected = true;
+ }
+ t.ok(rejected, "a duplicate payment capability must be rejected");
+ const records = await runner.runReadWriteTx((tx) =>
+ tx.listAllPeerPullDebits(),
+ );
+ t.equal(records.length, 1, "the original payment must be retained");
+ t.equal(records[0].peerPullDebitId, first.peerPullDebitId);
+ },
+ },
+
+ {
name: "peer pull credit: round trip and active range",
async run(t, runner) {
const rec = makePeerPullCredit("pplc-1");
diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts
@@ -221,6 +221,37 @@ function validateSchemaMigrations(migrations: SchemaMigration[]): void {
}
}
+function validateAppliedSchemaMigrations(
+ applied: ResultRow[],
+ extraMigrations: SchemaMigration[],
+): void {
+ const expected = new Map<number, string>([[1, "baseline"]]);
+ for (const migration of [...schemaMigrations, ...extraMigrations]) {
+ const previous = expected.get(migration.version);
+ if (previous !== undefined && previous !== migration.name) {
+ throw Error(
+ `schema migration ${migration.version} has conflicting names` +
+ ` (${previous} and ${migration.name})`,
+ );
+ }
+ expected.set(migration.version, migration.name);
+ }
+ for (const row of applied) {
+ const version = Number(row.version);
+ const name = String(row.name);
+ const expectedName = expected.get(version);
+ if (expectedName === undefined) {
+ throw Error(`database records unknown schema migration ${version}`);
+ }
+ if (name !== expectedName) {
+ throw Error(
+ `database schema migration ${version} is named ${name},` +
+ ` expected ${expectedName}`,
+ );
+ }
+ }
+}
+
/**
* Open the database and bring its schema up to date.
*
@@ -239,6 +270,28 @@ export async function initSqliteWalletDb(
validateSchemaMigrations(migrations);
await db.exec("PRAGMA foreign_keys = ON");
await db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
+ const versionRows = await (
+ await db.prepare("PRAGMA user_version")
+ ).getAll();
+ const databaseVersion = Number(versionRows[0]?.user_version ?? 0);
+ if (databaseVersion > SQLITE_SCHEMA_VERSION) {
+ throw Error(
+ `database schema version ${databaseVersion} is newer than this wallet` +
+ ` (version ${SQLITE_SCHEMA_VERSION})`,
+ );
+ }
+ const migrationTable = await (
+ await db.prepare(
+ "SELECT 1 AS present FROM sqlite_master" +
+ " WHERE type = 'table' AND name = 'schema_migrations'",
+ )
+ ).getAll();
+ if (migrationTable.length !== 0) {
+ const recorded = await (
+ await db.prepare("SELECT version, name FROM schema_migrations")
+ ).getAll();
+ validateAppliedSchemaMigrations(recorded, migrations);
+ }
// WAL: readers do not block the writer, and a commit appends to the log
// instead of fsyncing the whole database.
//
@@ -276,8 +329,9 @@ export async function initSqliteWalletDb(
});
const applied = await (
- await db.prepare("SELECT version FROM schema_migrations")
+ await db.prepare("SELECT version, name FROM schema_migrations")
).getAll();
+ validateAppliedSchemaMigrations(applied, migrations);
const have = new Set(applied.map((r) => Number(r.version)));
for (const mig of migrations) {
@@ -896,6 +950,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
$verification_status
)
ON CONFLICT(exchange_master_pub, denom_pub_hash) DO UPDATE SET
+ exchange_base_url = excluded.exchange_base_url,
denom_pub = excluded.denom_pub,
exchange_master_pub = excluded.exchange_master_pub,
currency = excluded.currency,