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