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.
This commit is contained in:
William O'Beirne 2017-11-18 13:33:53 -07:00 committed by Daniel Ternyak
parent 1510533ec7
commit c0cd668c64
33 changed files with 914 additions and 98 deletions

View File

@ -1,5 +1,6 @@
import * as interfaces from './actionTypes'; import * as interfaces from './actionTypes';
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
import { NodeConfig, CustomNodeConfig } from 'config/data';
export type TForceOfflineConfig = typeof forceOfflineConfig; export type TForceOfflineConfig = typeof forceOfflineConfig;
export function forceOfflineConfig(): interfaces.ForceOfflineAction { export function forceOfflineConfig(): interfaces.ForceOfflineAction {
@ -24,10 +25,13 @@ export function changeLanguage(sign: string): interfaces.ChangeLanguageAction {
} }
export type TChangeNode = typeof changeNode; export type TChangeNode = typeof changeNode;
export function changeNode(value: string): interfaces.ChangeNodeAction { export function changeNode(
nodeSelection: string,
node: NodeConfig
): interfaces.ChangeNodeAction {
return { return {
type: TypeKeys.CONFIG_NODE_CHANGE, 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 type TWeb3UnsetNode = typeof web3UnsetNode;
export function web3UnsetNode(): interfaces.Web3UnsetNodeAction { export function web3UnsetNode(): interfaces.Web3UnsetNodeAction {
return { return {

View File

@ -1,4 +1,5 @@
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
import { CustomNodeConfig, NodeConfig } from 'config/data';
/*** Toggle Offline ***/ /*** Toggle Offline ***/
export interface ToggleOfflineAction { export interface ToggleOfflineAction {
@ -20,7 +21,10 @@ export interface ChangeLanguageAction {
export interface ChangeNodeAction { export interface ChangeNodeAction {
type: TypeKeys.CONFIG_NODE_CHANGE; type: TypeKeys.CONFIG_NODE_CHANGE;
// FIXME $keyof? // FIXME $keyof?
payload: string; payload: {
nodeSelection: string;
node: NodeConfig;
};
} }
/*** Change gas price ***/ /*** Change gas price ***/
@ -40,6 +44,24 @@ export interface ChangeNodeIntentAction {
payload: string; 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 ***/ /*** Unset Web3 as a Node ***/
export interface Web3UnsetNodeAction { export interface Web3UnsetNodeAction {
type: TypeKeys.CONFIG_NODE_WEB3_UNSET; type: TypeKeys.CONFIG_NODE_WEB3_UNSET;
@ -54,4 +76,7 @@ export type ConfigAction =
| PollOfflineStatus | PollOfflineStatus
| ForceOfflineAction | ForceOfflineAction
| ChangeNodeIntentAction | ChangeNodeIntentAction
| AddCustomNodeAction
| RemoveCustomNodeAction
| SetLatestBlockAction
| Web3UnsetNodeAction; | Web3UnsetNodeAction;

View File

@ -6,5 +6,8 @@ export enum TypeKeys {
CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE', CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE',
CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE', CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE',
CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS', 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' CONFIG_NODE_WEB3_UNSET = 'CONFIG_NODE_WEB3_UNSET'
} }

View File

@ -92,11 +92,15 @@ const LINKS_SOCIAL = [
} }
]; ];
interface ComponentState { interface Props {
latestBlock: string;
};
interface State {
isOpen: boolean; isOpen: boolean;
} }
export default class Footer extends React.Component<{}, ComponentState> { export default class Footer extends React.Component<Props, State> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { isOpen: false }; this.state = { isOpen: false };
@ -276,9 +280,7 @@ export default class Footer extends React.Component<{}, ComponentState> {
); );
})} })}
</p> </p>
<p>Latest Block#: {this.props.latestBlock}</p>
{/* TODO: Fix me */}
<p>Latest Block#: ?????</p>
</div> </div>
</footer> </footer>
</div> </div>

View File

@ -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<Props, State> {
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 (
<Modal
title={translate('NODE_Title')}
isOpen={true}
buttons={buttons}
handleClose={handleClose}
>
<div>
{isHttps &&
<div className="alert alert-danger small">
{translate('NODE_Warning')}
</div>
}
<form>
<div className="row">
<div className="col-sm-7">
<label>{translate('NODE_Name')}</label>
{this.renderInput({
name: 'name',
placeholder: 'My Node',
}, invalids)}
</div>
<div className="col-sm-5">
<label>Network</label>
<select
className="form-control"
name="network"
value={this.state.network}
onChange={this.handleChange}
>
{NETWORK_KEYS.map((net) =>
<option key={net} value={net}>{net}</option>
)}
</select>
</div>
</div>
<div className="row">
<div className="col-sm-9">
<label>URL</label>
{this.renderInput({
name: 'url',
placeholder: 'http://127.0.0.1/',
}, invalids)}
</div>
<div className="col-sm-3">
<label>{translate('NODE_Port')}</label>
{this.renderInput({
name: 'port',
placeholder: '8545',
type: 'number',
}, invalids)}
</div>
</div>
<div className="row">
<div className="col-sm-12">
<label>
<input
type="checkbox"
name="hasAuth"
checked={this.state.hasAuth}
onChange={this.handleCheckbox}
/>
{' '}
<span>HTTP Basic Authentication</span>
</label>
</div>
</div>
{this.state.hasAuth &&
<div className="row">
<div className="col-sm-6">
<label>Username</label>
{this.renderInput({ name: 'username' }, invalids)}
</div>
<div className="col-sm-6">
<label>Password</label>
{this.renderInput({
name: 'password',
type: 'password',
}, invalids)}
</div>
</div>
}
</form>
</div>
</Modal>
);
}
private renderInput(input: Input, invalids: { [key: string]: boolean }) {
return <input
className={classnames({
'form-control': true,
'is-invalid': this.state[input.name] && invalids[input.name],
})}
value={this.state[name]}
onChange={this.handleChange}
{...input}
/>;
}
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<HTMLInputElement>) => {
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);
};
}

View File

@ -15,6 +15,15 @@ $small-size: 900px;
} }
} }
@keyframes dropdown-is-flashing {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 0.7;
}
}
// Header // Header
.Header { .Header {
margin-bottom: 2rem; margin-bottom: 2rem;
@ -124,6 +133,11 @@ $small-size: 900px;
padding-top: $space-sm !important; padding-top: $space-sm !important;
padding-bottom: $space-sm !important; padding-bottom: $space-sm !important;
} }
&.is-flashing {
pointer-events: none;
animation: dropdown-is-flashing 800ms ease infinite;
}
} }
} }
} }

