commit 1608ac45588ad17d2d558040c266af95f651e817 parent 29c82def69b4b07618e759a8bf40f8fecf7bc043 Author: Florian Dold <dold@taler.net> Date: Thu, 3 Sep 2026 01:26:19 +0200 wallet-core: show old-key transactions in legacy exchange scopes Diffstat:
22 files changed, 571 insertions(+), 26 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts b/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts @@ -157,6 +157,32 @@ export async function runExchangeMasterPubChangeTest( change.supersededMasterPub, ); + if (legacy.scopeInfo.type !== ScopeType.ExchangeLegacyKeys) { + throw Error("legacy balance has the wrong scope type"); + } + const legacyTransactions = await walletClient.call( + WalletApiOperation.GetTransactions, + { scopeInfo: legacy.scopeInfo }, + ); + const oldWithdrawal = legacyTransactions.transactions.find( + (tx) => tx.type === TransactionType.Withdrawal, + ); + t.assertTrue(oldWithdrawal != null); + t.assertTrue( + oldWithdrawal.scopes.some( + (scope) => + scope.type === ScopeType.ExchangeLegacyKeys && + scope.masterPub === change.supersededMasterPub, + ), + ); + // URL-based transaction scopes remain deliberately conservative. + t.assertTrue( + oldWithdrawal.scopes.some( + (scope) => + scope.type === ScopeType.Exchange && scope.url === exchange.baseUrl, + ), + ); + const txs = await walletClient.call(WalletApiOperation.GetTransactions, {}); const denomLoss = txs.transactions.filter( (tx) => tx.type === TransactionType.DenomLoss, diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -870,6 +870,8 @@ export interface TransactionContext { * in a state that allows the user to delete. */ userDeleteTransaction(): Promise<void>; + /** Refresh this transaction's entry in the materialized history view. */ + updateTransactionMeta(tx: WalletDbTransaction): Promise<void>; lookupFullTransaction( tx: WalletDbTransaction, args?: LookupFullTransactionOpts, diff --git a/packages/taler-wallet-core/src/db/indexeddb/transaction.ts b/packages/taler-wallet-core/src/db/indexeddb/transaction.ts @@ -340,6 +340,7 @@ export class IdbWalletTransaction implements WalletDbTransaction { timestamp: rec.timestamp, status: rec.status, exchanges: rec.exchanges, + legacyScopes: rec.legacyScopes ?? [], currency: rec.currency, }); } diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -23,6 +23,7 @@ import { TokenUseSig, MerchantContractTokenDetails, ScopeInfo, + ScopeInfoExchangeLegacyKeys, TalerErrorDetail, DenominationPubKey, Amounts, @@ -94,6 +95,14 @@ export interface WalletTransactionMeta { */ exchanges: string[]; + /** + * Exchange-key scopes proven by the coins involved in this transaction. + * + * This is part of the materialized transaction view so listing transaction + * history never needs to hydrate coins merely to classify a key rotation. + */ + legacyScopes?: ScopeInfoExchangeLegacyKeys[]; + currency: string; } @@ -1342,6 +1351,12 @@ export interface WalletDenomLossEvent { denomLossEventId: string; currency: string; denomPubHashes: string[]; + /** + * Master key whose denominations caused this event. + * + * Missing only on records created before this provenance was persisted. + */ + exchangeMasterPub?: string; status: DenomLossStatus; timestampCreated: DbPreciseTimestamp; amount: string; diff --git a/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts b/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts @@ -276,6 +276,55 @@ test("legacy withdrawal migration adds and backfills its marker", async () => { } }); +test("transaction legacy-scope migration adds an empty JSON default", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((x) => x.version < 14), + ); + let columns = await queryAll(db, "PRAGMA table_info(transactions_meta)"); + assert.ok(!columns.some((x) => x.name === "legacy_scopes")); + await db.close(); + + db = await openRaw(path); + await initSqliteWalletDb(db); + columns = await queryAll(db, "PRAGMA table_info(transactions_meta)"); + const legacyScopes = columns.find((x) => x.name === "legacy_scopes"); + assert.ok(legacyScopes); + assert.strictEqual(legacyScopes.notnull, 1); + assert.strictEqual(String(legacyScopes.dflt_value), "'[]'"); + await db.close(); + } finally { + cleanup(); + } +}); + +test("denomination-loss migration adds master-key provenance", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((x) => x.version < 15), + ); + let columns = await queryAll(db, "PRAGMA table_info(denom_loss_events)"); + assert.ok(!columns.some((x) => x.name === "exchange_master_pub")); + await db.close(); + + db = await openRaw(path); + await initSqliteWalletDb(db); + columns = await queryAll(db, "PRAGMA table_info(denom_loss_events)"); + const masterPub = columns.find((x) => x.name === "exchange_master_pub"); + assert.ok(masterPub); + assert.strictEqual(masterPub.notnull, 0); + await db.close(); + } finally { + cleanup(); + } +}); + test("peer capability migration deterministically removes legacy duplicates", async () => { const { path, cleanup } = withTempDb(); try { 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 = 13; +export const SQLITE_SCHEMA_VERSION = 15; /** * Tables of the IndexedDB emulation, children before parents. @@ -191,6 +191,7 @@ export const BLOB_COLUMNS: Readonly<Record<string, readonly string[]>> = { "denom_pub_hash", "exchange_master_pub", ], + denom_loss_events: ["exchange_master_pub"], denomination_families: ["exchange_master_pub"], denominations: ["denom_pub_hash", "exchange_master_pub", "master_sig"], deposit_groups: [ @@ -1412,6 +1413,20 @@ export const schemaMigrations: SchemaMigration[] = [ "ALTER TABLE withdrawal_groups ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0 CHECK (legacy IN (0, 1))", ], }, + { + version: 14, + name: "transaction-legacy-scopes", + statements: [ + "ALTER TABLE transactions_meta ADD COLUMN legacy_scopes TEXT NOT NULL DEFAULT '[]'", + ], + }, + { + version: 15, + name: "denom-loss-master-pub", + statements: [ + "ALTER TABLE denom_loss_events ADD COLUMN exchange_master_pub BLOB", + ], + }, ]; /** Native tables that contain wallet records (not schema bookkeeping). */ diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.ts @@ -2737,6 +2737,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { status: num(row.status), currency: str(row.currency), exchanges: dbToJson(row.exchanges), + legacyScopes: dbToJson(row.legacy_scopes), }; } @@ -2786,19 +2787,21 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.ensureLocalTransactionIdentifier(rec.transactionId); await this.run( `INSERT INTO transactions_meta ( - transaction_id, timestamp, status, currency, exchanges - ) VALUES ($id, $ts, $status, $cur, $ex) + transaction_id, timestamp, status, currency, exchanges, legacy_scopes + ) VALUES ($id, $ts, $status, $cur, $ex, $legacyScopes) ON CONFLICT(transaction_id) DO UPDATE SET timestamp = excluded.timestamp, status = excluded.status, currency = excluded.currency, - exchanges = excluded.exchanges`, + exchanges = excluded.exchanges, + legacy_scopes = excluded.legacy_scopes`, { id: rec.transactionId, ts: rec.timestamp, status: rec.status, cur: rec.currency, ex: jsonToDb(rec.exchanges), + legacyScopes: jsonToDb(rec.legacyScopes ?? []), }, ); } @@ -3805,6 +3808,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { amount: str(row.amount), eventType: str(row.event_type) as DenomLossEventType, exchangeBaseUrl: str(row.exchange_base_url), + exchangeMasterPub: dbToOptCrock(row.exchange_master_pub), }; } @@ -3822,8 +3826,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.run( `INSERT INTO denom_loss_events ( denom_loss_event_id, currency, denom_pub_hashes, status, - timestamp_created, amount, event_type, exchange_base_url - ) VALUES ($id, $cur, $dph, $status, $created, $amt, $et, $url) + timestamp_created, amount, event_type, exchange_base_url, + exchange_master_pub + ) VALUES ($id, $cur, $dph, $status, $created, $amt, $et, $url, $mpub) ON CONFLICT(denom_loss_event_id) DO UPDATE SET currency = excluded.currency, denom_pub_hashes = excluded.denom_pub_hashes, @@ -3831,7 +3836,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { timestamp_created = excluded.timestamp_created, amount = excluded.amount, event_type = excluded.event_type, - exchange_base_url = excluded.exchange_base_url`, + exchange_base_url = excluded.exchange_base_url, + exchange_master_pub = excluded.exchange_master_pub`, { id: rec.denomLossEventId, cur: rec.currency, @@ -3841,6 +3847,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { amt: rec.amount, et: rec.eventType, url: rec.exchangeBaseUrl, + mpub: optCrockToDb(rec.exchangeMasterPub), }, ); } diff --git a/packages/taler-wallet-core/src/db/testing/conformance-cases.ts b/packages/taler-wallet-core/src/db/testing/conformance-cases.ts @@ -27,6 +27,7 @@ import { AmountString, CoinStatus, decodeCrock, + DenomLossEventType, encodeCrock, MerchantContractTokenKind, RefreshReason, @@ -44,11 +45,13 @@ import { DbPreciseTimestamp, DbProtocolTimestamp, DenominationVerificationStatus, + DenomLossStatus, RefundGroupStatus, RefundItemStatus, timestampPreciseToDb, timestampProtocolToDb, WalletDenomination, + WalletDenomLossEvent, WalletRefundGroup, WalletRefundItem, WalletOperationRetry, @@ -2769,6 +2772,28 @@ export const conformanceCases: ConformanceCase[] = [ t.equal(other.length, 1); }, }, + + { + name: "denomination loss: master-key provenance round trips", + async run(t, runner) { + const rec: WalletDenomLossEvent = { + denomLossEventId: "denom-loss-1", + currency: "TESTKUDOS", + denomPubHashes: [ckh("lost-denomination")], + exchangeMasterPub: ck("loss-master-pub"), + status: DenomLossStatus.Done, + timestampCreated: tsPrecise(500), + amount: "TESTKUDOS:1", + eventType: DenomLossEventType.DenomExpired, + exchangeBaseUrl: "https://loss.example/", + }; + await runner.runReadWriteTx((tx) => tx.upsertDenomLossEvent(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getDenomLossEvent(rec.denomLossEventId), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); + }, + }, // -------------------------------------------------- withdrawal groups { @@ -3087,6 +3112,14 @@ export const conformanceCases: ConformanceCase[] = [ status: WithdrawalGroupStatus.PendingRegisteringBank, currency: "TESTKUDOS", exchanges: ["https://e1/", "https://e2/"], + legacyScopes: [ + { + type: ScopeType.ExchangeLegacyKeys, + currency: "TESTKUDOS", + url: "https://e1/", + masterPub: ck("legacy-master"), + }, + ], }; await runner.runReadWriteTx((tx) => tx.upsertTransactionMeta(rec)); let got = await runner.runReadWriteTx((tx) => diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -120,6 +120,7 @@ import { fetchFreshExchangeWithRetryNow, findExchangeWireFee, getExchangeDetailsInTx, + getLegacyScopesForTransaction, getScopeForAllExchanges, markExchangeUsed, requireExchangeCoinUseConfirmedOrThrow, @@ -445,12 +446,18 @@ export class DepositTransactionContext implements TransactionContext { await tx.deleteTransactionMeta(this.transactionId); return; } + const exchanges = Object.keys(depositRec.infoPerExchange ?? {}); await tx.upsertTransactionMeta({ transactionId: this.transactionId, status: depositRec.operationStatus, timestamp: depositRec.timestampCreated, currency: depositRec.currency, - exchanges: Object.keys(depositRec.infoPerExchange ?? {}), + exchanges, + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: depositRec.currency, + exchanges, + coinPubs: depositRec.payCoinSelection?.coinPubs, + }), }); } diff --git a/packages/taler-wallet-core/src/exchanges.test.ts b/packages/taler-wallet-core/src/exchanges.test.ts @@ -16,7 +16,7 @@ import assert from "node:assert"; import { test } from "node:test"; -import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { ScopeType, TalerErrorCode } from "@gnu-taler/taler-util"; import { CoinSourceType, WalletCoin, @@ -24,10 +24,13 @@ import { WalletWithdrawalGroup, } from "./db/records.js"; import { WalletDbTransaction } from "./db/transaction.js"; +import { WalletExecutionContext } from "./wallet.js"; import { filterCoinsByExchangeMasterPub, + getLegacyScopesForTransaction, getLegacyMasterPubs, makeWireAccountValidationRequest, + purgeExchangeLegacyKeys, purgeExchangeLegacyKeysInTx, } from "./exchanges.js"; @@ -98,6 +101,160 @@ test("legacy exchange keys are sorted, deduplicated, and exclude the current key ); }); +test("transaction materialization skips coin reads without historical keys", async () => { + const exchangeBaseUrl = "https://exchange.example/"; + const tx = { + getExchange: async () => ({ + detailsPointer: { + currency: "TESTKUDOS", + masterPublicKey: "current-master", + }, + }), + listExchangeDetailsByBaseUrl: async () => [ + { currency: "TESTKUDOS", masterPublicKey: "current-master" }, + ], + getCoinsByPubs: async () => { + throw Error("coin store must not be read"); + }, + } as unknown as WalletDbTransaction; + + assert.deepStrictEqual( + await getLegacyScopesForTransaction(tx, { + currency: "TESTKUDOS", + exchanges: [exchangeBaseUrl], + coinPubs: ["coin"], + }), + [], + ); +}); + +test("transaction materialization records proven legacy keys once", async () => { + const exchangeBaseUrl = "https://exchange.example/"; + const tx = { + getExchange: async () => ({ + detailsPointer: { + currency: "TESTKUDOS", + masterPublicKey: "current-master", + }, + }), + listExchangeDetailsByBaseUrl: async () => [ + { currency: "TESTKUDOS", masterPublicKey: "current-master" }, + { currency: "TESTKUDOS", masterPublicKey: "legacy-master" }, + ], + getCoinsByPubs: async () => [ + { + coinPub: "old-one", + exchangeBaseUrl, + exchangeMasterPub: "legacy-master", + }, + { + coinPub: "old-two", + exchangeBaseUrl, + exchangeMasterPub: "legacy-master", + }, + { + coinPub: "new", + exchangeBaseUrl, + exchangeMasterPub: "current-master", + }, + ], + } as unknown as WalletDbTransaction; + + assert.deepStrictEqual( + await getLegacyScopesForTransaction(tx, { + currency: "TESTKUDOS", + exchanges: [exchangeBaseUrl], + coinPubs: ["old-one", "old-two", "new"], + }), + [ + { + type: ScopeType.ExchangeLegacyKeys, + currency: "TESTKUDOS", + url: exchangeBaseUrl, + masterPub: "legacy-master", + }, + ], + ); +}); + +test("transaction materialization treats an old currency as a legacy identity", async () => { + const exchangeBaseUrl = "https://exchange.example/"; + const masterPublicKey = "shared-master"; + const tx = { + getExchange: async () => ({ + detailsPointer: { + currency: "NEWCURRENCY", + masterPublicKey, + }, + }), + listExchangeDetailsByBaseUrl: async () => [ + { currency: "NEWCURRENCY", masterPublicKey }, + { currency: "OLDCURRENCY", masterPublicKey }, + ], + getCoinsByPubs: async () => [ + { + coinPub: "old-currency-coin", + exchangeBaseUrl, + exchangeMasterPub: masterPublicKey, + }, + ], + } as unknown as WalletDbTransaction; + + assert.deepStrictEqual( + await getLegacyScopesForTransaction(tx, { + currency: "OLDCURRENCY", + exchanges: [exchangeBaseUrl], + coinPubs: ["old-currency-coin"], + }), + [ + { + type: ScopeType.ExchangeLegacyKeys, + currency: "OLDCURRENCY", + url: exchangeBaseUrl, + masterPub: masterPublicKey, + }, + ], + ); +}); + +test("denomination-loss scopes only use coins from the event master key", async () => { + const exchangeBaseUrl = "https://exchange.example/"; + const tx = { + getExchange: async () => ({ + detailsPointer: { + currency: "TESTKUDOS", + masterPublicKey: "current-master", + }, + }), + listExchangeDetailsByBaseUrl: async () => [ + { currency: "TESTKUDOS", masterPublicKey: "current-master" }, + { currency: "TESTKUDOS", masterPublicKey: "legacy-master" }, + ], + getCoinsByDenomPubHashes: async () => [ + { + denomPubHash: "shared-denomination", + exchangeBaseUrl, + exchangeMasterPub: "current-master", + }, + { + denomPubHash: "shared-denomination", + exchangeBaseUrl, + exchangeMasterPub: "legacy-master", + }, + ], + } as unknown as WalletDbTransaction; + + assert.deepStrictEqual( + await getLegacyScopesForTransaction(tx, { + currency: "TESTKUDOS", + exchanges: [exchangeBaseUrl], + denomPubHashes: ["shared-denomination"], + exchangeMasterPub: "current-master", + }), + [], + ); +}); + test("purging legacy keys removes key-scoped data and marks source withdrawals", async () => { const exchangeBaseUrl = "https://exchange.example/"; const currentMasterPub = "current-master"; @@ -227,16 +384,24 @@ test("purging legacy keys removes key-scoped data and marks source withdrawals", deleteCurrencyInfo: async (scope: { masterPub: string }) => { deleted.currencyScopes.push(scope.masterPub); }, + listTransactionMetaByStatus: async () => [], notify: (notification: unknown) => notifications.push(notification), } as unknown as WalletDbTransaction; - assert.equal( - await purgeExchangeLegacyKeysInTx(fakeTx, { - exchangeBaseUrl, - currentMasterPub, - }), - true, - ); + let dbTransactionRuns = 0; + const fakeWex = { + runWalletDbTx: async (f: (tx: WalletDbTransaction) => Promise<unknown>) => { + dbTransactionRuns++; + return await f(fakeTx); + }, + ws: { exchangeCache: { clear() {} } }, + taskScheduler: { async reload() {} }, + } as unknown as WalletExecutionContext; + await purgeExchangeLegacyKeys(fakeWex, { + exchangeBaseUrl, + currentMasterPub, + }); + assert.equal(dbTransactionRuns, 1); assert.equal(withdrawal.legacy, true); assert.deepStrictEqual(deleted.coins, ["legacy-coin"]); assert.deepStrictEqual(deleted.availabilities, ["legacy-denom"]); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -80,6 +80,7 @@ import { Recoup, RefreshReason, ScopeInfo, + ScopeInfoExchangeLegacyKeys, ScopeType, StartExchangeWalletKycRequest, TalerError, @@ -195,6 +196,7 @@ import { constructTransactionIdentifier, makeTransactionActionUnsupportedError, rematerializeTransactions, + rematerializeTransactionsForExchange, } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { @@ -430,6 +432,94 @@ export async function getScopeForAllCoins( } /** + * Find legacy exchange-key scopes proven by coins used by a transaction. + * + * The cheap identity-history check comes first. For the overwhelmingly + * common case where an exchange never changed currency or master key, + * materializing transaction metadata does not touch the coin store at all. + */ +export async function getLegacyScopesForTransaction( + tx: WalletDbTransaction, + args: { + currency: string; + exchanges: string[]; + coinPubs?: string[]; + coinSourceTransactionId?: string; + denomPubHashes?: string[]; + exchangeMasterPub?: string; + coins?: WalletCoin[]; + }, +): Promise<ScopeInfoExchangeLegacyKeys[]> { + const legacyKeysByExchange = new Map<string, Set<string>>(); + for (const exchangeBaseUrl of new Set(args.exchanges)) { + const exchange = await tx.getExchange(exchangeBaseUrl); + const currentDetails = exchange?.detailsPointer; + if (!currentDetails) { + continue; + } + const details = await tx.listExchangeDetailsByBaseUrl(exchangeBaseUrl); + const legacyKeys = new Set( + details + .filter( + (detail) => + detail.currency === args.currency && + (detail.currency !== currentDetails.currency || + detail.masterPublicKey !== currentDetails.masterPublicKey), + ) + .map((detail) => detail.masterPublicKey), + ); + if (legacyKeys.size > 0) { + legacyKeysByExchange.set(exchangeBaseUrl, legacyKeys); + } + } + if (legacyKeysByExchange.size === 0) { + return []; + } + + let coins = args.coins ?? []; + if (!args.coins) { + if (args.coinPubs?.length) { + coins = await tx.getCoinsByPubs(args.coinPubs); + } else if (args.coinSourceTransactionId) { + coins = await tx.getCoinsBySourceTransaction( + args.coinSourceTransactionId, + ); + } else if (args.denomPubHashes?.length) { + coins = await tx.getCoinsByDenomPubHashes(args.denomPubHashes); + } + } + const result: ScopeInfoExchangeLegacyKeys[] = []; + const seen = new Set<string>(); + for (const coin of coins) { + if ( + args.exchangeMasterPub !== undefined && + coin.exchangeMasterPub !== args.exchangeMasterPub + ) { + continue; + } + if ( + !legacyKeysByExchange + .get(coin.exchangeBaseUrl) + ?.has(coin.exchangeMasterPub) + ) { + continue; + } + const key = `${coin.exchangeBaseUrl}\0${coin.exchangeMasterPub}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push({ + type: ScopeType.ExchangeLegacyKeys, + currency: args.currency, + url: coin.exchangeBaseUrl, + masterPub: coin.exchangeMasterPub, + }); + } + return result; +} + +/** * Get a list of scope infos applicable to a list of exchanges. */ export async function getScopeForAllExchanges( @@ -3013,6 +3103,10 @@ export async function updateExchangeFromUrlHandler( newDetails.masterPublicKey, ); + if (detailsIncompatible) { + await rematerializeTransactionsForExchange(wex, tx, exchangeBaseUrl); + } + const newExchangeState = getExchangeState(r); tx.notify({ @@ -3294,6 +3388,7 @@ async function handleDenomLoss( amount: amountVanished.toString(), currency, exchangeBaseUrl, + exchangeMasterPub, denomPubHashes: denomsVanished, eventType: DenomLossEventType.DenomVanished, status: DenomLossStatus.Done, @@ -3327,6 +3422,7 @@ async function handleDenomLoss( amount: amountRevoked.toString(), currency, exchangeBaseUrl, + exchangeMasterPub, denomPubHashes: denomsRevoked, eventType: DenomLossEventType.DenomRevoked, status: DenomLossStatus.Done, @@ -3360,6 +3456,7 @@ async function handleDenomLoss( amount: amountUnoffered.toString(), currency, exchangeBaseUrl, + exchangeMasterPub, denomPubHashes: denomsUnoffered, eventType: DenomLossEventType.DenomUnoffered, status: DenomLossStatus.Done, @@ -3393,6 +3490,7 @@ async function handleDenomLoss( amount: amountExpired.toString(), currency, exchangeBaseUrl, + exchangeMasterPub, denomPubHashes: denomsExpired, eventType: DenomLossEventType.DenomExpired, status: DenomLossStatus.Done, @@ -3466,6 +3564,14 @@ export class DenomLossTransactionContext implements TransactionContext { timestamp: denomLossRec.timestampCreated, currency: denomLossRec.currency, exchanges: [denomLossRec.exchangeBaseUrl], + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: denomLossRec.currency, + exchanges: [denomLossRec.exchangeBaseUrl], + denomPubHashes: denomLossRec.exchangeMasterPub + ? denomLossRec.denomPubHashes + : undefined, + exchangeMasterPub: denomLossRec.exchangeMasterPub, + }), }); } @@ -4538,9 +4644,13 @@ export async function purgeExchangeLegacyKeys( wex: WalletExecutionContext, req: PurgeExchangeLegacyKeysRequest, ): Promise<void> { - const changed = await wex.runWalletDbTx(async (tx) => - purgeExchangeLegacyKeysInTx(tx, req), - ); + const changed = await wex.runWalletDbTx(async (tx) => { + const didPurge = await purgeExchangeLegacyKeysInTx(tx, req); + if (didPurge) { + await rematerializeTransactionsForExchange(wex, tx, req.exchangeBaseUrl); + } + return didPurge; + }); if (changed) { wex.ws.exchangeCache.clear(); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -162,6 +162,7 @@ import { WalletDbTransaction } from "./db/transaction.js"; import { acceptDonauBlindSigs, generateDonauPlanchets } from "./donau.js"; import { getExchangeScopeInfoOrUndefined, + getLegacyScopesForTransaction, getScopeForAllCoins, getScopeForAllExchanges, requireExchangeCoinUseConfirmedOrThrow, @@ -337,12 +338,18 @@ export class PayMerchantTransactionContext implements TransactionContext { } } + const exchanges = await computePayMerchantExchangesInTx(tx, purchaseRec); await tx.upsertTransactionMeta({ transactionId: this.transactionId, status: purchaseRec.purchaseStatus, timestamp: purchaseRec.timestamp, currency, - exchanges: purchaseRec.exchanges ?? [], + exchanges, + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency, + exchanges, + coinPubs: purchaseRec.payInfo?.payCoinSelection?.coinPubs, + }), }); } @@ -892,6 +899,11 @@ export class RefundTransactionContext implements TransactionContext { timestamp: refundRec.timestampCreated, currency: Amounts.currencyOf(refundRec.amountEffective), exchanges, + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: Amounts.currencyOf(refundRec.amountEffective), + exchanges, + coinPubs: purchaseRecord?.payInfo?.payCoinSelection?.coinPubs, + }), }); } diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts @@ -77,6 +77,12 @@ test("peer pull-credit actions match the withdrawal handlers", () => { test("aborting a suspended pre-withdrawal pull credit starts cleanup", async () => { let record = makeRecord(PeerPullPaymentCreditStatus.SuspendedCreatePurse); const tx = { + async getExchange() { + return undefined; + }, + async listExchangeDetailsByBaseUrl() { + return []; + }, async getPeerPullCredit(): Promise<WalletPeerPullCredit> { return record; }, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -88,6 +88,7 @@ import { BalanceThresholdCheckResult, checkIncomingAmountLegalUnderKycBalanceThreshold, fetchFreshExchangeWithRetryNow, + getLegacyScopesForTransaction, getPreferredExchangeForCurrency, getScopeForAllExchanges, handleStartExchangeWalletKyc, @@ -165,6 +166,16 @@ export class PeerPullCreditTransactionContext implements TransactionContext { await tx.upsertTransactionMeta({ currency: Amounts.currencyOf(rec.estimatedAmountEffective), exchanges: [rec.exchangeBaseUrl], + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: Amounts.currencyOf(rec.estimatedAmountEffective), + exchanges: [rec.exchangeBaseUrl], + coinSourceTransactionId: rec.withdrawalGroupId + ? constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: rec.withdrawalGroupId, + }) + : undefined, + }), status: rec.status, timestamp: rec.mergeTimestamp, transactionId: this.transactionId, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -91,6 +91,7 @@ import { } from "./db/records.js"; import { getExchangeScopeInfo, + getLegacyScopesForTransaction, getScopeForAllExchanges, requireExchangeCoinUseConfirmedOrThrow, } from "./exchanges.js"; @@ -325,6 +326,11 @@ export class PeerPullDebitTransactionContext implements TransactionContext { await tx.upsertTransactionMeta({ currency: Amounts.currencyOf(rec.amount), exchanges: [rec.exchangeBaseUrl], + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: Amounts.currencyOf(rec.amount), + exchanges: [rec.exchangeBaseUrl], + coinPubs: rec.coinSel?.coinPubs, + }), status: rec.status, timestamp: rec.timestampCreated, transactionId: this.transactionId, diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -86,6 +86,7 @@ import { checkIncomingAmountLegalUnderKycBalanceThreshold, fetchFreshExchangeWithRetryNow, getExchangeScopeInfo, + getLegacyScopesForTransaction, getScopeForAllExchanges, handleStartExchangeWalletKyc, } from "./exchanges.js"; @@ -153,6 +154,16 @@ export class PeerPushCreditTransactionContext implements TransactionContext { await tx.upsertTransactionMeta({ currency: Amounts.currencyOf(rec.estimatedAmountEffective), exchanges: [rec.exchangeBaseUrl], + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: Amounts.currencyOf(rec.estimatedAmountEffective), + exchanges: [rec.exchangeBaseUrl], + coinSourceTransactionId: rec.withdrawalGroupId + ? constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: rec.withdrawalGroupId, + }) + : undefined, + }), status: rec.status, timestamp: rec.timestamp, transactionId: this.transactionId, diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts @@ -81,6 +81,12 @@ test("peer push debit metadata logging excludes private capabilities", async () timestampCreated: 1, }; const tx = { + async getExchange() { + return undefined; + }, + async listExchangeDetailsByBaseUrl() { + return []; + }, async getPeerPushDebit() { return rec; }, @@ -131,6 +137,12 @@ async function processOfflinePushDebit( timestampCreated: 1 as any, }; const tx = { + async getExchange() { + return undefined; + }, + async listExchangeDetailsByBaseUrl() { + return []; + }, async getPeerPushDebit(): Promise<WalletPeerPushDebit> { return record; }, diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -97,6 +97,7 @@ import { import { fetchFreshExchange, getExchangeDetailsInTx, + getLegacyScopesForTransaction, getPreferredExchangeForCurrency, getScopeForAllExchanges, requireExchangeCoinUseConfirmedOrThrow, @@ -149,6 +150,11 @@ export class PeerPushDebitTransactionContext implements TransactionContext { await tx.upsertTransactionMeta({ currency: Amounts.currencyOf(rec.amount), exchanges: [rec.exchangeBaseUrl], + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: Amounts.currencyOf(rec.amount), + exchanges: [rec.exchangeBaseUrl], + coinPubs: rec.coinSel?.coinPubs, + }), status: rec.status, timestamp: rec.timestampCreated, transactionId: this.transactionId, diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -110,6 +110,7 @@ import { import { selectWithdrawalDenominations } from "./denomSelection.js"; import { fetchFreshExchange, + getLegacyScopesForTransaction, getScopeForAllExchanges, requireExchangeCoinUseConfirmedOrThrow, requireExchangeReadyTx, @@ -164,12 +165,18 @@ export class RefreshTransactionContext implements TransactionContext { await tx.deleteTransactionMeta(this.transactionId); return; } + const exchanges = Object.keys(rgRec.infoPerExchange ?? {}); await tx.upsertTransactionMeta({ transactionId: this.transactionId, status: rgRec.operationStatus, timestamp: rgRec.timestampCreated, currency: rgRec.currency, - exchanges: Object.keys(rgRec.infoPerExchange ?? {}), + exchanges, + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency: rgRec.currency, + exchanges, + coinPubs: rgRec.oldCoinPubs, + }), }); } diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -29,6 +29,7 @@ import { ResolveTransactionReferenceRequest, ResolveTransactionReferenceResponse, ScopeType, + stringifyScopeInfo, TalerError, TalerErrorCode, Transaction, @@ -225,6 +226,7 @@ export async function getTransactionById( txDetails: await ctx.lookupFullTransaction(tx, { includeContractTerms: req.includeContractTerms, }), + txMeta: await tx.getTransactionMeta(req.transactionId), localIdent: ( await tx.getLocalTransactionIdentifiers([req.transactionId]) ).get(req.transactionId), @@ -237,13 +239,32 @@ export async function getTransactionById( ); } return withLocalTransactionIdentifier( - result.txDetails, + withMaterializedLegacyScopes(result.txDetails, result.txMeta), result.localIdent, ); } } } +function withMaterializedLegacyScopes( + transaction: Transaction, + meta: WalletTransactionMeta | undefined, +): Transaction { + if (!meta?.legacyScopes?.length) { + return transaction; + } + const scopes = [...transaction.scopes]; + const seen = new Set(scopes.map(stringifyScopeInfo)); + for (const scope of meta.legacyScopes) { + const key = stringifyScopeInfo(scope); + if (!seen.has(key)) { + seen.add(key); + scopes.push(scope); + } + } + return { ...transaction, scopes } as Transaction; +} + function withLocalTransactionIdentifier( tx: Transaction, localIdent: string | undefined, @@ -441,7 +462,7 @@ async function addFiltered( numAdded += 1; target.push( withLocalTransactionIdentifier( - txDetails, + withMaterializedLegacyScopes(txDetails, mtx), localIdentifiers.get(mtx.transactionId), ), ); @@ -640,7 +661,7 @@ export async function getTransactions( } transactions.push( withLocalTransactionIdentifier( - txDetails, + withMaterializedLegacyScopes(txDetails, metaTx), localIdentifiers.get(metaTx.transactionId), ), ); @@ -765,6 +786,22 @@ export async function rematerializeTransactions( } } +/** Rebuild only transaction metadata that refers to one exchange URL. */ +export async function rematerializeTransactionsForExchange( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + exchangeBaseUrl: string, +): Promise<void> { + const metadata = await tx.listTransactionMetaByStatus({ onlyActive: false }); + for (const meta of metadata) { + if (!meta.exchanges.includes(exchangeBaseUrl)) { + continue; + } + const ctx = await getContextForTransaction(wex, meta.transactionId); + await ctx.updateTransactionMeta(tx); + } +} + export type ParsedTransactionIdentifier = | { tag: TransactionType.Deposit; depositGroupId: string } | { tag: TransactionType.Payment; proposalId: string } diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -404,7 +404,7 @@ type CancelFn = () => void; * (fixup20260718StatusEnumDigits) rewrites the raw records, transactionsMeta * and its byStatus index must be rebuilt from the corrected values. */ -const MATERIALIZED_TRANSACTIONS_VERSION = 4; +const MATERIALIZED_TRANSACTIONS_VERSION = 6; /** * Incremented each time coin-availability counters need to be rebuilt from diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -172,6 +172,7 @@ import { fetchFreshExchangeWithRetryNow, getExchangeDetailsInTx, getExchangePaytoUri, + getLegacyScopesForTransaction, getPreferredExchangeForCurrency, getScopeForAllExchanges, handleStartExchangeWalletKyc, @@ -503,12 +504,18 @@ export class WithdrawTransactionContext implements TransactionContext { if (!currency) { return; } + const exchanges = [wgRecord.exchangeBaseUrl]; await tx.upsertTransactionMeta({ transactionId: ctx.transactionId, status: wgRecord.status, timestamp: wgRecord.timestampStart, currency, - exchanges: [wgRecord.exchangeBaseUrl], + exchanges, + legacyScopes: await getLegacyScopesForTransaction(tx, { + currency, + exchanges, + coinSourceTransactionId: ctx.transactionId, + }), }); // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted?