taler-typescript-core

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

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:
Mpackages/taler-wallet-cli/src/index.ts | 4++--
Mpackages/taler-wallet-core/src/common.ts | 1+
Mpackages/taler-wallet-core/src/db.ts | 10+++++++---
Mpackages/taler-wallet-core/src/deposits.ts | 12+++++++++++-
Mpackages/taler-wallet-core/src/exchanges.ts | 1+
Mpackages/taler-wallet-core/src/pay-merchant.ts | 22++++++----------------
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 6++++--
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 3+++
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 3+++
Mpackages/taler-wallet-core/src/query.ts | 28+++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/recoup.ts | 1+
Mpackages/taler-wallet-core/src/refresh.ts | 21++++++++++++++++++---
Mpackages/taler-wallet-core/src/withdraw.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
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) {