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 { 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 {

View File

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

View File

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

View File

@ -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<Props, State> {
constructor(props) {
super(props);
this.state = { isOpen: false };
@ -276,9 +280,7 @@ export default class Footer extends React.Component<{}, ComponentState> {
);
})}
</p>
{/* TODO: Fix me */}
<p>Latest Block#: ?????</p>
<p>Latest Block#: {this.props.latestBlock}</p>
</div>
</footer>
</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 {
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;
}
}
}
}

View File

@ -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,33 +16,59 @@ 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<Props, {}> {
interface State {
isAddingCustomNode: boolean;
}
export default class Header extends Component<Props, State> {
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 => {
const nodeOptions = Object.keys(NODES)
.map(key => {
return {
value: key,
name: (
@ -50,7 +79,22 @@ export default class Header extends Component<Props, {}> {
color: NETWORKS[NODES[key].network].color,
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 (
<div className="Header">
@ -66,7 +110,7 @@ export default class Header extends Component<Props, {}> {
<section className="Header-branding">
<section className="Header-branding-inner container">
<Link
to={'/'}
to="/"
className="Header-branding-title"
aria-label="Go to homepage"
>
@ -109,22 +153,29 @@ export default class Header extends Component<Props, {}> {
/>
</div>
<div className="Header-branding-right-dropdown">
<div
className={classnames({
'Header-branding-right-dropdown': true,
'is-flashing': isChangingNode
})}
>
<ColorDropdown
ariaLabel={`change node. current node ${
selectedNode.network
} node by ${selectedNode.service}`}
ariaLabel={`
change node. current node ${node.network}
node by ${node.service}
`}
options={nodeOptions}
value={nodeSelection}
extra={
<li>
<a>Add Custom Node</a>
<a onClick={this.openCustomNodeModal}>Add Custom Node</a>
</li>
}
disabled={nodeSelection === 'web3'}
onChange={changeNodeIntent}
size="smr"
color="white"
menuAlign="right"
/>
</div>
</div>
@ -132,6 +183,13 @@ export default class Header extends Component<Props, {}> {
</section>
<Navigation color={selectedNetwork.color} />
{isAddingCustomNode && (
<CustomNodeModal
handleAddCustomNode={this.addCustomNode}
handleClose={this.closeCustomNodeModal}
/>
)}
</div>
);
}
@ -142,4 +200,17 @@ export default class Header extends Component<Props, {}> {
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 classnames from 'classnames';
import DropdownShell from './DropdownShell';
import removeIcon from 'assets/images/icon-remove.svg';
import './ColorDropdown.scss';
interface Option<T> {
name: any;
value: T;
color?: string;
hidden: boolean | undefined;
onRemove?(): void;
}
interface Props<T> {
@ -67,6 +70,7 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
}, []);
const menuClass = classnames({
ColorDropdown: true,
'dropdown-menu': true,
[`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" />;
} else {
return (
<li key={i} style={{ borderLeft: `2px solid ${option.color}` }}>
<li
key={i}
className="ColorDropdown-item"
style={{ borderColor: option.color }}
>
<a
className={option.value === value ? 'active' : ''}
onClick={this.onChange.bind(null, option.value)}
>
{option.name}
{option.onRemove && (
<img
className="ColorDropdown-item-remove"
onClick={this.onRemove.bind(null, option.onRemove)}
src={removeIcon}
/>
)}
</a>
</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() {
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';
export interface IButton {
text: string;
text: string | React.ReactElement<string>;
type?:
| 'default'
| 'primary'
@ -17,7 +17,7 @@ export interface IButton {
}
interface Props {
isOpen?: boolean;
title: string;
title: string | React.ReactElement<any>;
disableButtons?: boolean;
children: any;
buttons: IButton[];

View File

@ -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 {

View File

@ -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<Props, {}> {
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<Props, {}> {
<main>
<Header {...headerProps} />
<div className="Tab container">{children}</div>
<Footer />
<Footer latestBlock={latestBlock} />
</main>
<Notifications />
<AlphaAgreement />
@ -64,14 +86,20 @@ class TabSection extends Component<Props, {}> {
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);

View File

@ -14,4 +14,5 @@ export interface INode {
getTransactionCount(address: string): Promise<string>;
sendRawTx(tx: string): 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,
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',
};
}
}

View File

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

View File

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

View File

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

View File

@ -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<Wei> {
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<Wei> {
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<TokenValue> {
@ -77,12 +78,14 @@ export default class RpcNode implements INode {
public getTransactionCount(address: string): Promise<string> {
return this.client
.call(this.requests.getTransactionCount(address))
.then(response => {
if (response.error) {
throw new Error(response.error.message);
.then(errorOrResult);
}
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> {

View File

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

View File

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

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> {
return new Promise((resolve, reject) =>
this.web3.eth.sendRawTransaction(signedTx, (err, txHash) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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<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 = {
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

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 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

View File

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

View File

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

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