taler-typescript-core

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

commit d0d6d67c3c6eb4c3906dcdc22ef1d479e5b9b6ee
parent eadc578f3e29bc60715f5bde271d3b3f8f2e78f7
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 18:12:00 +0200

wallet-core: schedule and execute DD71 automatic renewal

Persist private execution deadlines and cap randomized retries by coin
lifetime. Revalidate selected outputs and input ownership before melting,
and retain commitments when an earlier melt may have reached the exchange.

Accept transient host power observations and persist recovery metadata for
coins renewed near expiration.

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts | 39++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/dev-experiments.ts | 17+++++++++++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 203+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/taler-wallet-core/src/refresh.ts | 327++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/requests.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/shepherd.ts | 41+++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 19+++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 32+++++++++++++++++++++++++++++++-
8 files changed, 619 insertions(+), 111 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts @@ -19,6 +19,8 @@ */ import { Duration, + RefreshReason, + TransactionType, ExchangeUpdateStatus, NotificationType, TalerCorebankApiClient, @@ -195,7 +197,7 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { // into the future. t.logStep("applying first time travel"); await applyTimeTravelV2( - Duration.toMilliseconds(Duration.fromSpec({ days: 400 })), + Duration.toMilliseconds(Duration.fromSpec({ days: 600 })), { walletClient, exchange, @@ -207,6 +209,28 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { t.logStep("The time travel should cause exchanges to update"); await exchangeUpdated1Cond; t.logStep("exchange updated, waiting for tx"); + // DD71 first persists a private delay of at most one day. Observe that + // scheduled check, then advance beyond every possible execution deadline. + await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { + exchangeBaseUrl: exchange.baseUrl, + waitAutoRefresh: true, + }); + await applyTimeTravelV2( + Duration.toMilliseconds(Duration.fromSpec({ days: 602 })), + { + walletClient, + exchange, + merchant, + }, + ); + await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { + exchangeBaseUrl: exchange.baseUrl, + waitAutoRefresh: true, + }); + await walletClient.call(WalletApiOperation.TestingWaitBalance, { + type: "material", + amount: "TESTKUDOS:15", + }); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); { const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); @@ -305,6 +329,19 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { major: TransactionMajorState.Done, }, }); + // Payment completion can precede its change refresh. Keep the exchange alive + // until that refresh finishes, and assert this exercised automatic renewal. + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const history = await walletClient.call(WalletApiOperation.GetTransactions, { + includeRefreshes: true, + }); + t.assertTrue( + history.transactions.some( + (tx) => + tx.type === TransactionType.Refresh && + tx.refreshReason === RefreshReason.Scheduled, + ), + ); } runTimetravelAutorefreshTest.suites = ["wallet"]; diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts @@ -25,6 +25,7 @@ * Imports. */ +import { NotificationType } from "@gnu-taler/taler-util"; import { AbsoluteTime, AmountString, @@ -99,6 +100,7 @@ export interface DevExperimentState { /** Deterministic, queued wallet API responses for frontend integration tests. */ apiResponses?: Map<string, Array<{ response: unknown; remaining: number }>>; blockRefreshes?: boolean; + refreshScenario?: "risk" | "recovered" | "cost-unavailable"; /** Pretend that exchanges have no fees.*/ pretendNoFees?: boolean; /** Pretend exchange has no withdrawable denoms. */ @@ -185,6 +187,21 @@ export async function applyDevExperiment( throw Error("can't handle devmode URI unless devmode is active"); } switch (parsedUri.devExperimentId) { + case "dd71-risk": + case "dd71-recovered": + case "dd71-cost-unavailable": + case "dd71-clear": { + const scenario = parsedUri.devExperimentId.slice(5); + wex.ws.devExperimentState.refreshScenario = + scenario === "clear" + ? undefined + : (scenario as "risk" | "recovered" | "cost-unavailable"); + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: "dd71-experiment", + }); + return; + } case "start-block-refresh": { wex.ws.devExperimentState.blockRefreshes = true; return; diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -22,6 +22,12 @@ */ import { + evaluateAutoRefresh, + autoRefreshDeadline, + refreshLifetime, + DAY_MS, +} from "./autoRefresh.js"; +import { AbsoluteTime, AccountKycStatus, AccountLimit, @@ -111,7 +117,6 @@ import { assertUnreachable, checkDbInvariant, checkLogicInvariant, - durationMul, encodeCrock, getRandomBytes, hashDenomPub, @@ -138,7 +143,6 @@ import { TransactionContext, constructTaskIdentifier, genericWaitForState, - getAutoRefreshExecuteThreshold, getExchangeEntryStatusFromRecord, getExchangeState, getExchangeTosStatusFromRecord, @@ -3131,7 +3135,7 @@ export async function updateExchangeFromUrlHandler( // Make sure an auto-refresh task is scheduled for this exchange. const autoRefreshTaskId = TaskIdentifiers.forExchangeAutoRefreshFromUrl(exchangeBaseUrl); - await wex.taskScheduler.resetTask(autoRefreshTaskId); + wex.taskScheduler.startShepherdTask(autoRefreshTaskId); await wex.taskScheduler.resetTask( constructTaskIdentifier({ tag: PendingTaskType.ValidateDenoms }), ); @@ -3139,60 +3143,118 @@ export async function updateExchangeFromUrlHandler( return taskRes; } -async function doExchangeAutoRefresh( +export async function doExchangeAutoRefresh( wex: WalletExecutionContext, exchangeBaseUrl: string, ): Promise<void> { await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl); - let minCheckThreshold = AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ days: 1 }), - ); + const now = AbsoluteTime.now(); + const nowMs = AbsoluteTime.toStampMs(now); + let minCheckThreshold = AbsoluteTime.fromMilliseconds(nowMs + DAY_MS); await wex.runWalletDbTx(async (tx) => { const exchange = await tx.getExchange(exchangeBaseUrl); - if (!exchange || !exchange.detailsPointer) { - return; - } + if (!exchange?.detailsPointer) return; const coins = await tx.getCoinsByExchange(exchangeBaseUrl); const denominations = await tx.getDenominationsByRefs(coins); const denominationsByRef = new Map( - denominations.map((denom) => [denomRefKey(denom), denom]), + denominations.map((d) => [denomRefKey(d), d]), + ); + const candidates = await tx.getDenominationsByMasterPub( + exchange.detailsPointer.masterPublicKey, ); + const deadlines = (exchange.autoRefreshDeadlines ??= {}); + const freshDenoms = new Set<string>(); + const evaluations = new Map< + string, + ReturnType<typeof evaluateAutoRefresh> + >(); const refreshCoins: CoinRefreshRequest[] = []; + const plans = new Map< + string, + import("./autoRefresh.js").AutoRefreshOutputPlan + >(); for (const coin of coins) { - if (coin.status !== CoinStatus.Fresh) { - continue; - } const denom = denominationsByRef.get(denomRefKey(coin)); - if (!denom) { - logger.warn("denomination not in database"); + if (!denom) continue; + if (coin.status !== CoinStatus.Fresh) { + const life = refreshLifetime(denom); + if (life) + for (const boundary of [ + Math.floor(life.deposit - life.emergency) + 1, + life.deposit, + ]) { + if (boundary > nowMs) + minCheckThreshold = AbsoluteTime.min( + minCheckThreshold, + AbsoluteTime.fromMilliseconds(boundary), + ); + } continue; } - const executeThreshold = getAutoRefreshExecuteThresholdForDenom(denom); - if (AbsoluteTime.isExpired(executeThreshold)) { - refreshCoins.push({ - coinPub: coin.coinPub, - amount: denom.value, + const key = denomRefKey(denom); + freshDenoms.add(key); + let evaluation = evaluations.get(key); + if (!evaluation) { + evaluation = evaluateAutoRefresh({ + now, + inputAmount: denom.value, + oldDenom: denom, + withdrawableDenoms: candidates, + powerSource: wex.ws.powerSource, }); - } else { - const checkThreshold = getAutoRefreshCheckThreshold(denom); - minCheckThreshold = AbsoluteTime.min(minCheckThreshold, checkThreshold); + evaluations.set(key, evaluation); } - } - if (refreshCoins.length > 0) { - const res = await createRefreshGroup( - wex, - tx, - exchange.detailsPointer?.currency, - refreshCoins, - RefreshReason.Scheduled, - undefined, - ); - logger.trace( - `created refresh group for auto-refresh (${res.refreshGroupId})`, + if (Number.isFinite(evaluation.nextCheck)) { + minCheckThreshold = AbsoluteTime.min( + minCheckThreshold, + AbsoluteTime.fromMilliseconds(evaluation.nextCheck), + ); + } + if (!evaluation.rule || !evaluation.plan) continue; + const deadline = autoRefreshDeadline( + nowMs, + refreshLifetime(denom)!, + deadlines[key], ); + deadlines[key] = deadline; + if (deadline > nowMs) { + minCheckThreshold = AbsoluteTime.min( + minCheckThreshold, + AbsoluteTime.fromMilliseconds(deadline), + ); + continue; + } + refreshCoins.push({ coinPub: coin.coinPub, amount: denom.value }); + plans.set(coin.coinPub, evaluation.plan); + } + for (const key of Object.keys(deadlines)) + if (!freshDenoms.has(key)) delete deadlines[key]; + if (refreshCoins.length) { + // One group per denomination keeps recovery notices attributable and lets + // urgency cap retries without coupling unrelated denomination lifetimes. + const groups = new Map<string, CoinRefreshRequest[]>(); + const coinsByPub = new Map(coins.map((c) => [c.coinPub, c])); + for (const request of refreshCoins) { + const coin = coinsByPub.get(request.coinPub)!; + const key = denomRefKey(coin); + const group = groups.get(key) ?? []; + group.push(request); + groups.set(key, group); + } + for (const [key, requests] of groups) { + await createRefreshGroup( + wex, + tx, + exchange.detailsPointer.currency, + requests, + RefreshReason.Scheduled, + undefined, + plans, + ); + delete deadlines[key]; + } } logger.trace( `next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`, @@ -3235,31 +3297,30 @@ export async function processTaskExchangeAutoRefresh( return TaskRunResult.finished(); } - let nextRefreshCheckStamp = timestampAbsoluteFromDb( + // Startup, coin and power wakeups must reconsider eligibility even when the + // next periodic key check is in the future. Persistent execution deadlines + // are separate from these reevaluation wakeups. + await doExchangeAutoRefresh(wex, exchangeBaseUrl); + const previousCheck = timestampAbsoluteFromDb( oldExchangeRec.nextRefreshCheckStamp, ); - if ( - !AbsoluteTime.isNever(nextRefreshCheckStamp) && - !AbsoluteTime.isExpired(nextRefreshCheckStamp) - ) { - logger.trace( - `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString( - nextRefreshCheckStamp, - )}`, - ); - logger.trace( - "exchange auto-refresh check not necessary, running again later", - ); - return TaskRunResult.runAgainAt(nextRefreshCheckStamp); + const retry = await wex.runWalletDbTx((tx) => + tx.getOperationRetry( + TaskIdentifiers.forExchangeAutoRefreshFromUrl(exchangeBaseUrl), + ), + ); + if (AbsoluteTime.isExpired(previousCheck) || retry?.lastError) { + await fetchFreshExchange(wex, exchangeBaseUrl); + await doExchangeAutoRefresh(wex, exchangeBaseUrl); } - - logger.trace("exchange auto-refresh check necessary"); - - await fetchFreshExchange(wex, exchangeBaseUrl); - - await doExchangeAutoRefresh(wex, exchangeBaseUrl); - - return TaskRunResult.progress(); + const current = await wex.runWalletDbTx((tx) => + tx.getExchange(exchangeBaseUrl), + ); + return current + ? TaskRunResult.runAgainAt( + timestampAbsoluteFromDb(current.nextRefreshCheckStamp), + ) + : TaskRunResult.finished(); } /** @@ -3707,30 +3768,6 @@ async function handleRecoup( } } -function getAutoRefreshExecuteThresholdForDenom( - d: WalletDenomination, -): AbsoluteTime { - return getAutoRefreshExecuteThreshold({ - stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), - stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), - }); -} - -/** - * Timestamp after which the wallet would do the next check for an auto-refresh. - */ -function getAutoRefreshCheckThreshold(d: WalletDenomination): AbsoluteTime { - const expireWithdraw = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampExpireWithdraw), - ); - const expireDeposit = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampExpireDeposit), - ); - const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit); - const deltaDiv = durationMul(delta, 0.75); - return AbsoluteTime.addDuration(expireWithdraw, deltaDiv); -} - /** * Find a payto:// URI of the exchange that is of one * of the given target types. diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -22,6 +22,7 @@ /** * Imports. */ +import { AutoRefreshOutputPlan, evaluateAutoRefresh } from "./autoRefresh.js"; import { AbsoluteTime, AgeRestriction, @@ -548,6 +549,7 @@ async function initRefreshSession( tx: WalletDbTransaction, refreshGroup: WalletRefreshGroup, coinIndex: number, + plannedOutput?: AutoRefreshOutputPlan, ): Promise<void> { const refreshGroupId = refreshGroup.refreshGroupId; logger.trace( @@ -570,27 +572,39 @@ async function initRefreshSession( throw Error("db inconsistent: denomination for coin not found"); } - const currency = refreshGroup.currency; + if (refreshGroup.reason === RefreshReason.Scheduled && !plannedOutput) { + const oldRecord = await tx.getDenomination(oldCoin); + if (!oldRecord) return; + const evaluation = evaluateAutoRefresh({ + now: AbsoluteTime.now(), + inputAmount: refreshGroup.inputPerCoin[coinIndex], + oldDenom: oldRecord, + withdrawableDenoms: await tx.getDenominationsByMasterPub( + oldCoin.exchangeMasterPub, + ), + powerSource: wex.ws.powerSource, + }); + // A temporarily empty replacement set must not finish the input with zero + // output. Leave it pending so key/power changes can produce a valid plan. + if (!evaluation.rule || !evaluation.plan) return; + plannedOutput = evaluation.plan; + refreshGroup.expectedOutputPerCoin[coinIndex] = + plannedOutput.totalCoinValue; + } - const availableDenoms = await getWithdrawableDenomsTx( - wex, - tx, - exchangeBaseUrl, - currency, - ); + const currency = refreshGroup.currency; const availableAmount = Amounts.sub( refreshGroup.inputPerCoin[coinIndex], oldDenom.feeRefresh, ).amount; - - const newCoinDenoms = selectWithdrawalDenominations( - availableAmount, - availableDenoms, - { - limitCoins: maxRefreshSessionSize, - }, - ); + const newCoinDenoms = + plannedOutput ?? + selectWithdrawalDenominations( + availableAmount, + await getWithdrawableDenomsTx(wex, tx, exchangeBaseUrl, currency), + { limitCoins: maxRefreshSessionSize }, + ); if (newCoinDenoms.selectedDenoms.length === 0) { logger.trace( @@ -625,6 +639,9 @@ async function initRefreshSession( } const newSession: WalletRefreshSession = { + ...(refreshGroup.reason === RefreshReason.Scheduled + ? { autoRefreshMeltStarted: false } + : {}), coinIndex, refreshGroupId, norevealIndex: undefined, @@ -920,6 +937,79 @@ export async function recoverRefreshCoinNonce( : undefined; } +/** Revalidate an automatic session before preparing a new melt commitment. */ +export async function prepareAutoRefreshSession( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + refreshGroupId: string, + coinIndex: number, +): Promise<boolean> { + const rg = await tx.getRefreshGroup(refreshGroupId); + const session = await tx.getRefreshSession(refreshGroupId, coinIndex); + if ( + !rg || + !session || + rg.reason !== RefreshReason.Scheduled || + session.norevealIndex !== undefined + ) + return true; + // Legacy seeded sessions might already have been sent. + if ( + session.autoRefreshMeltStarted || + (session.autoRefreshMeltStarted === undefined && session.sessionPublicSeed) + ) + return true; + const coin = await tx.getCoin(rg.oldCoinPubs[coinIndex]); + if (!coin || coin.status !== CoinStatus.Dormant) return false; + const history = await tx.getCoinHistory(coin.coinPub); + if ( + !history?.history.some( + (h) => + h.type === "refresh" && + h.transactionId === + constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }), + ) + ) + return false; + const old = await tx.getDenomination(coin); + if (!old) return false; + const exchange = await tx.getExchange(coin.exchangeBaseUrl); + if (exchange?.detailsPointer?.masterPublicKey !== coin.exchangeMasterPub) + return false; + const evaluation = evaluateAutoRefresh({ + now: AbsoluteTime.now(), + inputAmount: rg.inputPerCoin[coinIndex], + oldDenom: old, + withdrawableDenoms: await tx.getDenominationsByMasterPub( + coin.exchangeMasterPub, + ), + powerSource: wex.ws.powerSource, + }); + if (!evaluation.rule || !evaluation.plan) return false; + if ( + JSON.stringify(session.newDenoms) !== + JSON.stringify(evaluation.plan.selectedDenoms) + ) { + await destroyRefreshSession(tx, rg, session); + await initRefreshSession(wex, tx, rg, coinIndex, evaluation.plan); + rg.expectedOutputPerCoin[coinIndex] = evaluation.plan.totalCoinValue; + if (rg.infoPerExchange) + rg.infoPerExchange[coin.exchangeBaseUrl] = { + outputEffective: Amounts.stringify( + Amounts.add( + Amounts.zeroOfCurrency(rg.currency), + ...rg.expectedOutputPerCoin, + ).amount, + ), + }; + await tx.upsertRefreshGroup(rg); + } + return true; +} + /** * Run the melt step of a refresh session. * @@ -936,6 +1026,10 @@ async function refreshMelt( refreshGroupId: string, coinIndex: number, ): Promise<boolean> { + const ready = await wex.runWalletDbTx((tx) => + prepareAutoRefreshSession(wex, tx, refreshGroupId, coinIndex), + ); + if (!ready) return false; const ctx = new RefreshTransactionContext(wex, refreshGroupId); const d = await wex.runWalletDbTx(async (tx) => { const refreshGroup = await tx.getRefreshGroup(refreshGroupId); @@ -1087,10 +1181,127 @@ async function refreshMelt( wex, oldCoin.exchangeBaseUrl, ); + if (refreshGroup.reason === RefreshReason.Scheduled) { + const permitted = await wex.runWalletDbTx(async (tx) => { + const session = await tx.getRefreshSession(refreshGroupId, coinIndex); + const rg = await tx.getRefreshGroup(refreshGroupId); + if (!session || !rg) return false; + if ( + session.autoRefreshMeltStarted || + (session.autoRefreshMeltStarted === undefined && + session.sessionPublicSeed) + ) + return true; + const coin = await tx.getCoin(oldCoin.coinPub); + if (!coin || coin.status !== CoinStatus.Dormant) return false; + const history = await tx.getCoinHistory(coin.coinPub); + if ( + !history?.history.some( + (h) => + h.type === "refresh" && + h.transactionId === + constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }), + ) + ) + return false; + const old = await tx.getDenomination(coin); + if (!old) return false; + const exchange = await tx.getExchange(coin.exchangeBaseUrl); + if ( + exchange?.detailsPointer?.masterPublicKey !== coin.exchangeMasterPub + ) + return false; + const now = AbsoluteTime.now(); + const evaluation = evaluateAutoRefresh({ + now, + inputAmount: rg.inputPerCoin[coinIndex], + oldDenom: old, + withdrawableDenoms: await tx.getDenominationsByMasterPub( + coin.exchangeMasterPub, + ), + powerSource: wex.ws.powerSource, + }); + if ( + !evaluation.rule || + !evaluation.plan || + JSON.stringify(evaluation.plan.selectedDenoms) !== + JSON.stringify(session.newDenoms) || + session.sessionPublicSeed !== refreshSession.sessionPublicSeed + ) + return false; + session.autoRefreshMeltStarted = true; + await tx.upsertRefreshSession(session); + if (evaluation.risk) { + const plan = evaluation.plan; + rg.autoRefresh ??= {}; + const previous = rg.autoRefresh.recovery; + const recoveryInputs = (rg.autoRefresh.recoveryInputs ??= {}); + recoveryInputs[coinIndex] = rg.inputPerCoin[coinIndex]; + rg.autoRefresh.recovery = { + warningId: rg.refreshGroupId, + exchangeBaseUrl: coin.exchangeBaseUrl, + exchangeMasterPub: coin.exchangeMasterPub, + amount: Amounts.stringify( + Amounts.add( + Amounts.zeroOfCurrency(rg.currency), + ...Object.values(recoveryInputs), + ).amount, + ), + oldDepositExpiration: timestampProtocolFromDb( + old.stampExpireDeposit, + ), + newDepositExpiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.fromMilliseconds( + Math.min( + previous + ? AbsoluteTime.toStampMs( + AbsoluteTime.fromProtocolTimestamp( + previous.newDepositExpiration, + ), + ) + : Infinity, + plan.minOutputExpiry, + ), + ), + ), + nextRelevantDate: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.fromMilliseconds( + Math.min( + previous + ? AbsoluteTime.toStampMs( + AbsoluteTime.fromProtocolTimestamp( + previous.nextRelevantDate, + ), + ) + : Infinity, + plan.nextRelevantDate, + ), + ), + ), + }; + await tx.upsertRefreshGroup(rg); + } + return true; + }); + if (!permitted) return undefined; + } return exchangeClient.postMelt({ body: meltReqBody }); }, ); + if (!resp) return false; + if ( + refreshGroup.reason === RefreshReason.Scheduled && + refreshSession.autoRefreshMeltStarted !== false && + resp.case !== "ok" + ) { + // A previous attempt may have succeeded. An error on a later retry does + // not establish that its outputs can safely be discarded or replaced. + throwUnexpectedRequestError(resp.response, resp.detail!); + } switch (resp.case) { case HttpStatusCode.NotFound: await handleRefreshMeltNotFound( @@ -1101,7 +1312,12 @@ async function refreshMelt( ); return false; case HttpStatusCode.Gone: - await handleRefreshMeltGone(ctx, coinIndex, resp.detail!); + await handleRefreshMeltGone( + ctx, + coinIndex, + resp.detail!, + refreshSession.autoRefreshMeltStarted === false, + ); return false; case HttpStatusCode.Conflict: await handleRefreshMeltConflict( @@ -1231,6 +1447,7 @@ async function handleRefreshMeltGone( ctx: RefreshTransactionContext, coinIndex: number, errDetails: TalerErrorDetail, + firstAutoMeltAttempt: boolean, ): Promise<void> { // const expiredMsg = codecForDenominationExpiredMessage().decode(errDetails); @@ -1263,6 +1480,9 @@ async function handleRefreshMeltGone( throw Error("db invariant failed: missing refresh session in database"); } refreshSession.lastError = errDetails; + // This first attempt was definitively rejected; no ambiguous earlier + // request needs this commitment. Allow selection of replacement keys. + if (firstAutoMeltAttempt) refreshSession.autoRefreshMeltStarted = false; await tx.upsertRefreshSession(refreshSession); await h.update(rg, "melt-gone"); }); @@ -2060,6 +2280,18 @@ async function processRefreshSession( let rs = await tx.getRefreshSession(refreshGroupId, coinIndex); if ( + rg && + rs && + rg.statusPerCoin[coinIndex] === RefreshCoinStatus.PendingRedenominate && + (rs.norevealIndex !== undefined || + (rg.reason === RefreshReason.Scheduled && rs.autoRefreshMeltStarted)) + ) { + // A commitment that might have been accepted must survive key expiry or + // a contradictory later response. Continue its existing melt/reveal. + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Pending; + await tx.upsertRefreshGroup(rg); + } + if ( rg != null && rg.statusPerCoin[coinIndex] === RefreshCoinStatus.PendingRedenominate ) { @@ -2077,6 +2309,16 @@ async function processRefreshSession( rs = await tx.getRefreshSession(refreshGroupId, coinIndex); } + if ( + rg?.reason === RefreshReason.Scheduled && + !rs && + rg.statusPerCoin[coinIndex] === RefreshCoinStatus.Pending + ) { + await initRefreshSession(wex, tx, rg, coinIndex); + await tx.upsertRefreshGroup(rg); + rs = await tx.getRefreshSession(refreshGroupId, coinIndex); + } + return { refreshGroup: rg, refreshSession: rs, @@ -2141,20 +2383,25 @@ async function calculateRefreshOutputFromLoaded( wex: WalletExecutionContext, tx: WalletDbTransaction, loadedCoins: LoadedRefreshCoin[], + plannedOutputs?: Map<string, AutoRefreshOutputPlan>, ): Promise<RefreshOutputInfo> { const estimatedOutputPerCoin: AmountJson[] = []; const infoPerExchange: Record<string, WalletRefreshGroupPerExchangeInfo> = {}; const refreshAmounts = loadedCoins.map(({ request }) => Amounts.parseOrThrow(request.amount), ); - const costs = await getTotalRefreshCosts( - wex, - tx, - loadedCoins.map(({ denom }, i) => ({ - refreshedDenom: denom, - amountLeft: refreshAmounts[i], - })), - ); + const costs = plannedOutputs + ? loadedCoins.map(({ coin }) => + Amounts.parseOrThrow(plannedOutputs.get(coin.coinPub)!.totalCost), + ) + : await getTotalRefreshCosts( + wex, + tx, + loadedCoins.map(({ denom }, i) => ({ + refreshedDenom: denom, + amountLeft: refreshAmounts[i], + })), + ); for (let i = 0; i < loadedCoins.length; i++) { const { request, coin } = loadedCoins[i]; @@ -2338,9 +2585,25 @@ export async function createRefreshGroup( oldCoinPubs: CoinRefreshRequest[], refreshReason: RefreshReason, originatingTransactionId: string | undefined, + plannedOutputs?: Map<string, AutoRefreshOutputPlan>, ): Promise<CreateRefreshGroupResult> { const exchanges: Set<string> = new Set(); const loadedCoins = await loadRefreshCoins(wex, tx, oldCoinPubs); + if (refreshReason === RefreshReason.Scheduled) { + for (const { coin, request } of loadedCoins) { + checkLogicInvariant( + coin.status === CoinStatus.Fresh && + !!plannedOutputs?.get(coin.coinPub)?.selectedDenoms.length, + "automatic refresh requires an available coin and a nonempty approved plan", + ); + checkLogicInvariant( + Amounts.cmp( + request.amount, + plannedOutputs!.get(coin.coinPub)!.totalCoinValue, + ) >= 0, + ); + } + } for (const { coin } of loadedCoins) { exchanges.add(coin.exchangeBaseUrl); } @@ -2351,7 +2614,12 @@ export async function createRefreshGroup( const refreshGroupId = encodeCrock(getRandomBytes(32)); - const outInfo = await calculateRefreshOutputFromLoaded(wex, tx, loadedCoins); + const outInfo = await calculateRefreshOutputFromLoaded( + wex, + tx, + loadedCoins, + plannedOutputs, + ); const estimatedOutputPerCoin = outInfo.outputPerCoin; @@ -2373,6 +2641,7 @@ export async function createRefreshGroup( } const refreshGroup: WalletRefreshGroup = { + ...(refreshReason === RefreshReason.Scheduled ? { autoRefresh: {} } : {}), operationStatus: RefreshOperationStatus.Pending, currency, timestampFinished: undefined, @@ -2399,7 +2668,13 @@ export async function createRefreshGroup( } for (let i = 0; i < oldCoinPubs.length; i++) { - await initRefreshSession(wex, tx, refreshGroup, i); + await initRefreshSession( + wex, + tx, + refreshGroup, + i, + plannedOutputs?.get(oldCoinPubs[i].coinPub), + ); } const ctx = new RefreshTransactionContext(wex, refreshGroupId); diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -84,6 +84,10 @@ import { GetQrCodesForPaytoRequest, GetQrCodesForPaytoResponse, HintNetworkAvailabilityRequest, + HintPowerStateRequest, + DismissWalletWarningRequest, + codecForHintPowerStateRequest, + codecForDismissWalletWarningRequest, HttpStatusCode, ImportDbFromFileRequest, ImportDbRequest, @@ -1549,6 +1553,46 @@ async function handleHintNetworkAvailability( return {}; } +export async function handleHintPowerState( + wex: WalletExecutionContext, + req: HintPowerStateRequest, +): Promise<EmptyObject> { + const changed = wex.ws.powerSource !== req.powerSource; + wex.ws.powerSource = req.powerSource; + if (changed) { + await restartAllRunningTasks(wex); + } + return {}; +} + +export async function handleDismissWalletWarning( + wex: WalletExecutionContext, + req: DismissWalletWarningRequest, +): Promise<EmptyObject> { + if ( + wex.ws.config.testing.devModeActive && + req.warningId === "dd71-experiment" + ) { + wex.ws.devExperimentState.refreshScenario = undefined; + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: "dd71-experiment", + }); + return {}; + } + await wex.runWalletDbTx(async (tx) => { + const group = await tx.getRefreshGroup(req.warningId); + if (!group?.autoRefresh?.recovery || !group.timestampFinished) return; + group.autoRefresh.dismissed = true; + await tx.upsertRefreshGroup(group); + tx.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: `refresh:${group.refreshGroupId}`, + }); + }); + return {}; +} + async function handleGetDepositWireTypes( wex: WalletExecutionContext, req: GetDepositWireTypesRequest, @@ -2852,6 +2896,14 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForGetCurrencyInfoRequest(), handler: handleGetCurrencySpecification, }, + [WalletApiOperation.HintPowerState]: { + codec: codecForHintPowerStateRequest(), + handler: handleHintPowerState, + }, + [WalletApiOperation.DismissWalletWarning]: { + codec: codecForDismissWalletWarningRequest(), + handler: handleDismissWalletWarning, + }, [WalletApiOperation.HintNetworkAvailability]: { codec: codecForHintNetworkAvailabilityRequest(), handler: handleHintNetworkAvailability, diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -17,6 +17,11 @@ /** * Imports. */ +import { + autoRefreshRetryCap, + refreshLifetime, + refreshRandom, +} from "./autoRefresh.js"; import { computeRecoupTransactionState } from "./recoup.js"; import { AbsoluteTime, @@ -34,6 +39,8 @@ import { TransactionState, TransactionType, WalletNotification, + RefreshReason, + CoinStatus, assertUnreachable, getErrorDetailFromException, j2s, @@ -687,6 +694,38 @@ export class TaskSchedulerImpl implements TaskScheduler { } } +async function capAutoRefreshRetry( + tx: WalletDbTransaction, + retry: WalletOperationRetry, +): Promise<void> { + const task = parseTaskIdentifier(retry.id); + let coins; + if (task.tag === PendingTaskType.ExchangeAutoRefresh) { + coins = (await tx.getCoinsByExchange(task.exchangeBaseUrl)).filter( + (c) => c.status === CoinStatus.Fresh, + ); + } else if (task.tag === PendingTaskType.Refresh) { + const rg = await tx.getRefreshGroup(task.refreshGroupId); + if (rg?.reason !== RefreshReason.Scheduled) return; + coins = await tx.getCoinsByPubs(rg.oldCoinPubs); + } else return; + const now = AbsoluteTime.toStampMs(AbsoluteTime.now()); + const planned = AbsoluteTime.toStampMs( + timestampAbsoluteFromDb(retry.retryInfo.nextRetry), + ); + let delay = Math.max(1, (planned - now) * (0.5 + refreshRandom() / 2)); + for (const d of await tx.getDenominationsByRefs(coins)) { + const life = refreshLifetime(d); + if (life && life.deposit > now) + delay = Math.min(delay, autoRefreshRetryCap(now, life)); + } + retry.retryInfo.nextRetry = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp( + AbsoluteTime.fromMilliseconds(now + Math.max(1, Math.floor(delay))), + ), + ); +} + async function storePendingTaskError( ws: InternalWalletState, pendingTaskId: string, @@ -705,6 +744,7 @@ async function storePendingTaskError( retryRecord.lastError = e; retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); } + await capAutoRefreshRetry(tx, retryRecord); await tx.upsertOperationRetry(retryRecord); return { notification: await taskToRetryNotification(ws, tx, pendingTaskId, e), @@ -760,6 +800,7 @@ async function storePendingTaskPending( AbsoluteTime.toPreciseTimestamp(schedTime), ); } + if (!schedTime) await capAutoRefreshRetry(tx, retryRecord); await tx.upsertOperationRetry(retryRecord); if (hadError) { const notif = await taskToRetryNotification( diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -131,6 +131,8 @@ import { GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForUriRequest, HintNetworkAvailabilityRequest, + HintPowerStateRequest, + DismissWalletWarningRequest, ImportDbFromFileRequest, ImportDbRequest, InitRequest, @@ -423,6 +425,8 @@ export enum WalletApiOperation { // Hints HintNetworkAvailability = "hintNetworkAvailability", + HintPowerState = "hintPowerState", + DismissWalletWarning = "dismissWalletWarning", HintApplicationResumed = "hintApplicationResumed", /** @@ -495,6 +499,19 @@ export type HintNetworkAvailabilityOp = { response: EmptyObject; }; +/** Report a current power observation. Unknown power never enables free opportunistic refresh. */ +export type HintPowerStateOp = { + op: WalletApiOperation.HintPowerState; + request: HintPowerStateRequest; + response: EmptyObject; +}; +/** Dismiss a completed renewal notice. Active expiration risks cannot be dismissed. */ +export type DismissWalletWarningOp = { + op: WalletApiOperation.DismissWalletWarning; + request: DismissWalletWarningRequest; + response: EmptyObject; +}; + // group: Generic request handling export type RetryProgressTokenNowOp = { @@ -2181,6 +2198,8 @@ export type WalletOperations = { [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp; [WalletApiOperation.TestingResetAllRetries]: TestingResetAllRetriesOp; [WalletApiOperation.HintNetworkAvailability]: HintNetworkAvailabilityOp; + [WalletApiOperation.HintPowerState]: HintPowerStateOp; + [WalletApiOperation.DismissWalletWarning]: DismissWalletWarningOp; [WalletApiOperation.GetDepositWireTypes]: GetDepositWireTypesOp; [WalletApiOperation.GetDepositWireTypesForCurrency]: GetDepositWireTypesForCurrencyOp; [WalletApiOperation.GetQrCodesForPayto]: GetQrCodesForPaytoOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -23,6 +23,7 @@ /** * Imports. */ +import { TaskIdentifiers, PendingTaskType } from "./common.js"; import { AmountJson, AsyncCondition, @@ -56,6 +57,7 @@ import { TimerAPI, TimerGroup, WalletNotification, + WalletPowerSource, WalletRunConfig, assertUnreachable, checkDbInvariant, @@ -1441,6 +1443,19 @@ export class InternalWalletState { */ private _networkAvailable = true; + /** Transient host observation, discarded on restart. */ + private observedPower = WalletPowerSource.Unknown; + private powerObservedAt = 0; + get powerSource(): WalletPowerSource { + return Date.now() - this.powerObservedAt < 60_000 + ? this.observedPower + : WalletPowerSource.Unknown; + } + set powerSource(value: WalletPowerSource) { + this.observedPower = value; + this.powerObservedAt = Date.now(); + } + get networkAvailable(): boolean { return this._networkAvailable; } @@ -1753,6 +1768,22 @@ export class InternalWalletState { notify(n: WalletNotification): void { logger.trace(`Notification: ${j2s(n)}`); + if ( + n.type === NotificationType.BalanceChange || + n.type === NotificationType.TransactionStateTransition + ) { + for (const id of this.taskScheduler.getActiveTasks()) { + if (id.startsWith(`${PendingTaskType.ExchangeAutoRefresh}:`)) + this.taskScheduler.startShepherdTask(id); + } + } else if ( + n.type === NotificationType.ExchangeStateTransition && + n.causeHint !== "auto-refresh" + ) { + this.taskScheduler.startShepherdTask( + TaskIdentifiers.forExchangeAutoRefreshFromUrl(n.exchangeBaseUrl), + ); + } if (n.type === NotificationType.DatabaseMaintenanceProgress) { this.maintenanceNotifications.offer(n); return; @@ -1833,5 +1864,4 @@ export class InternalWalletState { logger.trace(`end exclusive execution on ${JSON.stringify(tokens)}`); } } - }