View File

@ -1,11 +1,14 @@
import { import {
TChangeGasPrice, TChangeGasPrice,
TChangeLanguage, TChangeLanguage,
TChangeNodeIntent TChangeNodeIntent,
TAddCustomNode,
TRemoveCustomNode
} from 'actions/config'; } from 'actions/config';
import logo from 'assets/images/logo-myetherwallet.svg'; import logo from 'assets/images/logo-myetherwallet.svg';
import { Dropdown, ColorDropdown } from 'components/ui'; import { Dropdown, ColorDropdown } from 'components/ui';
import React, { Component } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
ANNOUNCEMENT_MESSAGE, ANNOUNCEMENT_MESSAGE,
@ -13,33 +16,59 @@ import {
languages, languages,
NETWORKS, NETWORKS,
NODES, NODES,
VERSION VERSION,
NodeConfig,
CustomNodeConfig
} from '../../config/data'; } from '../../config/data';
import GasPriceDropdown from './components/GasPriceDropdown'; import GasPriceDropdown from './components/GasPriceDropdown';
import Navigation from './components/Navigation'; import Navigation from './components/Navigation';
import CustomNodeModal from './components/CustomNodeModal';
import { getKeyByValue } from 'utils/helpers'; import { getKeyByValue } from 'utils/helpers';
import { makeCustomNodeId } from 'utils/node';
import './index.scss'; import './index.scss';
interface Props { interface Props {
languageSelection: string; languageSelection: string;
node: NodeConfig;
nodeSelection: string; nodeSelection: string;
isChangingNode: boolean;
gasPriceGwei: number; gasPriceGwei: number;
customNodes: CustomNodeConfig[];
changeLanguage: TChangeLanguage; changeLanguage: TChangeLanguage;
changeNodeIntent: TChangeNodeIntent; changeNodeIntent: TChangeNodeIntent;
changeGasPrice: TChangeGasPrice; changeGasPrice: TChangeGasPrice;
addCustomNode: TAddCustomNode;
removeCustomNode: TRemoveCustomNode;
} }
export default class Header extends Component<Props, {}> { interface State {
isAddingCustomNode: boolean;
}
export default class Header extends Component<Props, State> {
public state = {
isAddingCustomNode: false
};
public render() { 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 selectedLanguage = languageSelection;
const selectedNode = NODES[nodeSelection]; const selectedNetwork = NETWORKS[node.network];
const selectedNetwork = NETWORKS[selectedNode.network];
const LanguageDropDown = Dropdown as new () => Dropdown< const LanguageDropDown = Dropdown as new () => Dropdown<
typeof selectedLanguage typeof selectedLanguage
>; >;
const nodeOptions = Object.keys(NODES).map(key => {
const nodeOptions = Object.keys(NODES)
.map(key => {
return { return {
value: key, value: key,
name: ( name: (
@ -50,7 +79,22 @@ export default class Header extends Component<Props, {}> {
color: NETWORKS[NODES[key].network].color, color: NETWORKS[NODES[key].network].color,
hidden: NODES[key].hidden hidden: NODES[key].hidden
}; };
}); })
.concat(
customNodes.map(customNode => {
return {
value: makeCustomNodeId(customNode),
name: (
<span>
{customNode.network} - {customNode.name} <small>(custom)</small>
</span>
),
color: '#000',
hidden: false,
onRemove: () => this.props.removeCustomNode(customNode)
};
})
);
return ( return (
<div className="Header"> <div className="Header">
@ -66,7 +110,7 @@ export default class Header extends Component<Props, {}> {
<section className="Header-branding"> <section className="Header-branding">
<section className="Header-branding-inner container"> <section className="Header-branding-inner container">
<Link <Link
to={'/'} to="/"
className="Header-branding-title" className="Header-branding-title"
aria-label="Go to homepage" aria-label="Go to homepage"
> >
@ -109,22 +153,29 @@ export default class Header extends Component<Props, {}> {
/> />
</div> </div>
<div className="Header-branding-right-dropdown"> <div
className={classnames({
'Header-branding-right-dropdown': true,
'is-flashing': isChangingNode
})}
>
<ColorDropdown <ColorDropdown
ariaLabel={`change node. current node ${ ariaLabel={`
selectedNode.network change node. current node ${node.network}
} node by ${selectedNode.service}`} node by ${node.service}
`}
options={nodeOptions} options={nodeOptions}
value={nodeSelection} value={nodeSelection}
extra={ extra={
<li> <li>
<a>Add Custom Node</a> <a onClick={this.openCustomNodeModal}>Add Custom Node</a>
</li> </li>
} }
disabled={nodeSelection === 'web3'} disabled={nodeSelection === 'web3'}
onChange={changeNodeIntent} onChange={changeNodeIntent}
size="smr" size="smr"
color="white" color="white"
menuAlign="right"
/> />
</div> </div>
</div> </div>
@ -132,6 +183,13 @@ export default class Header extends Component<Props, {}> {
</section> </section>
<Navigation color={selectedNetwork.color} /> <Navigation color={selectedNetwork.color} />
{isAddingCustomNode && (
<CustomNodeModal
handleAddCustomNode={this.addCustomNode}
handleClose={this.closeCustomNodeModal}
/>
)}
</div> </div>
); );
} }
@ -142,4 +200,17 @@ export default class Header extends Component<Props, {}> {
this.props.changeLanguage(key); 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);
};
} }

View File

@ -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;
}
}
}
}

