taler-typescript-core

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

commit daa6250585b1d6036410272fa8cf6cf51f874ac1
parent 273ae7e712e8c61cfc24e71d05bb41e51d1bf7e9
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 18:11:23 +0200

wallet-core: define DD71 greedy renewal policy and cost bounds

Evaluate lifetime extension, free renewal on external power, and emergency
renewal in strict priority order. Use deterministic greedy outputs for
eligibility and bound projected holding costs over reachable denominations.
Provide a yielding calculation for background maintenance.

Diffstat:
Mpackages/taler-util/src/types-taler-wallet.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/autoRefresh.test.ts | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/autoRefresh.ts | 373+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/common.ts | 17++++++++++++++---
Mpackages/taler-wallet-core/src/denomSelection.ts | 4++--
Mpackages/taler-wallet-core/src/denominations.ts | 19+++++++++++++++----
Mpackages/taler-wallet-core/src/diagnostics.ts | 7++++---
7 files changed, 791 insertions(+), 12 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -398,7 +398,79 @@ export enum BalanceFlag { OutgoingKyc = "outgoing-kyc", } +/** Host observation; unknown never qualifies for an opportunistic refresh. */ +export enum WalletPowerSource { + External = "external", + Battery = "battery", + Unknown = "unknown", +} + +export interface HintPowerStateRequest { + powerSource: WalletPowerSource; +} +export const codecForHintPowerStateRequest = (): Codec<HintPowerStateRequest> => + buildCodecForObject<HintPowerStateRequest>() + .property("powerSource", codecForStringEnum(WalletPowerSource)) + .build("HintPowerStateRequest"); + +export interface DismissWalletWarningRequest { + warningId: string; +} +export const codecForDismissWalletWarningRequest = + (): Codec<DismissWalletWarningRequest> => + buildCodecForObject<DismissWalletWarningRequest>() + .property("warningId", codecForString()) + .build("DismissWalletWarningRequest"); + +export interface CashExpirationRisk { + exchangeBaseUrl: string; + exchangeMasterPub: string; + amount: AmountString; + earliestDepositExpiration: TalerProtocolTimestamp; + reason: + | "pending" + | "connectivity" + | "exchange-error" + | "no-replacement" + | "checking" + | "invalid-lifetime"; +} + +export interface CashRenewalNotice { + warningId: string; + exchangeBaseUrl: string; + exchangeMasterPub: string; + amount: AmountString; + oldDepositExpiration: TalerProtocolTimestamp; + newDepositExpiration: TalerProtocolTimestamp; + /** Earliest emergency threshold of the renewed coins. */ + nextRelevantDate: TalerProtocolTimestamp; +} + +/** Conditional on stable, continuously available compatible offerings and timely reveal. */ +export type AnnualRefreshCostBound = { + horizonDays: 365; + projection: "stable-current-offerings"; +} & ( + | { + status: "available"; + amount: AmountString; + } + | { + status: "unavailable"; + reasons: string[]; + } +); + +export interface WalletRefreshInfo { + risks: CashExpirationRisk[]; + recoveries: CashRenewalNotice[]; + annualCostBound: AnnualRefreshCostBound; +} + export interface WalletBalance { + /** DD71 expiry information and conditional cost of keeping this balance. */ + refreshInfo?: WalletRefreshInfo; scopeInfo: ScopeInfo; available: AmountString; pendingIncoming: AmountString; diff --git a/packages/taler-wallet-core/src/autoRefresh.test.ts b/packages/taler-wallet-core/src/autoRefresh.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 { + AbsoluteTime, + AmountString, + DenomKeyType, + WalletPowerSource, +} from "@gnu-taler/taler-util"; +import { + DenominationVerificationStatus, + timestampProtocolToDb, + WalletDenomination, +} from "./db/records.js"; +import { + annualLossBound, + autoRefreshDeadline, + autoRefreshRetryCap, + computeAnnualRefreshBound, + computeAnnualRefreshBoundAsync, + DAY_MS as day, + evaluateAutoRefresh, + refreshLifetime, + selectAutoRefreshOutputs, +} from "./autoRefresh.js"; + +const nowMs = 2_000_000_000_000; +const now = AbsoluteTime.fromMilliseconds(nowMs); +const stamp = (ms: number) => timestampProtocolToDb({ t_s: ms / 1000 }); +function denom( + hash: string, + remaining = 740, + value: AmountString = "TEST:1", +): WalletDenomination { + return { + currency: "TEST", + value, + denomPubHash: hash, + exchangeBaseUrl: "https://exchange/", + exchangeMasterPub: "master", + denomPub: { cipher: DenomKeyType.Rsa, age_mask: 0, rsa_public_key: "rsa" }, + fees: { + feeDeposit: "TEST:0", + feeWithdraw: "TEST:0", + feeRefresh: "TEST:0", + feeRefund: "TEST:0", + }, + stampStart: stamp(nowMs + (remaining - 750) * day), + stampExpireWithdraw: stamp(nowMs + (remaining - 720) * day), + stampExpireDeposit: stamp(nowMs + remaining * day), + stampExpireLegal: stamp(nowMs + (remaining + 10) * day), + verificationStatus: DenominationVerificationStatus.VerifiedGood, + isOffered: true, + isLost: false, + isRevoked: false, + masterSig: "signature", + } as WalletDenomination; +} +function evaluate( + old: WalletDenomination, + outputs: WalletDenomination[], + power = WalletPowerSource.Unknown, + at = nowMs, +) { + return evaluateAutoRefresh({ + now: AbsoluteTime.fromMilliseconds(at), + inputAmount: old.value, + oldDenom: old, + withdrawableDenoms: outputs, + powerSource: power, + }); +} + +test("rule 1 is strict and schedules the first millisecond after equality", () => { + const old = denom("old", 185), + output = denom("output"); + const equal = evaluate(old, [output]); + assert.equal(equal.rule, undefined); + assert.equal(equal.nextCheck, nowMs + 1); + assert.equal( + evaluate(old, [output], WalletPowerSource.Battery, nowMs + 1).rule, + 1, + ); +}); + +test("adaptive emergency threshold prevents repeated short-lived emergency refreshes", () => { + const old = denom("old", 10); + old.stampExpireWithdraw = stamp(nowMs - 30 * day); + const output = denom("output", 39); + output.stampExpireWithdraw = stamp(nowMs + day); + output.stampStart = stamp(nowMs - day); + assert.equal(refreshLifetime(old)?.emergency, 10 * day); + assert.equal(evaluate(old, [output]).rule, undefined); + const after = evaluate(old, [output], WalletPowerSource.Battery, nowMs + 1); + assert.equal(after.rule, 3); + assert.equal(after.risk, true); + assert.equal(evaluate(output, [output]).rule, undefined); +}); + +test("rule 2 requires actual zero total loss and confirmed external power", () => { + const old = denom("old", 179), + output = denom("output", 400); + output.stampExpireWithdraw = stamp(nowMs + 30 * day); + output.stampStart = stamp(nowMs - day); + assert.equal(evaluate(old, [output], WalletPowerSource.External).rule, 2); + assert.equal(evaluate(old, [output]).rule, undefined); + assert.equal( + evaluate(old, [output], WalletPowerSource.Battery).rule, + undefined, + ); + output.fees.feeWithdraw = "TEST:0.01"; + assert.equal( + evaluate(old, [output], WalletPowerSource.External).rule, + undefined, + ); + output.fees.feeWithdraw = "TEST:0"; + output.value = "TEST:0.99"; + assert.equal( + evaluate(old, [output], WalletPowerSource.External).rule, + undefined, + ); +}); + +test("rule 2 input and output boundaries are strict", () => { + const old = denom("old", 180), + output = denom("output", 360); + output.stampExpireWithdraw = stamp(nowMs + day); + output.stampStart = stamp(nowMs - day); + assert.equal( + evaluate(old, [output], WalletPowerSource.External).rule, + undefined, + ); + old.stampExpireDeposit = stamp(nowMs + 179 * day); + assert.equal( + evaluate(old, [output], WalletPowerSource.External).rule, + undefined, + ); + output.stampExpireDeposit = stamp(nowMs + 361 * day); + assert.equal(evaluate(old, [output], WalletPowerSource.External).rule, 2); +}); + +test("risk is independent of rule 1; invalid and expired lifetimes are not candidates", () => { + const old = denom("old", 89); + assert.deepEqual( + [ + evaluate(old, [denom("output")]).rule, + evaluate(old, [denom("output")]).risk, + ], + [1, true], + ); + old.stampExpireDeposit = stamp(nowMs); + assert.equal(evaluate(old, [denom("output")]).rule, undefined); + assert.equal(evaluate(old, [denom("output")]).risk, false); + old.stampExpireDeposit = old.stampExpireWithdraw; + assert.equal(evaluate(old, []).invalidLifetime, true); + old.stampExpireDeposit = timestampProtocolToDb({ t_s: "never" }); + assert.equal(evaluate(old, []).invalidLifetime, true); +}); + +test("greedy outputs can be suboptimal and still define the policy's actual cost", () => { + const old = denom("old", 100, "TEST:6"); + const plan = selectAutoRefreshOutputs(now, old.value, old, [ + denom("three", 740, "TEST:3"), + denom("four", 740, "TEST:4"), + ])!; + assert.deepEqual(plan.selectedDenoms, [{ denomPubHash: "four", count: 1 }]); + assert.equal(plan.totalCoinValue, "TEST:4"); + assert.equal(plan.totalCost, "TEST:2"); +}); + +test("mixed outputs use earliest expiry; fees and the output limit include remainder", () => { + const old = denom("old", 100, "TEST:6"); + const a = denom("four", 740, "TEST:4"), + b = denom("one", 730); + old.fees.feeRefresh = "TEST:0.5"; + b.fees.feeWithdraw = "TEST:0.1"; + const plan = selectAutoRefreshOutputs(now, old.value, old, [b, a])!; + assert.equal(plan.totalCoinValue, "TEST:5"); + assert.equal(plan.totalCost, "TEST:1"); + assert.equal(plan.minOutputExpiry, nowMs + 730 * day); + old.value = "TEST:100"; + old.fees.feeRefresh = "TEST:0"; + b.fees.feeWithdraw = "TEST:0"; + assert.equal( + selectAutoRefreshOutputs(now, old.value, old, [b])?.totalCoinValue, + "TEST:64", + ); +}); + +test("output ordering is fee, expiration and hash deterministic; unusable keys excluded", () => { + const old = denom("old", 100), + a = denom("a"), + b = denom("b"); + const expensive = denom("cheap-looking", 745); + expensive.fees.feeWithdraw = "TEST:0.1"; + assert.equal( + selectAutoRefreshOutputs(now, old.value, old, [expensive, b, a]) + ?.selectedDenoms[0].denomPubHash, + "a", + ); + for (const bad of [ + { ...a, isRevoked: true }, + { ...a, isLost: true }, + { ...a, isOffered: false }, + { ...a, verificationStatus: DenominationVerificationStatus.Unverified }, + { ...a, exchangeMasterPub: "other" }, + { ...a, stampStart: stamp(nowMs + day) }, + { ...a, denomPub: { ...a.denomPub, age_mask: 8 } }, + { ...a, stampExpireWithdraw: stamp(nowMs) }, + ]) + assert.equal( + selectAutoRefreshOutputs(now, old.value, old, [bad]), + undefined, + ); + assert.equal(selectAutoRefreshOutputs(now, old.value, old, []), undefined); +}); + +test("deadlines survive reevaluation, restart, missed eligibility and urgent boundaries", () => { + const life = refreshLifetime(denom("old", 100))!; + let draws = 0; + const deadline = autoRefreshDeadline(nowMs, life, undefined, () => { + draws++; + return 0.5; + }); + assert.ok(deadline > nowMs && deadline <= nowMs + day); + const restored = JSON.parse(JSON.stringify({ deadline })); + assert.equal( + autoRefreshDeadline(nowMs + 100, life, restored.deadline, () => { + throw Error("redraw"); + }), + deadline, + ); + assert.equal(autoRefreshDeadline(deadline + day, life, deadline), deadline); + assert.equal(autoRefreshDeadline(life.urgent, life), life.urgent); + assert.equal(draws, 1); + assert.ok(autoRefreshRetryCap(life.urgent - 10, life) <= 10); + assert.ok(autoRefreshRetryCap(life.deposit - 10, life) <= 10); +}); + +test("annual bound includes initial refresh, greedy rounding and split descendants", () => { + const output = denom("one"), + old = denom("old", 80, "TEST:10"); + old.fees.feeRefresh = "TEST:0.1"; + const bound = computeAnnualRefreshBound(now, "TEST:100", [old], [output]); + assert.equal(bound.status, "available"); + if (bound.status === "available") assert.equal(bound.amount, "TEST:10"); + assert.equal(annualLossBound("TEST:100", 1n, 100n, 1), "TEST:1"); + assert.equal(annualLossBound("TEST:1", 1n, 3n, 1), "TEST:0.33333334"); + assert.equal(annualLossBound("TEST:100", 0n, 1n, 100), "TEST:0"); + assert.equal(annualLossBound("TEST:100", 1n, 10n, 2), "TEST:19"); +}); + +test("annual bound refuses unrefreshable descendants and incompatible lifetime bounds", () => { + const output = denom("dust"); + output.fees.feeRefresh = "TEST:0.01"; + assert.equal( + computeAnnualRefreshBound( + now, + "TEST:10", + [denom("old", 100, "TEST:10")], + [output], + ).status, + "unavailable", + ); + const short = denom("short"); + short.stampExpireWithdraw = stamp(nowMs + 739 * day); + assert.equal( + computeAnnualRefreshBound(now, "TEST:1", [denom("old", 100)], [short]) + .status, + "unavailable", + ); +}); + +test("background cost projection yields while preserving the synchronous result", async () => { + const outputs = Array.from({ length: 100 }, (_, i) => denom(`key-${i}`)); + const expected = computeAnnualRefreshBound( + now, + "TEST:10", + [outputs[0]], + outputs, + ); + let otherTaskRan = false; + const timer = setTimeout(() => { + otherTaskRan = true; + }, 0); + try { + const actual = await computeAnnualRefreshBoundAsync( + now, + "TEST:10", + [outputs[0]], + outputs, + ); + assert.equal(otherTaskRan, true); + assert.deepEqual(actual, expected); + } finally { + clearTimeout(timer); + } +}); diff --git a/packages/taler-wallet-core/src/autoRefresh.ts b/packages/taler-wallet-core/src/autoRefresh.ts @@ -0,0 +1,373 @@ +/* + 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, + DenomSelectionState, + WalletPowerSource, + AnnualRefreshCostBound, + getRandomBytes, +} from "@gnu-taler/taler-util"; +import { + DenominationVerificationStatus, + WalletDenomination, + timestampAbsoluteFromDb, +} from "./db/records.js"; +import { isWithdrawableDenom } from "./denominations.js"; +import { selectWithdrawalDenominations } from "./denomSelection.js"; + +export const DAY_MS = 86_400_000; +export const MAX_REFRESH_OUTPUTS = 64; +export const stampMs = ( + d: WalletDenomination, + field: "stampExpireDeposit" | "stampExpireWithdraw" | "stampStart", +) => + AbsoluteTime.isNever(timestampAbsoluteFromDb(d[field])) + ? Infinity + : AbsoluteTime.toStampMs(timestampAbsoluteFromDb(d[field])); + +export function refreshLifetime( + d: WalletDenomination, +): { deposit: number; emergency: number; urgent: number } | undefined { + const deposit = stampMs(d, "stampExpireDeposit"); + const withdraw = stampMs(d, "stampExpireWithdraw"); + if ( + !Number.isFinite(deposit) || + !Number.isFinite(withdraw) || + deposit <= withdraw + ) + return; + const emergency = Math.min(90 * DAY_MS, (deposit - withdraw) / 4); + return { deposit, emergency, urgent: deposit - emergency / 10 }; +} + +export interface AutoRefreshOutputPlan extends DenomSelectionState { + minOutputExpiry: number; + nextRelevantDate: number; + totalCost: AmountString; +} + +/** Greedy by explicit user choice: DD71's global optimum is deferred. + * Use this exact selection for the policy decision, accounting and /melt. + */ +export function selectAutoRefreshOutputs( + now: AbsoluteTime, + input: AmountString, + old: WalletDenomination, + candidates: WalletDenomination[], +): AutoRefreshOutputPlan | undefined { + if (Amounts.cmp(input, old.fees.feeRefresh) <= 0) return; + const denoms = candidates + .filter( + (d) => + d.exchangeMasterPub === old.exchangeMasterPub && + d.currency === old.currency && + d.denomPub.cipher === old.denomPub.cipher && + d.denomPub.age_mask === old.denomPub.age_mask && + refreshLifetime(d) !== undefined && + Amounts.isNonZero(d.value) && + d.verificationStatus === DenominationVerificationStatus.VerifiedGood && + isWithdrawableDenom(d, now, 0), + ) + .sort( + (a, b) => + Amounts.cmp(b.value, a.value) || + Amounts.cmp(a.fees.feeWithdraw, b.fees.feeWithdraw) || + stampMs(b, "stampExpireDeposit") - stampMs(a, "stampExpireDeposit") || + (a.denomPubHash < b.denomPubHash + ? -1 + : a.denomPubHash > b.denomPubHash + ? 1 + : 0), + ); + const selection = selectWithdrawalDenominations( + Amounts.sub(input, old.fees.feeRefresh).amount, + denoms, + { limitCoins: MAX_REFRESH_OUTPUTS, now, marginMs: 0 }, + ); + if ( + !selection.selectedDenoms.length || + Amounts.isZero(selection.totalCoinValue) + ) + return; + const byHash = new Map(denoms.map((d) => [d.denomPubHash, d])); + const outputs = selection.selectedDenoms.map( + (s) => refreshLifetime(byHash.get(s.denomPubHash)!)!, + ); + return { + ...selection, + minOutputExpiry: Math.min(...outputs.map((d) => d.deposit)), + nextRelevantDate: Math.min(...outputs.map((d) => d.deposit - d.emergency)), + totalCost: Amounts.stringify( + Amounts.sub(input, selection.totalCoinValue).amount, + ), + }; +} + +export interface AutoRefreshEvaluation { + rule?: 1 | 2 | 3; + plan?: AutoRefreshOutputPlan; + risk: boolean; + invalidLifetime: boolean; + nextCheck: number; +} + +export function evaluateAutoRefresh(input: { + now: AbsoluteTime; + inputAmount: AmountString; + oldDenom: WalletDenomination; + withdrawableDenoms: WalletDenomination[]; + powerSource: WalletPowerSource; +}): AutoRefreshEvaluation { + const now = AbsoluteTime.toStampMs(input.now); + const lifetime = refreshLifetime(input.oldDenom); + const result: AutoRefreshEvaluation = { + risk: false, + invalidLifetime: !lifetime, + nextCheck: Infinity, + }; + if (!lifetime || lifetime.deposit <= now) return result; + const { deposit: D, emergency: E } = lifetime; + const R = D - now; + result.risk = R < E; + const next = (t: number) => { + if (t > now) result.nextCheck = Math.min(result.nextCheck, t); + }; + // The scheduling clock has millisecond resolution. Equality is not eligible. + const after = (t: number) => next(Math.floor(t) + 1); + next(D); + after(D - E); + for (const d of input.withdrawableDenoms) { + next(stampMs(d, "stampStart")); + next(stampMs(d, "stampExpireWithdraw")); + } + const plan = selectAutoRefreshOutputs( + input.now, + input.inputAmount, + input.oldDenom, + input.withdrawableDenoms, + ); + result.plan = plan; + if (!plan || plan.minOutputExpiry <= D) return result; + const remaining = plan.minOutputExpiry - now; + if (remaining > 4 * R) result.rule = 1; + else if ( + R < 180 * DAY_MS && + remaining > 360 * DAY_MS && + Amounts.isZero(plan.totalCost) && + input.powerSource === WalletPowerSource.External + ) + result.rule = 2; + else if (R < E) result.rule = 3; + after((4 * D - plan.minOutputExpiry) / 3); + if (Amounts.isZero(plan.totalCost)) { + after(D - 180 * DAY_MS); + next(plan.minOutputExpiry - 360 * DAY_MS); + } + return result; +} + +/** Private 48-bit uniform variate, independent of public denomination data. */ +export function refreshRandom(): number { + return getRandomBytes(6).reduce((n, b) => n * 256 + b, 0) / 2 ** 48; +} + +export function autoRefreshDeadline( + now: number, + lifetime: NonNullable<ReturnType<typeof refreshLifetime>>, + previous?: number, + random = refreshRandom, +): number { + if (now >= lifetime.urgent) return Math.min(previous ?? now, now); + const cap = Math.min( + DAY_MS, + lifetime.emergency / 10, + (lifetime.urgent - now) / 2, + ); + // Existing deadlines can move earlier, but never get a fresh random delay. + return previous === undefined + ? now + Math.floor(random() * (Math.floor(cap) + 1)) + : Math.min(previous, Math.floor(lifetime.urgent)); +} + +/** Never let retry backoff consume the last opportunity before urgency/expiry. */ +export function autoRefreshRetryCap( + now: number, + lifetime: NonNullable<ReturnType<typeof refreshLifetime>>, +): number { + const boundary = now < lifetime.urgent ? lifetime.urgent : lifetime.deposit; + return Math.max(1, Math.floor((boundary - now) / 2)); +} + +function units(amount: AmountString): bigint { + const a = Amounts.parseOrThrow(amount); + return BigInt(a.value) * 100_000_000n + BigInt(a.fraction); +} +function fromUnits(currency: string, n: bigint): AmountString { + return Amounts.stringify({ + currency, + value: Number(n / 100_000_000n), + fraction: Number(n % 100_000_000n), + }); +} +export const unavailableAnnualBound = ( + ...reasons: string[] +): AnnualRefreshCostBound => ({ + status: "unavailable", + horizonDays: 365, + projection: "stable-current-offerings", + reasons, +}); + +/** Round retention downward with fixed-point exponentiation. This bounds loss + * upward without enormous rational powers for very short denomination lives. + */ +export function annualLossBound( + balance: AmountString, + loss: bigint, + value: bigint, + count: number, +): AmountString { + const scale = 10n ** 24n; + let retained = scale; + let factor = ((value - loss) * scale) / value; + let n = BigInt(count); + while (n > 0n) { + if (n & 1n) retained = (retained * factor) / scale; + factor = (factor * factor) / scale; + n >>= 1n; + } + const numerator = units(balance) * (scale - retained); + return fromUnits( + Amounts.currencyOf(balance), + (numerator + scale - 1n) / scale, + ); +} + +/** Conditional projection: every currently offered family remains continuously + * available with its value, fees and lifetime bounds. Include every compatible + * reachable face value, not just the first generation's output. Hash/expiry + * ties cannot affect recovered value in our greedy ordering. + */ +function* annualRefreshBoundSteps( + now: AbsoluteTime, + balance: AmountString, + inputs: WalletDenomination[], + candidates: WalletDenomination[], +): Generator<void, AnnualRefreshCostBound> { + const zero = (): AnnualRefreshCostBound => ({ + status: "available", + horizonDays: 365, + projection: "stable-current-offerings", + amount: fromUnits(Amounts.currencyOf(balance), 0n), + }); + if (Amounts.isZero(balance)) return zero(); + if (!inputs.length) return unavailableAnnualBound("missing-holdings"); + let gmin = Infinity, + hmax = 0, + loss = 0n, + value = 1n; + const seen = new Set<string>(); + const pending = [...inputs]; + const queued = new Set(inputs.map((d) => d.denomPubHash)); + while (pending.length) { + const old = pending.pop()!; + if (seen.has(old.denomPubHash)) continue; + seen.add(old.denomPubHash); + yield; + if (!refreshLifetime(old)) + return unavailableAnnualBound("invalid-lifetime"); + if (stampMs(old, "stampExpireDeposit") <= AbsoluteTime.toStampMs(now)) + return unavailableAnnualBound("expired-funds"); + const plan = selectAutoRefreshOutputs(now, old.value, old, candidates); + if (!plan) return unavailableAnnualBound("unrefreshable-denomination"); + const l = units(plan.totalCost), + v = units(old.value); + if (l >= v) return unavailableAnnualBound("unrefreshable-denomination"); + if (l * value > loss * v) { + loss = l; + value = v; + } + // Every equal-value/fee key can win as offerings rotate. Conservatively + // inspect all affordable compatible outputs, including change and its fees. + for (const d of candidates) { + if ( + d.exchangeMasterPub !== old.exchangeMasterPub || + d.currency !== old.currency || + d.denomPub.cipher !== old.denomPub.cipher || + d.denomPub.age_mask !== old.denomPub.age_mask || + d.verificationStatus !== DenominationVerificationStatus.VerifiedGood || + !isWithdrawableDenom(d, now, 0) || + Amounts.cmp(d.value, old.value) > 0 + ) + continue; + const life = refreshLifetime(d); + const start = stampMs(d, "stampStart"), + withdraw = stampMs(d, "stampExpireWithdraw"); + if (!life || !Number.isFinite(start) || start >= withdraw) + return unavailableAnnualBound("invalid-lifetime"); + gmin = Math.min(gmin, life.deposit - withdraw); + hmax = Math.max(hmax, life.deposit - start); + if (!queued.has(d.denomPubHash)) { + queued.add(d.denomPubHash); + pending.push(d); + } + } + } + const delta = gmin - hmax / 4; + if (!Number.isFinite(delta) || delta <= 0) + return unavailableAnnualBound("nonpositive-refresh-interval"); + return { + status: "available", + horizonDays: 365, + projection: "stable-current-offerings", + amount: annualLossBound( + balance, + loss, + value, + 1 + Math.floor((365 * DAY_MS) / delta), + ), + }; +} + +/** Synchronous policy helper for callers that already have a bounded input. */ +export function computeAnnualRefreshBound( + ...args: Parameters<typeof annualRefreshBoundSteps> +): AnnualRefreshCostBound { + const steps = annualRefreshBoundSteps(...args); + while (true) { + const next = steps.next(); + if (next.done) return next.value; + } +} + +/** Background projection yields to API requests and other wallet tasks. */ +export async function computeAnnualRefreshBoundAsync( + ...args: Parameters<typeof annualRefreshBoundSteps> +): Promise<AnnualRefreshCostBound> { + const steps = annualRefreshBoundSteps(...args); + let start = Date.now(), + count = 0; + while (true) { + const next = steps.next(); + if (next.done) return next.value; + if (++count >= 32 || Date.now() - start >= 8) { + await new Promise<void>((resolve) => setTimeout(resolve, 0)); + start = Date.now(); + count = 0; + } + } +} diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -53,7 +53,6 @@ import { checkDbInvariant, checkLogicInvariant, decodeCrock, - durationMul, getQrCodesForPayto, j2s, paytoFromTransferSubject, @@ -656,8 +655,20 @@ export function getAutoRefreshExecuteThreshold(d: { d.stampExpireDeposit, ); const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit); - const deltaDiv = durationMul(delta, 0.5); - return AbsoluteTime.addDuration(expireWithdraw, deltaDiv); + if ( + AbsoluteTime.isNever(expireDeposit) || + AbsoluteTime.isNever(expireWithdraw) || + AbsoluteTime.cmp(expireDeposit, expireWithdraw) <= 0 + ) + return AbsoluteTime.now(); + const emergencyMs = Math.min( + 90 * 86_400_000, + Duration.toMilliseconds(delta) / 4, + ); + return AbsoluteTime.subtractDuraction( + expireDeposit, + Duration.fromMilliseconds(emergencyMs), + ); } /** diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts @@ -81,7 +81,7 @@ export class UnverifiedDenomError extends Error { export function selectWithdrawalDenominations( amountAvailable: AmountJson, denoms: WalletDenomination[], - opts: { limitCoins?: number } = {}, + opts: { limitCoins?: number; now?: AbsoluteTime; marginMs?: number } = {}, ): DenomSelectionState { // No candidate denominations yield an empty selection; the caller // reports that as insufficient denominations. @@ -106,7 +106,7 @@ export function selectWithdrawalDenominations( WalletDenomination.toDenomInfo(d), ); } - if (!isWithdrawableDenom(d)) { + if (!isWithdrawableDenom(d, opts.now, opts.marginMs)) { throw Error( "non-withdrawable denom passed to selectWithdrawalDenominations", ); diff --git a/packages/taler-wallet-core/src/denominations.ts b/packages/taler-wallet-core/src/denominations.ts @@ -17,6 +17,7 @@ /** * Imports. */ +import { TaskIdentifiers } from "./common.js"; import { AbsoluteTime, AmountJson, @@ -349,7 +350,11 @@ export function createTimeline<Type extends object>( * Denominations with an unverified signature * are not considered withdrawable. */ -export function isWithdrawableDenom(d: WalletDenomination): boolean { +export function isWithdrawableDenom( + d: WalletDenomination, + now = AbsoluteTime.now(), + marginMs = 300_000, +): boolean { if (d.isLost) { logger.trace( `Skipping lost denomination ${d.denomPubHash} of ${d.exchangeBaseUrl}`, @@ -369,7 +374,7 @@ export function isWithdrawableDenom(d: WalletDenomination): boolean { default: assertUnreachable(d.verificationStatus); } - return isCandidateWithdrawableDenomRec(d); + return isCandidateWithdrawableDenomRec(d, now, marginMs); } /** @@ -381,8 +386,9 @@ export function isWithdrawableDenom(d: WalletDenomination): boolean { */ export function isCandidateWithdrawableDenomRec( d: WalletDenomination, + now = AbsoluteTime.now(), + marginMs = 300_000, ): boolean { - const now = AbsoluteTime.now(); const start = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(d.stampStart), ); @@ -402,7 +408,7 @@ export function isCandidateWithdrawableDenomRec( const lastPossibleWithdraw = AbsoluteTime.subtractDuraction( withdrawExpire, - Duration.fromSpec({ minutes: 5 }), + Duration.fromMilliseconds(marginMs), ); const remaining = Duration.getRemaining(lastPossibleWithdraw, now); const stillOkay = remaining.d_ms !== 0; @@ -550,6 +556,11 @@ export async function processValidateDenoms( } await validateDenoms(wex, denoms); + for (const url of new Set(denoms.map((d) => d.exchangeBaseUrl))) { + wex.taskScheduler.startShepherdTask( + TaskIdentifiers.forExchangeAutoRefreshFromUrl(url), + ); + } return TaskRunResult.runAgainAfter({ seconds: 1, diff --git a/packages/taler-wallet-core/src/diagnostics.ts b/packages/taler-wallet-core/src/diagnostics.ts @@ -344,9 +344,10 @@ export async function buildWalletDiagnosticsReport( directDepositsDisabled: exchange.directDepositDisabled ?? false, noFees: exchange.noFees ?? false, numDenoms: denoms.length, - numWithdrawableDenoms: denoms.filter(isWithdrawableDenom).length, - numCandidateWithdrawableDenoms: denoms.filter( - isCandidateWithdrawableDenomRec, + numWithdrawableDenoms: denoms.filter((d) => isWithdrawableDenom(d)) + .length, + numCandidateWithdrawableDenoms: denoms.filter((d) => + isCandidateWithdrawableDenomRec(d), ).length, errorCodes: collectErrorCodes(exchange.unavailableReason), });