Merge pull request #1127 from MyCryptoHQ/develop

Tag Beta 0.3.0
This commit is contained in:
Daniel Ternyak 2018-02-17 16:14:36 -06:00 committed by GitHub
commit 279a6da364
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 1395 additions and 161 deletions

View File

@ -1,6 +1,7 @@
# MyCrypto Beta (VISIT [MyCryptoHQ/mycrypto.com](https://github.com/MyCryptoHQ/mycrypto.com) for the current site)<br/>Just looking to download? Grab our [latest release](https://github.com/MyCryptoHQ/MyCrypto/releases)
[![Greenkeeper badge](https://badges.greenkeeper.io/MyCryptoHq/MyCrypto.svg)](https://greenkeeper.io/)
[![Coverage Status](https://coveralls.io/repos/github/MyCryptoHQ/MyCrypto/badge.svg?branch=develop)](https://coveralls.io/github/MyCryptoHQ/MyCrypto?branch=develop)
## Running the App

View File

@ -9,6 +9,7 @@ import SendTransaction from 'containers/Tabs/SendTransaction';
import Swap from 'containers/Tabs/Swap';
import SignAndVerifyMessage from 'containers/Tabs/SignAndVerifyMessage';
import BroadcastTx from 'containers/Tabs/BroadcastTx';
import CheckTransaction from 'containers/Tabs/CheckTransaction';
import ErrorScreen from 'components/ErrorScreen';
import PageNotFound from 'components/PageNotFound';
import LogOutPrompt from 'components/LogOutPrompt';
@ -67,6 +68,7 @@ export default class Root extends Component<Props, State> {
<Route path="/contracts" component={Contracts} />
<Route path="/ens" component={ENS} exact={true} />
<Route path="/sign-and-verify-message" component={SignAndVerifyMessage} />
<Route path="/tx-status" component={CheckTransaction} exact={true} />
<Route path="/pushTx" component={BroadcastTx} />
<RouteNotFound />
</Switch>
@ -120,8 +122,7 @@ const LegacyRoutes = withRouter(props => {
history.push('/account/info');
break;
case '#check-tx-status':
history.push('/check-tx-status');
break;
return <RedirectWithQuery from={pathname} to={'/tx-status'} />;
}
}

View File

@ -0,0 +1,19 @@
import * as interfaces from './actionTypes';
import { TypeKeys } from './constants';
export type TFetchGasEstimates = typeof fetchGasEstimates;
export function fetchGasEstimates(): interfaces.FetchGasEstimatesAction {
return {
type: TypeKeys.GAS_FETCH_ESTIMATES
};
}
export type TSetGasEstimates = typeof setGasEstimates;
export function setGasEstimates(
payload: interfaces.SetGasEstimatesAction['payload']
): interfaces.SetGasEstimatesAction {
return {
type: TypeKeys.GAS_SET_ESTIMATES,
payload
};
}

View File

@ -0,0 +1,14 @@
import { TypeKeys } from './constants';
import { GasEstimates } from 'api/gas';
export interface FetchGasEstimatesAction {
type: TypeKeys.GAS_FETCH_ESTIMATES;
}
export interface SetGasEstimatesAction {
type: TypeKeys.GAS_SET_ESTIMATES;
payload: GasEstimates;
}
/*** Union Type ***/
export type GasAction = FetchGasEstimatesAction | SetGasEstimatesAction;

View File

@ -0,0 +1,4 @@
export enum TypeKeys {
GAS_FETCH_ESTIMATES = 'GAS_FETCH_ESTIMATES',
GAS_SET_ESTIMATES = 'GAS_SET_ESTIMATES'
}

View File

@ -0,0 +1,3 @@
export * from './actionCreators';
export * from './actionTypes';
export * from './constants';

View File

@ -0,0 +1,20 @@
import * as interfaces from './actionTypes';
import { TypeKeys } from './constants';
export type TFetchTransactionData = typeof fetchTransactionData;
export function fetchTransactionData(txhash: string): interfaces.FetchTransactionDataAction {
return {
type: TypeKeys.TRANSACTIONS_FETCH_TRANSACTION_DATA,
payload: txhash
};
}
export type TSetTransactionData = typeof setTransactionData;
export function setTransactionData(
payload: interfaces.SetTransactionDataAction['payload']
): interfaces.SetTransactionDataAction {
return {
type: TypeKeys.TRANSACTIONS_SET_TRANSACTION_DATA,
payload
};
}

View File

@ -0,0 +1,20 @@
import { TypeKeys } from './constants';
import { TransactionData, TransactionReceipt } from 'libs/nodes';
export interface FetchTransactionDataAction {
type: TypeKeys.TRANSACTIONS_FETCH_TRANSACTION_DATA;
payload: string;
}
export interface SetTransactionDataAction {
type: TypeKeys.TRANSACTIONS_SET_TRANSACTION_DATA;
payload: {
txhash: string;
data: TransactionData | null;
receipt: TransactionReceipt | null;
error: string | null;
};
}
/*** Union Type ***/
export type TransactionsAction = FetchTransactionDataAction | SetTransactionDataAction;

View File

@ -0,0 +1,5 @@
export enum TypeKeys {
TRANSACTIONS_FETCH_TRANSACTION_DATA = 'TRANSACTIONS_FETCH_TRANSACTION_DATA',
TRANSACTIONS_SET_TRANSACTION_DATA = 'TRANSACTIONS_SET_TRANSACTION_DATA',
TRANSACTIONS_SET_TRANSACTION_ERROR = 'TRANSACTIONS_SET_TRANSACTION_ERROR'
}

View File

@ -0,0 +1,3 @@
export * from './actionCreators';
export * from './actionTypes';
export * from './constants';

71
common/api/gas.ts Normal file
View File

@ -0,0 +1,71 @@
import { checkHttpStatus, parseJSON } from './utils';
const MAX_GAS_FAST = 250;
interface RawGasEstimates {
safeLow: number;
standard: number;
fast: number;
fastest: number;
block_time: number;
blockNum: number;
}
export interface GasEstimates {
safeLow: number;
standard: number;
fast: number;
fastest: number;
time: number;
isDefault: boolean;
}
export function fetchGasEstimates(): Promise<GasEstimates> {
return fetch('https://dev.blockscale.net/api/gasexpress.json', {
mode: 'cors'
})
.then(checkHttpStatus)
.then(parseJSON)
.then((res: object) => {
// Make sure it looks like a raw gas estimate, and it has valid values
const keys = ['safeLow', 'standard', 'fast', 'fastest'];
keys.forEach(key => {
if (typeof res[key] !== 'number') {
throw new Error(
`Gas estimate API has invalid shape: Expected numeric key '${key}' in response, got '${
res[key]
}' instead`
);
}
});
// Make sure the estimate isn't totally crazy
const estimateRes = res as RawGasEstimates;
if (estimateRes.fast > MAX_GAS_FAST) {
throw new Error(
`Gas estimate response estimate too high: Max fast is ${MAX_GAS_FAST}, was given ${
estimateRes.fast
}`
);
}
if (
estimateRes.safeLow > estimateRes.standard ||
estimateRes.standard > estimateRes.fast ||
estimateRes.fast > estimateRes.fastest
) {
throw new Error(
`Gas esimates are in illogical order: should be safeLow < standard < fast < fastest, received ${
estimateRes.safeLow
} < ${estimateRes.standard} < ${estimateRes.fast} < ${estimateRes.fastest}`
);
}
return estimateRes;
})
.then((res: RawGasEstimates) => ({
...res,
time: Date.now(),
isDefault: false
}));
}

View File

@ -134,17 +134,21 @@ class AccountInfo extends React.Component<Props, State> {
symbol={balance.wei ? network.name : null}
/>
</span>
{balance.isPending ? (
<Spinner />
) : (
!isOffline && (
<button
className="AccountInfo-section-refresh"
onClick={this.props.setAccountBalance}
>
<i className="fa fa-refresh" />
</button>
)
{balance.wei && (
<React.Fragment>
{balance.isPending ? (
<Spinner />
) : (
!isOffline && (
<button
className="AccountInfo-section-refresh"
onClick={this.props.setAccountBalance}
>
<i className="fa fa-refresh" />
</button>
)
)}
</React.Fragment>
)}
</li>
</ul>

View File

@ -1,17 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import BityLogo from 'assets/images/logo-bity-white.svg';
export const Bity: React.SFC = () => (
<Link className="Promos-promo Promos-Bity" target="_blank" rel="noopener noreferrer" to="/swap">
<div className="Promos-promo-inner">
<div className="Promos-promo-text">
<p>Its now easier to get more ETH</p>
<h5>Swap BTC &lt;-&gt; ETH</h5>
</div>
<div className="Promos-promo-images">
<img src={BityLogo} />
</div>
</div>
</Link>
);

View File

@ -1,11 +1,10 @@
import React from 'react';
import CoinbaseLogo from 'assets/images/logo-coinbase.svg';
import { NewTabLink } from 'components/ui';
export const Coinbase: React.SFC = () => (
<a
className="Promos-promo Promos-Coinbase"
target="_blank"
rel="noopener noreferrer"
<NewTabLink
className="Promos-promo Promos--coinbase"
href="https://buy.coinbase.com?code=60c05061-3a76-57be-b1cd-a7afa97bcb8c&address=0xA7DeFf12461661212734dB35AdE9aE7d987D648c&crypto_currency=ETH&currency=USD"
>
<div className="Promos-promo-inner">
@ -17,5 +16,5 @@ export const Coinbase: React.SFC = () => (
<img src={CoinbaseLogo} />
</div>
</div>
</a>
</NewTabLink>
);

View File

@ -5,10 +5,7 @@ import ledgerLogo from 'assets/images/logo-ledger.svg';
import trezorLogo from 'assets/images/logo-trezor.svg';
export const HardwareWallets: React.SFC = () => (
<HelpLink
className="Promos-promo Promos-HardwareWallets"
article={HELP_ARTICLE.PROTECT_YOUR_FUNDS}
>
<HelpLink className="Promos-promo Promos--hardware" article={HELP_ARTICLE.PROTECT_YOUR_FUNDS}>
<div className="Promos-promo-inner">
<div className="Promos-promo-text">
<h6>Learn more about protecting your funds.</h6>

View File

@ -0,0 +1,20 @@
import React from 'react';
import { Link } from 'react-router-dom';
import ShapeshiftLogo from 'assets/images/logo-shapeshift.svg';
export const Shapeshift: React.SFC = () => (
<Link className="Promos-promo Promos--shapeshift" to="/swap">
<div className="Promos-promo-inner">
<div className="Promos-promo-text">
<h5>
Exchange Coins
<br />
& Tokens with
</h5>
</div>
<div className="Promos-promo-images">
<img src={ShapeshiftLogo} />
</div>
</div>
</Link>
);

View File

@ -1,3 +1,3 @@
export * from './HardwareWallets';
export * from './Coinbase';
export * from './Bity';
export * from './Shapeshift';

View File

@ -9,18 +9,6 @@
overflow: hidden;
}
&-Bity {
background-color: #006e79;
}
&-Coinbase {
background-color: #2b71b1;
}
&-HardwareWallets {
background-color: #6e9a3e;
}
&-promo {
position: relative;
height: inherit;
@ -42,19 +30,18 @@
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 50%;
left: 0;
width: 100%;
transform: translateY(-50%);
}
&-text,
&-images {
padding: 0 $space-sm;
padding: 0 $space;
}
&-text {
flex: 1;
flex: 5;
padding-right: $space-xs;
max-width: 220px;
p,
h4,
@ -73,15 +60,15 @@
}
&-images {
padding: 0 $space * 1.5;
flex: 3;
max-width: 108px;
padding-left: $space-xs;
img {
display: block;
margin: 0 auto;
width: 100%;
max-width: 96px;
height: auto;
padding: $space-xs;
}
}
}
@ -106,6 +93,23 @@
}
}
}
// Per-promo customizations
&--shapeshift {
background-color: #263A52;
.Promos-promo-images {
max-width: 130px;
}
}
&--coinbase {
background-color: #2b71b1;
}
&--hardware {
background-color: #6e9a3e;
}
}
.carousel-exit {

View File

@ -1,9 +1,9 @@
import React from 'react';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import { HardwareWallets, Coinbase, Bity } from './PromoComponents';
import { HardwareWallets, Coinbase, Shapeshift } from './PromoComponents';
import './Promos.scss';
const promos = [HardwareWallets, Coinbase, Bity];
const promos = [HardwareWallets, Coinbase, Shapeshift];
const CarouselAnimation = ({ children, ...props }) => (
<CSSTransition {...props} timeout={300} classNames="carousel">
@ -36,11 +36,7 @@ export default class Promos extends React.PureComponent<{}, State> {
return (
<div className="Promos">
<TransitionGroup className="Promos-promo-wrapper">
{promos
.filter(i => {
return i === promos[activePromo];
})
.map(promo => <CarouselAnimation key={Math.random()}>{promo}</CarouselAnimation>)}
<CarouselAnimation key={Math.random()}>{promos[activePromo]}</CarouselAnimation>
</TransitionGroup>
<div className="Promos-nav">
{promos.map((_, index) => {

View File

@ -13,19 +13,24 @@ interface ReduxProps {
wallet: AppState['wallet']['inst'];
}
type Props = ReduxProps;
interface State {
walletAddress: string | null;
}
class CurrentCustomMessageClass extends PureComponent<ReduxProps, State> {
class CurrentCustomMessageClass extends PureComponent<Props, State> {
public state: State = {
walletAddress: null
};
public async componentDidMount() {
if (this.props.wallet) {
const walletAddress = await this.props.wallet.getAddressString();
this.setState({ walletAddress });
this.setAddressState(this.props);
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.wallet !== nextProps.wallet) {
this.setAddressState(nextProps);
}
}
@ -42,6 +47,15 @@ class CurrentCustomMessageClass extends PureComponent<ReduxProps, State> {
}
}
private async setAddressState(props: Props) {
if (props.wallet) {
const walletAddress = await props.wallet.getAddressString();
this.setState({ walletAddress });
} else {
this.setState({ walletAddress: '' });
}
}
private getMessage() {
const { currentTo, tokens } = this.props;
const { walletAddress } = this.state;

View File

@ -53,8 +53,8 @@ class GasPriceDropdown extends Component<Props> {
<input
type="range"
value={this.props.gasPrice.raw}
min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei}
min={gasPriceDefaults.minGwei}
max={gasPriceDefaults.maxGwei}
onChange={this.handleGasPriceChange}
/>
<p className="small col-xs-4 text-left GasPrice-padding-reset">Not So Fast</p>

View File

@ -34,6 +34,10 @@ const tabs: TabLink[] = [
name: 'Sign & Verify Message',
to: '/sign-and-verify-message'
},
{
name: 'NAV_TxStatus',
to: '/tx-status'
},
{
name: 'Broadcast Transaction',
to: '/pushTx'

View File

@ -3,7 +3,9 @@ import BN from 'bn.js';
import { connect } from 'react-redux';
import { AppState } from 'reducers';
import { getNetworkConfig, getOffline } from 'selectors/config';
import { UnitDisplay } from 'components/ui';
import { getIsEstimating } from 'selectors/gas';
import { getGasLimit } from 'selectors/transaction';
import { UnitDisplay, Spinner } from 'components/ui';
import { NetworkConfig } from 'types/network';
import './FeeSummary.scss';
@ -20,6 +22,7 @@ interface ReduxStateProps {
rates: AppState['rates']['rates'];
network: NetworkConfig;
isOffline: AppState['config']['meta']['offline'];
isGasEstimating: AppState['gas']['isEstimating'];
}
interface OwnProps {
@ -31,7 +34,15 @@ type Props = OwnProps & ReduxStateProps;
class FeeSummary extends React.Component<Props> {
public render() {
const { gasPrice, gasLimit, rates, network, isOffline } = this.props;
const { gasPrice, gasLimit, rates, network, isOffline, isGasEstimating } = this.props;
if (isGasEstimating) {
return (
<div className="FeeSummary is-loading">
<Spinner />
</div>
);
}
const feeBig = gasPrice.value && gasLimit.value && gasPrice.value.mul(gasLimit.value);
const fee = (
@ -73,10 +84,11 @@ class FeeSummary extends React.Component<Props> {
function mapStateToProps(state: AppState): ReduxStateProps {
return {
gasLimit: state.transaction.fields.gasLimit,
gasLimit: getGasLimit(state),
rates: state.rates.rates,
network: getNetworkConfig(state),
isOffline: getOffline(state)
isOffline: getOffline(state),
isGasEstimating: getIsEstimating(state)
};
}

View File

@ -11,33 +11,50 @@ import {
nonceRequestPending
} from 'selectors/transaction';
import { connect } from 'react-redux';
import { fetchGasEstimates, TFetchGasEstimates } from 'actions/gas';
import { getIsWeb3Node } from 'selectors/config';
import { getEstimates, getIsEstimating } from 'selectors/gas';
import { Wei, fromWei } from 'libs/units';
import { InlineSpinner } from 'components/ui/InlineSpinner';
const SliderWithTooltip = Slider.createSliderWithTooltip(Slider);
interface OwnProps {
gasPrice: AppState['transaction']['fields']['gasPrice'];
noncePending: boolean;
gasLimitPending: boolean;
inputGasPrice(rawGas: string);
setGasPrice(rawGas: string);
}
interface StateProps {
gasEstimates: AppState['gas']['estimates'];
isGasEstimating: AppState['gas']['isEstimating'];
noncePending: boolean;
gasLimitPending: boolean;
isWeb3Node: boolean;
gasLimitEstimationTimedOut: boolean;
}
type Props = OwnProps & StateProps;
interface ActionProps {
fetchGasEstimates: TFetchGasEstimates;
}
type Props = OwnProps & StateProps & ActionProps;
class SimpleGas extends React.Component<Props> {
public componentDidMount() {
this.fixGasPrice(this.props.gasPrice);
this.props.fetchGasEstimates();
}
public componentWillReceiveProps(nextProps: Props) {
if (!this.props.gasEstimates && nextProps.gasEstimates) {
this.props.setGasPrice(nextProps.gasEstimates.fast.toString());
}
}
public render() {
const {
isGasEstimating,
gasEstimates,
gasPrice,
gasLimitEstimationTimedOut,
isWeb3Node,
@ -45,6 +62,11 @@ class SimpleGas extends React.Component<Props> {
gasLimitPending
} = this.props;
const bounds = {
max: gasEstimates ? gasEstimates.fastest : gasPriceDefaults.minGwei,
min: gasEstimates ? gasEstimates.safeLow : gasPriceDefaults.maxGwei
};
return (
<div className="SimpleGas row form-group">
<div className="SimpleGas-title">
@ -69,14 +91,14 @@ class SimpleGas extends React.Component<Props> {
<div className="SimpleGas-slider">
<SliderWithTooltip
onChange={this.handleSlider}
min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei}
min={bounds.min}
max={bounds.max}
value={this.getGasPriceGwei(gasPrice.value)}
tipFormatter={gas => `${gas} Gwei`}
tipFormatter={this.formatTooltip}
disabled={isGasEstimating}
/>
<div className="SimpleGas-slider-labels">
<span>{translate('Cheap')}</span>
<span>{translate('Balanced')}</span>
<span>{translate('Fast')}</span>
</div>
</div>
@ -100,21 +122,38 @@ class SimpleGas extends React.Component<Props> {
private fixGasPrice(gasPrice: AppState['transaction']['fields']['gasPrice']) {
// If the gas price is above or below our minimum, bring it in line
const gasPriceGwei = this.getGasPriceGwei(gasPrice.value);
if (gasPriceGwei > gasPriceDefaults.gasPriceMaxGwei) {
this.props.setGasPrice(gasPriceDefaults.gasPriceMaxGwei.toString());
} else if (gasPriceGwei < gasPriceDefaults.gasPriceMinGwei) {
this.props.setGasPrice(gasPriceDefaults.gasPriceMinGwei.toString());
if (gasPriceGwei > gasPriceDefaults.maxGwei) {
this.props.setGasPrice(gasPriceDefaults.maxGwei.toString());
} else if (gasPriceGwei < gasPriceDefaults.minGwei) {
this.props.setGasPrice(gasPriceDefaults.minGwei.toString());
}
}
private getGasPriceGwei(gasPriceValue: Wei) {
return parseFloat(fromWei(gasPriceValue, 'gwei'));
}
private formatTooltip = (gas: number) => {
const { gasEstimates } = this.props;
let recommended = '';
if (gasEstimates && !gasEstimates.isDefault && gas === gasEstimates.fast) {
recommended = '(Recommended)';
}
return `${gas} Gwei ${recommended}`;
};
}
export default connect((state: AppState) => ({
noncePending: nonceRequestPending(state),
gasLimitPending: getGasEstimationPending(state),
gasLimitEstimationTimedOut: getGasLimitEstimationTimedOut(state),
isWeb3Node: getIsWeb3Node(state)
}))(SimpleGas);
export default connect(
(state: AppState): StateProps => ({
gasEstimates: getEstimates(state),
isGasEstimating: getIsEstimating(state),
noncePending: nonceRequestPending(state),
gasLimitPending: getGasEstimationPending(state),
gasLimitEstimationTimedOut: getGasLimitEstimationTimedOut(state),
isWeb3Node: getIsWeb3Node(state)
}),
{
fetchGasEstimates
}
)(SimpleGas);

View File

@ -0,0 +1,49 @@
@import 'common/sass/variables';
@import 'common/sass/mixins';
.TxData {
&-row {
font-size: 0.9rem;
&-label {
font-weight: bold;
text-align: right;
}
&-data {
@include mono;
&-more {
margin-left: $space-sm;
font-size: 0.7rem;
opacity: 0.7;
}
&-status {
font-weight: bold;
&.is-success {
color: $brand-success;
}
&.is-warning {
color: $brand-warning;
}
&.is-danger {
color: $brand-danger;
}
}
.Identicon {
float: left;
margin-right: $space-sm;
}
textarea {
display: block;
width: 100%;
height: 7.2rem;
background: $gray-lighter;
}
}
}
}

View File

@ -0,0 +1,174 @@
import React from 'react';
import translate from 'translations';
import { Identicon, UnitDisplay, NewTabLink } from 'components/ui';
import { TransactionData, TransactionReceipt } from 'libs/nodes';
import { NetworkConfig } from 'types/network';
import './TransactionDataTable.scss';
interface TableRow {
label: React.ReactElement<string> | string;
data: React.ReactElement<string> | string | number | null;
}
const MaybeLink: React.SFC<{
href: string | null | undefined | false;
children: any; // Too many damn React element types
}> = ({ href, children }) => {
if (href) {
return <NewTabLink href={href}>{children}</NewTabLink>;
} else {
return <React.Fragment>{children}</React.Fragment>;
}
};
interface Props {
data: TransactionData;
receipt: TransactionReceipt | null;
network: NetworkConfig;
}
const TransactionDataTable: React.SFC<Props> = ({ data, receipt, network }) => {
const explorer: { [key: string]: string | false | null } = {};
const hasInputData = data.input && data.input !== '0x';
if (!network.isCustom) {
explorer.tx = network.blockExplorer && network.blockExplorer.txUrl(data.hash);
explorer.block =
network.blockExplorer &&
!!data.blockNumber &&
network.blockExplorer.blockUrl(data.blockNumber);
explorer.to = network.blockExplorer && network.blockExplorer.addressUrl(data.to);
explorer.from = network.blockExplorer && network.blockExplorer.addressUrl(data.from);
explorer.contract =
network.blockExplorer &&
receipt &&
receipt.contractAddress &&
network.blockExplorer.addressUrl(receipt.contractAddress);
}
let statusMsg = '';
let statusType = '';
let statusSeeMore = false;
if (receipt) {
if (receipt.status === 1) {
statusMsg = 'SUCCESSFUL';
statusType = 'success';
} else if (receipt.status === 0) {
statusMsg = 'FAILED';
statusType = 'danger';
statusSeeMore = true;
} else {
// Pre-byzantium transactions don't use status, and cannot have their
// success determined over the JSON RPC api
statusMsg = 'UNKNOWN';
statusType = 'warning';
statusSeeMore = true;
}
} else {
statusMsg = 'PENDING';
statusType = 'warning';
}
const rows: TableRow[] = [
{
label: 'Status',
data: (
<React.Fragment>
<strong className={`TxData-row-data-status is-${statusType}`}>{statusMsg}</strong>
{statusSeeMore &&
explorer.tx &&
!network.isCustom && (
<NewTabLink className="TxData-row-data-more" href={explorer.tx as string}>
(See more on {network.blockExplorer.name})
</NewTabLink>
)}
</React.Fragment>
)
},
{
label: translate('x_TxHash'),
data: <MaybeLink href={explorer.tx}>{data.hash}</MaybeLink>
},
{
label: 'Block Number',
data: receipt && <MaybeLink href={explorer.block}>{receipt.blockNumber}</MaybeLink>
},
{
label: translate('OFFLINE_Step1_Label_1'),
data: (
<MaybeLink href={explorer.from}>
<Identicon address={data.from} size="26px" />
{data.from}
</MaybeLink>
)
},
{
label: translate('OFFLINE_Step2_Label_1'),
data: (
<MaybeLink href={explorer.to}>
<Identicon address={data.to} size="26px" />
{data.to}
</MaybeLink>
)
},
{
label: translate('SEND_amount_short'),
data: <UnitDisplay value={data.value} unit="ether" symbol={network.unit} />
},
{
label: translate('OFFLINE_Step2_Label_3'),
data: <UnitDisplay value={data.gasPrice} unit="gwei" symbol="Gwei" />
},
{
label: translate('OFFLINE_Step2_Label_4'),
data: <UnitDisplay value={data.gas} unit="wei" />
},
{
label: 'Gas Used',
data: receipt && <UnitDisplay value={receipt.gasUsed} unit="wei" />
},
{
label: 'Transaction Fee',
data: receipt && (
<UnitDisplay
value={receipt.gasUsed.mul(data.gasPrice)}
unit="ether"
symbol={network.unit}
/>
)
},
{
label: translate('New contract address'),
data: receipt &&
receipt.contractAddress && (
<MaybeLink href={explorer.contract}>{receipt.contractAddress}</MaybeLink>
)
},
{
label: translate('OFFLINE_Step2_Label_5'),
data: data.nonce
},
{
label: translate('TRANS_data'),
data: hasInputData ? (
<textarea className="form-control" value={data.input} disabled={true} />
) : null
}
];
const filteredRows = rows.filter(row => !!row.data);
return (
<table className="TxData table table-striped">
<tbody>
{filteredRows.map((row, idx) => (
<tr className="TxData-row" key={idx}>
<td className="TxData-row-label">{row.label}</td>
<td className="TxData-row-data">{row.data}</td>
</tr>
))}
</tbody>
</table>
);
};
export default TransactionDataTable;

View File

@ -0,0 +1,35 @@
@import 'common/sass/variables';
.TxStatus {
text-align: center;
&-title {
margin-top: 0;
}
&-data {
text-align: left;
}
&-loading {
padding: $space * 3 0;
}
&-error {
max-width: 620px;
margin: 0 auto;
&-title {
color: $brand-danger;
}
&-desc {
font-size: $font-size-bump;
margin-bottom: $space-md;
}
&-list {
text-align: left;
}
}
}

View File

@ -0,0 +1,88 @@
import React from 'react';
import { connect } from 'react-redux';
import translate from 'translations';
import { fetchTransactionData, TFetchTransactionData } from 'actions/transactions';
import { getTransactionDatas } from 'selectors/transactions';
import { getNetworkConfig } from 'selectors/config';
import { Spinner } from 'components/ui';
import TransactionDataTable from './TransactionDataTable';
import { AppState } from 'reducers';
import { NetworkConfig } from 'types/network';
import { TransactionState } from 'reducers/transactions';
import './TransactionStatus.scss';
interface OwnProps {
txHash: string;
}
interface StateProps {
tx: TransactionState | null;
network: NetworkConfig;
}
interface ActionProps {
fetchTransactionData: TFetchTransactionData;
}
type Props = OwnProps & StateProps & ActionProps;
class TransactionStatus extends React.Component<Props> {
public componentDidMount() {
this.props.fetchTransactionData(this.props.txHash);
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.txHash !== nextProps.txHash) {
this.props.fetchTransactionData(nextProps.txHash);
}
}
public render() {
const { tx, network } = this.props;
let content;
if (tx && tx.data) {
content = (
<React.Fragment>
<h2 className="TxStatus-title">Transaction Found</h2>
<div className="TxStatus-data">
<TransactionDataTable network={network} data={tx.data} receipt={tx.receipt} />
</div>
</React.Fragment>
);
} else if (tx && tx.error) {
content = (
<div className="TxStatus-error">
<h2 className="TxStatus-error-title">{translate('tx_notFound')}</h2>
<p className="TxStatus-error-desc">{translate('tx_notFound_1')}</p>
<ul className="TxStatus-error-list">
<li>Make sure you copied the Transaction Hash correctly</li>
<li>{translate('tx_notFound_2')}</li>
<li>{translate('tx_notFound_3')}</li>
<li>{translate('tx_notFound_4')}</li>
</ul>
</div>
);
} else if (tx && tx.isLoading) {
// tx.isLoading... probably.
content = (
<div className="TxStatus-loading">
<Spinner size="x3" />
</div>
);
}
return <div className="TxStatus">{content}</div>;
}
}
function mapStateToProps(state: AppState, ownProps: OwnProps): StateProps {
const { txHash } = ownProps;
return {
tx: getTransactionDatas(state)[txHash],
network: getNetworkConfig(state)
};
}
export default connect(mapStateToProps, { fetchTransactionData })(TransactionStatus);

View File

@ -0,0 +1,2 @@
import TransactionStatus from './TransactionStatus';
export default TransactionStatus;

View File

@ -114,7 +114,7 @@ export class InsecureWalletWarning extends React.Component<Props, State> {
private makeCheckbox = (checkbox: Checkbox) => {
return (
<label className="AcknowledgeCheckbox">
<label className="AcknowledgeCheckbox" key={checkbox.name}>
<input
type="checkbox"
name={checkbox.name}

View File

@ -18,3 +18,4 @@ export { default as TXMetaDataPanel } from './TXMetaDataPanel';
export { default as WalletDecrypt } from './WalletDecrypt';
export { default as TogglablePassword } from './TogglablePassword';
export { default as GenerateKeystoreModal } from './GenerateKeystoreModal';
export { default as TransactionStatus } from './TransactionStatus';

View File

@ -108,6 +108,11 @@ $m-anim-speed: 400ms;
min-width: 100px;
}
}
// Mobile styles
@media(max-width: $screen-sm) {
width: calc(100% - 40px);
}
}
.animate-modal {

View File

@ -31,8 +31,8 @@ export interface AAttributes {
interface NewTabLinkProps extends AAttributes {
href: string;
content?: React.ReactElement<any> | string | string[];
children?: React.ReactElement<any> | string | string[];
content?: React.ReactElement<any> | string | string[] | number;
children?: React.ReactElement<any> | string | string[] | number;
}
export class NewTabLink extends React.Component<NewTabLinkProps> {

View File

@ -5,3 +5,4 @@ export const GAS_LIMIT_UPPER_BOUND = 8000000;
// Lower/upper ranges for gas price in gwei
export const GAS_PRICE_GWEI_LOWER_BOUND = 1;
export const GAS_PRICE_GWEI_UPPER_BOUND = 10000;
export const GAS_PRICE_GWEI_DEFAULT = 40;

View File

@ -4,7 +4,7 @@ import { getValues } from '../utils/helpers';
export const languages = require('./languages.json');
// Displays in the footer
export const VERSION = '0.2.1 (BETA)';
export const VERSION = '0.3.0 (BETA)';
export const N_FACTOR = 8192;
// Displays at the top of the site, make message empty string to remove.
@ -42,9 +42,11 @@ export const donationAddressMap = {
};
export const gasPriceDefaults = {
gasPriceMinGwei: 1,
gasPriceMaxGwei: 60
minGwei: 1,
maxGwei: 60,
default: 21
};
export const gasEstimateCacheTime = 60000;
export const MINIMUM_PASSWORD_LENGTH = 12;

View File

@ -3,10 +3,10 @@ export enum HELP_ARTICLE {
ENS = 'ens',
ENS_BAD_REVEAL = 'ens/ens-debugging-a-bad-instruction-reveal',
DIFFERENCE_BETWEEN_PKEY_AND_KEYSTORE = 'private-keys-passwords/difference-beween-private-key-and-keystore-file.html',
RUNNING_LOCALLY = 'offline/running-myetherwallet-locally.html',
RUNNING_LOCALLY = 'offline/running-mycrypto-locally.html',
MIGRATE_TO_METAMASK = 'migration/moving-from-private-key-to-metamask.html',
MIGRATE_TO_LEDGER = 'migration/moving-from-private-key-to-ledger-hardware-wallet.html',
ADDING_NEW_TOKENS = 'send/adding-new-token-and-sending-custom-tokens.html',
ADDING_NEW_TOKENS = 'tokens/adding-new-token-and-sending-custom-tokens.html',
HARDWARE_WALLET_RECOMMENDATIONS = 'hardware-wallets/hardware-wallet-recommendations.html',
SENDING_TO_TREZOR = 'hardware-wallets/trezor-sending-to-trezor-device.html',
SECURING_YOUR_ETH = 'security/securing-your-ethereum.html',

View File

@ -0,0 +1,4 @@
.TxHashInput {
max-width: 700px;
margin: 0 auto;
}

View File

@ -0,0 +1,63 @@
import React from 'react';
import translate from 'translations';
import { isValidTxHash, isValidETHAddress } from 'libs/validators';
import './TxHashInput.scss';
interface Props {
hash?: string;
onSubmit(hash: string): void;
}
interface State {
hash: string;
}
export default class TxHashInput extends React.Component<Props, State> {
public constructor(props: Props) {
super(props);
this.state = { hash: props.hash || '' };
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.hash !== nextProps.hash && nextProps.hash) {
this.setState({ hash: nextProps.hash });
}
}
public render() {
const { hash } = this.state;
const validClass = hash ? (isValidTxHash(hash) ? 'is-valid' : 'is-invalid') : '';
return (
<form className="TxHashInput" onSubmit={this.handleSubmit}>
<input
value={hash}
placeholder="0x16e521..."
className={`TxHashInput-field form-control ${validClass}`}
onChange={this.handleChange}
/>
{isValidETHAddress(hash) && (
<p className="TxHashInput-message help-block is-invalid">
You cannot use an address, you must use a transaction hash
</p>
)}
<button className="TxHashInput-submit btn btn-primary btn-block">
{translate('NAV_CheckTxStatus')}
</button>
</form>
);
}
private handleChange = (ev: React.FormEvent<HTMLInputElement>) => {
this.setState({ hash: ev.currentTarget.value });
};
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (isValidTxHash(this.state.hash)) {
this.props.onSubmit(this.state.hash);
}
};
}

View File

@ -0,0 +1,12 @@
@import 'common/sass/variables';
.CheckTransaction {
&-form {
text-align: center;
&-desc {
max-width: 580px;
margin: 0 auto $space;
}
}
}

View File

@ -0,0 +1,69 @@
import React from 'react';
import { connect } from 'react-redux';
import TabSection from 'containers/TabSection';
import TxHashInput from './components/TxHashInput';
import { TransactionStatus as TransactionStatusComponent } from 'components';
import { NewTabLink } from 'components/ui';
import { getNetworkConfig } from 'selectors/config';
import { AppState } from 'reducers';
import { NetworkConfig } from 'types/network';
import './index.scss';
interface Props {
network: NetworkConfig;
}
interface State {
hash: string;
}
class CheckTransaction extends React.Component<Props, State> {
public state: State = {
hash: ''
};
public render() {
const { network } = this.props;
const { hash } = this.state;
return (
<TabSection>
<div className="CheckTransaction Tab-content">
<section className="CheckTransaction-form Tab-content-pane">
<h1 className="CheckTransaction-form-title">Check Transaction Status</h1>
<p className="CheckTransaction-form-desc">
Enter your Transaction Hash to check on its status.{' '}
{!network.isCustom && (
<React.Fragment>
If you dont know your Transaction Hash, you can look it up on the{' '}
<NewTabLink href={network.blockExplorer.origin}>
{network.blockExplorer.name} explorer
</NewTabLink>{' '}
by looking up your address.
</React.Fragment>
)}
</p>
<TxHashInput onSubmit={this.handleHashSubmit} />
</section>
{hash && (
<section className="CheckTransaction-tx Tab-content-pane">
<TransactionStatusComponent txHash={hash} />
</section>
)}
</div>
</TabSection>
);
}
private handleHashSubmit = (hash: string) => {
// Reset to re-mount the component
this.setState({ hash: '' }, () => {
this.setState({ hash });
});
};
}
export default connect((state: AppState) => ({
network: getNetworkConfig(state)
}))(CheckTransaction);

View File

@ -34,11 +34,11 @@
<p class="is-desktop">
MyCrypto requires certain features that your browser doesn't offer. Your browser may also be missing security updates
that could open you up to vulnerabilities. Please update your browser, or switch to one of the following browsers
ones to continue using MyCrypto.
to continue using MyCrypto.
</p>
<p class="is-mobile">
MyCrypto requires certain features that your browser doesn't offer. Please use your device's default browser, or switch
to a laptop or computer to continue using MyCrypto.
to a laptop or desktop computer to continue using MyCrypto.
</p>
<div class="BadBrowser-content-browsers is-desktop">
<a class="BadBrowser-content-browsers-browser firefox" href="https://www.mozilla.org/en-US/firefox/new/" rel="noopener noreferrer" target="_blank">

View File

@ -6,10 +6,39 @@ export interface TxObj {
to: string;
data: string;
}
interface TokenBalanceResult {
balance: TokenValue;
error: string | null;
}
export interface TransactionData {
hash: string;
nonce: number;
blockHash: string | null;
blockNumber: number | null;
transactionIndex: number | null;
from: string;
to: string;
value: Wei;
gasPrice: Wei;
gas: Wei;
input: string;
}
export interface TransactionReceipt {
transactionHash: string;
transactionIndex: number;
blockHash: string;
blockNumber: number;
cumulativeGasUsed: Wei;
gasUsed: Wei;
contractAddress: string | null;
logs: string[];
logsBloom: string;
status: number;
}
export interface INode {
ping(): Promise<boolean>;
getBalance(address: string): Promise<Wei>;
@ -17,6 +46,8 @@ export interface INode {
getTokenBalances(address: string, tokens: Token[]): Promise<TokenBalanceResult[]>;
estimateGas(tx: Partial<IHexStrTransaction>): Promise<Wei>;
getTransactionCount(address: string): Promise<string>;
getTransactionByHash(txhash: string): Promise<TransactionData>;
getTransactionReceipt(txhash: string): Promise<TransactionReceipt>;
sendRawTx(tx: string): Promise<string>;
sendCallRequest(txObj: TxObj): Promise<string>;
getCurrentBlock(): Promise<string>;

View File

@ -6,6 +6,7 @@ import {
GetBalanceRequest,
GetTokenBalanceRequest,
GetTransactionCountRequest,
GetTransactionByHashRequest,
SendRawTxRequest,
GetCurrentBlockRequest
} from './types';
@ -58,6 +59,14 @@ export default class EtherscanRequests extends RPCRequests {
};
}
public getTransactionByHash(txhash: string): GetTransactionByHashRequest {
return {
module: 'proxy',
action: 'eth_getTransactionByHash',
txhash
};
}
public getTokenBalance(address: string, token: Token): GetTokenBalanceRequest {
return this.ethCall({
to: token.address,

View File

@ -41,6 +41,12 @@ export interface GetTransactionCountRequest extends EtherscanReqBase {
tag: 'latest';
}
export interface GetTransactionByHashRequest extends EtherscanReqBase {
module: 'proxy';
action: 'eth_getTransactionByHash';
txhash: string;
}
export interface GetCurrentBlockRequest extends EtherscanReqBase {
module: 'proxy';
action: 'eth_blockNumber';
@ -53,4 +59,5 @@ export type EtherscanRequest =
| GetTokenBalanceRequest
| EstimateGasRequest
| GetTransactionCountRequest
| GetTransactionByHashRequest
| GetCurrentBlockRequest;

View File

@ -3,3 +3,4 @@ export { default as InfuraNode } from './infura';
export { default as EtherscanNode } from './etherscan';
export { default as CustomNode } from './custom';
export { default as Web3Node } from './web3';
export * from './INode';

View File

@ -2,7 +2,8 @@ import BN from 'bn.js';
import { IHexStrTransaction } from 'libs/transaction';
import { Wei, TokenValue } from 'libs/units';
import { stripHexPrefix } from 'libs/values';
import { INode, TxObj } from '../INode';
import { hexToNumber } from 'utils/formatters';
import { INode, TxObj, TransactionData, TransactionReceipt } from '../INode';
import RPCClient from './client';
import RPCRequests from './requests';
import {
@ -11,9 +12,11 @@ import {
isValidCallRequest,
isValidTokenBalance,
isValidTransactionCount,
isValidTransactionByHash,
isValidTransactionReceipt,
isValidCurrentBlock,
isValidRawTxApi
} from '../../validators';
} from 'libs/validators';
import { Token } from 'types/network';
export default class RpcNode implements INode {
@ -46,8 +49,6 @@ export default class RpcNode implements INode {
}
public estimateGas(transaction: Partial<IHexStrTransaction>): Promise<Wei> {
// Timeout after 10 seconds
return this.client
.call(this.requests.estimateGas(transaction))
.then(isValidEstimateGas)
@ -106,6 +107,37 @@ export default class RpcNode implements INode {
.then(({ result }) => result);
}
public getTransactionByHash(txhash: string): Promise<TransactionData> {
return this.client
.call(this.requests.getTransactionByHash(txhash))
.then(isValidTransactionByHash)
.then(({ result }) => ({
...result,
to: result.to || '0x0',
value: Wei(result.value),
gasPrice: Wei(result.gasPrice),
gas: Wei(result.gas),
nonce: hexToNumber(result.nonce),
blockNumber: result.blockNumber ? hexToNumber(result.blockNumber) : null,
transactionIndex: result.transactionIndex ? hexToNumber(result.transactionIndex) : null
}));
}
public getTransactionReceipt(txhash: string): Promise<TransactionReceipt> {
return this.client
.call(this.requests.getTransactionReceipt(txhash))
.then(isValidTransactionReceipt)
.then(({ result }) => ({
...result,
transactionIndex: hexToNumber(result.transactionIndex),
blockNumber: hexToNumber(result.blockNumber),
cumulativeGasUsed: Wei(result.cumulativeGasUsed),
gasUsed: Wei(result.gasUsed),
status: result.status ? hexToNumber(result.status) : null,
root: result.root || null
}));
}
public getCurrentBlock(): Promise<string> {
return this.client
.call(this.requests.getCurrentBlock())

View File

@ -6,7 +6,9 @@ import {
GetTokenBalanceRequest,
GetTransactionCountRequest,
SendRawTxRequest,
GetCurrentBlockRequest
GetCurrentBlockRequest,
GetTransactionByHashRequest,
GetTransactionReceiptRequest
} from './types';
import { hexEncodeData } from './utils';
import { TxObj } from '../INode';
@ -17,6 +19,8 @@ export default class RPCRequests {
return { method: 'net_version' };
}
/* TODO: Fix `| any` on all of these */
public sendRawTx(signedTx: string): SendRawTxRequest | any {
return {
method: 'eth_sendRawTransaction',
@ -52,6 +56,20 @@ export default class RPCRequests {
};
}
public getTransactionByHash(txhash: string): GetTransactionByHashRequest | any {
return {
method: 'eth_getTransactionByHash',
params: [txhash]
};
}
public getTransactionReceipt(txhash: string): GetTransactionReceiptRequest | any {
return {
method: 'eth_getTransactionReceipt',
params: [txhash]
};
}
public getTokenBalance(address: string, token: Token): GetTokenBalanceRequest | any {
return {
method: 'eth_call',

View File

@ -69,6 +69,16 @@ export interface GetTransactionCountRequest extends RPCRequestBase {
params: [DATA, DEFAULT_BLOCK];
}
export interface GetTransactionByHashRequest extends RPCRequestBase {
method: 'eth_getTransactionByHash';
params: [string];
}
export interface GetTransactionReceiptRequest extends RPCRequestBase {
method: 'eth_getTransactionReceipt';
params: [string];
}
export interface GetCurrentBlockRequest extends RPCRequestBase {
method: 'eth_blockNumber';
}
@ -80,4 +90,6 @@ export type RPCRequest =
| CallRequest
| EstimateGasRequest
| GetTransactionCountRequest
| GetTransactionByHashRequest
| GetTransactionReceiptRequest
| GetCurrentBlockRequest;

View File

@ -167,7 +167,9 @@ export const schema = {
properties: {
jsonrpc: { type: 'string' },
id: { oneOf: [{ type: 'string' }, { type: 'integer' }] },
result: { oneOf: [{ type: 'string' }, { type: 'array' }] },
result: {
oneOf: [{ type: 'string' }, { type: 'array' }, { type: 'object' }]
},
status: { type: 'string' },
message: { type: 'string', maxLength: 2 }
}
@ -236,6 +238,12 @@ export const isValidTokenBalance = (response: JsonRpcResponse) =>
export const isValidTransactionCount = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Transaction Count');
export const isValidTransactionByHash = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Transaction By Hash');
export const isValidTransactionReceipt = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Transaction Receipt');
export const isValidCurrentBlock = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Current Block');
@ -253,3 +261,6 @@ export const isValidGetAccounts = (response: JsonRpcResponse) =>
export const isValidGetNetVersion = (response: JsonRpcResponse) =>
isValidEthCall(response, schema.RpcNode)('Net Version');
export const isValidTxHash = (hash: string) =>
hash.substring(0, 2) === '0x' && hash.length === 66 && isValidHex(hash);

View File

@ -17,11 +17,13 @@ export type State = { [key in StaticNetworkIds]: StaticNetworkConfig };
// Must be a website that follows the ethplorer convention of /tx/[hash] and
// address/[address] to generate the correct functions.
// TODO: put this in utils / libs
export function makeExplorer(origin: string): BlockExplorerConfig {
export function makeExplorer(name: string, origin: string): BlockExplorerConfig {
return {
name,
origin,
txUrl: hash => `${origin}/tx/${hash}`,
addressUrl: address => `${origin}/address/${address}`
addressUrl: address => `${origin}/address/${address}`,
blockUrl: blockNum => `${origin}/block/${blockNum}`
};
}
@ -32,7 +34,7 @@ const INITIAL_STATE: State = {
chainId: 1,
isCustom: false,
color: '#0e97c0',
blockExplorer: makeExplorer('https://etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://etherscan.io'),
tokenExplorer: {
name: ethPlorer,
address: ETHTokenExplorer
@ -51,7 +53,7 @@ const INITIAL_STATE: State = {
chainId: 3,
isCustom: false,
color: '#adc101',
blockExplorer: makeExplorer('https://ropsten.etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://ropsten.etherscan.io'),
tokens: require('config/tokens/ropsten.json'),
contracts: require('config/contracts/ropsten.json'),
isTestnet: true,
@ -67,7 +69,7 @@ const INITIAL_STATE: State = {
chainId: 42,
isCustom: false,
color: '#adc101',
blockExplorer: makeExplorer('https://kovan.etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://kovan.etherscan.io'),
tokens: require('config/tokens/ropsten.json'),
contracts: require('config/contracts/ropsten.json'),
isTestnet: true,
@ -83,7 +85,7 @@ const INITIAL_STATE: State = {
chainId: 4,
isCustom: false,
color: '#adc101',
blockExplorer: makeExplorer('https://rinkeby.etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://rinkeby.etherscan.io'),
tokens: require('config/tokens/rinkeby.json'),
contracts: require('config/contracts/rinkeby.json'),
isTestnet: true,
@ -99,7 +101,7 @@ const INITIAL_STATE: State = {
chainId: 61,
isCustom: false,
color: '#669073',
blockExplorer: makeExplorer('https://gastracker.io'),
blockExplorer: makeExplorer('GasTracker', 'https://gastracker.io'),
tokens: require('config/tokens/etc.json'),
contracts: require('config/contracts/etc.json'),
dPathFormats: {
@ -114,7 +116,7 @@ const INITIAL_STATE: State = {
chainId: 8,
isCustom: false,
color: '#b37aff',
blockExplorer: makeExplorer('https://ubiqscan.io/en'),
blockExplorer: makeExplorer('Ubiqscan', 'https://ubiqscan.io/en'),
tokens: require('config/tokens/ubq.json'),
contracts: require('config/contracts/ubq.json'),
dPathFormats: {
@ -129,7 +131,7 @@ const INITIAL_STATE: State = {
chainId: 2,
isCustom: false,
color: '#673ab7',
blockExplorer: makeExplorer('https://www.gander.tech'),
blockExplorer: makeExplorer('Gander', 'https://www.gander.tech'),
tokens: require('config/tokens/exp.json'),
contracts: require('config/contracts/exp.json'),
dPathFormats: {

View File

@ -23,14 +23,14 @@ export const INITIAL_STATE: State = {
network: 'ETH',
isCustom: false,
service: 'infura.io',
lib: new InfuraNode('https://mainnet.infura.io/mew'),
lib: new InfuraNode('https://mainnet.infura.io/mycrypto'),
estimateGas: false
},
rop_infura: {
network: 'Ropsten',
isCustom: false,
service: 'infura.io',
lib: new InfuraNode('https://ropsten.infura.io/mew'),
lib: new InfuraNode('https://ropsten.infura.io/mycrypto'),
estimateGas: false
},
kov_ethscan: {
@ -51,7 +51,7 @@ export const INITIAL_STATE: State = {
network: 'Rinkeby',
isCustom: false,
service: 'infura.io',
lib: new InfuraNode('https://rinkeby.infura.io/mew'),
lib: new InfuraNode('https://rinkeby.infura.io/mycrypto'),
estimateGas: false
},
etc_epool: {

38
common/reducers/gas.ts Normal file
View File

@ -0,0 +1,38 @@
import { SetGasEstimatesAction, GasAction, TypeKeys } from 'actions/gas';
import { GasEstimates } from 'api/gas';
export interface State {
estimates: GasEstimates | null;
isEstimating: boolean;
}
export const INITIAL_STATE: State = {
estimates: null,
isEstimating: false
};
function fetchGasEstimates(state: State): State {
return {
...state,
isEstimating: true
};
}
function setGasEstimates(state: State, action: SetGasEstimatesAction): State {
return {
...state,
estimates: action.payload,
isEstimating: false
};
}
export function gas(state: State = INITIAL_STATE, action: GasAction): State {
switch (action.type) {
case TypeKeys.GAS_FETCH_ESTIMATES:
return fetchGasEstimates(state);
case TypeKeys.GAS_SET_ESTIMATES:
return setGasEstimates(state, action);
default:
return state;
}
}

View File

@ -9,7 +9,9 @@ import { rates, State as RatesState } from './rates';
import { State as SwapState, swap } from './swap';
import { State as WalletState, wallet } from './wallet';
import { State as TransactionState, transaction } from './transaction';
import { State as GasState, gas } from './gas';
import { onboardStatus, State as OnboardStatusState } from './onboardStatus';
import { State as TransactionsState, transactions } from './transactions';
export interface AppState {
// Custom reducers
@ -21,11 +23,12 @@ export interface AppState {
customTokens: CustomTokensState;
rates: RatesState;
deterministicWallets: DeterministicWalletsState;
// Third party reducers (TODO: Fill these out)
form: any;
routing: any;
swap: SwapState;
transaction: TransactionState;
transactions: TransactionsState;
gas: GasState;
// Third party reducers (TODO: Fill these out)
routing: any;
}
export default combineReducers<AppState>({
@ -38,6 +41,8 @@ export default combineReducers<AppState>({
customTokens,
rates,
deterministicWallets,
routing: routerReducer,
transaction
transaction,
transactions,
gas,
routing: routerReducer
});

View File

@ -11,6 +11,7 @@ import {
import { Reducer } from 'redux';
import { State } from './typings';
import { gasPricetoBase } from 'libs/units';
import { gasPriceDefaults } from 'config';
const INITIAL_STATE: State = {
to: { raw: '', value: null },
@ -18,7 +19,10 @@ const INITIAL_STATE: State = {
nonce: { raw: '', value: null },
value: { raw: '', value: null },
gasLimit: { raw: '21000', value: new BN(21000) },
gasPrice: { raw: '21', value: gasPricetoBase(21) }
gasPrice: {
raw: gasPriceDefaults.default.toString(),
value: gasPricetoBase(gasPriceDefaults.default)
}
};
const updateField = (key: keyof State): Reducer<State> => (state: State, action: FieldAction) => ({

View File

@ -0,0 +1,63 @@
import {
FetchTransactionDataAction,
SetTransactionDataAction,
TransactionsAction,
TypeKeys
} from 'actions/transactions';
import { TransactionData, TransactionReceipt } from 'libs/nodes';
export interface TransactionState {
data: TransactionData | null;
receipt: TransactionReceipt | null;
error: string | null;
isLoading: boolean;
}
export interface State {
txData: { [txhash: string]: TransactionState };
}
export const INITIAL_STATE: State = {
txData: {}
};
function fetchTxData(state: State, action: FetchTransactionDataAction): State {
return {
...state,
txData: {
...state.txData,
[action.payload]: {
data: null,
receipt: null,
error: null,
isLoading: true
}
}
};
}
function setTxData(state: State, action: SetTransactionDataAction): State {
return {
...state,
txData: {
...state.txData,
[action.payload.txhash]: {
data: action.payload.data,
receipt: action.payload.receipt,
error: action.payload.error,
isLoading: false
}
}
};
}
export function transactions(state: State = INITIAL_STATE, action: TransactionsAction): State {
switch (action.type) {
case TypeKeys.TRANSACTIONS_FETCH_TRANSACTION_DATA:
return fetchTxData(state, action);
case TypeKeys.TRANSACTIONS_SET_TRANSACTION_DATA:
return setTxData(state, action);
default:
return state;
}
}

53
common/sagas/gas.ts Normal file
View File

@ -0,0 +1,53 @@
import { setGasEstimates, TypeKeys } from 'actions/gas';
import { SagaIterator } from 'redux-saga';
import { call, put, select, takeLatest } from 'redux-saga/effects';
import { AppState } from 'reducers';
import { fetchGasEstimates, GasEstimates } from 'api/gas';
import { gasPriceDefaults, gasEstimateCacheTime } from 'config';
import { getEstimates } from 'selectors/gas';
import { getOffline } from 'selectors/config';
export function* setDefaultEstimates(): SagaIterator {
// Must yield time for testability
const time = yield call(Date.now);
yield put(
setGasEstimates({
safeLow: gasPriceDefaults.minGwei,
standard: gasPriceDefaults.default,
fast: gasPriceDefaults.default,
fastest: gasPriceDefaults.maxGwei,
isDefault: true,
time
})
);
}
export function* fetchEstimates(): SagaIterator {
// Don't even try offline
const isOffline: boolean = yield select(getOffline);
if (isOffline) {
yield call(setDefaultEstimates);
return;
}
// Cache estimates for a bit
const oldEstimates: AppState['gas']['estimates'] = yield select(getEstimates);
if (oldEstimates && oldEstimates.time + gasEstimateCacheTime > Date.now()) {
yield put(setGasEstimates(oldEstimates));
return;
}
// Try to fetch new estimates
try {
const estimates: GasEstimates = yield call(fetchGasEstimates);
yield put(setGasEstimates(estimates));
} catch (err) {
console.warn('Failed to fetch gas estimates:', err);
yield call(setDefaultEstimates);
}
}
export default function* gas(): SagaIterator {
yield takeLatest(TypeKeys.GAS_FETCH_ESTIMATES, fetchEstimates);
}

View File

@ -15,6 +15,8 @@ import { getBityRatesSaga, getShapeShiftRatesSaga, swapProviderSaga } from './sw
import wallet from './wallet';
import { ens } from './ens';
import { transaction } from './transaction';
import transactions from './transactions';
import gas from './gas';
export default {
ens,
@ -33,5 +35,7 @@ export default {
transaction,
deterministicWallets,
swapProviderSaga,
rates
rates,
transactions,
gas
};

View File

@ -0,0 +1,39 @@
import { setTransactionData, FetchTransactionDataAction, TypeKeys } from 'actions/transactions';
import { SagaIterator } from 'redux-saga';
import { put, select, apply, takeEvery } from 'redux-saga/effects';
import { getNodeLib } from 'selectors/config';
import { INode, TransactionData, TransactionReceipt } from 'libs/nodes';
export function* fetchTxData(action: FetchTransactionDataAction): SagaIterator {
const txhash = action.payload;
let data: TransactionData | null = null;
let receipt: TransactionReceipt | null = null;
let error: string | null = null;
const node: INode = yield select(getNodeLib);
// Fetch data and receipt separately, not in parallel. Receipt should only be
// fetched if the tx is mined, and throws if it's not, but that's not really
// an "error", since we'd still want to show the unmined tx data.
try {
data = yield apply(node, node.getTransactionByHash, [txhash]);
} catch (err) {
console.warn('Failed to fetch transaction data', err);
error = err.message;
}
if (data && data.blockHash) {
try {
receipt = yield apply(node, node.getTransactionReceipt, [txhash]);
} catch (err) {
console.warn('Failed to fetch transaction receipt', err);
receipt = null;
}
}
yield put(setTransactionData({ txhash, data, receipt, error }));
}
export default function* transactions(): SagaIterator {
yield takeEvery(TypeKeys.TRANSACTIONS_FETCH_TRANSACTION_DATA, fetchTxData);
}

View File

@ -5,6 +5,15 @@ $handle-size: 22px;
$speed: 70ms;
$tooltip-bg: rgba(#222, 0.95);
@keyframes slider-loading {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
}
.rc-slider {
&-rail {
background: $gray-lighter;
@ -39,4 +48,20 @@ $tooltip-bg: rgba(#222, 0.95);
border-radius: 3px;
}
}
// Disabled styles
&-disabled {
background: none;
.rc-slider {
&-handle,
&-track {
display: none;
}
&-rail {
animation: slider-loading 1s ease infinite;
}
}
}
}

View File

@ -57,3 +57,7 @@ hr {
border: 0;
border-top: 1px solid $hr-border;
}
#app {
overflow-x: hidden;
}

5
common/selectors/gas.ts Normal file
View File

@ -0,0 +1,5 @@
import { AppState } from 'reducers';
const getGas = (state: AppState) => state.gas;
export const getEstimates = (state: AppState) => getGas(state).estimates;
export const getIsEstimating = (state: AppState) => getGas(state).isEstimating;

View File

@ -0,0 +1,5 @@
import { AppState } from 'reducers';
export function getTransactionDatas(state: AppState) {
return state.transactions.txData;
}

View File

@ -654,7 +654,3 @@
"ONBOARD_resume": "It looks like you didn't finish reading through these slides last time. ProTip: Finish reading through the slides 😉"
}
}

View File

@ -1,4 +1,6 @@
import BN from 'bn.js';
import { Wei } from 'libs/units';
import { stripHexPrefix } from 'libs/values';
export function toFixedIfLarger(num: number, fixedSize: number = 6): string {
return parseFloat(num.toFixed(fixedSize)).toString();
@ -113,3 +115,7 @@ export function bytesToHuman(bytes: number) {
export function ensV3Url(name: string) {
return `https://mycrypto.com/?ensname=${name}#ens`;
}
export function hexToNumber(hex: string) {
return new BN(stripHexPrefix(hex)).toNumber();
}

View File

@ -1,7 +1,7 @@
{
"name": "MyCrypto",
"author": "MyCryptoHQ",
"version": "0.2.1",
"version": "0.3.0",
"main": "main.js",
"description": "MyCrypto web and electron app",
"repository": "https://github.com/MyCryptoHQ/MyCrypto",
@ -37,7 +37,7 @@
"react": "16.2.0",
"react-dom": "16.2.0",
"react-markdown": "3.2.0",
"react-redux": "5.0.6",
"react-redux": "5.0.7",
"react-router-dom": "4.2.2",
"react-router-redux": "4.0.8",
"react-select": "1.2.1",
@ -55,17 +55,17 @@
"devDependencies": {
"@types/classnames": "2.2.3",
"@types/history": "4.6.2",
"@types/jest": "22.1.2",
"@types/lodash": "4.14.102",
"@types/jest": "22.1.3",
"@types/lodash": "4.14.104",
"@types/qrcode": "0.8.0",
"@types/qrcode.react": "0.6.3",
"@types/query-string": "5.0.1",
"@types/react": "16.0.37",
"@types/query-string": "5.1.0",
"@types/react": "16.0.38",
"@types/react-dom": "16.0.4",
"@types/react-redux": "5.0.15",
"@types/react-router-dom": "4.2.4",
"@types/react-router-redux": "5.0.12",
"@types/react-select": "1.2.2",
"@types/react-select": "1.2.3",
"@types/redux-logger": "3.0.5",
"@types/uuid": "3.4.3",
"@types/webpack-env": "1.13.4",
@ -80,7 +80,7 @@
"copy-webpack-plugin": "4.4.1",
"css-loader": "0.28.9",
"electron": "1.8.2",
"electron-builder": "19.56.0",
"electron-builder": "20.0.4",
"empty": "0.10.1",
"enzyme": "3.3.0",
"enzyme-adapter-react-16": "1.1.1",
@ -91,7 +91,7 @@
"file-loader": "1.1.6",
"friendly-errors-webpack-plugin": "1.6.1",
"glob": "7.1.2",
"hoist-non-react-statics": "2.3.1",
"hoist-non-react-statics": "2.5.0",
"html-webpack-plugin": "2.30.1",
"husky": "0.14.3",
"image-webpack-loader": "4.1.0",
@ -99,10 +99,10 @@
"klaw-sync": "3.0.2",
"less": "2.7.3",
"less-loader": "4.0.5",
"lint-staged": "6.1.0",
"lint-staged": "6.1.1",
"minimist": "1.2.0",
"node-sass": "4.7.2",
"nodemon": "1.14.12",
"nodemon": "1.15.0",
"null-loader": "0.1.1",
"prettier": "1.10.2",
"progress": "2.0.0",
@ -113,14 +113,14 @@
"resolve-url-loader": "2.2.1",
"rimraf": "2.6.2",
"sass-loader": "6.0.6",
"style-loader": "0.20.0",
"style-loader": "0.20.2",
"thread-loader": "1.1.2",
"ts-jest": "22.0.4",
"ts-loader": "3.3.1",
"tslint": "5.9.1",
"tslint-config-prettier": "1.7.0",
"tslint-microsoft-contrib": "5.0.2",
"tslint-react": "3.5.0",
"tslint-config-prettier": "1.8.0",
"tslint-microsoft-contrib": "5.0.3",
"tslint-react": "3.5.1",
"types-rlp": "0.0.1",
"typescript": "2.6.2",
"url-loader": "0.6.2",
@ -129,7 +129,7 @@
"webpack-dev-middleware": "2.0.5",
"webpack-hot-middleware": "2.21.0",
"webpack-sources": "1.0.1",
"webpack-subresource-integrity": "1.0.3",
"webpack-subresource-integrity": "1.0.4",
"worker-loader": "1.1.0",
"what-input": "5.0.5"
},

View File

@ -3,9 +3,11 @@ import { StaticNetworksState, CustomNetworksState } from 'reducers/config/networ
type StaticNetworkIds = 'ETH' | 'Ropsten' | 'Kovan' | 'Rinkeby' | 'ETC' | 'UBQ' | 'EXP';
interface BlockExplorerConfig {
name: string;
origin: string;
txUrl(txHash: string): string;
addressUrl(address: string): string;
blockUrl(blockNum: string | number): string;
}
interface Token {
@ -32,7 +34,7 @@ interface StaticNetworkConfig {
name: StaticNetworkIds;
unit: string;
color?: string;
blockExplorer?: BlockExplorerConfig;
blockExplorer: BlockExplorerConfig;
tokenExplorer?: {
name: string;
address(address: string): string;

View File

@ -18,7 +18,7 @@ const expectedInitialState = {
chainId: 1,
isCustom: false,
color: '#0e97c0',
blockExplorer: makeExplorer('https://etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://etherscan.io'),
tokenExplorer: {
name: ethPlorer,
address: ETHTokenExplorer
@ -37,7 +37,7 @@ const expectedInitialState = {
chainId: 3,
isCustom: false,
color: '#adc101',
blockExplorer: makeExplorer('https://ropsten.etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://ropsten.etherscan.io'),
tokens: require('config/tokens/ropsten.json'),
contracts: require('config/contracts/ropsten.json'),
isTestnet: true,
@ -53,7 +53,7 @@ const expectedInitialState = {
chainId: 42,
isCustom: false,
color: '#adc101',
blockExplorer: makeExplorer('https://kovan.etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://kovan.etherscan.io'),
tokens: require('config/tokens/ropsten.json'),
contracts: require('config/contracts/ropsten.json'),
isTestnet: true,
@ -69,7 +69,7 @@ const expectedInitialState = {
chainId: 4,
isCustom: false,
color: '#adc101',
blockExplorer: makeExplorer('https://rinkeby.etherscan.io'),
blockExplorer: makeExplorer('Etherscan', 'https://rinkeby.etherscan.io'),
tokens: require('config/tokens/rinkeby.json'),
contracts: require('config/contracts/rinkeby.json'),
isTestnet: true,
@ -85,7 +85,7 @@ const expectedInitialState = {
chainId: 61,
isCustom: false,
color: '#669073',
blockExplorer: makeExplorer('https://gastracker.io'),
blockExplorer: makeExplorer('GasTracker', 'https://gastracker.io'),
tokens: require('config/tokens/etc.json'),
contracts: require('config/contracts/etc.json'),
dPathFormats: {
@ -100,7 +100,7 @@ const expectedInitialState = {
chainId: 8,
isCustom: false,
color: '#b37aff',
blockExplorer: makeExplorer('https://ubiqscan.io/en'),
blockExplorer: makeExplorer('Ubiqscan', 'https://ubiqscan.io/en'),
tokens: require('config/tokens/ubq.json'),
contracts: require('config/contracts/ubq.json'),
dPathFormats: {
@ -115,7 +115,7 @@ const expectedInitialState = {
chainId: 2,
isCustom: false,
color: '#673ab7',
blockExplorer: makeExplorer('https://www.gander.tech'),
blockExplorer: makeExplorer('Gander', 'https://www.gander.tech'),
tokens: require('config/tokens/exp.json'),
contracts: require('config/contracts/exp.json'),
dPathFormats: {

View File

@ -23,14 +23,14 @@ const expectedInitialState = {
network: 'ETH',
isCustom: false,
service: 'infura.io',
lib: new InfuraNode('https://mainnet.infura.io/mew'),
lib: new InfuraNode('https://mainnet.infura.io/mycrypto'),
estimateGas: false
},
rop_infura: {
network: 'Ropsten',
isCustom: false,
service: 'infura.io',
lib: new InfuraNode('https://ropsten.infura.io/mew'),
lib: new InfuraNode('https://ropsten.infura.io/mycrypto'),
estimateGas: false
},
kov_ethscan: {
@ -51,7 +51,7 @@ const expectedInitialState = {
network: 'Rinkeby',
isCustom: false,
service: 'infura.io',
lib: new InfuraNode('https://rinkeby.infura.io/mew'),
lib: new InfuraNode('https://rinkeby.infura.io/mycrypto'),
estimateGas: false
},
etc_epool: {

30
spec/reducers/gas.spec.ts Normal file
View File

@ -0,0 +1,30 @@
import { gas, INITIAL_STATE } from 'reducers/gas';
import { fetchGasEstimates, setGasEstimates } from 'actions/gas';
import { GasEstimates } from 'api/gas';
describe('gas reducer', () => {
it('should handle GAS_FETCH_ESTIMATES', () => {
const state = gas(undefined, fetchGasEstimates());
expect(state).toEqual({
...INITIAL_STATE,
isEstimating: true
});
});
it('should handle GAS_SET_ESTIMATES', () => {
const estimates: GasEstimates = {
safeLow: 1,
standard: 1,
fast: 4,
fastest: 20,
time: Date.now(),
isDefault: false
};
const state = gas(undefined, setGasEstimates(estimates));
expect(state).toEqual({
...INITIAL_STATE,
estimates,
isEstimating: false
});
});
});

94
spec/sagas/gas.spec.ts Normal file
View File

@ -0,0 +1,94 @@
import { fetchEstimates, setDefaultEstimates } from 'sagas/gas';
import { call, put, select } from 'redux-saga/effects';
import { cloneableGenerator } from 'redux-saga/utils';
import { fetchGasEstimates, GasEstimates } from 'api/gas';
import { setGasEstimates } from 'actions/gas';
import { getEstimates } from 'selectors/gas';
import { getOffline } from 'selectors/config';
import { gasPriceDefaults, gasEstimateCacheTime } from 'config';
describe('fetchEstimates*', () => {
const gen = cloneableGenerator(fetchEstimates)();
const offline = false;
const oldEstimates: GasEstimates = {
safeLow: 1,
standard: 1,
fast: 4,
fastest: 20,
time: Date.now() - gasEstimateCacheTime - 1000,
isDefault: false
};
const newEstimates: GasEstimates = {
safeLow: 2,
standard: 2,
fast: 8,
fastest: 80,
time: Date.now(),
isDefault: false
};
it('Should select getOffline', () => {
expect(gen.next().value).toEqual(select(getOffline));
});
it('Should use default estimates if offline', () => {
const offlineGen = gen.clone();
expect(offlineGen.next(true).value).toEqual(call(setDefaultEstimates));
expect(offlineGen.next().done).toBeTruthy();
});
it('Should select getEstimates', () => {
expect(gen.next(offline).value).toEqual(select(getEstimates));
});
it('Should use cached estimates if theyre recent', () => {
const cachedGen = gen.clone();
const cacheEstimate = {
...oldEstimates,
time: Date.now() - gasEstimateCacheTime + 1000
};
expect(cachedGen.next(cacheEstimate).value).toEqual(put(setGasEstimates(cacheEstimate)));
expect(cachedGen.next().done).toBeTruthy();
});
it('Should fetch new estimates', () => {
expect(gen.next(oldEstimates).value).toEqual(call(fetchGasEstimates));
});
it('Should use default estimates if request fails', () => {
const failedReqGen = gen.clone();
// Not sure why, but typescript seems to think throw might be missing.
if (failedReqGen.throw) {
expect(failedReqGen.throw('test').value).toEqual(call(setDefaultEstimates));
expect(failedReqGen.next().done).toBeTruthy();
} else {
throw new Error('SagaIterator didnt have throw');
}
});
it('Should use fetched estimates', () => {
expect(gen.next(newEstimates).value).toEqual(put(setGasEstimates(newEstimates)));
expect(gen.next().done).toBeTruthy();
});
});
describe('setDefaultEstimates*', () => {
const gen = cloneableGenerator(setDefaultEstimates)();
it('Should put setGasEstimates with config defaults', () => {
const time = Date.now();
gen.next();
expect(gen.next(time).value).toEqual(
put(
setGasEstimates({
safeLow: gasPriceDefaults.minGwei,
standard: gasPriceDefaults.default,
fast: gasPriceDefaults.default,
fastest: gasPriceDefaults.maxGwei,
isDefault: true,
time
})
)
);
});
});