summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts155
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts2
-rw-r--r--packages/taler-wallet-core/src/common.ts43
-rw-r--r--packages/taler-wallet-core/src/db.ts2
-rw-r--r--packages/taler-wallet-core/src/exchanges.ts40
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);