From 02f1d4b08116c24f0af1f32cb6d82be292fa6d10 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 2 Jun 2021 13:23:51 +0200 Subject: support multiple exchange details per base URL --- packages/taler-util/src/backupTypes.ts | 34 +- packages/taler-wallet-cli/src/index.ts | 4 +- packages/taler-wallet-core/src/db.ts | 135 ++--- .../src/operations/backup/export.ts | 53 +- .../src/operations/backup/import.ts | 116 ++-- .../src/operations/backup/index.ts | 6 +- .../taler-wallet-core/src/operations/currencies.ts | 8 +- .../taler-wallet-core/src/operations/deposits.ts | 14 +- .../taler-wallet-core/src/operations/exchanges.ts | 627 +++++++++++---------- packages/taler-wallet-core/src/operations/pay.ts | 21 +- .../taler-wallet-core/src/operations/pending.ts | 36 +- .../taler-wallet-core/src/operations/recoup.ts | 49 +- .../taler-wallet-core/src/operations/refresh.ts | 2 +- .../taler-wallet-core/src/operations/reserves.ts | 25 +- .../src/operations/transactions.ts | 11 +- .../taler-wallet-core/src/operations/withdraw.ts | 91 ++- packages/taler-wallet-core/src/wallet.ts | 63 ++- 17 files changed, 699 insertions(+), 596 deletions(-) (limited to 'packages') diff --git a/packages/taler-util/src/backupTypes.ts b/packages/taler-util/src/backupTypes.ts index ce2eb6b77..dc344ee23 100644 --- a/packages/taler-util/src/backupTypes.ts +++ b/packages/taler-util/src/backupTypes.ts @@ -128,6 +128,8 @@ export interface WalletBackupContentV1 { */ exchanges: BackupExchange[]; + exchange_details: BackupExchangeDetails[]; + /** * Grouped refresh sessions. * @@ -1090,9 +1092,34 @@ export class BackupExchangeAuditor { } /** - * Backup information about an exchange. + * Backup information for an exchange. Serves effectively + * as a pointer to the exchange details identified by + * the base URL, master public key and currency. */ export interface BackupExchange { + base_url: string; + + master_public_key: string; + + currency: string; + + /** + * Time when the pointer to the exchange details + * was last updated. + * + * Used to facilitate automatic merging. + */ + update_clock: Timestamp; +} + +/** + * Backup information about an exchange's details. + * + * Note that one base URL can have multiple exchange + * details. The BackupExchange stores a pointer + * to the current exchange details. + */ +export interface BackupExchangeDetails { /** * Canonicalized base url of the exchange. */ @@ -1158,11 +1185,6 @@ export interface BackupExchange { * ETag for last terms of service download. */ tos_etag_accepted: string | undefined; - - /** - * Should this exchange be considered defective? - */ - defective?: boolean; } export enum BackupProposalStatus { diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index aad034b87..db6c0a9f3 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -598,10 +598,10 @@ advancedCli }) .action(async (args) => { await withWallet(args, async (wallet) => { - const exchange = await wallet.updateExchangeFromUrl( + const { exchange, exchangeDetails } = await wallet.updateExchangeFromUrl( args.withdrawManually.exchange, ); - const acct = exchange.wireInfo?.accounts[0]; + const acct = exchangeDetails.wireInfo.accounts[0]; if (!acct) { console.log("exchange has no accounts"); return; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 0ff34d3c7..c457d0ffc 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -513,26 +513,43 @@ export interface DenominationRecord { exchangeBaseUrl: string; } -/** - * Details about the exchange that we only know after - * querying /keys and /wire. - */ -export interface ExchangeDetails { +export enum ExchangeUpdateStatus { + FetchKeys = "fetch-keys", + FetchWire = "fetch-wire", + FetchTerms = "fetch-terms", + FinalizeUpdate = "finalize-update", + Finished = "finished", +} + +export interface ExchangeBankAccount { + payto_uri: string; + master_sig: string; +} + +export enum ExchangeUpdateReason { + Initial = "initial", + Forced = "forced", + Scheduled = "scheduled", +} + +export interface ExchangeDetailsRecord { /** * Master public key of the exchange. */ masterPublicKey: string; - /** - * Auditors (partially) auditing the exchange. - */ - auditors: Auditor[]; + exchangeBaseUrl: string; /** * Currency that the exchange offers. */ currency: string; + /** + * Auditors (partially) auditing the exchange. + */ + auditors: Auditor[]; + /** * Last observed protocol version. */ @@ -546,6 +563,23 @@ export interface ExchangeDetails { */ signingKeys: ExchangeSignKeyJson[]; + /** + * Terms of service text or undefined if not downloaded yet. + * + * This is just used as a cache of the last downloaded ToS. + */ + termsOfServiceText: string | undefined; + + /** + * ETag for last terms of service download. + */ + termsOfServiceLastEtag: string | undefined; + + /** + * ETag for last terms of service download. + */ + termsOfServiceAcceptedEtag: string | undefined; + /** * Timestamp for last update. */ @@ -555,30 +589,25 @@ export interface ExchangeDetails { * When should we next update the information about the exchange? */ nextUpdateTime: Timestamp; -} -export enum ExchangeUpdateStatus { - FetchKeys = "fetch-keys", - FetchWire = "fetch-wire", - FetchTerms = "fetch-terms", - FinalizeUpdate = "finalize-update", - Finished = "finished", + wireInfo: WireInfo; } -export interface ExchangeBankAccount { - payto_uri: string; - master_sig: string; -} - -export interface ExchangeWireInfo { +export interface WireInfo { feesForType: { [wireMethod: string]: WireFee[] }; + accounts: ExchangeBankAccount[]; } -export enum ExchangeUpdateReason { - Initial = "initial", - Forced = "forced", - Scheduled = "scheduled", +export interface ExchangeDetailsPointer { + masterPublicKey: string; + currency: string; + + /** + * Timestamp when the (masterPublicKey, currency) pointer + * has been updated. + */ + updateClock: Timestamp; } /** @@ -590,48 +619,13 @@ export interface ExchangeRecord { */ baseUrl: string; - /** - * Did we finish adding the exchange? - */ - addComplete: boolean; + detailsPointer: ExchangeDetailsPointer | undefined; /** * Is this a permanent or temporary exchange record? */ permanent: boolean; - /** - * Was the exchange added as a built-in exchange? - */ - builtIn: boolean; - - /** - * Details, once known. - */ - details: ExchangeDetails | undefined; - - /** - * Mapping from wire method type to the wire fee. - */ - wireInfo: ExchangeWireInfo | undefined; - - /** - * Terms of service text or undefined if not downloaded yet. - * - * This is just used as a cache of the last downloaded ToS. - */ - termsOfServiceText: string | undefined; - - /** - * ETag for last terms of service download. - */ - termsOfServiceLastEtag: string | undefined; - - /** - * ETag for last terms of service download. - */ - termsOfServiceAcceptedEtag: string | undefined; - /** * Time when the update to the exchange has been started or * undefined if no update is in progress. @@ -640,6 +634,9 @@ export interface ExchangeRecord { /** * Status of updating the info about the exchange. + * + * FIXME: Adapt this to recent changes regarding how + * updating exchange details works. */ updateStatus: ExchangeUpdateStatus; @@ -1548,7 +1545,7 @@ export interface BackupProviderTerms { export interface BackupProviderRecord { /** * Base URL of the provider. - * + * * Primary key for the record. */ baseUrl: string; @@ -1692,6 +1689,17 @@ class ExchangesStore extends Store<"exchanges", ExchangeRecord> { } } +class ExchangeDetailsStore extends Store< + "exchangeDetails", + ExchangeDetailsRecord +> { + constructor() { + super("exchangeDetails", { + keyPath: ["exchangeBaseUrl", "currency", "masterPublicKey"], + }); + } +} + class CoinsStore extends Store<"coins", CoinRecord> { constructor() { super("coins", { keyPath: "coinPub" }); @@ -1924,6 +1932,7 @@ export const Stores = { exchangeTrustStore: new ExchangeTrustStore(), denominations: new DenominationsStore(), exchanges: new ExchangesStore(), + exchangeDetails: new ExchangeDetailsStore(), proposals: new ProposalsStore(), refreshGroups: new Store<"refreshGroups", RefreshGroupRecord>( "refreshGroups", diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index 70d249ab8..fa0af1b07 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -47,6 +47,7 @@ import { BackupProposalStatus, BackupRefreshOldCoin, BackupRefreshSession, + BackupExchangeDetails, } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../state"; import { @@ -65,6 +66,7 @@ import { } from "../../db.js"; import { encodeCrock, stringToBytes, getRandomBytes } from "../../index.js"; import { canonicalizeBaseUrl, canonicalJson } from "@gnu-taler/taler-util"; +import { getExchangeDetails } from "../exchanges.js"; export async function exportBackup( ws: InternalWalletState, @@ -74,6 +76,7 @@ export async function exportBackup( [ Stores.config, Stores.exchanges, + Stores.exchangeDetails, Stores.coins, Stores.denominations, Stores.purchases, @@ -88,6 +91,7 @@ export async function exportBackup( async (tx) => { const bs = await getWalletBackupState(ws, tx); + const backupExchangeDetails: BackupExchangeDetails[] = []; const backupExchanges: BackupExchange[] = []; const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {}; const backupDenominationsByExchange: { @@ -254,21 +258,22 @@ export async function exportBackup( }); }); - await tx.iter(Stores.exchanges).forEach((ex) => { - // Only back up permanently added exchanges. - - if (!ex.details) { - return; - } - if (!ex.wireInfo) { - return; - } - if (!ex.addComplete) { - return; - } - if (!ex.permanent) { + await tx.iter(Stores.exchanges).forEachAsync(async (ex) => { + const dp = ex.detailsPointer; + if (!dp) { return; } + backupExchanges.push({ + base_url: ex.baseUrl, + currency: dp.currency, + master_public_key: dp.masterPublicKey, + update_clock: dp.updateClock, + }); + }); + + await tx.iter(Stores.exchangeDetails).forEachAsync(async (ex) => { + // Only back up permanently added exchanges. + const wi = ex.wireInfo; const wireFees: BackupExchangeWireFee[] = []; @@ -285,23 +290,23 @@ export async function exportBackup( } }); - backupExchanges.push({ - base_url: ex.baseUrl, - reserve_closing_delay: ex.details.reserveClosingDelay, + backupExchangeDetails.push({ + base_url: ex.exchangeBaseUrl, + reserve_closing_delay: ex.reserveClosingDelay, accounts: ex.wireInfo.accounts.map((x) => ({ payto_uri: x.payto_uri, master_sig: x.master_sig, })), - auditors: ex.details.auditors.map((x) => ({ + auditors: ex.auditors.map((x) => ({ auditor_pub: x.auditor_pub, auditor_url: x.auditor_url, denomination_keys: x.denomination_keys, })), - master_public_key: ex.details.masterPublicKey, - currency: ex.details.currency, - protocol_version: ex.details.protocolVersion, + master_public_key: ex.masterPublicKey, + currency: ex.currency, + protocol_version: ex.protocolVersion, wire_fees: wireFees, - signing_keys: ex.details.signingKeys.map((x) => ({ + signing_keys: ex.signingKeys.map((x) => ({ key: x.key, master_sig: x.master_sig, stamp_end: x.stamp_end, @@ -310,8 +315,9 @@ export async function exportBackup( })), tos_etag_accepted: ex.termsOfServiceAcceptedEtag, tos_etag_last: ex.termsOfServiceLastEtag, - denominations: backupDenominationsByExchange[ex.baseUrl] ?? [], - reserves: backupReservesByExchange[ex.baseUrl] ?? [], + denominations: + backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [], + reserves: backupReservesByExchange[ex.exchangeBaseUrl] ?? [], }); }); @@ -451,6 +457,7 @@ export async function exportBackup( schema_id: "gnu-taler-wallet-backup-content", schema_version: 1, exchanges: backupExchanges, + exchange_details: backupExchangeDetails, wallet_root_pub: bs.walletRootPub, backup_providers: backupBackupProviders, current_device_id: bs.deviceId, diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 1bbba6e26..f0a944a22 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -32,7 +32,6 @@ import { Stores, WalletContractData, DenomSelectionState, - ExchangeWireInfo, ExchangeUpdateStatus, DenominationStatus, CoinSource, @@ -46,6 +45,7 @@ import { RefundState, AbortStatus, RefreshSessionRecord, + WireInfo, } from "../../db.js"; import { TransactionHandle } from "../../index.js"; import { PayCoinSelection } from "../../util/coinSelection"; @@ -56,6 +56,7 @@ import { initRetryInfo } from "../../util/retries"; import { InternalWalletState } from "../state"; import { provideBackupState } from "./state"; import { makeEventId, TombstoneTag } from "../transactions.js"; +import { getExchangeDetails } from "../exchanges.js"; const logger = new Logger("operations/backup/import.ts"); @@ -102,13 +103,13 @@ async function recoverPayCoinSelection( totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount; if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) { - const exchange = await tx.get( - Stores.exchanges, + const exchangeDetails = await getExchangeDetails( + tx, coinRecord.exchangeBaseUrl, ); - checkBackupInvariant(!!exchange); + checkBackupInvariant(!!exchangeDetails); let wireFee: AmountJson | undefined; - const feesForType = exchange.wireInfo?.feesForType; + const feesForType = exchangeDetails.wireInfo.feesForType; checkBackupInvariant(!!feesForType); for (const fee of feesForType[contractData.wireMethod] || []) { if ( @@ -218,6 +219,7 @@ export async function importBackup( [ Stores.config, Stores.exchanges, + Stores.exchangeDetails, Stores.coins, Stores.denominations, Stores.purchases, @@ -245,21 +247,46 @@ export async function importBackup( const tombstoneSet = new Set(backupBlob.tombstones); + // FIXME: Validate that the "details pointer" is correct + for (const backupExchange of backupBlob.exchanges) { const existingExchange = await tx.get( Stores.exchanges, backupExchange.base_url, ); + if (existingExchange) { + continue; + } + await tx.put(Stores.exchanges, { + baseUrl: backupExchange.base_url, + detailsPointer: { + currency: backupExchange.currency, + masterPublicKey: backupExchange.master_public_key, + updateClock: backupExchange.update_clock, + }, + permanent: true, + retryInfo: initRetryInfo(false), + updateStarted: { t_ms: "never" }, + updateStatus: ExchangeUpdateStatus.Finished, + }); + } + + for (const backupExchangeDetails of backupBlob.exchange_details) { + const existingExchangeDetails = await tx.get(Stores.exchangeDetails, [ + backupExchangeDetails.base_url, + backupExchangeDetails.currency, + backupExchangeDetails.master_public_key, + ]); - if (!existingExchange) { - const wireInfo: ExchangeWireInfo = { - accounts: backupExchange.accounts.map((x) => ({ + if (!existingExchangeDetails) { + const wireInfo: WireInfo = { + accounts: backupExchangeDetails.accounts.map((x) => ({ master_sig: x.master_sig, payto_uri: x.payto_uri, })), feesForType: {}, }; - for (const fee of backupExchange.wire_fees) { + for (const fee of backupExchangeDetails.wire_fees) { const w = (wireInfo.feesForType[fee.wire_type] ??= []); w.push({ closingFee: Amounts.parseOrThrow(fee.closing_fee), @@ -269,48 +296,39 @@ export async function importBackup( wireFee: Amounts.parseOrThrow(fee.wire_fee), }); } - await tx.put(Stores.exchanges, { - addComplete: true, - baseUrl: backupExchange.base_url, - builtIn: false, - updateReason: undefined, - permanent: true, - retryInfo: initRetryInfo(), - termsOfServiceAcceptedEtag: backupExchange.tos_etag_accepted, + await tx.put(Stores.exchangeDetails, { + exchangeBaseUrl: backupExchangeDetails.base_url, + termsOfServiceAcceptedEtag: backupExchangeDetails.tos_etag_accepted, termsOfServiceText: undefined, - termsOfServiceLastEtag: backupExchange.tos_etag_last, - updateStarted: getTimestampNow(), - updateStatus: ExchangeUpdateStatus.FetchKeys, + termsOfServiceLastEtag: backupExchangeDetails.tos_etag_last, wireInfo, - details: { - currency: backupExchange.currency, - reserveClosingDelay: backupExchange.reserve_closing_delay, - auditors: backupExchange.auditors.map((x) => ({ - auditor_pub: x.auditor_pub, - auditor_url: x.auditor_url, - denomination_keys: x.denomination_keys, - })), - lastUpdateTime: { t_ms: "never" }, - masterPublicKey: backupExchange.master_public_key, - nextUpdateTime: { t_ms: "never" }, - protocolVersion: backupExchange.protocol_version, - signingKeys: backupExchange.signing_keys.map((x) => ({ - key: x.key, - master_sig: x.master_sig, - stamp_end: x.stamp_end, - stamp_expire: x.stamp_expire, - stamp_start: x.stamp_start, - })), - }, + currency: backupExchangeDetails.currency, + auditors: backupExchangeDetails.auditors.map((x) => ({ + auditor_pub: x.auditor_pub, + auditor_url: x.auditor_url, + denomination_keys: x.denomination_keys, + })), + lastUpdateTime: { t_ms: "never" }, + masterPublicKey: backupExchangeDetails.master_public_key, + nextUpdateTime: { t_ms: "never" }, + protocolVersion: backupExchangeDetails.protocol_version, + reserveClosingDelay: backupExchangeDetails.reserve_closing_delay, + signingKeys: backupExchangeDetails.signing_keys.map((x) => ({ + key: x.key, + master_sig: x.master_sig, + stamp_end: x.stamp_end, + stamp_expire: x.stamp_expire, + stamp_start: x.stamp_start, + })), }); } - for (const backupDenomination of backupExchange.denominations) { + for (const backupDenomination of backupExchangeDetails.denominations) { const denomPubHash = cryptoComp.denomPubToHash[backupDenomination.denom_pub]; checkLogicInvariant(!!denomPubHash); const existingDenom = await tx.get(Stores.denominations, [ - backupExchange.base_url, + backupExchangeDetails.base_url, denomPubHash, ]); if (!existingDenom) { @@ -321,7 +339,7 @@ export async function importBackup( await tx.put(Stores.denominations, { denomPub: backupDenomination.denom_pub, denomPubHash: denomPubHash, - exchangeBaseUrl: backupExchange.base_url, + exchangeBaseUrl: backupExchangeDetails.base_url, feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit), feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh), feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund), @@ -378,7 +396,7 @@ export async function importBackup( denomSig: backupCoin.denom_sig, coinPub: compCoin.coinPub, suspended: false, - exchangeBaseUrl: backupExchange.base_url, + exchangeBaseUrl: backupExchangeDetails.base_url, denomPub: backupDenomination.denom_pub, denomPubHash, status: backupCoin.fresh @@ -390,7 +408,7 @@ export async function importBackup( } } - for (const backupReserve of backupExchange.reserves) { + for (const backupReserve of backupExchangeDetails.reserves) { const reservePub = cryptoComp.reservePrivToPub[backupReserve.reserve_priv]; const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub); @@ -414,7 +432,7 @@ export async function importBackup( await tx.put(Stores.reserves, { currency: instructedAmount.currency, instructedAmount, - exchangeBaseUrl: backupExchange.base_url, + exchangeBaseUrl: backupExchangeDetails.base_url, reservePub, reservePriv: backupReserve.reserve_priv, requestedQuery: false, @@ -436,7 +454,7 @@ export async function importBackup( reserveStatus: ReserveRecordStatus.QUERYING_STATUS, initialDenomSel: await getDenomSelStateFromBackup( tx, - backupExchange.base_url, + backupExchangeDetails.base_url, backupReserve.initial_selected_denoms, ), }); @@ -457,10 +475,10 @@ export async function importBackup( await tx.put(Stores.withdrawalGroups, { denomsSel: await getDenomSelStateFromBackup( tx, - backupExchange.base_url, + backupExchangeDetails.base_url, backupWg.selected_denoms, ), - exchangeBaseUrl: backupExchange.base_url, + exchangeBaseUrl: backupExchangeDetails.base_url, lastError: undefined, rawWithdrawalAmount: Amounts.parseOrThrow( backupWg.raw_withdrawal_amount, diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index 110e76596..2314c730d 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -155,8 +155,8 @@ async function computeBackupCryptoData( proposalNoncePrivToPub: {}, reservePrivToPub: {}, }; - for (const backupExchange of backupContent.exchanges) { - for (const backupDenom of backupExchange.denominations) { + for (const backupExchangeDetails of backupContent.exchange_details) { + for (const backupDenom of backupExchangeDetails.denominations) { for (const backupCoin of backupDenom.coins) { const coinPub = encodeCrock( eddsaGetPublic(decodeCrock(backupCoin.coin_priv)), @@ -175,7 +175,7 @@ async function computeBackupCryptoData( hash(decodeCrock(backupDenom.denom_pub)), ); } - for (const backupReserve of backupExchange.reserves) { + for (const backupReserve of backupExchangeDetails.reserves) { cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock( eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)), ); diff --git a/packages/taler-wallet-core/src/operations/currencies.ts b/packages/taler-wallet-core/src/operations/currencies.ts index 1af30dfb5..5371d4a54 100644 --- a/packages/taler-wallet-core/src/operations/currencies.ts +++ b/packages/taler-wallet-core/src/operations/currencies.ts @@ -19,6 +19,7 @@ */ import { ExchangeRecord, Stores } from "../db.js"; import { Logger } from "../index.js"; +import { getExchangeDetails } from "./exchanges.js"; import { InternalWalletState } from "./state.js"; const logger = new Logger("currencies.ts"); @@ -37,7 +38,12 @@ export async function getExchangeTrust( ): Promise { let isTrusted = false; let isAudited = false; - const exchangeDetails = exchangeInfo.details; + const exchangeDetails = await ws.db.runWithReadTransaction( + [Stores.exchangeDetails, Stores.exchanges], + async (tx) => { + return getExchangeDetails(tx, exchangeInfo.baseUrl); + }, + ); if (!exchangeDetails) { throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); } diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 4c87f122f..59c27b9cc 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -58,6 +58,7 @@ import { InternalWalletState } from "./state"; import { Logger } from "../util/logging.js"; import { DepositGroupRecord, Stores } from "../db.js"; import { guardOperationException } from "./errors.js"; +import { getExchangeDetails } from "./exchanges.js"; /** * Logger. @@ -308,14 +309,17 @@ export async function createDepositGroup( const allExchanges = await ws.db.iter(Stores.exchanges).toArray(); const exchangeInfos: { url: string; master_pub: string }[] = []; for (const e of allExchanges) { - if (!e.details) { - continue; - } - if (e.details.currency != amount.currency) { + const details = await ws.db.runWithReadTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + return getExchangeDetails(tx, e.baseUrl); + }, + ); + if (!details) { continue; } exchangeInfos.push({ - master_pub: e.details.masterPublicKey, + master_pub: details.masterPublicKey, url: e.baseUrl, }); } diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index e8833699d..be9a383d2 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -19,18 +19,23 @@ */ import { Amounts, + Auditor, codecForExchangeKeysJson, codecForExchangeWireJson, compare, Denomination, Duration, durationFromSpec, + ExchangeSignKeyJson, + ExchangeWireJson, getTimestampNow, isTimestampExpired, NotificationType, parsePaytoUri, + Recoup, TalerErrorCode, TalerErrorDetails, + Timestamp, } from "@gnu-taler/taler-util"; import { DenominationRecord, @@ -40,6 +45,8 @@ import { ExchangeUpdateStatus, WireFee, ExchangeUpdateReason, + ExchangeDetailsRecord, + WireInfo, } from "../db.js"; import { Logger, @@ -47,14 +54,16 @@ import { readSuccessResponseJsonOrThrow, getExpiryTimestamp, readSuccessResponseTextOrThrow, + encodeCrock, + hash, + decodeCrock, } from "../index.js"; import { j2s, canonicalizeBaseUrl } from "@gnu-taler/taler-util"; -import { checkDbInvariant } from "../util/invariants.js"; import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries.js"; import { makeErrorDetails, - OperationFailedAndReportedError, guardOperationException, + OperationFailedError, } from "./errors.js"; import { createRecoupGroup, processRecoupGroup } from "./recoup.js"; import { InternalWalletState } from "./state.js"; @@ -62,15 +71,17 @@ import { WALLET_CACHE_BREAKER_CLIENT_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, } from "./versions.js"; +import { HttpRequestLibrary } from "../util/http.js"; +import { CryptoApi } from "../crypto/workers/cryptoApi.js"; +import { TransactionHandle } from "../util/query.js"; const logger = new Logger("exchanges.ts"); -async function denominationRecordFromKeys( - ws: InternalWalletState, +function denominationRecordFromKeys( exchangeBaseUrl: string, denomIn: Denomination, -): Promise { - const denomPubHash = await ws.cryptoApi.hashEncoded(denomIn.denom_pub); +): DenominationRecord { + const denomPubHash = encodeCrock(hash(decodeCrock(denomIn.denom_pub))); const d: DenominationRecord = { denomPub: denomIn.denom_pub, denomPubHash, @@ -115,29 +126,206 @@ function getExchangeRequestTimeout(e: ExchangeRecord): Duration { return { d_ms: 5000 }; } +interface ExchangeTosDownloadResult { + tosText: string; + tosEtag: string; +} + +async function downloadExchangeWithTermsOfService( + exchangeBaseUrl: string, + http: HttpRequestLibrary, + timeout: Duration, +): Promise { + const reqUrl = new URL("terms", exchangeBaseUrl); + reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); + const headers = { + Accept: "text/plain", + }; + + const resp = await http.get(reqUrl.href, { + headers, + timeout, + }); + const tosText = await readSuccessResponseTextOrThrow(resp); + const tosEtag = resp.headers.get("etag") || "unknown"; + + return { tosText, tosEtag }; +} + +export async function getExchangeDetails( + tx: TransactionHandle< + typeof Stores.exchanges | typeof Stores.exchangeDetails + >, + exchangeBaseUrl: string, +): Promise { + const r = await tx.get(Stores.exchanges, exchangeBaseUrl); + if (!r) { + return; + } + const dp = r.detailsPointer; + if (!dp) { + return; + } + const { currency, masterPublicKey } = dp; + return await tx.get(Stores.exchangeDetails, [ + r.baseUrl, + currency, + masterPublicKey, + ]); +} + +export async function acceptExchangeTermsOfService( + ws: InternalWalletState, + exchangeBaseUrl: string, + etag: string | undefined, +): Promise { + await ws.db.runWithWriteTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + const d = await getExchangeDetails(tx, exchangeBaseUrl); + if (d) { + d.termsOfServiceAcceptedEtag = etag; + await tx.put(Stores.exchangeDetails, d); + } + }, + ); +} + +async function validateWireInfo( + wireInfo: ExchangeWireJson, + masterPublicKey: string, + cryptoApi: CryptoApi, +): Promise { + for (const a of wireInfo.accounts) { + logger.trace("validating exchange acct"); + const isValid = await cryptoApi.isValidWireAccount( + a.payto_uri, + a.master_sig, + masterPublicKey, + ); + if (!isValid) { + throw Error("exchange acct signature invalid"); + } + } + const feesForType: { [wireMethod: string]: WireFee[] } = {}; + for (const wireMethod of Object.keys(wireInfo.fees)) { + const feeList: WireFee[] = []; + for (const x of wireInfo.fees[wireMethod]) { + const startStamp = x.start_date; + const endStamp = x.end_date; + const fee: WireFee = { + closingFee: Amounts.parseOrThrow(x.closing_fee), + endStamp, + sig: x.sig, + startStamp, + wireFee: Amounts.parseOrThrow(x.wire_fee), + }; + const isValid = await cryptoApi.isValidWireFee( + wireMethod, + fee, + masterPublicKey, + ); + if (!isValid) { + throw Error("exchange wire fee signature invalid"); + } + feeList.push(fee); + } + feesForType[wireMethod] = feeList; + } + + return { + accounts: wireInfo.accounts, + feesForType, + }; +} + /** - * Fetch the exchange's /keys and update our database accordingly. + * Fetch wire information for an exchange. * - * Exceptions thrown in this method must be caught and reported - * in the pending operations. + * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. */ -async function updateExchangeWithKeys( +async function downloadExchangeWithWireInfo( + exchangeBaseUrl: string, + http: HttpRequestLibrary, + timeout: Duration, +): Promise { + const reqUrl = new URL("wire", exchangeBaseUrl); + reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); + + const resp = await http.get(reqUrl.href, { + timeout, + }); + const wireInfo = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeWireJson(), + ); + + return wireInfo; +} + +export async function updateExchangeFromUrl( ws: InternalWalletState, baseUrl: string, -): Promise { - const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl); + forceNow = false, +): Promise<{ + exchange: ExchangeRecord; + exchangeDetails: ExchangeDetailsRecord; +}> { + const onOpErr = (e: TalerErrorDetails): Promise => + handleExchangeUpdateError(ws, baseUrl, e); + return await guardOperationException( + () => updateExchangeFromUrlImpl(ws, baseUrl, forceNow), + onOpErr, + ); +} - if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) { - return; +async function provideExchangeRecord( + ws: InternalWalletState, + baseUrl: string, + now: Timestamp, +): Promise { + let r = await ws.db.get(Stores.exchanges, baseUrl); + if (!r) { + const newExchangeRecord: ExchangeRecord = { + permanent: true, + baseUrl: baseUrl, + updateStatus: ExchangeUpdateStatus.FetchKeys, + updateStarted: now, + updateReason: ExchangeUpdateReason.Initial, + retryInfo: initRetryInfo(false), + detailsPointer: undefined, + }; + await ws.db.put(Stores.exchanges, newExchangeRecord); + r = newExchangeRecord; } + return r; +} - logger.info("updating exchange /keys info"); +interface ExchangeKeysDownloadResult { + masterPublicKey: string; + currency: string; + auditors: Auditor[]; + currentDenominations: DenominationRecord[]; + protocolVersion: string; + signingKeys: ExchangeSignKeyJson[]; + reserveClosingDelay: Duration; + expiry: Timestamp; + recoup: Recoup[]; +} +/** + * Download and validate an exchange's /keys data. + */ +async function downloadKeysInfo( + baseUrl: string, + http: HttpRequestLibrary, + timeout: Duration, +): Promise { const keysUrl = new URL("keys", baseUrl); keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - const resp = await ws.http.get(keysUrl.href, { - timeout: getExchangeRequestTimeout(existingExchangeRecord), + const resp = await http.get(keysUrl.href, { + timeout, }); const exchangeKeysJson = await readSuccessResponseJsonOrThrow( resp, @@ -155,8 +343,7 @@ async function updateExchangeWithKeys( exchangeBaseUrl: baseUrl, }, ); - await handleExchangeUpdateError(ws, baseUrl, opErr); - throw new OperationFailedAndReportedError(opErr); + throw new OperationFailedError(opErr); } const protocolVersion = exchangeKeysJson.version; @@ -171,70 +358,138 @@ async function updateExchangeWithKeys( walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, }, ); - await handleExchangeUpdateError(ws, baseUrl, opErr); - throw new OperationFailedAndReportedError(opErr); + throw new OperationFailedError(opErr); } - const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) - .currency; - - logger.trace("processing denominations"); + const currency = Amounts.parseOrThrow( + exchangeKeysJson.denoms[0].value, + ).currency.toUpperCase(); - const newDenominations = await Promise.all( - exchangeKeysJson.denoms.map((d) => - denominationRecordFromKeys(ws, baseUrl, d), + return { + masterPublicKey: exchangeKeysJson.master_public_key, + currency, + auditors: exchangeKeysJson.auditors, + currentDenominations: exchangeKeysJson.denoms.map((d) => + denominationRecordFromKeys(baseUrl, d), ), + protocolVersion: exchangeKeysJson.version, + signingKeys: exchangeKeysJson.signkeys, + reserveClosingDelay: exchangeKeysJson.reserve_closing_delay, + expiry: getExpiryTimestamp(resp, { + minDuration: durationFromSpec({ hours: 1 }), + }), + recoup: exchangeKeysJson.recoup ?? [], + }; +} + +/** + * Update or add exchange DB entry by fetching the /keys and /wire information. + * Optionally link the reserve entry to the new or existing + * exchange entry in then DB. + */ +async function updateExchangeFromUrlImpl( + ws: InternalWalletState, + baseUrl: string, + forceNow = false, +): Promise<{ + exchange: ExchangeRecord; + exchangeDetails: ExchangeDetailsRecord; +}> { + logger.trace(`updating exchange info for ${baseUrl}`); + const now = getTimestampNow(); + baseUrl = canonicalizeBaseUrl(baseUrl); + + const r = await provideExchangeRecord(ws, baseUrl, now); + + logger.info("updating exchange /keys info"); + + const timeout = getExchangeRequestTimeout(r); + + const keysInfo = await downloadKeysInfo(baseUrl, ws.http, timeout); + + const wireInfoDownload = await downloadExchangeWithWireInfo( + baseUrl, + ws.http, + timeout, ); - logger.trace("done with processing denominations"); + const wireInfo = await validateWireInfo( + wireInfoDownload, + keysInfo.masterPublicKey, + ws.cryptoApi, + ); - const lastUpdateTimestamp = getTimestampNow(); + const tosDownload = await downloadExchangeWithTermsOfService( + baseUrl, + ws.http, + timeout, + ); - const recoupGroupId: string | undefined = undefined; + let recoupGroupId: string | undefined = undefined; - await ws.db.runWithWriteTransaction( - [Stores.exchanges, Stores.denominations, Stores.recoupGroups, Stores.coins], + const updated = await ws.db.runWithWriteTransaction( + [ + Stores.exchanges, + Stores.exchangeDetails, + Stores.denominations, + Stores.recoupGroups, + Stores.coins, + ], async (tx) => { const r = await tx.get(Stores.exchanges, baseUrl); if (!r) { logger.warn(`exchange ${baseUrl} no longer present`); return; } - if (r.details) { + let details = await getExchangeDetails(tx, r.baseUrl); + if (details) { // FIXME: We need to do some consistency checks! } // FIXME: validate signing keys and merge with old set - r.details = { - auditors: exchangeKeysJson.auditors, - currency: currency, - lastUpdateTime: lastUpdateTimestamp, - masterPublicKey: exchangeKeysJson.master_public_key, - protocolVersion: protocolVersion, - signingKeys: exchangeKeysJson.signkeys, - nextUpdateTime: getExpiryTimestamp(resp, { - minDuration: durationFromSpec({ hours: 1 }), - }), - reserveClosingDelay: exchangeKeysJson.reserve_closing_delay, + details = { + auditors: keysInfo.auditors, + currency: keysInfo.currency, + lastUpdateTime: now, + masterPublicKey: keysInfo.masterPublicKey, + protocolVersion: keysInfo.protocolVersion, + signingKeys: keysInfo.signingKeys, + nextUpdateTime: keysInfo.expiry, + reserveClosingDelay: keysInfo.reserveClosingDelay, + exchangeBaseUrl: r.baseUrl, + wireInfo, + termsOfServiceText: tosDownload.tosText, + termsOfServiceAcceptedEtag: undefined, + termsOfServiceLastEtag: tosDownload.tosEtag, }; r.updateStatus = ExchangeUpdateStatus.FetchWire; + // FIXME: only update if pointer got updated r.lastError = undefined; r.retryInfo = initRetryInfo(false); + // New denominations might be available. + r.nextRefreshCheck = undefined; + r.detailsPointer = { + currency: details.currency, + masterPublicKey: details.masterPublicKey, + // FIXME: only change if pointer really changed + updateClock: getTimestampNow(), + }; await tx.put(Stores.exchanges, r); + await tx.put(Stores.exchangeDetails, details); - for (const newDenom of newDenominations) { + for (const currentDenom of keysInfo.currentDenominations) { const oldDenom = await tx.get(Stores.denominations, [ baseUrl, - newDenom.denomPubHash, + currentDenom.denomPubHash, ]); if (oldDenom) { // FIXME: Do consistency check } else { - await tx.put(Stores.denominations, newDenom); + await tx.put(Stores.denominations, currentDenom); } } // Handle recoup - const recoupDenomList = exchangeKeysJson.recoup ?? []; + const recoupDenomList = keysInfo.recoup; const newlyRevokedCoinPubs: string[] = []; logger.trace("recoup list from exchange", recoupDenomList); for (const recoupInfo of recoupDenomList) { @@ -264,8 +519,12 @@ async function updateExchangeWithKeys( } if (newlyRevokedCoinPubs.length != 0) { logger.trace("recouping coins", newlyRevokedCoinPubs); - await createRecoupGroup(ws, tx, newlyRevokedCoinPubs); + recoupGroupId = await createRecoupGroup(ws, tx, newlyRevokedCoinPubs); } + return { + exchange: r, + exchangeDetails: details, + }; }, ); @@ -277,257 +536,16 @@ async function updateExchangeWithKeys( }); } - logger.trace("done updating exchange /keys"); -} - -async function updateExchangeFinalize( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise { - const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - return; - } - if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) { - return; + if (!updated) { + throw Error("something went wrong with updating the exchange"); } - await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - if (r.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) { - return; - } - r.addComplete = true; - r.updateStatus = ExchangeUpdateStatus.Finished; - // Reset time to next auto refresh check, - // as now new denominations might be available. - r.nextRefreshCheck = undefined; - await tx.put(Stores.exchanges, r); - }); -} -async function updateExchangeWithTermsOfService( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise { - const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - return; - } - if (exchange.updateStatus != ExchangeUpdateStatus.FetchTerms) { - return; - } - const reqUrl = new URL("terms", exchangeBaseUrl); - reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - const headers = { - Accept: "text/plain", + return { + exchange: updated.exchange, + exchangeDetails: updated.exchangeDetails, }; - - const resp = await ws.http.get(reqUrl.href, { - headers, - timeout: getExchangeRequestTimeout(exchange), - }); - const tosText = await readSuccessResponseTextOrThrow(resp); - const tosEtag = resp.headers.get("etag") || undefined; - - await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - if (r.updateStatus != ExchangeUpdateStatus.FetchTerms) { - return; - } - r.termsOfServiceText = tosText; - r.termsOfServiceLastEtag = tosEtag; - r.updateStatus = ExchangeUpdateStatus.FinalizeUpdate; - await tx.put(Stores.exchanges, r); - }); -} - -export async function acceptExchangeTermsOfService( - ws: InternalWalletState, - exchangeBaseUrl: string, - etag: string | undefined, -): Promise { - await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - r.termsOfServiceAcceptedEtag = etag; - await tx.put(Stores.exchanges, r); - }); -} - -/** - * Fetch wire information for an exchange and store it in the database. - * - * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. - */ -async function updateExchangeWithWireInfo( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise { - const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - return; - } - if (exchange.updateStatus != ExchangeUpdateStatus.FetchWire) { - return; - } - const details = exchange.details; - if (!details) { - throw Error("invalid exchange state"); - } - const reqUrl = new URL("wire", exchangeBaseUrl); - reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - - const resp = await ws.http.get(reqUrl.href, { - timeout: getExchangeRequestTimeout(exchange), - }); - const wireInfo = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeWireJson(), - ); - - for (const a of wireInfo.accounts) { - logger.trace("validating exchange acct"); - const isValid = await ws.cryptoApi.isValidWireAccount( - a.payto_uri, - a.master_sig, - details.masterPublicKey, - ); - if (!isValid) { - throw Error("exchange acct signature invalid"); - } - } - const feesForType: { [wireMethod: string]: WireFee[] } = {}; - for (const wireMethod of Object.keys(wireInfo.fees)) { - const feeList: WireFee[] = []; - for (const x of wireInfo.fees[wireMethod]) { - const startStamp = x.start_date; - const endStamp = x.end_date; - const fee: WireFee = { - closingFee: Amounts.parseOrThrow(x.closing_fee), - endStamp, - sig: x.sig, - startStamp, - wireFee: Amounts.parseOrThrow(x.wire_fee), - }; - const isValid = await ws.cryptoApi.isValidWireFee( - wireMethod, - fee, - details.masterPublicKey, - ); - if (!isValid) { - throw Error("exchange wire fee signature invalid"); - } - feeList.push(fee); - } - feesForType[wireMethod] = feeList; - } - - await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - if (r.updateStatus != ExchangeUpdateStatus.FetchWire) { - return; - } - r.wireInfo = { - accounts: wireInfo.accounts, - feesForType: feesForType, - }; - r.updateStatus = ExchangeUpdateStatus.FetchTerms; - r.lastError = undefined; - r.retryInfo = initRetryInfo(false); - await tx.put(Stores.exchanges, r); - }); } -export async function updateExchangeFromUrl( - ws: InternalWalletState, - baseUrl: string, - forceNow = false, -): Promise { - const onOpErr = (e: TalerErrorDetails): Promise => - handleExchangeUpdateError(ws, baseUrl, e); - return await guardOperationException( - () => updateExchangeFromUrlImpl(ws, baseUrl, forceNow), - onOpErr, - ); -} - -/** - * Update or add exchange DB entry by fetching the /keys and /wire information. - * Optionally link the reserve entry to the new or existing - * exchange entry in then DB. - */ -async function updateExchangeFromUrlImpl( - ws: InternalWalletState, - baseUrl: string, - forceNow = false, -): Promise { - logger.trace(`updating exchange info for ${baseUrl}`); - const now = getTimestampNow(); - baseUrl = canonicalizeBaseUrl(baseUrl); - - let r = await ws.db.get(Stores.exchanges, baseUrl); - if (!r) { - const newExchangeRecord: ExchangeRecord = { - builtIn: false, - addComplete: false, - permanent: true, - baseUrl: baseUrl, - details: undefined, - wireInfo: undefined, - updateStatus: ExchangeUpdateStatus.FetchKeys, - updateStarted: now, - updateReason: ExchangeUpdateReason.Initial, - termsOfServiceAcceptedEtag: undefined, - termsOfServiceLastEtag: undefined, - termsOfServiceText: undefined, - retryInfo: initRetryInfo(false), - }; - await ws.db.put(Stores.exchanges, newExchangeRecord); - } else { - await ws.db.runWithWriteTransaction([Stores.exchanges], async (t) => { - const rec = await t.get(Stores.exchanges, baseUrl); - if (!rec) { - return; - } - if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys) { - const t = rec.details?.nextUpdateTime; - if (!forceNow && t && !isTimestampExpired(t)) { - return; - } - } - if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && forceNow) { - rec.updateReason = ExchangeUpdateReason.Forced; - } - rec.updateStarted = now; - rec.updateStatus = ExchangeUpdateStatus.FetchKeys; - rec.lastError = undefined; - rec.retryInfo = initRetryInfo(false); - t.put(Stores.exchanges, rec); - }); - } - - await updateExchangeWithKeys(ws, baseUrl); - await updateExchangeWithWireInfo(ws, baseUrl); - await updateExchangeWithTermsOfService(ws, baseUrl); - await updateExchangeFinalize(ws, baseUrl); - - const updatedExchange = await ws.db.get(Stores.exchanges, baseUrl); - checkDbInvariant(!!updatedExchange); - return updatedExchange; -} - - export async function getExchangePaytoUri( ws: InternalWalletState, exchangeBaseUrl: string, @@ -535,15 +553,14 @@ export async function getExchangePaytoUri( ): Promise { // We do the update here, since the exchange might not even exist // yet in our database. - const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl); - if (!exchangeRecord) { - throw Error(`Exchange '${exchangeBaseUrl}' not found.`); - } - const exchangeWireInfo = exchangeRecord.wireInfo; - if (!exchangeWireInfo) { - throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); - } - for (const account of exchangeWireInfo.accounts) { + const details = await ws.db.runWithReadTransaction( + [Stores.exchangeDetails, Stores.exchanges], + async (tx) => { + return getExchangeDetails(tx, exchangeBaseUrl); + }, + ); + const accounts = details?.wireInfo.accounts ?? []; + for (const account of accounts) { const res = parsePaytoUri(account.payto_uri); if (!res) { continue; diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index 1ed8d72b9..dad460b8c 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -94,6 +94,7 @@ import { import { getTotalRefreshCost, createRefreshGroup } from "./refresh.js"; import { InternalWalletState, EXCHANGE_COINS_LOCK } from "./state.js"; import { ContractTermsUtil } from "../util/contractTerms.js"; +import { getExchangeDetails } from "./exchanges.js"; /** * Logger. @@ -170,11 +171,16 @@ export async function getEffectiveDepositAmount( exchangeSet.add(coin.exchangeBaseUrl); } for (const exchangeUrl of exchangeSet.values()) { - const exchange = await ws.db.get(Stores.exchanges, exchangeUrl); - if (!exchange?.wireInfo) { + const exchangeDetails = await ws.db.runWithReadTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + return getExchangeDetails(tx, exchangeUrl); + }, + ); + if (!exchangeDetails) { continue; } - const fee = exchange.wireInfo.feesForType[wireType].find((x) => { + const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => { return timestampIsBetween(getTimestampNow(), x.startStamp, x.endStamp); })?.wireFee; if (fee) { @@ -240,11 +246,16 @@ export async function getCandidatePayCoins( const exchanges = await ws.db.iter(Stores.exchanges).toArray(); for (const exchange of exchanges) { let isOkay = false; - const exchangeDetails = exchange.details; + const exchangeDetails = await ws.db.runWithReadTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + return getExchangeDetails(tx, exchange.baseUrl); + }, + ); if (!exchangeDetails) { continue; } - const exchangeFees = exchange.wireInfo; + const exchangeFees = exchangeDetails.wireInfo; if (!exchangeFees) { continue; } diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index 01920a85b..85f8faa18 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -37,9 +37,10 @@ import { getDurationRemaining, durationMin, } from "@gnu-taler/taler-util"; -import { Store, TransactionHandle } from "../util/query"; +import { TransactionHandle } from "../util/query"; import { InternalWalletState } from "./state"; import { getBalancesInsideTransaction } from "./balance"; +import { getExchangeDetails } from "./exchanges.js"; function updateRetryDelay( oldDelay: Duration, @@ -52,12 +53,14 @@ function updateRetryDelay( } async function gatherExchangePending( - tx: TransactionHandle, + tx: TransactionHandle< + typeof Stores.exchanges | typeof Stores.exchangeDetails + >, now: Timestamp, resp: PendingOperationsResponse, onlyDue = false, ): Promise { - await tx.iter(Stores.exchanges).forEach((e) => { + await tx.iter(Stores.exchanges).forEachAsync(async (e) => { switch (e.updateStatus) { case ExchangeUpdateStatus.Finished: if (e.lastError) { @@ -71,30 +74,9 @@ async function gatherExchangePending( }, }); } - if (!e.details) { - resp.pendingOperations.push({ - type: PendingOperationType.Bug, - givesLifeness: false, - message: - "Exchange record does not have details, but no update finished.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - if (!e.wireInfo) { - resp.pendingOperations.push({ - type: PendingOperationType.Bug, - givesLifeness: false, - message: - "Exchange record does not have wire info, but no update finished.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } + const details = await getExchangeDetails(tx, e.baseUrl); const keysUpdateRequired = - e.details && e.details.nextUpdateTime.t_ms < now.t_ms; + details && details.nextUpdateTime.t_ms < now.t_ms; if (keysUpdateRequired) { resp.pendingOperations.push({ type: PendingOperationType.ExchangeUpdate, @@ -106,7 +88,7 @@ async function gatherExchangePending( }); } if ( - e.details && + details && (!e.nextRefreshCheck || e.nextRefreshCheck.t_ms < now.t_ms) ) { resp.pendingOperations.push({ diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index 337892f77..aa551e8da 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -24,9 +24,25 @@ /** * Imports. */ -import { Amounts, codecForRecoupConfirmation, getTimestampNow, NotificationType, RefreshReason, TalerErrorDetails } from "@gnu-taler/taler-util"; +import { + Amounts, + codecForRecoupConfirmation, + getTimestampNow, + NotificationType, + RefreshReason, + TalerErrorDetails, +} from "@gnu-taler/taler-util"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; -import { CoinRecord, CoinSourceType, CoinStatus, RecoupGroupRecord, RefreshCoinSource, ReserveRecordStatus, Stores, WithdrawCoinSource } from "../db.js"; +import { + CoinRecord, + CoinSourceType, + CoinStatus, + RecoupGroupRecord, + RefreshCoinSource, + ReserveRecordStatus, + Stores, + WithdrawCoinSource, +} from "../db.js"; import { readSuccessResponseJsonOrThrow } from "../util/http"; import { Logger } from "../util/logging"; @@ -34,6 +50,7 @@ import { TransactionHandle } from "../util/query"; import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries"; import { URL } from "../util/url"; import { guardOperationException } from "./errors"; +import { getExchangeDetails } from "./exchanges.js"; import { createRefreshGroup, processRefreshGroup } from "./refresh"; import { getReserveRequestTimeout, processReserve } from "./reserves"; import { InternalWalletState } from "./state"; @@ -155,12 +172,13 @@ async function recoupWithdrawCoin( throw Error(`Coin's reserve doesn't match reserve on recoup`); } - const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl); - if (!exchange) { - // FIXME: report inconsistency? - return; - } - const exchangeDetails = exchange.details; + const exchangeDetails = await ws.db.runWithReadTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + return getExchangeDetails(tx, reserve.exchangeBaseUrl); + }, + ); + if (!exchangeDetails) { // FIXME: report inconsistency? return; @@ -232,13 +250,14 @@ async function recoupRefreshCoin( throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`); } - const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl); - if (!exchange) { - logger.warn("exchange for recoup does not exist anymore"); - // FIXME: report inconsistency? - return; - } - const exchangeDetails = exchange.details; + const exchangeDetails = await ws.db.runWithReadTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + // FIXME: Get the exchange details based on the + // exchange master public key instead of via just the URL. + return getExchangeDetails(tx, coin.exchangeBaseUrl); + }, + ); if (!exchangeDetails) { // FIXME: report inconsistency? logger.warn("exchange details for recoup not found"); diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 84460fb88..9d4390abd 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -122,7 +122,7 @@ async function refreshCreateSession( throw Error("Can't refresh, coin not found"); } - const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); + const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); if (!exchange) { throw Error("db inconsistent: exchange of coin not found"); } diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts index d8821d560..d06ce31ed 100644 --- a/packages/taler-wallet-core/src/operations/reserves.ts +++ b/packages/taler-wallet-core/src/operations/reserves.ts @@ -58,7 +58,11 @@ import { updateRetryInfoTimeout, } from "../util/retries.js"; import { guardOperationException, OperationFailedError } from "./errors.js"; -import { updateExchangeFromUrl, getExchangePaytoUri } from "./exchanges.js"; +import { + updateExchangeFromUrl, + getExchangePaytoUri, + getExchangeDetails, +} from "./exchanges.js"; import { InternalWalletState } from "./state.js"; import { updateWithdrawalDenoms, @@ -148,12 +152,15 @@ export async function createReserve( }; const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); - const exchangeDetails = exchangeInfo.details; + const exchangeDetails = exchangeInfo.exchangeDetails; if (!exchangeDetails) { logger.trace(exchangeDetails); throw Error("exchange not updated"); } - const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo); + const { isAudited, isTrusted } = await getExchangeTrust( + ws, + exchangeInfo.exchange, + ); const resp = await ws.db.runWithWriteTransaction( [Stores.exchangeTrustStore, Stores.reserves, Stores.bankWithdrawUris], @@ -728,7 +735,11 @@ export async function createTalerWithdrawReserve( * Get payto URIs needed to fund a reserve. */ export async function getFundingPaytoUris( - tx: TransactionHandle, + tx: TransactionHandle< + | typeof Stores.reserves + | typeof Stores.exchanges + | typeof Stores.exchangeDetails + >, reservePub: string, ): Promise { const r = await tx.get(Stores.reserves, reservePub); @@ -736,13 +747,13 @@ export async function getFundingPaytoUris( logger.error(`reserve ${reservePub} not found (DB corrupted?)`); return []; } - const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl); - if (!exchange) { + const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl); + if (!exchangeDetails) { logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`); return []; } const plainPaytoUris = - exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; + exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; if (!plainPaytoUris) { logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`); return []; diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 1df7c7be2..42ed2d2ec 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -38,6 +38,7 @@ import { OrderShortInfo, } from "@gnu-taler/taler-util"; import { getFundingPaytoUris } from "./reserves"; +import { getExchangeDetails } from "./exchanges.js"; /** * Create an event ID from the type and the primary key for the event. @@ -89,6 +90,7 @@ export async function getTransactions( Stores.coins, Stores.denominations, Stores.exchanges, + Stores.exchangeDetails, Stores.proposals, Stores.purchases, Stores.refreshGroups, @@ -134,15 +136,18 @@ export async function getTransactions( bankConfirmationUrl: r.bankInfo.confirmUrl, }; } else { - const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl); - if (!exchange) { + const exchangeDetails = await getExchangeDetails( + tx, + wsr.exchangeBaseUrl, + ); + if (!exchangeDetails) { // FIXME: report somehow return; } withdrawalDetails = { type: WithdrawalType.ManualTransfer, exchangePaytoUris: - exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [], + exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [], }; } transactions.push({ diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 0ff69cb5a..5f050620a 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -35,7 +35,7 @@ import { PlanchetRecord, DenomSelectionState, ExchangeRecord, - ExchangeWireInfo, + ExchangeDetailsRecord, } from "../db"; import { BankWithdrawDetails, @@ -51,7 +51,7 @@ import { } from "@gnu-taler/taler-util"; import { InternalWalletState } from "./state"; import { Logger } from "../util/logging"; -import { updateExchangeFromUrl } from "./exchanges"; +import { getExchangeDetails, updateExchangeFromUrl } from "./exchanges"; import { WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, @@ -94,6 +94,8 @@ interface ExchangeWithdrawDetails { */ exchangeInfo: ExchangeRecord; + exchangeDetails: ExchangeDetailsRecord; + /** * Filtered wire info to send to the bank. */ @@ -114,11 +116,6 @@ interface ExchangeWithdrawDetails { */ overhead: AmountJson; - /** - * Wire fees from the exchange. - */ - wireFees: ExchangeWireInfo; - /** * Does the wallet know about an auditor for * the exchange that the reserve. @@ -639,12 +636,12 @@ export async function updateWithdrawalDenoms( ws: InternalWalletState, exchangeBaseUrl: string, ): Promise { - const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - logger.error("exchange not found"); - throw Error(`exchange ${exchangeBaseUrl} not found`); - } - const exchangeDetails = exchange.details; + const exchangeDetails = await ws.db.runWithReadTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + return getExchangeDetails(tx, exchangeBaseUrl); + }, + ); if (!exchangeDetails) { logger.error("exchange details not available"); throw Error(`exchange ${exchangeBaseUrl} details not available`); @@ -849,25 +846,19 @@ export async function getExchangeWithdrawalInfo( baseUrl: string, amount: AmountJson, ): Promise { - const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const exchangeWireInfo = exchangeInfo.wireInfo; - if (!exchangeWireInfo) { - throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`); - } - + const { exchange, exchangeDetails } = await updateExchangeFromUrl( + ws, + baseUrl, + ); await updateWithdrawalDenoms(ws, baseUrl); const denoms = await getCandidateWithdrawalDenoms(ws, baseUrl); const selectedDenoms = selectWithdrawalDenominations(amount, denoms); const exchangeWireAccounts: string[] = []; - for (const account of exchangeWireInfo.accounts) { + for (const account of exchangeDetails.wireInfo.accounts) { exchangeWireAccounts.push(account.payto_uri); } - const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo); + const { isTrusted, isAudited } = await getExchangeTrust(ws, exchange); let earliestDepositExpiration = selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit; @@ -904,10 +895,10 @@ export async function getExchangeWithdrawalInfo( let tosAccepted = false; - if (exchangeInfo.termsOfServiceLastEtag) { + if (exchangeDetails.termsOfServiceLastEtag) { if ( - exchangeInfo.termsOfServiceAcceptedEtag === - exchangeInfo.termsOfServiceLastEtag + exchangeDetails.termsOfServiceAcceptedEtag === + exchangeDetails.termsOfServiceLastEtag ) { tosAccepted = true; } @@ -920,7 +911,8 @@ export async function getExchangeWithdrawalInfo( const ret: ExchangeWithdrawDetails = { earliestDepositExpiration, - exchangeInfo, + exchangeInfo: exchange, + exchangeDetails, exchangeWireAccounts, exchangeVersion: exchangeDetails.protocolVersion || "unknown", isAudited, @@ -932,7 +924,6 @@ export async function getExchangeWithdrawalInfo( trustedAuditorPubs: [], versionMatch, walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, - wireFees: exchangeWireInfo, withdrawFee, termsOfServiceAccepted: tosAccepted, }; @@ -960,29 +951,25 @@ export async function getWithdrawalDetailsForUri( } } - const exchangesRes: (ExchangeListItem | undefined)[] = await ws.db - .iter(Stores.exchanges) - .map((x) => { - const details = x.details; - if (!details) { - return undefined; - } - if (!x.addComplete) { - return undefined; - } - if (!x.wireInfo) { - return undefined; - } - if (details.currency !== info.amount.currency) { - return undefined; - } - return { - exchangeBaseUrl: x.baseUrl, + const exchanges: ExchangeListItem[] = []; + + const exchangeRecords = await ws.db.iter(Stores.exchanges).toArray(); + + for (const r of exchangeRecords) { + const details = await ws.db.runWithReadTransaction( + [Stores.exchanges, Stores.exchangeDetails], + async (tx) => { + return getExchangeDetails(tx, r.baseUrl); + }, + ); + if (details) { + exchanges.push({ + exchangeBaseUrl: details.exchangeBaseUrl, currency: details.currency, - paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri), - }; - }); - const exchanges = exchangesRes.filter((x) => !!x) as ExchangeListItem[]; + paytoUris: details.wireInfo.accounts.map((x) => x.payto_uri), + }); + } + } return { amount: Amounts.stringify(info.amount), diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 317d81ceb..d968fea47 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -105,6 +105,7 @@ import { CoinRecord, CoinSourceType, DenominationRecord, + ExchangeDetailsRecord, ExchangeRecord, PurchaseRecord, RefundState, @@ -232,7 +233,7 @@ export class Wallet { exchangeBaseUrl, amount, ); - const paytoUris = wi.exchangeInfo.wireInfo?.accounts.map( + const paytoUris = wi.exchangeDetails.wireInfo.accounts.map( (x) => x.payto_uri, ); if (!paytoUris) { @@ -586,13 +587,14 @@ export class Wallet { /** * Update or add exchange DB entry by fetching the /keys and /wire information. - * Optionally link the reserve entry to the new or existing - * exchange entry in then DB. */ async updateExchangeFromUrl( baseUrl: string, force = false, - ): Promise { + ): Promise<{ + exchange: ExchangeRecord; + exchangeDetails: ExchangeDetailsRecord; + }> { try { return updateExchangeFromUrl(this.ws, baseUrl, force); } finally { @@ -601,14 +603,16 @@ export class Wallet { } async getExchangeTos(exchangeBaseUrl: string): Promise { - const exchange = await this.updateExchangeFromUrl(exchangeBaseUrl); - const tos = exchange.termsOfServiceText; - const currentEtag = exchange.termsOfServiceLastEtag; + const { exchange, exchangeDetails } = await this.updateExchangeFromUrl( + exchangeBaseUrl, + ); + const tos = exchangeDetails.termsOfServiceText; + const currentEtag = exchangeDetails.termsOfServiceLastEtag; if (!tos || !currentEtag) { throw Error("exchange is in invalid state"); } return { - acceptedEtag: exchange.termsOfServiceAcceptedEtag, + acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag, currentEtag, tos, }; @@ -678,28 +682,29 @@ export class Wallet { } async getExchanges(): Promise { - const exchanges: (ExchangeListItem | undefined)[] = await this.db - .iter(Stores.exchanges) - .map((x) => { - const details = x.details; - if (!details) { - return undefined; - } - if (!x.addComplete) { - return undefined; - } - if (!x.wireInfo) { - return undefined; - } - return { - exchangeBaseUrl: x.baseUrl, - currency: details.currency, - paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri), - }; + const exchangeRecords = await this.db.iter(Stores.exchanges).toArray(); + const exchanges: ExchangeListItem[] = []; + for (const r of exchangeRecords) { + const dp = r.detailsPointer; + if (!dp) { + continue; + } + const { currency, masterPublicKey } = dp; + const exchangeDetails = await this.db.get(Stores.exchangeDetails, [ + r.baseUrl, + currency, + masterPublicKey, + ]); + if (!exchangeDetails) { + continue; + } + exchanges.push({ + exchangeBaseUrl: r.baseUrl, + currency, + paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), }); - return { - exchanges: exchanges.filter((x) => !!x) as ExchangeListItem[], - }; + } + return { exchanges }; } async getCurrencies(): Promise { -- cgit v1.2.3