support multiple exchange details per base URL

This commit is contained in:
Florian Dold 2021-06-02 13:23:51 +02:00
parent c6c17a1c0a
commit 02f1d4b081
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
17 changed files with 710 additions and 607 deletions

View File

@ -128,6 +128,8 @@ export interface WalletBackupContentV1 {
*/ */
exchanges: BackupExchange[]; exchanges: BackupExchange[];
exchange_details: BackupExchangeDetails[];
/** /**
* Grouped refresh sessions. * 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 { 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. * Canonicalized base url of the exchange.
*/ */
@ -1158,11 +1185,6 @@ export interface BackupExchange {
* ETag for last terms of service download. * ETag for last terms of service download.
*/ */
tos_etag_accepted: string | undefined; tos_etag_accepted: string | undefined;
/**
* Should this exchange be considered defective?
*/
defective?: boolean;
} }
export enum BackupProposalStatus { export enum BackupProposalStatus {

View File

@ -598,10 +598,10 @@ advancedCli
}) })
.action(async (args) => { .action(async (args) => {
await withWallet(args, async (wallet) => { await withWallet(args, async (wallet) => {
const exchange = await wallet.updateExchangeFromUrl( const { exchange, exchangeDetails } = await wallet.updateExchangeFromUrl(
args.withdrawManually.exchange, args.withdrawManually.exchange,
); );
const acct = exchange.wireInfo?.accounts[0]; const acct = exchangeDetails.wireInfo.accounts[0];
if (!acct) { if (!acct) {
console.log("exchange has no accounts"); console.log("exchange has no accounts");
return; return;

View File

@ -513,50 +513,6 @@ export interface DenominationRecord {
exchangeBaseUrl: string; exchangeBaseUrl: string;
} }
/**
* Details about the exchange that we only know after
* querying /keys and /wire.
*/
export interface ExchangeDetails {
/**
* Master public key of the exchange.
*/
masterPublicKey: string;
/**
* Auditors (partially) auditing the exchange.
*/
auditors: Auditor[];
/**
* Currency that the exchange offers.
*/
currency: string;
/**
* Last observed protocol version.
*/
protocolVersion: string;
reserveClosingDelay: Duration;
/**
* Signing keys we got from the exchange, can also contain
* older signing keys that are not returned by /keys anymore.
*/
signingKeys: ExchangeSignKeyJson[];
/**
* Timestamp for last update.
*/
lastUpdateTime: Timestamp;
/**
* When should we next update the information about the exchange?
*/
nextUpdateTime: Timestamp;
}
export enum ExchangeUpdateStatus { export enum ExchangeUpdateStatus {
FetchKeys = "fetch-keys", FetchKeys = "fetch-keys",
FetchWire = "fetch-wire", FetchWire = "fetch-wire",
@ -570,50 +526,42 @@ export interface ExchangeBankAccount {
master_sig: string; master_sig: string;
} }
export interface ExchangeWireInfo {
feesForType: { [wireMethod: string]: WireFee[] };
accounts: ExchangeBankAccount[];
}
export enum ExchangeUpdateReason { export enum ExchangeUpdateReason {
Initial = "initial", Initial = "initial",
Forced = "forced", Forced = "forced",
Scheduled = "scheduled", Scheduled = "scheduled",
} }
/** export interface ExchangeDetailsRecord {
* Exchange record as stored in the wallet's database.
*/
export interface ExchangeRecord {
/** /**
* Base url of the exchange. * Master public key of the exchange.
*/ */
baseUrl: string; masterPublicKey: string;
exchangeBaseUrl: string;
/** /**
* Did we finish adding the exchange? * Currency that the exchange offers.
*/ */
addComplete: boolean; currency: string;
/** /**
* Is this a permanent or temporary exchange record? * Auditors (partially) auditing the exchange.
*/ */
permanent: boolean; auditors: Auditor[];
/** /**
* Was the exchange added as a built-in exchange? * Last observed protocol version.
*/ */
builtIn: boolean; protocolVersion: string;
reserveClosingDelay: Duration;
/** /**
* Details, once known. * Signing keys we got from the exchange, can also contain
* older signing keys that are not returned by /keys anymore.
*/ */
details: ExchangeDetails | undefined; signingKeys: ExchangeSignKeyJson[];
/**
* Mapping from wire method type to the wire fee.
*/
wireInfo: ExchangeWireInfo | undefined;
/** /**
* Terms of service text or undefined if not downloaded yet. * Terms of service text or undefined if not downloaded yet.
@ -632,6 +580,52 @@ export interface ExchangeRecord {
*/ */
termsOfServiceAcceptedEtag: string | undefined; termsOfServiceAcceptedEtag: string | undefined;
/**
* Timestamp for last update.
*/
lastUpdateTime: Timestamp;
/**
* When should we next update the information about the exchange?
*/
nextUpdateTime: Timestamp;
wireInfo: WireInfo;
}
export interface WireInfo {
feesForType: { [wireMethod: string]: WireFee[] };
accounts: ExchangeBankAccount[];
}
export interface ExchangeDetailsPointer {
masterPublicKey: string;
currency: string;
/**
* Timestamp when the (masterPublicKey, currency) pointer
* has been updated.
*/
updateClock: Timestamp;
}
/**
* Exchange record as stored in the wallet's database.
*/
export interface ExchangeRecord {
/**
* Base url of the exchange.
*/
baseUrl: string;
detailsPointer: ExchangeDetailsPointer | undefined;
/**
* Is this a permanent or temporary exchange record?
*/
permanent: boolean;
/** /**
* Time when the update to the exchange has been started or * Time when the update to the exchange has been started or
* undefined if no update is in progress. * undefined if no update is in progress.
@ -640,6 +634,9 @@ export interface ExchangeRecord {
/** /**
* Status of updating the info about the exchange. * Status of updating the info about the exchange.
*
* FIXME: Adapt this to recent changes regarding how
* updating exchange details works.
*/ */
updateStatus: ExchangeUpdateStatus; updateStatus: ExchangeUpdateStatus;
@ -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> { class CoinsStore extends Store<"coins", CoinRecord> {
constructor() { constructor() {
super("coins", { keyPath: "coinPub" }); super("coins", { keyPath: "coinPub" });
@ -1924,6 +1932,7 @@ export const Stores = {
exchangeTrustStore: new ExchangeTrustStore(), exchangeTrustStore: new ExchangeTrustStore(),
denominations: new DenominationsStore(), denominations: new DenominationsStore(),
exchanges: new ExchangesStore(), exchanges: new ExchangesStore(),
exchangeDetails: new ExchangeDetailsStore(),
proposals: new ProposalsStore(), proposals: new ProposalsStore(),
refreshGroups: new Store<"refreshGroups", RefreshGroupRecord>( refreshGroups: new Store<"refreshGroups", RefreshGroupRecord>(
"refreshGroups", "refreshGroups",

View File

@ -47,6 +47,7 @@ import {
BackupProposalStatus, BackupProposalStatus,
BackupRefreshOldCoin, BackupRefreshOldCoin,
BackupRefreshSession, BackupRefreshSession,
BackupExchangeDetails,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { InternalWalletState } from "../state"; import { InternalWalletState } from "../state";
import { import {
@ -65,6 +66,7 @@ import {
} from "../../db.js"; } from "../../db.js";
import { encodeCrock, stringToBytes, getRandomBytes } from "../../index.js"; import { encodeCrock, stringToBytes, getRandomBytes } from "../../index.js";
import { canonicalizeBaseUrl, canonicalJson } from "@gnu-taler/taler-util"; import { canonicalizeBaseUrl, canonicalJson } from "@gnu-taler/taler-util";
import { getExchangeDetails } from "../exchanges.js";
export async function exportBackup( export async function exportBackup(
ws: InternalWalletState, ws: InternalWalletState,
@ -74,6 +76,7 @@ export async function exportBackup(
[ [
Stores.config, Stores.config,
Stores.exchanges, Stores.exchanges,
Stores.exchangeDetails,
Stores.coins, Stores.coins,
Stores.denominations, Stores.denominations,
Stores.purchases, Stores.purchases,
@ -88,6 +91,7 @@ export async function exportBackup(
async (tx) => { async (tx) => {
const bs = await getWalletBackupState(ws, tx); const bs = await getWalletBackupState(ws, tx);
const backupExchangeDetails: BackupExchangeDetails[] = [];
const backupExchanges: BackupExchange[] = []; const backupExchanges: BackupExchange[] = [];
const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {}; const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {};
const backupDenominationsByExchange: { const backupDenominationsByExchange: {
@ -254,21 +258,22 @@ export async function exportBackup(
}); });
}); });
await tx.iter(Stores.exchanges).forEach((ex) => { 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. // Only back up permanently added exchanges.
if (!ex.details) {
return;
}
if (!ex.wireInfo) {
return;
}
if (!ex.addComplete) {
return;
}
if (!ex.permanent) {
return;
}
const wi = ex.wireInfo; const wi = ex.wireInfo;
const wireFees: BackupExchangeWireFee[] = []; const wireFees: BackupExchangeWireFee[] = [];
@ -285,23 +290,23 @@ export async function exportBackup(
} }
}); });
backupExchanges.push({ backupExchangeDetails.push({
base_url: ex.baseUrl, base_url: ex.exchangeBaseUrl,
reserve_closing_delay: ex.details.reserveClosingDelay, reserve_closing_delay: ex.reserveClosingDelay,
accounts: ex.wireInfo.accounts.map((x) => ({ accounts: ex.wireInfo.accounts.map((x) => ({
payto_uri: x.payto_uri, payto_uri: x.payto_uri,
master_sig: x.master_sig, master_sig: x.master_sig,
})), })),
auditors: ex.details.auditors.map((x) => ({ auditors: ex.auditors.map((x) => ({
auditor_pub: x.auditor_pub, auditor_pub: x.auditor_pub,
auditor_url: x.auditor_url, auditor_url: x.auditor_url,
denomination_keys: x.denomination_keys, denomination_keys: x.denomination_keys,
})), })),
master_public_key: ex.details.masterPublicKey, master_public_key: ex.masterPublicKey,
currency: ex.details.currency, currency: ex.currency,
protocol_version: ex.details.protocolVersion, protocol_version: ex.protocolVersion,
wire_fees: wireFees, wire_fees: wireFees,
signing_keys: ex.details.signingKeys.map((x) => ({ signing_keys: ex.signingKeys.map((x) => ({
key: x.key, key: x.key,
master_sig: x.master_sig, master_sig: x.master_sig,
stamp_end: x.stamp_end, stamp_end: x.stamp_end,
@ -310,8 +315,9 @@ export async function exportBackup(
})), })),
tos_etag_accepted: ex.termsOfServiceAcceptedEtag, tos_etag_accepted: ex.termsOfServiceAcceptedEtag,
tos_etag_last: ex.termsOfServiceLastEtag, tos_etag_last: ex.termsOfServiceLastEtag,
denominations: backupDenominationsByExchange[ex.baseUrl] ?? [], denominations:
reserves: backupReservesByExchange[ex.baseUrl] ?? [], backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [],
reserves: backupReservesByExchange[ex.exchangeBaseUrl] ?? [],
}); });
}); });
@ -451,6 +457,7 @@ export async function exportBackup(
schema_id: "gnu-taler-wallet-backup-content", schema_id: "gnu-taler-wallet-backup-content",
schema_version: 1, schema_version: 1,
exchanges: backupExchanges, exchanges: backupExchanges,
exchange_details: backupExchangeDetails,
wallet_root_pub: bs.walletRootPub, wallet_root_pub: bs.walletRootPub,
backup_providers: backupBackupProviders, backup_providers: backupBackupProviders,
current_device_id: bs.deviceId, current_device_id: bs.deviceId,

View File

@ -32,7 +32,6 @@ import {
Stores, Stores,
WalletContractData, WalletContractData,
DenomSelectionState, DenomSelectionState,
ExchangeWireInfo,
ExchangeUpdateStatus, ExchangeUpdateStatus,
DenominationStatus, DenominationStatus,
CoinSource, CoinSource,
@ -46,6 +45,7 @@ import {
RefundState, RefundState,
AbortStatus, AbortStatus,
RefreshSessionRecord, RefreshSessionRecord,
WireInfo,
} from "../../db.js"; } from "../../db.js";
import { TransactionHandle } from "../../index.js"; import { TransactionHandle } from "../../index.js";
import { PayCoinSelection } from "../../util/coinSelection"; import { PayCoinSelection } from "../../util/coinSelection";
@ -56,6 +56,7 @@ import { initRetryInfo } from "../../util/retries";
import { InternalWalletState } from "../state"; import { InternalWalletState } from "../state";
import { provideBackupState } from "./state"; import { provideBackupState } from "./state";
import { makeEventId, TombstoneTag } from "../transactions.js"; import { makeEventId, TombstoneTag } from "../transactions.js";
import { getExchangeDetails } from "../exchanges.js";
const logger = new Logger("operations/backup/import.ts"); const logger = new Logger("operations/backup/import.ts");
@ -102,13 +103,13 @@ async function recoverPayCoinSelection(
totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount; totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount;
if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) { if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) {
const exchange = await tx.get( const exchangeDetails = await getExchangeDetails(
Stores.exchanges, tx,
coinRecord.exchangeBaseUrl, coinRecord.exchangeBaseUrl,
); );
checkBackupInvariant(!!exchange); checkBackupInvariant(!!exchangeDetails);
let wireFee: AmountJson | undefined; let wireFee: AmountJson | undefined;
const feesForType = exchange.wireInfo?.feesForType; const feesForType = exchangeDetails.wireInfo.feesForType;
checkBackupInvariant(!!feesForType); checkBackupInvariant(!!feesForType);
for (const fee of feesForType[contractData.wireMethod] || []) { for (const fee of feesForType[contractData.wireMethod] || []) {
if ( if (
@ -218,6 +219,7 @@ export async function importBackup(
[ [
Stores.config, Stores.config,
Stores.exchanges, Stores.exchanges,
Stores.exchangeDetails,
Stores.coins, Stores.coins,
Stores.denominations, Stores.denominations,
Stores.purchases, Stores.purchases,
@ -245,21 +247,46 @@ export async function importBackup(
const tombstoneSet = new Set(backupBlob.tombstones); const tombstoneSet = new Set(backupBlob.tombstones);
// FIXME: Validate that the "details pointer" is correct
for (const backupExchange of backupBlob.exchanges) { for (const backupExchange of backupBlob.exchanges) {
const existingExchange = await tx.get( const existingExchange = await tx.get(
Stores.exchanges, Stores.exchanges,
backupExchange.base_url, 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,
});
}
if (!existingExchange) { for (const backupExchangeDetails of backupBlob.exchange_details) {
const wireInfo: ExchangeWireInfo = { const existingExchangeDetails = await tx.get(Stores.exchangeDetails, [
accounts: backupExchange.accounts.map((x) => ({ backupExchangeDetails.base_url,
backupExchangeDetails.currency,
backupExchangeDetails.master_public_key,
]);
if (!existingExchangeDetails) {
const wireInfo: WireInfo = {
accounts: backupExchangeDetails.accounts.map((x) => ({
master_sig: x.master_sig, master_sig: x.master_sig,
payto_uri: x.payto_uri, payto_uri: x.payto_uri,
})), })),
feesForType: {}, feesForType: {},
}; };
for (const fee of backupExchange.wire_fees) { for (const fee of backupExchangeDetails.wire_fees) {
const w = (wireInfo.feesForType[fee.wire_type] ??= []); const w = (wireInfo.feesForType[fee.wire_type] ??= []);
w.push({ w.push({
closingFee: Amounts.parseOrThrow(fee.closing_fee), closingFee: Amounts.parseOrThrow(fee.closing_fee),
@ -269,48 +296,39 @@ export async function importBackup(
wireFee: Amounts.parseOrThrow(fee.wire_fee), wireFee: Amounts.parseOrThrow(fee.wire_fee),
}); });
} }
await tx.put(Stores.exchanges, { await tx.put(Stores.exchangeDetails, {
addComplete: true, exchangeBaseUrl: backupExchangeDetails.base_url,
baseUrl: backupExchange.base_url, termsOfServiceAcceptedEtag: backupExchangeDetails.tos_etag_accepted,
builtIn: false,
updateReason: undefined,
permanent: true,
retryInfo: initRetryInfo(),
termsOfServiceAcceptedEtag: backupExchange.tos_etag_accepted,
termsOfServiceText: undefined, termsOfServiceText: undefined,
termsOfServiceLastEtag: backupExchange.tos_etag_last, termsOfServiceLastEtag: backupExchangeDetails.tos_etag_last,
updateStarted: getTimestampNow(),
updateStatus: ExchangeUpdateStatus.FetchKeys,
wireInfo, wireInfo,
details: { currency: backupExchangeDetails.currency,
currency: backupExchange.currency, auditors: backupExchangeDetails.auditors.map((x) => ({
reserveClosingDelay: backupExchange.reserve_closing_delay, auditor_pub: x.auditor_pub,
auditors: backupExchange.auditors.map((x) => ({ auditor_url: x.auditor_url,
auditor_pub: x.auditor_pub, denomination_keys: x.denomination_keys,
auditor_url: x.auditor_url, })),
denomination_keys: x.denomination_keys, lastUpdateTime: { t_ms: "never" },
})), masterPublicKey: backupExchangeDetails.master_public_key,
lastUpdateTime: { t_ms: "never" }, nextUpdateTime: { t_ms: "never" },
masterPublicKey: backupExchange.master_public_key, protocolVersion: backupExchangeDetails.protocol_version,
nextUpdateTime: { t_ms: "never" }, reserveClosingDelay: backupExchangeDetails.reserve_closing_delay,
protocolVersion: backupExchange.protocol_version, signingKeys: backupExchangeDetails.signing_keys.map((x) => ({
signingKeys: backupExchange.signing_keys.map((x) => ({ key: x.key,
key: x.key, master_sig: x.master_sig,
master_sig: x.master_sig, stamp_end: x.stamp_end,
stamp_end: x.stamp_end, stamp_expire: x.stamp_expire,
stamp_expire: x.stamp_expire, stamp_start: x.stamp_start,
stamp_start: x.stamp_start, })),
})),
},
}); });
} }
for (const backupDenomination of backupExchange.denominations) { for (const backupDenomination of backupExchangeDetails.denominations) {
const denomPubHash = const denomPubHash =
cryptoComp.denomPubToHash[backupDenomination.denom_pub]; cryptoComp.denomPubToHash[backupDenomination.denom_pub];
checkLogicInvariant(!!denomPubHash); checkLogicInvariant(!!denomPubHash);
const existingDenom = await tx.get(Stores.denominations, [ const existingDenom = await tx.get(Stores.denominations, [
backupExchange.base_url, backupExchangeDetails.base_url,
denomPubHash, denomPubHash,
]); ]);
if (!existingDenom) { if (!existingDenom) {
@ -321,7 +339,7 @@ export async function importBackup(
await tx.put(Stores.denominations, { await tx.put(Stores.denominations, {
denomPub: backupDenomination.denom_pub, denomPub: backupDenomination.denom_pub,
denomPubHash: denomPubHash, denomPubHash: denomPubHash,
exchangeBaseUrl: backupExchange.base_url, exchangeBaseUrl: backupExchangeDetails.base_url,
feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit), feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit),
feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh), feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh),
feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund), feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund),
@ -378,7 +396,7 @@ export async function importBackup(
denomSig: backupCoin.denom_sig, denomSig: backupCoin.denom_sig,
coinPub: compCoin.coinPub, coinPub: compCoin.coinPub,
suspended: false, suspended: false,
exchangeBaseUrl: backupExchange.base_url, exchangeBaseUrl: backupExchangeDetails.base_url,
denomPub: backupDenomination.denom_pub, denomPub: backupDenomination.denom_pub,
denomPubHash, denomPubHash,
status: backupCoin.fresh 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 = const reservePub =
cryptoComp.reservePrivToPub[backupReserve.reserve_priv]; cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub); const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
@ -414,7 +432,7 @@ export async function importBackup(
await tx.put(Stores.reserves, { await tx.put(Stores.reserves, {
currency: instructedAmount.currency, currency: instructedAmount.currency,
instructedAmount, instructedAmount,
exchangeBaseUrl: backupExchange.base_url, exchangeBaseUrl: backupExchangeDetails.base_url,
reservePub, reservePub,
reservePriv: backupReserve.reserve_priv, reservePriv: backupReserve.reserve_priv,
requestedQuery: false, requestedQuery: false,
@ -436,7 +454,7 @@ export async function importBackup(
reserveStatus: ReserveRecordStatus.QUERYING_STATUS, reserveStatus: ReserveRecordStatus.QUERYING_STATUS,
initialDenomSel: await getDenomSelStateFromBackup( initialDenomSel: await getDenomSelStateFromBackup(
tx, tx,
backupExchange.base_url, backupExchangeDetails.base_url,
backupReserve.initial_selected_denoms, backupReserve.initial_selected_denoms,
), ),
}); });
@ -457,10 +475,10 @@ export async function importBackup(
await tx.put(Stores.withdrawalGroups, { await tx.put(Stores.withdrawalGroups, {
denomsSel: await getDenomSelStateFromBackup( denomsSel: await getDenomSelStateFromBackup(
tx, tx,
backupExchange.base_url, backupExchangeDetails.base_url,
backupWg.selected_denoms, backupWg.selected_denoms,
), ),
exchangeBaseUrl: backupExchange.base_url, exchangeBaseUrl: backupExchangeDetails.base_url,
lastError: undefined, lastError: undefined,
rawWithdrawalAmount: Amounts.parseOrThrow( rawWithdrawalAmount: Amounts.parseOrThrow(
backupWg.raw_withdrawal_amount, backupWg.raw_withdrawal_amount,

View File

@ -155,8 +155,8 @@ async function computeBackupCryptoData(
proposalNoncePrivToPub: {}, proposalNoncePrivToPub: {},
reservePrivToPub: {}, reservePrivToPub: {},
}; };
for (const backupExchange of backupContent.exchanges) { for (const backupExchangeDetails of backupContent.exchange_details) {
for (const backupDenom of backupExchange.denominations) { for (const backupDenom of backupExchangeDetails.denominations) {
for (const backupCoin of backupDenom.coins) { for (const backupCoin of backupDenom.coins) {
const coinPub = encodeCrock( const coinPub = encodeCrock(
eddsaGetPublic(decodeCrock(backupCoin.coin_priv)), eddsaGetPublic(decodeCrock(backupCoin.coin_priv)),
@ -175,7 +175,7 @@ async function computeBackupCryptoData(
hash(decodeCrock(backupDenom.denom_pub)), hash(decodeCrock(backupDenom.denom_pub)),
); );
} }
for (const backupReserve of backupExchange.reserves) { for (const backupReserve of backupExchangeDetails.reserves) {
cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock( cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock(
eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)), eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)),
); );

View File

@ -19,6 +19,7 @@
*/ */
import { ExchangeRecord, Stores } from "../db.js"; import { ExchangeRecord, Stores } from "../db.js";
import { Logger } from "../index.js"; import { Logger } from "../index.js";
import { getExchangeDetails } from "./exchanges.js";
import { InternalWalletState } from "./state.js"; import { InternalWalletState } from "./state.js";
const logger = new Logger("currencies.ts"); const logger = new Logger("currencies.ts");
@ -37,7 +38,12 @@ export async function getExchangeTrust(
): Promise<TrustInfo> { ): Promise<TrustInfo> {
let isTrusted = false; let isTrusted = false;
let isAudited = 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) { if (!exchangeDetails) {
throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
} }

View File

@ -58,6 +58,7 @@ import { InternalWalletState } from "./state";
import { Logger } from "../util/logging.js"; import { Logger } from "../util/logging.js";
import { DepositGroupRecord, Stores } from "../db.js"; import { DepositGroupRecord, Stores } from "../db.js";
import { guardOperationException } from "./errors.js"; import { guardOperationException } from "./errors.js";
import { getExchangeDetails } from "./exchanges.js";
/** /**
* Logger. * Logger.
@ -308,14 +309,17 @@ export async function createDepositGroup(
const allExchanges = await ws.db.iter(Stores.exchanges).toArray(); const allExchanges = await ws.db.iter(Stores.exchanges).toArray();
const exchangeInfos: { url: string; master_pub: string }[] = []; const exchangeInfos: { url: string; master_pub: string }[] = [];
for (const e of allExchanges) { for (const e of allExchanges) {
if (!e.details) { const details = await ws.db.runWithReadTransaction(
continue; [Stores.exchanges, Stores.exchangeDetails],
} async (tx) => {
if (e.details.currency != amount.currency) { return getExchangeDetails(tx, e.baseUrl);
},
);
if (!details) {
continue; continue;
} }
exchangeInfos.push({ exchangeInfos.push({
master_pub: e.details.masterPublicKey, master_pub: details.masterPublicKey,
url: e.baseUrl, url: e.baseUrl,
}); });
} }

View File

@ -19,18 +19,23 @@
*/ */
import { import {
Amounts, Amounts,
Auditor,
codecForExchangeKeysJson, codecForExchangeKeysJson,
codecForExchangeWireJson, codecForExchangeWireJson,
compare, compare,
Denomination, Denomination,
Duration, Duration,
durationFromSpec, durationFromSpec,
ExchangeSignKeyJson,
ExchangeWireJson,
getTimestampNow, getTimestampNow,
isTimestampExpired, isTimestampExpired,
NotificationType, NotificationType,
parsePaytoUri, parsePaytoUri,
Recoup,
TalerErrorCode, TalerErrorCode,
TalerErrorDetails, TalerErrorDetails,
Timestamp,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
DenominationRecord, DenominationRecord,
@ -40,6 +45,8 @@ import {
ExchangeUpdateStatus, ExchangeUpdateStatus,
WireFee, WireFee,
ExchangeUpdateReason, ExchangeUpdateReason,
ExchangeDetailsRecord,
WireInfo,
} from "../db.js"; } from "../db.js";
import { import {
Logger, Logger,
@ -47,14 +54,16 @@ import {
readSuccessResponseJsonOrThrow, readSuccessResponseJsonOrThrow,
getExpiryTimestamp, getExpiryTimestamp,
readSuccessResponseTextOrThrow, readSuccessResponseTextOrThrow,
encodeCrock,
hash,
decodeCrock,
} from "../index.js"; } from "../index.js";
import { j2s, canonicalizeBaseUrl } from "@gnu-taler/taler-util"; import { j2s, canonicalizeBaseUrl } from "@gnu-taler/taler-util";
import { checkDbInvariant } from "../util/invariants.js";
import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries.js"; import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries.js";
import { import {
makeErrorDetails, makeErrorDetails,
OperationFailedAndReportedError,
guardOperationException, guardOperationException,
OperationFailedError,
} from "./errors.js"; } from "./errors.js";
import { createRecoupGroup, processRecoupGroup } from "./recoup.js"; import { createRecoupGroup, processRecoupGroup } from "./recoup.js";
import { InternalWalletState } from "./state.js"; import { InternalWalletState } from "./state.js";
@ -62,15 +71,17 @@ import {
WALLET_CACHE_BREAKER_CLIENT_VERSION, WALLET_CACHE_BREAKER_CLIENT_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "./versions.js"; } 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"); const logger = new Logger("exchanges.ts");
async function denominationRecordFromKeys( function denominationRecordFromKeys(
ws: InternalWalletState,
exchangeBaseUrl: string, exchangeBaseUrl: string,
denomIn: Denomination, denomIn: Denomination,
): Promise<DenominationRecord> { ): DenominationRecord {
const denomPubHash = await ws.cryptoApi.hashEncoded(denomIn.denom_pub); const denomPubHash = encodeCrock(hash(decodeCrock(denomIn.denom_pub)));
const d: DenominationRecord = { const d: DenominationRecord = {
denomPub: denomIn.denom_pub, denomPub: denomIn.denom_pub,
denomPubHash, denomPubHash,
@ -115,29 +126,206 @@ function getExchangeRequestTimeout(e: ExchangeRecord): Duration {
return { d_ms: 5000 }; return { d_ms: 5000 };
} }
/** interface ExchangeTosDownloadResult {
* Fetch the exchange's /keys and update our database accordingly. tosText: string;
* tosEtag: string;
* Exceptions thrown in this method must be caught and reported }
* in the pending operations.
*/
async function updateExchangeWithKeys(
ws: InternalWalletState,
baseUrl: string,
): Promise<void> {
const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl);
if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) { async function downloadExchangeWithTermsOfService(
exchangeBaseUrl: string,
http: HttpRequestLibrary,
timeout: Duration,
): Promise<ExchangeTosDownloadResult> {
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<ExchangeDetailsRecord | undefined> {
const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
if (!r) {
return; return;
} }
const dp = r.detailsPointer;
if (!dp) {
return;
}
const { currency, masterPublicKey } = dp;
return await tx.get(Stores.exchangeDetails, [
r.baseUrl,
currency,
masterPublicKey,
]);
}
logger.info("updating exchange /keys info"); export async function acceptExchangeTermsOfService(
ws: InternalWalletState,
exchangeBaseUrl: string,
etag: string | undefined,
): Promise<void> {
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<WireInfo> {
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 wire information for an exchange.
*
* @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
*/
async function downloadExchangeWithWireInfo(
exchangeBaseUrl: string,
http: HttpRequestLibrary,
timeout: Duration,
): Promise<ExchangeWireJson> {
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,
forceNow = false,
): Promise<{
exchange: ExchangeRecord;
exchangeDetails: ExchangeDetailsRecord;
}> {
const onOpErr = (e: TalerErrorDetails): Promise<void> =>
handleExchangeUpdateError(ws, baseUrl, e);
return await guardOperationException(
() => updateExchangeFromUrlImpl(ws, baseUrl, forceNow),
onOpErr,
);
}
async function provideExchangeRecord(
ws: InternalWalletState,
baseUrl: string,
now: Timestamp,
): Promise<ExchangeRecord> {
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;
}
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<ExchangeKeysDownloadResult> {
const keysUrl = new URL("keys", baseUrl); const keysUrl = new URL("keys", baseUrl);
keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
const resp = await ws.http.get(keysUrl.href, { const resp = await http.get(keysUrl.href, {
timeout: getExchangeRequestTimeout(existingExchangeRecord), timeout,
}); });
const exchangeKeysJson = await readSuccessResponseJsonOrThrow( const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
resp, resp,
@ -155,8 +343,7 @@ async function updateExchangeWithKeys(
exchangeBaseUrl: baseUrl, exchangeBaseUrl: baseUrl,
}, },
); );
await handleExchangeUpdateError(ws, baseUrl, opErr); throw new OperationFailedError(opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const protocolVersion = exchangeKeysJson.version; const protocolVersion = exchangeKeysJson.version;
@ -171,70 +358,138 @@ async function updateExchangeWithKeys(
walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
}, },
); );
await handleExchangeUpdateError(ws, baseUrl, opErr); throw new OperationFailedError(opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) const currency = Amounts.parseOrThrow(
.currency; exchangeKeysJson.denoms[0].value,
).currency.toUpperCase();
logger.trace("processing denominations"); return {
masterPublicKey: exchangeKeysJson.master_public_key,
const newDenominations = await Promise.all( currency,
exchangeKeysJson.denoms.map((d) => auditors: exchangeKeysJson.auditors,
denominationRecordFromKeys(ws, baseUrl, d), 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( const updated = await ws.db.runWithWriteTransaction(
[Stores.exchanges, Stores.denominations, Stores.recoupGroups, Stores.coins], [
Stores.exchanges,
Stores.exchangeDetails,
Stores.denominations,
Stores.recoupGroups,
Stores.coins,
],
async (tx) => { async (tx) => {
const r = await tx.get(Stores.exchanges, baseUrl); const r = await tx.get(Stores.exchanges, baseUrl);
if (!r) { if (!r) {
logger.warn(`exchange ${baseUrl} no longer present`); logger.warn(`exchange ${baseUrl} no longer present`);
return; return;
} }
if (r.details) { let details = await getExchangeDetails(tx, r.baseUrl);
if (details) {
// FIXME: We need to do some consistency checks! // FIXME: We need to do some consistency checks!
} }
// FIXME: validate signing keys and merge with old set // FIXME: validate signing keys and merge with old set
r.details = { details = {
auditors: exchangeKeysJson.auditors, auditors: keysInfo.auditors,
currency: currency, currency: keysInfo.currency,
lastUpdateTime: lastUpdateTimestamp, lastUpdateTime: now,
masterPublicKey: exchangeKeysJson.master_public_key, masterPublicKey: keysInfo.masterPublicKey,
protocolVersion: protocolVersion, protocolVersion: keysInfo.protocolVersion,
signingKeys: exchangeKeysJson.signkeys, signingKeys: keysInfo.signingKeys,
nextUpdateTime: getExpiryTimestamp(resp, { nextUpdateTime: keysInfo.expiry,
minDuration: durationFromSpec({ hours: 1 }), reserveClosingDelay: keysInfo.reserveClosingDelay,
}), exchangeBaseUrl: r.baseUrl,
reserveClosingDelay: exchangeKeysJson.reserve_closing_delay, wireInfo,
termsOfServiceText: tosDownload.tosText,
termsOfServiceAcceptedEtag: undefined,
termsOfServiceLastEtag: tosDownload.tosEtag,
}; };
r.updateStatus = ExchangeUpdateStatus.FetchWire; r.updateStatus = ExchangeUpdateStatus.FetchWire;
// FIXME: only update if pointer got updated
r.lastError = undefined; r.lastError = undefined;
r.retryInfo = initRetryInfo(false); 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.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, [ const oldDenom = await tx.get(Stores.denominations, [
baseUrl, baseUrl,
newDenom.denomPubHash, currentDenom.denomPubHash,
]); ]);
if (oldDenom) { if (oldDenom) {
// FIXME: Do consistency check // FIXME: Do consistency check
} else { } else {
await tx.put(Stores.denominations, newDenom); await tx.put(Stores.denominations, currentDenom);
} }
} }
// Handle recoup // Handle recoup
const recoupDenomList = exchangeKeysJson.recoup ?? []; const recoupDenomList = keysInfo.recoup;
const newlyRevokedCoinPubs: string[] = []; const newlyRevokedCoinPubs: string[] = [];
logger.trace("recoup list from exchange", recoupDenomList); logger.trace("recoup list from exchange", recoupDenomList);
for (const recoupInfo of recoupDenomList) { for (const recoupInfo of recoupDenomList) {
@ -264,8 +519,12 @@ async function updateExchangeWithKeys(
} }
if (newlyRevokedCoinPubs.length != 0) { if (newlyRevokedCoinPubs.length != 0) {
logger.trace("recouping coins", newlyRevokedCoinPubs); 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"); if (!updated) {
} throw Error("something went wrong with updating the exchange");
}
async function updateExchangeFinalize( return {
ws: InternalWalletState, exchange: updated.exchange,
exchangeBaseUrl: string, exchangeDetails: updated.exchangeDetails,
): Promise<void> {
const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
if (!exchange) {
return;
}
if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
return;
}
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<void> {
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",
}; };
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<void> {
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<void> {
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<ExchangeRecord> {
const onOpErr = (e: TalerErrorDetails): Promise<void> =>
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<ExchangeRecord> {
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( export async function getExchangePaytoUri(
ws: InternalWalletState, ws: InternalWalletState,
exchangeBaseUrl: string, exchangeBaseUrl: string,
@ -535,15 +553,14 @@ export async function getExchangePaytoUri(
): Promise<string> { ): Promise<string> {
// We do the update here, since the exchange might not even exist // We do the update here, since the exchange might not even exist
// yet in our database. // yet in our database.
const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl); const details = await ws.db.runWithReadTransaction(
if (!exchangeRecord) { [Stores.exchangeDetails, Stores.exchanges],
throw Error(`Exchange '${exchangeBaseUrl}' not found.`); async (tx) => {
} return getExchangeDetails(tx, exchangeBaseUrl);
const exchangeWireInfo = exchangeRecord.wireInfo; },
if (!exchangeWireInfo) { );
throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); const accounts = details?.wireInfo.accounts ?? [];
} for (const account of accounts) {
for (const account of exchangeWireInfo.accounts) {
const res = parsePaytoUri(account.payto_uri); const res = parsePaytoUri(account.payto_uri);
if (!res) { if (!res) {
continue; continue;

View File

@ -94,6 +94,7 @@ import {
import { getTotalRefreshCost, createRefreshGroup } from "./refresh.js"; import { getTotalRefreshCost, createRefreshGroup } from "./refresh.js";
import { InternalWalletState, EXCHANGE_COINS_LOCK } from "./state.js"; import { InternalWalletState, EXCHANGE_COINS_LOCK } from "./state.js";
import { ContractTermsUtil } from "../util/contractTerms.js"; import { ContractTermsUtil } from "../util/contractTerms.js";
import { getExchangeDetails } from "./exchanges.js";
/** /**
* Logger. * Logger.
@ -170,11 +171,16 @@ export async function getEffectiveDepositAmount(
exchangeSet.add(coin.exchangeBaseUrl); exchangeSet.add(coin.exchangeBaseUrl);
} }
for (const exchangeUrl of exchangeSet.values()) { for (const exchangeUrl of exchangeSet.values()) {
const exchange = await ws.db.get(Stores.exchanges, exchangeUrl); const exchangeDetails = await ws.db.runWithReadTransaction(
if (!exchange?.wireInfo) { [Stores.exchanges, Stores.exchangeDetails],
async (tx) => {
return getExchangeDetails(tx, exchangeUrl);
},
);
if (!exchangeDetails) {
continue; continue;
} }
const fee = exchange.wireInfo.feesForType[wireType].find((x) => { const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
return timestampIsBetween(getTimestampNow(), x.startStamp, x.endStamp); return timestampIsBetween(getTimestampNow(), x.startStamp, x.endStamp);
})?.wireFee; })?.wireFee;
if (fee) { if (fee) {
@ -240,11 +246,16 @@ export async function getCandidatePayCoins(
const exchanges = await ws.db.iter(Stores.exchanges).toArray(); const exchanges = await ws.db.iter(Stores.exchanges).toArray();
for (const exchange of exchanges) { for (const exchange of exchanges) {
let isOkay = false; 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) { if (!exchangeDetails) {
continue; continue;
} }
const exchangeFees = exchange.wireInfo; const exchangeFees = exchangeDetails.wireInfo;
if (!exchangeFees) { if (!exchangeFees) {
continue; continue;
} }

View File

@ -37,9 +37,10 @@ import {
getDurationRemaining, getDurationRemaining,
durationMin, durationMin,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { Store, TransactionHandle } from "../util/query"; import { TransactionHandle } from "../util/query";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { getBalancesInsideTransaction } from "./balance"; import { getBalancesInsideTransaction } from "./balance";
import { getExchangeDetails } from "./exchanges.js";
function updateRetryDelay( function updateRetryDelay(
oldDelay: Duration, oldDelay: Duration,
@ -52,12 +53,14 @@ function updateRetryDelay(
} }
async function gatherExchangePending( async function gatherExchangePending(
tx: TransactionHandle<typeof Stores.exchanges>, tx: TransactionHandle<
typeof Stores.exchanges | typeof Stores.exchangeDetails
>,
now: Timestamp, now: Timestamp,
resp: PendingOperationsResponse, resp: PendingOperationsResponse,
onlyDue = false, onlyDue = false,
): Promise<void> { ): Promise<void> {
await tx.iter(Stores.exchanges).forEach((e) => { await tx.iter(Stores.exchanges).forEachAsync(async (e) => {
switch (e.updateStatus) { switch (e.updateStatus) {
case ExchangeUpdateStatus.Finished: case ExchangeUpdateStatus.Finished:
if (e.lastError) { if (e.lastError) {
@ -71,30 +74,9 @@ async function gatherExchangePending(
}, },
}); });
} }
if (!e.details) { const details = await getExchangeDetails(tx, e.baseUrl);
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 keysUpdateRequired = const keysUpdateRequired =
e.details && e.details.nextUpdateTime.t_ms < now.t_ms; details && details.nextUpdateTime.t_ms < now.t_ms;
if (keysUpdateRequired) { if (keysUpdateRequired) {
resp.pendingOperations.push({ resp.pendingOperations.push({
type: PendingOperationType.ExchangeUpdate, type: PendingOperationType.ExchangeUpdate,
@ -106,7 +88,7 @@ async function gatherExchangePending(
}); });
} }
if ( if (
e.details && details &&
(!e.nextRefreshCheck || e.nextRefreshCheck.t_ms < now.t_ms) (!e.nextRefreshCheck || e.nextRefreshCheck.t_ms < now.t_ms)
) { ) {
resp.pendingOperations.push({ resp.pendingOperations.push({

View File

@ -24,9 +24,25 @@
/** /**
* Imports. * 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 { 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 { readSuccessResponseJsonOrThrow } from "../util/http";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
@ -34,6 +50,7 @@ import { TransactionHandle } from "../util/query";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries"; import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries";
import { URL } from "../util/url"; import { URL } from "../util/url";
import { guardOperationException } from "./errors"; import { guardOperationException } from "./errors";
import { getExchangeDetails } from "./exchanges.js";
import { createRefreshGroup, processRefreshGroup } from "./refresh"; import { createRefreshGroup, processRefreshGroup } from "./refresh";
import { getReserveRequestTimeout, processReserve } from "./reserves"; import { getReserveRequestTimeout, processReserve } from "./reserves";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
@ -155,12 +172,13 @@ async function recoupWithdrawCoin(
throw Error(`Coin's reserve doesn't match reserve on recoup`); throw Error(`Coin's reserve doesn't match reserve on recoup`);
} }
const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl); const exchangeDetails = await ws.db.runWithReadTransaction(
if (!exchange) { [Stores.exchanges, Stores.exchangeDetails],
// FIXME: report inconsistency? async (tx) => {
return; return getExchangeDetails(tx, reserve.exchangeBaseUrl);
} },
const exchangeDetails = exchange.details; );
if (!exchangeDetails) { if (!exchangeDetails) {
// FIXME: report inconsistency? // FIXME: report inconsistency?
return; return;
@ -232,13 +250,14 @@ async function recoupRefreshCoin(
throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`); throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
} }
const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl); const exchangeDetails = await ws.db.runWithReadTransaction(
if (!exchange) { [Stores.exchanges, Stores.exchangeDetails],
logger.warn("exchange for recoup does not exist anymore"); async (tx) => {
// FIXME: report inconsistency? // FIXME: Get the exchange details based on the
return; // exchange master public key instead of via just the URL.
} return getExchangeDetails(tx, coin.exchangeBaseUrl);
const exchangeDetails = exchange.details; },
);
if (!exchangeDetails) { if (!exchangeDetails) {
// FIXME: report inconsistency? // FIXME: report inconsistency?
logger.warn("exchange details for recoup not found"); logger.warn("exchange details for recoup not found");

View File

@ -122,7 +122,7 @@ async function refreshCreateSession(
throw Error("Can't refresh, coin not found"); throw Error("Can't refresh, coin not found");
} }
const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
if (!exchange) { if (!exchange) {
throw Error("db inconsistent: exchange of coin not found"); throw Error("db inconsistent: exchange of coin not found");
} }

View File

@ -58,7 +58,11 @@ import {
updateRetryInfoTimeout, updateRetryInfoTimeout,
} from "../util/retries.js"; } from "../util/retries.js";
import { guardOperationException, OperationFailedError } from "./errors.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 { InternalWalletState } from "./state.js";
import { import {
updateWithdrawalDenoms, updateWithdrawalDenoms,
@ -148,12 +152,15 @@ export async function createReserve(
}; };
const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
const exchangeDetails = exchangeInfo.details; const exchangeDetails = exchangeInfo.exchangeDetails;
if (!exchangeDetails) { if (!exchangeDetails) {
logger.trace(exchangeDetails); logger.trace(exchangeDetails);
throw Error("exchange not updated"); 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( const resp = await ws.db.runWithWriteTransaction(
[Stores.exchangeTrustStore, Stores.reserves, Stores.bankWithdrawUris], [Stores.exchangeTrustStore, Stores.reserves, Stores.bankWithdrawUris],
@ -728,7 +735,11 @@ export async function createTalerWithdrawReserve(
* Get payto URIs needed to fund a reserve. * Get payto URIs needed to fund a reserve.
*/ */
export async function getFundingPaytoUris( export async function getFundingPaytoUris(
tx: TransactionHandle<typeof Stores.reserves | typeof Stores.exchanges>, tx: TransactionHandle<
| typeof Stores.reserves
| typeof Stores.exchanges
| typeof Stores.exchangeDetails
>,
reservePub: string, reservePub: string,
): Promise<string[]> { ): Promise<string[]> {
const r = await tx.get(Stores.reserves, reservePub); const r = await tx.get(Stores.reserves, reservePub);
@ -736,13 +747,13 @@ export async function getFundingPaytoUris(
logger.error(`reserve ${reservePub} not found (DB corrupted?)`); logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
return []; return [];
} }
const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl); const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl);
if (!exchange) { if (!exchangeDetails) {
logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`); logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
return []; return [];
} }
const plainPaytoUris = const plainPaytoUris =
exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
if (!plainPaytoUris) { if (!plainPaytoUris) {
logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`); logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
return []; return [];

View File

@ -38,6 +38,7 @@ import {
OrderShortInfo, OrderShortInfo,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { getFundingPaytoUris } from "./reserves"; import { getFundingPaytoUris } from "./reserves";
import { getExchangeDetails } from "./exchanges.js";
/** /**
* Create an event ID from the type and the primary key for the event. * 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.coins,
Stores.denominations, Stores.denominations,
Stores.exchanges, Stores.exchanges,
Stores.exchangeDetails,
Stores.proposals, Stores.proposals,
Stores.purchases, Stores.purchases,
Stores.refreshGroups, Stores.refreshGroups,
@ -134,15 +136,18 @@ export async function getTransactions(
bankConfirmationUrl: r.bankInfo.confirmUrl, bankConfirmationUrl: r.bankInfo.confirmUrl,
}; };
} else { } else {
const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl); const exchangeDetails = await getExchangeDetails(
if (!exchange) { tx,
wsr.exchangeBaseUrl,
);
if (!exchangeDetails) {
// FIXME: report somehow // FIXME: report somehow
return; return;
} }
withdrawalDetails = { withdrawalDetails = {
type: WithdrawalType.ManualTransfer, type: WithdrawalType.ManualTransfer,
exchangePaytoUris: exchangePaytoUris:
exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [], exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [],
}; };
} }
transactions.push({ transactions.push({

View File

@ -35,7 +35,7 @@ import {
PlanchetRecord, PlanchetRecord,
DenomSelectionState, DenomSelectionState,
ExchangeRecord, ExchangeRecord,
ExchangeWireInfo, ExchangeDetailsRecord,
} from "../db"; } from "../db";
import { import {
BankWithdrawDetails, BankWithdrawDetails,
@ -51,7 +51,7 @@ import {
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
import { updateExchangeFromUrl } from "./exchanges"; import { getExchangeDetails, updateExchangeFromUrl } from "./exchanges";
import { import {
WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION,
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
@ -94,6 +94,8 @@ interface ExchangeWithdrawDetails {
*/ */
exchangeInfo: ExchangeRecord; exchangeInfo: ExchangeRecord;
exchangeDetails: ExchangeDetailsRecord;
/** /**
* Filtered wire info to send to the bank. * Filtered wire info to send to the bank.
*/ */
@ -114,11 +116,6 @@ interface ExchangeWithdrawDetails {
*/ */
overhead: AmountJson; overhead: AmountJson;
/**
* Wire fees from the exchange.
*/
wireFees: ExchangeWireInfo;
/** /**
* Does the wallet know about an auditor for * Does the wallet know about an auditor for
* the exchange that the reserve. * the exchange that the reserve.
@ -639,12 +636,12 @@ export async function updateWithdrawalDenoms(
ws: InternalWalletState, ws: InternalWalletState,
exchangeBaseUrl: string, exchangeBaseUrl: string,
): Promise<void> { ): Promise<void> {
const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl); const exchangeDetails = await ws.db.runWithReadTransaction(
if (!exchange) { [Stores.exchanges, Stores.exchangeDetails],
logger.error("exchange not found"); async (tx) => {
throw Error(`exchange ${exchangeBaseUrl} not found`); return getExchangeDetails(tx, exchangeBaseUrl);
} },
const exchangeDetails = exchange.details; );
if (!exchangeDetails) { if (!exchangeDetails) {
logger.error("exchange details not available"); logger.error("exchange details not available");
throw Error(`exchange ${exchangeBaseUrl} details not available`); throw Error(`exchange ${exchangeBaseUrl} details not available`);
@ -849,25 +846,19 @@ export async function getExchangeWithdrawalInfo(
baseUrl: string, baseUrl: string,
amount: AmountJson, amount: AmountJson,
): Promise<ExchangeWithdrawDetails> { ): Promise<ExchangeWithdrawDetails> {
const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl); const { exchange, exchangeDetails } = await updateExchangeFromUrl(
const exchangeDetails = exchangeInfo.details; ws,
if (!exchangeDetails) { baseUrl,
throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); );
}
const exchangeWireInfo = exchangeInfo.wireInfo;
if (!exchangeWireInfo) {
throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`);
}
await updateWithdrawalDenoms(ws, baseUrl); await updateWithdrawalDenoms(ws, baseUrl);
const denoms = await getCandidateWithdrawalDenoms(ws, baseUrl); const denoms = await getCandidateWithdrawalDenoms(ws, baseUrl);
const selectedDenoms = selectWithdrawalDenominations(amount, denoms); const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
const exchangeWireAccounts: string[] = []; const exchangeWireAccounts: string[] = [];
for (const account of exchangeWireInfo.accounts) { for (const account of exchangeDetails.wireInfo.accounts) {
exchangeWireAccounts.push(account.payto_uri); exchangeWireAccounts.push(account.payto_uri);
} }
const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo); const { isTrusted, isAudited } = await getExchangeTrust(ws, exchange);
let earliestDepositExpiration = let earliestDepositExpiration =
selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit; selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
@ -904,10 +895,10 @@ export async function getExchangeWithdrawalInfo(
let tosAccepted = false; let tosAccepted = false;
if (exchangeInfo.termsOfServiceLastEtag) { if (exchangeDetails.termsOfServiceLastEtag) {
if ( if (
exchangeInfo.termsOfServiceAcceptedEtag === exchangeDetails.termsOfServiceAcceptedEtag ===
exchangeInfo.termsOfServiceLastEtag exchangeDetails.termsOfServiceLastEtag
) { ) {
tosAccepted = true; tosAccepted = true;
} }
@ -920,7 +911,8 @@ export async function getExchangeWithdrawalInfo(
const ret: ExchangeWithdrawDetails = { const ret: ExchangeWithdrawDetails = {
earliestDepositExpiration, earliestDepositExpiration,
exchangeInfo, exchangeInfo: exchange,
exchangeDetails,
exchangeWireAccounts, exchangeWireAccounts,
exchangeVersion: exchangeDetails.protocolVersion || "unknown", exchangeVersion: exchangeDetails.protocolVersion || "unknown",
isAudited, isAudited,
@ -932,7 +924,6 @@ export async function getExchangeWithdrawalInfo(
trustedAuditorPubs: [], trustedAuditorPubs: [],
versionMatch, versionMatch,
walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
wireFees: exchangeWireInfo,
withdrawFee, withdrawFee,
termsOfServiceAccepted: tosAccepted, termsOfServiceAccepted: tosAccepted,
}; };
@ -960,29 +951,25 @@ export async function getWithdrawalDetailsForUri(
} }
} }
const exchangesRes: (ExchangeListItem | undefined)[] = await ws.db const exchanges: ExchangeListItem[] = [];
.iter(Stores.exchanges)
.map((x) => { const exchangeRecords = await ws.db.iter(Stores.exchanges).toArray();
const details = x.details;
if (!details) { for (const r of exchangeRecords) {
return undefined; const details = await ws.db.runWithReadTransaction(
} [Stores.exchanges, Stores.exchangeDetails],
if (!x.addComplete) { async (tx) => {
return undefined; return getExchangeDetails(tx, r.baseUrl);
} },
if (!x.wireInfo) { );
return undefined; if (details) {
} exchanges.push({
if (details.currency !== info.amount.currency) { exchangeBaseUrl: details.exchangeBaseUrl,
return undefined;
}
return {
exchangeBaseUrl: x.baseUrl,
currency: details.currency, currency: details.currency,
paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri), paytoUris: details.wireInfo.accounts.map((x) => x.payto_uri),
}; });
}); }
const exchanges = exchangesRes.filter((x) => !!x) as ExchangeListItem[]; }
return { return {
amount: Amounts.stringify(info.amount), amount: Amounts.stringify(info.amount),

View File

@ -105,6 +105,7 @@ import {
CoinRecord, CoinRecord,
CoinSourceType, CoinSourceType,
DenominationRecord, DenominationRecord,
ExchangeDetailsRecord,
ExchangeRecord, ExchangeRecord,
PurchaseRecord, PurchaseRecord,
RefundState, RefundState,
@ -232,7 +233,7 @@ export class Wallet {
exchangeBaseUrl, exchangeBaseUrl,
amount, amount,
); );
const paytoUris = wi.exchangeInfo.wireInfo?.accounts.map( const paytoUris = wi.exchangeDetails.wireInfo.accounts.map(
(x) => x.payto_uri, (x) => x.payto_uri,
); );
if (!paytoUris) { if (!paytoUris) {
@ -586,13 +587,14 @@ export class Wallet {
/** /**
* Update or add exchange DB entry by fetching the /keys and /wire information. * 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( async updateExchangeFromUrl(
baseUrl: string, baseUrl: string,
force = false, force = false,
): Promise<ExchangeRecord> { ): Promise<{
exchange: ExchangeRecord;
exchangeDetails: ExchangeDetailsRecord;
}> {
try { try {
return updateExchangeFromUrl(this.ws, baseUrl, force); return updateExchangeFromUrl(this.ws, baseUrl, force);
} finally { } finally {
@ -601,14 +603,16 @@ export class Wallet {
} }
async getExchangeTos(exchangeBaseUrl: string): Promise<GetExchangeTosResult> { async getExchangeTos(exchangeBaseUrl: string): Promise<GetExchangeTosResult> {
const exchange = await this.updateExchangeFromUrl(exchangeBaseUrl); const { exchange, exchangeDetails } = await this.updateExchangeFromUrl(
const tos = exchange.termsOfServiceText; exchangeBaseUrl,
const currentEtag = exchange.termsOfServiceLastEtag; );
const tos = exchangeDetails.termsOfServiceText;
const currentEtag = exchangeDetails.termsOfServiceLastEtag;
if (!tos || !currentEtag) { if (!tos || !currentEtag) {
throw Error("exchange is in invalid state"); throw Error("exchange is in invalid state");
} }
return { return {
acceptedEtag: exchange.termsOfServiceAcceptedEtag, acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag,
currentEtag, currentEtag,
tos, tos,
}; };
@ -678,28 +682,29 @@ export class Wallet {
} }
async getExchanges(): Promise<ExchangesListRespose> { async getExchanges(): Promise<ExchangesListRespose> {
const exchanges: (ExchangeListItem | undefined)[] = await this.db const exchangeRecords = await this.db.iter(Stores.exchanges).toArray();
.iter(Stores.exchanges) const exchanges: ExchangeListItem[] = [];
.map((x) => { for (const r of exchangeRecords) {
const details = x.details; const dp = r.detailsPointer;
if (!details) { if (!dp) {
return undefined; continue;
} }
if (!x.addComplete) { const { currency, masterPublicKey } = dp;
return undefined; const exchangeDetails = await this.db.get(Stores.exchangeDetails, [
} r.baseUrl,
if (!x.wireInfo) { currency,
return undefined; masterPublicKey,
} ]);
return { if (!exchangeDetails) {
exchangeBaseUrl: x.baseUrl, continue;
currency: details.currency, }
paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri), 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<WalletCurrencyInfo> { async getCurrencies(): Promise<WalletCurrencyInfo> {