taler-typescript-core

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

commit 35d065bd14a2e4692ccf3d17fcaa769a1e116211
parent bcd18410284f3fa3af9db774801cc0fcd339a1b3
Author: Florian Dold <dold@taler.net>
Date:   Thu, 27 Aug 2026 23:21:21 +0200

wallet-core: keep refresh availability counters synchronized

Diffstat:
Mpackages/taler-wallet-core/src/common.ts | 12+++++++++---
Mpackages/taler-wallet-core/src/refresh.test.ts | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/refresh.ts | 78+++++++++++++++++++++++++++++-------------------------------------------------
3 files changed, 116 insertions(+), 53 deletions(-)

diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -120,11 +120,17 @@ export async function makeCoinsVisible( if (!coinRecord.visible) { coinRecord.visible = 1; await tx.upsertCoin(coinRecord); - const car = await tx.getCoinAvailability(coinRecord); - if (!car) { - logger.error("missing coin availability record"); + // A source transaction can finish after one of its coins has already + // been spent or suspended. Record that the source is final, but only a + // still-fresh coin contributes to the visible balance. + if (coinRecord.status !== CoinStatus.Fresh) { continue; } + const car = await tx.getCoinAvailability(coinRecord); + checkDbInvariant( + !!car, + `missing coin availability for ${coinRecord.denomPubHash}`, + ); const visCount = car.visibleCoinCount ?? 0; car.visibleCoinCount = visCount + 1; await tx.upsertCoinAvailability(car); diff --git a/packages/taler-wallet-core/src/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts @@ -15,6 +15,7 @@ */ import { Amounts, + CoinStatus, DenominationInfo, TalerError, TalerErrorCode, @@ -29,7 +30,15 @@ import { RefreshTransactionContext, requireValidNorevealIndex, } from "./refresh.js"; -import { RefreshOperationStatus, WalletRefreshGroup } from "./db/records.js"; +import { + RefreshCoinStatus, + RefreshOperationStatus, + WalletCoin, + WalletCoinAvailability, + WalletRefreshGroup, + WalletRefreshSession, +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { WalletExecutionContext } from "./wallet.js"; test("melt noreveal index must be an integer inside kappa", () => { @@ -89,6 +98,74 @@ test("a live refresh cannot be failed and lose its recovery path", () => { ); }); +test("deleting a live refresh removes its pending output count", async () => { + const refreshGroup = { + refreshGroupId: "refresh-group", + operationStatus: RefreshOperationStatus.Pending, + oldCoinPubs: ["old-coin"], + statusPerCoin: [RefreshCoinStatus.Pending], + } as WalletRefreshGroup | undefined; + let storedRefreshGroup = refreshGroup; + const oldCoin = { + coinPub: "old-coin", + status: CoinStatus.Dormant, + denomPubHash: "old-denom", + exchangeMasterPub: "exchange-master-pub", + maxAge: 0, + } as WalletCoin; + const availability = { + denomPubHash: "new-denom", + exchangeMasterPub: oldCoin.exchangeMasterPub, + maxAge: oldCoin.maxAge, + freshCoinCount: 0, + visibleCoinCount: 0, + hasFreshCoins: 0, + pendingRefreshOutputCount: 2, + } as WalletCoinAvailability; + const session = { + refreshGroupId: "refresh-group", + coinIndex: 0, + newDenoms: [{ denomPubHash: availability.denomPubHash, count: 2 }], + } as WalletRefreshSession; + let sessionDeleted = false; + let metaDeleted = false; + const tx = { + notify() {}, + async getRefreshGroup() { + return storedRefreshGroup; + }, + async getRefreshSessionsByGroup() { + return [session]; + }, + async getCoin() { + return oldCoin; + }, + async getCoinAvailability() { + return availability; + }, + async upsertCoinAvailability() {}, + async deleteRefreshSession() { + sessionDeleted = true; + }, + async deleteRefreshGroup() { + storedRefreshGroup = undefined; + }, + async deleteTransactionMeta() { + metaDeleted = true; + }, + } as unknown as WalletDbTransaction; + const ctx = new RefreshTransactionContext( + {} as WalletExecutionContext, + "refresh-group", + ); + + await ctx.deleteTransactionInTx(tx); + + assert.strictEqual(availability.pendingRefreshOutputCount, 0); + assert.strictEqual(sessionDeleted, true); + assert.strictEqual(metaDeleted, true); +}); + test("an impossible refresh costs the full remaining amount", () => { const amountLeft = Amounts.parseOrThrow("TESTKUDOS:4"); const refreshedDenom = { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -247,7 +247,7 @@ export class RefreshTransactionContext implements TransactionContext { } async userDeleteTransaction(): Promise<void> { - const res = await this.wex.runWalletDbTx(async (tx) => { + await this.wex.runWalletDbTx(async (tx) => { return this.deleteTransactionInTx(tx); }); } @@ -262,6 +262,13 @@ export class RefreshTransactionContext implements TransactionContext { } const sessions = await tx.getRefreshSessionsByGroup(rg.refreshGroupId); for (const s of sessions) { + const coinStatus = rg.statusPerCoin[s.coinIndex]; + if ( + coinStatus === RefreshCoinStatus.Pending || + coinStatus === RefreshCoinStatus.PendingRedenominate + ) { + await destroyRefreshSession(tx, rg, s); + } await tx.deleteRefreshSession(s.refreshGroupId, s.coinIndex); } await h.update(undefined, "delete"); @@ -558,10 +565,7 @@ async function initRefreshSession( exchangeMasterPub: oldDenom.exchangeMasterPub, denomPubHash: dph, }); - if (!denom) { - logger.error(`denom ${dph} not in DB`); - continue; - } + checkDbInvariant(!!denom, `denom ${dph} not in DB`); const car = await getCoinAvailabilityForDenom( wex, tx, @@ -593,7 +597,6 @@ async function initRefreshSession( * Adjust the coin availability of involved coins. */ async function destroyRefreshSession( - wex: WalletExecutionContext, tx: WalletDbTransaction, refreshGroup: WalletRefreshGroup, refreshSession: WalletRefreshSession, @@ -606,22 +609,15 @@ async function destroyRefreshSession( continue; } const dph = refreshSession.newDenoms[i].denomPubHash; - const denom = await getDenomInfo(wex, tx, { + const car = await tx.getCoinAvailability({ exchangeMasterPub: oldCoin.exchangeMasterPub, denomPubHash: dph, + maxAge: oldCoin.maxAge, }); - if (!denom) { - logger.error(`denom ${dph} not in DB`); - continue; - } - const car = await getCoinAvailabilityForDenom( - wex, - tx, - denom, - oldCoin.maxAge, - ); checkDbInvariant( - car.pendingRefreshOutputCount != null, + !!car && + car.pendingRefreshOutputCount != null && + car.pendingRefreshOutputCount >= refreshSession.newDenoms[i].count, `no pendingRefreshOutputCount for denom ${dph}`, ); car.pendingRefreshOutputCount = @@ -1242,7 +1238,7 @@ async function handleRefreshMeltConflict( // started the precautionary abort refresh. The merchant's abort // refund is responsible for recovering that value, so this refresh // coin has no output and is complete rather than failed. - await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + await destroyRefreshSession(tx, rg, refreshSession); await tx.deleteRefreshSession(ctx.refreshGroupId, coinIndex); rg.expectedOutputPerCoin[coinIndex] = Amounts.stringify( Amounts.zeroOfCurrency(rg.currency), @@ -1252,6 +1248,7 @@ async function handleRefreshMeltConflict( } else { rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; refreshSession.lastError = errDetails; + await destroyRefreshSession(tx, rg, refreshSession); await tx.upsertRefreshSession(refreshSession); await h.update(rg, "melt-conflict"); } @@ -1265,7 +1262,7 @@ async function handleRefreshMeltConflict( if (!refreshSession) { throw Error("db invariant failed: missing refresh session in database"); } - await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + await destroyRefreshSession(tx, rg, refreshSession); await tx.deleteRefreshSession(ctx.refreshGroupId, coinIndex); await initRefreshSession(ctx.wex, tx, rg, coinIndex); // The new session was computed from the corrected input amount, @@ -1311,7 +1308,7 @@ async function handleRefreshMeltNotFound( } refreshSession.lastError = errDetails; await tx.upsertRefreshSession(refreshSession); - await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + await destroyRefreshSession(tx, rg, refreshSession); await h.update(rg, "melt-not-found"); }); } @@ -1519,44 +1516,27 @@ async function refreshReveal( coins.map((coin) => coin.coinPub), ); const existingCoinPubs = new Set(existingCoins.map((coin) => coin.coinPub)); - const newCoins = coins.filter( - (coin) => !existingCoinPubs.has(coin.coinPub), - ); - const denoms = await getDenomInfos(wex, tx, newCoins); - const loadedAvailabilities = await tx.getCoinAvailabilitiesByRefs(newCoins); + const loadedAvailabilities = await tx.getCoinAvailabilitiesByRefs(coins); const availabilities = new Map( loadedAvailabilities.map((availability) => [ coinAvailabilityRefKey(availability), availability, ]), ); - for (const coin of newCoins) { - await tx.upsertCoin(coin); - const denomInfo = denoms.get(denomRefKey(coin)); - checkDbInvariant(!!denomInfo, `no denom with hash ${coin.denomPubHash}`); + for (const coin of coins) { const availabilityKey = coinAvailabilityRefKey(coin); - let car = availabilities.get(availabilityKey); - if (!car) { - car = { - maxAge: coin.maxAge, - value: denomInfo.value, - currency: Amounts.currencyOf(denomInfo.value), - denomPubHash: denomInfo.denomPubHash, - exchangeBaseUrl: denomInfo.exchangeBaseUrl, - exchangeMasterPub: denomInfo.exchangeMasterPub, - freshCoinCount: 0, - hasFreshCoins: 0, - visibleCoinCount: 0, - }; - availabilities.set(availabilityKey, car); - } + const car = availabilities.get(availabilityKey); checkDbInvariant( - car.pendingRefreshOutputCount != null && + !!car && + car.pendingRefreshOutputCount != null && car.pendingRefreshOutputCount > 0, `no pendingRefreshOutputCount for denom ${coin.denomPubHash} age ${coin.maxAge}`, ); car.pendingRefreshOutputCount--; - car.freshCoinCount++; + if (!existingCoinPubs.has(coin.coinPub)) { + await tx.upsertCoin(coin); + car.freshCoinCount++; + } } for (const availability of availabilities.values()) { await tx.upsertCoinAvailability(availability); @@ -1591,7 +1571,7 @@ async function handleRefreshRevealError( throw Error("db invariant failed: missing refresh session in database"); } refreshSession.lastError = errDetails; - await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + await destroyRefreshSession(tx, rg, refreshSession); await tx.upsertRefreshSession(refreshSession); await h.update(rg, "reveal-error"); }); @@ -1769,7 +1749,7 @@ async function processRefreshSession( rg.statusPerCoin[coinIndex] === RefreshCoinStatus.PendingRedenominate ) { if (rs != null) { - await destroyRefreshSession(wex, tx, rg, rs); + await destroyRefreshSession(tx, rg, rs); } await tx.deleteRefreshSession(refreshGroupId, coinIndex); // Set the status before initializing the session, as the