View File

@ -1,12 +1,15 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import DropdownShell from './DropdownShell'; import DropdownShell from './DropdownShell';
import removeIcon from 'assets/images/icon-remove.svg';
import './ColorDropdown.scss';
interface Option<T> { interface Option<T> {
name: any; name: any;
value: T; value: T;
color?: string; color?: string;
hidden: boolean | undefined; hidden: boolean | undefined;
onRemove?(): void;
} }
interface Props<T> { interface Props<T> {
@ -67,6 +70,7 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
}, []); }, []);
const menuClass = classnames({ const menuClass = classnames({
ColorDropdown: true,
'dropdown-menu': true, 'dropdown-menu': true,
[`dropdown-menu-${menuAlign || ''}`]: !!menuAlign [`dropdown-menu-${menuAlign || ''}`]: !!menuAlign
}); });
@ -78,12 +82,24 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
return <li key={i} role="separator" className="divider" />; return <li key={i} role="separator" className="divider" />;
} else { } else {
return ( return (
<li key={i} style={{ borderLeft: `2px solid ${option.color}` }}> <li
key={i}
className="ColorDropdown-item"
style={{ borderColor: option.color }}
>
<a <a
className={option.value === value ? 'active' : ''} className={option.value === value ? 'active' : ''}
onClick={this.onChange.bind(null, option.value)} onClick={this.onChange.bind(null, option.value)}
> >
{option.name} {option.name}
{option.onRemove && (
<img
className="ColorDropdown-item-remove"
onClick={this.onRemove.bind(null, option.onRemove)}
src={removeIcon}
/>
)}
</a> </a>
</li> </li>
); );
@ -102,6 +118,17 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
} }
}; };
private onRemove(
onRemove: () => void,
ev?: React.SyntheticEvent<HTMLButtonElement>
) {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
onRemove();
}
private getActiveOption() { private getActiveOption() {
return this.props.options.find(opt => opt.value === this.props.value); return this.props.options.find(opt => opt.value === this.props.value);
} }

View File

