taler-typescript-core

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

commit beb73940b19ebdc3cc5da42e09d2a946b0c60e7c
parent a3d5df4fe0aafb498347d35b86168fdc035a4a5b
Author: Florian Dold <florian@dold.me>
Date:   Wed, 14 Jan 2026 16:47:17 +0100

wallet-core: fix issue with empty refresh groups

In some situations, wallet-core created refresh groups with a non-zero
input and zero output.  This happened when required output denominations
were not validated yet.  In this case, wallet-core treated the
denominations as absent and wasn't able to create any outputs for the
refresh, so a refresh group with empty outputs was created.

This was fixed by:
* disallowing the creation of refresh groups when there are required
  denominations that should be validated but aren't validated yet
* validating denominations in the background
* repairing existing, bad refresh transactions via fixup

Diffstat:
Mpackages/taler-util/src/types-taler-wallet.ts | 10++++++++++
Mpackages/taler-wallet-core/src/common.ts | 28+++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/db.ts | 137++++++++++++++++++++++++-------------------------------------------------------
Mpackages/taler-wallet-core/src/denomSelection.ts | 20++++++++++++++++++--
Mpackages/taler-wallet-core/src/denominations.ts | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-wallet-core/src/exchanges.ts | 3+++
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 10++++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 4++++
Mpackages/taler-wallet-core/src/refresh.ts | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/shepherd.ts | 15+++++++++++----
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 9+++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/withdraw.ts | 59++++++++++++++++++++++++++++-------------------------------
13 files changed, 368 insertions(+), 164 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -4056,6 +4056,16 @@ export interface TestingGetDenomStatsResponse { numLost: number; } +export interface TestingGetDiagnosticsResponse { + version: 0; + exchangeEntries: { + exchangeBaseUrl: string; + numDenoms: number; + numWithdrawableDenoms: number; + numCandidateWithdrawableDenoms: number; + }[]; +} + export const codecForTestingGetDenomStatsRequest = (): Codec<TestingGetDenomStatsRequest> => buildCodecForObject<TestingGetDenomStatsRequest>() diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -23,6 +23,7 @@ import { CoinRefreshRequest, CoinStatus, Duration, + DurationUnitSpec, ExchangeEntryState, ExchangeEntryStatus, ExchangeTosStatus, @@ -468,6 +469,16 @@ export namespace TaskRunResult { runAt, }; } + + /** + * Run the task again at a fixed time in the future. + */ + export function runAgainAfter(d: DurationUnitSpec): TaskRunResult { + return { + type: TaskRunResultType.ScheduleLater, + runAt: AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec(d)), + }; + } /** * Longpolling returned, but what we're waiting for * is still pending on the other side. @@ -644,6 +655,9 @@ export function getAutoRefreshExecuteThreshold(d: { * interface to the wallet. */ +/** + * Pending task types. + */ export enum PendingTaskType { ExchangeUpdate = "exchange-update", ExchangeAutoRefresh = "exchange-auto-refresh", @@ -651,7 +665,6 @@ export enum PendingTaskType { Purchase = "purchase", Refresh = "refresh", Recoup = "recoup", - RewardPickup = "reward-pickup", Withdraw = "withdraw", Deposit = "deposit", Backup = "backup", @@ -659,6 +672,7 @@ export enum PendingTaskType { PeerPullCredit = "peer-pull-credit", PeerPushCredit = "peer-push-credit", PeerPullDebit = "peer-pull-debit", + ValidateDenoms = "validate-denoms", } /** @@ -680,8 +694,8 @@ export type ParsedTaskIdentifier = | { tag: PendingTaskType.PeerPushDebit; pursePub: string } | { tag: PendingTaskType.Purchase; proposalId: string } | { tag: PendingTaskType.Recoup; recoupGroupId: string } - | { tag: PendingTaskType.RewardPickup; walletRewardId: string } - | { tag: PendingTaskType.Refresh; refreshGroupId: string }; + | { tag: PendingTaskType.Refresh; refreshGroupId: string } + | { tag: PendingTaskType.ValidateDenoms }; export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { const task = x.split(":"); @@ -716,10 +730,10 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { return { tag: type, recoupGroupId: rest[0] }; case PendingTaskType.Refresh: return { tag: type, refreshGroupId: rest[0] }; - case PendingTaskType.RewardPickup: - return { tag: type, walletRewardId: rest[0] }; case PendingTaskType.Withdraw: return { tag: type, withdrawalGroupId: rest[0] }; + case PendingTaskType.ValidateDenoms: + return { tag: type }; default: throw Error("invalid task identifier"); } @@ -751,10 +765,10 @@ export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr { return `${p.tag}:${p.recoupGroupId}` as TaskIdStr; case PendingTaskType.Refresh: return `${p.tag}:${p.refreshGroupId}` as TaskIdStr; - case PendingTaskType.RewardPickup: - return `${p.tag}:${p.walletRewardId}` as TaskIdStr; case PendingTaskType.Withdraw: return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr; + case PendingTaskType.ValidateDenoms: + return `${p.tag}:` as TaskIdStr; default: assertUnreachable(p); } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -98,7 +98,6 @@ import { describeStoreV2, openDatabase, } from "./query.js"; -import { rematerializeTransactions } from "./transactions.js"; /** * This file contains the database schema of the Taler wallet together @@ -172,7 +171,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 25; +export const WALLET_DB_MINOR_VERSION = 26; declare const symDbProtocolTimestamp: unique symbol; @@ -1193,7 +1192,10 @@ export enum RefreshCoinStatus { export enum RefreshOperationStatus { Pending = 0x0100_0000, + /** Output coin selection was bad, re-select. */ + PendingRedenominate = 0x0100_0001, Suspended = 0x0110_0000, + SuspendedRedenominate = 0x0110_0001, Finished = 0x0500_000, Failed = 0x0501_000, @@ -3165,6 +3167,13 @@ export const WalletStoresV1 = { }), { byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl"), + byVerificationStatus: describeIndex( + "byVerificationStatus", + "verificationStatus", + { + versionAdded: 26, + }, + ), }, ), exchanges: describeStore( @@ -3819,32 +3828,47 @@ export interface FixupDescription { * Manual migrations between minor versions of the DB schema. */ export const walletDbFixups: FixupDescription[] = [ - // Can be removed 2025-11-01. - { - fn: fixup20250916TopsBlunder, - name: "fixup20250916TopsBlunder", - }, // Removing this would cause old transactions // to show up under multiple exchanges { fn: fixup20250915TransactionsScope, name: "fixup20250915TransactionsScope", }, - // Can be removed, since hash is generated - // on the fly for old tokens when missing. - { - fn: fixupTokenFamilyHash, - name: "fixupTokenFamilyHash", - }, // Removing this would cause merchant acceptable // amount to be calculaed based on exchangeBaseUrl // instead of masterPublicKey for old coins. { fn: fixupCoinAvailabilityExchangePub, name: "fixupCoinAvailabilityExchangePub", - } + }, + // Can be removed once all affected refresh groups have + // been fixed. Conservative estimate: Jan 2028. + { + fn: fixup20260116BadRefreshCoinSelection, + name: "fixup20260116BadRefreshCoinSelection", + }, ]; +async function fixup20260116BadRefreshCoinSelection( + tx: WalletDbAllStoresReadWriteTransaction, +): Promise<void> { + await tx.refreshGroups.iter().forEachAsync(async (rec) => { + const inputAmount = Amounts.sumOrZero( + rec.currency, + rec.inputPerCoin, + ).amount; + const outputAmount = Amounts.sumOrZero( + rec.currency, + rec.expectedOutputPerCoin, + ).amount; + if (Amounts.isNonZero(inputAmount) && Amounts.isZero(outputAmount)) { + logger.info(`fixing up refresh group ${rec.refreshGroupId}, setting status to PendingRedenominate`); + rec.operationStatus = RefreshOperationStatus.PendingRedenominate; + delete rec.timestampFinished; + await tx.refreshGroups.put(rec); + } + }); +} /** * Some old payment transactions didn't correctly @@ -3890,85 +3914,6 @@ async function fixup20250915TransactionsScope( }); } -/** - * TOPS accidentally revoked keys. - * Make sure to re-request keys and re-do denom selection - * for withdrawal groups with zero selected denominations. - */ -async function fixup20250916TopsBlunder( - tx: WalletDbAllStoresReadWriteTransaction, -): Promise<void> { - const exchangeUrls = [ - "https://exchange.taler-ops.ch/", - "https://exchange.stage.taler-ops.ch/", - ]; - - for (const exch of exchangeUrls) { - const exchRec = await tx.exchanges.get(exch); - if (!exchRec) { - continue; - } - logger.info( - `have exchange ${exch} in update state ${exchRec.updateStatus}`, - ); - exchRec.lastUpdate = undefined; - exchRec.lastKeysEtag = undefined; - switch (exchRec.updateStatus) { - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - case ExchangeEntryDbUpdateStatus.Ready: - break; - default: - continue; - } - logger.info(`fixup: forcing update of exchange ${exch}`); - exchRec.lastKeysEtag = undefined; - exchRec.lastUpdate = undefined; - exchRec.updateRetryCounter = undefined; - exchRec.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; - exchRec.nextUpdateStamp = timestampPreciseToDb(TalerPreciseTimestamp.now()); - await tx.exchanges.put(exchRec); - } - for (const exch of exchangeUrls) { - const wgs = - await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(exch); - logger.info( - `have ${wgs.length} withdrawal transactions that might need fixup`, - ); - for (const wg of wgs) { - logger.info(`status ${wg.status}`); - logger.info(`denom sel ${j2s(wg.denomsSel)}`); - if (wg.status !== WithdrawalGroupStatus.Done) { - continue; - } - let numActiveDenoms = 0; - if (wg.denomsSel?.selectedDenoms) { - for (const sd of wg.denomsSel.selectedDenoms) { - numActiveDenoms += sd.count - (sd.skip ?? 0); - } - } - if (numActiveDenoms > 0) { - continue; - } - logger.info(`updating withdrawal group status`); - wg.status = WithdrawalGroupStatus.PendingQueryingStatus; - await tx.withdrawalGroups.put(wg); - } - } -} - -async function fixupTokenFamilyHash( - tx: WalletDbAllStoresReadWriteTransaction, -): Promise<void> { - const tokens = await tx.tokens.getAll(); - for (const token of tokens) { - if (!token.tokenFamilyHash) { - logger.info(`hashing token family info for ${token.tokenIssuePubHash}`); - token.tokenFamilyHash = TokenRecord.hashInfo(token); - await tx.tokens.put(token); - } - } -} - async function fixupCoinAvailabilityExchangePub( tx: WalletDbAllStoresReadWriteTransaction, ): Promise<void> { @@ -3977,8 +3922,10 @@ async function fixupCoinAvailabilityExchangePub( for (const car of cars) { if (car.exchangeMasterPub === undefined) { if (exchanges[car.exchangeBaseUrl] === undefined) { - exchanges[car.exchangeBaseUrl] = await tx.exchangeDetails.indexes - .byExchangeBaseUrl.get(car.exchangeBaseUrl); + exchanges[car.exchangeBaseUrl] = + await tx.exchangeDetails.indexes.byExchangeBaseUrl.get( + car.exchangeBaseUrl, + ); } const exchange = exchanges[car.exchangeBaseUrl]; diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts @@ -40,6 +40,13 @@ const logger = new Logger("denomSelection.ts"); * Get a list of denominations (with repetitions possible) * whose total value is as close as possible to the available * amount, but never larger. + * + * Throws if any of the denoms is invalid. + * + * @param amountAvailable available withdrawal amount + * @param denoms list of denominations that can be used, + * must be validated to be withdrawable + * @param opts extra options */ export function selectWithdrawalDenominations( amountAvailable: AmountJson, @@ -58,7 +65,14 @@ export function selectWithdrawalDenominations( let earliestDepositExpiration: AbsoluteTime | undefined; let hasDenomWithAgeRestriction = false; - denoms = denoms.filter((d) => isWithdrawableDenom(d)); + for (const d of denoms) { + if (!isWithdrawableDenom(d)) { + throw Error( + "non-withdrawable denom passed to selectWithdrawalDenominations", + ); + } + } + denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); if (logger.shouldLogTrace()) { @@ -147,7 +161,9 @@ export function selectForcedWithdrawalDenominations( let earliestDepositExpiration: AbsoluteTime | undefined; let hasDenomWithAgeRestriction = false; - denoms = denoms.filter((d) => isWithdrawableDenom(d)); + // Since this is a forced withdrawal (used for testing), we do *not* check + // if the denoms are actually withdrawable. + denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); for (const fds of forcedDenomSel.denoms) { diff --git a/packages/taler-wallet-core/src/denominations.ts b/packages/taler-wallet-core/src/denominations.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (C) 2021-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 @@ -31,11 +31,13 @@ import { TalerProtocolTimestamp, TimePoint, } from "@gnu-taler/taler-util"; +import { TaskRunResult } from "./common.js"; import { DenominationRecord, DenominationVerificationStatus, timestampProtocolFromDb, } from "./db.js"; +import { WalletExecutionContext } from "./index.js"; /** * Logger for this file. @@ -473,27 +475,6 @@ export function isWithdrawableDenom(d: DenominationRecord): boolean { ); return false; } - const now = AbsoluteTime.now(); - const start = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampStart), - ); - const started = AbsoluteTime.cmp(now, start) >= 0; - if (!started) { - // Withdrawal only starts in the future. - return false; - } - const withdrawExpire = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampExpireWithdraw), - ); - const lastPossibleWithdraw = AbsoluteTime.subtractDuraction( - withdrawExpire, - Duration.fromSpec({ minutes: 5 }), - ); - const remaining = Duration.getRemaining(lastPossibleWithdraw, now); - const withdrawalStillOkay = remaining.d_ms !== 0; - if (!withdrawalStillOkay) { - return false; - } switch (d.verificationStatus) { case DenominationVerificationStatus.Unverified: logger.error( @@ -507,7 +488,7 @@ export function isWithdrawableDenom(d: DenominationRecord): boolean { default: assertUnreachable(d.verificationStatus); } - return !d.isRevoked && d.isOffered; + return isCandidateWithdrawableDenom(d); } /** @@ -544,3 +525,75 @@ export function isCandidateWithdrawableDenom(d: DenominationRecord): boolean { const stillOkay = remaining.d_ms !== 0; return started && stillOkay && !d.isRevoked && d.isOffered && !d.isLost; } + +export async function isValidDenomRecord( + wex: WalletExecutionContext, + exchangeMasterPub: string, + d: DenominationRecord, +): Promise<boolean> { + const res = await wex.cryptoApi.isValidDenom({ + masterPub: exchangeMasterPub, + denomPubHash: d.denomPubHash, + feeDeposit: Amounts.parseOrThrow(d.fees.feeDeposit), + feeRefresh: Amounts.parseOrThrow(d.fees.feeRefresh), + feeRefund: Amounts.parseOrThrow(d.fees.feeRefund), + feeWithdraw: Amounts.parseOrThrow(d.fees.feeWithdraw), + masterSig: d.masterSig, + stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), + stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal), + stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), + stampStart: timestampProtocolFromDb(d.stampStart), + value: Amounts.parseOrThrow(d.value), + }); + return res.valid; +} + +const denomBatchSize = 70; + +export async function processValidateDenoms( + wex: WalletExecutionContext, +): Promise<TaskRunResult> { + logger.info("validating denoms in background task"); + const denoms = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + return tx.denominations.indexes.byVerificationStatus.getAll( + DenominationVerificationStatus.Unverified, + denomBatchSize, + ); + }); + if (denoms.length === 0) { + return TaskRunResult.finished(); + } + logger.info(`validating batch of ${denoms.length} denoms`); + const dv: boolean[] = []; + for (const denom of denoms) { + const valid = await isValidDenomRecord(wex, denom.exchangeMasterPub, denom); + dv.push(valid); + } + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + for (let i = 0; i < denoms.length; i++) { + const d = denoms[i]; + const rec = await tx.denominations.get([ + d.exchangeBaseUrl, + d.denomPubHash, + ]); + if (!rec) { + continue; + } + if ( + rec.verificationStatus !== DenominationVerificationStatus.Unverified + ) { + continue; + } + if (dv[i]) { + rec.verificationStatus = DenominationVerificationStatus.VerifiedGood; + } else { + rec.verificationStatus = DenominationVerificationStatus.VerifiedBad; + } + await tx.denominations.put(rec); + } + }); + logger.info(`denom validation batch done`); + return TaskRunResult.runAgainAfter({ + seconds: 1, + }); +} diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -2200,6 +2200,9 @@ export async function updateExchangeFromUrlHandler( const autoRefreshTaskId = TaskIdentifiers.forExchangeAutoRefreshFromUrl(exchangeBaseUrl); await wex.taskScheduler.resetTaskRetries(autoRefreshTaskId); + await wex.taskScheduler.resetTaskRetries( + constructTaskIdentifier({ tag: PendingTaskType.ValidateDenoms }), + ); // Next invocation will cause the task to be run again // at the necessary time. diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -99,6 +99,7 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; +import { updateWithdrawalDenomsForCurrency } from "./withdraw.js"; const logger = new Logger("pay-peer-pull-debit.ts"); @@ -286,6 +287,15 @@ export class PeerPullDebitTransactionContext implements TransactionContext { } async abortTransaction(reason?: TalerErrorDetail): Promise<void> { + const oldRec = await this.wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const [rec, _] = await this.getRecordHandle(tx); + return rec; + }); + if (!oldRec) { + return; + } + const currency = Amounts.currencyOf(oldRec.amount); + await updateWithdrawalDenomsForCurrency(this.wex, currency); await recordTransition( this, { diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -806,6 +806,10 @@ async function processPeerPushDebitAbortingDeletePurse( return TaskRunResult.finished(); } + const currency = Amounts.currencyOf(peerPushInitiation.amount); + + await updateWithdrawalDenomsForCurrency(wex, currency); + await recordTransition( ctx, { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -361,12 +361,17 @@ export class RefreshTransactionContext implements TransactionContext { switch (rec.operationStatus) { case RefreshOperationStatus.Finished: case RefreshOperationStatus.Suspended: + case RefreshOperationStatus.SuspendedRedenominate: case RefreshOperationStatus.Failed: return TransitionResult.stay(); case RefreshOperationStatus.Pending: { rec.operationStatus = RefreshOperationStatus.Suspended; return TransitionResult.transition(rec); } + case RefreshOperationStatus.PendingRedenominate: { + rec.operationStatus = RefreshOperationStatus.SuspendedRedenominate; + return TransitionResult.transition(rec); + } default: assertUnreachable(rec.operationStatus); } @@ -387,11 +392,16 @@ export class RefreshTransactionContext implements TransactionContext { case RefreshOperationStatus.Finished: case RefreshOperationStatus.Failed: case RefreshOperationStatus.Pending: + case RefreshOperationStatus.PendingRedenominate: return TransitionResult.stay(); case RefreshOperationStatus.Suspended: { rec.operationStatus = RefreshOperationStatus.Pending; return TransitionResult.transition(rec); } + case RefreshOperationStatus.SuspendedRedenominate: { + rec.operationStatus = RefreshOperationStatus.PendingRedenominate; + return TransitionResult.transition(rec); + } default: assertUnreachable(rec.operationStatus); } @@ -408,6 +418,8 @@ export class RefreshTransactionContext implements TransactionContext { case RefreshOperationStatus.Failed: return TransitionResult.stay(); case RefreshOperationStatus.Pending: + case RefreshOperationStatus.PendingRedenominate: + case RefreshOperationStatus.SuspendedRedenominate: case RefreshOperationStatus.Suspended: { rec.operationStatus = RefreshOperationStatus.Failed; rec.failReason = reason; @@ -430,6 +442,7 @@ export async function getTotalRefreshCost( const key = `denom=${exchangeBaseUrl}/${denomPubHash};left=${Amounts.stringify( amountLeft, )}`; + // FIXME: What about expiration of this cache? return wex.ws.refreshCostCache.getOrPut(key, async () => { const allDenoms = await getWithdrawableDenomsTx( wex, @@ -1705,6 +1718,16 @@ export async function processRefreshGroup( if (!refreshGroup) { return TaskRunResult.finished(); } + + switch (refreshGroup.operationStatus) { + case RefreshOperationStatus.Pending: + break; + case RefreshOperationStatus.PendingRedenominate: + return redenominateRefresh(wex, refreshGroupId); + default: + return TaskRunResult.finished(); + } + if (refreshGroup.timestampFinished) { return TaskRunResult.finished(); } @@ -1888,8 +1911,6 @@ export async function calculateRefreshOutput( ): Promise<RefreshOutputInfo> { const estimatedOutputPerCoin: AmountJson[] = []; - const denomsPerExchange: Record<string, DenominationRecord[]> = {}; - const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {}; for (const ocp of oldCoinPubs) { @@ -2136,6 +2157,63 @@ export async function createRefreshGroup( }; } +export async function redenominateRefresh( + wex: WalletExecutionContext, + refreshGroupId: string, +): Promise<TaskRunResult> { + const ctx = new RefreshTransactionContext(wex, refreshGroupId); + logger.info(`re-denominating refresh group ${refreshGroupId}`); + return await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const refreshGroup = await tx.refreshGroups.get(refreshGroupId); + if (!refreshGroup) { + return TaskRunResult.finished(); + } + const inputs: CoinRefreshRequest[] = []; + for (let i = 0; i < refreshGroup.oldCoinPubs.length; i++) { + inputs.push({ + amount: refreshGroup.inputPerCoin[i], + coinPub: refreshGroup.oldCoinPubs[i], + }); + } + + const outInfo = await calculateRefreshOutput( + wex, + tx, + refreshGroup.currency, + inputs, + ); + + logger.info(`input per coin: ${j2s(refreshGroup.inputPerCoin)}`); + logger.info( + `old output per coin: ${j2s(refreshGroup.expectedOutputPerCoin)}`, + ); + logger.info(`new out info: ${j2s(outInfo)}`); + + for (let i = 0; i < refreshGroup.oldCoinPubs.length; i++) { + if ( + Amounts.isZero(refreshGroup.expectedOutputPerCoin[i]) && + Amounts.isNonZero(outInfo.outputPerCoin[i]) && + refreshGroup.statusPerCoin[i] === RefreshCoinStatus.Finished + ) { + refreshGroup.expectedOutputPerCoin[i] = Amounts.stringify( + outInfo.outputPerCoin[i], + ); + refreshGroup.statusPerCoin[i] = RefreshCoinStatus.Pending; + await initRefreshSession(wex, tx, refreshGroup, i); + } + } + + logger.info( + `new output per coin: ${j2s(refreshGroup.expectedOutputPerCoin)}`, + ); + + refreshGroup.operationStatus = RefreshOperationStatus.Pending; + await tx.refreshGroups.put(refreshGroup); + await ctx.updateTransactionMeta(tx); + return TaskRunResult.progress(); + }); +} + export function computeRefreshTransactionState( rg: RefreshGroupRecord, ): TransactionState { @@ -2148,10 +2226,20 @@ export function computeRefreshTransactionState( return { major: TransactionMajorState.Failed, }; + case RefreshOperationStatus.PendingRedenominate: + // Internal state, not shown to UI. + return { + major: TransactionMajorState.Pending, + }; case RefreshOperationStatus.Pending: return { major: TransactionMajorState.Pending, }; + case RefreshOperationStatus.SuspendedRedenominate: + // Internal state, not shown to UI. + return { + major: TransactionMajorState.Suspended, + }; case RefreshOperationStatus.Suspended: return { major: TransactionMajorState.Suspended, @@ -2167,12 +2255,14 @@ export function computeRefreshTransactionActions( return [TransactionAction.Delete]; case RefreshOperationStatus.Failed: return [TransactionAction.Delete]; + case RefreshOperationStatus.PendingRedenominate: case RefreshOperationStatus.Pending: return [ TransactionAction.Retry, TransactionAction.Suspend, TransactionAction.Fail, ]; + case RefreshOperationStatus.SuspendedRedenominate: case RefreshOperationStatus.Suspended: return [TransactionAction.Resume, TransactionAction.Fail]; } diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -59,6 +59,7 @@ import { timestampAbsoluteFromDb, timestampPreciseToDb, } from "./db.js"; +import { processValidateDenoms } from "./denominations.js"; import { computeDepositTransactionStatus, processDepositGroup, @@ -132,6 +133,7 @@ function taskGivesLiveness(taskId: string): boolean { case PendingTaskType.ExchangeUpdate: case PendingTaskType.ExchangeAutoRefresh: case PendingTaskType.ExchangeWalletKyc: + case PendingTaskType.ValidateDenoms: return false; case PendingTaskType.Deposit: case PendingTaskType.PeerPullCredit: @@ -139,7 +141,6 @@ function taskGivesLiveness(taskId: string): boolean { case PendingTaskType.PeerPushCredit: case PendingTaskType.Refresh: case PendingTaskType.Recoup: - case PendingTaskType.RewardPickup: case PendingTaskType.Withdraw: case PendingTaskType.PeerPushDebit: case PendingTaskType.Purchase: @@ -672,8 +673,8 @@ async function callOperationHandlerForTaskId( return await processPeerPushCredit(wex, pending.peerPushCreditId); case PendingTaskType.ExchangeWalletKyc: return await processExchangeKyc(wex, pending.exchangeBaseUrl); - case PendingTaskType.RewardPickup: - throw Error("not supported anymore"); + case PendingTaskType.ValidateDenoms: + return await processValidateDenoms(wex); default: return assertUnreachable(pending); } @@ -707,12 +708,12 @@ async function taskToRetryNotification( case PendingTaskType.PeerPushCredit: case PendingTaskType.Deposit: case PendingTaskType.Refresh: - case PendingTaskType.RewardPickup: case PendingTaskType.PeerPushDebit: case PendingTaskType.Purchase: return makeTransactionRetryNotification(ws, tx, pendingTaskId, e); case PendingTaskType.Backup: case PendingTaskType.Recoup: + case PendingTaskType.ValidateDenoms: return undefined; } } @@ -1164,6 +1165,12 @@ export async function getActiveTaskIds( } } + // Always try to validate unvalidated denoms. + + res.taskIds.push( + constructTaskIdentifier({ tag: PendingTaskType.ValidateDenoms }), + ); + // FIXME: Recoup! }, ); diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -177,6 +177,7 @@ import { TestPayResult, TestingGetDenomStatsRequest, TestingGetDenomStatsResponse, + TestingGetDiagnosticsResponse, TestingGetReserveHistoryRequest, TestingPlanMigrateExchangeBaseUrlRequest, TestingSetTimetravelRequest, @@ -365,6 +366,7 @@ export enum WalletApiOperation { TestingWaitExchangeWalletKyc = "testingWaitWalletKyc", TestingPlanMigrateExchangeBaseUrl = "testingPlanMigrateExchangeBaseUrl", TestingRunFixup = "testingRunFixup", + TestingGetDiagnostics = "testingGetDiagnostics", // Hints @@ -1611,6 +1613,12 @@ export type TestingRunFixupOp = { response: EmptyObject; }; +export type TestingGetDiagnosticsOp = { + op: WalletApiOperation.TestingGetDiagnostics; + request: EmptyObject; + response: TestingGetDiagnosticsResponse; +}; + /** * Set a coin as (un-)suspended. * Suspended coins won't be used for payments. @@ -1776,6 +1784,7 @@ export type WalletOperations = { [WalletApiOperation.SendTalerUriMailboxMessage]: SendTalerUriMailboxMessageOp; [WalletApiOperation.ConvertIbanAccountFieldToPayto]: ConvertIbanAccountFieldToPaytoOp; [WalletApiOperation.ConvertIbanPaytoToAccountField]: ConvertIbanPaytoToAccountFieldOp; + [WalletApiOperation.TestingGetDiagnostics]: TestingGetDiagnosticsOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -135,6 +135,7 @@ import { TalerUriAction, TestingGetDenomStatsRequest, TestingGetDenomStatsResponse, + TestingGetDiagnosticsResponse, TestingGetReserveHistoryRequest, TestingSetTimetravelRequest, TimerAPI, @@ -326,6 +327,10 @@ import { walletDbFixups, } from "./db.js"; import { + isCandidateWithdrawableDenom, + isWithdrawableDenom, +} from "./denominations.js"; +import { checkDepositGroup, createDepositGroup, generateDepositGroupTxId, @@ -2017,6 +2022,41 @@ export async function handleConvertIbanPaytoToAccountField( }; } +export async function handleGetDiagnostics( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<TestingGetDiagnosticsResponse> { + const exchangeEntries: TestingGetDiagnosticsResponse["exchangeEntries"] = []; + await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + for (const exch of await tx.exchanges.getAll()) { + const denoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll( + exch.baseUrl, + ); + let numWithdrawableDenoms = 0; + let numCandidateWithdrawableDenoms = 0; + for (let i = 0; i < denoms.length; i++) { + const d = denoms[i]; + if (isWithdrawableDenom(d)) { + numWithdrawableDenoms++; + } + if (isCandidateWithdrawableDenom(d)) { + numCandidateWithdrawableDenoms++; + } + } + exchangeEntries.push({ + exchangeBaseUrl: exch.baseUrl, + numDenoms: denoms.length, + numCandidateWithdrawableDenoms, + numWithdrawableDenoms, + }); + } + }); + return { + version: 0, + exchangeEntries, + }; +} + interface HandlerWithValidator<Tag extends WalletApiOperation> { codec: Codec<WalletCoreRequestType<Tag>>; handler: ( @@ -2026,6 +2066,10 @@ interface HandlerWithValidator<Tag extends WalletApiOperation> { } const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { + [WalletApiOperation.TestingGetDiagnostics]: { + codec: codecForEmptyObject(), + handler: handleGetDiagnostics, + }, [WalletApiOperation.ConvertIbanAccountFieldToPayto]: { codec: codecForConvertIbanAccountFieldToPaytoRequest(), handler: handleConvertIbanAccountFieldToPayto, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -152,7 +152,6 @@ import { timestampAbsoluteFromDb, timestampPreciseFromDb, timestampPreciseToDb, - timestampProtocolFromDb, } from "./db.js"; import { selectForcedWithdrawalDenominations, @@ -160,6 +159,7 @@ import { } from "./denomSelection.js"; import { isCandidateWithdrawableDenom, + isValidDenomRecord, isWithdrawableDenom, } from "./denominations.js"; import { @@ -168,8 +168,8 @@ import { ReadyExchangeSummary, checkIncomingAmountLegalUnderKycBalanceThreshold, fetchFreshExchange, - getExchangePaytoUri, getExchangeDetailsInTx, + getExchangePaytoUri, getPreferredExchangeForCurrency, getScopeForAllExchanges, handleStartExchangeWalletKyc, @@ -1378,6 +1378,13 @@ async function getWithdrawableDenoms( ); } +/** + * Get denominations that are currently withdrawable + * for the given exchange base URL and currency. + * + * Throws if a candidate withdrawal denomination + * isn't validated yet. + */ export async function getWithdrawableDenomsTx( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction<["denominations"]>, @@ -1387,9 +1394,24 @@ export async function getWithdrawableDenomsTx( // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations! const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); - return allDenoms - .filter((d) => d.currency === currency) - .filter((d) => isWithdrawableDenom(d)); + const withdrawableDenoms: DenominationRecord[] = []; + for (const denom of allDenoms) { + if (denom.currency !== currency) { + continue; + } + if (!isCandidateWithdrawableDenom(denom)) { + continue; + } + if ( + denom.verificationStatus === DenominationVerificationStatus.Unverified + ) { + const msg = "candidate withdrawal denomination not verified"; + logger.error(msg); + throw Error(msg); + } + withdrawableDenoms.push(denom); + } + return withdrawableDenoms; } /** @@ -2031,28 +2053,6 @@ async function processPlanchetVerifyAndStoreCoin( ); } -async function isValidDenomRecord( - wex: WalletExecutionContext, - exchangeMasterPub: string, - d: DenominationRecord, -): Promise<boolean> { - const res = await wex.cryptoApi.isValidDenom({ - masterPub: exchangeMasterPub, - denomPubHash: d.denomPubHash, - feeDeposit: Amounts.parseOrThrow(d.fees.feeDeposit), - feeRefresh: Amounts.parseOrThrow(d.fees.feeRefresh), - feeRefund: Amounts.parseOrThrow(d.fees.feeRefund), - feeWithdraw: Amounts.parseOrThrow(d.fees.feeWithdraw), - masterSig: d.masterSig, - stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), - stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal), - stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), - stampStart: timestampProtocolFromDb(d.stampStart), - value: Amounts.parseOrThrow(d.value), - }); - return res.valid; -} - /** * Make sure that denominations that currently can be used for withdrawal * are validated, and the result of validation is stored in the database. @@ -2089,10 +2089,7 @@ export async function updateWithdrawalDenomsForExchange( const res = await wex.db.runReadOnlyTx( { storeNames: ["exchanges", "exchangeDetails", "denominations"] }, async (tx) => { - const exchangeDetails = await getExchangeDetailsInTx( - tx, - exchangeBaseUrl, - ); + const exchangeDetails = await getExchangeDetailsInTx(tx, exchangeBaseUrl); let denominations: DenominationRecord[] | undefined = []; if (exchangeDetails) { // FIXME: Use denom groups to speed this up.