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