commit fd79c569c35812b76d39ba073d78e928f61a4512
parent d0d6d67c3c6eb4c3906dcdc22ef1d479e5b9b6ee
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 18:12:01 +0200
wallet-core: report renewal risks and costs from background snapshots
Compute scoped expiration risks, recovery notices, and conditional annual
cost bounds in a maintenance task. Calculate outside the database
transaction and persist results only while their input generation remains
current. Wake maintenance on relevant changes and validity boundaries.
Balance queries read persisted reports without denomination selection or
history scans. Retain known warnings during recomputation and withhold
stale cost bounds. Reuse current reports after reopening the wallet.
Diffstat:
9 files changed, 1316 insertions(+), 14 deletions(-)
diff --git a/packages/taler-wallet-core/src/autoRefreshBalance.ts b/packages/taler-wallet-core/src/autoRefreshBalance.ts
@@ -0,0 +1,344 @@
+/*
+ This file is part of GNU Taler
+ (C) 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
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License along with GNU Taler;
+ see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ AmountString,
+ BalancesResponse,
+ CashExpirationRisk,
+ ScopeInfo,
+ stringifyScopeInfo,
+ TalerErrorCode,
+ TalerErrorDetail,
+ WalletPowerSource,
+} from "@gnu-taler/taler-util";
+import { WalletDbTransaction } from "./db/transaction.js";
+import {
+ DenominationVerificationStatus,
+ RefreshOperationStatus,
+ RefreshCoinStatus,
+ WalletCoinAvailability,
+ WalletDenomination,
+ WalletExchangeEntry,
+ timestampProtocolFromDb,
+ timestampAbsoluteFromDb,
+} from "./db/records.js";
+import { WalletExecutionContext, denomRefKey } from "./wallet.js";
+import { TaskIdentifiers } from "./common.js";
+import {
+ computeAnnualRefreshBoundAsync,
+ evaluateAutoRefresh,
+ unavailableAnnualBound,
+ DAY_MS,
+ refreshLifetime,
+} from "./autoRefresh.js";
+
+export function refreshFailureReason(
+ error: TalerErrorDetail | undefined,
+): "connectivity" | "exchange-error" | undefined {
+ if (!error) return;
+ if (
+ error.code === TalerErrorCode.WALLET_NETWORK_ERROR ||
+ error.code === TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT
+ )
+ return "connectivity";
+ const nested = (error.details as { errors?: unknown } | undefined)?.errors;
+ if (
+ Array.isArray(nested) &&
+ nested.some(
+ (e) => refreshFailureReason(e as TalerErrorDetail) === "connectivity",
+ )
+ )
+ return "connectivity";
+ return "exchange-error";
+}
+
+export type RefreshBalanceSource = Pick<
+ WalletDbTransaction,
+ | "getDenominationsByRefs"
+ | "getDenominationsByMasterPub"
+ | "getOperationRetry"
+ | "listAllRefreshGroups"
+ | "getCoinsByPubs"
+>;
+
+/** Attach DD71 data to the same scopes used for the monetary balance. */
+export async function addRefreshBalanceInfo(
+ wex: WalletExecutionContext,
+ tx: RefreshBalanceSource,
+ response: BalancesResponse,
+ exchanges: WalletExchangeEntry[],
+ availability: WalletCoinAvailability[],
+ resolve: (
+ url: string,
+ currency: string,
+ master?: string,
+ hash?: string,
+ ) => Promise<ScopeInfo>,
+ now = AbsoluteTime.now(),
+): Promise<void> {
+ const nowMs = AbsoluteTime.toStampMs(now);
+ const byScope = new Map(
+ response.balances.map((b) => {
+ b.refreshInfo = {
+ risks: [],
+ recoveries: [],
+ annualCostBound: unavailableAnnualBound("missing-holdings"),
+ };
+ return [stringifyScopeInfo(b.scopeInfo), b];
+ }),
+ );
+ const denoms = new Map(
+ (await tx.getDenominationsByRefs(availability)).map((d) => [
+ denomRefKey(d),
+ d,
+ ]),
+ );
+ const candidateCache = new Map<string, WalletDenomination[]>();
+ const candidates = async (master: string) => {
+ let ds = candidateCache.get(master);
+ if (!ds) {
+ ds = exchanges.some((e) => e.detailsPointer?.masterPublicKey === master)
+ ? await tx.getDenominationsByMasterPub(master)
+ : [];
+ candidateCache.set(master, ds);
+ }
+ return ds;
+ };
+ const holdings = new Map<
+ string,
+ Map<string, { amount: AmountString; denoms: WalletDenomination[] }>
+ >();
+ const unavailable = new Map<string, Set<string>>();
+ const markUnavailable = (scope: string, reason: string) => {
+ const reasons = unavailable.get(scope) ?? new Set<string>();
+ reasons.add(reason);
+ unavailable.set(scope, reasons);
+ };
+ const balanceFor = async (
+ url: string,
+ currency: string,
+ master: string,
+ hash?: string,
+ ) =>
+ byScope.get(stringifyScopeInfo(await resolve(url, currency, master, hash)));
+ const evaluations = new Map<string, ReturnType<typeof evaluateAutoRefresh>>();
+ let riskChecks = 0;
+ const riskFor = async (
+ d: WalletDenomination,
+ amount: AmountString,
+ pendingError?: TalerErrorDetail,
+ pending = false,
+ ) => {
+ const b = await balanceFor(
+ d.exchangeBaseUrl,
+ d.currency,
+ d.exchangeMasterPub,
+ d.denomPubHash,
+ );
+ if (!b || Amounts.isZero(amount)) return;
+ const life = refreshLifetime(d);
+ if (
+ life &&
+ (life.deposit <= nowMs || life.deposit - nowMs >= life.emergency)
+ )
+ return;
+ if (++riskChecks % 32 === 0)
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
+ const key = denomRefKey(d);
+ let evaluation = evaluations.get(key);
+ if (!evaluation) {
+ evaluation = evaluateAutoRefresh({
+ now,
+ inputAmount: d.value,
+ oldDenom: d,
+ withdrawableDenoms: await candidates(d.exchangeMasterPub),
+ powerSource: wex.ws.powerSource ?? WalletPowerSource.Unknown,
+ });
+ evaluations.set(key, evaluation);
+ }
+ if (!evaluation.risk && !evaluation.invalidLifetime) return;
+ const exchange = exchanges.find((e) => e.baseUrl === d.exchangeBaseUrl);
+ const retry = await tx.getOperationRetry(
+ TaskIdentifiers.forExchangeAutoRefreshFromUrl(d.exchangeBaseUrl),
+ );
+ const freshKeys =
+ exchange?.lastUpdate != null &&
+ nowMs -
+ AbsoluteTime.toStampMs(timestampAbsoluteFromDb(exchange.lastUpdate)) <
+ DAY_MS;
+ const verifying = (await candidates(d.exchangeMasterPub)).some(
+ (d) => d.verificationStatus === DenominationVerificationStatus.Unverified,
+ );
+ const failure = refreshFailureReason(
+ pendingError ?? retry?.lastError ?? exchange?.unavailableReason,
+ );
+ const reason: CashExpirationRisk["reason"] = evaluation.invalidLifetime
+ ? "invalid-lifetime"
+ : (failure ??
+ (pending
+ ? "pending"
+ : freshKeys &&
+ !verifying &&
+ (!evaluation.plan ||
+ evaluation.plan.minOutputExpiry <=
+ AbsoluteTime.toStampMs(
+ timestampAbsoluteFromDb(d.stampExpireDeposit),
+ ))
+ ? "no-replacement"
+ : "checking"));
+ const risks = b.refreshInfo!.risks;
+ const previous = risks.find(
+ (r) =>
+ r.exchangeBaseUrl === d.exchangeBaseUrl &&
+ r.exchangeMasterPub === d.exchangeMasterPub &&
+ r.reason === reason,
+ );
+ const expiry = timestampProtocolFromDb(d.stampExpireDeposit);
+ if (previous) {
+ previous.amount = Amounts.stringify(
+ Amounts.add(previous.amount, amount).amount,
+ );
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromProtocolTimestamp(expiry),
+ AbsoluteTime.fromProtocolTimestamp(
+ previous.earliestDepositExpiration,
+ ),
+ ) < 0
+ )
+ previous.earliestDepositExpiration = expiry;
+ } else
+ risks.push({
+ exchangeBaseUrl: d.exchangeBaseUrl,
+ exchangeMasterPub: d.exchangeMasterPub,
+ amount,
+ earliestDepositExpiration: expiry,
+ reason,
+ });
+ };
+ for (const ca of availability) {
+ const b = await balanceFor(
+ ca.exchangeBaseUrl,
+ ca.currency,
+ ca.exchangeMasterPub,
+ ca.denomPubHash,
+ );
+ if (!b) continue;
+ const scope = stringifyScopeInfo(b.scopeInfo);
+ const d = denoms.get(denomRefKey(ca));
+ if (!d) {
+ markUnavailable(scope, "missing-denomination");
+ continue;
+ }
+ await riskFor(
+ d,
+ Amounts.stringify(
+ Amounts.mult(ca.value, Math.min(ca.freshCoinCount, ca.visibleCoinCount))
+ .amount,
+ ),
+ );
+ const groups = holdings.get(scope) ?? new Map();
+ holdings.set(scope, groups);
+ const exchange = exchanges.find((e) => e.baseUrl === ca.exchangeBaseUrl);
+ if (
+ exchange?.lastUpdate == null ||
+ nowMs -
+ AbsoluteTime.toStampMs(timestampAbsoluteFromDb(exchange.lastUpdate)) >=
+ DAY_MS ||
+ exchange.unavailableReason
+ )
+ markUnavailable(scope, "stale-key-information");
+ const groupKey = `${ca.exchangeBaseUrl}/${ca.exchangeMasterPub}/${d.denomPub.cipher}/${d.denomPub.age_mask}`;
+ const group = groups.get(groupKey) ?? {
+ amount: Amounts.stringify(Amounts.zeroOfCurrency(ca.currency)),
+ denoms: [],
+ };
+ group.amount = Amounts.stringify(
+ Amounts.add(
+ group.amount,
+ Amounts.mult(ca.value, ca.visibleCoinCount).amount,
+ ).amount,
+ );
+ group.denoms.push(d);
+ groups.set(groupKey, group);
+ }
+ for (const rg of await tx.listAllRefreshGroups()) {
+ if (rg.operationStatus === RefreshOperationStatus.Finished) {
+ const notice = rg.autoRefresh?.recovery;
+ if (notice && !rg.autoRefresh?.dismissed) {
+ const b = await balanceFor(
+ notice.exchangeBaseUrl,
+ rg.currency,
+ notice.exchangeMasterPub,
+ );
+ b?.refreshInfo?.recoveries.push(notice);
+ }
+ continue;
+ }
+ const coins = await tx.getCoinsByPubs(rg.oldCoinPubs);
+ const oldDenoms = new Map(
+ (await tx.getDenominationsByRefs(coins)).map((d) => [denomRefKey(d), d]),
+ );
+ const retry = await tx.getOperationRetry(`refresh:${rg.refreshGroupId}`);
+ for (const coin of coins) {
+ const coinIndex = rg.oldCoinPubs.indexOf(coin.coinPub);
+ if (rg.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) continue;
+ const d = oldDenoms.get(denomRefKey(coin));
+ if (!d) continue;
+ const b = await balanceFor(
+ d.exchangeBaseUrl,
+ d.currency,
+ d.exchangeMasterPub,
+ d.denomPubHash,
+ );
+ if (b)
+ markUnavailable(stringifyScopeInfo(b.scopeInfo), "pending-refresh");
+ await riskFor(
+ d,
+ rg.inputPerCoin[coinIndex],
+ retry?.lastError ?? rg.failReason,
+ true,
+ );
+ }
+ }
+ for (const [scope, b] of byScope) {
+ let total = Amounts.zeroOfCurrency(b.scopeInfo.currency);
+ let accounted = Amounts.zeroOfCurrency(b.scopeInfo.currency);
+ for (const group of holdings.get(scope)?.values() ?? []) {
+ accounted = Amounts.add(accounted, group.amount).amount;
+ const bound = await computeAnnualRefreshBoundAsync(
+ now,
+ group.amount,
+ group.denoms,
+ await candidates(group.denoms[0].exchangeMasterPub),
+ );
+ if (bound.status === "available")
+ total = Amounts.add(total, bound.amount).amount;
+ else for (const reason of bound.reasons) markUnavailable(scope, reason);
+ }
+ if (Amounts.cmp(accounted, b.available) !== 0)
+ markUnavailable(scope, "unsettled-balance");
+ const reasons = unavailable.get(scope);
+ b.refreshInfo!.annualCostBound = reasons?.size
+ ? unavailableAnnualBound(...reasons)
+ : {
+ status: "available",
+ horizonDays: 365,
+ projection: "stable-current-offerings",
+ amount: Amounts.stringify(total),
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/autoRefreshLifecycle.test.ts b/packages/taler-wallet-core/src/autoRefreshLifecycle.test.ts
@@ -0,0 +1,220 @@
+/*
+ This file is part of GNU Taler
+ (C) 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
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License along with GNU Taler;
+ see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ AbsoluteTime,
+ CoinStatus,
+ DenomKeyType,
+ RefreshReason,
+ ScopeType,
+ TalerErrorCode,
+ WalletPowerSource,
+ BalancesResponse,
+} from "@gnu-taler/taler-util";
+import {
+ DenominationVerificationStatus,
+ RefreshOperationStatus,
+ RefreshCoinStatus,
+ timestampProtocolToDb,
+ WalletCoin,
+ WalletDenomination,
+ WalletExchangeEntry,
+ WalletRefreshGroup,
+ WalletRefreshSession,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { WalletExecutionContext } from "./wallet.js";
+import { prepareAutoRefreshSession } from "./refresh.js";
+import { addRefreshBalanceInfo } from "./autoRefreshBalance.js";
+import { handleDismissWalletWarning } from "./requests.js";
+import { DAY_MS, selectAutoRefreshOutputs } from "./autoRefresh.js";
+
+function fixture() {
+ const now = AbsoluteTime.toStampMs(AbsoluteTime.now());
+ const stamp = (days: number) =>
+ timestampProtocolToDb({ t_s: Math.floor((now + days * DAY_MS) / 1000) });
+ const old = {
+ currency: "TEST",
+ value: "TEST:1",
+ denomPubHash: "old",
+ exchangeBaseUrl: "https://exchange/",
+ exchangeMasterPub: "master",
+ denomPub: { cipher: DenomKeyType.Rsa, age_mask: 0, rsa_public_key: "rsa" },
+ fees: {
+ feeRefresh: "TEST:0",
+ feeWithdraw: "TEST:0",
+ feeDeposit: "TEST:0",
+ feeRefund: "TEST:0",
+ },
+ stampStart: stamp(-650),
+ stampExpireWithdraw: stamp(-640),
+ stampExpireDeposit: stamp(80),
+ stampExpireLegal: stamp(1000),
+ isOffered: true,
+ isRevoked: false,
+ isLost: false,
+ verificationStatus: DenominationVerificationStatus.VerifiedGood,
+ } as WalletDenomination;
+ const output = {
+ ...old,
+ denomPubHash: "new",
+ stampStart: stamp(-10),
+ stampExpireWithdraw: stamp(20),
+ stampExpireDeposit: stamp(740),
+ };
+ const coin = {
+ coinPub: "coin",
+ denomPubHash: "old",
+ exchangeBaseUrl: old.exchangeBaseUrl,
+ exchangeMasterPub: "master",
+ status: CoinStatus.Dormant,
+ } as WalletCoin;
+ const rg = {
+ refreshGroupId: "group",
+ reason: RefreshReason.Scheduled,
+ currency: "TEST",
+ oldCoinPubs: ["coin"],
+ inputPerCoin: ["TEST:1"],
+ statusPerCoin: [RefreshCoinStatus.Pending],
+ operationStatus: RefreshOperationStatus.Pending,
+ autoRefresh: {},
+ } as WalletRefreshGroup;
+ const plan = selectAutoRefreshOutputs(AbsoluteTime.now(), old.value, old, [
+ output,
+ ])!;
+ const session = {
+ refreshGroupId: "group",
+ coinIndex: 0,
+ newDenoms: plan.selectedDenoms,
+ autoRefreshMeltStarted: false,
+ } as WalletRefreshSession;
+ const ex = {
+ baseUrl: old.exchangeBaseUrl,
+ detailsPointer: { masterPublicKey: "master", currency: "TEST" },
+ lastUpdate: stamp(0),
+ } as unknown as WalletExchangeEntry;
+ const tx = {
+ getRefreshGroup: async () => rg,
+ getExchange: async () => ex,
+ getRefreshSession: async () => session,
+ getCoin: async () => coin,
+ getCoinHistory: async () => ({
+ coinPub: "coin",
+ history: [
+ {
+ type: "refresh",
+ transactionId: "txn:refresh:group",
+ amount: "TEST:1",
+ },
+ ],
+ }),
+ getDenomination: async () => old,
+ getDenominationsByMasterPub: async () => [output],
+ getDenominationsByRefs: async () => [old],
+ listAllRefreshGroups: async () => [rg],
+ getCoinsByPubs: async () => [coin],
+ getOperationRetry: async () => undefined,
+ upsertRefreshGroup: async () => {},
+ notify: () => {},
+ } as unknown as WalletDbTransaction;
+ const wex = {
+ ws: { powerSource: WalletPowerSource.Unknown, config: { testing: {} } },
+ runWalletDbTx: async (f: (tx: WalletDbTransaction) => unknown) => f(tx),
+ } as unknown as WalletExecutionContext;
+ return { tx, wex, old, output, session, coin, rg, ex };
+}
+
+test("automatic session revalidation rejects unavailable keys and other ownership", async () => {
+ const { tx, wex, output, coin, ex } = fixture();
+ assert.equal(await prepareAutoRefreshSession(wex, tx, "group", 0), true);
+ ex.detailsPointer!.masterPublicKey = "rotated";
+ assert.equal(await prepareAutoRefreshSession(wex, tx, "group", 0), false);
+ ex.detailsPointer!.masterPublicKey = "master";
+ output.isRevoked = true;
+ assert.equal(await prepareAutoRefreshSession(wex, tx, "group", 0), false);
+ output.isRevoked = false;
+ coin.status = CoinStatus.Fresh;
+ assert.equal(await prepareAutoRefreshSession(wex, tx, "group", 0), false);
+ coin.status = CoinStatus.Dormant;
+ tx.getCoinHistory = async () => undefined;
+ assert.equal(await prepareAutoRefreshSession(wex, tx, "group", 0), false);
+});
+
+test("an ambiguous melt retains its commitment despite loss of eligibility", async () => {
+ const { tx, wex, session, output } = fixture();
+ session.autoRefreshMeltStarted = true;
+ session.sessionPublicSeed = "seed";
+ output.isRevoked = true;
+ const before = JSON.stringify(session);
+ assert.equal(await prepareAutoRefreshSession(wex, tx, "group", 0), true);
+ assert.equal(JSON.stringify(session), before);
+});
+
+test("pending and failed renewals retain risks; successful groups yield one dismissible notice", async () => {
+ const { tx, wex, rg, ex } = fixture();
+ const getResponse = async () => {
+ const response: BalancesResponse = {
+ haveProdBalance: true,
+ balances: [
+ {
+ scopeInfo: {
+ type: ScopeType.Exchange,
+ currency: "TEST",
+ url: ex.baseUrl,
+ },
+ available: "TEST:1",
+ pendingIncoming: "TEST:0",
+ pendingOutgoing: "TEST:0",
+ flags: [],
+ },
+ ],
+ };
+ await addRefreshBalanceInfo(
+ wex,
+ tx,
+ response,
+ [ex],
+ [],
+ async () => response.balances[0].scopeInfo,
+ );
+ return response.balances[0].refreshInfo!;
+ };
+ assert.equal((await getResponse()).risks[0].reason, "pending");
+ rg.statusPerCoin[0] = RefreshCoinStatus.Finished;
+ assert.equal((await getResponse()).risks.length, 0);
+ rg.statusPerCoin[0] = RefreshCoinStatus.Pending;
+ rg.failReason = { code: TalerErrorCode.WALLET_NETWORK_ERROR };
+ assert.equal((await getResponse()).risks[0].reason, "connectivity");
+ rg.operationStatus = RefreshOperationStatus.Failed;
+ assert.equal((await getResponse()).risks.length, 1);
+ rg.autoRefresh = {
+ recovery: {
+ warningId: "group",
+ exchangeBaseUrl: ex.baseUrl,
+ exchangeMasterPub: "master",
+ amount: "TEST:1",
+ oldDepositExpiration: { t_s: 100 },
+ newDepositExpiration: { t_s: 200 },
+ nextRelevantDate: { t_s: 190 },
+ },
+ };
+ rg.operationStatus = RefreshOperationStatus.Finished;
+ rg.timestampFinished = ex.lastUpdate;
+ assert.equal((await getResponse()).recoveries.length, 1);
+ assert.equal((await getResponse()).risks.length, 0);
+ await handleDismissWalletWarning(wex, { warningId: "group" });
+ assert.equal((await getResponse()).recoveries.length, 0);
+});
diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts
@@ -73,6 +73,18 @@ function makeBalanceContext(
]),
);
const tx = {
+ async getConfig() {
+ return undefined;
+ },
+ async listAllRefreshGroups() {
+ throw Error("balance reads must not scan refresh history");
+ },
+ async getDenominationsByMasterPub() {
+ throw Error("balance reads must not load denomination candidates");
+ },
+ async getOperationRetry() {
+ throw Error("balance reads must not look up renewal retries");
+ },
async getDonationSummaries() {
return [];
},
@@ -346,8 +358,10 @@ test("balance scope inputs are loaded once per exchange", async () => {
tx.getExchangeScopeInfo = async () => {
throw Error("per-row scope lookup must not be used");
};
+ let denominationLoads = 0;
tx.getDenominationsByRefs = async () => {
- throw Error("getBalances must not hydrate denominations");
+ denominationLoads++;
+ return [];
};
const result = await getBalancesInsideTransaction(wex, tx);
@@ -355,6 +369,11 @@ test("balance scope inputs are loaded once per exchange", async () => {
assert.strictEqual(detailLoads, 1);
assert.strictEqual(globalExchangeLoads, 1);
assert.strictEqual(globalAuditorLoads, 1);
+ assert.strictEqual(
+ denominationLoads,
+ 0,
+ "balance reporting must not load denominations",
+ );
});
test("visible availability retains auditor and legacy-key scopes", async () => {
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -60,6 +60,7 @@
* Imports.
*/
+import { readRefreshBalanceInfo } from "./refreshBalance.js";
import {
AmountJson,
AmountLike,
@@ -153,7 +154,7 @@ function globalAuditorKey(
* user's global memberships are not. Keeping those inputs here avoids
* resolving them through the DAL once per availability row.
*/
-class BalanceScopeResolver {
+export class BalanceScopeResolver {
private exchangeDetails = new Map<
string,
WalletExchangeDetails | undefined
@@ -604,6 +605,7 @@ class BalancesStore {
export async function getBalancesInsideTransaction(
wex: WalletExecutionContext,
tx: WalletDbTransaction,
+ includeRefreshInfo = true,
): Promise<BalancesResponse> {
const scopeResolver = await BalanceScopeResolver.load(tx);
const balanceStore: BalancesStore = new BalancesStore(wex, scopeResolver);
@@ -966,7 +968,61 @@ export async function getBalancesInsideTransaction(
}
}
- return balanceStore.toBalancesResponse(haveProdBalance);
+ const response = balanceStore.toBalancesResponse(haveProdBalance);
+ if (includeRefreshInfo) await readRefreshBalanceInfo(tx, response);
+ const scenario = wex.ws.devExperimentState.refreshScenario;
+ if (wex.ws.config?.testing?.devModeActive && scenario) {
+ const b = response.balances[0];
+ if (b) {
+ const date = Math.floor(Date.now() / 1000);
+ const amount = b.available;
+ b.refreshInfo = {
+ risks:
+ scenario === "risk"
+ ? [
+ {
+ exchangeBaseUrl: "https://exchange.example/",
+ exchangeMasterPub: "demo",
+ amount,
+ earliestDepositExpiration: { t_s: date + 7 * 86400 },
+ reason: "no-replacement",
+ },
+ ]
+ : [],
+ recoveries:
+ scenario === "recovered"
+ ? [
+ {
+ warningId: "dd71-experiment",
+ exchangeBaseUrl: "https://exchange.example/",
+ exchangeMasterPub: "demo",
+ amount,
+ oldDepositExpiration: { t_s: date + 7 * 86400 },
+ newDepositExpiration: { t_s: date + 740 * 86400 },
+ nextRelevantDate: { t_s: date + 650 * 86400 },
+ },
+ ]
+ : [],
+ annualCostBound:
+ scenario === "cost-unavailable"
+ ? {
+ status: "unavailable",
+ horizonDays: 365,
+ projection: "stable-current-offerings",
+ reasons: ["unrefreshable-denomination"],
+ }
+ : {
+ status: "available",
+ horizonDays: 365,
+ projection: "stable-current-offerings",
+ amount: Amounts.stringify(
+ Amounts.zeroOfCurrency(b.scopeInfo.currency),
+ ),
+ },
+ };
+ }
+ }
+ return response;
}
/**
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -696,6 +696,7 @@ export enum PendingTaskType {
PeerPullDebit = "peer-pull-debit",
ValidateDenoms = "validate-denoms",
CleanupExpiredTransactions = "cleanup-expired-transactions",
+ RefreshBalance = "refresh-balance",
}
/**
@@ -718,6 +719,7 @@ export type ParsedTaskIdentifier =
| { tag: PendingTaskType.Recoup; recoupGroupId: string }
| { tag: PendingTaskType.Refresh; refreshGroupId: string }
| { tag: PendingTaskType.ValidateDenoms }
+ | { tag: PendingTaskType.RefreshBalance }
| { tag: PendingTaskType.CleanupExpiredTransactions };
export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
@@ -755,6 +757,7 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
return { tag: type, withdrawalGroupId: rest[0] };
case PendingTaskType.ValidateDenoms:
return { tag: type };
+ case PendingTaskType.RefreshBalance:
case PendingTaskType.CleanupExpiredTransactions:
return { tag: type };
default:
@@ -790,6 +793,7 @@ export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr {
return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr;
case PendingTaskType.ValidateDenoms:
return `${p.tag}:` as TaskIdStr;
+ case PendingTaskType.RefreshBalance:
case PendingTaskType.CleanupExpiredTransactions:
return `${p.tag}:` as TaskIdStr;
default:
diff --git a/packages/taler-wallet-core/src/refreshBalance.test.ts b/packages/taler-wallet-core/src/refreshBalance.test.ts
@@ -0,0 +1,311 @@
+/*
+ This file is part of GNU Taler
+ (C) 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
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License along with GNU Taler;
+ see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ AbsoluteTime,
+ BalancesResponse,
+ ScopeType,
+ stringifyScopeInfo,
+ encodeCrock,
+ DenomKeyType,
+} from "@gnu-taler/taler-util";
+import {
+ ConfigRecordKey,
+ WalletRefreshBalance,
+ WalletDenomination,
+ DenominationVerificationStatus,
+ timestampProtocolToDb,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { runnerFactories } from "./db/testing/runners.js";
+import {
+ persistRefreshBalanceInvalidation,
+ watchForCacheInvalidation,
+ WalletCacheInvalidation,
+} from "./db/shared.js";
+import {
+ publishRefreshBalance,
+ readRefreshBalanceInfo,
+ loadRefreshBalanceSnapshot,
+ processRefreshBalance,
+} from "./refreshBalance.js";
+import { addRefreshBalanceInfo } from "./autoRefreshBalance.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+function fixture() {
+ const now = AbsoluteTime.toStampMs(AbsoluteTime.now());
+ const response: BalancesResponse = {
+ haveProdBalance: true,
+ balances: [
+ {
+ scopeInfo: {
+ type: ScopeType.Exchange,
+ currency: "TEST",
+ url: "https://exchange/",
+ },
+ available: "TEST:10",
+ pendingIncoming: "TEST:0",
+ pendingOutgoing: "TEST:0",
+ flags: [],
+ },
+ ],
+ };
+ const saved: WalletRefreshBalance = {
+ version: 1,
+ generation: "",
+ computedAt: now,
+ nextCheck: now + 60_000,
+ scopes: {
+ [stringifyScopeInfo(response.balances[0].scopeInfo)]: {
+ available: "TEST:10",
+ info: {
+ risks: [
+ {
+ exchangeBaseUrl: "https://exchange/",
+ exchangeMasterPub: "master",
+ amount: "TEST:2",
+ reason: "pending",
+ earliestDepositExpiration: {
+ t_s: Math.floor(now / 1000) + 86400,
+ },
+ },
+ ],
+ recoveries: [],
+ annualCostBound: {
+ status: "available",
+ amount: "TEST:0.1",
+ horizonDays: 365,
+ projection: "stable-current-offerings",
+ },
+ },
+ },
+ },
+ };
+ return { saved, response };
+}
+
+for (const makeRunner of runnerFactories) {
+ test(`${makeRunner.name}: renewal cache survives reopening; invalidation is atomic and rejects stale publication`, async () => {
+ const dir = mkdtempSync(join(tmpdir(), "refresh-balance-"));
+ const file = join(dir, "wallet.sqlite3");
+ let runner = await makeRunner(file);
+ try {
+ const { saved, response } = fixture();
+ const status = () =>
+ response.balances[0].refreshInfo?.annualCostBound.status;
+ assert.equal(
+ await runner.runReadWriteTx((tx) => publishRefreshBalance(tx, saved)),
+ true,
+ );
+ await runner.close();
+ runner = await makeRunner(file);
+ await runner.runReadWriteTx((tx) => readRefreshBalanceInfo(tx, response));
+ assert.equal(status(), "available");
+ await assert.rejects(
+ runner.runReadWriteTx(async (tx) => {
+ const flag: WalletCacheInvalidation = { dirty: false };
+ await watchForCacheInvalidation(tx, flag).deleteRefreshGroup(
+ "missing",
+ );
+ await persistRefreshBalanceInvalidation(tx, flag);
+ throw Error("rollback changed inputs and generation");
+ }),
+ /rollback/,
+ );
+ await runner.runReadWriteTx((tx) => readRefreshBalanceInfo(tx, response));
+ assert.equal(status(), "available");
+ await runner.runReadWriteTx(async (tx) => {
+ const flag: WalletCacheInvalidation = { dirty: false };
+ await watchForCacheInvalidation(tx, flag).deleteRefreshGroup("missing");
+ await persistRefreshBalanceInvalidation(tx, flag);
+ });
+ assert.equal(
+ await runner.runReadWriteTx((tx) => publishRefreshBalance(tx, saved)),
+ false,
+ );
+ await runner.runReadWriteTx((tx) => readRefreshBalanceInfo(tx, response));
+ assert.equal(status(), "unavailable");
+ assert.equal(response.balances[0].refreshInfo?.risks.length, 1);
+ const generation = await runner.runReadWriteTx(
+ async (tx) =>
+ (await tx.getConfig(ConfigRecordKey.RefreshBalanceGeneration))!.value,
+ );
+ assert.equal(
+ await runner.runReadWriteTx((tx) =>
+ publishRefreshBalance(tx, { ...saved, generation }),
+ ),
+ true,
+ );
+ await runner.runReadWriteTx((tx) => readRefreshBalanceInfo(tx, response));
+ assert.equal(status(), "available");
+ } finally {
+ await runner.close();
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ test(`${makeRunner.name}: renewal calculation uses a detached snapshot`, async () => {
+ const runner = await makeRunner();
+ const wex = {
+ ws: { devExperimentState: {} },
+ runWalletDbTx: runner.runReadWriteTx.bind(runner),
+ } as unknown as WalletExecutionContext;
+ const now =
+ Math.floor(AbsoluteTime.toStampMs(AbsoluteTime.now()) / 1000) * 1000;
+ const day = 86400000;
+ const stamp = (ms: number) => timestampProtocolToDb({ t_s: ms / 1000 });
+ const denom: WalletDenomination = {
+ exchangeBaseUrl: "https://exchange/",
+ exchangeMasterPub: encodeCrock(new Uint8Array(32)),
+ denomPubHash: encodeCrock(new Uint8Array(64)),
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: "dummy",
+ age_mask: 0,
+ },
+ currency: "TEST",
+ value: "TEST:1",
+ stampStart: stamp(now - 640 * day),
+ stampExpireWithdraw: stamp(now - 630 * day),
+ stampExpireDeposit: stamp(now + 90 * day + 5000),
+ stampExpireLegal: stamp(now + 100 * day),
+ fees: {
+ feeWithdraw: "TEST:0",
+ feeRefresh: "TEST:0",
+ feeDeposit: "TEST:0",
+ feeRefund: "TEST:0",
+ },
+ isOffered: false,
+ isLost: false,
+ isRevoked: false,
+ masterSig: encodeCrock(new Uint8Array(64)),
+ verificationStatus: DenominationVerificationStatus.VerifiedGood,
+ };
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertDenomination(denom);
+ await tx.upsertCoinAvailability({
+ ...denom,
+ maxAge: 0,
+ freshCoinCount: 1,
+ visibleCoinCount: 1,
+ hasFreshCoins: 1,
+ });
+ });
+ const snapshot = await runner.runReadWriteTx((tx) =>
+ loadRefreshBalanceSnapshot(wex, tx),
+ );
+ assert.equal(snapshot.nextCheck, now + 5001);
+ await runner.close();
+ await addRefreshBalanceInfo(
+ wex,
+ snapshot.source,
+ snapshot.response,
+ snapshot.exchanges,
+ snapshot.availability,
+ snapshot.resolve,
+ snapshot.now,
+ );
+ assert.equal(snapshot.response.balances[0].available, "TEST:1");
+ assert.equal(
+ snapshot.response.balances[0].refreshInfo?.annualCostBound.status,
+ "unavailable",
+ );
+ });
+}
+
+test("cached reporting has fixed reads, with no eager fallback on a miss or expired result", async () => {
+ const { saved, response } = fixture();
+ let record: WalletRefreshBalance | undefined = saved;
+ const calls: string[] = [];
+ const tx = new Proxy(
+ {},
+ {
+ get: (_, method) => {
+ assert.equal(
+ method,
+ "getConfig",
+ "only persisted cache reads are allowed",
+ );
+ return async (key: string) => {
+ calls.push(key);
+ return key === ConfigRecordKey.RefreshBalance && record
+ ? { key, value: record }
+ : undefined;
+ };
+ },
+ },
+ ) as WalletDbTransaction;
+ for (let i = 0; i < 20; i++) await readRefreshBalanceInfo(tx, response);
+ assert.equal(calls.length, 40);
+ record = { ...saved, nextCheck: 0 };
+ await readRefreshBalanceInfo(tx, response);
+ assert.equal(
+ response.balances[0].refreshInfo?.annualCostBound.status,
+ "unavailable",
+ );
+ assert.equal(response.balances[0].refreshInfo?.risks.length, 1);
+ record = undefined;
+ await readRefreshBalanceInfo(tx, response);
+ assert.deepEqual(response.balances[0].refreshInfo?.risks, []);
+ assert.equal(
+ response.balances[0].refreshInfo?.annualCostBound.status,
+ "unavailable",
+ );
+});
+
+test("a current persisted result lets the background task sleep after restart", async () => {
+ const { saved } = fixture();
+ const tx = new Proxy(
+ {},
+ {
+ get: (_, method) => {
+ assert.equal(
+ method,
+ "getConfig",
+ "a current cache needs no snapshot scan",
+ );
+ return async (key: string) =>
+ key === ConfigRecordKey.RefreshBalance
+ ? { key, value: saved }
+ : undefined;
+ },
+ },
+ ) as WalletDbTransaction;
+ const wex = {
+ runWalletDbTx: async (f: (tx: WalletDbTransaction) => unknown) => f(tx),
+ } as unknown as WalletExecutionContext;
+ await processRefreshBalance(wex);
+});
+
+test("cache writes and maintenance retries do not invalidate the renewal report", async () => {
+ const { saved } = fixture();
+ const flag: WalletCacheInvalidation = { dirty: false };
+ const tx = watchForCacheInvalidation(
+ {
+ upsertConfig: async () => {},
+ deleteOperationRetry: async () => {},
+ } as unknown as WalletDbTransaction,
+ flag,
+ );
+ await tx.upsertConfig({ key: ConfigRecordKey.RefreshBalance, value: saved });
+ await tx.deleteOperationRetry("refresh-balance:");
+ assert.equal(flag.refreshBalanceDirty, undefined);
+ await tx.deleteOperationRetry("exchange-auto-refresh:https://exchange/");
+ assert.equal(flag.refreshBalanceDirty, true);
+});
diff --git a/packages/taler-wallet-core/src/refreshBalance.ts b/packages/taler-wallet-core/src/refreshBalance.ts
@@ -0,0 +1,314 @@
+/*
+ This file is part of GNU Taler
+ (C) 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
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License along with GNU Taler;
+ see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ BalancesResponse,
+ NotificationType,
+ stringifyScopeInfo,
+} from "@gnu-taler/taler-util";
+import {
+ ConfigRecordKey,
+ RefreshCoinStatus,
+ RefreshOperationStatus,
+ WalletRefreshBalance,
+ timestampAbsoluteFromDb,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { WalletExecutionContext, denomRefKey } from "./wallet.js";
+import {
+ BalanceScopeResolver,
+ getBalancesInsideTransaction,
+} from "./balance.js";
+import {
+ addRefreshBalanceInfo,
+ RefreshBalanceSource,
+} from "./autoRefreshBalance.js";
+import {
+ DAY_MS,
+ refreshLifetime,
+ stampMs,
+ unavailableAnnualBound,
+} from "./autoRefresh.js";
+import { TaskIdentifiers, TaskRunResult } from "./common.js";
+
+export const REFRESH_BALANCE_NOTIFICATION = "refresh-balance";
+
+/** Balance reads do no denomination selection or history scans. */
+export async function readRefreshBalanceInfo(
+ tx: WalletDbTransaction,
+ response: BalancesResponse,
+): Promise<void> {
+ const saved = (await tx.getConfig(ConfigRecordKey.RefreshBalance))?.value;
+ const generation =
+ (await tx.getConfig(ConfigRecordKey.RefreshBalanceGeneration))?.value ?? "";
+ const now = AbsoluteTime.toStampMs(AbsoluteTime.now());
+ const current =
+ saved?.version === 1 &&
+ saved.generation === generation &&
+ saved.computedAt <= now &&
+ saved.nextCheck > now;
+ for (const balance of response.balances) {
+ const cached =
+ saved?.version === 1
+ ? saved.scopes[stringifyScopeInfo(balance.scopeInfo)]
+ : undefined;
+ balance.refreshInfo = {
+ // Retain known warnings during recomputation. Expired coins use the
+ // ordinary expiry indication, even if the maintenance task is delayed.
+ risks:
+ cached?.info.risks.filter(
+ (r) =>
+ AbsoluteTime.toStampMs(
+ AbsoluteTime.fromProtocolTimestamp(r.earliestDepositExpiration),
+ ) > now,
+ ) ?? [],
+ recoveries: cached?.info.recoveries ?? [],
+ annualCostBound:
+ current && cached?.available === balance.available
+ ? cached.info.annualCostBound
+ : unavailableAnnualBound("checking"),
+ };
+ }
+}
+
+/** Capture database inputs once; all selection and projection runs afterward. */
+export async function loadRefreshBalanceSnapshot(
+ wex: WalletExecutionContext,
+ tx: WalletDbTransaction,
+) {
+ const now = AbsoluteTime.now(),
+ nowMs = AbsoluteTime.toStampMs(now);
+ const generation =
+ (await tx.getConfig(ConfigRecordKey.RefreshBalanceGeneration))?.value ?? "";
+ const response = await getBalancesInsideTransaction(wex, tx, false);
+ const exchanges = await tx.getExchanges();
+ const availability = await tx.getVisibleCoinAvailabilities();
+ const groups = await tx.listAllRefreshGroups();
+ const pending = groups.filter(
+ (g) => g.operationStatus !== RefreshOperationStatus.Finished,
+ );
+ const coins = await tx.getCoinsByPubs([
+ ...new Set(
+ pending.flatMap((g) =>
+ g.oldCoinPubs.filter(
+ (_, i) => g.statusPerCoin[i] !== RefreshCoinStatus.Finished,
+ ),
+ ),
+ ),
+ ]);
+ const coinsByPub = new Map(coins.map((c) => [c.coinPub, c]));
+ const denoms = new Map(
+ (await tx.getDenominationsByRefs([...availability, ...coins])).map((d) => [
+ denomRefKey(d),
+ d,
+ ]),
+ );
+ const masters = new Set([...denoms.values()].map((d) => d.exchangeMasterPub));
+ const byMaster = new Map(
+ await Promise.all(
+ [...masters].map(
+ async (master) =>
+ [
+ master,
+ exchanges.some((e) => e.detailsPointer?.masterPublicKey === master)
+ ? await tx.getDenominationsByMasterPub(master)
+ : [],
+ ] as const,
+ ),
+ ),
+ );
+ for (const ds of byMaster.values())
+ for (const d of ds) denoms.set(denomRefKey(d), d);
+ const retryIds = new Set([
+ ...pending.map((g) => `refresh:${g.refreshGroupId}`),
+ ...exchanges.map((e) =>
+ TaskIdentifiers.forExchangeAutoRefreshFromUrl(e.baseUrl),
+ ),
+ ]);
+ const retries = new Map(
+ await Promise.all(
+ [...retryIds].map(
+ async (id) => [id, await tx.getOperationRetry(id)] as const,
+ ),
+ ),
+ );
+ const resolver = await BalanceScopeResolver.load(tx);
+ const scopeKey = (
+ url: string,
+ currency: string,
+ master?: string,
+ hash?: string,
+ ) => JSON.stringify([url, currency, master, hash]);
+ const scopes = new Map<
+ string,
+ Awaited<ReturnType<BalanceScopeResolver["resolveScope"]>>
+ >();
+ for (const d of [...availability, ...denoms.values()]) {
+ scopes.set(
+ scopeKey(
+ d.exchangeBaseUrl,
+ d.currency,
+ d.exchangeMasterPub,
+ d.denomPubHash,
+ ),
+ await resolver.resolveScope(
+ d.exchangeBaseUrl,
+ d.currency,
+ d.exchangeMasterPub,
+ d.denomPubHash,
+ ),
+ );
+ }
+ for (const g of groups) {
+ const n = g.autoRefresh?.recovery;
+ if (n)
+ scopes.set(
+ scopeKey(n.exchangeBaseUrl, g.currency, n.exchangeMasterPub),
+ await resolver.resolveScope(
+ n.exchangeBaseUrl,
+ g.currency,
+ n.exchangeMasterPub,
+ ),
+ );
+ }
+ const source: RefreshBalanceSource = {
+ getDenominationsByRefs: async (refs) =>
+ refs.flatMap((r) => {
+ const d = denoms.get(denomRefKey(r));
+ return d ? [d] : [];
+ }),
+ getDenominationsByMasterPub: async (master) => byMaster.get(master) ?? [],
+ getCoinsByPubs: async (pubs) =>
+ pubs.flatMap((p) => {
+ const c = coinsByPub.get(p);
+ return c ? [c] : [];
+ }),
+ getOperationRetry: async (id) => retries.get(id),
+ listAllRefreshGroups: async () => groups,
+ };
+ let nextCheck = nowMs + DAY_MS;
+ const boundary = (at: number) => {
+ if (at > nowMs) nextCheck = Math.min(nextCheck, at);
+ };
+ for (const e of exchanges)
+ if (e.lastUpdate != null)
+ boundary(
+ AbsoluteTime.toStampMs(timestampAbsoluteFromDb(e.lastUpdate)) + DAY_MS,
+ );
+ for (const d of denoms.values()) {
+ boundary(stampMs(d, "stampStart"));
+ boundary(stampMs(d, "stampExpireWithdraw"));
+ const life = refreshLifetime(d);
+ if (life) {
+ boundary(Math.floor(life.deposit - life.emergency) + 1);
+ boundary(life.deposit);
+ }
+ }
+ return {
+ generation,
+ response,
+ exchanges,
+ availability,
+ source,
+ now,
+ nextCheck,
+ resolve: async (
+ url: string,
+ currency: string,
+ master?: string,
+ hash?: string,
+ ) => {
+ const scope = scopes.get(scopeKey(url, currency, master, hash));
+ if (!scope) throw Error("missing scope in refresh balance snapshot");
+ return scope;
+ },
+ };
+}
+
+/** Compare the generation in the publishing transaction to reject stale work. */
+export async function publishRefreshBalance(
+ tx: WalletDbTransaction,
+ value: WalletRefreshBalance,
+): Promise<boolean> {
+ const generation =
+ (await tx.getConfig(ConfigRecordKey.RefreshBalanceGeneration))?.value ?? "";
+ if (generation !== value.generation) return false;
+ const previous = (await tx.getConfig(ConfigRecordKey.RefreshBalance))?.value;
+ await tx.upsertConfig({ key: ConfigRecordKey.RefreshBalance, value });
+ if (
+ JSON.stringify(previous?.scopes) !== JSON.stringify(value.scopes) ||
+ previous?.generation !== value.generation ||
+ (previous?.nextCheck ?? 0) <= value.computedAt
+ ) {
+ tx.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: REFRESH_BALANCE_NOTIFICATION,
+ });
+ }
+ return true;
+}
+
+export async function processRefreshBalance(
+ wex: WalletExecutionContext,
+): Promise<TaskRunResult> {
+ const current = await wex.runWalletDbTx(async (tx) => {
+ const saved = (await tx.getConfig(ConfigRecordKey.RefreshBalance))?.value;
+ const generation =
+ (await tx.getConfig(ConfigRecordKey.RefreshBalanceGeneration))?.value ??
+ "";
+ return saved?.version === 1 &&
+ saved.generation === generation &&
+ saved.computedAt <= AbsoluteTime.toStampMs(AbsoluteTime.now()) &&
+ saved.nextCheck > AbsoluteTime.toStampMs(AbsoluteTime.now())
+ ? saved
+ : undefined;
+ });
+ if (current)
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.fromMilliseconds(current.nextCheck),
+ );
+ const snapshot = await wex.runWalletDbTx((tx) =>
+ loadRefreshBalanceSnapshot(wex, tx),
+ );
+ // This source contains only detached records and in-memory lookups. The
+ // expensive calculation does not hold a database transaction open.
+ await addRefreshBalanceInfo(
+ wex,
+ snapshot.source,
+ snapshot.response,
+ snapshot.exchanges,
+ snapshot.availability,
+ snapshot.resolve,
+ snapshot.now,
+ );
+ const value: WalletRefreshBalance = {
+ version: 1,
+ generation: snapshot.generation,
+ computedAt: AbsoluteTime.toStampMs(snapshot.now),
+ nextCheck: snapshot.nextCheck,
+ scopes: Object.fromEntries(
+ snapshot.response.balances.map((b) => [
+ stringifyScopeInfo(b.scopeInfo),
+ { available: b.available, info: b.refreshInfo! },
+ ]),
+ ),
+ };
+ const saved = await wex.runWalletDbTx((tx) =>
+ publishRefreshBalance(tx, value),
+ );
+ return saved
+ ? TaskRunResult.runAgainAt(AbsoluteTime.fromMilliseconds(value.nextCheck))
+ : TaskRunResult.runAgainAfter({ seconds: 1 });
+}
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
@@ -17,6 +17,7 @@
/**
* Imports.
*/
+import { processRefreshBalance } from "./refreshBalance.js";
import {
autoRefreshRetryCap,
refreshLifetime,
@@ -171,6 +172,7 @@ function taskGivesLiveness(taskId: string): boolean {
case PendingTaskType.ExchangeUpdate:
case PendingTaskType.ExchangeAutoRefresh:
case PendingTaskType.ExchangeWalletKyc:
+ case PendingTaskType.RefreshBalance:
case PendingTaskType.ValidateDenoms:
case PendingTaskType.CleanupExpiredTransactions:
return false;
@@ -906,6 +908,8 @@ async function callOperationHandlerForTaskId(
return await processPeerPushCredit(wex, pending.peerPushCreditId);
case PendingTaskType.ExchangeWalletKyc:
return await processExchangeKyc(wex, pending.exchangeBaseUrl);
+ case PendingTaskType.RefreshBalance:
+ return await processRefreshBalance(wex);
case PendingTaskType.ValidateDenoms:
return await processValidateDenoms(wex);
case PendingTaskType.CleanupExpiredTransactions:
@@ -944,6 +948,7 @@ async function taskToRetryNotification(
case PendingTaskType.PeerPushDebit:
case PendingTaskType.Purchase:
return makeTransactionRetryNotification(ws, tx, pendingTaskId, e);
+ case PendingTaskType.RefreshBalance:
case PendingTaskType.ValidateDenoms:
case PendingTaskType.CleanupExpiredTransactions:
case PendingTaskType.ExchangeWalletKyc:
@@ -1376,6 +1381,10 @@ export async function getActiveTaskIds(
}
}
+ res.taskIds.push(
+ constructTaskIdentifier({ tag: PendingTaskType.RefreshBalance }),
+ );
+
// Always try to validate unvalidated denoms.
res.taskIds.push(
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -23,7 +23,11 @@
/**
* Imports.
*/
-import { TaskIdentifiers, PendingTaskType } from "./common.js";
+import {
+ TaskIdentifiers,
+ PendingTaskType,
+ constructTaskIdentifier,
+} from "./common.js";
import {
AmountJson,
AsyncCondition,
@@ -88,7 +92,10 @@ import {
WALLET_DB_REMATERIALIZE_STEP,
} from "./db/indexeddb/fixups.js";
import { WalletDbHandle } from "./db/handle.js";
-import { watchForCacheInvalidation } from "./db/shared.js";
+import {
+ persistRefreshBalanceInvalidation,
+ watchForCacheInvalidation,
+} from "./db/shared.js";
import {
WalletCoinAvailabilityRef,
WalletDbTransaction,
@@ -893,12 +900,17 @@ async function runWalletDbTx<T>(
// the wallet's caches stale. Acted on only after a successful commit: a
// transaction that rolls back changed nothing, and an attempt that is about
// to be retried resets the flag so a discarded write cannot carry over.
- const dirty = { dirty: false, terminalPaymentIds: new Set<string>() };
+ const dirty = {
+ dirty: false,
+ refreshBalanceDirty: false,
+ terminalPaymentIds: new Set<string>(),
+ };
// Retries wrap the transaction for both backends: the failure modes they
// guard against (transaction aborted, retryable conflict) are not specific
// to the storage layer.
return await handleTxRetries(wex, async () => {
dirty.dirty = false;
+ dirty.refreshBalanceDirty = false;
dirty.terminalPaymentIds.clear();
const location = getCallerInfo();
wex.oc.observe({
@@ -908,9 +920,11 @@ async function runWalletDbTx<T>(
});
const start = performanceNow();
try {
- const ret = await wex.ws.db.runReadWriteTx(
- async (tx) => await f(watchForCacheInvalidation(tx, dirty)),
- );
+ const ret = await wex.ws.db.runReadWriteTx(async (tx) => {
+ const ret = await f(watchForCacheInvalidation(tx, dirty));
+ await persistRefreshBalanceInvalidation(tx, dirty);
+ return ret;
+ });
wex.oc.observe({
type: ObservabilityEventType.DbQueryFinishSuccess,
name: "<unknown>",
@@ -920,6 +934,10 @@ async function runWalletDbTx<T>(
if (dirty.dirty) {
wex.ws.clearAllCaches();
}
+ if (dirty.refreshBalanceDirty && !wex.ws.stopped)
+ wex.taskScheduler.startShepherdTask(
+ constructTaskIdentifier({ tag: PendingTaskType.RefreshBalance }),
+ );
for (const proposalId of dirty.terminalPaymentIds) {
wex.ws.clearDepositPermissionCache(proposalId);
}
@@ -1419,13 +1437,19 @@ export class InternalWalletState {
async runStandaloneWalletDbTx<T>(
f: (tx: WalletDbTransaction) => Promise<T>,
): Promise<T> {
- const dirty = { dirty: false };
- const ret = await this.db.runReadWriteTx(
- async (tx) => await f(watchForCacheInvalidation(tx, dirty)),
- );
+ const dirty = { dirty: false, refreshBalanceDirty: false };
+ const ret = await this.db.runReadWriteTx(async (tx) => {
+ const ret = await f(watchForCacheInvalidation(tx, dirty));
+ await persistRefreshBalanceInvalidation(tx, dirty);
+ return ret;
+ });
if (dirty.dirty) {
this.clearAllCaches();
}
+ if (dirty.refreshBalanceDirty && !this.stopped)
+ this.taskScheduler.startShepherdTask(
+ constructTaskIdentifier({ tag: PendingTaskType.RefreshBalance }),
+ );
return ret;
}
@@ -1769,7 +1793,8 @@ export class InternalWalletState {
notify(n: WalletNotification): void {
logger.trace(`Notification: ${j2s(n)}`);
if (
- n.type === NotificationType.BalanceChange ||
+ (n.type === NotificationType.BalanceChange &&
+ n.hintTransactionId !== "refresh-balance") ||
n.type === NotificationType.TransactionStateTransition
) {
for (const id of this.taskScheduler.getActiveTasks()) {