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:
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;