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:
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[];
}