diff options
author | Florian Dold <florian@dold.me> | 2024-04-05 13:29:45 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-04-05 13:29:45 +0200 |
commit | 7b2f95e482367183ca77f619d9ecbe34d5fd85bd (patch) | |
tree | 6ad95fae73143d5af464c96dc4df1e844a6451c9 | |
parent | 7054acfd0470e13b8fc4c1f08834bc2fcc776ef4 (diff) | |
download | wallet-core-7b2f95e482367183ca77f619d9ecbe34d5fd85bd.tar.xz |
wallet-core: put exchange in update-unavailable if master pub / currency change unexpectedly
-rw-r--r-- | packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts | 155 | ||||
-rw-r--r-- | packages/taler-harness/src/integrationtests/testrunner.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/common.ts | 43 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/exchanges.ts | 40 |
5 files changed, 220 insertions, 22 deletions
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts new file mode 100644 index 000000000..15ac79953 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts @@ -0,0 +1,155 @@ +/* + This file is part of GNU Taler + (C) 2020 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 { + ExchangeUpdateStatus, + NotificationType, + j2s, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + BankService, + ExchangeService, + GlobalTestState, + setupDb, +} from "../harness/harness.js"; +import { + createWalletDaemonWithClient, + withdrawViaBankV2, +} from "../harness/helpers.js"; + +/** + * Test how the wallet reacts when an exchange unexpectedly updates + * properties like the master public key. + */ +export async function runWalletExchangeUpdateTest( + t: GlobalTestState, +): Promise<void> { + // Set up test environment + + const db = await setupDb(t); + const db2 = await setupDb(t, { + nameSuffix: "two", + }); + + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "TESTKUDOS", + database: db.connStr, + httpPort: 8082, + }); + + const exchangeOne = ExchangeService.create(t, { + name: "testexchange-1", + currency: "TESTKUDOS", + httpPort: 8081, + database: db.connStr, + }); + + // Danger: The second exchange has the same port! + // That's because we want it to have the same base URL, + // and we'll only start on of them at a time. + const exchangeTwo = ExchangeService.create(t, { + name: "testexchange-2", + currency: "TESTKUDOS", + httpPort: 8081, + database: db2.connStr, + }); + + const exchangeBankAccount = await bank.createExchangeAccount( + "myexchange", + "x", + ); + + await exchangeOne.addBankAccount("1", exchangeBankAccount); + await exchangeTwo.addBankAccount("1", exchangeBankAccount); + + // Same anyway. + bank.setSuggestedExchange(exchangeOne, exchangeBankAccount.accountPaytoUri); + + await bank.start(); + + exchangeOne.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); + exchangeTwo.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); + + // Only start first exchange. + await exchangeOne.start(); + + const { walletClient } = await createWalletDaemonWithClient(t, { + name: "wallet", + persistent: true, + }); + + // Since the default exchanges can change, we start the wallet in tests + // with no built-in defaults. Thus the list of exchanges is empty here. + const exchangesListResult = await walletClient.call( + WalletApiOperation.ListExchanges, + {}, + ); + + t.assertDeepEqual(exchangesListResult.exchanges.length, 0); + + await withdrawViaBankV2(t, { + walletClient, + bank, + exchange: exchangeOne, + amount: "TESTKUDOS:10", + }); + + await exchangeOne.stop(); + + console.log("starting second exchange"); + await exchangeTwo.start(); + + console.log("updating exchange entry"); + + await t.assertThrowsAsync(async () => { + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: exchangeOne.baseUrl, + force: true, + }); + }); + + const exchangeEntry = await walletClient.call( + WalletApiOperation.GetExchangeEntryByUrl, + { + exchangeBaseUrl: exchangeOne.baseUrl, + }, + ); + + console.log(`exchange entry: ${j2s(exchangeEntry)}`); + + const exchangeAvailableCond = walletClient.waitForNotificationCond((n) => { + console.log(`got notif ${j2s(n)}`); + return ( + n.type === NotificationType.ExchangeStateTransition && + n.newExchangeState.exchangeUpdateStatus === ExchangeUpdateStatus.Ready + ); + }); + + await exchangeTwo.stop(); + + console.log("starting first exchange"); + await exchangeOne.start(); + + await exchangeAvailableCond; +} + +runWalletExchangeUpdateTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index 9841cb37b..6e76261f0 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -101,6 +101,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 { runWalletExchangeUpdateTest } from "./test-wallet-exchange-update.js"; import { runWalletGenDbTest } from "./test-wallet-gendb.js"; import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js"; import { runWalletNotificationsTest } from "./test-wallet-notifications.js"; @@ -220,6 +221,7 @@ const allTests: TestMainFunction[] = [ runWalletBlockedPayMerchantTest, runWalletBlockedPayPeerPushTest, runWalletBlockedPayPeerPullTest, + runWalletExchangeUpdateTest, ]; export interface TestRunSpec { diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts index 5b7ceeead..6d116c47e 100644 --- a/packages/taler-wallet-core/src/common.ts +++ b/packages/taler-wallet-core/src/common.ts @@ -42,10 +42,8 @@ import { } from "@gnu-taler/taler-util"; import { BackupProviderRecord, - CoinAvailabilityRecord, CoinRecord, DbPreciseTimestamp, - DenominationRecord, DepositGroupRecord, ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, @@ -283,6 +281,8 @@ export function getExchangeUpdateStatusFromRecord( return ExchangeUpdateStatus.ReadyUpdate; case ExchangeEntryDbUpdateStatus.Suspended: return ExchangeUpdateStatus.Suspended; + default: + assertUnreachable(r.updateStatus); } } @@ -296,6 +296,8 @@ export function getExchangeEntryStatusFromRecord( return ExchangeEntryStatus.Preset; case ExchangeEntryDbRecordStatus.Used: return ExchangeEntryStatus.Used; + default: + assertUnreachable(r.entryStatus); } } @@ -488,25 +490,28 @@ function updateTimeout( r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t)); } -export namespace DbRetryInfo { - export function getDuration( - r: DbRetryInfo | undefined, - p: RetryPolicy = defaultRetryPolicy, - ): Duration { - if (!r) { - // If we don't have any retry info, run immediately. - return { d_ms: 0 }; - } - if (p.backoffDelta.d_ms === "forever") { - return { d_ms: "forever" }; - } - const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter); - return { - d_ms: - p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t), - }; +export function computeDbBackoff(retryCounter: number): DbPreciseTimestamp { + const now = AbsoluteTime.now(); + if (now.t_ms === "never") { + throw Error("assertion failed"); + } + const p = defaultRetryPolicy; + if (p.backoffDelta.d_ms === "forever") { + throw Error("assertion failed"); } + const nextIncrement = + p.backoffDelta.d_ms * Math.pow(p.backoffBase, retryCounter); + + const t = + now.t_ms + + (p.maxTimeout.d_ms === "forever" + ? nextIncrement + : Math.min(p.maxTimeout.d_ms, nextIncrement)); + return timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t)); +} + +export namespace DbRetryInfo { export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo { const now = TalerPreciseTimestamp.now(); const info: DbRetryInfo = { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 389d82a03..5bab70968 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -674,6 +674,8 @@ export interface ExchangeEntryRecord { */ nextUpdateStamp: DbPreciseTimestamp; + updateRetryCounter?: number; + lastKeysEtag: string | undefined; /** diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index fa2876e30..cae758a43 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -102,6 +102,7 @@ import { TaskRunResult, TaskRunResultType, TransactionContext, + computeDbBackoff, constructTaskIdentifier, getAutoRefreshExecuteThreshold, getExchangeEntryStatusFromRecord, @@ -1061,6 +1062,14 @@ async function internalWaitReadyExchange( ready = true; } break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); default: { if (retryInfo) { throw TalerError.fromDetail( @@ -1285,9 +1294,11 @@ export async function updateExchangeFromUrlHandler( return TaskRunResult.finished(); case ExchangeEntryDbUpdateStatus.InitialUpdate: case ExchangeEntryDbUpdateStatus.ReadyUpdate: - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: updateRequestedExplicitly = true; break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + // Only retry when scheduled to respect backoff + break; case ExchangeEntryDbUpdateStatus.Ready: break; default: @@ -1407,8 +1418,6 @@ export async function updateExchangeFromUrlHandler( logger.trace("updating exchange info in database"); - let detailsPointerChanged = false; - let ageMask = 0; for (const x of keysInfo.currentDenominations) { if ( @@ -1442,18 +1451,42 @@ export async function updateExchangeFromUrlHandler( } const oldExchangeState = getExchangeState(r); const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); + let detailsPointerChanged = false; if (!existingDetails) { detailsPointerChanged = true; } + let detailsIncompatible = false; if (existingDetails) { if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { + detailsIncompatible = true; detailsPointerChanged = true; } if (existingDetails.currency !== keysInfo.currency) { + detailsIncompatible = true; detailsPointerChanged = true; } // FIXME: We need to do some consistency checks! } + if (detailsIncompatible) { + logger.warn( + `exchange ${r.baseUrl} has incompatible data in /keys, not updating`, + ); + // We don't support this gracefully right now. + // See https://bugs.taler.net/n/8576 + r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; + r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1; + r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter); + r.nextRefreshCheckStamp = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), + ); + r.cachebreakNextUpdate = true; + await tx.exchanges.put(r); + return { + oldExchangeState, + newExchangeState: getExchangeState(r), + }; + } + r.updateRetryCounter = 0; const newDetails: ExchangeDetailsRecord = { auditors: keysInfo.auditors, currency: keysInfo.currency, @@ -1488,6 +1521,7 @@ export async function updateExchangeFromUrlHandler( updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()), }; } + r.updateStatus = ExchangeEntryDbUpdateStatus.Ready; r.cachebreakNextUpdate = false; await tx.exchanges.put(r); |