taler-typescript-core

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

commit 51920edbf7702dfc52a4c55a6471972d50221ece
parent 62f078545a272d0abff0b52d865e1159ed11d17e
Author: Florian Dold <florian@dold.me>
Date:   Fri, 13 Feb 2026 23:18:43 +0100

wallet-core: handle gone response in refresh melt properly, migrate

Diffstat:
Mpackages/taler-wallet-core/src/db.ts | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/refresh.ts | 128++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
2 files changed, 143 insertions(+), 45 deletions(-)

diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -64,6 +64,7 @@ import { RefreshReason, ScopeInfo, SignedTokenEnvelope, + TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolDuration, @@ -1207,6 +1208,12 @@ export interface CoinHistoryRecord { export enum RefreshCoinStatus { Pending = 0x0100_0000, + + /** + * Re-try the melt with a new target denomination. + */ + PendingRedenominate = 0x0100_0001, + Finished = 0x0500_0000, /** @@ -1218,7 +1225,10 @@ export enum RefreshCoinStatus { export enum RefreshOperationStatus { Pending = 0x0100_0000, - /** Output coin selection was bad, re-select. */ + /** + * Entire output coin selection was bad, re-select + * and potentially revive finished coins with zero output. + */ PendingRedenominate = 0x0100_0001, Suspended = 0x0110_0000, SuspendedRedenominate = 0x0110_0001, @@ -1343,6 +1353,11 @@ export interface RefreshSessionRecord { */ norevealIndex?: number; + /** + * Last error response from the exchange. + * + * FIXME: We don't store the last HTTP status yet. + */ lastError?: TalerErrorDetail; // Reserved legacy fields: @@ -3919,8 +3934,51 @@ export const walletDbFixups: FixupDescription[] = [ fn: fixup20260203DenomFamilyMigration, name: "fixup20260203DenomFamilyMigration", }, + // Fix a problem where refreshes went into a failed state + // instead of retrying. + { + fn: fixup20260213RefreshBlunder, + name: "fixup20260213RefreshBlunder", + }, ]; +async function fixup20260213RefreshBlunder( + tx: WalletDbAllStoresReadWriteTransaction, +): Promise<void> { + const refreshes = await tx.refreshGroups.indexes.byStatus.getAll( + RefreshOperationStatus.Failed, + ); + for (const refreshGroup of refreshes) { + for ( + let coinIndex = 0; + coinIndex < refreshGroup.statusPerCoin.length; + coinIndex++ + ) { + let changed = false; + if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Failed) { + const rs = await tx.refreshSessions.get([ + refreshGroup.refreshGroupId, + coinIndex, + ]); + if ( + rs?.lastError?.code === + TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED + ) { + refreshGroup.statusPerCoin[coinIndex] = + RefreshCoinStatus.PendingRedenominate; + refreshGroup.operationStatus = + RefreshOperationStatus.PendingRedenominate; + delete refreshGroup.timestampFinished; + changed = true; + } + } + if (changed) { + await tx.refreshGroups.put(refreshGroup); + } + } + } +} + async function fixup20260203DenomFamilyMigration( tx: WalletDbAllStoresReadWriteTransaction, ): Promise<void> { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -117,7 +117,10 @@ import { getDenomInfo, WalletExecutionContext, } from "./wallet.js"; -import { getWithdrawableDenomsTx } from "./withdraw.js"; +import { + getWithdrawableDenomsTx, + updateWithdrawalDenomsForExchange, +} from "./withdraw.js"; /** Maximum number of new coins. */ const maxRefreshSessionSize = 64; @@ -833,6 +836,9 @@ async function refreshMelt( ); } +/** + * Handle a "Gone" response from the exchange to a melt request. + */ async function handleRefreshMeltGone( ctx: RefreshTransactionContext, coinIndex: number, @@ -840,7 +846,7 @@ async function handleRefreshMeltGone( ): Promise<void> { // const expiredMsg = codecForDenominationExpiredMessage().decode(errDetails); - // FIXME: Validate signature. + // FIXME: Validate signature, possibly even store. await ctx.wex.db.runReadWriteTx( { @@ -864,7 +870,7 @@ async function handleRefreshMeltGone( if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { return; } - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.PendingRedenominate; const refreshSession = await tx.refreshSessions.get([ ctx.refreshGroupId, coinIndex, @@ -874,7 +880,6 @@ async function handleRefreshMeltGone( } refreshSession.lastError = errDetails; await tx.refreshSessions.put(refreshSession); - await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); await h.update(rg); }, ); @@ -1400,6 +1405,30 @@ export async function processRefreshGroup( throw Error("refresh blocked"); } + // If any redenomination is pending, first make sure the + // info about exchanges is recent enough. + { + let shouldReloadExchanges = false; + for (let i = 0; i < refreshGroup.statusPerCoin.length; i++) { + if ( + refreshGroup.statusPerCoin[i] === RefreshCoinStatus.PendingRedenominate + ) { + shouldReloadExchanges = true; + } + } + + if (shouldReloadExchanges && refreshGroup.infoPerExchange != null) { + for (const e of Object.keys(refreshGroup.infoPerExchange)) { + // FIXME: Maybe have some "soft" force update + // so we don't do this too often? + await fetchFreshExchange(wex, e, { + forceUpdate: true, + }); + await updateWithdrawalDenomsForExchange(wex, e); + } + } + } + logger.trace( `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`, ); @@ -1516,11 +1545,22 @@ async function processRefreshSession( logger.trace( `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`, ); - let { refreshGroup, refreshSession } = await wex.db.runReadOnlyTx( - { storeNames: ["refreshGroups", "refreshSessions"] }, + let { refreshGroup, refreshSession } = await wex.db.runAllStoresReadWriteTx( + {}, async (tx) => { const rg = await tx.refreshGroups.get(refreshGroupId); const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); + + if ( + rg != null && + rg.statusPerCoin[coinIndex] === RefreshCoinStatus.PendingRedenominate + ) { + await tx.refreshSessions.delete([refreshGroupId, coinIndex]); + await initRefreshSession(wex, tx, rg, coinIndex); + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Pending; + await tx.refreshGroups.put(rg); + } + return { refreshGroup: rg, refreshSession: rs, @@ -1817,6 +1857,20 @@ async function redenominateRefresh( ): Promise<TaskRunResult> { const ctx = new RefreshTransactionContext(wex, refreshGroupId); logger.info(`re-denominating refresh group ${refreshGroupId}`); + + const exchanges = await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const [rg, _] = await ctx.getRecordHandle(tx); + if (rg?.infoPerExchange) { + return Object.keys(rg.infoPerExchange); + } + return []; + }); + + for (const e of exchanges) { + await fetchFreshExchange(wex, e); + await updateWithdrawalDenomsForExchange(wex, e); + } + return await wex.db.runAllStoresReadWriteTx({}, async (tx) => { const [refreshGroup, h] = await ctx.getRecordHandle(tx); if (!refreshGroup) { @@ -1946,48 +2000,34 @@ export async function forceRefresh( if (req.refreshCoinSpecs.length == 0) { throw Error("refusing to create empty refresh group"); } - const res = await wex.db.runReadWriteTx( - { - storeNames: [ - "refreshGroups", - "coinAvailability", - "refreshSessions", - "denominations", - "denominationFamilies", - "coins", - "coinHistory", - "transactionsMeta", - ], - }, - async (tx) => { - const coinPubs: CoinRefreshRequest[] = []; - for (const c of req.refreshCoinSpecs) { - const coin = await tx.coins.get(c.coinPub); - if (!coin) { - throw Error(`coin (pubkey ${c}) not found`); - } - const denom = await getDenomInfo( - wex, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant(!!denom, `no denom hash: ${coin.denomPubHash}`); - coinPubs.push({ - coinPub: c.coinPub, - amount: c.amount ?? denom.value, - }); + const res = await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const coinPubs: CoinRefreshRequest[] = []; + for (const c of req.refreshCoinSpecs) { + const coin = await tx.coins.get(c.coinPub); + if (!coin) { + throw Error(`coin (pubkey ${c}) not found`); } - return await createRefreshGroup( + const denom = await getDenomInfo( wex, tx, - Amounts.currencyOf(coinPubs[0].amount), - coinPubs, - RefreshReason.Manual, - undefined, + coin.exchangeBaseUrl, + coin.denomPubHash, ); - }, - ); + checkDbInvariant(!!denom, `no denom hash: ${coin.denomPubHash}`); + coinPubs.push({ + coinPub: c.coinPub, + amount: c.amount ?? denom.value, + }); + } + return await createRefreshGroup( + wex, + tx, + Amounts.currencyOf(coinPubs[0].amount), + coinPubs, + RefreshReason.Manual, + undefined, + ); + }); return { refreshGroupId: res.refreshGroupId,