/* This file is part of TALER (C) 2015-2016 GNUnet e.V. 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. 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 TALER; see the file COPYING. If not, see */ /** * Page shown to the user to confirm creation * of a reserve, usually requested by the bank. * * @author Florian Dold */ import {amountToPretty, canonicalizeBaseUrl} from "../../helpers"; import * as i18n from "../../i18n"; import { AmountJson, Amounts, CreateReserveResponse, CurrencyRecord, Denomination, DenominationRecord, ReserveCreationInfo, } from "../../types"; import {ImplicitStateComponent, StateHolder} from "../components"; import { getCurrency, getExchangeInfo, getReserveCreationInfo, } from "../wxApi"; 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 { return new Promise((resolve, reject) => { setTimeout(() => resolve(value), delayMs); }); } class EventTrigger { private triggerResolve: any; private triggerPromise: Promise; constructor() { this.reset(); } private reset() { this.triggerPromise = new Promise((resolve, reject) => { this.triggerResolve = resolve; }); } trigger() { this.triggerResolve(false); this.reset(); } async wait(delayMs: number): Promise { return await Promise.race([this.triggerPromise, delay(delayMs, true)]); } } 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.

); } const denoms = rci.selectedDenoms; const countByPub: {[s: string]: number} = {}; const uniq: DenominationRecord[] = []; denoms.forEach((x: DenominationRecord) => { let c = countByPub[x.denomPub] || 0; if (c === 0) { uniq.push(x); } c += 1; countByPub[x.denomPub] = c; }); function row(denom: DenominationRecord) { return ( {countByPub[denom.denomPub] + "x"} {amountToPretty(denom.value)} {amountToPretty(denom.feeWithdraw)} {amountToPretty(denom.feeRefresh)} {amountToPretty(denom.feeDeposit)} ); } 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)} ))} , ]; } const withdrawFeeStr = amountToPretty(rci.withdrawFee); const overheadStr = amountToPretty(rci.overhead); return (

Overview

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

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

{i18n.str`Earliest expiration (for deposit): ${moment.unix(rci.earliestDepositExpiration).fromNow()}`}

Coin Fees

{uniq.map(row)}
{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)}
); } function WithdrawFee(props: {reserveCreationInfo: ReserveCreationInfo|null}): JSX.Element { if (props.reserveCreationInfo) { const {overhead, withdrawFee} = props.reserveCreationInfo; const totalCost = Amounts.add(overhead, withdrawFee).amount; return

{i18n.str`Withdraw fees:`} {amountToPretty(totalCost)}

; } return

; } interface ExchangeSelectionProps { suggestedExchangeUrl: string; amount: AmountJson; callback_url: string; wt_types: string[]; currencyRecord: CurrencyRecord|null; } interface ManualSelectionProps { onSelect(url: string): void; initialUrl: string; } class ManualSelection extends ImplicitStateComponent { private url: StateHolder = this.makeState(""); private errorMessage: StateHolder = this.makeState(null); private isOkay: StateHolder = this.makeState(false); private 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; } const parsedUrl = new URI(this.url()!); if (parsedUrl.is("relative")) { this.errorMessage(i18n.str`Error: URL may not be relative`); this.isOkay(false); return; } try { const url = canonicalizeBaseUrl(this.url()!); const 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")) { const 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(); const waited = await this.updateEvent.wait(200); if (waited) { // Run the actual update if nobody else preempted us. this.update(); } } } class ExchangeSelection extends ImplicitStateComponent { private statusString: StateHolder = this.makeState(null); private reserveCreationInfo: StateHolder = this.makeState( null); private url: StateHolder = this.makeState(null); private selectingExchange: StateHolder = this.makeState(false); constructor(props: ExchangeSelectionProps) { super(props); const prefilledExchangesUrls = []; if (props.currencyRecord) { const exchanges = props.currencyRecord.exchanges.map((x) => x.baseUrl); prefilledExchangesUrls.push(...exchanges); } if (props.suggestedExchangeUrl) { prefilledExchangesUrls.push(props.suggestedExchangeUrl); } if (prefilledExchangesUrls.length !== 0) { this.url(prefilledExchangesUrls[0]); this.forceReserveUpdate(); } else { this.selectingExchange(true); } } renderFeeStatus() { const rci = this.reserveCreationInfo(); if (rci) { const 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 {" "} {amountToPretty(totalCost)} {" "} in fees. {trustMessage}
); } if (this.url() && !this.statusString()) { const shortName = new URI(this.url()!).host(); return ( Waiting for a response from {" "} {shortName} ); } if (this.statusString()) { return (

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

); } return (

{i18n.str`Information about fees will be available when an exchange provider is selected.`}

); } renderConfirm() { return (
{this.renderFeeStatus()} { " " }
{renderReserveCreationDetails(this.reserveCreationInfo())} {renderAuditorDetails(this.reserveCreationInfo())}
); } select(url: string) { this.reserveCreationInfo(null); this.url(url); this.selectingExchange(false); this.forceReserveUpdate(); } renderSelect() { const 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()}
); } confirmReserve() { this.confirmReserveImpl(this.reserveCreationInfo()!, this.url()!, this.props.amount, this.props.callback_url); } /** * Do an update of the reserve creation info, without any debouncing. */ async forceReserveUpdate() { this.reserveCreationInfo(null); try { const url = canonicalizeBaseUrl(this.url()!); const r = await getReserveCreationInfo(url, this.props.amount); console.log("get exchange info resolved"); this.reserveCreationInfo(r); console.dir(r); } catch (e) { console.log("get exchange info rejected", e); if (e.hasOwnProperty("httpStatus")) { this.statusString(`Error: request failed with status ${e.httpStatus}`); } else if (e.hasOwnProperty("errorResponse")) { const resp = e.errorResponse; this.statusString(`Error: ${resp.error} (${resp.hint})`); } } } confirmReserveImpl(rci: ReserveCreationInfo, exchange: string, amount: AmountJson, callback_url: string) { const d = {exchange: canonicalizeBaseUrl(exchange), amount}; const cb = (rawResp: any) => { if (!rawResp) { throw Error("empty response"); } // FIXME: filter out types that bank/exchange don't have in common const wireDetails = rci.wireInfo; const filteredWireDetails: any = {}; for (const wireType in wireDetails) { if (this.props.wt_types.findIndex((x) => x.toLowerCase() === wireType.toLowerCase()) < 0) { continue; } const obj = Object.assign({}, wireDetails[wireType]); // The bank doesn't need to know about fees delete obj.fees; // Consequently the bank can't verify signatures anyway, so // we delete this extra data, to make the request URL shorter. delete obj.salt; delete obj.sig; filteredWireDetails[wireType] = obj; } if (!rawResp.error) { const resp = CreateReserveResponse.checked(rawResp); const q: {[name: string]: string|number} = { amount_currency: amount.currency, amount_fraction: amount.fraction, amount_value: amount.value, exchange: resp.exchange, reserve_pub: resp.reservePub, wire_details: JSON.stringify(filteredWireDetails), }; const url = new URI(callback_url).addQuery(q); if (!url.is("absolute")) { throw Error("callback url is not absolute"); } console.log("going to", url.href()); document.location.href = url.href(); } else { this.statusString( i18n.str`Oops, something went wrong. The wallet responded with error status (${rawResp.error}).`); } }; chrome.runtime.sendMessage({type: "create-reserve", detail: d}, cb); } renderStatus(): any { if (this.statusString()) { return

{this.statusString()}

; } else if (!this.reserveCreationInfo()) { return

{i18n.str`Checking URL, please wait ...`}

; } return ""; } } async function main() { try { const url = new URI(document.location.href); const query: any = URI.parseQuery(url.query()); let amount; try { amount = AmountJson.checked(JSON.parse(query.amount)); } catch (e) { throw Error(i18n.str`Can't parse amount: ${e.message}`); } const callback_url = query.callback_url; const bank_url = query.bank_url; let wt_types; try { wt_types = JSON.parse(query.wt_types); } catch (e) { throw Error(i18n.str`Can't parse wire_types: ${e.message}`); } const suggestedExchangeUrl = query.suggested_exchange_url; const currencyRecord = await getCurrency(amount.currency); const args = { amount, callback_url, currencyRecord, suggestedExchangeUrl, wt_types, }; ReactDOM.render(, document.getElementById( "exchange-selection")!); } catch (e) { // TODO: provide more context information, maybe factor it out into a // TODO:generic error reporting function or component. document.body.innerText = i18n.str`Fatal error: "${e.message}".`; console.error(`got error "${e.message}"`, e); } } document.addEventListener("DOMContentLoaded", () => { main(); });