From c0cd668c6491f6db8d8eb50236c75be5ce91c1d6 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Sat, 18 Nov 2017 13:33:53 -0700 Subject: [PATCH] Custom Nodes (#322) * Layed out components for custom nodes. * Outline of custom nodes. Still missing various features and error handling. * Persist custom nodes to local storage. * Make custom nodes removable. * Add latest block functions, call it when switching nodes. * Initialize correct node, move node utils into utils file. * Fix names * Send headers along with rpc requests. * Remove custom network options for now. * PR feedback. * One last log. * Fix tests. * Headers in batch too. * Switch to node when you add it. * Reduce hackery. * Clean up linter and tsc. * Fix latest block hex conversion. * Unit tests. * Fix missing property. * Fix Modal title typing. --- common/actions/config/actionCreators.ts | 38 ++- common/actions/config/actionTypes.ts | 27 +- common/actions/config/constants.ts | 3 + common/components/Footer/index.tsx | 12 +- .../Header/components/CustomNodeModal.tsx | 230 ++++++++++++++++++ .../Header/components/Navigation.tsx | 2 +- common/components/Header/index.scss | 14 ++ common/components/Header/index.tsx | 119 +++++++-- common/components/ui/ColorDropdown.scss | 23 ++ common/components/ui/ColorDropdown.tsx | 29 ++- common/components/ui/Modal.tsx | 4 +- common/config/data.ts | 11 + common/containers/TabSection/index.tsx | 42 +++- common/libs/nodes/INode.ts | 1 + common/libs/nodes/custom/index.ts | 18 ++ common/libs/nodes/etherscan/requests.ts | 10 +- common/libs/nodes/etherscan/types.ts | 8 +- common/libs/nodes/index.ts | 1 + common/libs/nodes/rpc/client.ts | 10 +- common/libs/nodes/rpc/index.ts | 39 +-- common/libs/nodes/rpc/requests.ts | 9 +- common/libs/nodes/rpc/types.ts | 7 +- common/libs/nodes/web3/index.ts | 11 + common/reducers/config.ts | 71 +++++- common/sagas/config.ts | 82 ++++++- common/sass/styles/overrides/alerts.scss | 9 + common/selectors/config.ts | 12 +- common/store.ts | 35 ++- common/utils/node.ts | 36 +++ spec/pages/SendTransaction.spec.tsx | 5 +- .../SendTransaction.spec.tsx.snap | 1 + spec/reducers/config.spec.ts | 55 ++++- spec/utils/node.spec.ts | 38 +++ 33 files changed, 914 insertions(+), 98 deletions(-) create mode 100644 common/components/Header/components/CustomNodeModal.tsx create mode 100644 common/components/ui/ColorDropdown.scss create mode 100644 common/libs/nodes/custom/index.ts create mode 100644 common/utils/node.ts create mode 100644 spec/utils/node.spec.ts diff --git a/common/actions/config/actionCreators.ts b/common/actions/config/actionCreators.ts index 3c169f9f..8b189127 100644 --- a/common/actions/config/actionCreators.ts +++ b/common/actions/config/actionCreators.ts @@ -1,5 +1,6 @@ import * as interfaces from './actionTypes'; import { TypeKeys } from './constants'; +import { NodeConfig, CustomNodeConfig } from 'config/data'; export type TForceOfflineConfig = typeof forceOfflineConfig; export function forceOfflineConfig(): interfaces.ForceOfflineAction { @@ -24,10 +25,13 @@ export function changeLanguage(sign: string): interfaces.ChangeLanguageAction { } export type TChangeNode = typeof changeNode; -export function changeNode(value: string): interfaces.ChangeNodeAction { +export function changeNode( + nodeSelection: string, + node: NodeConfig +): interfaces.ChangeNodeAction { return { type: TypeKeys.CONFIG_NODE_CHANGE, - payload: value + payload: { nodeSelection, node } }; } @@ -56,6 +60,36 @@ export function changeNodeIntent( }; } +export type TAddCustomNode = typeof addCustomNode; +export function addCustomNode( + payload: CustomNodeConfig +): interfaces.AddCustomNodeAction { + return { + type: TypeKeys.CONFIG_ADD_CUSTOM_NODE, + payload + }; +} + +export type TRemoveCustomNode = typeof removeCustomNode; +export function removeCustomNode( + payload: CustomNodeConfig +): interfaces.RemoveCustomNodeAction { + return { + type: TypeKeys.CONFIG_REMOVE_CUSTOM_NODE, + payload + }; +} + +export type TSetLatestBlock = typeof setLatestBlock; +export function setLatestBlock( + payload: string +): interfaces.SetLatestBlockAction { + return { + type: TypeKeys.CONFIG_SET_LATEST_BLOCK, + payload + }; +} + export type TWeb3UnsetNode = typeof web3UnsetNode; export function web3UnsetNode(): interfaces.Web3UnsetNodeAction { return { diff --git a/common/actions/config/actionTypes.ts b/common/actions/config/actionTypes.ts index 57480439..e8b27135 100644 --- a/common/actions/config/actionTypes.ts +++ b/common/actions/config/actionTypes.ts @@ -1,4 +1,5 @@ import { TypeKeys } from './constants'; +import { CustomNodeConfig, NodeConfig } from 'config/data'; /*** Toggle Offline ***/ export interface ToggleOfflineAction { @@ -20,7 +21,10 @@ export interface ChangeLanguageAction { export interface ChangeNodeAction { type: TypeKeys.CONFIG_NODE_CHANGE; // FIXME $keyof? - payload: string; + payload: { + nodeSelection: string; + node: NodeConfig; + }; } /*** Change gas price ***/ @@ -40,6 +44,24 @@ export interface ChangeNodeIntentAction { payload: string; } +/*** Add Custom Node ***/ +export interface AddCustomNodeAction { + type: TypeKeys.CONFIG_ADD_CUSTOM_NODE; + payload: CustomNodeConfig; +} + +/*** Remove Custom Node ***/ +export interface RemoveCustomNodeAction { + type: TypeKeys.CONFIG_REMOVE_CUSTOM_NODE; + payload: CustomNodeConfig; +} + +/*** Set Latest Block ***/ +export interface SetLatestBlockAction { + type: TypeKeys.CONFIG_SET_LATEST_BLOCK; + payload: string; +} + /*** Unset Web3 as a Node ***/ export interface Web3UnsetNodeAction { type: TypeKeys.CONFIG_NODE_WEB3_UNSET; @@ -54,4 +76,7 @@ export type ConfigAction = | PollOfflineStatus | ForceOfflineAction | ChangeNodeIntentAction + | AddCustomNodeAction + | RemoveCustomNodeAction + | SetLatestBlockAction | Web3UnsetNodeAction; diff --git a/common/actions/config/constants.ts b/common/actions/config/constants.ts index 3e333e64..d11471ac 100644 --- a/common/actions/config/constants.ts +++ b/common/actions/config/constants.ts @@ -6,5 +6,8 @@ export enum TypeKeys { CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE', CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE', CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS', + CONFIG_ADD_CUSTOM_NODE = 'CONFIG_ADD_CUSTOM_NODE', + CONFIG_REMOVE_CUSTOM_NODE = 'CONFIG_REMOVE_CUSTOM_NODE', + CONFIG_SET_LATEST_BLOCK = 'CONFIG_SET_LATEST_BLOCK', CONFIG_NODE_WEB3_UNSET = 'CONFIG_NODE_WEB3_UNSET' } diff --git a/common/components/Footer/index.tsx b/common/components/Footer/index.tsx index 4c5ae0b7..e8eb76c4 100644 --- a/common/components/Footer/index.tsx +++ b/common/components/Footer/index.tsx @@ -92,11 +92,15 @@ const LINKS_SOCIAL = [ } ]; -interface ComponentState { +interface Props { + latestBlock: string; +}; + +interface State { isOpen: boolean; } -export default class Footer extends React.Component<{}, ComponentState> { +export default class Footer extends React.Component { constructor(props) { super(props); this.state = { isOpen: false }; @@ -276,9 +280,7 @@ export default class Footer extends React.Component<{}, ComponentState> { ); })}

- - {/* TODO: Fix me */} -

Latest Block#: ?????

+

Latest Block#: {this.props.latestBlock}

diff --git a/common/components/Header/components/CustomNodeModal.tsx b/common/components/Header/components/CustomNodeModal.tsx new file mode 100644 index 00000000..d4b63800 --- /dev/null +++ b/common/components/Header/components/CustomNodeModal.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import classnames from 'classnames'; +import Modal, { IButton } from 'components/ui/Modal'; +import translate from 'translations'; +import { NETWORKS, CustomNodeConfig } from 'config/data'; + +const NETWORK_KEYS = Object.keys(NETWORKS); + +interface Input { + name: string; + placeholder?: string; + type?: string; +} + +interface Props { + handleAddCustomNode(node: CustomNodeConfig): void; + handleClose(): void; +} + +interface State { + name: string; + url: string; + port: string; + network: string; + hasAuth: boolean; + username: string; + password: string; +} + +export default class CustomNodeModal extends React.Component { + public state: State = { + name: '', + url: '', + port: '', + network: NETWORK_KEYS[0], + hasAuth: false, + username: '', + password: '', + }; + + public render() { + const { handleClose } = this.props; + const isHttps = window.location.protocol.includes('https'); + const invalids = this.getInvalids(); + + const buttons: IButton[] = [{ + type: 'primary', + text: translate('NODE_CTA'), + onClick: this.saveAndAdd, + disabled: !!Object.keys(invalids).length, + }, { + text: translate('x_Cancel'), + onClick: handleClose + }]; + + return ( + +
+ {isHttps && +
+ {translate('NODE_Warning')} +
+ } + +
+
+
+ + {this.renderInput({ + name: 'name', + placeholder: 'My Node', + }, invalids)} +
+
+ + +
+
+ +
+
+ + {this.renderInput({ + name: 'url', + placeholder: 'http://127.0.0.1/', + }, invalids)} +
+ +
+ + {this.renderInput({ + name: 'port', + placeholder: '8545', + type: 'number', + }, invalids)} +
+
+
+
+ +
+
+ {this.state.hasAuth && +
+
+ + {this.renderInput({ name: 'username' }, invalids)} +
+
+ + {this.renderInput({ + name: 'password', + type: 'password', + }, invalids)} +
+
+ } +
+
+
+ ); + } + + private renderInput(input: Input, invalids: { [key: string]: boolean }) { + return ; + } + + private getInvalids(): { [key: string]: boolean } { + const { + url, + port, + hasAuth, + username, + password, + } = this.state; + const required = ["name", "url", "port", "network"]; + const invalids: { [key: string]: boolean } = {}; + + // Required fields + required.forEach((field) => { + if (!this.state[field]) { + invalids[field] = true; + } + }); + + // Somewhat valid URL, not 100% fool-proof + if (!/https?\:\/\/\w+/i.test(url)) { + invalids.url = true; + } + + // Numeric port within range + const iport = parseInt(port, 10); + if (!iport || iport < 1 || iport > 65535) { + invalids.port = true; + } + + // If they have auth, make sure it's provided + if (hasAuth) { + if (!username) { + invalids.username = true; + } + if (!password) { + invalids.password = true; + } + } + + return invalids; + } + + private handleChange = (ev: React.FormEvent< + HTMLInputElement | HTMLSelectElement + >) => { + const { name, value } = ev.currentTarget; + this.setState({ [name as any]: value }); + }; + + private handleCheckbox = (ev: React.FormEvent) => { + const { name } = ev.currentTarget; + this.setState({ [name as any]: !this.state[name] }); + }; + + private saveAndAdd = () => { + const node: CustomNodeConfig = { + name: this.state.name.trim(), + url: this.state.url.trim(), + port: parseInt(this.state.port, 10), + network: this.state.network, + }; + + if (this.state.hasAuth) { + node.auth = { + username: this.state.username, + password: this.state.password, + }; + } + + this.props.handleAddCustomNode(node); + }; +} diff --git a/common/components/Header/components/Navigation.tsx b/common/components/Header/components/Navigation.tsx index 7b5d6775..facdd691 100644 --- a/common/components/Header/components/Navigation.tsx +++ b/common/components/Header/components/Navigation.tsx @@ -65,7 +65,7 @@ export default class Navigation extends Component { /* * public scrollLeft() {} public scrollRight() {} - * + * */ public render() { diff --git a/common/components/Header/index.scss b/common/components/Header/index.scss index b063d3af..7f8aae6e 100644 --- a/common/components/Header/index.scss +++ b/common/components/Header/index.scss @@ -15,6 +15,15 @@ $small-size: 900px; } } +@keyframes dropdown-is-flashing { + 0%, 100% { + opacity: 0.8; + } + 50% { + opacity: 0.7; + } +} + // Header .Header { margin-bottom: 2rem; @@ -124,6 +133,11 @@ $small-size: 900px; padding-top: $space-sm !important; padding-bottom: $space-sm !important; } + + &.is-flashing { + pointer-events: none; + animation: dropdown-is-flashing 800ms ease infinite; + } } } } diff --git a/common/components/Header/index.tsx b/common/components/Header/index.tsx index 7811a496..6e381db4 100644 --- a/common/components/Header/index.tsx +++ b/common/components/Header/index.tsx @@ -1,11 +1,14 @@ import { TChangeGasPrice, TChangeLanguage, - TChangeNodeIntent + TChangeNodeIntent, + TAddCustomNode, + TRemoveCustomNode } from 'actions/config'; import logo from 'assets/images/logo-myetherwallet.svg'; import { Dropdown, ColorDropdown } from 'components/ui'; import React, { Component } from 'react'; +import classnames from 'classnames'; import { Link } from 'react-router-dom'; import { ANNOUNCEMENT_MESSAGE, @@ -13,44 +16,85 @@ import { languages, NETWORKS, NODES, - VERSION + VERSION, + NodeConfig, + CustomNodeConfig } from '../../config/data'; import GasPriceDropdown from './components/GasPriceDropdown'; import Navigation from './components/Navigation'; +import CustomNodeModal from './components/CustomNodeModal'; import { getKeyByValue } from 'utils/helpers'; +import { makeCustomNodeId } from 'utils/node'; import './index.scss'; interface Props { languageSelection: string; + node: NodeConfig; nodeSelection: string; + isChangingNode: boolean; gasPriceGwei: number; + customNodes: CustomNodeConfig[]; changeLanguage: TChangeLanguage; changeNodeIntent: TChangeNodeIntent; changeGasPrice: TChangeGasPrice; + addCustomNode: TAddCustomNode; + removeCustomNode: TRemoveCustomNode; } -export default class Header extends Component { +interface State { + isAddingCustomNode: boolean; +} + +export default class Header extends Component { + public state = { + isAddingCustomNode: false + }; + public render() { - const { languageSelection, changeNodeIntent, nodeSelection } = this.props; + const { + languageSelection, + changeNodeIntent, + node, + nodeSelection, + isChangingNode, + customNodes + } = this.props; + const { isAddingCustomNode } = this.state; const selectedLanguage = languageSelection; - const selectedNode = NODES[nodeSelection]; - const selectedNetwork = NETWORKS[selectedNode.network]; + const selectedNetwork = NETWORKS[node.network]; const LanguageDropDown = Dropdown as new () => Dropdown< typeof selectedLanguage >; - const nodeOptions = Object.keys(NODES).map(key => { - return { - value: key, - name: ( - - {NODES[key].network} ({NODES[key].service}) - - ), - color: NETWORKS[NODES[key].network].color, - hidden: NODES[key].hidden - }; - }); + + const nodeOptions = Object.keys(NODES) + .map(key => { + return { + value: key, + name: ( + + {NODES[key].network} ({NODES[key].service}) + + ), + color: NETWORKS[NODES[key].network].color, + hidden: NODES[key].hidden + }; + }) + .concat( + customNodes.map(customNode => { + return { + value: makeCustomNodeId(customNode), + name: ( + + {customNode.network} - {customNode.name} (custom) + + ), + color: '#000', + hidden: false, + onRemove: () => this.props.removeCustomNode(customNode) + }; + }) + ); return (
@@ -66,7 +110,7 @@ export default class Header extends Component {
@@ -109,22 +153,29 @@ export default class Header extends Component { />
-
+
- Add Custom Node + Add Custom Node } disabled={nodeSelection === 'web3'} onChange={changeNodeIntent} size="smr" color="white" + menuAlign="right" />
@@ -132,6 +183,13 @@ export default class Header extends Component { + + {isAddingCustomNode && ( + + )} ); } @@ -142,4 +200,17 @@ export default class Header extends Component { this.props.changeLanguage(key); } }; + + private openCustomNodeModal = () => { + this.setState({ isAddingCustomNode: true }); + }; + + private closeCustomNodeModal = () => { + this.setState({ isAddingCustomNode: false }); + }; + + private addCustomNode = (node: CustomNodeConfig) => { + this.setState({ isAddingCustomNode: false }); + this.props.addCustomNode(node); + }; } diff --git a/common/components/ui/ColorDropdown.scss b/common/components/ui/ColorDropdown.scss new file mode 100644 index 00000000..4f0a8211 --- /dev/null +++ b/common/components/ui/ColorDropdown.scss @@ -0,0 +1,23 @@ +.ColorDropdown { + &-item { + position: relative; + padding-right: 10px; + border-left: 2px solid; + + &-remove { + position: absolute; + top: 50%; + right: 5px; + width: 15px; + height: 15px; + opacity: 0.5; + cursor: pointer; + // Z fixes clipping issue + transform: translateY(-50%) translateZ(0); + + &:hover { + opacity: 1; + } + } + } +} diff --git a/common/components/ui/ColorDropdown.tsx b/common/components/ui/ColorDropdown.tsx index e0120d66..9a90b116 100644 --- a/common/components/ui/ColorDropdown.tsx +++ b/common/components/ui/ColorDropdown.tsx @@ -1,12 +1,15 @@ import React, { Component } from 'react'; import classnames from 'classnames'; import DropdownShell from './DropdownShell'; +import removeIcon from 'assets/images/icon-remove.svg'; +import './ColorDropdown.scss'; interface Option { name: any; value: T; color?: string; hidden: boolean | undefined; + onRemove?(): void; } interface Props { @@ -67,6 +70,7 @@ export default class ColorDropdown extends Component, {}> { }, []); const menuClass = classnames({ + ColorDropdown: true, 'dropdown-menu': true, [`dropdown-menu-${menuAlign || ''}`]: !!menuAlign }); @@ -78,12 +82,24 @@ export default class ColorDropdown extends Component, {}> { return
  • ; } else { return ( -
  • +
  • {option.name} + + {option.onRemove && ( + + )}
  • ); @@ -102,6 +118,17 @@ export default class ColorDropdown extends Component, {}> { } }; + private onRemove( + onRemove: () => void, + ev?: React.SyntheticEvent + ) { + if (ev) { + ev.preventDefault(); + ev.stopPropagation(); + } + onRemove(); + } + private getActiveOption() { return this.props.options.find(opt => opt.value === this.props.value); } diff --git a/common/components/ui/Modal.tsx b/common/components/ui/Modal.tsx index 86e519dd..6c1795c5 100644 --- a/common/components/ui/Modal.tsx +++ b/common/components/ui/Modal.tsx @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import './Modal.scss'; export interface IButton { - text: string; + text: string | React.ReactElement; type?: | 'default' | 'primary' @@ -17,7 +17,7 @@ export interface IButton { } interface Props { isOpen?: boolean; - title: string; + title: string | React.ReactElement; disableButtons?: boolean; children: any; buttons: IButton[]; diff --git a/common/config/data.ts b/common/config/data.ts index 6fae2258..d5472d56 100644 --- a/common/config/data.ts +++ b/common/config/data.ts @@ -81,6 +81,17 @@ export interface NodeConfig { hidden?: boolean; } +export interface CustomNodeConfig { + name: string; + url: string; + port: number; + network: string; + auth?: { + username: string; + password: string; + }; +} + // Must be a website that follows the ethplorer convention of /tx/[hash] and // address/[address] to generate the correct functions. function makeExplorer(url): BlockExplorerConfig { diff --git a/common/containers/TabSection/index.tsx b/common/containers/TabSection/index.tsx index 335d79d7..72f3f30c 100644 --- a/common/containers/TabSection/index.tsx +++ b/common/containers/TabSection/index.tsx @@ -2,50 +2,72 @@ import { changeGasPrice as dChangeGasPrice, changeLanguage as dChangeLanguage, changeNodeIntent as dChangeNodeIntent, + addCustomNode as dAddCustomNode, + removeCustomNode as dRemoveCustomNode, TChangeGasPrice, TChangeLanguage, - TChangeNodeIntent + TChangeNodeIntent, + TAddCustomNode, + TRemoveCustomNode, } from 'actions/config'; import { AlphaAgreement, Footer, Header } from 'components'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { AppState } from 'reducers'; import Notifications from './Notifications'; +import { NodeConfig, CustomNodeConfig } from 'config/data'; + interface Props { // FIXME children: any; languageSelection: string; + node: NodeConfig; nodeSelection: string; - + isChangingNode: boolean; gasPriceGwei: number; + customNodes: CustomNodeConfig[]; + latestBlock: string; changeLanguage: TChangeLanguage; changeNodeIntent: TChangeNodeIntent; changeGasPrice: TChangeGasPrice; + addCustomNode: TAddCustomNode; + removeCustomNode: TRemoveCustomNode; } class TabSection extends Component { public render() { const { children, // APP + node, nodeSelection, + isChangingNode, languageSelection, gasPriceGwei, + customNodes, + latestBlock, changeLanguage, changeNodeIntent, - changeGasPrice + changeGasPrice, + addCustomNode, + removeCustomNode, } = this.props; const headerProps = { languageSelection, + node, nodeSelection, + isChangingNode, gasPriceGwei, + customNodes, changeLanguage, changeNodeIntent, - changeGasPrice + changeGasPrice, + addCustomNode, + removeCustomNode, }; return ( @@ -53,7 +75,7 @@ class TabSection extends Component {
    {children}
    -
    +
    @@ -64,14 +86,20 @@ class TabSection extends Component { function mapStateToProps(state: AppState) { return { + node: state.config.node, nodeSelection: state.config.nodeSelection, + isChangingNode: state.config.isChangingNode, languageSelection: state.config.languageSelection, - gasPriceGwei: state.config.gasPriceGwei + gasPriceGwei: state.config.gasPriceGwei, + customNodes: state.config.customNodes, + latestBlock: state.config.latestBlock, }; } export default connect(mapStateToProps, { changeGasPrice: dChangeGasPrice, changeLanguage: dChangeLanguage, - changeNodeIntent: dChangeNodeIntent + changeNodeIntent: dChangeNodeIntent, + addCustomNode: dAddCustomNode, + removeCustomNode: dRemoveCustomNode, })(TabSection); diff --git a/common/libs/nodes/INode.ts b/common/libs/nodes/INode.ts index d47b53b0..669728ea 100644 --- a/common/libs/nodes/INode.ts +++ b/common/libs/nodes/INode.ts @@ -14,4 +14,5 @@ export interface INode { getTransactionCount(address: string): Promise; sendRawTx(tx: string): Promise; sendCallRequest(txObj: TxObj): Promise; + getCurrentBlock(): Promise; } diff --git a/common/libs/nodes/custom/index.ts b/common/libs/nodes/custom/index.ts new file mode 100644 index 00000000..7d5a8e48 --- /dev/null +++ b/common/libs/nodes/custom/index.ts @@ -0,0 +1,18 @@ +import RPCNode from '../rpc'; +import RPCClient from '../rpc/client'; +import { CustomNodeConfig } from 'config/data'; + +export default class CustomNode extends RPCNode { + constructor(config: CustomNodeConfig) { + const endpoint = `${config.url}:${config.port}`; + super(endpoint); + + const headers: { [key: string]: string } = {}; + if (config.auth) { + const { username, password } = config.auth; + headers.Authorization = `Basic ${btoa(`${username}:${password}`)}`; + } + + this.client = new RPCClient(endpoint, headers); + } +} diff --git a/common/libs/nodes/etherscan/requests.ts b/common/libs/nodes/etherscan/requests.ts index 3e2aff77..1cf353ba 100644 --- a/common/libs/nodes/etherscan/requests.ts +++ b/common/libs/nodes/etherscan/requests.ts @@ -7,7 +7,8 @@ import { GetBalanceRequest, GetTokenBalanceRequest, GetTransactionCountRequest, - SendRawTxRequest + SendRawTxRequest, + GetCurrentBlockRequest } from './types'; export default class EtherscanRequests extends RPCRequests { @@ -66,4 +67,11 @@ export default class EtherscanRequests extends RPCRequests { data: ERC20.balanceOf(address) }); } + + public getCurrentBlock(): GetCurrentBlockRequest { + return { + module: 'proxy', + action: 'eth_blockNumber', + }; + } } diff --git a/common/libs/nodes/etherscan/types.ts b/common/libs/nodes/etherscan/types.ts index 92a9900f..fd153ba8 100644 --- a/common/libs/nodes/etherscan/types.ts +++ b/common/libs/nodes/etherscan/types.ts @@ -41,10 +41,16 @@ export interface GetTransactionCountRequest extends EtherscanReqBase { tag: 'latest'; } +export interface GetCurrentBlockRequest extends EtherscanReqBase { + module: 'proxy'; + action: 'eth_blockNumber'; +} + export type EtherscanRequest = | SendRawTxRequest | GetBalanceRequest | CallRequest | GetTokenBalanceRequest | EstimateGasRequest - | GetTransactionCountRequest; + | GetTransactionCountRequest + | GetCurrentBlockRequest; diff --git a/common/libs/nodes/index.ts b/common/libs/nodes/index.ts index acd96109..66ba5096 100644 --- a/common/libs/nodes/index.ts +++ b/common/libs/nodes/index.ts @@ -1,4 +1,5 @@ export { default as RPCNode } from './rpc'; export { default as InfuraNode } from './infura'; export { default as EtherscanNode } from './etherscan'; +export { default as CustomNode } from './custom'; export { default as Web3Node } from './web3'; diff --git a/common/libs/nodes/rpc/client.ts b/common/libs/nodes/rpc/client.ts index e6b7f2b0..eb6a8d9b 100644 --- a/common/libs/nodes/rpc/client.ts +++ b/common/libs/nodes/rpc/client.ts @@ -3,8 +3,10 @@ import { JsonRpcResponse, RPCRequest } from './types'; export default class RPCClient { public endpoint: string; - constructor(endpoint: string) { + public headers: object; + constructor(endpoint: string, headers: object = {}) { this.endpoint = endpoint; + this.headers = headers; } public id(): string { @@ -21,7 +23,8 @@ export default class RPCClient { return fetch(this.endpoint, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...this.headers, }, body: JSON.stringify(this.decorateRequest(request)) }).then(r => r.json()); @@ -31,7 +34,8 @@ export default class RPCClient { return fetch(this.endpoint, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...this.headers, }, body: JSON.stringify(requests.map(this.decorateRequest)) }).then(r => r.json()); diff --git a/common/libs/nodes/rpc/index.ts b/common/libs/nodes/rpc/index.ts index 60f2f2de..26229d56 100644 --- a/common/libs/nodes/rpc/index.ts +++ b/common/libs/nodes/rpc/index.ts @@ -1,10 +1,19 @@ +import BN from 'bn.js'; import { Token } from 'config/data'; import { TransactionWithoutGas } from 'libs/messages'; import { Wei, TokenValue } from 'libs/units'; +import { stripHexPrefix } from 'libs/values'; import { INode, TxObj } from '../INode'; import RPCClient from './client'; import RPCRequests from './requests'; +function errorOrResult(response) { + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + export default class RpcNode implements INode { public client: RPCClient; public requests: RPCRequests; @@ -25,23 +34,15 @@ export default class RpcNode implements INode { public getBalance(address: string): Promise { return this.client .call(this.requests.getBalance(address)) - .then(response => { - if (response.error) { - throw new Error(response.error.message); - } - return Wei(response.result); - }); + .then(errorOrResult) + .then(result => Wei(result)); } public estimateGas(transaction: TransactionWithoutGas): Promise { return this.client .call(this.requests.estimateGas(transaction)) - .then(response => { - if (response.error) { - throw new Error(response.error.message); - } - return Wei(response.result); - }); + .then(errorOrResult) + .then(result => Wei(result)); } public getTokenBalance(address: string, token: Token): Promise { @@ -77,12 +78,14 @@ export default class RpcNode implements INode { public getTransactionCount(address: string): Promise { return this.client .call(this.requests.getTransactionCount(address)) - .then(response => { - if (response.error) { - throw new Error(response.error.message); - } - return response.result; - }); + .then(errorOrResult); + } + + public getCurrentBlock(): Promise { + return this.client + .call(this.requests.getCurrentBlock()) + .then(errorOrResult) + .then(result => new BN(stripHexPrefix(result)).toString()); } public sendRawTx(signedTx: string): Promise { diff --git a/common/libs/nodes/rpc/requests.ts b/common/libs/nodes/rpc/requests.ts index cd78e466..848459c7 100644 --- a/common/libs/nodes/rpc/requests.ts +++ b/common/libs/nodes/rpc/requests.ts @@ -6,7 +6,8 @@ import { GetBalanceRequest, GetTokenBalanceRequest, GetTransactionCountRequest, - SendRawTxRequest + SendRawTxRequest, + GetCurrentBlockRequest, } from './types'; import { hexEncodeData } from './utils'; import { TxObj } from '../INode'; @@ -63,4 +64,10 @@ export default class RPCRequests { ] }; } + + public getCurrentBlock(): GetCurrentBlockRequest | any { + return { + method: 'eth_blockNumber', + }; + } } diff --git a/common/libs/nodes/rpc/types.ts b/common/libs/nodes/rpc/types.ts index e36bba6c..ec21b521 100644 --- a/common/libs/nodes/rpc/types.ts +++ b/common/libs/nodes/rpc/types.ts @@ -69,9 +69,14 @@ export interface GetTransactionCountRequest extends RPCRequestBase { params: [DATA, DEFAULT_BLOCK]; } +export interface GetCurrentBlockRequest extends RPCRequestBase { + method: 'eth_blockNumber' +} + export type RPCRequest = | GetBalanceRequest | GetTokenBalanceRequest | CallRequest | EstimateGasRequest - | GetTransactionCountRequest; + | GetTransactionCountRequest + | GetCurrentBlockRequest; diff --git a/common/libs/nodes/web3/index.ts b/common/libs/nodes/web3/index.ts index 6962dd47..925c507c 100644 --- a/common/libs/nodes/web3/index.ts +++ b/common/libs/nodes/web3/index.ts @@ -126,6 +126,17 @@ export default class Web3Node implements INode { ); } + public getCurrentBlock(): Promise { + return new Promise((resolve, reject) => + this.web3.eth.getBlock('latest', false, (err, block) => { + if (err) { + return reject(err); + } + resolve(block.number); + }) + ); + } + public sendRawTx(signedTx: string): Promise { return new Promise((resolve, reject) => this.web3.eth.sendRawTransaction(signedTx, (err, txHash) => { diff --git a/common/reducers/config.ts b/common/reducers/config.ts index 19c9dbf1..db87e129 100644 --- a/common/reducers/config.ts +++ b/common/reducers/config.ts @@ -2,26 +2,43 @@ import { ChangeGasPriceAction, ChangeLanguageAction, ChangeNodeAction, - ConfigAction + AddCustomNodeAction, + RemoveCustomNodeAction, + SetLatestBlockAction, + ConfigAction, } from 'actions/config'; import { TypeKeys } from 'actions/config/constants'; -import { NODES } from '../config/data'; +import { + NODES, + NodeConfig, + CustomNodeConfig, +} from '../config/data'; +import { makeCustomNodeId } from 'utils/node'; export interface State { // FIXME languageSelection: string; nodeSelection: string; + node: NodeConfig; + isChangingNode: boolean; gasPriceGwei: number; offline: boolean; forceOffline: boolean; + customNodes: CustomNodeConfig[]; + latestBlock: string; } +const defaultNode = 'eth_mew'; export const INITIAL_STATE: State = { languageSelection: 'en', - nodeSelection: Object.keys(NODES)[0], + nodeSelection: defaultNode, + node: NODES[defaultNode], + isChangingNode: false, gasPriceGwei: 21, offline: false, - forceOffline: false + forceOffline: false, + customNodes: [], + latestBlock: "???", }; function changeLanguage(state: State, action: ChangeLanguageAction): State { @@ -34,7 +51,16 @@ function changeLanguage(state: State, action: ChangeLanguageAction): State { function changeNode(state: State, action: ChangeNodeAction): State { return { ...state, - nodeSelection: action.payload + nodeSelection: action.payload.nodeSelection, + node: action.payload.node, + isChangingNode: false, + }; +} + +function changeNodeIntent(state: State): State { + return { + ...state, + isChangingNode: true, }; } @@ -59,6 +85,33 @@ function forceOffline(state: State): State { }; } +function addCustomNode(state: State, action: AddCustomNodeAction): State { + return { + ...state, + customNodes: [ + ...state.customNodes, + action.payload, + ], + }; +} + +function removeCustomNode(state: State, action: RemoveCustomNodeAction): State { + const id = makeCustomNodeId(action.payload); + return { + ...state, + customNodes: state.customNodes.filter((cn) => cn !== action.payload), + nodeSelection: id === state.nodeSelection ? + defaultNode : state.nodeSelection, + }; +} + +function setLatestBlock(state: State, action: SetLatestBlockAction): State { + return { + ...state, + latestBlock: action.payload, + }; +} + export function config( state: State = INITIAL_STATE, action: ConfigAction @@ -68,12 +121,20 @@ export function config( return changeLanguage(state, action); case TypeKeys.CONFIG_NODE_CHANGE: return changeNode(state, action); + case TypeKeys.CONFIG_NODE_CHANGE_INTENT: + return changeNodeIntent(state); case TypeKeys.CONFIG_GAS_PRICE: return changeGasPrice(state, action); case TypeKeys.CONFIG_TOGGLE_OFFLINE: return toggleOffline(state); case TypeKeys.CONFIG_FORCE_OFFLINE: return forceOffline(state); + case TypeKeys.CONFIG_ADD_CUSTOM_NODE: + return addCustomNode(state, action); + case TypeKeys.CONFIG_REMOVE_CUSTOM_NODE: + return removeCustomNode(state, action); + case TypeKeys.CONFIG_SET_LATEST_BLOCK: + return setLatestBlock(state, action); default: return state; } diff --git a/common/sagas/config.ts b/common/sagas/config.ts index 9f94e11a..9693b80b 100644 --- a/common/sagas/config.ts +++ b/common/sagas/config.ts @@ -7,20 +7,30 @@ import { take, takeLatest, takeEvery, - select + select, + race } from 'redux-saga/effects'; import { NODES } from 'config/data'; -import { Web3Wallet } from 'libs/wallet'; -import { getNode, getNodeConfig } from 'selectors/config'; -import { getWalletInst } from 'selectors/wallet'; +import { + makeCustomNodeId, + getCustomNodeConfigFromId, + makeNodeConfigFromCustomConfig +} from 'utils/node'; +import { getNode, getNodeConfig, getCustomNodeConfigs } from 'selectors/config'; import { AppState } from 'reducers'; import { TypeKeys } from 'actions/config/constants'; -import { TypeKeys as WalletTypeKeys } from 'actions/wallet/constants'; import { toggleOfflineConfig, changeNode, - changeNodeIntent + changeNodeIntent, + setLatestBlock, + AddCustomNodeAction } from 'actions/config'; +import { showNotification } from 'actions/notifications'; +import translate from 'translations'; +import { Web3Wallet } from 'libs/wallet'; +import { getWalletInst } from 'selectors/wallet'; +import { TypeKeys as WalletTypeKeys } from 'actions/wallet/constants'; import { State as ConfigState, INITIAL_STATE as configInitialState @@ -54,19 +64,68 @@ function* reload(): SagaIterator { } function* handleNodeChangeIntent(action): SagaIterator { - const nodeConfig = yield select(getNodeConfig); - const currentNetwork = nodeConfig.network; - const actionNetwork = NODES[action.payload].network; + const currentNode = yield select(getNode); + const currentConfig = yield select(getNodeConfig); const currentWallet = yield select(getWalletInst); + const currentNetwork = currentConfig.network; - yield put(changeNode(action.payload)); + let actionConfig = NODES[action.payload]; + if (!actionConfig) { + const customConfigs = yield select(getCustomNodeConfigs); + const config = getCustomNodeConfigFromId(action.payload, customConfigs); + if (config) { + actionConfig = makeNodeConfigFromCustomConfig(config); + } + } + + if (!actionConfig) { + yield put( + showNotification( + 'danger', + `Attempted to switch to unknown node '${action.payload}'`, + 5000 + ) + ); + yield put(changeNode(currentNode, currentConfig)); + return; + } + + // Grab latest block from the node, before switching, to confirm it's online + // Give it 5 seconds before we call it offline + let latestBlock; + let timeout; + try { + const { lb, to } = yield race({ + lb: call(actionConfig.lib.getCurrentBlock.bind(actionConfig.lib)), + to: call(delay, 5000) + }); + latestBlock = lb; + timeout = to; + } catch (err) { + // Whether it times out or errors, same message + timeout = true; + } + + if (timeout) { + yield put(showNotification('danger', translate('ERROR_32'), 5000)); + yield put(changeNode(currentNode, currentConfig)); + return; + } + + yield put(setLatestBlock(latestBlock)); + yield put(changeNode(action.payload, actionConfig)); // if there's no wallet, do not reload as there's no component state to resync - if (currentWallet && currentNetwork !== actionNetwork) { + if (currentWallet && currentNetwork !== actionConfig.network) { yield call(reload); } } +export function* switchToNewNode(action: AddCustomNodeAction): SagaIterator { + const nodeId = makeCustomNodeId(action.payload); + yield put(changeNodeIntent(nodeId)); +} + // unset web3 as the selected node if a non-web3 wallet has been selected function* unsetWeb3Node(action): SagaIterator { const node = yield select(getNode); @@ -107,6 +166,7 @@ export default function* configSaga(): SagaIterator { ); yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent); yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload); + yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode); yield takeEvery(WalletTypeKeys.WALLET_SET, unsetWeb3Node); yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node); } diff --git a/common/sass/styles/overrides/alerts.scss b/common/sass/styles/overrides/alerts.scss index 7dcef4d3..e29ea60e 100644 --- a/common/sass/styles/overrides/alerts.scss +++ b/common/sass/styles/overrides/alerts.scss @@ -23,6 +23,15 @@ @media screen and (max-width: $screen-xs) { left: 1%; } + + a { + color: #FFF; + + &:hover { + color: #FFF; + opacity: 0.8; + } + } } .alert, diff --git a/common/selectors/config.ts b/common/selectors/config.ts index b5e71636..1c4e8fab 100644 --- a/common/selectors/config.ts +++ b/common/selectors/config.ts @@ -3,7 +3,7 @@ import { NetworkContract, NETWORKS, NodeConfig, - NODES + CustomNodeConfig } from 'config/data'; import { INode } from 'libs/nodes/INode'; import { AppState } from 'reducers'; @@ -13,15 +13,15 @@ export function getNode(state: AppState): string { } export function getNodeConfig(state: AppState): NodeConfig { - return NODES[state.config.nodeSelection]; + return state.config.node; } export function getNodeLib(state: AppState): INode { - return NODES[state.config.nodeSelection].lib; + return getNodeConfig(state).lib; } export function getNetworkConfig(state: AppState): NetworkConfig { - return NETWORKS[NODES[state.config.nodeSelection].network]; + return NETWORKS[getNodeConfig(state).network]; } export function getNetworkContracts(state: AppState): NetworkContract[] | null { @@ -35,3 +35,7 @@ export function getGasPriceGwei(state: AppState): number { export function getLanguageSelection(state: AppState): string { return state.config.languageSelection; } + +export function getCustomNodeConfigs(state: AppState): CustomNodeConfig[] { + return state.config.customNodes; +} diff --git a/common/store.ts b/common/store.ts index 04e058d5..2016ff3b 100644 --- a/common/store.ts +++ b/common/store.ts @@ -9,9 +9,11 @@ import { createLogger } from 'redux-logger'; import createSagaMiddleware from 'redux-saga'; import { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage'; import RootReducer from './reducers'; +import { State as ConfigState } from './reducers/config'; import { State as CustomTokenState } from './reducers/customTokens'; import { State as SwapState } from './reducers/swap'; import promiseMiddleware from 'redux-promise-middleware'; +import { getNodeConfigFromId } from 'utils/node'; import sagas from './sagas'; @@ -56,10 +58,29 @@ const configureStore = () => { 'customTokens' ); + const savedConfigState = loadStatePropertyOrEmptyObject( + 'config' + ); + + // If they have a saved node, make sure we assign that too. The node selected + // isn't serializable, so we have to assign it here. + if (savedConfigState && savedConfigState.nodeSelection) { + const savedNode = getNodeConfigFromId( + savedConfigState.nodeSelection, + savedConfigState.customNodes + ); + // If we couldn't find it, revert to defaults + if (savedNode) { + savedConfigState.node = savedNode; + } else { + savedConfigState.nodeSelection = configInitialState.nodeSelection; + } + } + const persistedInitialState = { config: { ...configInitialState, - ...loadStatePropertyOrEmptyObject('config') + ...savedConfigState }, customTokens: localCustomTokens || customTokensInitialState, // ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3 @@ -82,14 +103,16 @@ const configureStore = () => { store.subscribe( throttle(() => { + const state = store.getState(); saveState({ config: { - gasPriceGwei: store.getState().config.gasPriceGwei, - nodeSelection: store.getState().config.nodeSelection, - languageSelection: store.getState().config.languageSelection + gasPriceGwei: state.config.gasPriceGwei, + nodeSelection: state.config.nodeSelection, + languageSelection: state.config.languageSelection, + customNodes: state.config.customNodes }, - swap: store.getState().swap, - customTokens: store.getState().customTokens + swap: state.swap, + customTokens: state.customTokens }); }), 1000 diff --git a/common/utils/node.ts b/common/utils/node.ts new file mode 100644 index 00000000..34612e3e --- /dev/null +++ b/common/utils/node.ts @@ -0,0 +1,36 @@ +import { CustomNode } from 'libs/nodes'; +import { NODES, NodeConfig, CustomNodeConfig } from 'config/data'; + +export function makeCustomNodeId(config: CustomNodeConfig): string { + return `${config.url}:${config.port}`; +} + +export function getCustomNodeConfigFromId( + id: string, configs: CustomNodeConfig[] +): CustomNodeConfig | undefined { + return configs.find((node) => makeCustomNodeId(node) === id); +} + +export function getNodeConfigFromId( + id: string, configs: CustomNodeConfig[] +): NodeConfig | undefined { + if (NODES[id]) { + return NODES[id]; + } + + const config = getCustomNodeConfigFromId(id, configs); + if (config) { + return makeNodeConfigFromCustomConfig(config); + } +} + +export function makeNodeConfigFromCustomConfig( + config: CustomNodeConfig +): NodeConfig { + return { + network: config.network, + lib: new CustomNode(config), + service: "your custom node", + estimateGas: true, + }; +} diff --git a/spec/pages/SendTransaction.spec.tsx b/spec/pages/SendTransaction.spec.tsx index af5de171..fd582d76 100644 --- a/spec/pages/SendTransaction.spec.tsx +++ b/spec/pages/SendTransaction.spec.tsx @@ -4,13 +4,16 @@ import Adapter from 'enzyme-adapter-react-16'; import SendTransaction from 'containers/Tabs/SendTransaction'; import shallowWithStore from '../utils/shallowWithStore'; import { createMockStore } from 'redux-test-utils'; +import { NODES } from 'config/data'; Enzyme.configure({ adapter: new Adapter() }); it('render snapshot', () => { + const testNode = 'rop_mew'; const testStateConfig = { languageSelection: 'en', - nodeSelection: 'rop_mew', + nodeSelection: testNode, + node: NODES[testNode], gasPriceGwei: 21, offline: false, forceOffline: false diff --git a/spec/pages/__snapshots__/SendTransaction.spec.tsx.snap b/spec/pages/__snapshots__/SendTransaction.spec.tsx.snap index ebfcdc96..51777a68 100644 --- a/spec/pages/__snapshots__/SendTransaction.spec.tsx.snap +++ b/spec/pages/__snapshots__/SendTransaction.spec.tsx.snap @@ -58,6 +58,7 @@ exports[`render snapshot 1`] = ` "call": [Function], "decorateRequest": [Function], "endpoint": "https://api.myetherapi.com/rop", + "headers": Object {}, }, "requests": RPCRequests {}, } diff --git a/spec/reducers/config.spec.ts b/spec/reducers/config.spec.ts index ee6d930b..53b3f8a5 100644 --- a/spec/reducers/config.spec.ts +++ b/spec/reducers/config.spec.ts @@ -1,6 +1,14 @@ import { config, INITIAL_STATE } from 'reducers/config'; import * as configActions from 'actions/config'; import { NODES } from 'config/data'; +import { makeCustomNodeId, makeNodeConfigFromCustomConfig } from 'utils/node'; + +const custNode = { + name: 'Test Config', + url: 'http://somecustomconfig.org/', + port: 443, + network: 'ETH' +}; describe('config reducer', () => { it('should handle CONFIG_LANGUAGE_CHANGE', () => { @@ -12,11 +20,14 @@ describe('config reducer', () => { }); it('should handle CONFIG_NODE_CHANGE', () => { - const node = Object.keys(NODES)[0]; + const key = Object.keys(NODES)[0]; - expect(config(undefined, configActions.changeNode(node))).toEqual({ + expect( + config(undefined, configActions.changeNode(key, NODES[key])) + ).toEqual({ ...INITIAL_STATE, - nodeSelection: node + node: NODES[key], + nodeSelection: key }); }); @@ -76,4 +87,42 @@ describe('config reducer', () => { forceOffline: true }); }); + + it('should handle CONFIG_ADD_CUSTOM_NODE', () => { + expect(config(undefined, configActions.addCustomNode(custNode))).toEqual({ + ...INITIAL_STATE, + customNodes: [custNode] + }); + }); + + describe('should handle CONFIG_REMOVE_CUSTOM_NODE', () => { + const customNodeId = makeCustomNodeId(custNode); + const addedState = config(undefined, configActions.addCustomNode(custNode)); + const addedAndActiveState = config( + addedState, + configActions.changeNode( + customNodeId, + makeNodeConfigFromCustomConfig(custNode) + ) + ); + const removedState = config( + addedAndActiveState, + configActions.removeCustomNode(custNode) + ); + + it('should remove the custom node from `customNodes`', () => { + expect(removedState.customNodes.length).toBe(0); + }); + + it('should change the active node, if the custom one was active', () => { + expect(removedState.nodeSelection === customNodeId).toBeFalsy(); + }); + }); + + it('should handle CONFIG_SET_LATEST_BLOCK', () => { + expect(config(undefined, configActions.setLatestBlock('12345'))).toEqual({ + ...INITIAL_STATE, + latestBlock: '12345' + }); + }); }); diff --git a/spec/utils/node.spec.ts b/spec/utils/node.spec.ts new file mode 100644 index 00000000..c310162b --- /dev/null +++ b/spec/utils/node.spec.ts @@ -0,0 +1,38 @@ +import { + makeCustomNodeId, + getCustomNodeConfigFromId, + getNodeConfigFromId, + makeNodeConfigFromCustomConfig +} from 'utils/node'; + +const custNode = { + name: 'Test Config', + url: 'http://somecustomconfig.org/', + port: 443, + network: 'ETH' +}; +const custNodeId = 'http://somecustomconfig.org/:443'; + +describe('makeCustomNodeId', () => { + it('should construct an ID from url:port', () => { + expect(makeCustomNodeId(custNode) === custNodeId).toBeTruthy(); + }); +}); + +describe('getCustomNodeConfigFromId', () => { + it('should fetch the correct config, given its ID', () => { + expect(getCustomNodeConfigFromId(custNodeId, [custNode])).toBeTruthy(); + }); +}); + +describe('getNodeConfigFromId', () => { + it('should fetch the correct config, given its ID', () => { + expect(getNodeConfigFromId(custNodeId, [custNode])).toBeTruthy(); + }); +}); + +describe('makeNodeConfigFromCustomConfig', () => { + it('Should create a node config from a custom config', () => { + expect(makeNodeConfigFromCustomConfig(custNode)).toBeTruthy(); + }); +});