commit d466940f1ae91aae3ba4d24bd8f5ee24ebba2f98
parent f57659f98cf5cba0c806e87caec8d069f656a6e2
Author: Florian Dold <florian@dold.me>
Date: Mon, 28 Apr 2025 19:57:05 +0200
harness,wallet-core: improve protocol version handling
- implements fakeprotover dev experiment
- improves handling of incompatible protocol version
in wallet-core
Diffstat:
9 files changed, 419 insertions(+), 98 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-devexp-fakeprotover.ts b/packages/taler-harness/src/integrationtests/test-wallet-devexp-fakeprotover.ts
@@ -0,0 +1,115 @@
+/*
+ This file is part of GNU Taler
+ (C) 2025 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 for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ j2s,
+ LibtoolVersion,
+ Logger,
+ succeedOrThrow,
+ TalerCorebankApiClient,
+ TalerErrorCode,
+ TalerExchangeHttpClient,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ useSharedTestkudosEnvironment,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+
+const logger = new Logger("test-wallet-devexp-fakeprotover.ts");
+
+export async function runWalletDevexpFakeprotoverTest(t: GlobalTestState) {
+ const { walletClient, exchange, merchant, bank } =
+ await useSharedTestkudosEnvironment(t);
+
+ const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl, {
+ httpClient: harnessHttpLib,
+ });
+
+ const exchangeConfRes = succeedOrThrow(await exchangeClient.getConfig());
+ const exchVer = LibtoolVersion.parseVersionOrThrow(exchangeConfRes.version);
+
+ const newerVer = `${exchVer.current + 1}:0:0`;
+
+ const bankClient = new TalerCorebankApiClient(bank.baseUrl);
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ exchange,
+ amount: "TESTKUDOS:10",
+ bankClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const devExpUri = new URL("taler://dev-experiment/start-fakeprotover");
+ devExpUri.searchParams.set("base_url", exchange.baseUrl);
+ devExpUri.searchParams.set("fake_ver", newerVer);
+
+ await walletClient.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ devModeActive: true,
+ skipDefaults: true,
+ },
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: devExpUri.href,
+ });
+
+ logger.info("updating exchange entry after dev experiment");
+
+ const err1 = await t.assertThrowsTalerErrorAsync(
+ walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ }),
+ );
+
+ t.assertTrue(
+ err1.errorDetail.code === TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ );
+
+ t.assertTrue(
+ err1.errorDetail.innerError.code ===
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ );
+
+ logger.info("done updating exchange entry after dev experiment");
+
+ const err2 = await t.assertThrowsTalerErrorAsync(
+ walletClient.call(WalletApiOperation.GetWithdrawalDetailsForAmount, {
+ amount: "TESTKUDOS:10",
+ exchangeBaseUrl: exchange.baseUrl,
+ }),
+ );
+
+ t.assertTrue(
+ err2.errorDetail.code === TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ );
+
+ t.assertTrue(
+ err2.errorDetail.innerError.code ===
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ );
+}
+
+runWalletDevexpFakeprotoverTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -136,6 +136,7 @@ import { runWalletDblessTest } from "./test-wallet-dbless.js";
import { runWalletDd48Test } from "./test-wallet-dd48.js";
import { runWalletDenomExpireTest } from "./test-wallet-denom-expire.js";
import { runWalletDevExperimentsTest } from "./test-wallet-dev-experiments.js";
+import { runWalletDevexpFakeprotoverTest } from "./test-wallet-devexp-fakeprotover.js";
import { runWalletExchangeUpdateTest } from "./test-wallet-exchange-update.js";
import { runWalletGenDbTest } from "./test-wallet-gendb.js";
import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js";
@@ -316,6 +317,7 @@ const allTests: TestMainFunction[] = [
runAgeRestrictionsPeerTest,
runAgeRestrictionsDepositTest,
runKycDecisionEventsTest,
+ runWalletDevexpFakeprotoverTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-util/src/libtool-version.ts b/packages/taler-util/src/libtool-version.ts
@@ -65,6 +65,14 @@ export namespace LibtoolVersion {
return { compatible, currentCmp };
}
+ export function parseVersionOrThrow(v: string): Version {
+ const res = parseVersion(v);
+ if (!res) {
+ throw Error("invalid libtool version");
+ }
+ return res;
+ }
+
export function parseVersion(v: string): Version | undefined {
const [currentStr, revisionStr, ageStr, ...rest] = v.split(":");
if (rest.length !== 0) {
diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts
@@ -114,6 +114,7 @@ export interface PayPullUriResult {
export interface DevExperimentUri {
type: TalerUriAction.DevExperiment;
devExperimentId: string;
+ query?: URLSearchParams;
}
export interface BackupRestoreUri {
@@ -573,6 +574,7 @@ export function parseDevExperimentUri(s: string): DevExperimentUri | undefined {
return {
type: TalerUriAction.DevExperiment,
devExperimentId: parts[0],
+ query: new URLSearchParams(c[1] ?? ""),
};
}
@@ -696,9 +698,9 @@ export function stringifyWithdrawUri({
}
export function getURLHostnamePortPath(baseUrl: string) {
- const path = getUrlInfo(baseUrl).path
- if (path.endsWith("/")){
- return path.substring(0, path.length -1)
+ const path = getUrlInfo(baseUrl).path;
+ if (path.endsWith("/")) {
+ return path.substring(0, path.length - 1);
}
return path;
}
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -490,7 +490,7 @@ export namespace TaskRunResult {
export function error(detail: TalerErrorDetail): TaskRunResult {
return {
type: TaskRunResultType.Error,
- errorDetail: detail
+ errorDetail: detail,
};
}
}
@@ -643,6 +643,7 @@ export function getAutoRefreshExecuteThreshold(d: {
export enum PendingTaskType {
ExchangeUpdate = "exchange-update",
+ ExchangeAutoRefresh = "exchange-auto-refresh",
ExchangeWalletKyc = "exchange-wallet-kyc",
Purchase = "purchase",
Refresh = "refresh",
@@ -666,6 +667,7 @@ export type ParsedTaskIdentifier =
withdrawalGroupId: string;
}
| { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
+ | { tag: PendingTaskType.ExchangeAutoRefresh; exchangeBaseUrl: string }
| { tag: PendingTaskType.ExchangeWalletKyc; exchangeBaseUrl: string }
| { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
| { tag: PendingTaskType.Deposit; depositGroupId: string }
@@ -693,6 +695,8 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
return { tag: type, depositGroupId: rest[0] };
case PendingTaskType.ExchangeUpdate:
return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
+ case PendingTaskType.ExchangeAutoRefresh:
+ return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
case PendingTaskType.ExchangeWalletKyc:
return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
case PendingTaskType.PeerPullCredit:
@@ -728,6 +732,8 @@ export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr {
return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
case PendingTaskType.ExchangeWalletKyc:
return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
+ case PendingTaskType.ExchangeAutoRefresh:
+ return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
case PendingTaskType.PeerPullDebit:
return `${p.tag}:${p.peerPullDebitId}` as TaskIdStr;
case PendingTaskType.PeerPushCredit:
@@ -765,6 +771,13 @@ export namespace TaskIdentifiers {
exchBaseUrl,
)}` as TaskIdStr;
}
+ export function forExchangeAutoRefreshFromUrl(
+ exchBaseUrl: string,
+ ): TaskIdStr {
+ return `${PendingTaskType.ExchangeAutoRefresh}:${encodeURIComponent(
+ exchBaseUrl,
+ )}` as TaskIdStr;
+ }
export function forRefresh(
refreshGroupRecord: RefreshGroupRecord,
): TaskIdStr {
diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts
@@ -49,7 +49,7 @@ import {
} from "./db.js";
import { DenomLossTransactionContext } from "./exchanges.js";
import { RefreshTransactionContext } from "./refresh.js";
-import { WalletExecutionContext } from "./wallet.js";
+import { DevExperimentState, WalletExecutionContext } from "./wallet.js";
const logger = new Logger("dev-experiments.ts");
@@ -144,24 +144,87 @@ export async function applyDevExperiment(
wex.ws.devExperimentState.merchantDepositInsufficient = true;
return;
}
+ case "start-fakeprotover": {
+ const baseUrl = parsedUri.query?.get("base_url");
+ if (!baseUrl) {
+ throw Error("base_url required");
+ }
+ const fakeVer = parsedUri.query?.get("fake_ver");
+ if (!fakeVer) {
+ throw Error("fake_ver required");
+ }
+ let fakeSt = wex.ws.devExperimentState.fakeProtoVer;
+ if (!fakeSt) {
+ fakeSt = wex.ws.devExperimentState.fakeProtoVer = new Map();
+ }
+ fakeSt.set(baseUrl, {
+ fakeVer,
+ });
+ return;
+ }
}
throw Error(`dev-experiment id not understood ${parsedUri.devExperimentId}`);
}
+function mockResponseJson(resp: HttpResponse, respJson: any): HttpResponse {
+ const textEncoder = new TextEncoder();
+ return {
+ requestMethod: resp.requestMethod,
+ requestUrl: resp.requestUrl,
+ status: resp.status,
+ headers: resp.headers,
+ async bytes() {
+ return textEncoder.encode(JSON.stringify(respJson, undefined, 2));
+ },
+ async json() {
+ return respJson;
+ },
+ async text() {
+ return JSON.stringify(respJson, undefined, 2);
+ },
+ };
+}
+
export class DevExperimentHttpLib implements HttpRequestLibrary {
_isDevExperimentLib = true;
underlyingLib: HttpRequestLibrary;
- constructor(lib: HttpRequestLibrary) {
+ constructor(
+ lib: HttpRequestLibrary,
+ private devExperimentState: DevExperimentState,
+ ) {
this.underlyingLib = lib;
}
- fetch(
+ async fetch(
url: string,
opt?: HttpRequestOptions | undefined,
): Promise<HttpResponse> {
- logger.trace(`devexperiment httplib ${url}`);
+ if (this.devExperimentState.fakeProtoVer != null) {
+ if ((opt?.method ?? "get").toLowerCase() == "get") {
+ let verBaseUrl: string | undefined;
+ const confSuffix = "/config";
+ const keysSuffix = "/keys";
+ if (url.endsWith(confSuffix)) {
+ verBaseUrl = url.substring(0, url.length - confSuffix.length + 1);
+ } else if (url.endsWith(keysSuffix)) {
+ verBaseUrl = url.substring(0, url.length - keysSuffix.length + 1);
+ }
+ const fakeSt =
+ verBaseUrl && this.devExperimentState.fakeProtoVer.get(verBaseUrl);
+ if (fakeSt) {
+ const resp = await this.underlyingLib.fetch(url, opt);
+ if (resp.status !== 200) {
+ return resp;
+ }
+ logger.info(`replacing proto version with ${fakeSt.fakeVer}`);
+ const respJson = await resp.json();
+ respJson.version = fakeSt.fakeVer;
+ return mockResponseJson(resp, respJson);
+ }
+ }
+ }
return this.underlyingLib.fetch(url, opt);
}
}
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -175,7 +175,6 @@ import { PeerPullCreditTransactionContext } from "./pay-peer-pull-credit.js";
import { PeerPullDebitTransactionContext } from "./pay-peer-pull-debit.js";
import { PeerPushCreditTransactionContext } from "./pay-peer-push-credit.js";
import { PeerPushDebitTransactionContext } from "./pay-peer-push-debit.js";
-import { DbReadOnlyTransaction } from "./query.js";
import { RecoupTransactionContext, createRecoupGroup } from "./recoup.js";
import { RefreshTransactionContext, createRefreshGroup } from "./refresh.js";
import {
@@ -476,8 +475,8 @@ async function makeExchangeListItem(
): Promise<ExchangeListItem> {
const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
? {
- error: lastError,
- }
+ error: lastError,
+ }
: undefined;
let scopeInfo: ScopeInfo | undefined = undefined;
@@ -913,7 +912,10 @@ async function downloadExchangeKeysInfo(
timeout: Duration,
cancellationToken: CancellationToken,
noCache: boolean,
-): Promise<ExchangeKeysDownloadResult> {
+): Promise<
+ | { type: "ok"; res: ExchangeKeysDownloadResult }
+ | { type: "version-incompatible"; exchangeProtocolVersion: string }
+> {
const keysUrl = new URL("keys", baseUrl);
const headers: Record<string, string> = {};
@@ -957,22 +959,18 @@ async function downloadExchangeKeysInfo(
);
}
if (!versionRes.compatible) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- "exchange protocol version not compatible with wallet",
- );
+ return {
+ type: "version-incompatible",
+ exchangeProtocolVersion: protocolVersion,
+ };
}
- const ExchangeKeysResponseUnchecked = await readSuccessResponseJsonOrThrow(
+ const exchangeKeysResponseUnchecked = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeKeysResponse(),
);
- if (ExchangeKeysResponseUnchecked.denominations.length === 0) {
+ if (exchangeKeysResponseUnchecked.denominations.length === 0) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
{
@@ -982,11 +980,11 @@ async function downloadExchangeKeysInfo(
);
}
- const currency = ExchangeKeysResponseUnchecked.currency;
+ const currency = exchangeKeysResponseUnchecked.currency;
const currentDenominations: DenominationRecord[] = [];
- for (const denomGroup of ExchangeKeysResponseUnchecked.denominations) {
+ for (const denomGroup of exchangeKeysResponseUnchecked.denominations) {
switch (denomGroup.cipher) {
case "RSA":
case "RSA+age_restricted": {
@@ -1006,7 +1004,7 @@ async function downloadExchangeKeysInfo(
denomPub,
denomPubHash,
exchangeBaseUrl: baseUrl,
- exchangeMasterPub: ExchangeKeysResponseUnchecked.master_public_key,
+ exchangeMasterPub: exchangeKeysResponseUnchecked.master_public_key,
isOffered: true,
isRevoked: false,
isLost: denomIn.lost ?? false,
@@ -1045,30 +1043,34 @@ async function downloadExchangeKeysInfo(
}
}
- return {
- masterPublicKey: ExchangeKeysResponseUnchecked.master_public_key,
+ const res: ExchangeKeysDownloadResult = {
+ masterPublicKey: exchangeKeysResponseUnchecked.master_public_key,
currency,
- baseUrl: ExchangeKeysResponseUnchecked.base_url,
- auditors: ExchangeKeysResponseUnchecked.auditors,
+ baseUrl: exchangeKeysResponseUnchecked.base_url,
+ auditors: exchangeKeysResponseUnchecked.auditors,
currentDenominations,
- protocolVersion: ExchangeKeysResponseUnchecked.version,
- signingKeys: ExchangeKeysResponseUnchecked.signkeys,
- reserveClosingDelay: ExchangeKeysResponseUnchecked.reserve_closing_delay,
+ protocolVersion: exchangeKeysResponseUnchecked.version,
+ signingKeys: exchangeKeysResponseUnchecked.signkeys,
+ reserveClosingDelay: exchangeKeysResponseUnchecked.reserve_closing_delay,
expiry: AbsoluteTime.toProtocolTimestamp(
getExpiry(resp, {
minDuration: Duration.fromSpec({ hours: 1 }),
}),
),
- recoup: ExchangeKeysResponseUnchecked.recoup ?? [],
- listIssueDate: ExchangeKeysResponseUnchecked.list_issue_date,
- globalFees: ExchangeKeysResponseUnchecked.global_fees,
- accounts: ExchangeKeysResponseUnchecked.accounts,
- wireFees: ExchangeKeysResponseUnchecked.wire_fees,
- currencySpecification: ExchangeKeysResponseUnchecked.currency_specification,
+ recoup: exchangeKeysResponseUnchecked.recoup ?? [],
+ listIssueDate: exchangeKeysResponseUnchecked.list_issue_date,
+ globalFees: exchangeKeysResponseUnchecked.global_fees,
+ accounts: exchangeKeysResponseUnchecked.accounts,
+ wireFees: exchangeKeysResponseUnchecked.wire_fees,
+ currencySpecification: exchangeKeysResponseUnchecked.currency_specification,
walletBalanceLimits:
- ExchangeKeysResponseUnchecked.wallet_balance_limit_without_kyc,
- hardLimits: ExchangeKeysResponseUnchecked.hard_limits,
- zeroLimits: ExchangeKeysResponseUnchecked.zero_limits,
+ exchangeKeysResponseUnchecked.wallet_balance_limit_without_kyc,
+ hardLimits: exchangeKeysResponseUnchecked.hard_limits,
+ zeroLimits: exchangeKeysResponseUnchecked.zero_limits,
+ };
+ return {
+ type: "ok",
+ res,
};
}
@@ -1185,7 +1187,8 @@ async function startUpdateExchangeEntry(
options: { forceUpdate?: boolean } = {},
): Promise<void> {
logger.trace(
- `starting update of exchange entry ${exchangeBaseUrl}, forced=${options.forceUpdate ?? false
+ `starting update of exchange entry ${exchangeBaseUrl}, forced=${
+ options.forceUpdate ?? false
}`,
);
@@ -1546,6 +1549,62 @@ function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean {
return true;
}
+async function handleExchageUpdateIncompatible(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ exchangeProtocolVersion: string,
+): Promise<TaskRunResult> {
+ const updated = await wex.db.runReadWriteTx(
+ {
+ storeNames: ["exchanges"],
+ },
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
+ return undefined;
+ }
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ break;
+ default:
+ return undefined;
+ }
+ const oldExchangeState = getExchangeState(r);
+ r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
+ r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
+ r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
+ r.unavailableReason = makeTalerErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ exchangeProtocolVersion: exchangeProtocolVersion,
+ walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ },
+ );
+ const newExchangeState = getExchangeState(r);
+ await tx.exchanges.put(r);
+ return {
+ oldExchangeState,
+ newExchangeState,
+ };
+ },
+ );
+ if (updated) {
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: updated.newExchangeState,
+ oldExchangeState: updated.oldExchangeState,
+ });
+ }
+ // Next invocation will cause the task to be run again
+ // at the necessary time.
+ return TaskRunResult.progress();
+}
+
/**
* Update an exchange entry in the wallet's database
* by fetching the /keys and /wire information.
@@ -1575,7 +1634,6 @@ export async function updateExchangeFromUrlHandler(
}
let updateRequestedExplicitly = false;
-
switch (oldExchangeRec.updateStatus) {
case ExchangeEntryDbUpdateStatus.Suspended:
logger.info(`not updating exchange in status "suspended"`);
@@ -1584,7 +1642,11 @@ export async function updateExchangeFromUrlHandler(
logger.info(`not updating exchange in status "initial"`);
return TaskRunResult.finished();
case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
+ updateRequestedExplicitly = true;
+ break;
case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ updateRequestedExplicitly = true;
+ break;
case ExchangeEntryDbUpdateStatus.ReadyUpdate:
updateRequestedExplicitly = true;
break;
@@ -1597,8 +1659,6 @@ export async function updateExchangeFromUrlHandler(
assertUnreachable(oldExchangeRec.updateStatus);
}
- let refreshCheckNecessary = true;
-
if (!updateRequestedExplicitly) {
// If the update wasn't requested explicitly,
// check if we really need to update.
@@ -1607,12 +1667,6 @@ export async function updateExchangeFromUrlHandler(
oldExchangeRec.nextUpdateStamp,
);
- let nextRefreshCheckStamp = timestampAbsoluteFromDb(
- oldExchangeRec.nextRefreshCheckStamp,
- );
-
- let updateNecessary = true;
-
if (
!AbsoluteTime.isNever(nextUpdateStamp) &&
!AbsoluteTime.isExpired(nextUpdateStamp)
@@ -1622,25 +1676,8 @@ export async function updateExchangeFromUrlHandler(
nextUpdateStamp,
)}`,
);
- updateNecessary = false;
- }
-
- if (
- !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
- !AbsoluteTime.isExpired(nextRefreshCheckStamp)
- ) {
- logger.trace(
- `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
- nextRefreshCheckStamp,
- )}`,
- );
- refreshCheckNecessary = false;
- }
- if (!(updateNecessary || refreshCheckNecessary)) {
logger.trace("update not necessary, running again later");
- return TaskRunResult.runAgainAt(
- AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
- );
+ return TaskRunResult.runAgainAt(nextUpdateStamp);
}
}
@@ -1651,7 +1688,7 @@ export async function updateExchangeFromUrlHandler(
const timeout = getExchangeRequestTimeout();
- const keysInfo = await downloadExchangeKeysInfo(
+ const keysInfoRes = await downloadExchangeKeysInfo(
exchangeBaseUrl,
wex.http,
timeout,
@@ -1661,6 +1698,17 @@ export async function updateExchangeFromUrlHandler(
logger.trace("validating exchange wire info");
+ switch (keysInfoRes.type) {
+ case "version-incompatible":
+ return handleExchageUpdateIncompatible(
+ wex,
+ exchangeBaseUrl,
+ keysInfoRes.exchangeProtocolVersion,
+ );
+ }
+
+ const keysInfo = keysInfoRes.res;
+
const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
if (!version) {
// Should have been validated earlier.
@@ -1958,10 +2006,6 @@ export async function updateExchangeFromUrlHandler(
logger.trace("done updating exchange info in database");
- if (refreshCheckNecessary) {
- await doAutoRefresh(wex, exchangeBaseUrl);
- }
-
wex.ws.notify({
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl,
@@ -1969,18 +2013,23 @@ export async function updateExchangeFromUrlHandler(
oldExchangeState: updated.oldExchangeState,
});
+ // Always trigger auto-refresh after an exchange update.
+ await doExchangeAutoRefresh(wex, exchangeBaseUrl);
+
+ // Make sure an auto-refresh task is scheduled for this exchange.
+ const autoRefreshTaskId =
+ TaskIdentifiers.forExchangeAutoRefreshFromUrl(exchangeBaseUrl);
+ await wex.taskScheduler.resetTaskRetries(autoRefreshTaskId);
+
// Next invocation will cause the task to be run again
// at the necessary time.
return TaskRunResult.progress();
}
-async function doAutoRefresh(
+async function doExchangeAutoRefresh(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise<void> {
- logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
-
- // FIXME: This should be part of updating the exchange entry.
await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl);
let minCheckThreshold = AbsoluteTime.addDuration(
@@ -2061,6 +2110,52 @@ async function doAutoRefresh(
);
}
+export async function processTaskExchangeAutoRefresh(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<TaskRunResult> {
+ logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
+
+ const oldExchangeRec = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
+ return tx.exchanges.get(exchangeBaseUrl);
+ },
+ );
+
+ if (!oldExchangeRec) {
+ return TaskRunResult.finished();
+ }
+
+ let nextRefreshCheckStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextRefreshCheckStamp,
+ );
+
+ let refreshCheckNecessary;
+
+ if (
+ !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
+ !AbsoluteTime.isExpired(nextRefreshCheckStamp)
+ ) {
+ logger.trace(
+ `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextRefreshCheckStamp,
+ )}`,
+ );
+ refreshCheckNecessary = false;
+ }
+ if (!refreshCheckNecessary) {
+ logger.trace("update not necessary, running again later");
+ return TaskRunResult.runAgainAt(nextRefreshCheckStamp);
+ }
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ await doExchangeAutoRefresh(wex, exchangeBaseUrl);
+
+ return TaskRunResult.progress();
+}
+
interface DenomLossResult {
notifications: WalletNotification[];
}
@@ -2548,8 +2643,11 @@ export async function downloadExchangeInfo(
CancellationToken.CONTINUE,
false,
);
+ if (keysInfo.type !== "ok") {
+ throw Error(`bad keys download: ${keysInfo.type}`);
+ }
return {
- keys: keysInfo,
+ keys: keysInfo.res,
};
}
@@ -2822,9 +2920,7 @@ export async function getExchangeDetailedInfo(
}
async function internalGetExchangeResources(
- tx: WalletDbReadOnlyTransaction<
- ["exchanges", "coins", "withdrawalGroups"]
- >,
+ tx: WalletDbReadOnlyTransaction<["exchanges", "coins", "withdrawalGroups"]>,
exchangeBaseUrl: string,
): Promise<GetExchangeResourcesResponse> {
let numWithdrawals = 0;
@@ -3138,14 +3234,14 @@ export async function getExchangeWireFee(
export type BalanceThresholdCheckResult =
| {
- result: "ok";
- }
+ result: "ok";
+ }
| {
- result: "violation";
- nextThreshold: AmountString;
- walletKycStatus: ExchangeWalletKycStatus | undefined;
- walletKycAccessToken: string | undefined;
- };
+ result: "violation";
+ nextThreshold: AmountString;
+ walletKycStatus: ExchangeWalletKycStatus | undefined;
+ walletKycAccessToken: string | undefined;
+ };
export async function checkIncomingAmountLegalUnderKycBalanceThreshold(
wex: WalletExecutionContext,
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
@@ -65,6 +65,7 @@ import {
} from "./deposits.js";
import {
computeDenomLossTransactionStatus,
+ processTaskExchangeAutoRefresh,
processExchangeKyc,
updateExchangeFromUrlHandler,
} from "./exchanges.js";
@@ -129,6 +130,7 @@ function taskGivesLiveness(taskId: string): boolean {
switch (parsedTaskId.tag) {
case PendingTaskType.Backup:
case PendingTaskType.ExchangeUpdate:
+ case PendingTaskType.ExchangeAutoRefresh:
case PendingTaskType.ExchangeWalletKyc:
return false;
case PendingTaskType.Deposit:
@@ -175,7 +177,7 @@ export class TaskSchedulerImpl implements TaskScheduler {
isRunning: boolean = false;
- constructor(private ws: InternalWalletState) { }
+ constructor(private ws: InternalWalletState) {}
private async loadTasksFromDb(): Promise<void> {
const activeTasks = await getActiveTaskIds(this.ws);
@@ -627,7 +629,7 @@ function getWalletExecutionContextForTask(
);
} else {
oc = {
- observe(evt) { },
+ observe(evt) {},
};
wex = getNormalWalletExecutionContext(ws, cancellationToken, undefined, oc);
}
@@ -651,6 +653,8 @@ async function callOperationHandlerForTaskId(
switch (pending.tag) {
case PendingTaskType.ExchangeUpdate:
return await updateExchangeFromUrlHandler(wex, pending.exchangeBaseUrl);
+ case PendingTaskType.ExchangeAutoRefresh:
+ return await processTaskExchangeAutoRefresh(wex, pending.exchangeBaseUrl);
case PendingTaskType.Refresh:
return await processRefreshGroup(wex, pending.refreshGroupId);
case PendingTaskType.Withdraw:
@@ -700,6 +704,7 @@ async function taskToRetryNotification(
switch (parsedTaskId.tag) {
case PendingTaskType.ExchangeUpdate:
case PendingTaskType.ExchangeWalletKyc:
+ case PendingTaskType.ExchangeAutoRefresh:
return makeExchangeRetryNotification(ws, tx, pendingTaskId, e);
case PendingTaskType.PeerPullCredit:
case PendingTaskType.PeerPullDebit:
@@ -857,6 +862,7 @@ async function makeExchangeRetryNotification(
switch (parsedTaskId.tag) {
case PendingTaskType.ExchangeUpdate:
case PendingTaskType.ExchangeWalletKyc:
+ case PendingTaskType.ExchangeAutoRefresh:
break;
default:
throw Error("invalid task identifier");
@@ -1108,6 +1114,12 @@ export async function getActiveTaskIds(
});
res.taskIds.push(taskIdUpdate);
+ const taskIdAutoRefresh = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeAutoRefresh,
+ exchangeBaseUrl: rec.baseUrl,
+ });
+ res.taskIds.push(taskIdAutoRefresh);
+
const reserveId = rec.currentMergeReserveRowId;
if (reserveId == null) {
continue;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -421,12 +421,15 @@ export interface WalletExecutionContext {
readonly taskScheduler: TaskScheduler;
}
-export function walletExchangeClient(baseUrl: string, wex: WalletExecutionContext): TalerExchangeHttpClient {
+export function walletExchangeClient(
+ baseUrl: string,
+ wex: WalletExecutionContext,
+): TalerExchangeHttpClient {
return new TalerExchangeHttpClient(baseUrl, {
httpClient: wex.http,
cancelationToken: wex.cancellationToken,
- longPollQueue: wex.ws.longpollQueue
- })
+ longPollQueue: wex.ws.longpollQueue,
+ });
}
export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
@@ -2461,7 +2464,7 @@ async function dispatchWalletCoreApiRequest(
wex = getObservedWalletExecutionContext(ws, cts.token, cts, oc);
} else {
oc = {
- observe(evt) { },
+ observe(evt) {},
};
wex = getNormalWalletExecutionContext(ws, cts.token, cts, oc);
}
@@ -2598,6 +2601,13 @@ export class Wallet {
export interface DevExperimentState {
blockRefreshes?: boolean;
merchantDepositInsufficient?: boolean;
+ /** Map from base URL to faked version for /config or /keys */
+ fakeProtoVer?: Map<
+ string,
+ {
+ fakeVer: string;
+ }
+ >;
}
export class Cache<T> {
@@ -2606,7 +2616,7 @@ export class Cache<T> {
constructor(
private maxCapacity: number,
private cacheDuration: Duration,
- ) { }
+ ) {}
get(key: string): T | undefined {
const r = this.map.get(key);
@@ -2642,7 +2652,7 @@ export class Cache<T> {
* Implementation of triggers for the wallet DB.
*/
class WalletDbTriggerSpec implements TriggerSpec {
- constructor(public ws: InternalWalletState) { }
+ constructor(public ws: InternalWalletState) {}
afterCommit(info: AfterCommitInfo): void {
if (info.mode !== "readwrite") {
@@ -2765,7 +2775,7 @@ export class InternalWalletState {
this._http = this.httpFactory(newConfig);
if (this.config.testing.devModeActive) {
- this._http = new DevExperimentHttpLib(this.http);
+ this._http = new DevExperimentHttpLib(this.http, this.devExperimentState);
}
}