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')}
+
+ }
+
+
+
+
+ );
+ }
+
+ 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 {
/>
-
@@ -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();
+ });
+});