taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/db-sqlite-migrations.test.ts | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
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)", ],