diff options
-rw-r--r-- | node_modules/.yarn-integrity | 2 | ||||
-rw-r--r-- | package.json | 6 | ||||
-rw-r--r-- | src/components.ts | 13 | ||||
-rw-r--r-- | src/i18n.tsx | 4 | ||||
-rw-r--r-- | src/pages/auditors.html | 3 | ||||
-rw-r--r-- | src/pages/auditors.tsx | 32 | ||||
-rw-r--r-- | src/pages/confirm-create-reserve.html | 29 | ||||
-rw-r--r-- | src/pages/confirm-create-reserve.tsx | 401 | ||||
-rw-r--r-- | src/pages/popup.html | 1 | ||||
-rw-r--r-- | src/pages/popup.tsx | 22 | ||||
-rw-r--r-- | src/style/wallet.css | 4 | ||||
-rw-r--r-- | src/types.ts | 6 | ||||
-rw-r--r-- | src/wallet.ts | 79 | ||||
-rw-r--r-- | src/wxApi.ts | 17 | ||||
-rw-r--r-- | src/wxBackend.ts | 15 | ||||
-rw-r--r-- | yarn.lock | 21 |
16 files changed, 520 insertions, 135 deletions
diff --git a/node_modules/.yarn-integrity b/node_modules/.yarn-integrity index 6d4c42d0c..d45440279 100644 --- a/node_modules/.yarn-integrity +++ b/node_modules/.yarn-integrity @@ -1 +1 @@ -751d3ff225403bea12799f2c0ad32d26a0ff81a4f88821c8f1615d3ddc5a9533
\ No newline at end of file +f57c90a35fd7bae0b594a5c9114779b9b7c1629f6977a421d3e666087dc7ed0f
\ No newline at end of file diff --git a/package.json b/package.json index f6675834a..0709bc29b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "author": "", "license": "GPL-3.0", "devDependencies": { + "@types/moment": "^2.13.0", "@types/react": "^15.0.22", "@types/react-dom": "^15.5.0", "async": "^2.1.2", @@ -39,6 +40,7 @@ "map-stream": "0.0.6", "minimist": "^1.2.0", "mocha": "^2.4.5", + "moment": "^2.18.1", "po2json": "git+https://github.com/mikeedwards/po2json", "react": "^15.5.4", "react-dom": "^15.5.4", @@ -50,11 +52,11 @@ "ts-loader": "^2.0.3", "typescript": "next", "typhonjs-istanbul-instrument-jspm": "^0.1.0", + "uglify-js": "^2.8.22", "urijs": "^1.18.10", "vinyl": "^2.0.0", "vinyl-fs": "^2.4.3", "webpack": "^2.4.1", - "webpack-merge": "^4.1.0", - "uglify-js": "^2.8.22" + "webpack-merge": "^4.1.0" } } 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<T> { * but has multiple state holders. */ export abstract class ImplicitStateComponent<PropType> extends React.Component<PropType, any> { + _implicit = {needsUpdate: false, didMount: false}; + componentDidMount() { + this._implicit.didMount = true; + if (this._implicit.needsUpdate) { + this.setState({} as any); + } + } makeState<StateType>(initial: StateType): StateHolder<StateType> { 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<TranslateProps,void> { 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 @@ <script src="/dist/auditors-bundle.js"></script> <style> + body { + font-size: 100%; + } .tree-item { margin: 2em; border-radius: 5px; diff --git a/src/pages/auditors.tsx b/src/pages/auditors.tsx index 41339b0d8..762d22ad8 100644 --- a/src/pages/auditors.tsx +++ b/src/pages/auditors.tsx @@ -23,6 +23,7 @@ import { ExchangeRecord, + ExchangeForCurrencyRecord, DenominationRecord, AuditorRecord, CurrencyRecord, @@ -65,13 +66,20 @@ class CurrencyList extends React.Component<any, CurrencyListState> { this.setState({ currencies }); } - async confirmRemove(c: CurrencyRecord, a: AuditorRecord) { + async confirmRemoveAuditor(c: CurrencyRecord, a: AuditorRecord) { if (window.confirm(`Do you really want to remove auditor ${a.baseUrl} for currency ${c.name}?`)) { c.auditors = c.auditors.filter((x) => x.auditorPub != a.auditorPub); await updateCurrency(c); } } + async confirmRemoveExchange(c: CurrencyRecord, e: ExchangeForCurrencyRecord) { + if (window.confirm(`Do you really want to remove exchange ${e.baseUrl} for currency ${c.name}?`)) { + c.exchanges = c.exchanges.filter((x) => x.baseUrl != e.baseUrl); + await updateCurrency(c); + } + } + renderAuditors(c: CurrencyRecord): any { if (c.auditors.length == 0) { return <p>No trusted auditors for this currency.</p> @@ -81,7 +89,7 @@ class CurrencyList extends React.Component<any, CurrencyListState> { <p>Trusted Auditors:</p> <ul> {c.auditors.map(a => ( - <li>{a.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemove(c, a)}>Remove</button> + <li>{a.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveAuditor(c, a)}>Remove</button> <ul> <li>valid until {new Date(a.expirationStamp).toString()}</li> <li>public key {a.auditorPub}</li> @@ -93,6 +101,23 @@ class CurrencyList extends React.Component<any, CurrencyListState> { ); } + renderExchanges(c: CurrencyRecord): any { + if (c.exchanges.length == 0) { + return <p>No trusted exchanges for this currency.</p> + } + return ( + <div> + <p>Trusted Exchanges:</p> + <ul> + {c.exchanges.map(e => ( + <li>{e.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveExchange(c, e)}>Remove</button> + </li> + ))} + </ul> + </div> + ); + } + render(): JSX.Element { let currencies = this.state.currencies; if (!currencies) { @@ -104,7 +129,10 @@ class CurrencyList extends React.Component<any, CurrencyListState> { <div> <h1>Currency {c.name}</h1> <p>Displayed with {c.fractionalDigits} fractional digits.</p> + <h2>Auditors</h2> <div>{this.renderAuditors(c)}</div> + <h2>Exchanges</h2> + <div>{this.renderExchanges(c)}</div> </div> ))} </div> diff --git a/src/pages/confirm-create-reserve.html b/src/pages/confirm-create-reserve.html index c1e4b7ce3..16ab12a30 100644 --- a/src/pages/confirm-create-reserve.html +++ b/src/pages/confirm-create-reserve.html @@ -7,10 +7,39 @@ <link rel="icon" href="/img/icon.png"> <link rel="stylesheet" type="text/css" href="/src/style/wallet.css"> + <link rel="stylesheet" type="text/css" href="/src/style/pure.css"> <script src="/dist/page-common-bundle.js"></script> <script src="/dist/confirm-create-reserve-bundle.js"></script> + <style> + body { + font-size: 100%; + overflow-y: scroll; + } + .button-success { + background: rgb(28, 184, 65); /* this is a green */ + color: white; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + } + .button-secondary { + background: rgb(66, 184, 221); /* this is a light blue */ + color: white; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + } + a.opener { + color: black; + } + .opener-open::before { + content: "\25bc" + } + .opener-collapsed::before { + content: "\25b6 " + } + </style> + </head> <body> 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<T>(delayMs: number, value: T): Promise<T> { @@ -67,10 +68,72 @@ class EventTrigger { } +interface CollapsibleState { + collapsed: boolean; +} + +interface CollapsibleProps { + initiallyCollapsed: boolean; + title: string; +} + +class Collapsible extends React.Component<CollapsibleProps, CollapsibleState> { + 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 <h2><a className="opener opener-collapsed" href="#" onClick={doOpen}>{this.props.title}</a></h2>; + } + return ( + <div> + <h2><a className="opener opener-open" href="#" onClick={doClose}>{this.props.title}</a></h2> + {this.props.children} + </div> + ); + } +} + +function renderAuditorDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( + <p> + Details will be displayed when a valid exchange provider URL is entered. + </p> + ); + } + if (rci.exchangeInfo.auditors.length == 0) { + return ( + <p> + The exchange is not audited by any auditors. + </p> + ); + } + return ( + <div> + {rci.exchangeInfo.auditors.map(a => ( + <h3>Auditor {a.url}</h3> + ))} + </div> + ); +} + function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { if (!rci) { - return <p> - Details will be displayed when a valid exchange provider URL is entered.</p> + return ( + <p> + Details will be displayed when a valid exchange provider URL is entered. + </p> + ); } let denoms = rci.selectedDenoms; @@ -99,25 +162,57 @@ function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { ); } + function wireFee(s: string) { + return [ + <thead> + <tr> + <th colSpan={3}>Wire Method {s}</th> + </tr> + <tr> + <th>Applies Until</th> + <th>Wire Fee</th> + <th>Closing Fee</th> + </tr> + </thead>, + <tbody> + {rci!.wireFees.feesForType[s].map(f => ( + <tr> + <td>{moment.unix(f.endStamp).format("llll")}</td> + <td>{amountToPretty(f.wireFee)}</td> + <td>{amountToPretty(f.closingFee)}</td> + </tr> + ))} + </tbody> + ]; + } + let withdrawFeeStr = amountToPretty(rci.withdrawFee); let overheadStr = amountToPretty(rci.overhead); return ( <div> + <h3>Overview</h3> <p>{i18n.str`Withdrawal fees: ${withdrawFeeStr}`}</p> <p>{i18n.str`Rounding loss: ${overheadStr}`}</p> - <table> + <h3>Coin Fees</h3> + <table className="pure-table"> <thead> - <th>{i18n.str`# Coins`}</th> - <th>{i18n.str`Value`}</th> - <th>{i18n.str`Withdraw Fee`}</th> - <th>{i18n.str`Refresh Fee`}</th> - <th>{i18n.str`Deposit Fee`}</th> + <tr> + <th>{i18n.str`# Coins`}</th> + <th>{i18n.str`Value`}</th> + <th>{i18n.str`Withdraw Fee`}</th> + <th>{i18n.str`Refresh Fee`}</th> + <th>{i18n.str`Deposit Fee`}</th> + </tr> </thead> <tbody> {uniq.map(row)} </tbody> </table> + <h3>Wire Fees</h3> + <table className="pure-table"> + {Object.keys(rci.wireFees.feesForType).map(wireFee)} + </table> </div> ); } @@ -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<ManualSelectionProps> { + url: StateHolder<string> = this.makeState(""); + errorMessage: StateHolder<string|null> = this.makeState(null); + isOkay: StateHolder<boolean> = this.makeState(false); + updateEvent = new EventTrigger(); + constructor(p: ManualSelectionProps) { + super(p); + this.url(p.initialUrl); + this.update(); + } + render() { + return ( + <div className="pure-g pure-form pure-form-stacked"> + <div className="pure-u-1"> + <label>URL</label> + <input className="url" type="text" spellCheck={false} + value={this.url()} + key="exchange-url-input" + onInput={(e) => this.onUrlChanged((e.target as HTMLInputElement).value)} /> + </div> + <div className="pure-u-1"> + <button className="pure-button button-success" + disabled={!this.isOkay()} + onClick={() => this.props.onSelect(this.url())}> + {i18n.str`Select`} + </button> + {this.errorMessage()} + </div> + </div> + ); + } + + 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<ExchangeSelectionProps> { reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState( null); url: StateHolder<string|null> = this.makeState(null); - detailCollapsed: StateHolder<boolean> = this.makeState(true); - updateEvent = new EventTrigger(); + selectingExchange: StateHolder<boolean> = 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 ( - <button className="linky" - onClick={() => this.detailCollapsed(false)}> - {i18n.str`view fee structure / select different exchange provider`} - </button> - ); + let prefilledExchangesUrls = []; + if (props.currencyRecord) { + let exchanges = props.currencyRecord.exchanges.map((x) => x.baseUrl); + prefilledExchangesUrls.push(...exchanges); } - return ( - <div> - <h2>Provider Selection</h2> - <label>URL: </label> - <input className="url" type="text" spellCheck={false} - value={this.url()!} - key="exchange-url-input" - onInput={(e) => this.onUrlChanged((e.target as HTMLInputElement).value)}/> - <br /> - {this.renderStatus()} - <h2>{i18n.str`Detailed Fee Structure`}</h2> - {renderReserveCreationDetails(this.reserveCreationInfo())} - </div>) - } - - 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 = ( + <i18n.Translate wrap="p"> + The exchange is trusted by the wallet. + </i18n.Translate> + ); + } else if (rci.isAudited) { + trustMessage = ( + <i18n.Translate wrap="p"> + The exchange is audited by a trusted auditor. + </i18n.Translate> + ); + } else { + trustMessage = ( + <i18n.Translate wrap="p"> + 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. + </i18n.Translate> + ); + } return ( + <div> <i18n.Translate wrap="p"> + Using exchange provider <strong>{this.url()}</strong>. The exchange provider will charge {" "} - <span>{this.renderFee()}</span> + <span>{amountToPretty(totalCost)}</span> {" "} in fees. </i18n.Translate> + {trustMessage} + </div> ); } if (this.url() && !this.statusString()) { @@ -233,7 +413,7 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { if (this.statusString()) { return ( <p> - <strong style={{color: "red"}}>{i18n.str`A problem occured, see below.`}</strong> + <strong style={{color: "red"}}>{i18n.str`A problem occured, see below. ${this.statusString()}`}</strong> </p> ); } @@ -244,22 +424,80 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { ); } - render(): JSX.Element { + renderConfirm() { return ( <div> - <i18n.Translate wrap="p"> - {"You are about to withdraw "} - <strong>{amountToPretty(this.props.amount)}</strong> - {" from your bank account into your wallet."} - </i18n.Translate> {this.renderFeeStatus()} - <button className="accept" + <button className="pure-button button-success" disabled={this.reserveCreationInfo() == null} onClick={() => this.confirmReserve()}> {i18n.str`Accept fees and withdraw`} </button> + { " " } + <button className="pure-button button-secondary" + onClick={() => this.selectingExchange(true)}> + {i18n.str`Change Exchange Provider`} + </button> <br/> - {this.renderAdvanced()} + <Collapsible initiallyCollapsed={true} title="Fee Details"> + {renderReserveCreationDetails(this.reserveCreationInfo())} + </Collapsible> + <Collapsible initiallyCollapsed={true} title="Auditor Details"> + {renderAuditorDetails(this.reserveCreationInfo())} + </Collapsible> + </div> + ); + } + + 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 ( + <div> + Please select an exchange. You can review the details before after your selection. + + {this.props.suggestedExchangeUrl && ( + <div> + <h2>Bank Suggestion</h2> + <button className="pure-button button-success" onClick={() => this.select(this.props.suggestedExchangeUrl)}> + Select <strong>{this.props.suggestedExchangeUrl}</strong> + </button> + </div> + )} + + {exchanges.length > 0 && ( + <div> + <h2>Known Exchanges</h2> + {exchanges.map(e => ( + <button className="pure-button button-success" onClick={() => this.select(e.baseUrl)}> + Select <strong>{e.baseUrl}</strong> + </button> + ))} + </div> + )} + + <h2>Manual Selection</h2> + <ManualSelection initialUrl={this.url() || ""} onSelect={(url: string) => this.select(url)} /> + </div> + ); + } + + render(): JSX.Element { + return ( + <div> + <i18n.Translate wrap="p"> + {"You are about to withdraw "} + <strong>{amountToPretty(this.props.amount)}</strong> + {" from your bank account into your wallet."} + </i18n.Translate> + {this.selectingExchange() ? this.renderSelect() : this.renderConfirm()} </div> ); } @@ -277,20 +515,6 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { */ 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<ExchangeSelectionProps> { 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<ExchangeSelectionProps> { 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 <p><strong style={{color: "red"}}>{this.statusString()}</strong></p>; @@ -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(<ExchangeSelection {...args} />, 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 @@ <meta charset="utf-8"> <link rel="stylesheet" type="text/css" href="../style/lang.css"> + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> <link rel="stylesheet" type="text/css" href="popup.css"> <script src="/dist/page-common-bundle.js"></script> 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<any, any> { </p> ); }); - if (listing.length > 0) { - let link = chrome.extension.getURL("/src/pages/auditors.html"); - let linkElem = <a href={link} target="_blank">auditors</a>; - return ( - <div> - {listing} - {linkElem} - </div> - ); - } - - return this.renderEmpty(); + let link = chrome.extension.getURL("/src/pages/auditors.html"); + let linkElem = <a className="actionLink" href={link} target="_blank">Trusted Auditors and Exchanges</a>; + return ( + <div> + <h2>Available Balance</h2> + {listing.length > 0 ? listing : this.renderEmpty()} + <h2>Settings</h2> + {linkElem} + </div> + ); } } 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<CreateReserveResponse> { 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<ReserveCreationInfo> { 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<ExchangeRecord>(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<CurrencyRecord|undefined> { + return this.q().get(Stores.currencies, currency); + } + async paymentSucceeded(contractHash: string, merchantSig: string): Promise<any> { 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<any> { - return new Promise<ExchangeRecord[]>((resolve, reject) => { + return new Promise<any>((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<CurrencyRecord[]> { return await callBackend("get-currencies"); } + +export async function getCurrency(name: string): Promise<CurrencyRecord|null> { + return await callBackend("currency-info", {name}); +} + +export async function getExchangeInfo(baseUrl: string): Promise<ExchangeRecord> { + return await callBackend("exchange-info", {baseUrl}); +} + export async function updateCurrency(currencyRecord: CurrencyRecord): Promise<void> { 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 } } @@ -2,6 +2,12 @@ # yarn lockfile v1 +"@types/moment@^2.13.0": + version "2.13.0" + resolved "https://registry.yarnpkg.com/@types/moment/-/moment-2.13.0.tgz#604ebd189bc3bc34a1548689404e61a2a4aac896" + dependencies: + moment "*" + "@types/react-dom@^15.5.0": version "15.5.0" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-15.5.0.tgz#7f4fb9613d4051141773242f7b6b5f1a46b34bd9" @@ -2554,6 +2560,10 @@ mocha@^2.4.5: supports-color "1.2.0" to-iso-string "0.0.2" +moment@*, moment@^2.18.1: + version "2.18.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" + ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" @@ -3673,16 +3683,7 @@ ua-parser-js@^0.7.9: version "0.7.12" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.12.tgz#04c81a99bdd5dc52263ea29d24c6bf8d4818a4bb" -uglify-js@^2.6, uglify-js@^2.8.5, uglify-js@~2.8.22: - version "2.8.22" - resolved "git://github.com/mishoo/UglifyJS2#278577f3cb75e72320564805ee91be63e5f9c806" - dependencies: - source-map "~0.5.1" - yargs "~3.10.0" - optionalDependencies: - uglify-to-browserify "~1.0.0" - -uglify-js@^2.8.22: +uglify-js@^2.6, uglify-js@^2.8.22, uglify-js@^2.8.5, uglify-js@~2.8.22: version "2.8.22" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.22.tgz#d54934778a8da14903fa29a326fb24c0ab51a1a0" dependencies: |