commit a446ebe2a3a2418210a2c2f24ef2c01a70f489a7
parent 2e7f87c83da85b183ad8005c09ac80d30b1555f8
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:51 +0200
wallet-core: migrate legacy peer-payment duplicates
Diffstat:
2 files changed, 206 insertions(+), 1 deletion(-)
diff --git a/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts b/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts
@@ -193,6 +193,161 @@ test("wallet query migration backfills availability and creates indexes", async
}
});
+test("peer capability migration deterministically removes legacy duplicates", async () => {
+ const { path, cleanup } = withTempDb();
+ try {
+ let db = await openRaw(path);
+ await initSqliteWalletDb(
+ db,
+ schemaMigrations.filter((x) => x.version < 10),
+ );
+
+ const exchange = "https://migration.example/";
+ const sharedPushContract = new Uint8Array([1]);
+ const insertPush = await db.prepare(
+ `INSERT INTO peer_push_credit (
+ peer_push_credit_id, exchange_base_url, purse_pub, merge_priv,
+ contract_priv, timestamp, estimated_amount_effective,
+ contract_terms_hash, status
+ ) VALUES ($id, $exchange, $purse, $merge, $contract, $timestamp,
+ 'TESTKUDOS:1', $hash, 0)`,
+ );
+ await insertPush.run({
+ id: "push-older",
+ exchange,
+ purse: new Uint8Array([2]),
+ merge: new Uint8Array([3]),
+ contract: sharedPushContract,
+ timestamp: 100,
+ hash: new Uint8Array([4]),
+ });
+ await insertPush.run({
+ id: "push-newer",
+ exchange,
+ purse: new Uint8Array([5]),
+ merge: new Uint8Array([6]),
+ contract: sharedPushContract,
+ timestamp: 200,
+ hash: new Uint8Array([7]),
+ });
+
+ const sharedPullContract = new Uint8Array([8]);
+ const insertPull = await db.prepare(
+ `INSERT INTO peer_pull_debit (
+ peer_pull_debit_id, purse_pub, exchange_base_url, amount,
+ contract_terms_hash, timestamp_created, contract_priv, status,
+ total_cost_estimated
+ ) VALUES ($id, $purse, $exchange, 'TESTKUDOS:1', $hash, $timestamp,
+ $contract, 0, 'TESTKUDOS:1')`,
+ );
+ // Equal timestamps deliberately exercise the stable primary-key tie-break.
+ await insertPull.run({
+ id: "pull-z",
+ purse: new Uint8Array([9]),
+ exchange,
+ hash: new Uint8Array([10]),
+ timestamp: 300,
+ contract: sharedPullContract,
+ });
+ await insertPull.run({
+ id: "pull-a",
+ purse: new Uint8Array([11]),
+ exchange,
+ hash: new Uint8Array([12]),
+ timestamp: 300,
+ contract: sharedPullContract,
+ });
+
+ const insertMeta = await db.prepare(
+ `INSERT INTO transactions_meta
+ (transaction_id, timestamp, status, currency, exchanges)
+ VALUES ($id, $timestamp, 0, 'TESTKUDOS', '[]')`,
+ );
+ const insertLocalId = await db.prepare(
+ `INSERT INTO transaction_local_ids
+ (transaction_id, transaction_type, local_ident)
+ VALUES ($id, $type, $localId)`,
+ );
+ const insertRetry = await db.prepare(
+ `INSERT INTO operation_retries (id, retry_info)
+ VALUES ($id, '{}')`,
+ );
+ for (const [type, id, timestamp, localId] of [
+ ["peer-push-credit", "push-older", 100, 1],
+ ["peer-push-credit", "push-newer", 200, 2],
+ ["peer-pull-debit", "pull-z", 300, 3],
+ ["peer-pull-debit", "pull-a", 300, 4],
+ ] as const) {
+ await insertMeta.run({
+ id: `txn:${type}:${id}`,
+ timestamp,
+ });
+ await insertLocalId.run({
+ id: `txn:${type}:${id}`,
+ type,
+ localId,
+ });
+ await insertRetry.run({ id: `${type}:${id}` });
+ }
+ await db.close();
+
+ db = await openRaw(path);
+ await initSqliteWalletDb(db);
+
+ assert.deepStrictEqual(
+ await queryAll(
+ db,
+ "SELECT peer_push_credit_id FROM peer_push_credit ORDER BY peer_push_credit_id",
+ ),
+ [{ peer_push_credit_id: "push-older" }],
+ );
+ assert.deepStrictEqual(
+ await queryAll(
+ db,
+ "SELECT peer_pull_debit_id FROM peer_pull_debit ORDER BY peer_pull_debit_id",
+ ),
+ [{ peer_pull_debit_id: "pull-a" }],
+ );
+ const applied = await queryAll(
+ db,
+ "SELECT name FROM schema_migrations WHERE version = 10",
+ );
+ assert.deepStrictEqual(applied, [
+ { name: "unique-peer-payment-capabilities" },
+ ]);
+ assert.deepStrictEqual(
+ await queryAll(
+ db,
+ "SELECT transaction_id FROM transactions_meta ORDER BY transaction_id",
+ ),
+ [
+ { transaction_id: "txn:peer-pull-debit:pull-a" },
+ { transaction_id: "txn:peer-push-credit:push-older" },
+ ],
+ "metadata for discarded duplicate transactions must be removed",
+ );
+ assert.deepStrictEqual(
+ await queryAll(
+ db,
+ "SELECT transaction_id FROM transaction_local_ids ORDER BY transaction_id",
+ ),
+ [
+ { transaction_id: "txn:peer-pull-debit:pull-a" },
+ { transaction_id: "txn:peer-push-credit:push-older" },
+ ],
+ "local identifiers for discarded duplicates must be removed",
+ );
+ assert.deepStrictEqual(
+ await queryAll(db, "SELECT id FROM operation_retries ORDER BY id"),
+ [{ id: "peer-pull-debit:pull-a" }, { id: "peer-push-credit:push-older" }],
+ "retry records for discarded duplicates must be removed",
+ );
+ await db.close();
+ } finally {
+ cleanup();
+ }
+});
+
test("a migration already recorded is not applied twice", async () => {
const { path, cleanup } = withTempDb();
try {
@@ -350,7 +505,10 @@ test("a mismatched schema migration name is rejected", async () => {
await db.close();
db = await openRaw(path);
- await assert.rejects(initSqliteWalletDb(db), /expected wallet-query-indexes/);
+ await assert.rejects(
+ initSqliteWalletDb(db),
+ /expected wallet-query-indexes/,
+ );
const rows = await queryAll(
db,
"SELECT name FROM schema_migrations WHERE version = 9",
diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts
@@ -1292,6 +1292,22 @@ CREATE UNIQUE INDEX IF NOT EXISTS refund_items_by_coin_and_rtxid
ON refund_items (coin_pub, rtxid);
`;
+const legacyPeerPushCreditDuplicate =
+ "EXISTS (SELECT 1 FROM peer_push_credit AS canonical" +
+ " WHERE canonical.exchange_base_url = duplicate.exchange_base_url" +
+ " AND canonical.contract_priv = duplicate.contract_priv" +
+ " AND (canonical.timestamp < duplicate.timestamp" +
+ " OR (canonical.timestamp = duplicate.timestamp" +
+ " AND canonical.peer_push_credit_id < duplicate.peer_push_credit_id)))";
+
+const legacyPeerPullDebitDuplicate =
+ "EXISTS (SELECT 1 FROM peer_pull_debit AS canonical" +
+ " WHERE canonical.exchange_base_url = duplicate.exchange_base_url" +
+ " AND canonical.contract_priv = duplicate.contract_priv" +
+ " AND (canonical.timestamp_created < duplicate.timestamp_created" +
+ " OR (canonical.timestamp_created = duplicate.timestamp_created" +
+ " AND canonical.peer_pull_debit_id < duplicate.peer_pull_debit_id)))";
+
/**
* Migrations applied on top of the baseline.
*
@@ -1343,8 +1359,39 @@ export const schemaMigrations: SchemaMigration[] = [
version: 10,
name: "unique-peer-payment-capabilities",
statements: [
+ // Older SQLite wallets could commit the same URI twice because these
+ // indexes were not unique. Retain the first-created record (breaking an
+ // equal-timestamp tie by primary key) before strengthening the indexes.
+ `DELETE FROM transactions_meta WHERE transaction_id IN
+ (SELECT 'txn:peer-push-credit:' || duplicate.peer_push_credit_id
+ FROM peer_push_credit AS duplicate
+ WHERE ${legacyPeerPushCreditDuplicate})`,
+ `DELETE FROM transaction_local_ids WHERE transaction_id IN
+ (SELECT 'txn:peer-push-credit:' || duplicate.peer_push_credit_id
+ FROM peer_push_credit AS duplicate
+ WHERE ${legacyPeerPushCreditDuplicate})`,
+ `DELETE FROM operation_retries WHERE id IN
+ (SELECT 'peer-push-credit:' || duplicate.peer_push_credit_id
+ FROM peer_push_credit AS duplicate
+ WHERE ${legacyPeerPushCreditDuplicate})`,
+ `DELETE FROM peer_push_credit AS duplicate
+ WHERE ${legacyPeerPushCreditDuplicate}`,
"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)",
+ `DELETE FROM transactions_meta WHERE transaction_id IN
+ (SELECT 'txn:peer-pull-debit:' || duplicate.peer_pull_debit_id
+ FROM peer_pull_debit AS duplicate
+ WHERE ${legacyPeerPullDebitDuplicate})`,
+ `DELETE FROM transaction_local_ids WHERE transaction_id IN
+ (SELECT 'txn:peer-pull-debit:' || duplicate.peer_pull_debit_id
+ FROM peer_pull_debit AS duplicate
+ WHERE ${legacyPeerPullDebitDuplicate})`,
+ `DELETE FROM operation_retries WHERE id IN
+ (SELECT 'peer-pull-debit:' || duplicate.peer_pull_debit_id
+ FROM peer_pull_debit AS duplicate
+ WHERE ${legacyPeerPullDebitDuplicate})`,
+ `DELETE FROM peer_pull_debit AS duplicate
+ WHERE ${legacyPeerPullDebitDuplicate}`,
"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)",
],