taler-typescript-core

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

commit b8992d0a3938aaaa60441425b51e507bfeef0761
parent 44fa9a141ac3ca5a73d600d48be373bd43f7f07d
Author: Florian Dold <florian@dold.me>
Date:   Wed,  4 Mar 2026 22:25:50 +0100

wallet-core: handle and test withdrawal redenomination better, draft/WIP for new DB layer

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts | 17++++-------------
Apackages/taler-harness/src/integrationtests/test-wallet-withdrawal-redenominate.ts | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/types-taler-wallet.ts | 11+++++++++++
Mpackages/taler-wallet-core/src/db.ts | 79-------------------------------------------------------------------------------
Apackages/taler-wallet-core/src/dbtx.ts | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/donau.ts | 49++++++++++++++++++++++---------------------------
Mpackages/taler-wallet-core/src/exchanges.ts | 562++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 9++++-----
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 9+++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-wallet-core/src/withdraw.ts | 118++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
12 files changed, 739 insertions(+), 470 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts @@ -17,20 +17,15 @@ /** * Imports. */ -import { Duration, Logger, NotificationType, j2s } from "@gnu-taler/taler-util"; +import { Duration, NotificationType, j2s } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { makeNoFeeCoinConfig } from "../harness/denomStructures.js"; import { - GlobalTestState, - setupDb, -} from "../harness/harness.js"; -import { applyTimeTravelV2, createSimpleTestkudosEnvironmentV3, withdrawViaBankV3, } from "../harness/environments.js"; - -const logger = new Logger("test-exchange-timetravel.ts"); +import { GlobalTestState, setupDb } from "../harness/harness.js"; /** * Test how the wallet handles an expired denomination. @@ -42,12 +37,8 @@ export async function runWalletDenomExpireTest(t: GlobalTestState) { const coinConfig = makeNoFeeCoinConfig("TESTKUDOS"); - const { - walletClient, - bankClient, - exchange, - merchant, - } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, {}); + const { walletClient, bankClient, exchange, merchant } = + await createSimpleTestkudosEnvironmentV3(t, coinConfig, {}); // Withdraw digital cash into the wallet. diff --git a/packages/taler-harness/src/integrationtests/test-wallet-withdrawal-redenominate.ts b/packages/taler-harness/src/integrationtests/test-wallet-withdrawal-redenominate.ts @@ -0,0 +1,178 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + AmountString, + Duration, + FlightRecordEvent, + TalerWireGatewayHttpClient, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + applyTimeTravelV2, + createSimpleTestkudosEnvironmentV3, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +export async function runWalletWithdrawalRedenominateTest(t: GlobalTestState) { + // Set up test environment + + const { + walletClient, + bankClient, + bank, + exchange, + merchant, + merchantAdminAccessToken, + exchangeBankAccount, + } = await createSimpleTestkudosEnvironmentV3( + t, + defaultCoinConfig.map((x) => x("TESTKUDOS")), + { + walletConfig: { + testing: { + devModeActive: true, + }, + }, + }, + ); + + { + await walletClient.call(WalletApiOperation.ClearDb, {}); + const recs = await walletClient.call( + WalletApiOperation.TestingGetFlightRecords, + {}, + ); + t.assertDeepEqual(recs.flightRecords.length, 0); + } + + { + const wres = await walletClient.call( + WalletApiOperation.AcceptManualWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + amount: "TESTKUDOS:10" as AmountString, + }, + ); + + await walletClient.call( + WalletApiOperation.TestingCorruptWithdrawalCoinSel, + { + transactionId: wres.transactionId, + }, + ); + + const reservePub: string = wres.reservePub; + const user = await bankClient.createRandomBankUser(); + + const wireGatewayApiClient = new TalerWireGatewayHttpClient( + exchangeBankAccount.wireGatewayApiBaseUrl, + ); + + await wireGatewayApiClient.addIncoming({ + auth: bank.getAdminAuth(), + body: { + amount: "TESTKUDOS:10", + debit_account: user.accountPaytoUri, + reserve_pub: reservePub, + }, + }); + + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + + const recs = await walletClient.call( + WalletApiOperation.TestingGetFlightRecords, + {}, + ); + + const myRec = recs.flightRecords.find( + (x) => x.event === FlightRecordEvent.WithdrawalRedenominate, + ); + t.assertTrue(myRec != null); + } + + { + await walletClient.call(WalletApiOperation.ClearDb, {}); + const recs = await walletClient.call( + WalletApiOperation.TestingGetFlightRecords, + {}, + ); + t.assertDeepEqual(recs.flightRecords.length, 0); + } + + { + const wres = await walletClient.call( + WalletApiOperation.AcceptManualWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + amount: "TESTKUDOS:10" as AmountString, + }, + ); + + const reservePub: string = wres.reservePub; + const user = await bankClient.createRandomBankUser(); + + const wireGatewayApiClient = new TalerWireGatewayHttpClient( + exchangeBankAccount.wireGatewayApiBaseUrl, + ); + + await applyTimeTravelV2( + Duration.toMilliseconds( + Duration.fromSpec({ + days: 14, + }), + ), + { + exchange, + merchant, + walletClient, + }, + ); + + await wireGatewayApiClient.addIncoming({ + auth: bank.getAdminAuth(), + body: { + amount: "TESTKUDOS:10", + debit_account: user.accountPaytoUri, + reserve_pub: reservePub, + }, + }); + + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + + const recs = await walletClient.call( + WalletApiOperation.TestingGetFlightRecords, + {}, + ); + + const myRec = recs.flightRecords.find( + (x) => x.event === FlightRecordEvent.WithdrawalRedenominate, + ); + t.assertTrue(myRec != null); + } +} + +runWalletWithdrawalRedenominateTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -204,6 +204,7 @@ import { runWalletTokensDiscountTest } from "./test-wallet-tokens-discount.js"; import { runWalletTokensTest } from "./test-wallet-tokens.js"; import { runWalletTransactionsTest } from "./test-wallet-transactions.js"; import { runWalletWirefeesTest } from "./test-wallet-wirefees.js"; +import { runWalletWithdrawalRedenominateTest } from "./test-wallet-withdrawal-redenominate.js"; import { runWallettestingTest } from "./test-wallettesting.js"; import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js"; import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; @@ -427,6 +428,7 @@ const allTests: TestMainFunction[] = [ runExchangeMerchantKycAuthTest, runMerchantKycAuthMultiTest, runTopsMerchantTosTest, + runWalletWithdrawalRedenominateTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -4718,6 +4718,7 @@ export interface FlightRecordEntry { export enum FlightRecordEvent { MeltGone = "melt-gone", + WithdrawalRedenominate = "withdrawal-redenominate", } export interface GetDefaultExchangesResponse { @@ -4738,3 +4739,13 @@ export interface GetDefaultExchangesResponse { currencySpec: CurrencySpecification; }[]; } + +export interface TestingCorruptWithdrawalCoinSelRequest { + transactionId: TransactionIdStr; +} + +export const codecForTestingCorruptWithdrawalCoinSelRequest = + (): Codec<TestingCorruptWithdrawalCoinSelRequest> => + buildCodecForObject<TestingCorruptWithdrawalCoinSelRequest>() + .property("transactionId", codecForTransactionIdStr()) + .build("TestingCorruptWithdrawalCoinSelRequest"); diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -4457,82 +4457,3 @@ export async function deleteTalerDatabase( req.onsuccess = () => resolve(); }); } - -/** - * High-level helpers to access the database. - * Eventually all access to the database should - * go through helpers in this namespace. - */ -export namespace WalletDbHelpers { - export interface GetCurrencyInfoDbResult { - /** - * Currency specification. - */ - currencySpec: CurrencySpecification; - - /** - * How did the currency info get set? - */ - source: "exchange" | "user" | "preset"; - } - - export interface StoreCurrencyInfoDbRequest { - scopeInfo: ScopeInfo; - currencySpec: CurrencySpecification; - source: "exchange" | "user" | "preset"; - } - - export async function getCurrencyInfo( - tx: WalletDbReadOnlyTransaction<["currencyInfo"]>, - scopeInfo: ScopeInfo, - ): Promise<GetCurrencyInfoDbResult | undefined> { - const s = stringifyScopeInfo(scopeInfo); - const res = await tx.currencyInfo.get(s); - if (!res) { - return undefined; - } - return { - currencySpec: res.currencySpec, - source: res.source, - }; - } - - /** - * Store currency info for a scope. - * - * Overrides existing currency infos. - */ - export async function upsertCurrencyInfo( - tx: WalletDbReadWriteTransaction<["currencyInfo"]>, - req: StoreCurrencyInfoDbRequest, - ): Promise<void> { - await tx.currencyInfo.put({ - scopeInfoStr: stringifyScopeInfo(req.scopeInfo), - currencySpec: req.currencySpec, - source: req.source, - }); - } - - export async function insertCurrencyInfoUnlessExists( - tx: WalletDbReadWriteTransaction<["currencyInfo"]>, - req: StoreCurrencyInfoDbRequest, - ): Promise<void> { - const scopeInfoStr = stringifyScopeInfo(req.scopeInfo); - const oldRec = await tx.currencyInfo.get(scopeInfoStr); - if (oldRec) { - return; - } - await tx.currencyInfo.put({ - scopeInfoStr: stringifyScopeInfo(req.scopeInfo), - currencySpec: req.currencySpec, - source: req.source, - }); - } - - export async function getConfig<T extends ConfigRecord["key"]>( - tx: WalletDbReadWriteTransaction<["config"]>, - key: T, - ): Promise<Extract<ConfigRecord, { key: T }> | undefined> { - return (await tx.config.get(key)) as any; - } -} diff --git a/packages/taler-wallet-core/src/dbtx.ts b/packages/taler-wallet-core/src/dbtx.ts @@ -0,0 +1,114 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + CurrencySpecification, + ScopeInfo, + stringifyScopeInfo, +} from "@gnu-taler/taler-util"; +import { ConfigRecord, WalletDbAllStoresReadWriteTransaction } from "./db.js"; + +export interface GetCurrencyInfoDbResult { + /** + * Currency specification. + */ + currencySpec: CurrencySpecification; + + /** + * How did the currency info get set? + */ + source: "exchange" | "user" | "preset"; +} + +export interface StoreCurrencyInfoDbRequest { + scopeInfo: ScopeInfo; + currencySpec: CurrencySpecification; + source: "exchange" | "user" | "preset"; +} + +export interface WalletDbTransaction { + getCurrencyInfo( + scopeInfo: ScopeInfo, + ): Promise<GetCurrencyInfoDbResult | undefined>; + + getConfig<T extends ConfigRecord["key"]>( + key: T, + ): Promise<Extract<ConfigRecord, { key: T }> | undefined>; + + /** + * Store currency info for a scope. + * + * Overrides existing currency infos. + */ + upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void>; + + insertCurrencyInfoUnlessExists( + req: StoreCurrencyInfoDbRequest, + ): Promise<void>; +} + +export class IdbWalletTransaction implements WalletDbTransaction { + tx: WalletDbAllStoresReadWriteTransaction; + constructor(tx: WalletDbAllStoresReadWriteTransaction) { + this.tx = tx; + } + async getCurrencyInfo( + scopeInfo: ScopeInfo, + ): Promise<GetCurrencyInfoDbResult | undefined> { + const tx = this.tx; + const s = stringifyScopeInfo(scopeInfo); + const res = await tx.currencyInfo.get(s); + if (!res) { + return undefined; + } + return { + currencySpec: res.currencySpec, + source: res.source, + }; + } + + async getConfig<T extends ConfigRecord["key"]>( + key: T, + ): Promise<Extract<ConfigRecord, { key: T }> | undefined> { + const tx = this.tx; + return (await tx.config.get(key)) as any; + } + + async upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void> { + const tx = this.tx; + await tx.currencyInfo.put({ + scopeInfoStr: stringifyScopeInfo(req.scopeInfo), + currencySpec: req.currencySpec, + source: req.source, + }); + } + + async insertCurrencyInfoUnlessExists( + req: StoreCurrencyInfoDbRequest, + ): Promise<void> { + const tx = this.tx; + const scopeInfoStr = stringifyScopeInfo(req.scopeInfo); + const oldRec = await tx.currencyInfo.get(scopeInfoStr); + if (oldRec) { + return; + } + await tx.currencyInfo.put({ + scopeInfoStr: stringifyScopeInfo(req.scopeInfo), + currencySpec: req.currencySpec, + source: req.source, + }); + } +} diff --git a/packages/taler-wallet-core/src/donau.ts b/packages/taler-wallet-core/src/donau.ts @@ -58,8 +58,8 @@ import { DonationPlanchetRecord, DonationReceiptRecord, DonationReceiptStatus, - WalletDbHelpers, } from "./db.js"; +import { IdbWalletTransaction } from "./dbtx.js"; import { WalletExecutionContext } from "./index.js"; /** @@ -75,7 +75,7 @@ interface DonationReceiptGroup { donorHashSalt: string; year: number; currency: string; -}; +} /** * Implementation of the getDonauStatements @@ -102,10 +102,11 @@ async function submitDonationReceipts( async (tx) => { let receipts; if (donauBaseUrl) { - receipts = await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([ - DonationReceiptStatus.Pending, - donauBaseUrl, - ]); + receipts = + await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([ + DonationReceiptStatus.Pending, + donauBaseUrl, + ]); } else { receipts = await tx.donationReceipts.indexes.byStatus.getAll( DonationReceiptStatus.Pending, @@ -120,7 +121,9 @@ async function submitDonationReceipts( const donauClient = new DonauHttpClient(group.donauBaseUrl); const conf = succeedOrThrow(await donauClient.getConfig()); - logger.info(`submitting donation receipts (${group.year}, ${group.donorTaxIdHash})`); + logger.info( + `submitting donation receipts (${group.year}, ${group.donorTaxIdHash})`, + ); succeedOrThrow( await donauClient.submitDonationReceipts({ h_donor_tax_id: group.donorTaxIdHash, @@ -186,10 +189,11 @@ async function fetchDonauStatements( async (tx) => { let receipts; if (donauBaseUrl) { - receipts = await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([ - DonationReceiptStatus.DoneSubmitted, - donauBaseUrl, - ]); + receipts = + await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([ + DonationReceiptStatus.DoneSubmitted, + donauBaseUrl, + ]); } else { receipts = await tx.donationReceipts.indexes.byStatus.getAll( DonationReceiptStatus.DoneSubmitted, @@ -206,10 +210,7 @@ async function fetchDonauStatements( const conf = succeedOrThrow(await donauClient.getConfig()); const stmt = succeedOrThrow( - await donauClient.getDonationStatement( - group.year, - group.donorTaxIdHash, - ), + await donauClient.getDonationStatement(group.year, group.donorTaxIdHash), ); const parsedDonauUrl = new URL(group.donauBaseUrl); const proto = parsedDonauUrl.protocol == "http:" ? "donau+http" : "donau"; @@ -233,9 +234,7 @@ async function fetchDonauStatements( function groupDonauReceipts( receipts: DonationReceiptRecord[], ): DonationReceiptGroup[] { - const donauUrlSet = new Set<string>( - receipts.map((x) => x.donauBaseUrl), - ); + const donauUrlSet = new Set<string>(receipts.map((x) => x.donauBaseUrl)); const donauUrls = [...donauUrlSet]; const groups: DonationReceiptGroup[] = []; @@ -269,7 +268,7 @@ function groupDonauReceipts( donorHashSalt: r0.donorHashSalt, year: r0.donationYear, currency: Amounts.currencyOf(r0.value), - }) + }); const year = r0.donationYear; const donauBaseUrl = r0.donauBaseUrl; const currency = Amounts.currencyOf(r0.value); @@ -296,10 +295,8 @@ export async function handleSetDonau( idHasher.update(stringToBytes(encodeCrock(salt) + "\0")); const saltedId = idHasher.finish(); await wex.db.runAllStoresReadWriteTx({}, async (tx) => { - const oldRec = await WalletDbHelpers.getConfig( - tx, - ConfigRecordKey.DonauConfig, - ); + const wtx = new IdbWalletTransaction(tx); + const oldRec = await wtx.getConfig(ConfigRecordKey.DonauConfig); if ( oldRec && oldRec.value.donauBaseUrl === req.donauBaseUrl && @@ -332,10 +329,8 @@ export async function handleGetDonau( const currentDonauInfo = await wex.db.runAllStoresReadWriteTx( {}, async (tx) => { - const res = await WalletDbHelpers.getConfig( - tx, - ConfigRecordKey.DonauConfig, - ); + const wtx = new IdbWalletTransaction(tx); + const res = await wtx.getConfig(ConfigRecordKey.DonauConfig); if (!res) { return undefined; } diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -151,7 +151,6 @@ import { ReserveRecordStatus, WalletDbAllStoresReadOnlyTransaction, WalletDbAllStoresReadWriteTransaction, - WalletDbHelpers, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, timestampAbsoluteFromDb, @@ -161,6 +160,7 @@ import { timestampProtocolFromDb, timestampProtocolToDb, } from "./db.js"; +import { IdbWalletTransaction } from "./dbtx.js"; import { createTimeline, isCandidateWithdrawableDenomRec, @@ -1860,329 +1860,305 @@ export async function updateExchangeFromUrlHandler( } } - const updated = await wex.db.runReadWriteTx( - { - storeNames: [ - "exchanges", - "exchangeDetails", - "exchangeSignKeys", - "denominations", - "denominationFamilies", - "coins", - "refreshGroups", - "recoupGroups", - "coinAvailability", - "denomLossEvents", - "currencyInfo", - "transactionsMeta", - ], - }, - async (tx) => { - const r = await tx.exchanges.get(exchangeBaseUrl); - if (!r) { - logger.warn(`exchange ${exchangeBaseUrl} no longer present`); - return; - } + const updated = await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const r = await tx.exchanges.get(exchangeBaseUrl); + if (!r) { + logger.warn(`exchange ${exchangeBaseUrl} no longer present`); + return; + } - wex.ws.clearAllCaches(); + wex.ws.clearAllCaches(); - const oldExchangeState = getExchangeState(r); - const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); - let detailsPointerChanged = false; - if (!existingDetails) { + const oldExchangeState = getExchangeState(r); + const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); + let detailsPointerChanged = false; + if (!existingDetails) { + detailsPointerChanged = true; + } + let detailsIncompatible = false; + let conflictHint: string | undefined = undefined; + if (existingDetails) { + if (existingDetails.masterPublicKey !== keysInfo.master_public_key) { + detailsIncompatible = true; detailsPointerChanged = true; + conflictHint = "master public key changed"; + } else if (existingDetails.currency !== keysInfo.currency) { + detailsIncompatible = true; + detailsPointerChanged = true; + conflictHint = "currency changed"; } - let detailsIncompatible = false; - let conflictHint: string | undefined = undefined; - if (existingDetails) { - if (existingDetails.masterPublicKey !== keysInfo.master_public_key) { - detailsIncompatible = true; - detailsPointerChanged = true; - conflictHint = "master public key changed"; - } else if (existingDetails.currency !== keysInfo.currency) { - detailsIncompatible = true; - detailsPointerChanged = true; - conflictHint = "currency changed"; - } - // FIXME: We need to do some more consistency checks! - } - if (detailsIncompatible) { - logger.warn( - `exchange ${r.baseUrl} has incompatible data in /keys, not updating`, - ); - // We don't support this gracefully right now. - // See https://bugs.taler.net/n/8576 - r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; - r.unavailableReason = makeTalerErrorDetail( - TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT, - { - detail: conflictHint, - }, - ); - r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1; - r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter); - r.nextRefreshCheckStamp = timestampPreciseToDb( - AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), - ); - r.cachebreakNextUpdate = true; - await tx.exchanges.put(r); - return { - oldExchangeState, - newExchangeState: getExchangeState(r), - }; - } - delete r.unavailableReason; - r.updateRetryCounter = 0; - const newDetails: ExchangeDetailsRecord = { - auditors: keysInfo.auditors, - currency: keysInfo.currency, - masterPublicKey: keysInfo.master_public_key, - protocolVersionRange: keysInfo.version, - reserveClosingDelay: keysInfo.reserve_closing_delay, - globalFees, - exchangeBaseUrl: r.baseUrl, - wireInfo, - ageMask, - walletBalanceLimits: keysInfo.wallet_balance_limit_without_kyc, - hardLimits: keysInfo.hard_limits, - zeroLimits: keysInfo.zero_limits, - bankComplianceLanguage: keysInfo.bank_compliance_language, - shoppingUrl: keysInfo.shopping_url, - }; - r.noFees = noFees; - r.peerPaymentsDisabled = peerPaymentsDisabled; - r.directDepositDisabled = keysInfo.disable_direct_deposit; - switch (tosMeta.type) { - case "not-found": - r.tosCurrentEtag = undefined; - break; - case "ok": - r.tosCurrentEtag = tosMeta.etag; - break; - } - if (existingDetails?.rowId) { - newDetails.rowId = existingDetails.rowId; - } - r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now()); - r.nextUpdateStamp = timestampPreciseToDb( - AbsoluteTime.toPreciseTimestamp( - // FIXME! - AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ hours: 2 }), - ), - ), + // FIXME: We need to do some more consistency checks! + } + if (detailsIncompatible) { + logger.warn( + `exchange ${r.baseUrl} has incompatible data in /keys, not updating`, + ); + // We don't support this gracefully right now. + // See https://bugs.taler.net/n/8576 + r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; + r.unavailableReason = makeTalerErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT, + { + detail: conflictHint, + }, ); - // New denominations might be available. + r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1; + r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter); r.nextRefreshCheckStamp = timestampPreciseToDb( - TalerPreciseTimestamp.now(), + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), ); - if (detailsPointerChanged) { - r.detailsPointer = { - currency: newDetails.currency, - masterPublicKey: newDetails.masterPublicKey, - updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }; - } - - r.updateStatus = ExchangeEntryDbUpdateStatus.Ready; - r.cachebreakNextUpdate = false; + r.cachebreakNextUpdate = true; await tx.exchanges.put(r); + return { + oldExchangeState, + newExchangeState: getExchangeState(r), + }; + } + delete r.unavailableReason; + r.updateRetryCounter = 0; + const newDetails: ExchangeDetailsRecord = { + auditors: keysInfo.auditors, + currency: keysInfo.currency, + masterPublicKey: keysInfo.master_public_key, + protocolVersionRange: keysInfo.version, + reserveClosingDelay: keysInfo.reserve_closing_delay, + globalFees, + exchangeBaseUrl: r.baseUrl, + wireInfo, + ageMask, + walletBalanceLimits: keysInfo.wallet_balance_limit_without_kyc, + hardLimits: keysInfo.hard_limits, + zeroLimits: keysInfo.zero_limits, + bankComplianceLanguage: keysInfo.bank_compliance_language, + shoppingUrl: keysInfo.shopping_url, + }; + r.noFees = noFees; + r.peerPaymentsDisabled = peerPaymentsDisabled; + r.directDepositDisabled = keysInfo.disable_direct_deposit; + switch (tosMeta.type) { + case "not-found": + r.tosCurrentEtag = undefined; + break; + case "ok": + r.tosCurrentEtag = tosMeta.etag; + break; + } + if (existingDetails?.rowId) { + newDetails.rowId = existingDetails.rowId; + } + r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now()); + r.nextUpdateStamp = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp( + // FIXME! + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ hours: 2 }), + ), + ), + ); + // New denominations might be available. + r.nextRefreshCheckStamp = timestampPreciseToDb(TalerPreciseTimestamp.now()); + if (detailsPointerChanged) { + r.detailsPointer = { + currency: newDetails.currency, + masterPublicKey: newDetails.masterPublicKey, + updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }; + } - if (keysInfo.currency_specification) { - // Since this is the per-exchange currency info, - // we update it when the exchange changes it. - await WalletDbHelpers.upsertCurrencyInfo(tx, { - currencySpec: keysInfo.currency_specification, - scopeInfo: { - type: ScopeType.Exchange, - currency: newDetails.currency, - url: exchangeBaseUrl, - }, - source: "exchange", - }); - } - - const drRowId = await tx.exchangeDetails.put(newDetails); - checkDbInvariant( - typeof drRowId.key === "number", - "exchange details key is not a number", - ); + r.updateStatus = ExchangeEntryDbUpdateStatus.Ready; + r.cachebreakNextUpdate = false; + await tx.exchanges.put(r); - for (const sk of keysInfo.signkeys) { - // FIXME: validate signing keys before inserting them - await tx.exchangeSignKeys.put({ - exchangeDetailsRowId: drRowId.key, - masterSig: sk.master_sig, - signkeyPub: sk.key, - stampEnd: timestampProtocolToDb(sk.stamp_end), - stampExpire: timestampProtocolToDb(sk.stamp_expire), - stampStart: timestampProtocolToDb(sk.stamp_start), - }); - } + const wtx = new IdbWalletTransaction(tx); - // In the future: Filter out old denominations by index - const allOldDenoms = - await tx.denominations.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - const oldDenomByDph = new Map<string, DenominationRecord>(); - for (const denom of allOldDenoms) { - oldDenomByDph.set(denom.denomPubHash, denom); - } + if (keysInfo.currency_specification) { + // Since this is the per-exchange currency info, + // we update it when the exchange changes it. + await wtx.upsertCurrencyInfo({ + currencySpec: keysInfo.currency_specification, + scopeInfo: { + type: ScopeType.Exchange, + currency: newDetails.currency, + url: exchangeBaseUrl, + }, + source: "exchange", + }); + } - logger.trace("updating denominations in database"); - - for (const currentDenom of denomInfos) { - // FIXME: Check if we really already need the denomination. - const familyParamsIndexKey = [ - currentDenom.exchangeBaseUrl, - currentDenom.exchangeMasterPub, - currentDenom.value, - currentDenom.feeWithdraw, - currentDenom.feeDeposit, - currentDenom.feeRefresh, - currentDenom.feeRefund, - ]; - let fpRec: DenominationFamilyRecord | undefined = - await tx.denominationFamilies.indexes.byFamilyParms.get( - familyParamsIndexKey, - ); - let denominationFamilySerial; - if (fpRec == null) { - const fp: DenomFamilyParams = { - exchangeBaseUrl: exchangeBaseUrl, - exchangeMasterPub: keysInfo.master_public_key, - feeDeposit: currentDenom.feeDeposit, - feeRefresh: currentDenom.feeRefresh, - feeRefund: currentDenom.feeRefund, - feeWithdraw: currentDenom.feeWithdraw, - value: currentDenom.value, - }; - fpRec = { - familyParams: fp, - }; - const insRes = await tx.denominationFamilies.put(fpRec); - denominationFamilySerial = insRes.key; - } else { - denominationFamilySerial = fpRec.denominationFamilySerial; - } + const drRowId = await tx.exchangeDetails.put(newDetails); + checkDbInvariant( + typeof drRowId.key === "number", + "exchange details key is not a number", + ); - checkDbInvariant( - typeof denominationFamilySerial === "number", - "denominationFamilySerial", - ); + for (const sk of keysInfo.signkeys) { + // FIXME: validate signing keys before inserting them + await tx.exchangeSignKeys.put({ + exchangeDetailsRowId: drRowId.key, + masterSig: sk.master_sig, + signkeyPub: sk.key, + stampEnd: timestampProtocolToDb(sk.stamp_end), + stampExpire: timestampProtocolToDb(sk.stamp_expire), + stampStart: timestampProtocolToDb(sk.stamp_start), + }); + } - // First, find denom family + // In the future: Filter out old denominations by index + const allOldDenoms = + await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); + const oldDenomByDph = new Map<string, DenominationRecord>(); + for (const denom of allOldDenoms) { + oldDenomByDph.set(denom.denomPubHash, denom); + } - const denomRec: DenominationRecord = { - currency: keysInfo.currency, - denominationFamilySerial, - denomPub: currentDenom.denomPub, - denomPubHash: currentDenom.denomPubHash, + logger.trace("updating denominations in database"); + + for (const currentDenom of denomInfos) { + // FIXME: Check if we really already need the denomination. + const familyParamsIndexKey = [ + currentDenom.exchangeBaseUrl, + currentDenom.exchangeMasterPub, + currentDenom.value, + currentDenom.feeWithdraw, + currentDenom.feeDeposit, + currentDenom.feeRefresh, + currentDenom.feeRefund, + ]; + let fpRec: DenominationFamilyRecord | undefined = + await tx.denominationFamilies.indexes.byFamilyParms.get( + familyParamsIndexKey, + ); + let denominationFamilySerial; + if (fpRec == null) { + const fp: DenomFamilyParams = { exchangeBaseUrl: exchangeBaseUrl, exchangeMasterPub: keysInfo.master_public_key, - fees: { - feeDeposit: currentDenom.feeDeposit, - feeRefresh: currentDenom.feeRefresh, - feeRefund: currentDenom.feeRefund, - feeWithdraw: currentDenom.feeWithdraw, - }, - isOffered: currentDenom.isOffered, - // If revoked, should not show up in keys response. - isRevoked: false, - masterSig: currentDenom.masterSig, - stampExpireDeposit: timestampProtocolToDb( - currentDenom.stampExpireDeposit, - ), - stampExpireLegal: timestampProtocolToDb( - currentDenom.stampExpireLegal, - ), - stampExpireWithdraw: timestampProtocolToDb( - currentDenom.stampExpireWithdraw, - ), - stampStart: timestampProtocolToDb(currentDenom.stampStart), + feeDeposit: currentDenom.feeDeposit, + feeRefresh: currentDenom.feeRefresh, + feeRefund: currentDenom.feeRefund, + feeWithdraw: currentDenom.feeWithdraw, value: currentDenom.value, - verificationStatus: DenominationVerificationStatus.Unverified, - isLost: currentDenom.isLost, }; + fpRec = { + familyParams: fp, + }; + const insRes = await tx.denominationFamilies.put(fpRec); + denominationFamilySerial = insRes.key; + } else { + denominationFamilySerial = fpRec.denominationFamilySerial; + } + + checkDbInvariant( + typeof denominationFamilySerial === "number", + "denominationFamilySerial", + ); - const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash); - if (oldDenom) { - // FIXME: Do consistency check, report to auditor if necessary. - // See https://bugs.taler.net/n/8594 + // First, find denom family - let changed = false; - // Mark lost denominations as lost. - if (currentDenom.isLost && !oldDenom.isLost) { - logger.warn( - `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`, - ); - oldDenom.isLost = true; - changed = true; - } - if (oldDenom.denominationFamilySerial != denominationFamilySerial) { - // Can happen in some wallet versions where denominations - // were not deleted properly when adding an exchange. - oldDenom.denominationFamilySerial = denominationFamilySerial; - changed = true; - } - if (changed) { - await tx.denominations.put(oldDenom); - } - } else { - await tx.denominations.put(denomRec); + const denomRec: DenominationRecord = { + currency: keysInfo.currency, + denominationFamilySerial, + denomPub: currentDenom.denomPub, + denomPubHash: currentDenom.denomPubHash, + exchangeBaseUrl: exchangeBaseUrl, + exchangeMasterPub: keysInfo.master_public_key, + fees: { + feeDeposit: currentDenom.feeDeposit, + feeRefresh: currentDenom.feeRefresh, + feeRefund: currentDenom.feeRefund, + feeWithdraw: currentDenom.feeWithdraw, + }, + isOffered: currentDenom.isOffered, + // If revoked, should not show up in keys response. + isRevoked: false, + masterSig: currentDenom.masterSig, + stampExpireDeposit: timestampProtocolToDb( + currentDenom.stampExpireDeposit, + ), + stampExpireLegal: timestampProtocolToDb(currentDenom.stampExpireLegal), + stampExpireWithdraw: timestampProtocolToDb( + currentDenom.stampExpireWithdraw, + ), + stampStart: timestampProtocolToDb(currentDenom.stampStart), + value: currentDenom.value, + verificationStatus: DenominationVerificationStatus.Unverified, + isLost: currentDenom.isLost, + }; + + const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash); + if (oldDenom) { + // FIXME: Do consistency check, report to auditor if necessary. + // See https://bugs.taler.net/n/8594 + + let changed = false; + // Mark lost denominations as lost. + if (currentDenom.isLost && !oldDenom.isLost) { + logger.warn( + `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`, + ); + oldDenom.isLost = true; + changed = true; + } + if (oldDenom.denominationFamilySerial != denominationFamilySerial) { + // Can happen in some wallet versions where denominations + // were not deleted properly when adding an exchange. + oldDenom.denominationFamilySerial = denominationFamilySerial; + changed = true; + } + if (changed) { + await tx.denominations.put(oldDenom); } + } else { + await tx.denominations.put(denomRec); } + } - // Update list issue date for all denominations, - // and mark non-offered denominations as such. - for (const x of allOldDenoms) { - if (!currentDenomSet.has(x.denomPubHash)) { - // FIXME: Here, an auditor report should be created, unless - // the denomination is really legally expired. - if (x.isOffered) { - x.isOffered = false; - logger.info( - `setting denomination ${x.denomPubHash} to offered=false`, - ); - await tx.denominations.put(x); - } - } else { - if (!x.isOffered) { - x.isOffered = true; - logger.info( - `setting denomination ${x.denomPubHash} to offered=true`, - ); - await tx.denominations.put(x); - } + // Update list issue date for all denominations, + // and mark non-offered denominations as such. + for (const x of allOldDenoms) { + if (!currentDenomSet.has(x.denomPubHash)) { + // FIXME: Here, an auditor report should be created, unless + // the denomination is really legally expired. + if (x.isOffered) { + x.isOffered = false; + logger.info( + `setting denomination ${x.denomPubHash} to offered=false`, + ); + await tx.denominations.put(x); + } + } else { + if (!x.isOffered) { + x.isOffered = true; + logger.info(`setting denomination ${x.denomPubHash} to offered=true`); + await tx.denominations.put(x); } } + } - logger.trace("done updating denominations in database"); + logger.trace("done updating denominations in database"); - const denomLossResult = await handleDenomLoss( - wex, - tx, - newDetails.currency, - exchangeBaseUrl, - ); + const denomLossResult = await handleDenomLoss( + wex, + tx, + newDetails.currency, + exchangeBaseUrl, + ); - if (keysInfo.recoup != null) { - await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup); - } + if (keysInfo.recoup != null) { + await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup); + } - const newExchangeState = getExchangeState(r); + const newExchangeState = getExchangeState(r); - return { - exchange: r, - exchangeDetails: newDetails, - oldExchangeState, - newExchangeState, - denomLossResult, - }; - }, - ); + return { + exchange: r, + exchangeDetails: newDetails, + oldExchangeState, + newExchangeState, + denomLossResult, + }; + }); if (!updated) { throw Error("something went wrong with updating the exchange"); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -170,10 +170,10 @@ import { TokenRecord, WalletDbAllStoresReadOnlyTransaction, WalletDbAllStoresReadWriteTransaction, - WalletDbHelpers, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, } from "./db.js"; +import { IdbWalletTransaction } from "./dbtx.js"; import { acceptDonauBlindSigs, generateDonauPlanchets } from "./donau.js"; import { getScopeForAllCoins, getScopeForAllExchanges } from "./exchanges.js"; import { @@ -2969,10 +2969,9 @@ export async function confirmPay( p.download.currency = Amounts.currencyOf(amount); } - const confRes = await WalletDbHelpers.getConfig( - tx, - ConfigRecordKey.DonauConfig, - ); + const wtx = new IdbWalletTransaction(tx); + + const confRes = await wtx.getConfig(ConfigRecordKey.DonauConfig); logger.info( `dona conf: ${j2s(confRes)}, useDonau: ${ diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -178,6 +178,7 @@ import { StoredBackupList, TestPayArgs, TestPayResult, + TestingCorruptWithdrawalCoinSelRequest, TestingGetDenomStatsRequest, TestingGetDenomStatsResponse, TestingGetDiagnosticsResponse, @@ -373,6 +374,7 @@ export enum WalletApiOperation { TestingRunFixup = "testingRunFixup", TestingGetFlightRecords = "testingGetFlightRecords", TestingGetPerformanceStats = "testingGetPerformanceStats", + TestingCorruptWithdrawalCoinSel = "testingCorruptWithdrawalCoinSel", // Diagnostics GetDiagnostics = "getDiagnostics", @@ -1659,6 +1661,12 @@ export type TestingGetFlightRecordsOp = { response: TestingGetFlightRecordsResponse; }; +export type TestingCorruptWithdrawalCoinSelOp = { + op: WalletApiOperation.TestingCorruptWithdrawalCoinSel; + request: TestingCorruptWithdrawalCoinSelRequest; + response: EmptyObject; +}; + /** * Set a coin as (un-)suspended. * Suspended coins won't be used for payments. @@ -1828,6 +1836,7 @@ export type WalletOperations = { [WalletApiOperation.TestingGetPerformanceStats]: GetPerformanceStatsOp; [WalletApiOperation.TestingGetFlightRecords]: TestingGetFlightRecordsOp; [WalletApiOperation.GetDefaultExchanges]: GetDefaultExchangesOp; + [WalletApiOperation.TestingCorruptWithdrawalCoinSel]: TestingCorruptWithdrawalCoinSelOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -140,6 +140,7 @@ import { TalerPreciseTimestamp, TalerProtocolTimestamp, TalerUriAction, + TestingCorruptWithdrawalCoinSelRequest, TestingGetDenomStatsRequest, TestingGetDenomStatsResponse, TestingGetDiagnosticsResponse, @@ -254,6 +255,7 @@ import { codecForString, codecForSuspendTransaction, codecForTestPayArgs, + codecForTestingCorruptWithdrawalCoinSelRequest, codecForTestingGetDenomStatsRequest, codecForTestingGetReserveHistoryRequest, codecForTestingPlanMigrateExchangeBaseUrlRequest, @@ -289,6 +291,7 @@ import { readSuccessResponseJsonOrThrow, type HttpRequestLibrary, } from "@gnu-taler/taler-util/http"; +import { randomBytes } from "crypto"; import { Result } from "../../taler-util/src/result.js"; import { getUserAttentions, @@ -323,7 +326,6 @@ import { CoinSourceType, ConfigRecordKey, DenominationRecord, - WalletDbHelpers, WalletDbReadOnlyTransaction, WalletStoresV1, applyFixups, @@ -336,6 +338,7 @@ import { timestampProtocolToDb, walletDbFixups, } from "./db.js"; +import { IdbWalletTransaction, WalletDbTransaction } from "./dbtx.js"; import { isCandidateWithdrawableDenomRec, isWithdrawableDenom, @@ -488,6 +491,7 @@ const logger = new Logger("wallet.ts"); * request handler or for a shepherded task. */ export interface WalletExecutionContext { + runWalletDbTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T>; readonly ws: InternalWalletState; readonly cryptoApi: TalerCryptoInterface; readonly cancellationToken: CancellationToken; @@ -1871,14 +1875,9 @@ async function handleGetCurrencySpecification( wex: WalletExecutionContext, req: GetCurrencySpecificationRequest, ): Promise<GetCurrencySpecificationResponse> { - const spec = await wex.db.runReadOnlyTx( - { - storeNames: ["currencyInfo"], - }, - async (tx) => { - return WalletDbHelpers.getCurrencyInfo(tx, req.scope); - }, - ); + const spec = await wex.runWalletDbTx(async (tx) => { + return tx.getCurrencyInfo(req.scope); + }); if (spec) { if ( wex.ws.devExperimentState.fakeDemoShortcuts != null && @@ -2101,6 +2100,29 @@ export async function handleGetDefaultExchanges( }; } +export async function handleTestingCorruptWithdrawalCoinSel( + wex: WalletExecutionContext, + req: TestingCorruptWithdrawalCoinSelRequest, +): Promise<EmptyObject> { + const txId = parseTransactionIdentifier(req.transactionId); + if (txId?.tag !== TransactionType.Withdrawal) { + throw Error("expected withdrawal transaction ID"); + } + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const wg = await tx.withdrawalGroups.get(txId.withdrawalGroupId); + if (!wg) { + return; + } + if (wg.denomsSel && (wg.denomsSel.selectedDenoms.length ?? 0) > 0) { + wg.denomsSel.selectedDenoms[0].denomPubHash = encodeCrock( + randomBytes(64), + ); + await tx.withdrawalGroups.put(wg); + } + }); + return {}; +} + export async function handleGetDiagnostics( wex: WalletExecutionContext, req: EmptyObject, @@ -2163,6 +2185,10 @@ interface HandlerWithValidator<Tag extends WalletApiOperation> { } const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { + [WalletApiOperation.TestingCorruptWithdrawalCoinSel]: { + codec: codecForTestingCorruptWithdrawalCoinSelRequest(), + handler: handleTestingCorruptWithdrawalCoinSel, + }, [WalletApiOperation.GetDefaultExchanges]: { codec: codecForEmptyObject(), handler: handleGetDefaultExchanges, @@ -2645,8 +2671,9 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForEmptyObject(), handler: async (wex, req) => { await clearDatabase(wex.db.idbHandle()); - await wex.taskScheduler.reload(); + wex.ws.flightRecords = []; wex.ws.clearAllCaches(); + await wex.taskScheduler.reload(); return {}; }, }, @@ -2878,6 +2905,9 @@ export function getObservedWalletExecutionContext( ): WalletExecutionContext { const db = ws.createDbAccessHandle(cancellationToken); const wex: WalletExecutionContext = { + async runWalletDbTx(f) { + return await ws.runWalletDbTx(f); + }, ws, cancellationToken, cts, @@ -2898,6 +2928,9 @@ export function getNormalWalletExecutionContext( ): WalletExecutionContext { const db = ws.createDbAccessHandle(cancellationToken); const wex: WalletExecutionContext = { + async runWalletDbTx(f) { + return await ws.runWalletDbTx(f); + }, ws, cancellationToken, cts, @@ -3374,6 +3407,14 @@ export class InternalWalletState { } } + runWalletDbTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T> { + // FIXME: Here's where we should add some retry logic. + return this.db.runAllStoresReadWriteTx({}, async (mytx) => { + const tx = new IdbWalletTransaction(mytx); + return await f(tx); + }); + } + createDbAccessHandle( cancellationToken: CancellationToken, ): DbAccess<typeof WalletStoresV1> { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -49,6 +49,7 @@ import { ExchangeWithdrawRequest, ExchangeWithdrawResponse, ExchangeWithdrawalDetails, + FlightRecordEvent, ForcedDenomSel, GetWithdrawalDetailsForAmountRequest, HashCode, @@ -141,7 +142,6 @@ import { PlanchetRecord, PlanchetStatus, WalletDbAllStoresReadOnlyTransaction, - WalletDbHelpers, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, WalletDbStoresArr, @@ -1496,7 +1496,7 @@ async function processPlanchetGenerate( wex: WalletExecutionContext, withdrawalGroup: WithdrawalGroupRecord, coinIdx: number, -): Promise<void> { +): Promise<{ badDenom?: boolean }> { checkDbInvariant( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", @@ -1516,7 +1516,7 @@ async function processPlanchetGenerate( }, ); if (planchet) { - return; + return {}; } let ci = 0; let isSkipped = false; @@ -1533,7 +1533,7 @@ async function processPlanchetGenerate( ci += d.count; } if (isSkipped) { - return; + return {}; } if (!maybeDenomPubHash) { throw Error("invariant violated"); @@ -1546,7 +1546,10 @@ async function processPlanchetGenerate( return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash); }, ); - checkDbInvariant(!!denom, `no denom info for ${denomPubHash}`); + if (!denom) { + // We handle this gracefully, to fix previous bugs that made it into production. + return { badDenom: true }; + } const r = await wex.cryptoApi.createPlanchet({ denomPub: denom.denomPub, feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), @@ -1583,6 +1586,7 @@ async function processPlanchetGenerate( await tx.planchets.put(newPlanchet); planchet = newPlanchet; }); + return {}; } interface WithdrawalRequestBatchArgs { @@ -2610,6 +2614,7 @@ async function redenominateWithdrawal( ): Promise<void> { await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl); logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`); + await wex.db.runReadWriteTx( { storeNames: [ @@ -2630,9 +2635,17 @@ async function redenominateWithdrawal( ); checkDbInvariant( wg.denomsSel !== undefined, - "can't process uninitialized exchange", + "can't process uninitialized wg", ); + if (!wg.reserveBalanceAmount) { + // FIXME: Should we transition to another state here to query it? + throw Error("reserve amount not known yet"); + } + const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost); + let remaining = Amount.from(wg.reserveBalanceAmount); + const zero = Amount.zeroOfCurrency(currency); + const exchangeBaseUrl = wg.exchangeBaseUrl; const candidates = await getWithdrawableDenomsTx( @@ -2648,8 +2661,6 @@ async function redenominateWithdrawal( logger.trace(`old denom sel: ${j2s(oldSel)}`); } - const zero = Amount.zeroOfCurrency(currency); - let amountRemaining = zero; let prevTotalCoinValue = zero; let prevTotalWithdrawalCost = zero; let prevHasDenomWithAgeRestriction = false; @@ -2662,39 +2673,44 @@ async function redenominateWithdrawal( exchangeBaseUrl, sel.denomPubHash, ]); - if (!denom) { - throw Error("denom in use but not not found"); - } - // FIXME: Also check planchet if there was a different error or planchet already withdrawn - const denomOkay = isWithdrawableDenom(denom); - const numCoins = sel.count - (sel.skip ?? 0); - const denomValue = Amount.from(denom.value).mult(numCoins); - const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult( - numCoins, - ); - if (denomOkay) { - prevTotalCoinValue = prevTotalCoinValue.add(denomValue); - prevTotalWithdrawalCost = prevTotalWithdrawalCost.add( - denomValue, - denomFeeWithdraw, - ); - prevDenoms.push({ - count: sel.count, - denomPubHash: sel.denomPubHash, - skip: sel.skip, - }); - prevHasDenomWithAgeRestriction = - prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0; - prevEarliestDepositExpiration = AbsoluteTime.min( - prevEarliestDepositExpiration, - timestampAbsoluteFromDb(denom.stampExpireDeposit), + + let denomOkay: boolean = false; + + if (denom != null) { + const denomOkay = isWithdrawableDenom(denom); + const numCoins = sel.count - (sel.skip ?? 0); + + const denomValue = Amount.from(denom.value).mult(numCoins); + const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult( + numCoins, ); - } else { - amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw); + + if (denomOkay) { + remaining = remaining.sub(denomValue).sub(denomFeeWithdraw); + prevTotalCoinValue = prevTotalCoinValue.add(denomValue); + prevTotalWithdrawalCost = prevTotalWithdrawalCost.add( + denomValue, + denomFeeWithdraw, + ); + prevDenoms.push({ + count: sel.count, + denomPubHash: sel.denomPubHash, + skip: sel.skip, + }); + prevHasDenomWithAgeRestriction = + prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0; + prevEarliestDepositExpiration = AbsoluteTime.min( + prevEarliestDepositExpiration, + timestampAbsoluteFromDb(denom.stampExpireDeposit), + ); + } + } + + if (!denomOkay) { prevDenoms.push({ count: sel.count, denomPubHash: sel.denomPubHash, - skip: (sel.skip ?? 0) + numCoins, + skip: sel.count, }); for (let j = 0; j < sel.count; j++) { @@ -2711,6 +2727,9 @@ async function redenominateWithdrawal( ); continue; } + // Technically the planchet could already + // have been withdrawn, then we're in for another + // re-denomination later. logger.info(`aborting planchet #${coinIndex}`); p.planchetStatus = PlanchetStatus.AbortedReplaced; await tx.planchets.put(p); @@ -2721,7 +2740,7 @@ async function redenominateWithdrawal( } const newSel = selectWithdrawalDenominations( - amountRemaining.toJson(), + remaining.toJson(), candidates, ); @@ -2843,7 +2862,17 @@ async function processWithdrawalGroupPendingReady( // We sequentially generate planchets, so that // large withdrawal groups don't make the wallet unresponsive. for (let i = 0; i < numTotalCoins; i++) { - await processPlanchetGenerate(wex, withdrawalGroup, i); + const res = await processPlanchetGenerate(wex, withdrawalGroup, i); + if (res.badDenom) { + logger.warn( + `redenomination required for withdrawal ${withdrawalGroupId}`, + ); + wex.ws.addFdr({ + target: ctx.transactionId, + event: FlightRecordEvent.WithdrawalRedenominate, + }); + return startRedenomination(ctx, exchangeBaseUrl); + } } const maxBatchSize = 64; @@ -2919,6 +2948,10 @@ async function processWithdrawalGroupPendingReady( if (redenomRequired) { logger.warn(`redenomination required for withdrawal ${withdrawalGroupId}`); + wex.ws.addFdr({ + target: ctx.transactionId, + event: FlightRecordEvent.WithdrawalRedenominate, + }); return startRedenomination(ctx, exchangeBaseUrl); } @@ -4501,10 +4534,9 @@ async function fetchAccount( transferAmount = Amounts.stringify(instructedAmount); // fetch currency specification from DB - const resp = await wex.db.runReadOnlyTx( - { storeNames: ["currencyInfo"] }, - (tx) => WalletDbHelpers.getCurrencyInfo(tx, scopeInfo), - ); + const resp = await wex.runWalletDbTx(async (tx) => { + return tx.getCurrencyInfo(scopeInfo); + }); if (resp) { currencySpecification = resp.currencySpec;