diff options
author | Florian Dold <florian.dold@gmail.com> | 2019-12-02 00:42:40 +0100 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2019-12-02 00:42:40 +0100 |
commit | e1369ff7e8fc02116b9c4261036f0e42e3423cf4 (patch) | |
tree | c621067ebda8977a888bfed34b7bbecf64b3b0f0 /src/wallet-impl/exchanges.ts | |
parent | aaf7e1338d6cdb1b4e01ad318938b3eaea2f922b (diff) | |
download | wallet-core-e1369ff7e8fc02116b9c4261036f0e42e3423cf4.tar.xz |
the giant refactoring: split wallet into multiple parts
Diffstat (limited to 'src/wallet-impl/exchanges.ts')
-rw-r--r-- | src/wallet-impl/exchanges.ts | 401 |
1 files changed, 401 insertions, 0 deletions
diff --git a/src/wallet-impl/exchanges.ts b/src/wallet-impl/exchanges.ts new file mode 100644 index 000000000..b3677c6c6 --- /dev/null +++ b/src/wallet-impl/exchanges.ts @@ -0,0 +1,401 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { InternalWalletState } from "./state"; +import { + WALLET_CACHE_BREAKER_CLIENT_VERSION, + OperationFailedAndReportedError, +} from "../wallet"; +import { KeysJson, Denomination, ExchangeWireJson } from "../talerTypes"; +import { getTimestampNow, OperationError } from "../walletTypes"; +import { + ExchangeRecord, + ExchangeUpdateStatus, + Stores, + DenominationRecord, + DenominationStatus, + WireFee, +} from "../dbTypes"; +import { + canonicalizeBaseUrl, + extractTalerStamp, + extractTalerStampOrThrow, +} from "../util/helpers"; +import { + oneShotGet, + oneShotPut, + runWithWriteTransaction, + oneShotMutate, +} from "../util/query"; +import * as Amounts from "../util/amounts"; +import { parsePaytoUri } from "../util/payto"; + +async function denominationRecordFromKeys( + ws: InternalWalletState, + exchangeBaseUrl: string, + denomIn: Denomination, +): Promise<DenominationRecord> { + const denomPubHash = await ws.cryptoApi.hashDenomPub(denomIn.denom_pub); + const d: DenominationRecord = { + denomPub: denomIn.denom_pub, + denomPubHash, + exchangeBaseUrl, + feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit), + feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh), + feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), + feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), + isOffered: true, + masterSig: denomIn.master_sig, + stampExpireDeposit: extractTalerStampOrThrow(denomIn.stamp_expire_deposit), + stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal), + stampExpireWithdraw: extractTalerStampOrThrow( + denomIn.stamp_expire_withdraw, + ), + stampStart: extractTalerStampOrThrow(denomIn.stamp_start), + status: DenominationStatus.Unverified, + value: Amounts.parseOrThrow(denomIn.value), + }; + return d; +} + +async function setExchangeError( + ws: InternalWalletState, + baseUrl: string, + err: OperationError, +): Promise<void> { + const mut = (exchange: ExchangeRecord) => { + exchange.lastError = err; + return exchange; + }; + await oneShotMutate(ws.db, Stores.exchanges, baseUrl, mut); +} + +/** + * Fetch the exchange's /keys and update our database accordingly. + * + * 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 oneShotGet( + ws.db, + Stores.exchanges, + baseUrl, + ); + + if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) { + return; + } + const keysUrl = new URL("keys", baseUrl); + keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); + + let keysResp; + try { + keysResp = await ws.http.get(keysUrl.href); + } catch (e) { + const m = `Fetching keys failed: ${e.message}`; + await setExchangeError(ws, baseUrl, { + type: "network", + details: { + requestUrl: e.config?.url, + }, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + let exchangeKeysJson: KeysJson; + try { + exchangeKeysJson = KeysJson.checked(keysResp.responseJson); + } catch (e) { + const m = `Parsing /keys response failed: ${e.message}`; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const lastUpdateTimestamp = extractTalerStamp( + exchangeKeysJson.list_issue_date, + ); + if (!lastUpdateTimestamp) { + const m = `Parsing /keys response failed: invalid list_issue_date.`; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + if (exchangeKeysJson.denoms.length === 0) { + const m = "exchange doesn't offer any denominations"; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const protocolVersion = exchangeKeysJson.version; + if (!protocolVersion) { + const m = "outdate exchange, no version in /keys response"; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) + .currency; + + const newDenominations = await Promise.all( + exchangeKeysJson.denoms.map(d => + denominationRecordFromKeys(ws, baseUrl, d), + ), + ); + + await runWithWriteTransaction( + ws.db, + [Stores.exchanges, Stores.denominations], + async tx => { + const r = await tx.get(Stores.exchanges, baseUrl); + if (!r) { + console.warn(`exchange ${baseUrl} no longer present`); + return; + } + if (r.details) { + // FIXME: We need to do some consistency checks! + } + r.details = { + auditors: exchangeKeysJson.auditors, + currency: currency, + lastUpdateTime: lastUpdateTimestamp, + masterPublicKey: exchangeKeysJson.master_public_key, + protocolVersion: protocolVersion, + }; + r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE; + r.lastError = undefined; + await tx.put(Stores.exchanges, r); + + for (const newDenom of newDenominations) { + const oldDenom = await tx.get(Stores.denominations, [ + baseUrl, + newDenom.denomPub, + ]); + if (oldDenom) { + // FIXME: Do consistency check + } else { + await tx.put(Stores.denominations, newDenom); + } + } + }, + ); +} + +/** + * 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, +) { + const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl); + if (!exchange) { + return; + } + if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { + return; + } + const reqUrl = new URL("wire", exchangeBaseUrl); + reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION) + + const resp = await ws.http.get(reqUrl.href); + + const wiJson = resp.responseJson; + if (!wiJson) { + throw Error("/wire response malformed"); + } + const wireInfo = ExchangeWireJson.checked(wiJson); + 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 = extractTalerStamp(x.start_date); + if (!startStamp) { + throw Error("wrong date format"); + } + const endStamp = extractTalerStamp(x.end_date); + if (!endStamp) { + throw Error("wrong date format"); + } + feeList.push({ + closingFee: Amounts.parseOrThrow(x.closing_fee), + endStamp, + sig: x.sig, + startStamp, + wireFee: Amounts.parseOrThrow(x.wire_fee), + }); + } + feesForType[wireMethod] = feeList; + } + + await runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => { + const r = await tx.get(Stores.exchanges, exchangeBaseUrl); + if (!r) { + return; + } + if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { + return; + } + r.wireInfo = { + accounts: wireInfo.accounts, + feesForType: feesForType, + }; + r.updateStatus = ExchangeUpdateStatus.FINISHED; + r.lastError = undefined; + await tx.put(Stores.exchanges, r); + }); +} + +/** + * 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. + */ +export async function updateExchangeFromUrl( + ws: InternalWalletState, + baseUrl: string, + force: boolean = false, +): Promise<ExchangeRecord> { + const now = getTimestampNow(); + baseUrl = canonicalizeBaseUrl(baseUrl); + + const r = await oneShotGet(ws.db, Stores.exchanges, baseUrl); + if (!r) { + const newExchangeRecord: ExchangeRecord = { + baseUrl: baseUrl, + details: undefined, + wireInfo: undefined, + updateStatus: ExchangeUpdateStatus.FETCH_KEYS, + updateStarted: now, + updateReason: "initial", + timestampAdded: getTimestampNow(), + }; + await oneShotPut(ws.db, Stores.exchanges, newExchangeRecord); + } else { + await runWithWriteTransaction(ws.db, [Stores.exchanges], async t => { + const rec = await t.get(Stores.exchanges, baseUrl); + if (!rec) { + return; + } + if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !force) { + return; + } + if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && force) { + rec.updateReason = "forced"; + } + rec.updateStarted = now; + rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS; + rec.lastError = undefined; + t.put(Stores.exchanges, rec); + }); + } + + await updateExchangeWithKeys(ws, baseUrl); + await updateExchangeWithWireInfo(ws, baseUrl); + + const updatedExchange = await oneShotGet(ws.db, Stores.exchanges, baseUrl); + + if (!updatedExchange) { + // This should practically never happen + throw Error("exchange not found"); + } + return updatedExchange; +} + +/** + * Check if and how an exchange is trusted and/or audited. + */ +export async function getExchangeTrust( + ws: InternalWalletState, + exchangeInfo: ExchangeRecord, +): Promise<{ isTrusted: boolean; isAudited: boolean }> { + let isTrusted = false; + let isAudited = false; + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); + } + const currencyRecord = await oneShotGet( + ws.db, + Stores.currencies, + exchangeDetails.currency, + ); + if (currencyRecord) { + for (const trustedExchange of currencyRecord.exchanges) { + if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) { + isTrusted = true; + break; + } + } + for (const trustedAuditor of currencyRecord.auditors) { + for (const exchangeAuditor of exchangeDetails.auditors) { + if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) { + isAudited = true; + break; + } + } + } + } + return { isTrusted, isAudited }; +} + +export async function getExchangePaytoUri( + ws: InternalWalletState, + exchangeBaseUrl: string, + supportedTargetTypes: string[], +): Promise<string> { + // 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 (let account of exchangeWireInfo.accounts) { + const res = parsePaytoUri(account.url); + if (!res) { + continue; + } + if (supportedTargetTypes.includes(res.targetType)) { + return account.url; + } + } + throw Error("no matching exchange account found"); +} |