Check Transaction page (Pt. 1 - The Basics) (#1099)

* Component layer and routing for transaction status.

* Initial start on redux for transactions.

* Initial crack at reducer / actions / saga for transactions.

* Finish off check transaction saga, reducer, component, and page.
This commit is contained in:
William O'Beirne 2018-02-16 11:57:23 -05:00 committed by Daniel Ternyak
parent f46df010db
commit be61d804e0
35 changed files with 825 additions and 37 deletions

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

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

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

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

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

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

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

@ -10,6 +10,7 @@ import { State as SwapState, swap } from './swap';
import { State as WalletState, wallet } from './wallet';
import { State as TransactionState, transaction } from './transaction';
import { onboardStatus, State as OnboardStatusState } from './onboardStatus';
import { State as TransactionsState, transactions } from './transactions';
export interface AppState {
// Custom reducers
@ -21,11 +22,11 @@ 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;
// Third party reducers (TODO: Fill these out)
routing: any;
}
export default combineReducers<AppState>({
@ -38,6 +39,7 @@ export default combineReducers<AppState>({
customTokens,
rates,
deterministicWallets,
routing: routerReducer,
transaction
transaction,
transactions,
routing: routerReducer
});

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

View File

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

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

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

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