@ -3,7 +3,7 @@ import React, { Component } from 'react';
import './Modal.scss'; import './Modal.scss';
export interface IButton { export interface IButton {
text: string; text: string | React.ReactElement<string>;
type?: type?:
| 'default' | 'default'
| 'primary' | 'primary'
@ -17,7 +17,7 @@ export interface IButton {
} }
interface Props { interface Props {
isOpen?: boolean; isOpen?: boolean;
title: string; title: string | React.ReactElement<any>;
disableButtons?: boolean; disableButtons?: boolean;
children: any; children: any;
buttons: IButton[]; buttons: IButton[];

View File

@ -81,6 +81,17 @@ export interface NodeConfig {
hidden?: boolean; 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 // Must be a website that follows the ethplorer convention of /tx/[hash] and
// address/[address] to generate the correct functions. // address/[address] to generate the correct functions.
function makeExplorer(url): BlockExplorerConfig { function makeExplorer(url): BlockExplorerConfig {

View File

@ -2,50 +2,72 @@ import {
changeGasPrice as dChangeGasPrice, changeGasPrice as dChangeGasPrice,
changeLanguage as dChangeLanguage, changeLanguage as dChangeLanguage,
changeNodeIntent as dChangeNodeIntent, changeNodeIntent as dChangeNodeIntent,
addCustomNode as dAddCustomNode,
removeCustomNode as dRemoveCustomNode,
TChangeGasPrice, TChangeGasPrice,
TChangeLanguage, TChangeLanguage,
TChangeNodeIntent TChangeNodeIntent,
TAddCustomNode,
TRemoveCustomNode,
} from 'actions/config'; } from 'actions/config';
import { AlphaAgreement, Footer, Header } from 'components'; import { AlphaAgreement, Footer, Header } from 'components';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import Notifications from './Notifications'; import Notifications from './Notifications';
import { NodeConfig, CustomNodeConfig } from 'config/data';
interface Props { interface Props {
// FIXME // FIXME
children: any; children: any;
languageSelection: string; languageSelection: string;
node: NodeConfig;
nodeSelection: string; nodeSelection: string;
isChangingNode: boolean;
gasPriceGwei: number; gasPriceGwei: number;
customNodes: CustomNodeConfig[];
latestBlock: string;
changeLanguage: TChangeLanguage; changeLanguage: TChangeLanguage;
changeNodeIntent: TChangeNodeIntent; changeNodeIntent: TChangeNodeIntent;
changeGasPrice: TChangeGasPrice; changeGasPrice: TChangeGasPrice;
addCustomNode: TAddCustomNode;
removeCustomNode: TRemoveCustomNode;
} }
class TabSection extends Component<Props, {}> { class TabSection extends Component<Props, {}> {
public render() { public render() {
const { const {
children, children,
// APP // APP
node,
nodeSelection, nodeSelection,
isChangingNode,
languageSelection, languageSelection,
gasPriceGwei, gasPriceGwei,
customNodes,
latestBlock,
changeLanguage, changeLanguage,
changeNodeIntent, changeNodeIntent,
changeGasPrice changeGasPrice,
addCustomNode,
removeCustomNode,
} = this.props; } = this.props;
const headerProps = { const headerProps = {
languageSelection, languageSelection,
node,
nodeSelection, nodeSelection,
isChangingNode,
gasPriceGwei, gasPriceGwei,
customNodes,
changeLanguage, changeLanguage,
changeNodeIntent, changeNodeIntent,
changeGasPrice changeGasPrice,
addCustomNode,
removeCustomNode,
}; };
return ( return (
@ -53,7 +75,7 @@ class TabSection extends Component<Props, {}> {
<main> <main>
<Header {...headerProps} /> <Header {...headerProps} />
<div className="Tab container">{children}</div> <div className="Tab container">{children}</div>
<Footer /> <Footer latestBlock={latestBlock} />
</main> </main>
<Notifications /> <Notifications />
<AlphaAgreement /> <AlphaAgreement />
@ -64,14 +86,20 @@ class TabSection extends Component<Props, {}> {
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState) {
return { return {
node: state.config.node,
nodeSelection: state.config.nodeSelection, nodeSelection: state.config.nodeSelection,
isChangingNode: state.config.isChangingNode,
languageSelection: state.config.languageSelection, languageSelection: state.config.languageSelection,
gasPriceGwei: state.config.gasPriceGwei gasPriceGwei: state.config.gasPriceGwei,
customNodes: state.config.customNodes,
latestBlock: state.config.latestBlock,
}; };
} }
export default connect(mapStateToProps, { export default connect(mapStateToProps, {
changeGasPrice: dChangeGasPrice, changeGasPrice: dChangeGasPrice,
changeLanguage: dChangeLanguage, changeLanguage: dChangeLanguage,
changeNodeIntent: dChangeNodeIntent changeNodeIntent: dChangeNodeIntent,
addCustomNode: dAddCustomNode,
removeCustomNode: dRemoveCustomNode,
})(TabSection); })(TabSection);

View File

@ -14,4 +14,5 @@ export interface INode {
getTransactionCount(address: string): Promise<string>; getTransactionCount(address: string): Promise<string>;
sendRawTx(tx: string): Promise<string>; sendRawTx(tx: string): Promise<string>;
sendCallRequest(txObj: TxObj): Promise<string>; sendCallRequest(txObj: TxObj): Promise<string>;
getCurrentBlock(): Promise<string>;
} }

View File

@ -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);
}
}

View File

@ -7,7 +7,8 @@ import {
GetBalanceRequest, GetBalanceRequest,
GetTokenBalanceRequest, GetTokenBalanceRequest,
GetTransactionCountRequest, GetTransactionCountRequest,
SendRawTxRequest SendRawTxRequest,
GetCurrentBlockRequest
} from './types'; } from './types';
export default class EtherscanRequests extends RPCRequests { export default class EtherscanRequests extends RPCRequests {
@ -66,4 +67,11 @@ export default class EtherscanRequests extends RPCRequests {
data: ERC20.balanceOf(address) data: ERC20.balanceOf(address)
}); });
} }
public getCurrentBlock(): GetCurrentBlockRequest {
return {
module: 'proxy',
action: 'eth_blockNumber',
};
}
} }

View File

@ -41,10 +41,16 @@ export interface GetTransactionCountRequest extends EtherscanReqBase {
tag: 'latest'; tag: 'latest';
} }
export interface GetCurrentBlockRequest extends EtherscanReqBase {
module: 'proxy';
action: 'eth_blockNumber';
}
export type EtherscanRequest = export type EtherscanRequest =
| SendRawTxRequest | SendRawTxRequest
| GetBalanceRequest | GetBalanceRequest
| CallRequest | CallRequest
| GetTokenBalanceRequest | GetTokenBalanceRequest
| EstimateGasRequest | EstimateGasRequest
| GetTransactionCountRequest; | GetTransactionCountRequest
| GetCurrentBlockRequest;

View File

@ -1,4 +1,5 @@
export { default as RPCNode } from './rpc'; export { default as RPCNode } from './rpc';
export { default as InfuraNode } from './infura'; export { default as InfuraNode } from './infura';
export { default as EtherscanNode } from './etherscan'; export { default as EtherscanNode } from './etherscan';
export { default as CustomNode } from './custom';
export { default as Web3Node } from './web3'; export { default as Web3Node } from './web3';

View File

@ -3,8 +3,10 @@ import { JsonRpcResponse, RPCRequest } from './types';
export default class RPCClient { export default class RPCClient {
public endpoint: string; public endpoint: string;
constructor(endpoint: string) { public headers: object;
constructor(endpoint: string, headers: object = {}) {
this.endpoint = endpoint; this.endpoint = endpoint;
this.headers = headers;
} }
public id(): string { public id(): string {
@ -21,7 +23,8 @@ export default class RPCClient {
return fetch(this.endpoint, { return fetch(this.endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
...this.headers,
}, },
body: JSON.stringify(this.decorateRequest(request)) body: JSON.stringify(this.decorateRequest(request))
}).then(r => r.json()); }).then(r => r.json());
@ -31,7 +34,8 @@ export default class RPCClient {
return fetch(this.endpoint, { return fetch(this.endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
...this.headers,
}, },
body: JSON.stringify(requests.map(this.decorateRequest)) body: JSON.stringify(requests.map(this.decorateRequest))
}).then(r => r.json()); }).then(r => r.json());

View File

@ -1,10 +1,19 @@
import BN from 'bn.js';
import { Token } from 'config/data'; import { Token } from 'config/data';
import { TransactionWithoutGas } from 'libs/messages'; import { TransactionWithoutGas } from 'libs/messages';
import { Wei, TokenValue } from 'libs/units'; import { Wei, TokenValue } from 'libs/units';
import { stripHexPrefix } from 'libs/values';
import { INode, TxObj } from '../INode'; import { INode, TxObj } from '../INode';
import RPCClient from './client'; import RPCClient from './client';
import RPCRequests from './requests'; 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 { export default class RpcNode implements INode {
public client: RPCClient; public client: RPCClient;
public requests: RPCRequests; public requests: RPCRequests;
@ -25,23 +34,15 @@ export default class RpcNode implements INode {
public getBalance(address: string): Promise<Wei> { public getBalance(address: string): Promise<Wei> {
return this.client return this.client
.call(this.requests.getBalance(address)) .call(this.requests.getBalance(address))
.then(response => { .then(errorOrResult)
if (response.error) { .then(result => Wei(result));
throw new Error(response.error.message);
}
return Wei(response.result);
});
} }
public estimateGas(transaction: TransactionWithoutGas): Promise<Wei> { public estimateGas(transaction: TransactionWithoutGas): Promise<Wei> {
return this.client return this.client
.call(this.requests.estimateGas(transaction)) .call(this.requests.estimateGas(transaction))
.then(response => { .then(errorOrResult)
if (response.error) { .then(result => Wei(result));
throw new Error(response.error.message);
}
return Wei(response.result);
});
} }
public getTokenBalance(address: string, token: Token): Promise<TokenValue> { public getTokenBalance(address: string, token: Token): Promise<TokenValue> {
@ -77,12 +78,14 @@ export default class RpcNode implements INode {
public getTransactionCount(address: string): Promise<string> { public getTransactionCount(address: string): Promise<string> {
return this.client return this.client
.call(this.requests.getTransactionCount(address)) .call(this.requests.getTransactionCount(address))
.then(response => { .then(errorOrResult);
if (response.error) {
throw new Error(response.error.message);
} }
return response.result;
}); public getCurrentBlock(): Promise<string> {
return this.client
.call(this.requests.getCurrentBlock())
.then(errorOrResult)
.then(result => new BN(stripHexPrefix(result)).toString());
} }
public sendRawTx(signedTx: string): Promise<string> { public sendRawTx(signedTx: string): Promise<string> {

View File

@ -6,7 +6,8 @@ import {
GetBalanceRequest, GetBalanceRequest,
GetTokenBalanceRequest, GetTokenBalanceRequest,
GetTransactionCountRequest, GetTransactionCountRequest,
SendRawTxRequest SendRawTxRequest,
GetCurrentBlockRequest,
} from './types'; } from './types';
import { hexEncodeData } from './utils'; import { hexEncodeData } from './utils';
import { TxObj } from '../INode'; import { TxObj } from '../INode';
@ -63,4 +64,10 @@ export default class RPCRequests {
] ]
}; };
} }
public getCurrentBlock(): GetCurrentBlockRequest | any {
return {
method: 'eth_blockNumber',
};
}
} }

View File

@ -69,9 +69,14 @@ export interface GetTransactionCountRequest extends RPCRequestBase {
params: [DATA, DEFAULT_BLOCK]; params: [DATA, DEFAULT_BLOCK];
} }
export interface GetCurrentBlockRequest extends RPCRequestBase {
method: 'eth_blockNumber'
}
export type RPCRequest = export type RPCRequest =
| GetBalanceRequest | GetBalanceRequest
| GetTokenBalanceRequest | GetTokenBalanceRequest
| CallRequest | CallRequest
| EstimateGasRequest | EstimateGasRequest
| GetTransactionCountRequest; | GetTransactionCountRequest
| GetCurrentBlockRequest;

View File

@ -126,6 +126,17 @@ export default class Web3Node implements INode {
); );
} }
public getCurrentBlock(): Promise<string> {
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<string> { public sendRawTx(signedTx: string): Promise<string> {
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
this.web3.eth.sendRawTransaction(signedTx, (err, txHash) => { this.web3.eth.sendRawTransaction(signedTx, (err, txHash) => {

View File

@ -2,26 +2,43 @@ import {
ChangeGasPriceAction, ChangeGasPriceAction,
ChangeLanguageAction, ChangeLanguageAction,
ChangeNodeAction, ChangeNodeAction,
ConfigAction AddCustomNodeAction,
RemoveCustomNodeAction,
SetLatestBlockAction,
ConfigAction,
} from 'actions/config'; } from 'actions/config';
import { TypeKeys } from 'actions/config/constants'; 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 { export interface State {
// FIXME // FIXME
languageSelection: string; languageSelection: string;
nodeSelection: string; nodeSelection: string;
node: NodeConfig;
isChangingNode: boolean;
gasPriceGwei: number; gasPriceGwei: number;
offline: boolean; offline: boolean;
forceOffline: boolean; forceOffline: boolean;
customNodes: CustomNodeConfig[];
latestBlock: string;
} }
const defaultNode = 'eth_mew';
export const INITIAL_STATE: State = { export const INITIAL_STATE: State = {
languageSelection: 'en', languageSelection: 'en',
nodeSelection: Object.keys(NODES)[0], nodeSelection: defaultNode,
node: NODES[defaultNode],
isChangingNode: false,
gasPriceGwei: 21, gasPriceGwei: 21,
offline: false, offline: false,
forceOffline: false forceOffline: false,
customNodes: [],
latestBlock: "???",
}; };
function changeLanguage(state: State, action: ChangeLanguageAction): State { 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 { function changeNode(state: State, action: ChangeNodeAction): State {
return { return {
...state, ...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( export function config(
state: State = INITIAL_STATE, state: State = INITIAL_STATE,
action: ConfigAction action: ConfigAction
@ -68,12 +121,20 @@ export function config(
return changeLanguage(state, action); return changeLanguage(state, action);
case TypeKeys.CONFIG_NODE_CHANGE: case TypeKeys.CONFIG_NODE_CHANGE:
return changeNode(state, action); return changeNode(state, action);
case TypeKeys.CONFIG_NODE_CHANGE_INTENT:
return changeNodeIntent(state);
case TypeKeys.CONFIG_GAS_PRICE: case TypeKeys.CONFIG_GAS_PRICE:
return changeGasPrice(state, action); return changeGasPrice(state, action);
case TypeKeys.CONFIG_TOGGLE_OFFLINE: case TypeKeys.CONFIG_TOGGLE_OFFLINE:
return toggleOffline(state); return toggleOffline(state);
case TypeKeys.CONFIG_FORCE_OFFLINE: case TypeKeys.CONFIG_FORCE_OFFLINE:
return forceOffline(state); 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: default:
return state; return state;
} }

View File

@ -7,20 +7,30 @@ import {
take, take,
takeLatest, takeLatest,
takeEvery, takeEvery,
select select,
race
} from 'redux-saga/effects'; } from 'redux-saga/effects';
import { NODES } from 'config/data'; import { NODES } from 'config/data';
import { Web3Wallet } from 'libs/wallet'; import {
import { getNode, getNodeConfig } from 'selectors/config'; makeCustomNodeId,
import { getWalletInst } from 'selectors/wallet'; getCustomNodeConfigFromId,
makeNodeConfigFromCustomConfig
} from 'utils/node';
import { getNode, getNodeConfig, getCustomNodeConfigs } from 'selectors/config';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { TypeKeys } from 'actions/config/constants'; import { TypeKeys } from 'actions/config/constants';
import { TypeKeys as WalletTypeKeys } from 'actions/wallet/constants';
import { import {
toggleOfflineConfig, toggleOfflineConfig,
changeNode, changeNode,
changeNodeIntent changeNodeIntent,
setLatestBlock,
AddCustomNodeAction
} from 'actions/config'; } 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 { import {
State as ConfigState, State as ConfigState,
INITIAL_STATE as configInitialState INITIAL_STATE as configInitialState
@ -54,19 +64,68 @@ function* reload(): SagaIterator {
} }
function* handleNodeChangeIntent(action): SagaIterator { function* handleNodeChangeIntent(action): SagaIterator {
const nodeConfig = yield select(getNodeConfig); const currentNode = yield select(getNode);
const currentNetwork = nodeConfig.network; const currentConfig = yield select(getNodeConfig);
const actionNetwork = NODES[action.payload].network;
const currentWallet = yield select(getWalletInst); 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 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); 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 // unset web3 as the selected node if a non-web3 wallet has been selected
function* unsetWeb3Node(action): SagaIterator { function* unsetWeb3Node(action): SagaIterator {
const node = yield select(getNode); 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_NODE_CHANGE_INTENT, handleNodeChangeIntent);
yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload); 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_SET, unsetWeb3Node);
yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node); yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node);
} }

View File

@ -23,6 +23,15 @@
@media screen and (max-width: $screen-xs) { @media screen and (max-width: $screen-xs) {
left: 1%; left: 1%;
} }
a {
color: #FFF;
&:hover {
color: #FFF;
opacity: 0.8;
}
}
} }
.alert, .alert,

View File

@ -3,7 +3,7 @@ import {
NetworkContract, NetworkContract,
NETWORKS, NETWORKS,
NodeConfig, NodeConfig,
NODES CustomNodeConfig
} from 'config/data'; } from 'config/data';
import { INode } from 'libs/nodes/INode'; import { INode } from 'libs/nodes/INode';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
@ -13,15 +13,15 @@ export function getNode(state: AppState): string {
} }
export function getNodeConfig(state: AppState): NodeConfig { export function getNodeConfig(state: AppState): NodeConfig {
return NODES[state.config.nodeSelection]; return state.config.node;
} }
export function getNodeLib(state: AppState): INode { export function getNodeLib(state: AppState): INode {
return NODES[state.config.nodeSelection].lib; return getNodeConfig(state).lib;
} }
export function getNetworkConfig(state: AppState): NetworkConfig { 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 { export function getNetworkContracts(state: AppState): NetworkContract[] | null {
@ -35,3 +35,7 @@ export function getGasPriceGwei(state: AppState): number {
export function getLanguageSelection(state: AppState): string { export function getLanguageSelection(state: AppState): string {
return state.config.languageSelection; return state.config.languageSelection;
} }
export function getCustomNodeConfigs(state: AppState): CustomNodeConfig[] {
return state.config.customNodes;
}

View File

@ -9,9 +9,11 @@ import { createLogger } from 'redux-logger';
import createSagaMiddleware from 'redux-saga'; import createSagaMiddleware from 'redux-saga';
import { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage'; import { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage';
import RootReducer from './reducers'; import RootReducer from './reducers';
import { State as ConfigState } from './reducers/config';
import { State as CustomTokenState } from './reducers/customTokens'; import { State as CustomTokenState } from './reducers/customTokens';
import { State as SwapState } from './reducers/swap'; import { State as SwapState } from './reducers/swap';
import promiseMiddleware from 'redux-promise-middleware'; import promiseMiddleware from 'redux-promise-middleware';
import { getNodeConfigFromId } from 'utils/node';
import sagas from './sagas'; import sagas from './sagas';
@ -56,10 +58,29 @@ const configureStore = () => {
'customTokens' 'customTokens'
); );
const savedConfigState = loadStatePropertyOrEmptyObject<ConfigState>(
'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 = { const persistedInitialState = {
config: { config: {
...configInitialState, ...configInitialState,
...loadStatePropertyOrEmptyObject('config') ...savedConfigState
}, },
customTokens: localCustomTokens || customTokensInitialState, customTokens: localCustomTokens || customTokensInitialState,
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3 // ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
@ -82,14 +103,16 @@ const configureStore = () => {
store.subscribe( store.subscribe(
throttle(() => { throttle(() => {
const state = store.getState();
saveState({ saveState({
config: { config: {
gasPriceGwei: store.getState().config.gasPriceGwei, gasPriceGwei: state.config.gasPriceGwei,
nodeSelection: store.getState().config.nodeSelection, nodeSelection: state.config.nodeSelection,
languageSelection: store.getState().config.languageSelection languageSelection: state.config.languageSelection,
customNodes: state.config.customNodes
}, },
swap: store.getState().swap, swap: state.swap,
customTokens: store.getState().customTokens customTokens: state.customTokens
}); });
}), }),
1000 1000

36
common/utils/node.ts Normal file
View File

@ -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,
};
}

View File

@ -4,13 +4,16 @@ import Adapter from 'enzyme-adapter-react-16';
import SendTransaction from 'containers/Tabs/SendTransaction'; import SendTransaction from 'containers/Tabs/SendTransaction';
import shallowWithStore from '../utils/shallowWithStore'; import shallowWithStore from '../utils/shallowWithStore';
import { createMockStore } from 'redux-test-utils'; import { createMockStore } from 'redux-test-utils';
import { NODES } from 'config/data';
Enzyme.configure({ adapter: new Adapter() }); Enzyme.configure({ adapter: new Adapter() });
it('render snapshot', () => { it('render snapshot', () => {
const testNode = 'rop_mew';
const testStateConfig = { const testStateConfig = {
languageSelection: 'en', languageSelection: 'en',
nodeSelection: 'rop_mew', nodeSelection: testNode,
node: NODES[testNode],
gasPriceGwei: 21, gasPriceGwei: 21,
offline: false, offline: false,
forceOffline: false forceOffline: false

View File

@ -58,6 +58,7 @@ exports[`render snapshot 1`] = `
"call": [Function], "call": [Function],
"decorateRequest": [Function], "decorateRequest": [Function],
"endpoint": "https://api.myetherapi.com/rop", "endpoint": "https://api.myetherapi.com/rop",
"headers": Object {},
}, },
"requests": RPCRequests {}, "requests": RPCRequests {},
} }

View File

@ -1,6 +1,14 @@
import { config, INITIAL_STATE } from 'reducers/config'; import { config, INITIAL_STATE } from 'reducers/config';
import * as configActions from 'actions/config'; import * as configActions from 'actions/config';
import { NODES } from 'config/data'; 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', () => { describe('config reducer', () => {
it('should handle CONFIG_LANGUAGE_CHANGE', () => { it('should handle CONFIG_LANGUAGE_CHANGE', () => {
@ -12,11 +20,14 @@ describe('config reducer', () => {
}); });
it('should handle CONFIG_NODE_CHANGE', () => { 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, ...INITIAL_STATE,
nodeSelection: node node: NODES[key],
nodeSelection: key
}); });
}); });
@ -76,4 +87,42 @@ describe('config reducer', () => {
forceOffline: true 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'
});
});
}); });

38
spec/utils/node.spec.ts Normal file
View File

@ -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();
});
});