From d6bf24902a34f2094363121c8d9f4d54db6f7b6c Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 28 Apr 2017 23:28:27 +0200 Subject: implement new reserve creation dialog and auditor management --- src/components.ts | 13 +- src/i18n.tsx | 4 +- src/pages/auditors.html | 3 + src/pages/auditors.tsx | 32 ++- src/pages/confirm-create-reserve.html | 29 +++ src/pages/confirm-create-reserve.tsx | 401 +++++++++++++++++++++++++--------- src/pages/popup.html | 1 + src/pages/popup.tsx | 22 +- src/style/wallet.css | 4 + src/types.ts | 6 +- src/wallet.ts | 79 ++++++- src/wxApi.ts | 17 +- src/wxBackend.ts | 15 +- 13 files changed, 504 insertions(+), 122 deletions(-) (limited to 'src') diff --git a/src/components.ts b/src/components.ts index 4ed746f67..569810f3a 100644 --- a/src/components.ts +++ b/src/components.ts @@ -33,12 +33,23 @@ export interface StateHolder { * but has multiple state holders. */ export abstract class ImplicitStateComponent extends React.Component { + _implicit = {needsUpdate: false, didMount: false}; + componentDidMount() { + this._implicit.didMount = true; + if (this._implicit.needsUpdate) { + this.setState({} as any); + } + } makeState(initial: StateType): StateHolder { let state: StateType = initial; return (s?: StateType): StateType => { if (s !== undefined) { state = s; - this.setState({} as any); + if (this._implicit.didMount) { + this.setState({} as any); + } else { + this._implicit.needsUpdate = true; + } } return state; }; diff --git a/src/i18n.tsx b/src/i18n.tsx index ff32e62a8..aa26407d9 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -177,11 +177,13 @@ interface TranslateProps { export class Translate extends React.Component { render(): JSX.Element { let s = stringifyChildren(this.props.children); + console.log(`string "${s}"`); let tr = jed.ngettext(s, s, 1).split(/%(\d+)\$s/).filter((e: any, i: number) => i % 2 == 0); + console.log(`tr "${JSON.stringify(tr)}"`); let childArray = React.Children.toArray(this.props.children!); for (let i = 0; i < childArray.length - 1; ++i) { if ((typeof childArray[i]) == "string" && (typeof childArray[i+1]) == "string") { - childArray[i+i] = childArray[i] as string + childArray[i+1] as string; + childArray[i+1] = (childArray[i] as string).concat(childArray[i+1] as string); childArray.splice(i,1); } } diff --git a/src/pages/auditors.html b/src/pages/auditors.html index 7e01f4e1f..2f50b28a1 100644 --- a/src/pages/auditors.html +++ b/src/pages/auditors.html @@ -14,6 +14,9 @@ + diff --git a/src/pages/confirm-create-reserve.tsx b/src/pages/confirm-create-reserve.tsx index a7fd7b0fd..6b618c273 100644 --- a/src/pages/confirm-create-reserve.tsx +++ b/src/pages/confirm-create-reserve.tsx @@ -26,14 +26,15 @@ import {amountToPretty, canonicalizeBaseUrl} from "../helpers"; import { AmountJson, CreateReserveResponse, ReserveCreationInfo, Amounts, - Denomination, DenominationRecord, + Denomination, DenominationRecord, CurrencyRecord } from "../types"; -import {getReserveCreationInfo} from "../wxApi"; +import {getReserveCreationInfo, getCurrency, getExchangeInfo} from "../wxApi"; import {ImplicitStateComponent, StateHolder} from "../components"; import * as i18n from "../i18n"; import * as React from "react"; import * as ReactDOM from "react-dom"; import URI = require("urijs"); +import * as moment from "moment"; function delay(delayMs: number, value: T): Promise { @@ -67,10 +68,72 @@ class EventTrigger { } +interface CollapsibleState { + collapsed: boolean; +} + +interface CollapsibleProps { + initiallyCollapsed: boolean; + title: string; +} + +class Collapsible extends React.Component { + constructor(props: CollapsibleProps) { + super(props); + this.state = { collapsed: props.initiallyCollapsed }; + } + render() { + const doOpen = (e: any) => { + this.setState({collapsed: false}) + e.preventDefault() + }; + const doClose = (e: any) => { + this.setState({collapsed: true}) + e.preventDefault(); + }; + if (this.state.collapsed) { + return

{this.props.title}

; + } + return ( +
+

{this.props.title}

+ {this.props.children} +
+ ); + } +} + +function renderAuditorDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( +

+ Details will be displayed when a valid exchange provider URL is entered. +

+ ); + } + if (rci.exchangeInfo.auditors.length == 0) { + return ( +

+ The exchange is not audited by any auditors. +

+ ); + } + return ( +
+ {rci.exchangeInfo.auditors.map(a => ( +

Auditor {a.url}

+ ))} +
+ ); +} + function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { if (!rci) { - return

- Details will be displayed when a valid exchange provider URL is entered.

+ return ( +

+ Details will be displayed when a valid exchange provider URL is entered. +

+ ); } let denoms = rci.selectedDenoms; @@ -99,25 +162,57 @@ function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { ); } + function wireFee(s: string) { + return [ + + + Wire Method {s} + + + Applies Until + Wire Fee + Closing Fee + + , + + {rci!.wireFees.feesForType[s].map(f => ( + + {moment.unix(f.endStamp).format("llll")} + {amountToPretty(f.wireFee)} + {amountToPretty(f.closingFee)} + + ))} + + ]; + } + let withdrawFeeStr = amountToPretty(rci.withdrawFee); let overheadStr = amountToPretty(rci.overhead); return (
+

Overview

{i18n.str`Withdrawal fees: ${withdrawFeeStr}`}

{i18n.str`Rounding loss: ${overheadStr}`}

- +

Coin Fees

+
- - - - - + + + + + + + {uniq.map(row)}
{i18n.str`# Coins`}{i18n.str`Value`}{i18n.str`Withdraw Fee`}{i18n.str`Refresh Fee`}{i18n.str`Deposit Fee`}
{i18n.str`# Coins`}{i18n.str`Value`}{i18n.str`Withdraw Fee`}{i18n.str`Refresh Fee`}{i18n.str`Deposit Fee`}
+

Wire Fees

+ + {Object.keys(rci.wireFees.feesForType).map(wireFee)} +
); } @@ -156,6 +251,87 @@ interface ExchangeSelectionProps { amount: AmountJson; callback_url: string; wt_types: string[]; + currencyRecord: CurrencyRecord|null; +} + +interface ManualSelectionProps { + onSelect(url: string): void; + initialUrl: string; +} + +class ManualSelection extends ImplicitStateComponent { + url: StateHolder = this.makeState(""); + errorMessage: StateHolder = this.makeState(null); + isOkay: StateHolder = this.makeState(false); + updateEvent = new EventTrigger(); + constructor(p: ManualSelectionProps) { + super(p); + this.url(p.initialUrl); + this.update(); + } + render() { + return ( +
+
+ + this.onUrlChanged((e.target as HTMLInputElement).value)} /> +
+
+ + {this.errorMessage()} +
+
+ ); + } + + async update() { + this.errorMessage(null); + this.isOkay(false); + if (!this.url()) { + return; + } + let parsedUrl = new URI(this.url()!); + if (parsedUrl.is("relative")) { + this.errorMessage(i18n.str`Error: URL may not be relative`); + this.isOkay(false); + return; + } + try { + let url = canonicalizeBaseUrl(this.url()!); + let r = await getExchangeInfo(url) + console.log("getExchangeInfo returned") + this.isOkay(true); + } catch (e) { + console.log("got error", e); + if (e.hasOwnProperty("httpStatus")) { + this.errorMessage(`Error: request failed with status ${e.httpStatus}`); + } else if (e.hasOwnProperty("errorResponse")) { + let resp = e.errorResponse; + this.errorMessage(`Error: ${resp.error} (${resp.hint})`); + } else { + this.errorMessage("invalid exchange URL"); + } + } + } + + async onUrlChanged(s: string) { + this.url(s); + this.errorMessage(null); + this.isOkay(false); + this.updateEvent.trigger(); + let waited = await this.updateEvent.wait(200); + if (waited) { + // Run the actual update if nobody else preempted us. + this.update(); + } + } } @@ -164,60 +340,64 @@ class ExchangeSelection extends ImplicitStateComponent { reserveCreationInfo: StateHolder = this.makeState( null); url: StateHolder = this.makeState(null); - detailCollapsed: StateHolder = this.makeState(true); - updateEvent = new EventTrigger(); + selectingExchange: StateHolder = this.makeState(false); constructor(props: ExchangeSelectionProps) { super(props); - this.onUrlChanged(props.suggestedExchangeUrl || null); - this.forceReserveUpdate(); - } - - - renderAdvanced(): JSX.Element { - if (this.detailCollapsed() && this.url() !== null && !this.statusString()) { - return ( - - ); + let prefilledExchangesUrls = []; + if (props.currencyRecord) { + let exchanges = props.currencyRecord.exchanges.map((x) => x.baseUrl); + prefilledExchangesUrls.push(...exchanges); } - return ( -
-

Provider Selection

- - this.onUrlChanged((e.target as HTMLInputElement).value)}/> -
- {this.renderStatus()} -

{i18n.str`Detailed Fee Structure`}

- {renderReserveCreationDetails(this.reserveCreationInfo())} -
) - } - - renderFee() { - if (!this.reserveCreationInfo()) { - return "??"; + if (props.suggestedExchangeUrl) { + prefilledExchangesUrls.push(props.suggestedExchangeUrl); + } + if (prefilledExchangesUrls.length != 0) { + this.url(prefilledExchangesUrls[0]); + this.forceReserveUpdate(); + } else { + this.selectingExchange(true); } - let rci = this.reserveCreationInfo()!; - let totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount; - return `${amountToPretty(totalCost)}`; } renderFeeStatus() { - if (this.reserveCreationInfo()) { + let rci = this.reserveCreationInfo(); + if (rci) { + let totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount; + let trustMessage; + if (rci.isTrusted) { + trustMessage = ( + + The exchange is trusted by the wallet. + + ); + } else if (rci.isAudited) { + trustMessage = ( + + The exchange is audited by a trusted auditor. + + ); + } else { + trustMessage = ( + + Warning: The exchange is neither directly trusted nor audited by a trusted auditor. + If you withdraw from this exchange, it will be trusted in the future. + + ); + } return ( +
+ Using exchange provider {this.url()}. The exchange provider will charge {" "} - {this.renderFee()} + {amountToPretty(totalCost)} {" "} in fees. + {trustMessage} +
); } if (this.url() && !this.statusString()) { @@ -233,7 +413,7 @@ class ExchangeSelection extends ImplicitStateComponent { if (this.statusString()) { return (

- {i18n.str`A problem occured, see below.`} + {i18n.str`A problem occured, see below. ${this.statusString()}`}

); } @@ -244,22 +424,80 @@ class ExchangeSelection extends ImplicitStateComponent { ); } - render(): JSX.Element { + renderConfirm() { return (
- - {"You are about to withdraw "} - {amountToPretty(this.props.amount)} - {" from your bank account into your wallet."} - {this.renderFeeStatus()} - + { " " } +
- {this.renderAdvanced()} + + {renderReserveCreationDetails(this.reserveCreationInfo())} + + + {renderAuditorDetails(this.reserveCreationInfo())} + +
+ ); + } + + select(url: string) { + this.reserveCreationInfo(null); + this.url(url); + this.selectingExchange(false); + this.forceReserveUpdate(); + } + + renderSelect() { + let exchanges = (this.props.currencyRecord && this.props.currencyRecord.exchanges) || []; + console.log(exchanges); + return ( +
+ Please select an exchange. You can review the details before after your selection. + + {this.props.suggestedExchangeUrl && ( +
+

Bank Suggestion

+ +
+ )} + + {exchanges.length > 0 && ( +
+

Known Exchanges

+ {exchanges.map(e => ( + + ))} +
+ )} + +

Manual Selection

+ this.select(url)} /> +
+ ); + } + + render(): JSX.Element { + return ( +
+ + {"You are about to withdraw "} + {amountToPretty(this.props.amount)} + {" from your bank account into your wallet."} + + {this.selectingExchange() ? this.renderSelect() : this.renderConfirm()}
); } @@ -277,20 +515,6 @@ class ExchangeSelection extends ImplicitStateComponent { */ async forceReserveUpdate() { this.reserveCreationInfo(null); - if (!this.url()) { - this.statusString(i18n.str`Error: URL is empty`); - this.detailCollapsed(false); - return; - } - - this.statusString(null); - let parsedUrl = new URI(this.url()!); - if (parsedUrl.is("relative")) { - this.statusString(i18n.str`Error: URL may not be relative`); - this.detailCollapsed(false); - return; - } - try { let url = canonicalizeBaseUrl(this.url()!); let r = await getReserveCreationInfo(url, @@ -299,23 +523,16 @@ class ExchangeSelection extends ImplicitStateComponent { this.reserveCreationInfo(r); console.dir(r); } catch (e) { - console.log("get exchange info rejected"); + console.log("get exchange info rejected", e); if (e.hasOwnProperty("httpStatus")) { this.statusString(`Error: request failed with status ${e.httpStatus}`); - this.detailCollapsed(false); } else if (e.hasOwnProperty("errorResponse")) { let resp = e.errorResponse; this.statusString(`Error: ${resp.error} (${resp.hint})`); - this.detailCollapsed(false); } } } - reset() { - this.statusString(null); - this.reserveCreationInfo(null); - } - confirmReserveImpl(rci: ReserveCreationInfo, exchange: string, amount: AmountJson, @@ -358,30 +575,13 @@ class ExchangeSelection extends ImplicitStateComponent { console.log("going to", url.href()); document.location.href = url.href(); } else { - this.reset(); this.statusString( i18n.str`Oops, something went wrong. The wallet responded with error status (${rawResp.error}).`); - this.detailCollapsed(false); } }; chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb); } - async onUrlChanged(url: string|null) { - this.reset(); - this.url(url); - if (url == undefined) { - return; - } - this.updateEvent.trigger(); - let waited = await this.updateEvent.wait(200); - if (waited) { - // Run the actual update if nobody else preempted us. - this.forceReserveUpdate(); - this.forceUpdate(); - } - } - renderStatus(): any { if (this.statusString()) { return

{this.statusString()}

; @@ -411,16 +611,15 @@ export async function main() { throw Error(i18n.str`Can't parse wire_types: ${e.message}`); } - let suggestedExchangeUrl = await getSuggestedExchange(amount.currency); - if (!suggestedExchangeUrl && query.suggested_exchange_url) { - suggestedExchangeUrl = query.suggested_exchange_url; - } + let suggestedExchangeUrl = query.suggested_exchange_url; + let currencyRecord = await getCurrency(amount.currency); let args = { wt_types, suggestedExchangeUrl, callback_url, - amount + amount, + currencyRecord, }; ReactDOM.render(, document.getElementById( diff --git a/src/pages/popup.html b/src/pages/popup.html index 7ff5cffaf..702f43cde 100644 --- a/src/pages/popup.html +++ b/src/pages/popup.html @@ -5,6 +5,7 @@ + diff --git a/src/pages/popup.tsx b/src/pages/popup.tsx index c8d52b45c..7f179366a 100644 --- a/src/pages/popup.tsx +++ b/src/pages/popup.tsx @@ -309,18 +309,16 @@ class WalletBalanceView extends React.Component {

); }); - if (listing.length > 0) { - let link = chrome.extension.getURL("/src/pages/auditors.html"); - let linkElem = auditors; - return ( -
- {listing} - {linkElem} -
- ); - } - - return this.renderEmpty(); + let link = chrome.extension.getURL("/src/pages/auditors.html"); + let linkElem = Trusted Auditors and Exchanges; + return ( +
+

Available Balance

+ {listing.length > 0 ? listing : this.renderEmpty()} +

Settings

+ {linkElem} +
+ ); } } diff --git a/src/style/wallet.css b/src/style/wallet.css index 7fe5e37c8..752fc6d75 100644 --- a/src/style/wallet.css +++ b/src/style/wallet.css @@ -216,3 +216,7 @@ span.spacer { .button-secondary { background: rgb(66, 184, 221); } + +a.actionLink { + color: black; +} diff --git a/src/types.ts b/src/types.ts index 5d53f8db0..4707edd95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,7 +87,7 @@ export interface ExchangeForCurrencyRecord { * Priority for automatic selection when withdrawing. */ priority: number; - pinnedPub: string; + pinnedPub?: string; baseUrl: string; } @@ -232,6 +232,7 @@ export interface ExchangeRecord { baseUrl: string; masterPublicKey: string; auditors: Auditor[]; + currency: string; /** * Timestamp for last update. @@ -249,6 +250,9 @@ export interface ReserveCreationInfo { selectedDenoms: DenominationRecord[]; withdrawFee: AmountJson; overhead: AmountJson; + wireFees: ExchangeWireFeesRecord; + isAudited: boolean; + isTrusted: boolean; } diff --git a/src/wallet.ts b/src/wallet.ts index 982801f43..bc3cd59fc 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -267,13 +267,22 @@ const builtinCurrencies: CurrencyRecord[] = [ fractionalDigits: 2, auditors: [ { - baseUrl: "https://auditor.demo.taler.net", + baseUrl: "https://auditor.demo.taler.net/", expirationStamp: (new Date(2027, 1)).getTime(), auditorPub: "XN9KMN5G2KGPCAN0E89MM5HE8FV4WBWA9KDTMTDR817MWBCYA7H0", }, ], exchanges: [], }, + { + name: "PUDOS", + fractionalDigits: 2, + auditors: [ + ], + exchanges: [ + { baseUrl: "https://exchange.test.taler.net/", priority: 0 }, + ], + }, ]; @@ -994,6 +1003,9 @@ export class Wallet { /** * Create a reserve, but do not flag it as confirmed yet. + * + * Adds the corresponding exchange as a trusted exchange if it is neither + * audited nor trusted already. */ async createReserve(req: CreateReserveRequest): Promise { let keypair = await this.cryptoApi.createEddsaKeypair(); @@ -1023,7 +1035,24 @@ export class Wallet { } }; + let exchangeInfo = await this.updateExchangeFromUrl(req.exchange); + let {isAudited, isTrusted} = await this.getExchangeTrust(exchangeInfo); + let currencyRecord = await this.q().get(Stores.currencies, exchangeInfo.currency); + if (!currencyRecord) { + currencyRecord = { + name: exchangeInfo.currency, + fractionalDigits: 2, + exchanges: [], + auditors: [], + } + } + + if (!isAudited && !isTrusted) { + currencyRecord.exchanges.push({baseUrl: req.exchange, priority: 0}); + } + await this.q() + .put(Stores.currencies, currencyRecord) .put(Stores.reserves, reserveRecord) .put(Stores.history, historyEntry) .finish(); @@ -1295,6 +1324,34 @@ export class Wallet { return selectedDenoms; } + + + /** + * Check if and how an exchange is trusted and/or audited. + */ + async getExchangeTrust(exchangeInfo: ExchangeRecord): Promise<{isTrusted: boolean, isAudited: boolean}> { + let isTrusted = false; + let isAudited = false; + let currencyRecord = await this.q().get(Stores.currencies, exchangeInfo.currency); + if (currencyRecord) { + for (let trustedExchange of currencyRecord.exchanges) { + if (trustedExchange.baseUrl == exchangeInfo.baseUrl) { + isTrusted = true; + break; + } + } + for (let trustedAuditor of currencyRecord.auditors) { + for (let exchangeAuditor of exchangeInfo.auditors) { + if (trustedAuditor.baseUrl == exchangeAuditor.url) { + isAudited = true; + break; + } + } + } + } + return {isTrusted, isAudited}; + } + async getReserveCreationInfo(baseUrl: string, amount: AmountJson): Promise { let exchangeInfo = await this.updateExchangeFromUrl(baseUrl); @@ -1312,10 +1369,21 @@ export class Wallet { let wireInfo = await this.getWireInfo(baseUrl); + let wireFees = await this.q().get(Stores.exchangeWireFees, baseUrl); + if (!wireFees) { + // should never happen unless DB is inconsistent + throw Error(`no wire fees found for exchange ${baseUrl}`); + } + + let {isTrusted, isAudited} = await this.getExchangeTrust(exchangeInfo); + let ret: ReserveCreationInfo = { exchangeInfo, selectedDenoms, wireInfo, + wireFees, + isAudited, + isTrusted, withdrawFee: acc, overhead: Amounts.sub(amount, actualCoinCost).amount, }; @@ -1388,6 +1456,10 @@ export class Wallet { throw Error("invalid update time"); } + if (exchangeKeysJson.denoms.length == 0) { + throw Error("exchange doesn't offer any denominations"); + } + const r = await this.q().get(Stores.exchanges, baseUrl); let exchangeInfo: ExchangeRecord; @@ -1398,6 +1470,7 @@ export class Wallet { lastUpdateTime: updateTimeSec, masterPublicKey: exchangeKeysJson.master_public_key, auditors: exchangeKeysJson.auditors, + currency: exchangeKeysJson.denoms[0].value.currency, }; console.log("making fresh exchange"); } else { @@ -1960,6 +2033,10 @@ export class Wallet { return pub; } + async getCurrencyRecord(currency: string): Promise { + return this.q().get(Stores.currencies, currency); + } + async paymentSucceeded(contractHash: string, merchantSig: string): Promise { const doPaymentSucceeded = async() => { diff --git a/src/wxApi.ts b/src/wxApi.ts index de59914a1..bdc02af1b 100644 --- a/src/wxApi.ts +++ b/src/wxApi.ts @@ -48,9 +48,13 @@ export function getReserveCreationInfo(baseUrl: string, } export async function callBackend(type: string, detail?: any): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { chrome.runtime.sendMessage({ type, detail }, (resp) => { - resolve(resp); + if (resp.error) { + reject(resp); + } else { + resolve(resp); + } }); }); } @@ -63,6 +67,15 @@ export async function getCurrencies(): Promise { return await callBackend("get-currencies"); } + +export async function getCurrency(name: string): Promise { + return await callBackend("currency-info", {name}); +} + +export async function getExchangeInfo(baseUrl: string): Promise { + return await callBackend("exchange-info", {baseUrl}); +} + export async function updateCurrency(currencyRecord: CurrencyRecord): Promise { return await callBackend("update-currency", { currencyRecord }); } diff --git a/src/wxBackend.ts b/src/wxBackend.ts index 984cad21a..716dc66be 100644 --- a/src/wxBackend.ts +++ b/src/wxBackend.ts @@ -167,6 +167,12 @@ function makeHandlers(db: IDBDatabase, } return wallet.updateExchangeFromUrl(detail.baseUrl); }, + ["currency-info"]: function (detail) { + if (!detail.name) { + return Promise.resolve({ error: "name missing" }); + } + return wallet.getCurrencyRecord(detail.name); + }, ["hash-contract"]: function (detail) { if (!detail.contract) { return Promise.resolve({ error: "contract missing" }); @@ -289,13 +295,20 @@ async function dispatch(handlers: any, req: any, sender: any, sendResponse: any) console.log(`exception during wallet handler for '${req.type}'`); console.log("request", req); console.error(e); + let stack = undefined; + try { + stack = e.stack.toString(); + } catch (e) { + // might fail + } try { sendResponse({ + stack, error: "exception", hint: e.message, - stack: e.stack.toString() }); } catch (e) { + console.log(e); // might fail if tab disconnected } } -- cgit v1.2.3