commit 810b6d4a34b7f758c3181d6525aff77591262f0c
parent 7ed1a0e8f8ced2723b1159294235d8fb9bfdb47c
Author: Florian Dold <florian@dold.me>
Date: Fri, 30 Jan 2026 01:26:46 +0100
wallet-core: use denom families for denom selection
Diffstat:
13 files changed, 243 insertions(+), 60 deletions(-)
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -694,7 +694,7 @@ withdrawCli
.action(async (args) => {
const uri = args.withdrawCheckUri.uri;
const restrictAge = args.withdrawCheckUri.restrictAge;
- console.log(`age restriction requested (${restrictAge})`);
+ logger.info(`age restriction requested (${restrictAge})`);
await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
const withdrawInfo = await wallet.client.call(
WalletApiOperation.GetWithdrawalDetailsForUri,
@@ -714,7 +714,7 @@ withdrawCli
.maybeOption("restrictAge", ["--restrict-age"], clk.INT)
.action(async (args) => {
const restrictAge = args.withdrawCheckAmount.restrictAge;
- console.log(`age restriction requested (${restrictAge})`);
+ logger.info(`age restriction requested (${restrictAge})`);
await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
const withdrawInfo = await wallet.client.call(
WalletApiOperation.GetWithdrawalDetailsForAmount,
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -176,6 +176,7 @@ export async function spendCoins(
"refreshGroups",
"refreshSessions",
"denominations",
+ "denominationFamilies",
"transactionsMeta",
]
>,
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -3221,9 +3221,13 @@ export const WalletStoresV1 = {
autoIncrement: true,
}),
{
- byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
- versionAdded: 27,
- }),
+ byExchangeBaseUrl: describeIndex(
+ "byExchangeBaseUrl",
+ "familyParams.exchangeBaseUrl",
+ {
+ versionAdded: 27,
+ },
+ ),
byFamilyParamsHash: describeIndex(
"byFamilyParamsHash",
"familyParamsHash",
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -941,6 +941,7 @@ async function refundDepositGroup(
"coinHistory",
"coins",
"denominations",
+ "denominationFamilies",
"depositGroups",
"refreshGroups",
"refreshSessions",
@@ -2377,6 +2378,7 @@ export async function createDepositGroup(
"coins",
"contractTerms",
"denominations",
+ "denominationFamilies",
"depositGroups",
"recoupGroups",
"refreshGroups",
@@ -2497,7 +2499,15 @@ async function getTotalFeesForDepositAmount(
const exchangeSet: Set<string> = new Set();
await wex.db.runReadOnlyTx(
- { storeNames: ["coins", "denominations", "exchanges", "exchangeDetails"] },
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "exchanges",
+ "exchangeDetails",
+ "denominationFamilies",
+ ],
+ },
async (tx) => {
for (let i = 0; i < pcs.length; i++) {
const denom = await getDenomInfo(
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -2234,6 +2234,7 @@ async function doExchangeAutoRefresh(
"coinHistory",
"coins",
"denominations",
+ "denominationFamilies",
"exchanges",
"refreshGroups",
"refreshSessions",
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -854,7 +854,7 @@ export async function getTotalPaymentCost(
pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
return wex.db.runReadOnlyTx(
- { storeNames: ["coins", "denominations"] },
+ { storeNames: ["coins", "denominations", "denominationFamilies"] },
async (tx) => {
return getTotalPaymentCostInTx(wex, tx, currency, pcs);
},
@@ -863,7 +863,9 @@ export async function getTotalPaymentCost(
export async function getTotalPaymentCostInTx(
wex: WalletExecutionContext,
- tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ tx: WalletDbReadOnlyTransaction<
+ ["coins", "denominations", "denominationFamilies"]
+ >,
currency: string,
pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
@@ -3293,20 +3295,8 @@ async function processPurchasePay(
selectCoinsResult.coinSel.coins,
);
- const transitionDone = await wex.db.runReadWriteTx(
- {
- storeNames: [
- "coinAvailability",
- "coinHistory",
- "coins",
- "denominations",
- "purchases",
- "refreshGroups",
- "refreshSessions",
- "tokens",
- "transactionsMeta",
- ],
- },
+ const transitionDone = await wex.db.runAllStoresReadWriteTx(
+ {},
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -88,7 +88,9 @@ export async function queryCoinInfosForSelection(
export async function getTotalPeerPaymentCostInTx(
wex: WalletExecutionContext,
- tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ tx: WalletDbReadOnlyTransaction<
+ ["coins", "denominations", "denominationFamilies"]
+ >,
pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
const costs: AmountJson[] = [];
@@ -131,7 +133,7 @@ export async function getTotalPeerPaymentCost(
await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl);
}
return wex.db.runReadOnlyTx(
- { storeNames: ["coins", "denominations"] },
+ { storeNames: ["coins", "denominations", "denominationFamilies"] },
async (tx) => {
return getTotalPeerPaymentCostInTx(wex, tx, pcs);
},
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -306,6 +306,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
"denominations",
"refreshGroups",
"refreshSessions",
+ "denominationFamilies",
],
},
async (pi, tx) => {
@@ -539,6 +540,7 @@ async function processPeerPullDebitPendingDeposit(
"exchanges",
"refreshGroups",
"refreshSessions",
+ "denominationFamilies",
],
},
async (rec, tx) => {
@@ -779,6 +781,7 @@ export async function confirmPeerPullDebit(
"exchanges",
"refreshGroups",
"refreshSessions",
+ "denominationFamilies",
],
},
async (rec, tx) => {
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -600,6 +600,7 @@ async function processPeerPushDebitCreateReserve(
"coins",
"contractTerms",
"denominations",
+ "denominationFamilies",
"exchanges",
"refreshGroups",
"refreshSessions",
@@ -818,6 +819,7 @@ async function processPeerPushDebitAbortingDeletePurse(
"coinHistory",
"coins",
"denominations",
+ "denominationFamilies",
"refreshGroups",
"refreshSessions",
],
@@ -896,6 +898,7 @@ async function processPeerPushDebitReady(
"coinHistory",
"coins",
"denominations",
+ "denominationFamilies",
"refreshGroups",
"refreshSessions",
],
diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts
@@ -258,7 +258,7 @@ class ResultStream<T> {
return arr;
}
- async next(): Promise<CursorResult<T>> {
+ async next(key?: IDBValidKey): Promise<CursorResult<T>> {
if (this.gotCursorEnd) {
return { hasValue: false };
}
@@ -280,6 +280,32 @@ class ResultStream<T> {
}
return { hasValue: true, value: cursor.value };
}
+
+ continue(k?: IDBValidKey): void {
+ if (this.awaitingResult) {
+ throw Error("can't continue while waiting for next cursor value");
+ }
+ const cursor: IDBCursor | undefined = this.req.result;
+ if (!cursor) {
+ throw Error("assertion failed");
+ }
+ this.awaitingResult = true;
+ cursor.continue(k);
+ }
+
+ async current(): Promise<CursorResult<T>> {
+ if (this.gotCursorEnd) {
+ return { hasValue: false };
+ }
+ if (this.awaitingResult) {
+ await this.currentPromise;
+ if (this.gotCursorEnd) {
+ return { hasValue: false };
+ }
+ }
+ const cursor = this.req.result;
+ return { hasValue: true, value: cursor.value };
+ }
}
/**
diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts
@@ -442,6 +442,7 @@ export async function processRecoupGroup(
"coinHistory",
"coins",
"denominations",
+ "denominationFamilies",
"recoupGroups",
"refreshGroups",
"refreshSessions",
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -434,7 +434,7 @@ export class RefreshTransactionContext implements TransactionContext {
export async function getTotalRefreshCost(
wex: WalletExecutionContext,
- tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ tx: WalletDbReadOnlyTransaction<["denominations", "denominationFamilies"]>,
refreshedDenom: DenominationInfo,
amountLeft: AmountJson,
): Promise<AmountJson> {
@@ -532,7 +532,13 @@ async function getCoinAvailabilityForDenom(
async function initRefreshSession(
wex: WalletExecutionContext,
tx: WalletDbReadWriteTransaction<
- ["refreshSessions", "coinAvailability", "coins", "denominations"]
+ [
+ "refreshSessions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "denominationFamilies",
+ ]
>,
refreshGroup: RefreshGroupRecord,
coinIndex: number,
@@ -1168,6 +1174,7 @@ async function handleRefreshMeltConflict(
"refreshGroups",
"refreshSessions",
"denominations",
+ "denominationFamilies",
"coins",
"coinAvailability",
"transactionsMeta",
@@ -1904,7 +1911,13 @@ export interface RefreshOutputInfo {
export async function calculateRefreshOutput(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<
- ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ [
+ "denominations",
+ "denominationFamilies",
+ "coins",
+ "refreshGroups",
+ "coinAvailability",
+ ]
>,
currency: string,
oldCoinPubs: CoinRefreshRequest[],
@@ -2059,6 +2072,7 @@ export async function createRefreshGroup(
tx: WalletDbReadWriteTransaction<
[
"denominations",
+ "denominationFamilies",
"coins",
"coinHistory",
"refreshGroups",
@@ -2304,6 +2318,7 @@ export async function forceRefresh(
"coinAvailability",
"refreshSessions",
"denominations",
+ "denominationFamilies",
"coins",
"coinHistory",
"transactionsMeta",
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -64,6 +64,7 @@ import {
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
TalerUriAction,
Transaction,
TransactionAction,
@@ -152,6 +153,7 @@ import {
timestampAbsoluteFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
+ timestampProtocolToDb,
} from "./db.js";
import {
selectForcedWithdrawalDenominations,
@@ -1371,7 +1373,7 @@ async function getWithdrawableDenoms(
currency: string,
): Promise<DenominationRecord[]> {
return await wex.db.runReadOnlyTx(
- { storeNames: ["denominations"] },
+ { storeNames: ["denominations", "denominationFamilies"] },
async (tx) => {
return getWithdrawableDenomsTx(wex, tx, exchangeBaseUrl, currency);
},
@@ -1387,31 +1389,66 @@ async function getWithdrawableDenoms(
*/
export async function getWithdrawableDenomsTx(
wex: WalletExecutionContext,
- tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ tx: WalletDbReadOnlyTransaction<["denominations", "denominationFamilies"]>,
exchangeBaseUrl: string,
currency: string,
+ maxAmount?: AmountLike,
): Promise<DenominationRecord[]> {
- // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations!
- const allDenoms =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
- const withdrawableDenoms: DenominationRecord[] = [];
- for (const denom of allDenoms) {
- if (denom.currency !== currency) {
+ const dbNow = timestampProtocolToDb(TalerProtocolTimestamp.now());
+ const allFamilies =
+ await tx.denominationFamilies.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ const relevantDenoms: DenominationRecord[] = [];
+ for (const fam of allFamilies) {
+ if (
+ maxAmount != null &&
+ Amounts.cmp(fam.familyParams.value, maxAmount) > 0
+ ) {
+ // Denom value too high, no point in selecting.
continue;
}
- if (!isCandidateWithdrawableDenomRec(denom)) {
+ const fpSerial = fam.denominationFamilySerial;
+ checkDbInvariant(typeof fpSerial === "number", "denominationFamilySerial");
+ // Now we need to find a representative denom for the family.
+ let denom: DenominationRecord | undefined;
+ const denomCursor =
+ tx.denominations.indexes.byDenominationFamilySerialAndStampExpireWithdraw.iter();
+ const dr0 = await denomCursor.current();
+ if (!dr0.hasValue) {
+ logger.warn(`no current denom for family ${fpSerial}`);
continue;
}
if (
- denom.verificationStatus === DenominationVerificationStatus.Unverified
+ !(
+ dr0.value.denominationFamilySerial > fpSerial ||
+ (dr0.value.denominationFamilySerial === fpSerial &&
+ dr0.value.stampExpireWithdraw >= dbNow)
+ )
) {
- const msg = "candidate withdrawal denomination not verified";
- logger.error(msg);
- throw Error(msg);
+ denomCursor.continue([fpSerial, dbNow]);
+ }
+ while (1) {
+ const dr = await denomCursor.current();
+ if (!dr.hasValue) {
+ break;
+ }
+ if (dr.value.denominationFamilySerial != fpSerial) {
+ // Cursor went to next serial already, we need to stop.
+ break;
+ }
+ if (isCandidateWithdrawableDenomRec(dr.value)) {
+ denom = dr.value;
+ break;
+ }
+
+ denomCursor.continue();
+ }
+ if (denom) {
+ relevantDenoms.push(denom);
}
- withdrawableDenoms.push(denom);
}
- return withdrawableDenoms;
+ return relevantDenoms;
}
/**
@@ -2085,26 +2122,76 @@ export async function updateWithdrawalDenomsForExchange(
logger.trace(
`updating denominations used for withdrawal for ${exchangeBaseUrl}`,
);
+ const dbNow = timestampProtocolToDb(TalerProtocolTimestamp.now());
const res = await wex.db.runReadOnlyTx(
- { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ "denominationFamilies",
+ ],
+ },
async (tx) => {
const exchangeDetails = await getExchangeDetailsInTx(tx, exchangeBaseUrl);
- let denominations: DenominationRecord[] | undefined = [];
- if (exchangeDetails) {
- // FIXME: Use denom groups to speed this up.
- const allDenoms =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(
- exchangeBaseUrl,
- );
- denominations = allDenoms
- .filter((d) => d.currency === exchangeDetails.currency)
- .filter((d) => isCandidateWithdrawableDenomRec(d));
+ const allFamilies =
+ await tx.denominationFamilies.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ const denominations: DenominationRecord[] | undefined = [];
+ for (const fam of allFamilies) {
+ const fpSerial = fam.denominationFamilySerial;
+ checkDbInvariant(
+ typeof fpSerial === "number",
+ "denominationFamilySerial",
+ );
+ const denomCursor =
+ tx.denominations.indexes.byDenominationFamilySerialAndStampExpireWithdraw.iter();
+ // Need to wait for cursor to be positioned before we can move it.
+ const dr0 = await denomCursor.current();
+ if (!dr0.hasValue) {
+ logger.warn(`no current denom for family ${fpSerial}`);
+ continue;
+ }
+ if (
+ !(
+ dr0.value.denominationFamilySerial > fpSerial ||
+ (dr0.value.denominationFamilySerial === fpSerial &&
+ dr0.value.stampExpireWithdraw >= dbNow)
+ )
+ ) {
+ denomCursor.continue([fpSerial, dbNow]);
+ }
+ while (1) {
+ const dr = await denomCursor.current();
+ if (!dr.hasValue) {
+ break;
+ }
+ if (dr.value.denominationFamilySerial != fpSerial) {
+ // Cursor went to next serial, we need to stop.
+ break;
+ }
+ if (isCandidateWithdrawableDenomRec(dr.value)) {
+ if (
+ dr.value.verificationStatus ===
+ DenominationVerificationStatus.Unverified
+ ) {
+ denominations.push(dr.value);
+ }
+ break;
+ }
+ denomCursor.continue();
+ }
}
return { exchangeDetails, denominations };
},
);
+ logger.info(
+ `need to validate ${res.denominations.length} withdrawal candidate denominations`,
+ );
+
const exchangeDetails = res.exchangeDetails;
if (!exchangeDetails) {
@@ -2115,7 +2202,7 @@ export async function updateWithdrawalDenomsForExchange(
// is checked and the result is stored in the database.
const denominations = res.denominations;
logger.trace(`got ${denominations.length} candidate denominations`);
- const batchSize = 500;
+ const batchSize = 50;
let current = 0;
while (current < denominations.length) {
@@ -2176,6 +2263,27 @@ export async function updateWithdrawalDenomsForExchange(
}
/**
+ * Look up relevant denominations (one candidate per family)
+ * and ensure that they are validated.
+ */
+async function getWithdrawalCandidateDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ amount: AmountString,
+): Promise<DenominationRecord[]> {
+ await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl);
+ return await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
+ return getWithdrawableDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ Amounts.currencyOf(amount),
+ amount,
+ );
+ });
+}
+
+/**
* Update the information about a reserve that is stored in the wallet
* by querying the reserve's exchange.
*
@@ -2210,6 +2318,16 @@ async function processQueryReserve(
"can't process uninitialized exchange",
);
+ const instructedAmount = withdrawalGroup.instructedAmount;
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+ const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount);
+
+ const relevantDenoms = await getWithdrawalCandidateDenoms(
+ wex,
+ exchangeBaseUrl,
+ instructedAmount,
+ );
+
const reservePub = withdrawalGroup.reservePub;
const reserveUrl = new URL(
@@ -2271,12 +2389,14 @@ async function processQueryReserve(
);
}
- const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
- const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount);
-
const transitionResult = await ctx.transition(
{
- extraStores: ["denominations", "bankAccountsV2", "planchets"],
+ extraStores: [
+ "denominations",
+ "denominationFamilies",
+ "bankAccountsV2",
+ "planchets",
+ ],
},
async (wg, tx) => {
if (!wg) {
@@ -2459,7 +2579,14 @@ async function redenominateWithdrawal(
await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl);
logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
await wex.db.runReadWriteTx(
- { storeNames: ["withdrawalGroups", "planchets", "denominations"] },
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "planchets",
+ "denominations",
+ "denominationFamilies",
+ ],
+ },
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {