taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-wallet-devexp-fakeprotover.ts | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/libtool-version.ts | 8++++++++
Mpackages/taler-util/src/taleruri.ts | 8+++++---
Mpackages/taler-wallet-core/src/common.ts | 15++++++++++++++-
Mpackages/taler-wallet-core/src/dev-experiments.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/exchanges.ts | 258++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mpackages/taler-wallet-core/src/shepherd.ts | 16++++++++++++++--
Mpackages/taler-wallet-core/src/wallet.ts | 24+++++++++++++++++-------
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); } }