diff options
author | Florian Dold <florian@dold.me> | 2023-12-11 20:01:28 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2023-12-12 15:42:34 +0100 |
commit | e31f18b8f129adb9cbe33158297a9cff56a7143e (patch) | |
tree | fc960e069a08ca1924a79c154f5ced26db709348 | |
parent | 055645e17aa9424f299aa04f686de7574ab437c7 (diff) |
wallet-core: towards better DD48 support
19 files changed, 838 insertions, 520 deletions
diff --git a/API_CHANGES.md b/API_CHANGES.md index 6fceca31c..f53daf598 100644 --- a/API_CHANGES.md +++ b/API_CHANGES.md @@ -23,3 +23,7 @@ This files contains all the API changes for the current release: via a taler://withdraw-exchange URI. - 2023-12-11 dold: Add exchangeBaseUrl to the checkPeerPushDebit response. - 2023-12-11 dold: Add scopeInfo to exchange entry list items. +- BREAK 2023-12-12 dold: Remove forceUpdate and masterPub arguments from addExchange + request. This request has previously been overloaded both to update an + exchange entry as well as to add it. + To update the entry, updateExchangeEntry should be used instead.
\ No newline at end of file diff --git a/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts index 259cc33f9..1a62a6065 100644 --- a/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts +++ b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts @@ -127,9 +127,9 @@ export async function runDenomUnofferedTest(t: GlobalTestState) { // TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND, // ); - await walletClient.call(WalletApiOperation.AddExchange, { + // Force updating the exchange entry so that the wallet knows about the new denominations. + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { exchangeBaseUrl: exchange.baseUrl, - forceUpdate: true, }); await walletClient.call(WalletApiOperation.DeleteTransaction, { diff --git a/packages/taler-harness/src/integrationtests/test-revocation.ts b/packages/taler-harness/src/integrationtests/test-revocation.ts index 9ed2d6206..6b47951bc 100644 --- a/packages/taler-harness/src/integrationtests/test-revocation.ts +++ b/packages/taler-harness/src/integrationtests/test-revocation.ts @@ -180,9 +180,8 @@ export async function runRevocationTest(t: GlobalTestState) { // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565 // is implemented. - await walletClient.call(WalletApiOperation.AddExchange, { + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { exchangeBaseUrl: exchange.baseUrl, - forceUpdate: true, }); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); const bal = await walletClient.call(WalletApiOperation.GetBalances, {}); @@ -218,9 +217,8 @@ export async function runRevocationTest(t: GlobalTestState) { // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565 // is implemented. - await walletClient.call(WalletApiOperation.AddExchange, { + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { exchangeBaseUrl: exchange.baseUrl, - forceUpdate: true, }); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); { diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts index 0e57ce477..c4ca94dc0 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-balance.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts @@ -75,6 +75,8 @@ export async function runWalletBalanceTest(t: GlobalTestState) { fulfillment_url: "taler://fulfillment-success/thx", }; + console.log("creating order"); + const orderResp = await merchantClient.createOrder({ order, }); @@ -117,6 +119,8 @@ export async function runWalletBalanceTest(t: GlobalTestState) { Amounts.isZero(preparePayResult.balanceDetails.balanceMerchantDepositable), ); + console.log("waiting for transactions to finalize"); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); } diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts index b91d91777..571d8f036 100644 --- a/packages/taler-util/src/notifications.ts +++ b/packages/taler-util/src/notifications.ts @@ -23,15 +23,14 @@ * Imports. */ import { TransactionState } from "./transactions-types.js"; -import { TalerErrorDetail } from "./wallet-types.js"; +import { ExchangeEntryState, TalerErrorDetail } from "./wallet-types.js"; export enum NotificationType { BalanceChange = "balance-change", - ExchangeOperationError = "exchange-operation-error", - ExchangeAdded = "exchange-added", BackupOperationError = "backup-error", PendingOperationProcessed = "pending-operation-processed", TransactionStateTransition = "transaction-state-transition", + ExchangeStateTransition = "exchange-state-transition", } export interface ErrorInfoSummary { @@ -59,19 +58,29 @@ export interface TransactionStateTransitionNotification { experimentalUserData?: any; } -export interface ExchangeAddedNotification { - type: NotificationType.ExchangeAdded; +export interface ExchangeStateTransitionNotification { + type: NotificationType.ExchangeStateTransition; + /** + * Identification of the exchange entry that this + * notification is about. + */ + exchangeBaseUrl: string; + + /** + * If missing, the notification means that + * the exchange entry is newly created. + */ + oldExchangeState?: ExchangeEntryState; + + newExchangeState: ExchangeEntryState; + + errorInfo?: ErrorInfoSummary; } export interface BalanceChangeNotification { type: NotificationType.BalanceChange; } -export interface ExchangeOperationErrorNotification { - type: NotificationType.ExchangeOperationError; - error: TalerErrorDetail; -} - export interface BackupOperationErrorNotification { type: NotificationType.BackupOperationError; error: TalerErrorDetail; @@ -80,12 +89,12 @@ export interface BackupOperationErrorNotification { export interface PendingOperationProcessedNotification { type: NotificationType.PendingOperationProcessed; id: string; + taskResultType: string; } export type WalletNotification = | BalanceChangeNotification | BackupOperationErrorNotification - | ExchangeAddedNotification - | ExchangeOperationErrorNotification + | ExchangeStateTransitionNotification | PendingOperationProcessedNotification | TransactionStateTransitionNotification; diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index 82c58246a..aa498c409 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -1289,12 +1289,11 @@ export enum ExchangeEntryStatus { export enum ExchangeUpdateStatus { Initial = "initial", - InitialUpdate = "initial(update)", + InitialUpdate = "initial-update", Suspended = "suspended", - Failed = "failed", - OutdatedUpdate = "outdated(update)", + UnavailableUpdate = "unavailable-update", Ready = "ready", - ReadyUpdate = "ready(update)", + ReadyUpdate = "ready-update", } export interface OperationErrorInfo { @@ -1645,15 +1644,11 @@ export type GetExchangeEntryByUrlResponse = ExchangeListItem; export interface AddExchangeRequest { exchangeBaseUrl: string; - masterPub?: string; - forceUpdate?: boolean; } export const codecForAddExchangeRequest = (): Codec<AddExchangeRequest> => buildCodecForObject<AddExchangeRequest>() .property("exchangeBaseUrl", codecForString()) - .property("forceUpdate", codecOptional(codecForBoolean())) - .property("masterPub", codecOptional(codecForString())) .build("AddExchangeRequest"); export interface UpdateExchangeEntryRequest { @@ -2875,3 +2870,9 @@ export interface PrepareWithdrawExchangeResponse { */ amount?: AmountString; } + +export interface ExchangeEntryState { + tosStatus: ExchangeTosStatus; + exchangeEntryStatus: ExchangeEntryStatus; + exchangeUpdateStatus: ExchangeUpdateStatus; +} diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index ea250de19..8a8f9737a 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -800,9 +800,8 @@ exchangesCli .flag("force", ["-f", "--force"]) .action(async (args) => { await withWallet(args, async (wallet) => { - await wallet.client.call(WalletApiOperation.AddExchange, { + await wallet.client.call(WalletApiOperation.UpdateExchangeEntry, { exchangeBaseUrl: args.exchangesUpdateCmd.url, - forceUpdate: args.exchangesUpdateCmd.force, }); }); }); diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 6b1fc2f5f..d2c6b8368 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -569,21 +569,6 @@ export interface ExchangeDetailsRecord { */ globalFees: ExchangeGlobalFees[]; - /** - * Etag of the current ToS of the exchange. - */ - tosCurrentEtag: string; - - /** - * Information about ToS acceptance from the user. - */ - tosAccepted: - | { - etag: string; - timestamp: DbPreciseTimestamp; - } - | undefined; - wireInfo: WireInfo; /** @@ -615,8 +600,8 @@ export enum ExchangeEntryDbUpdateStatus { Initial = 1, InitialUpdate = 2, Suspended = 3, - Failed = 4, - OutdatedUpdate = 5, + UnavailableUpdate = 4, + // Reserved 5 for backwards compatibility. Ready = 6, ReadyUpdate = 7, } @@ -660,6 +645,15 @@ export interface ExchangeEntryRecord { updateStatus: ExchangeEntryDbUpdateStatus; /** + * Etag of the current ToS of the exchange. + */ + tosCurrentEtag: string | undefined; + + tosAcceptedEtag: string | undefined; + + tosAcceptedTimestamp: DbPreciseTimestamp | undefined; + + /** * Last time when the exchange /keys info was updated. */ lastUpdate: DbPreciseTimestamp | undefined; diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index 7f4e9ca9b..e841d1d20 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -59,8 +59,11 @@ import { } from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { DenominationRecord } from "./db.js"; -import { isWithdrawableDenom } from "./index.js"; -import { ExchangeInfo } from "./operations/exchanges.js"; +import { + ExchangeInfo, + ExchangeKeysDownloadResult, + isWithdrawableDenom, +} from "./index.js"; import { assembleRefreshRevealRequest } from "./operations/refresh.js"; import { getBankStatusUrl, diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index 6ab6a54d9..abba3f7a7 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -26,6 +26,7 @@ import { CoinRefreshRequest, CoinStatus, Duration, + ExchangeEntryState, ExchangeEntryStatus, ExchangeListItem, ExchangeTosStatus, @@ -75,7 +76,10 @@ import { PendingTaskType, TaskId } from "../pending-types.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; -import { constructTransactionIdentifier } from "./transactions.js"; +import { + constructTransactionIdentifier, + parseTransactionIdentifier, +} from "./transactions.js"; const logger = new Logger("operations/common.ts"); @@ -320,11 +324,7 @@ function convertTaskToTransactionId( } } -/** - * For tasks that process a transaction, - * generate a state transition notification. - */ -async function taskToTransactionNotification( +async function makeTransactionRetryNotification( ws: InternalWalletState, tx: GetReadOnlyAccess<typeof WalletStoresV1>, pendingTaskId: string, @@ -353,6 +353,75 @@ async function taskToTransactionNotification( return notif; } +async function makeExchangeRetryNotification( + ws: InternalWalletState, + tx: GetReadOnlyAccess<typeof WalletStoresV1>, + pendingTaskId: string, + e: TalerErrorDetail | undefined, +): Promise<WalletNotification | undefined> { + logger.info("making exchange retry notification"); + const parsedTaskId = parseTaskIdentifier(pendingTaskId); + if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) { + throw Error("invalid task identifier"); + } + const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl); + + if (!rec) { + logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`); + return undefined; + } + + const notif: WalletNotification = { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: parsedTaskId.exchangeBaseUrl, + oldExchangeState: getExchangeState(rec), + newExchangeState: getExchangeState(rec), + }; + if (e) { + notif.errorInfo = { + code: e.code as number, + hint: e.hint, + }; + } + return notif; +} + +/** + * Generate an appropriate error transition notification + * for applicable tasks. + * + * Namely, transition notifications are generated for: + * - exchange update errors + * - transactions + */ +async function taskToRetryNotification( + ws: InternalWalletState, + tx: GetReadOnlyAccess<typeof WalletStoresV1>, + pendingTaskId: string, + e: TalerErrorDetail | undefined, +): Promise<WalletNotification | undefined> { + const parsedTaskId = parseTaskIdentifier(pendingTaskId); + + switch (parsedTaskId.tag) { + case PendingTaskType.ExchangeUpdate: + return makeExchangeRetryNotification(ws, tx, pendingTaskId, e); + case PendingTaskType.PeerPullCredit: + case PendingTaskType.PeerPullDebit: + case PendingTaskType.Withdraw: + case PendingTaskType.PeerPushCredit: + case PendingTaskType.Deposit: + case PendingTaskType.Refresh: + case PendingTaskType.RewardPickup: + case PendingTaskType.PeerPushDebit: + case PendingTaskType.Purchase: + return makeTransactionRetryNotification(ws, tx, pendingTaskId, e); + case PendingTaskType.Backup: + case PendingTaskType.ExchangeCheckRefresh: + case PendingTaskType.Recoup: + return undefined; + } +} + async function storePendingTaskError( ws: InternalWalletState, pendingTaskId: string, @@ -372,7 +441,7 @@ async function storePendingTaskError( retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); } await tx.operationRetries.put(retryRecord); - return taskToTransactionNotification(ws, tx, pendingTaskId, e); + return taskToRetryNotification(ws, tx, pendingTaskId, e); }); if (maybeNotification) { ws.notify(maybeNotification); @@ -391,7 +460,7 @@ export async function resetPendingTaskTimeout( retryRecord.retryInfo = DbRetryInfo.reset(); await tx.operationRetries.put(retryRecord); } - return taskToTransactionNotification(ws, tx, pendingTaskId, undefined); + return taskToRetryNotification(ws, tx, pendingTaskId, undefined); }); if (maybeNotification) { ws.notify(maybeNotification); @@ -419,7 +488,7 @@ async function storePendingTaskPending( } await tx.operationRetries.put(retryRecord); if (hadError) { - return taskToTransactionNotification(ws, tx, pendingTaskId, undefined); + return taskToRetryNotification(ws, tx, pendingTaskId, undefined); } else { return undefined; } @@ -532,66 +601,72 @@ export enum TombstoneTag { DeletePeerPushCredit = "delete-peer-push-credit", } -export function getExchangeTosStatus( - exchangeDetails: ExchangeDetailsRecord, +export function getExchangeTosStatusFromRecord( + exchange: ExchangeEntryRecord, ): ExchangeTosStatus { - if (!exchangeDetails.tosAccepted) { + if (!exchange.tosAcceptedEtag) { return ExchangeTosStatus.Proposed; } - if (exchangeDetails.tosAccepted?.etag == exchangeDetails.tosCurrentEtag) { + if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) { return ExchangeTosStatus.Accepted; } return ExchangeTosStatus.Proposed; } -export function makeExchangeListItem( +export function getExchangeUpdateStatusFromRecord( r: ExchangeEntryRecord, - exchangeDetails: ExchangeDetailsRecord | undefined, - lastError: TalerErrorDetail | undefined, -): ExchangeListItem { - const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError - ? { - error: lastError, - } - : undefined; - - let exchangeUpdateStatus: ExchangeUpdateStatus; +): ExchangeUpdateStatus { switch (r.updateStatus) { - case ExchangeEntryDbUpdateStatus.Failed: - exchangeUpdateStatus = ExchangeUpdateStatus.Failed; - break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + return ExchangeUpdateStatus.UnavailableUpdate; case ExchangeEntryDbUpdateStatus.Initial: - exchangeUpdateStatus = ExchangeUpdateStatus.Initial; - break; + return ExchangeUpdateStatus.Initial; case ExchangeEntryDbUpdateStatus.InitialUpdate: - exchangeUpdateStatus = ExchangeUpdateStatus.InitialUpdate; - break; - case ExchangeEntryDbUpdateStatus.OutdatedUpdate: - exchangeUpdateStatus = ExchangeUpdateStatus.OutdatedUpdate; - break; + return ExchangeUpdateStatus.InitialUpdate; case ExchangeEntryDbUpdateStatus.Ready: - exchangeUpdateStatus = ExchangeUpdateStatus.Ready; - break; + return ExchangeUpdateStatus.Ready; case ExchangeEntryDbUpdateStatus.ReadyUpdate: - exchangeUpdateStatus = ExchangeUpdateStatus.ReadyUpdate; - break; + return ExchangeUpdateStatus.ReadyUpdate; case ExchangeEntryDbUpdateStatus.Suspended: - exchangeUpdateStatus = ExchangeUpdateStatus.Suspended; - break; + return ExchangeUpdateStatus.Suspended; } +} - let exchangeEntryStatus: ExchangeEntryStatus; +export function getExchangeEntryStatusFromRecord( + r: ExchangeEntryRecord, +): ExchangeEntryStatus { switch (r.entryStatus) { case ExchangeEntryDbRecordStatus.Ephemeral: - exchangeEntryStatus = ExchangeEntryStatus.Ephemeral; - break; + return ExchangeEntryStatus.Ephemeral; case ExchangeEntryDbRecordStatus.Preset: - exchangeEntryStatus = ExchangeEntryStatus.Preset; - break; + return ExchangeEntryStatus.Preset; case ExchangeEntryDbRecordStatus.Used: - exchangeEntryStatus = ExchangeEntryStatus.Used; - break; + return ExchangeEntryStatus.Used; } +} + +/** + * Compute the state of an exchange entry from the DB + * record. + */ +export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState { + return { + exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), + exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), + tosStatus: getExchangeTosStatusFromRecord(r), + }; +} + +export function makeExchangeListItem( + r: ExchangeEntryRecord, + exchangeDetails: ExchangeDetailsRecord | undefined, + lastError: TalerErrorDetail | undefined, +): ExchangeListItem { + const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError + ? { + error: lastError, + } + : undefined; let scopeInfo: ScopeInfo | undefined = undefined; if (exchangeDetails) { @@ -606,11 +681,9 @@ export function makeExchangeListItem( return { exchangeBaseUrl: r.baseUrl, currency: exchangeDetails?.currency ?? r.presetCurrencyHint, - exchangeUpdateStatus, - exchangeEntryStatus, - tosStatus: exchangeDetails - ? getExchangeTosStatus(exchangeDetails) - : ExchangeTosStatus.Pending, + exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), + exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), + tosStatus: getExchangeTosStatusFromRecord(r), ageRestrictionOptions: exchangeDetails?.ageMask ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask) : [], @@ -852,7 +925,6 @@ export type ParsedTaskIdentifier = | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string } | { tag: PendingTaskType.Deposit; depositGroupId: string } | { tag: PendingTaskType.ExchangeCheckRefresh; exchangeBaseUrl: string } - | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string } | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string } | { tag: PendingTaskType.PeerPullCredit; pursePub: string } | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string } @@ -872,13 +944,13 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { const [type, ...rest] = task; switch (type) { case PendingTaskType.Backup: - return { tag: type, backupProviderBaseUrl: rest[0] }; + return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) }; case PendingTaskType.Deposit: return { tag: type, depositGroupId: rest[0] }; case PendingTaskType.ExchangeCheckRefresh: - return { tag: type, exchangeBaseUrl: rest[0] }; + return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) }; case PendingTaskType.ExchangeUpdate: - return { tag: type, exchangeBaseUrl: rest[0] }; + return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) }; case PendingTaskType.PeerPullCredit: return { tag: type, pursePub: rest[0] }; case PendingTaskType.PeerPullDebit: @@ -940,13 +1012,19 @@ export namespace TaskIdentifiers { return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId; } export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId { - return `${PendingTaskType.ExchangeUpdate}:${exch.baseUrl}` as TaskId; + return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( + exch.baseUrl, + )}` as TaskId; } export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId { - return `${PendingTaskType.ExchangeUpdate}:${exchBaseUrl}` as TaskId; + return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( + exchBaseUrl, + )}` as TaskId; } export function forExchangeCheckRefresh(exch: ExchangeEntryRecord): TaskId { - return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}` as TaskId; + return `${PendingTaskType.ExchangeCheckRefresh}:${encodeURIComponent( + exch.baseUrl, + )}` as TaskId; } export function forTipPickup(tipRecord: RewardRecord): TaskId { return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId; @@ -964,7 +1042,9 @@ export namespace TaskIdentifiers { return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId; } export function forBackup(backupRecord: BackupProviderRecord): TaskId { - return `${PendingTaskType.Backup}:${backupRecord.baseUrl}` as TaskId; + return `${PendingTaskType.Backup}:${encodeURIComponent( + backupRecord.baseUrl, + )}` as TaskId; } export function forPeerPushPaymentInitiation( ppi: PeerPushDebitRecord, diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 622f04bd3..253801e93 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -15,6 +15,12 @@ */ /** + * @fileoverview + * Implementation of exchange entry management in wallet-core. + * The details of exchange entry management are specified in DD48. + */ + +/** * Imports. */ import { @@ -31,7 +37,9 @@ import { ExchangeAuditor, ExchangeGlobalFees, ExchangeSignKeyJson, + ExchangeEntryState, ExchangeWireAccount, + GetExchangeTosResult, GlobalFees, hashDenomPub, j2s, @@ -48,10 +56,17 @@ import { TalerProtocolDuration, TalerProtocolTimestamp, URL, + WalletNotification, WireFee, WireFeeMap, WireFeesJson, WireInfo, + FeeDescription, + DenomOperationMap, + DenominationInfo, + ExchangeDetailedResponse, + ExchangeListItem, + ExchangesListResponse, } from "@gnu-taler/taler-util"; import { getExpiry, @@ -67,24 +82,29 @@ import { WalletStoresV1, } from "../db.js"; import { + createTimeline, ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, isWithdrawableDenom, + OpenedPromise, + openPromise, + selectBestForOverlappingDenominations, + selectMinimumFee, timestampPreciseFromDb, timestampPreciseToDb, timestampProtocolToDb, WalletDbReadWriteTransaction, } from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; +import { CancelFn, InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; -import { - DbAccess, - GetReadOnlyAccess, - GetReadWriteAccess, -} from "../util/query.js"; +import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; import { - runTaskWithErrorReporting, + getExchangeEntryStatusFromRecord, + getExchangeState, + getExchangeTosStatusFromRecord, + getExchangeUpdateStatusFromRecord, + makeExchangeListItem, TaskIdentifiers, TaskRunResult, TaskRunResultType, @@ -92,19 +112,19 @@ import { const logger = new Logger("exchanges.ts"); -export function getExchangeRequestTimeout(): Duration { +function getExchangeRequestTimeout(): Duration { return Duration.fromSpec({ seconds: 5, }); } -export interface ExchangeTosDownloadResult { +interface ExchangeTosDownloadResult { tosText: string; tosEtag: string; tosContentType: string; } -export async function downloadExchangeWithTermsOfService( +async function downloadExchangeWithTermsOfService( exchangeBaseUrl: string, http: HttpRequestLibrary, timeout: Duration, @@ -129,6 +149,8 @@ export async function downloadExchangeWithTermsOfService( /** * Get exchange details from the database. + * + * FIXME: Should we encapsulate the result better, instead of returning the raw DB records here? */ export async function getExchangeDetails( tx: GetReadOnlyAccess<{ @@ -153,9 +175,6 @@ export async function getExchangeDetails( ]); } -getExchangeDetails.makeContext = (db: DbAccess<typeof WalletStoresV1>) => - db.mktx((x) => [x.exchanges, x.exchangeDetails]); - /** * Mark a ToS version as accepted by the user. * @@ -169,13 +188,13 @@ export async function acceptExchangeTermsOfService( await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { - const d = await getExchangeDetails(tx, exchangeBaseUrl); - if (d) { - d.tosAccepted = { - etag: etag || d.tosCurrentEtag, - timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }; - await tx.exchangeDetails.put(d); + const exch = await tx.exchanges.get(exchangeBaseUrl); + if (exch && exch.tosCurrentEtag) { + exch.tosAcceptedEtag = exch.tosCurrentEtag; + exch.tosAcceptedTimestamp = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + await tx.exchanges.put(exch); } }); } @@ -284,29 +303,18 @@ async function validateGlobalFees( return egf; } -export interface ExchangeInfo { - keys: ExchangeKeysDownloadResult; -} - -export async function downloadExchangeInfo( - exchangeBaseUrl: string, - http: HttpRequestLibrary, -): Promise<ExchangeInfo> { - const keysInfo = await downloadExchangeKeysInfo( - exchangeBaseUrl, - http, - Duration.getForever(), - ); - return { - keys: keysInfo, - }; -} - +/** + * Add an exchange entry to the wallet database in the + * entry state "preset". + * + * Returns the notification to the caller that should be emitted + * if the DB transaction succeeds. + */ export async function addPresetExchangeEntry( tx: WalletDbReadWriteTransaction<"exchanges">, exchangeBaseUrl: string, currencyHint?: string, -): Promise<void> { +): Promise<{ notification?: WalletNotification }> { let exchange = await tx.exchanges.get(exchangeBaseUrl); if (!exchange) { const r: ExchangeEntryRecord = { @@ -323,9 +331,22 @@ export async function addPresetExchangeEntry( nextUpdateStamp: timestampPreciseToDb( AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), ), + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + tosCurrentEtag: undefined, }; await tx.exchanges.put(r); + return { + notification: { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: exchangeBaseUrl, + // Exchange did not exist yet + oldExchangeState: undefined, + newExchangeState: getExchangeState(r), + }, + }; } + return {}; } async function provideExchangeRecordInTx( @@ -339,7 +360,9 @@ async function provideExchangeRecordInTx( ): Promise<{ exchange: ExchangeEntryRecord; exchangeDetails: ExchangeDetailsRecord | undefined; + notification?: WalletNotification; }> { + let notification: WalletNotification | undefined = undefined; let exchange = await tx.exchanges.get(baseUrl); if (!exchange) { const r: ExchangeEntryRecord = { @@ -355,15 +378,24 @@ async function provideExchangeRecordInTx( AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), ), lastKeysEtag: undefined, + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + tosCurrentEtag: undefined, }; await tx.exchanges.put(r); exchange = r; + notification = { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: r.baseUrl, + oldExchangeState: undefined, + newExchangeState: getExchangeState(r), + }; } const exchangeDetails = await getExchangeDetails(tx, baseUrl); - return { exchange, exchangeDetails }; + return { exchange, exchangeDetails, notification }; } -interface ExchangeKeysDownloadResult { +export interface ExchangeKeysDownloadResult { baseUrl: string; masterPublicKey: string; currency: string; @@ -393,28 +425,36 @@ async function downloadExchangeKeysInfo( const resp = await http.fetch(keysUrl.href, { timeout, }); - const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeKeysJson(), - ); - if (exchangeKeysJsonUnchecked.denominations.length === 0) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, - { - exchangeBaseUrl: baseUrl, - }, - "exchange doesn't offer any denominations", - ); - } + // We must make sure to parse out the protocol version + // before we validate the body. + // Otherwise the parser might complain with a hard to understand + // message about some other field, when it is just a version + // incompatibility. - const protocolVersion = exchangeKeysJsonUnchecked.version; + const keysJson = await resp.json(); + + const protocolVersion = keysJson.version; + if (typeof protocolVersion !== "string") { + throw Error("bad exchange, does not even specify protocol version"); + } const versionRes = LibtoolVersion.compare( WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion, ); - if (versionRes?.compatible != true) { + if (!versionRes) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: resp.requestUrl, + httpStatusCode: resp.status, + requestMethod: resp.requestMethod, + }, + "exchange protocol version malformed", + ); + } + if (!versionRes.compatible) { throw TalerError.fromDetail( TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE, { @@ -425,6 +465,21 @@ async function downloadExchangeKeysInfo( ); } + const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeKeysJson(), + ); + + if (exchangeKeysJsonUnchecked.denominations.length === 0) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, + { + exchangeBaseUrl: baseUrl, + }, + "exchange doesn't offer any denominations", + ); + } + const currency = exchangeKeysJsonUnchecked.currency; const currentDenominations: DenominationRecord[] = []; @@ -512,7 +567,7 @@ async function downloadExchangeKeysInfo( }; } -export async function downloadTosFromAcceptedFormat( +async function downloadTosFromAcceptedFormat( ws: InternalWalletState, baseUrl: string, timeout: Duration, @@ -546,54 +601,225 @@ export async function downloadTosFromAcceptedFormat( } /** - * FIXME: Split this into two parts: (a) triggering the exchange - * to be updated and (b) waiting for the update to finish. + * Transition an exchange into an updating state. + * + * If the update is forced, the exchange is put into an updating state + * even if the old information should still be up to date. + * + * For backwards compatibility, if the exchange entry doesn't exist, + * a new ephemeral entry is created. */ -export async function updateExchangeFromUrl( +export async function startUpdateExchangeEntry( ws: InternalWalletState, - baseUrl: string, - options: { - checkMasterPub?: string; - forceNow?: boolean; - cancellationToken?: CancellationToken; - } = {}, + exchangeBaseUrl: string, + options: { forceUpdate?: boolean } = {}, +): Promise<void> { + const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + + const now = AbsoluteTime.now(); + + const { notification } = await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails]) + .runReadWrite(async (tx) => { + return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now); + }); + + if (notification) { + ws.notify(notification); + } + + const { oldExchangeState, newExchangeState } = await ws.db + .mktx((x) => [x.exchanges, x.operationRetries]) + .runReadWrite(async (tx) => { + const r = await tx.exchanges.get(canonBaseUrl); + if (!r) { + throw Error("exchange not found"); + } + const oldExchangeState = getExchangeState(r); + switch (r.updateStatus) { + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + break; + case ExchangeEntryDbUpdateStatus.Suspended: + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + break; + case ExchangeEntryDbUpdateStatus.Ready: { + const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp( + timestampPreciseFromDb(r.nextUpdateStamp), + ); + // Only update if entry is outdated or update is forced. + if ( + options.forceUpdate || + AbsoluteTime.isExpired(nextUpdateTimestamp) + ) { + r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + } + break; + } + case ExchangeEntryDbUpdateStatus.Initial: + r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate; + break; + } + await tx.exchanges.put(r); + const newExchangeState = getExchangeState(r); + // Reset retries for updating the exchange entry. + const taskId = TaskIdentifiers.forExchangeUpdate(r); + await tx.operationRetries.delete(taskId); + return { oldExchangeState, newExchangeState }; + }); + ws.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: canonBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + }); + ws.workAvailable.trigger(); +} + +export interface NotificationWaiter { + waitNext(): Promise<void>; + cancel(): void; +} + +export function createNotificationWaiter( + ws: InternalWalletState, + pred: (x: WalletNotification) => boolean, +): NotificationWaiter { + ws.ensureTaskLoopRunning(); + let cancelFn: CancelFn | undefined = undefined; + let p: OpenedPromise<void> | undefined = undefined; + + return { + cancel() { + cancelFn?.(); + }, + waitNext(): Promise<void> { + if (!p) { + p = openPromise(); + cancelFn = ws.addNotificationListener((notif) => { + if (pred(notif)) { + // We got a notification that matches our predicate. + // Resolve promise for existing waiters, + // and create a new promise to wait for the next + // notification occurrence. + const myResolve = p?.resolve; + const myCancel = cancelFn; + p = undefined; + cancelFn = undefined; + myResolve?.(); + myCancel?.(); + } + }); + } + return p.promise; + }, + }; +} + +/** + * Wait until an exchange entry got successfully updated. + * + * Reject with an exception if the update encountered an error. + */ +export async function waitExchangeEntryUpdated( + ws: InternalWalletState, + exchangeBaseUrl: string, + cancellationToken?: CancellationToken, ): Promise<{ exchange: ExchangeEntryRecord; exchangeDetails: ExchangeDetailsRecord; }> { - const canonUrl = canonicalizeBaseUrl(baseUrl); - const res = await runTaskWithErrorReporting( + exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + + const waiter = createNotificationWaiter( ws, - TaskIdentifiers.forExchangeUpdateFromUrl(canonUrl), - () => updateExchangeFromUrlHandler(ws, canonUrl, options), + (notif) => + notif.type == NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === exchangeBaseUrl, ); - switch (res.type) { - case TaskRunResultType.Finished: { - const now = AbsoluteTime.now(); - const { exchange, exchangeDetails } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - let exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeDetails(tx, canonUrl); - return { exchange, exchangeDetails }; - }); - if (!exchange) { - throw Error("exchange not found"); - } - if (!exchangeDetails) { - throw Error("exchange details not found"); + + const taskId = TaskIdentifiers.forExchangeUpdateFromUrl(exchangeBaseUrl); + + while (1) { + const { exchange, retryRecord } = await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails, x.operationRetries]) + .runReadOnly(async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + const retryRecord = await tx.operationRetries.get(taskId); + return { exchange, retryRecord }; + }); + + if (!exchange) { + throw Error("exchange does not exist anymore"); + } + + switch (exchange.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + const details = await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails]) + .runReadOnly(async (tx) => { + return getExchangeDetails(tx, exchangeBaseUrl); + }); + if (!details) { + throw Error("exchange entry inconsistent"); + } + waiter.cancel(); + return { exchange, exchangeDetails: details }; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + case ExchangeEntryDbUpdateStatus.InitialUpdate: { + waiter.cancel(); + if (retryRecord?.lastError) { + throw TalerError.fromUncheckedDetail(retryRecord.lastError); + } + break; } - return { exchange, exchangeDetails }; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + waiter.cancel(); + if (retryRecord?.lastError) { + throw TalerError.fromUncheckedDetail(retryRecord.lastError); + } else { + throw Error( + "updating exchange failed, error info unavailable (bug!)", + ); + } } - case TaskRunResultType.Error: - throw TalerError.fromUncheckedDetail(res.errorDetail); - default: - throw Error(`unexpected operation result (${res.type})`); + + await waiter.waitNext(); } + throw Error("not reached"); } /** - * Update or add exchange DB entry by fetching the /keys and /wire information. + * Ensure that a fresh exchange entry exists for the given + * exchange base URL. + * + * The cancellation token can be used to abort waiting for the + * updated exchange entry. + * + * If an exchange entry for the database doesn't exist in the + * DB, it will be added ephemerally. + */ +export async function fetchFreshExchange( + ws: InternalWalletState, + baseUrl: string, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + } = {}, +): Promise<{ + exchange: ExchangeEntryRecord; + exchangeDetails: ExchangeDetailsRecord; +}> { + const canonUrl = canonicalizeBaseUrl(baseUrl); + await startUpdateExchangeEntry(ws, canonUrl, { + forceUpdate: options.forceUpdate, + }); + return waitExchangeEntryUpdated(ws, canonUrl, options.cancellationToken); +} + +/** + * Update an exchange entry in the wallet's database + * by fetching the /keys and /wire information. * Optionally link the reserve entry to the new or existing * exchange entry in then DB. */ @@ -601,48 +827,11 @@ export async function updateExchangeFromUrlHandler( ws: InternalWalletState, exchangeBaseUrl: string, options: { - checkMasterPub?: string; - forceNow?: boolean; cancellationToken?: CancellationToken; } = {}, ): Promise<TaskRunResult> { - const forceNow = options.forceNow ?? false; - logger.trace( - `updating exchange info for ${exchangeBaseUrl}, forced: ${forceNow}`, - ); - - const now = AbsoluteTime.now(); + logger.trace(`updating exchange info for ${exchangeBaseUrl}`); exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - let isNewExchange = true; - const { exchange, exchangeDetails } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - let oldExch = await tx.exchanges.get(exchangeBaseUrl); - if (oldExch) { - isNewExchange = false; - } - return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now); - }); - - if ( - !forceNow && - exchangeDetails !== undefined && - !AbsoluteTime.isExpired( - AbsoluteTime.fromPreciseTimestamp( - timestampPreciseFromDb(exchange.nextUpdateStamp), - ), - ) - ) { - logger.trace("using existing exchange info"); - - if (options.checkMasterPub) { - if (exchangeDetails.masterPublicKey !== options.checkMasterPub) { - throw Error(`master public key mismatch`); - } - } - - return TaskRunResult.finished(); - } logger.trace("updating exchange /keys info"); @@ -654,12 +843,6 @@ export async function updateExchangeFromUrlHandler( timeout, ); - if (options.checkMasterPub) { - if (keysInfo.masterPublicKey !== options.checkMasterPub) { - throw Error(`master public key mismatch`); - } - } - logger.trace("validating exchange wire info"); const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion); @@ -740,6 +923,7 @@ export async function updateExchangeFromUrlHandler( logger.warn(`exchange ${exchangeBaseUrl} no longer present`); return; } + const oldExchangeState = getExchangeState(r); const existingDetails = await getExchangeDetails(tx, r.baseUrl); if (!existingDetails) { detailsPointerChanged = true; @@ -753,7 +937,6 @@ export async function updateExchangeFromUrlHandler( } // FIXME: We need to do some consistency checks! } - const existingTosAccepted = existingDetails?.tosAccepted; const newDetails: ExchangeDetailsRecord = { auditors: keysInfo.auditors, currency: keysInfo.currency, @@ -763,10 +946,9 @@ export async function updateExchangeFromUrlHandler( globalFees, exchangeBaseUrl: r.baseUrl, wireInfo, - tosCurrentEtag: tosDownload.tosEtag, - tosAccepted: existingTosAccepted, ageMask, }; + r.tosCurrentEtag = tosDownload.tosEtag; if (existingDetails?.rowId) { newDetails.rowId = existingDetails.rowId; } @@ -787,6 +969,7 @@ export async function updateExchangeFromUrlHandler( updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()), }; } + r.updateStatus = ExchangeEntryDbUpdateStatus.Ready; await tx.exchanges.put(r); const drRowId = await tx.exchangeDetails.put(newDetails); checkDbInvariant(typeof drRowId.key === "number"); @@ -881,14 +1064,18 @@ export async function updateExchangeFromUrlHandler( recoupGroupId = await ws.recoupOps.createRecoupGroup( ws, tx, - exchange.baseUrl, + exchangeBaseUrl, newlyRevokedCoinPubs, ); } + const newExchangeState = getExchangeState(r); + return { exchange: r, exchangeDetails: newDetails, + oldExchangeState, + newExchangeState, }; }); @@ -904,11 +1091,12 @@ export async function updateExchangeFromUrlHandler( logger.trace("done updating exchange info in database"); - if (isNewExchange) { - ws.notify({ - type: NotificationType.ExchangeAdded, - }); - } + ws.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: updated.newExchangeState, + oldExchangeState: updated.oldExchangeState, + }); return TaskRunResult.finished(); } @@ -926,8 +1114,8 @@ export async function getExchangePaytoUri( ): Promise<string> { // We do the update here, since the exchange might not even exist // yet in our database. - const details = await getExchangeDetails - .makeContext(ws.db) + const details = await ws.db + .mktx((x) => [x.exchangeDetails, x.exchanges]) .runReadOnly(async (tx) => { return getExchangeDetails(tx, exchangeBaseUrl); }); @@ -947,3 +1135,246 @@ export async function getExchangePaytoUri( )}`, ); } + +/** + * Get the exchange ToS in the requested format. + * Try to download in the accepted format not cached. + */ +export async function getExchangeTos( + ws: InternalWalletState, + exchangeBaseUrl: string, + acceptedFormat?: string[], +): Promise<GetExchangeTosResult> { + // FIXME: download ToS in acceptable format if passed! + const { exchange, exchangeDetails } = await fetchFreshExchange( + ws, + exchangeBaseUrl, + ); + + const tosDownload = await downloadTosFromAcceptedFormat( + ws, + exchangeBaseUrl, + getExchangeRequestTimeout(), + acceptedFormat, + ); + + await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails]) + .runReadWrite(async (tx) => { + const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl); + if (updateExchangeEntry) { + updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag; + await tx.exchanges.put(updateExchangeEntry); + } + }); + + return { + acceptedEtag: exchange.tosAcceptedEtag, + currentEtag: tosDownload.tosEtag, + content: tosDownload.tosText, + contentType: tosDownload.tosContentType, + tosStatus: getExchangeTosStatusFromRecord(exchange), + }; +} + +export interface ExchangeInfo { + keys: ExchangeKeysDownloadResult; +} + +/** + * Helper function to download the exchange /keys info. + * + * Only used for testing / dbless wallet. + */ +export async function downloadExchangeInfo( + exchangeBaseUrl: string, + http: HttpRequestLibrary, +): Promise<ExchangeInfo> { + const keysInfo = await downloadExchangeKeysInfo( + exchangeBaseUrl, + http, + Duration.getForever(), + ); + return { + keys: keysInfo, + }; +} + +export async function getExchanges( + ws: InternalWalletState, +): Promise<ExchangesListResponse> { + const exchanges: ExchangeListItem[] = []; + await ws.db + .mktx((x) => [ + x.exchanges, + x.exchangeDetails, + x.denominations, + x.operationRetries, + ]) + .runReadOnly(async (tx) => { + const exchangeRecords = await tx.exchanges.iter().toArray(); + for (const r of exchangeRecords) { + const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); + const opRetryRecord = await tx.operationRetries.get( + TaskIdentifiers.forExchangeUpdate(r), + ); + exchanges.push( + makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError), + ); + } + }); + return { exchanges }; +} + +export async function getExchangeDetailedInfo( + ws: InternalWalletState, + exchangeBaseurl: string, +): Promise<ExchangeDetailedResponse> { + //TODO: should we use the forceUpdate parameter? + const exchange = await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations]) + .runReadOnly(async (tx) => { + const ex = await tx.exchanges.get(exchangeBaseurl); + const dp = ex?.detailsPointer; + if (!dp) { + return; + } + const { currency } = dp; + const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl); + if (!exchangeDetails) { + return; + } + + const denominationRecords = + await tx.denominations.indexes.byExchangeBaseUrl + .iter(ex.baseUrl) + .toArray(); + + if (!denominationRecords) { + return; + } + + const denominations: DenominationInfo[] = denominationRecords.map((x) => + DenominationRecord.toDenomInfo(x), + ); + + return { + info: { + exchangeBaseUrl: ex.baseUrl, + currency, + paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), + auditors: exchangeDetails.auditors, + wireInfo: exchangeDetails.wireInfo, + globalFees: exchangeDetails.globalFees, + }, + denominations, + }; + }); + + if (!exchange) { + throw Error(`exchange with base url "${exchangeBaseurl}" not found`); + } + + const denoms = exchange.denominations.map((d) => ({ + ...d, + group: Amounts.stringifyValue(d.value), + })); + const denomFees: DenomOperationMap<FeeDescription[]> = { + deposit: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireDeposit", + "feeDeposit", + "group", + selectBestForOverlappingDenominations, + ), + refresh: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeRefresh", + "group", + selectBestForOverlappingDenominations, + ), + refund: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeRefund", + "group", + selectBestForOverlappingDenominations, + ), + withdraw: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeWithdraw", + "group", + selectBestForOverlappingDenominations, + ), + }; + + const transferFees = Object.entries( + exchange.info.wireInfo.feesForType, + ).reduce((prev, [wireType, infoForType]) => { + const feesByGroup = [ + ...infoForType.map((w) => ({ + ...w, + fee: Amounts.stringify(w.closingFee), + group: "closing", + })), + ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), + ]; + prev[wireType] = createTimeline( + feesByGroup, + "sig", + "startStamp", + "endStamp", + "fee", + "group", + selectMinimumFee, + ); + return prev; + }, {} as Record<string, FeeDescription[]>); + + const globalFeesByGroup = [ + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.accountFee, + group: "account", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.historyFee, + group: "history", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.purseFee, + group: "purse", + })), + ]; + + const globalFees = createTimeline( + globalFeesByGroup, + "signature", + "startDate", + "endDate", + "fee", + "group", + selectMinimumFee, + ); + + return { + exchange: { + ...exchange.info, + denomFees, + transferFees, + globalFees, + }, + }; +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts index 44c9436b1..f8ab07b10 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts @@ -63,7 +63,7 @@ import { timestampOptionalPreciseFromDb, timestampPreciseFromDb, timestampPreciseToDb, - updateExchangeFromUrl, + fetchFreshExchange, } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { PendingTaskType } from "../pending-types.js"; @@ -764,7 +764,7 @@ export async function initiatePeerPullPayment( const exchangeBaseUrl = maybeExchangeBaseUrl; - await updateExchangeFromUrl(ws, exchangeBaseUrl); + await fetchFreshExchange(ws, exchangeBaseUrl); const mergeReserveInfo = await getMergeReserveInfo(ws, { exchangeBaseUrl: exchangeBaseUrl, diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts index 690edf2e7..575780ba4 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts @@ -69,7 +69,7 @@ import { constructTaskIdentifier, runLongpollAsync, } from "./common.js"; -import { updateExchangeFromUrl } from "./exchanges.js"; +import { fetchFreshExchange } from "./exchanges.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, @@ -141,7 +141,7 @@ export async function preparePeerPushCredit( const exchangeBaseUrl = uri.exchangeBaseUrl; - await updateExchangeFromUrl(ws, exchangeBaseUrl); + await fetchFreshExchange(ws, exchangeBaseUrl); const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index 282f84ad7..a9d6c5595 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -22,12 +22,17 @@ * Imports. */ import { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { AbsoluteTime, TransactionRecordFilter } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + TalerErrorDetail, + TalerPreciseTimestamp, + TransactionRecordFilter, +} from "@gnu-taler/taler-util"; import { BackupProviderStateTag, + DbPreciseTimestamp, DepositElementStatus, DepositGroupRecord, - DepositOperationStatus, ExchangeEntryDbUpdateStatus, PeerPullCreditRecord, PeerPullDebitRecordStatus, @@ -48,7 +53,6 @@ import { RewardRecordStatus, WalletStoresV1, WithdrawalGroupRecord, - WithdrawalGroupStatus, depositOperationNonfinalStatusRange, timestampAbsoluteFromDb, timestampOptionalAbsoluteFromDb, @@ -94,18 +98,29 @@ async function gatherExchangePending( now: AbsoluteTime, resp: PendingOperationsResponse, ): Promise<void> { - // FIXME: We should do a range query here based on the update time - // and/or the entry state. + let timestampDue: DbPreciseTimestamp | undefined = undefined; await tx.exchanges.iter().forEachAsync(async (exch) => { switch (exch.updateStatus) { case ExchangeEntryDbUpdateStatus.Initial: case ExchangeEntryDbUpdateStatus.Suspended: - case ExchangeEntryDbUpdateStatus.Failed: return; } const opUpdateExchangeTag = TaskIdentifiers.forExchangeUpdate(exch); let opr = await tx.operationRetries.get(opUpdateExchangeTag); - const timestampDue = opr?.retryInfo.nextRetry ?? exch.nextRefreshCheckStamp; + + switch (exch.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + timestampDue = opr?.retryInfo.nextRetry ?? exch.nextRefreshCheckStamp; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + case ExchangeEntryDbUpdateStatus.InitialUpdate: + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + timestampDue = + opr?.retryInfo.nextRetry ?? + timestampPreciseToDb(TalerPreciseTimestamp.now()); + break; + } + resp.pendingOperations.push({ type: PendingTaskType.ExchangeUpdate, ...getPendingCommon( diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 3afdd2d71..51dd9adac 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -98,7 +98,11 @@ import { TaskRunResult, TaskRunResultType, } from "./common.js"; -import { updateExchangeFromUrl } from "./exchanges.js"; +import { + fetchFreshExchange, + startUpdateExchangeEntry, + waitExchangeEntryUpdated, +} from "./exchanges.js"; import { constructTransactionIdentifier, notifyTransition, @@ -221,7 +225,7 @@ async function provideRefreshSession( const { refreshGroup, coin } = d; - const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); + const { exchange } = await fetchFreshExchange(ws, coin.exchangeBaseUrl); if (!exchange) { throw Error("db inconsistent: exchange of coin not found"); } @@ -1157,9 +1161,7 @@ export async function autoRefresh( // We must make sure that the exchange is up-to-date so that // can refresh into new denominations. - await updateExchangeFromUrl(ws, exchangeBaseUrl, { - forceNow: true, - }); + await fetchFreshExchange(ws, exchangeBaseUrl); let minCheckThreshold = AbsoluteTime.addDuration( AbsoluteTime.now(), diff --git a/packages/taler-wallet-core/src/operations/reward.ts b/packages/taler-wallet-core/src/operations/reward.ts index 5d609f41d..90320d7cb 100644 --- a/packages/taler-wallet-core/src/operations/reward.ts +++ b/packages/taler-wallet-core/src/operations/reward.ts @@ -69,7 +69,7 @@ import { TaskRunResult, TaskRunResultType, } from "./common.js"; -import { updateExchangeFromUrl } from "./exchanges.js"; +import { fetchFreshExchange } from "./exchanges.js"; import { getCandidateWithdrawalDenoms, getExchangeWithdrawalInfo, @@ -175,7 +175,7 @@ export async function prepareTip( const amount = Amounts.parseOrThrow(tipPickupStatus.reward_amount); logger.trace("new tip, creating tip record"); - await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url); + await fetchFreshExchange(ws, tipPickupStatus.exchange_url); //FIXME: is this needed? withdrawDetails is not used // * if the intention is to update the exchange information in the database diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index b30c5f80b..a03d54d3a 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -58,7 +58,7 @@ import { OpenedPromise, openPromise } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { getBalances } from "./balance.js"; -import { updateExchangeFromUrl } from "./exchanges.js"; +import { fetchFreshExchange } from "./exchanges.js"; import { confirmPay, preparePayForUri, @@ -579,7 +579,7 @@ export async function runIntegrationTest2( // waiting for notifications. logger.info("running test with arguments", args); - const exchangeInfo = await updateExchangeFromUrl(ws, args.exchangeBaseUrl); + const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl); const currency = exchangeInfo.exchangeDetails.currency; diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index b9ba3058f..e7ba6d820 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -135,7 +135,7 @@ import { import { getExchangeDetails, getExchangePaytoUri, - updateExchangeFromUrl, + fetchFreshExchange, } from "./exchanges.js"; import { TransitionInfo, @@ -1862,8 +1862,8 @@ export async function getExchangeWithdrawalInfo( } let tosAccepted = false; - if (exchangeDetails.tosAccepted?.timestamp) { - if (exchangeDetails.tosAccepted.etag === exchangeDetails.tosCurrentEtag) { + if (exchange.tosAcceptedTimestamp) { + if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) { tosAccepted = true; } } @@ -2372,7 +2372,7 @@ export async function internalPrepareCreateWithdrawalGroup( wgInfo: args.wgInfo, }; - const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange); + const exchangeInfo = await fetchFreshExchange(ws, canonExchange); const exchangeDetails = exchangeInfo.exchangeDetails; const transactionId = constructTransactionIdentifier({ tag: TransactionType.Withdrawal, @@ -2515,7 +2515,7 @@ export async function acceptWithdrawalFromUri( }; } - await updateExchangeFromUrl(ws, selectedExchange); + await fetchFreshExchange(ws, selectedExchange); const withdrawInfo = await getBankWithdrawalInfo( ws.http, req.talerWithdrawUri, diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 25cfd7f6f..c9612da5f 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -33,17 +33,10 @@ import { CoreApiResponse, CreateStoredBackupResponse, DeleteStoredBackupRequest, - DenomOperationMap, DenominationInfo, Duration, - ExchangeDetailedResponse, - ExchangeListItem, - ExchangesListResponse, ExchangesShortListResponse, - FeeDescription, GetCurrencySpecificationResponse, - GetExchangeEntryByUrlResponse, - GetExchangeTosResult, InitResponse, KnownBankAccounts, KnownBankAccountsInfo, @@ -194,7 +187,6 @@ import { getBalanceDetail, getBalances } from "./operations/balance.js"; import { TaskIdentifiers, TaskRunResult, - getExchangeTosStatus, makeExchangeListItem, runTaskWithErrorReporting, } from "./operations/common.js"; @@ -208,10 +200,11 @@ import { import { acceptExchangeTermsOfService, addPresetExchangeEntry, - downloadTosFromAcceptedFormat, + fetchFreshExchange, + getExchangeDetailedInfo, getExchangeDetails, - getExchangeRequestTimeout, - updateExchangeFromUrl, + getExchangeTos, + getExchanges, updateExchangeFromUrlHandler, } from "./operations/exchanges.js"; import { getMerchantInfo } from "./operations/merchants.js"; @@ -296,11 +289,6 @@ import { import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; import { - createTimeline, - selectBestForOverlappingDenominations, - selectMinimumFee, -} from "./util/denominations.js"; -import { convertDepositAmount, convertPeerPushAmount, convertWithdrawalAmount, @@ -520,12 +508,13 @@ async function runTaskLoop( continue; } logger.trace(`running task ${p.id}`); - await runTaskWithErrorReporting(ws, p.id, async () => { + const res = await runTaskWithErrorReporting(ws, p.id, async () => { return await callOperationHandler(ws, p); }); ws.notify({ type: NotificationType.PendingOperationProcessed, id: p.id, + taskResultType: res.type, }); if (ws.stopped) { ws.isTaskLoopRunning = false; @@ -549,6 +538,7 @@ async function runTaskLoop( * already been applied. */ async function fillDefaults(ws: InternalWalletState): Promise<void> { + const notifications: WalletNotification[] = []; await ws.db .mktx((x) => [x.config, x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { @@ -559,55 +549,23 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> { return; } for (const exch of ws.config.builtin.exchanges) { - await addPresetExchangeEntry( + const resp = await addPresetExchangeEntry( tx, exch.exchangeBaseUrl, exch.currencyHint, ); + if (resp.notification) { + notifications.push(resp.notification); + } } await tx.config.put({ key: ConfigRecordKey.CurrencyDefaultsApplied, value: true, }); }); -} - -/** - * Get the exchange ToS in the requested format. - * Try to download in the accepted format not cached. - */ -async function getExchangeTos( - ws: InternalWalletState, - exchangeBaseUrl: string, - acceptedFormat?: string[], -): Promise<GetExchangeTosResult> { - // FIXME: download ToS in acceptable format if passed! - const { exchangeDetails } = await updateExchangeFromUrl(ws, exchangeBaseUrl); - - const tosDownload = await downloadTosFromAcceptedFormat( - ws, - exchangeBaseUrl, - getExchangeRequestTimeout(), - acceptedFormat, - ); - - await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - const d = await getExchangeDetails(tx, exchangeBaseUrl); - if (d) { - d.tosCurrentEtag = tosDownload.tosEtag; - await tx.exchangeDetails.put(d); - } - }); - - return { - acceptedEtag: exchangeDetails.tosAccepted?.etag, - currentEtag: tosDownload.tosEtag, - content: tosDownload.tosText, - contentType: tosDownload.tosContentType, - tosStatus: getExchangeTosStatus(exchangeDetails), - }; + for (const notif of notifications) { + ws.notify(notif); + } } /** @@ -680,185 +638,6 @@ async function forgetKnownBankAccounts( return; } -async function getExchanges( - ws: InternalWalletState, -): Promise<ExchangesListResponse> { - const exchanges: ExchangeListItem[] = []; - await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.operationRetries, - ]) - .runReadOnly(async (tx) => { - const exchangeRecords = await tx.exchanges.iter().toArray(); - for (const r of exchangeRecords) { - const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); - const opRetryRecord = await tx.operationRetries.get( - TaskIdentifiers.forExchangeUpdate(r), - ); - exchanges.push( - makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError), - ); - } - }); - return { exchanges }; -} - -async function getExchangeDetailedInfo( - ws: InternalWalletState, - exchangeBaseurl: string, -): Promise<ExchangeDetailedResponse> { - //TODO: should we use the forceUpdate parameter? - const exchange = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations]) - .runReadOnly(async (tx) => { - const ex = await tx.exchanges.get(exchangeBaseurl); - const dp = ex?.detailsPointer; - if (!dp) { - return; - } - const { currency } = dp; - const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl); - if (!exchangeDetails) { - return; - } - - const denominationRecords = - await tx.denominations.indexes.byExchangeBaseUrl - .iter(ex.baseUrl) - .toArray(); - - if (!denominationRecords) { - return; - } - - const denominations: DenominationInfo[] = denominationRecords.map((x) => - DenominationRecord.toDenomInfo(x), - ); - - return { - info: { - exchangeBaseUrl: ex.baseUrl, - currency, - paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), - auditors: exchangeDetails.auditors, - wireInfo: exchangeDetails.wireInfo, - globalFees: exchangeDetails.globalFees, - }, - denominations, - }; - }); - - if (!exchange) { - throw Error(`exchange with base url "${exchangeBaseurl}" not found`); - } - - const denoms = exchange.denominations.map((d) => ({ - ...d, - group: Amounts.stringifyValue(d.value), - })); - const denomFees: DenomOperationMap<FeeDescription[]> = { - deposit: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ), - refresh: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireWithdraw", - "feeRefresh", - "group", - selectBestForOverlappingDenominations, - ), - refund: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireWithdraw", - "feeRefund", - "group", - selectBestForOverlappingDenominations, - ), - withdraw: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireWithdraw", - "feeWithdraw", - "group", - selectBestForOverlappingDenominations, - ), - }; - - const transferFees = Object.entries( - exchange.info.wireInfo.feesForType, - ).reduce((prev, [wireType, infoForType]) => { - const feesByGroup = [ - ...infoForType.map((w) => ({ - ...w, - fee: Amounts.stringify(w.closingFee), - group: "closing", - })), - ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), - ]; - prev[wireType] = createTimeline( - feesByGroup, - "sig", - "startStamp", - "endStamp", - "fee", - "group", - selectMinimumFee, - ); - return prev; - }, {} as Record<string, FeeDescription[]>); - - const globalFeesByGroup = [ - ...exchange.info.globalFees.map((w) => ({ - ...w, - fee: w.accountFee, - group: "account", - })), - ...exchange.info.globalFees.map((w) => ({ - ...w, - fee: w.historyFee, - group: "history", - })), - ...exchange.info.globalFees.map((w) => ({ - ...w, - fee: w.purseFee, - group: "purse", - })), - ]; - - const globalFees = createTimeline( - globalFeesByGroup, - "signature", - "startDate", - "endDate", - "fee", - "group", - selectMinimumFee, - ); - - return { - exchange: { - ...exchange.info, - denomFees, - transferFees, - globalFees, - }, - }; -} - async function setCoinSuspended( ws: InternalWalletState, coinPub: string, @@ -1059,7 +838,7 @@ async function handlePrepareWithdrawExchange( throw Error("expected a taler://withdraw-exchange URI"); } const exchangeBaseUrl = parsedUri.exchangeBaseUrl; - const exchange = await updateExchangeFromUrl(ws, exchangeBaseUrl); + const exchange = await fetchFreshExchange(ws, exchangeBaseUrl); if (exchange.exchangeDetails.masterPublicKey != parsedUri.exchangePub) { throw Error("mismatch of exchange master public key (URI vs actual)"); } @@ -1166,15 +945,14 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( } case WalletApiOperation.AddExchange: { const req = codecForAddExchangeRequest().decode(payload); - await updateExchangeFromUrl(ws, req.exchangeBaseUrl, { - checkMasterPub: req.masterPub, - forceNow: req.forceUpdate, - }); + await fetchFreshExchange(ws, req.exchangeBaseUrl); return {}; } case WalletApiOperation.UpdateExchangeEntry: { const req = codecForUpdateExchangeEntryRequest().decode(payload); - await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {}); + await fetchFreshExchange(ws, req.exchangeBaseUrl, { + forceUpdate: true, + }); return {}; } case WalletApiOperation.ListExchanges: { @@ -1896,7 +1674,7 @@ class InternalWalletStateImpl implements InternalWalletState { exchangeOps: ExchangeOperations = { getExchangeDetails, - updateExchangeFromUrl, + updateExchangeFromUrl: fetchFreshExchange, }; recoupOps: RecoupOperations = { |