taler-typescript-core

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

commit b61c82c6ebf0e64a02cc10049b19a9f91ff953f8
parent 24e6f51cbf4f2141b28a2329447b7f9a24c31569
Author: Florian Dold <florian@dold.me>
Date:   Mon, 15 Jul 2024 22:09:58 +0200

wallet-core: implement exchange entry state outdated-update

Diffstat:
Mpackages/taler-util/src/taler-error-codes.ts | 8++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 1+
Mpackages/taler-wallet-core/src/common.ts | 2++
Mpackages/taler-wallet-core/src/db.ts | 9+++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 230++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
5 files changed, 166 insertions(+), 84 deletions(-)

diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts @@ -4193,6 +4193,14 @@ export enum TalerErrorCode { /** + * The wallet's information about the exchange is outdated. + * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). + * (A value of 0 indicates that the error is generated client-side). + */ + WALLET_EXCHANGE_ENTRY_OUTDATED = 7039, + + + /** * We encountered a timeout with our payment backend. * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504). * (A value of 0 indicates that the error is generated client-side). diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1401,6 +1401,7 @@ export enum ExchangeUpdateStatus { UnavailableUpdate = "unavailable-update", Ready = "ready", ReadyUpdate = "ready-update", + OutdatedUpdate = "outdated-update", } export interface OperationErrorInfo { diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -297,6 +297,8 @@ export function getExchangeUpdateStatusFromRecord( return ExchangeUpdateStatus.ReadyUpdate; case ExchangeEntryDbUpdateStatus.Suspended: return ExchangeUpdateStatus.Suspended; + case ExchangeEntryDbUpdateStatus.OutdatedUpdate: + return ExchangeUpdateStatus.OutdatedUpdate; default: assertUnreachable(r.updateStatus); } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -642,6 +642,7 @@ export enum ExchangeEntryDbUpdateStatus { // Reserved 5 for backwards compatibility. Ready = 6, ReadyUpdate = 7, + OutdatedUpdate = 8, } /** @@ -709,6 +710,14 @@ export interface ExchangeEntryRecord { */ nextUpdateStamp: DbPreciseTimestamp; + /** + * The number of times we tried to contact the exchange, + * the exchange returned a result, but it is conflicting with the + * existing exchange entry. + * + * We keep the retry counter here instead of using the task retries, + * as the task succeeded, the exchange is just not useable. + */ updateRetryCounter?: number; lastKeysEtag: string | undefined; diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -953,6 +953,36 @@ async function downloadTosFromAcceptedFormat( } /** + * Check if an exchange entry should be considered + * to be outdated. + */ +async function checkExchangeEntryOutdated( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["exchanges", "denominations"]>, + exchangeBaseUrl: string, +): Promise<boolean> { + // We currently consider the exchange outdated when no + // denominations can be used for withdrawal. + + logger.trace(`checking if exchange entry for ${exchangeBaseUrl} is outdated`); + let numOkay = 0; + let denoms = + await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); + logger.trace(`exchange entry has ${denoms.length} denominations`); + for (const denom of denoms) { + const denomOkay = isWithdrawableDenom( + denom, + wex.ws.config.testing.denomselAllowLate, + ); + if (denomOkay) { + numOkay++; + } + } + logger.trace(`Of these, ${numOkay} are useable`); + return numOkay === 0; +} + +/** * Transition an exchange into an updating state. * * If the update is forced, the exchange is put into an updating state @@ -988,12 +1018,16 @@ async function startUpdateExchangeEntry( const { oldExchangeState, newExchangeState, taskId } = await wex.db.runReadWriteTx( - { storeNames: ["exchanges", "operationRetries"] }, + { storeNames: ["exchanges", "operationRetries", "denominations"] }, async (tx) => { const r = await tx.exchanges.get(exchangeBaseUrl); if (!r) { throw Error("exchange not found"); } + + // FIXME: Do not transition at all if the exchange info is recent enough + // and the request is not forced. + const oldExchangeState = getExchangeState(r); switch (r.updateStatus) { case ExchangeEntryDbUpdateStatus.UnavailableUpdate: @@ -1002,7 +1036,21 @@ async function startUpdateExchangeEntry( case ExchangeEntryDbUpdateStatus.Suspended: r.cachebreakNextUpdate = options.forceUpdate; break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: + case ExchangeEntryDbUpdateStatus.ReadyUpdate: { + const outdated = await checkExchangeEntryOutdated( + wex, + tx, + exchangeBaseUrl, + ); + if (outdated) { + r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate; + } else { + r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + } + r.cachebreakNextUpdate = options.forceUpdate; + break; + } + case ExchangeEntryDbUpdateStatus.OutdatedUpdate: r.cachebreakNextUpdate = options.forceUpdate; break; case ExchangeEntryDbUpdateStatus.Ready: { @@ -1014,7 +1062,16 @@ async function startUpdateExchangeEntry( options.forceUpdate || AbsoluteTime.isExpired(nextUpdateTimestamp) ) { - r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + const outdated = await checkExchangeEntryOutdated( + wex, + tx, + exchangeBaseUrl, + ); + if (outdated) { + r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate; + } else { + r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + } r.cachebreakNextUpdate = options.forceUpdate; } break; @@ -1184,6 +1241,7 @@ async function waitReadyExchange( innerError: retryInfo?.lastError, }, ); + case ExchangeEntryDbUpdateStatus.OutdatedUpdate: default: { if (retryInfo) { throw TalerError.fromDetail( @@ -1332,6 +1390,7 @@ export async function updateExchangeFromUrlHandler( case ExchangeEntryDbUpdateStatus.Initial: logger.info(`not updating exchange in status "initial"`); return TaskRunResult.finished(); + case ExchangeEntryDbUpdateStatus.OutdatedUpdate: case ExchangeEntryDbUpdateStatus.InitialUpdate: case ExchangeEntryDbUpdateStatus.ReadyUpdate: updateRequestedExplicitly = true; @@ -1703,88 +1762,8 @@ export async function updateExchangeFromUrlHandler( logger.trace("done updating exchange info in database"); - logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); - - let minCheckThreshold = AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ days: 1 }), - ); - if (refreshCheckNecessary) { - // Do auto-refresh. - await wex.db.runReadWriteTx( - { - storeNames: [ - "coinAvailability", - "coinHistory", - "coins", - "denominations", - "exchanges", - "refreshGroups", - "refreshSessions", - ], - }, - async (tx) => { - const exchange = await tx.exchanges.get(exchangeBaseUrl); - if (!exchange || !exchange.detailsPointer) { - return; - } - const coins = await tx.coins.indexes.byBaseUrl - .iter(exchangeBaseUrl) - .toArray(); - const refreshCoins: CoinRefreshRequest[] = []; - for (const coin of coins) { - if (coin.status !== CoinStatus.Fresh) { - continue; - } - const denom = await tx.denominations.get([ - exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - logger.warn("denomination not in database"); - continue; - } - const executeThreshold = - getAutoRefreshExecuteThresholdForDenom(denom); - if (AbsoluteTime.isExpired(executeThreshold)) { - refreshCoins.push({ - coinPub: coin.coinPub, - amount: denom.value, - }); - } else { - const checkThreshold = getAutoRefreshCheckThreshold(denom); - minCheckThreshold = AbsoluteTime.min( - minCheckThreshold, - checkThreshold, - ); - } - } - 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})`, - ); - } - logger.trace( - `next refresh check at ${AbsoluteTime.toIsoString( - minCheckThreshold, - )}`, - ); - exchange.nextRefreshCheckStamp = timestampPreciseToDb( - AbsoluteTime.toPreciseTimestamp(minCheckThreshold), - ); - wex.ws.exchangeCache.clear(); - await tx.exchanges.put(exchange); - }, - ); + await doAutoRefresh(wex, exchangeBaseUrl); } wex.ws.notify({ @@ -1799,6 +1778,89 @@ export async function updateExchangeFromUrlHandler( return TaskRunResult.progress(); } +async function doAutoRefresh( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<void> { + logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); + + let minCheckThreshold = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 1 }), + ); + + await wex.db.runReadWriteTx( + { + storeNames: [ + "coinAvailability", + "coinHistory", + "coins", + "denominations", + "exchanges", + "refreshGroups", + "refreshSessions", + ], + }, + async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange || !exchange.detailsPointer) { + return; + } + const coins = await tx.coins.indexes.byBaseUrl + .iter(exchangeBaseUrl) + .toArray(); + const refreshCoins: CoinRefreshRequest[] = []; + for (const coin of coins) { + if (coin.status !== CoinStatus.Fresh) { + continue; + } + const denom = await tx.denominations.get([ + exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + logger.warn("denomination not in database"); + continue; + } + const executeThreshold = getAutoRefreshExecuteThresholdForDenom(denom); + if (AbsoluteTime.isExpired(executeThreshold)) { + refreshCoins.push({ + coinPub: coin.coinPub, + amount: denom.value, + }); + } else { + const checkThreshold = getAutoRefreshCheckThreshold(denom); + minCheckThreshold = AbsoluteTime.min( + minCheckThreshold, + checkThreshold, + ); + } + } + 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})`, + ); + } + logger.trace( + `next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`, + ); + exchange.nextRefreshCheckStamp = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(minCheckThreshold), + ); + wex.ws.exchangeCache.clear(); + await tx.exchanges.put(exchange); + }, + ); +} + interface DenomLossResult { notifications: WalletNotification[]; }