taler-typescript-core

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

commit eadc578f3e29bc60715f5bde271d3b3f8f2e78f7
parent daa6250585b1d6036410272fa8cf6cf51f874ac1
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 18:11:23 +0200

wallet-core database: persist automatic renewal state

Store denomination deadlines, melt commitments, recovery notices, and
notice dismissal. Migrate native SQLite databases to schema version 22.

Define persisted renewal reports and generation tokens, with shared
transaction hooks for atomic invalidation when their inputs change.

Diffstat:
Mpackages/taler-wallet-core/src/db/records.ts | 32+++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/db/shared.ts | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mpackages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts | 38++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/sqlite/schema.ts | 11++++++++++-
Mpackages/taler-wallet-core/src/db/sqlite/transaction.ts | 32+++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/db/testing/conformance-cases.ts | 15+++++++++++++++
6 files changed, 191 insertions(+), 35 deletions(-)

diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -398,6 +398,12 @@ export interface WalletRefreshGroupPerExchangeInfo { * currency. */ export interface WalletRefreshGroup { + /** DD71 metadata; notices are exposed only after successful completion. */ + autoRefresh?: { + dismissed?: boolean; + recoveryInputs?: Record<string, AmountString>; + recovery?: import("@gnu-taler/taler-util").CashRenewalNotice; + }; transactionAmounts?: WalletTransactionAmounts; operationStatus: RefreshOperationStatus; @@ -455,6 +461,8 @@ export interface WalletRefreshGroup { * Ongoing refresh */ export interface WalletRefreshSession { + /** Once set, the melt may have reached the exchange; never change outputs. */ + autoRefreshMeltStarted?: boolean; refreshGroupId: string; /** @@ -1263,6 +1271,9 @@ export interface WalletExchangeEntry { */ nextRefreshCheckStamp: DbPreciseTimestamp; + /** Private execution deadlines keyed by master public key / denomination. */ + autoRefreshDeadlines?: Record<string, number>; + /** * Public key of the reserve that we're currently using for * receiving P2P payments. @@ -2117,6 +2128,8 @@ export enum ConfigRecordKey { MaterializedTransactionsVersion = "materializedTransactionsVersion", CoinAvailabilityVersion = "coinAvailabilityVersion", DonauConfig = "donauConfig", + RefreshBalanceGeneration = "refreshBalanceGeneration", + RefreshBalance = "refreshBalance", } export interface DonauConfig { @@ -2154,6 +2167,21 @@ export interface WalletBackupConfState { * Configuration key/value entries to configure * the wallet. */ +/** Persisted derived data. Generation changes atomically with its inputs. */ +export interface WalletRefreshBalance { + version: 1; + generation: string; + computedAt: number; + nextCheck: number; + scopes: Record< + string, + { + available: AmountString; + info: import("@gnu-taler/taler-util").WalletRefreshInfo; + } + >; +} + export type ConfigRecord = | { key: ConfigRecordKey.WalletBackupState; @@ -2165,7 +2193,9 @@ export type ConfigRecord = | { key: ConfigRecordKey.LastResumed; value: DbProtocolTimestamp } | { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number } | { key: ConfigRecordKey.CoinAvailabilityVersion; value: number } - | { key: ConfigRecordKey.DonauConfig; value: DonauConfig }; + | { key: ConfigRecordKey.DonauConfig; value: DonauConfig } + | { key: ConfigRecordKey.RefreshBalanceGeneration; value: string } + | { key: ConfigRecordKey.RefreshBalance; value: WalletRefreshBalance }; export enum RecoupOperationStatus { Pending = 0x0100_0000, diff --git a/packages/taler-wallet-core/src/db/shared.ts b/packages/taler-wallet-core/src/db/shared.ts @@ -23,9 +23,15 @@ * reimplemented (and drifting) in each. */ -import { assertUnreachable, ScopeInfo, ScopeType } from "@gnu-taler/taler-util"; +import { + assertUnreachable, + ScopeInfo, + ScopeType, + encodeCrock, + getRandomBytes, +} from "@gnu-taler/taler-util"; import { WalletDbTransaction } from "./transaction.js"; -import { PurchaseStatus, WalletPurchase } from "./records.js"; +import { ConfigRecordKey, PurchaseStatus, WalletPurchase } from "./records.js"; import { auditorProvidesVerifiedTrust } from "../auditorTrust.js"; /** @@ -178,40 +184,80 @@ export const CACHE_INVALIDATING_METHODS: ReadonlySet<string> = new Set([ * transaction commits, never before, so a rolled-back transaction does not * invalidate anything. */ +/** Changes that affect the persisted renewal report, including its balance. */ +export const REFRESH_BALANCE_INVALIDATING_METHODS: ReadonlySet<string> = + new Set([ + ...CACHE_INVALIDATING_METHODS, + ...[ + "Coin", + "CoinAvailability", + "RefreshGroup", + "RefreshSession", + "WithdrawalGroup", + "Purchase", + "DepositGroup", + "PeerPushDebit", + "PeerPushCredit", + "PeerPullDebit", + "PeerPullCredit", + "RecoupGroup", + "DonationSummary", + ].flatMap((name) => [`upsert${name}`, `delete${name}`]), + ]); + +export interface WalletCacheInvalidation { + dirty: boolean; + terminalPaymentIds?: Set<string>; + refreshBalanceDirty?: boolean; +} + +/** Persist invalidation in the same transaction as the changed inputs. */ +export async function persistRefreshBalanceInvalidation( + tx: WalletDbTransaction, + flag: WalletCacheInvalidation, +): Promise<void> { + if (flag.refreshBalanceDirty) + await tx.upsertConfig({ + key: ConfigRecordKey.RefreshBalanceGeneration, + value: encodeCrock(getRandomBytes(16)), + }); +} + export function watchForCacheInvalidation<T extends WalletDbTransaction>( tx: T, - flag: { dirty: boolean; terminalPaymentIds?: Set<string> }, + flag: WalletCacheInvalidation, ): T { return new Proxy(tx, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); - if (prop === "upsertPurchase" && typeof value === "function") { - return (...args: unknown[]) => { + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + if (prop === "upsertPurchase") { const purchase = args[0] as WalletPurchase; - if (purchase.purchaseStatus >= PurchaseStatus.Done) { + if (purchase.purchaseStatus >= PurchaseStatus.Done) flag.terminalPaymentIds?.add(purchase.proposalId); - } - return value.apply(target, args); - }; - } - if (prop === "deletePurchase" && typeof value === "function") { - return (...args: unknown[]) => { + } else if (prop === "deletePurchase") flag.terminalPaymentIds?.add(args[0] as string); - return value.apply(target, args); - }; - } - if (typeof prop === "string" && CACHE_INVALIDATING_METHODS.has(prop)) { - if (typeof value === "function") { - return (...args: unknown[]) => { - flag.dirty = true; - return value.apply(target, args); - }; + if (typeof prop === "string" && CACHE_INVALIDATING_METHODS.has(prop)) + flag.dirty = true; + if ( + typeof prop === "string" && + REFRESH_BALANCE_INVALIDATING_METHODS.has(prop) + ) + flag.refreshBalanceDirty = true; + if ( + prop === "upsertOperationRetry" || + prop === "deleteOperationRetry" + ) { + const id = + prop === "upsertOperationRetry" + ? (args[0] as { id: string }).id + : (args[0] as string); + if (/^(refresh|exchange-auto-refresh|exchange-update):/.test(id)) + flag.refreshBalanceDirty = true; } - } - if (typeof value === "function") { - return value.bind(target); - } - return value; + return value.apply(target, args); + }; }, }); } 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 @@ -651,3 +651,41 @@ test("a mismatched schema migration name is rejected", async () => { cleanup(); } }); + +test("DD71 migration preserves deadlines through closing and reopening", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((m) => m.version < 22), + ); + await ( + await db.prepare(`INSERT INTO exchanges (base_url, entry_status, update_status, next_update_stamp, next_refresh_check_stamp) + VALUES ('https://dd71.example/', 0, 0, 1000, 2000)`) + ).run({}); + await db.close(); + db = await openRaw(path); + await initSqliteWalletDb(db); + assert.equal( + (await queryAll(db, "SELECT auto_refresh_deadlines FROM exchanges"))[0] + .auto_refresh_deadlines, + null, + ); + const deadline = '{"master/denom":2000000000123}'; + await ( + await db.prepare("UPDATE exchanges SET auto_refresh_deadlines = $value") + ).run({ value: deadline }); + await db.close(); + db = await openRaw(path); + await initSqliteWalletDb(db); + assert.equal( + (await queryAll(db, "SELECT auto_refresh_deadlines FROM exchanges"))[0] + .auto_refresh_deadlines, + deadline, + ); + 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 = 21; +export const SQLITE_SCHEMA_VERSION = 22; /** * Tables of the IndexedDB emulation, children before parents. @@ -1478,6 +1478,15 @@ export const schemaMigrations: SchemaMigration[] = [ "ALTER TABLE refund_groups ADD COLUMN transaction_amounts TEXT", ], }, + { + version: 22, + name: "dd71-auto-refresh", + statements: [ + "ALTER TABLE exchanges ADD COLUMN auto_refresh_deadlines TEXT", + "ALTER TABLE refresh_groups ADD COLUMN auto_refresh TEXT", + "ALTER TABLE refresh_sessions ADD COLUMN auto_refresh_melt_started INTEGER CHECK (auto_refresh_melt_started IN (0, 1))", + ], + }, ]; /** 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 @@ -1653,6 +1653,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { nextUpdateStamp: dbTimestamp(row.next_update_stamp), lastKeysEtag: optStr(row.last_keys_etag), nextRefreshCheckStamp: dbTimestamp(row.next_refresh_check_stamp), + ...(row.auto_refresh_deadlines == null + ? {} + : { autoRefreshDeadlines: dbToJson(row.auto_refresh_deadlines) }), ...(row.preset_currency_hint != null ? { presetCurrencyHint: str(row.preset_currency_hint) } : undefined), @@ -1726,7 +1729,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { entry_status, update_status, unavailable_reason, cachebreak_next_update, tos_current_etag, tos_accepted_etag, tos_accepted_timestamp, last_update, next_update_stamp, - last_keys_etag, next_refresh_check_stamp, + last_keys_etag, next_refresh_check_stamp, auto_refresh_deadlines, current_merge_reserve_row_id, current_account_priv, current_account_pub, peer_payments_disabled, direct_deposit_disabled, no_fees, @@ -1734,7 +1737,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { superseded_first_seen, superseded_shares_denoms ) VALUES ( $url, $pch, $pcs, $pt, $src, $lw, $dpmp, $dpc, $dpuc, $es, $us, $ur, - $cnu, $tce, $tae, $tat, $lu, $nus, $lastKeysEtag, $nrcs, $cmrri, $cap, + $cnu, $tce, $tae, $tat, $lu, $nus, $lastKeysEtag, $nrcs, $ard, $cmrri, $cap, $capub, $ppd, $ddd, $nf, $smp, $sc, $sfs, $ssd ) ON CONFLICT(base_url) DO UPDATE SET @@ -1758,6 +1761,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { next_update_stamp = excluded.next_update_stamp, last_keys_etag = excluded.last_keys_etag, next_refresh_check_stamp = excluded.next_refresh_check_stamp, + auto_refresh_deadlines = excluded.auto_refresh_deadlines, current_merge_reserve_row_id = excluded.current_merge_reserve_row_id, current_account_priv = excluded.current_account_priv, @@ -1803,6 +1807,10 @@ export class SqliteWalletTransaction implements WalletDbTransaction { nus: rec.nextUpdateStamp, lastKeysEtag: rec.lastKeysEtag ?? null, nrcs: rec.nextRefreshCheckStamp, + ard: + rec.autoRefreshDeadlines === undefined + ? null + : jsonToDb(rec.autoRefreshDeadlines), cmrri: rec.currentMergeReserveRowId ?? null, cap: optCrockToDb(rec.currentAccountPriv), capub: optCrockToDb(rec.currentAccountPub), @@ -3756,6 +3764,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToRefreshGroup(row: ResultRow): WalletRefreshGroup { return { + ...(row.auto_refresh == null + ? {} + : { autoRefresh: dbToJson(row.auto_refresh) }), ...(row.transaction_amounts == null ? {} : { transactionAmounts: dbToJson(row.transaction_amounts) }), @@ -3798,12 +3809,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async upsertRefreshGroup(rec: WalletRefreshGroup): Promise<void> { await this.run( `INSERT INTO refresh_groups (transaction_amounts, - refresh_group_id, operation_status, currency, reason, + refresh_group_id, operation_status, currency, reason, auto_refresh, originating_transaction_id, old_coin_pubs, input_per_coin, expected_output_per_coin, info_per_exchange, status_per_coin, refund_requests, timestamp_created, fail_reason, timestamp_finished ) VALUES ($transactionAmounts, - $id, $status, $cur, $reason, $otid, $ocp, $ipc, $eopc, $ipe, + $id, $status, $cur, $reason, $ar, $otid, $ocp, $ipc, $eopc, $ipe, $spc, $rr, $created, $fail, $finished ) ON CONFLICT(refresh_group_id) DO UPDATE SET @@ -3811,6 +3822,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { operation_status = excluded.operation_status, currency = excluded.currency, reason = excluded.reason, + auto_refresh = excluded.auto_refresh, originating_transaction_id = excluded.originating_transaction_id, old_coin_pubs = excluded.old_coin_pubs, input_per_coin = excluded.input_per_coin, @@ -3830,6 +3842,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { status: rec.operationStatus, cur: rec.currency, reason: rec.reason, + ar: rec.autoRefresh === undefined ? null : jsonToDb(rec.autoRefresh), otid: rec.originatingTransactionId ?? null, ocp: jsonToDb(rec.oldCoinPubs), ipc: jsonToDb(rec.inputPerCoin), @@ -5280,6 +5293,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToRefreshSession(row: ResultRow): WalletRefreshSession { return { + ...(row.auto_refresh_melt_started == null + ? {} + : { autoRefreshMeltStarted: dbToBool(row.auto_refresh_melt_started) }), refreshGroupId: str(row.refresh_group_id), coinIndex: num(row.coin_index), amountRefreshOutput: dbAmount(row.amount_refresh_output), @@ -5316,18 +5332,20 @@ export class SqliteWalletTransaction implements WalletDbTransaction { `INSERT INTO refresh_sessions ( refresh_group_id, coin_index, session_public_seed, refresh_protocol_version, amount_refresh_output, new_denoms, - noreveal_index, last_error - ) VALUES ($id, $idx, $seed, $rpv, $amt, $newDenoms, $nri, $err) + noreveal_index, last_error, auto_refresh_melt_started + ) VALUES ($id, $idx, $seed, $rpv, $amt, $newDenoms, $nri, $err, $arms) ON CONFLICT(refresh_group_id, coin_index) DO UPDATE SET session_public_seed = excluded.session_public_seed, refresh_protocol_version = excluded.refresh_protocol_version, amount_refresh_output = excluded.amount_refresh_output, new_denoms = excluded.new_denoms, noreveal_index = excluded.noreveal_index, - last_error = excluded.last_error`, + last_error = excluded.last_error, + auto_refresh_melt_started = excluded.auto_refresh_melt_started`, { id: rec.refreshGroupId, idx: rec.coinIndex, + arms: boolToDb(rec.autoRefreshMeltStarted), seed: optCrockToDb(rec.sessionPublicSeed), rpv: rec.refreshProtocolVersion ?? null, amt: rec.amountRefreshOutput, diff --git a/packages/taler-wallet-core/src/db/testing/conformance-cases.ts b/packages/taler-wallet-core/src/db/testing/conformance-cases.ts @@ -2330,6 +2330,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "exchange: round trips, including the flattened details pointer", async run(t, runner) { const ex = makeExchange("https://ex-rt/"); + ex.autoRefreshDeadlines = { "master/denom": 1234567890123 }; ex.detailsPointer = { masterPublicKey: ck("mpk-1"), currency: "TESTKUDOS", @@ -3808,6 +3809,18 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const rg = makeRefreshGroup("rg-1"); rg.originatingTransactionId = "txn:orig:1"; + rg.autoRefresh = { + dismissed: false, + recovery: { + warningId: "rg-1", + exchangeBaseUrl: "https://ex-rt/", + exchangeMasterPub: ck("mpk-1"), + amount: "TESTKUDOS:1", + oldDepositExpiration: { t_s: 1000 }, + newDepositExpiration: { t_s: 2000 }, + nextRelevantDate: { t_s: 1900 }, + }, + }; await runner.runReadWriteTx((tx) => tx.upsertRefreshGroup(rg)); const got = await runner.runReadWriteTx((tx) => tx.getRefreshGroup("rg-1"), @@ -3884,6 +3897,8 @@ export const conformanceCases: ConformanceCase[] = [ melted.sessionPublicSeed = ckh("seed-melt"); melted.refreshProtocolVersion = 32; melted.norevealIndex = 2; + melted.autoRefreshMeltStarted = true; + fresh.autoRefreshMeltStarted = false; await runner.runReadWriteTx(async (tx) => { await seedRefreshGroup(tx, "rs-melt"); await tx.upsertRefreshSession(fresh);