Trezor Unlock + Deterministic Wallet Groundwork (#137)

* Basic reducer / action / saga setup, rendering wallets.

* Better address rows, with values.

* Styling + back and next buttons.

* Formatting, dpath changing.

* Derived -> Deterministic

* Set wallet on confirm.

* Flesh out Trezor wallet, add transaction signing.

* Custom dpath, better handling of canceled switches and over-rendering / prop calling.

* Token empty string value.

* Move DPaths to config file.

* Clarifying comments.
This commit is contained in:
William O'Beirne 2017-08-28 13:43:57 -04:00 committed by Daniel Ternyak
parent cfba08ccf4
commit 1d235cf67a
24 changed files with 2059 additions and 38 deletions

View File

@ -0,0 +1,102 @@
// @flow
import type Big from 'bignumber.js';
export type TokenValues = { [string]: ?Big };
export type DeterministicWalletData = {
index: number,
address: string,
value?: Big,
tokenValues: TokenValues
};
/*** Get determinstic wallets ***/
export type GetDeterministicWalletsAction = {
type: 'DW_GET_WALLETS',
payload: {
dPath: string,
publicKey: string,
chainCode: string,
limit: number,
offset: number
}
};
export type GetDeterministicWalletsArgs = {
dPath: string,
publicKey: string,
chainCode: string,
limit?: number,
offset?: number
};
export function getDeterministicWallets(
args: GetDeterministicWalletsArgs
): GetDeterministicWalletsAction {
const { dPath, publicKey, chainCode, limit, offset } = args;
return {
type: 'DW_GET_WALLETS',
payload: {
dPath,
publicKey,
chainCode,
limit: limit || 5,
offset: offset || 0
}
};
}
/*** Set deterministic wallets ***/
export type SetDeterministicWalletsAction = {
type: 'DW_SET_WALLETS',
payload: DeterministicWalletData[]
};
export function setDeterministicWallets(
wallets: DeterministicWalletData[]
): SetDeterministicWalletsAction {
return {
type: 'DW_SET_WALLETS',
payload: wallets
};
}
/*** Set desired token ***/
export type SetDesiredTokenAction = {
type: 'DW_SET_DESIRED_TOKEN',
payload: ?string
};
export function setDesiredToken(token: ?string): SetDesiredTokenAction {
return {
type: 'DW_SET_DESIRED_TOKEN',
payload: token
};
}
/*** Set wallet values ***/
export type UpdateDeterministicWalletArgs = {
address: string,
value: ?Big,
tokenValues: ?TokenValues
};
export type UpdateDeterministicWalletAction = {
type: 'DW_UPDATE_WALLET',
payload: UpdateDeterministicWalletArgs
};
export function updateDeterministicWallet(
args: UpdateDeterministicWalletArgs
): UpdateDeterministicWalletAction {
return {
type: 'DW_UPDATE_WALLET',
payload: args
};
}
/*** Union Type ***/
export type DeterministicWalletAction =
| GetDeterministicWalletsAction
| UpdateDeterministicWalletAction
| SetDesiredTokenAction;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" title="external link icon" width="16" height="16" viewBox="0 0 511.626 511.627"><path fill="#0e97c0" d="M392.857 292.354h-18.274c-2.669 0-4.859.855-6.563 2.573-1.718 1.708-2.573 3.897-2.573 6.563v91.361c0 12.563-4.47 23.315-13.415 32.262-8.945 8.945-19.701 13.414-32.264 13.414H82.224c-12.562 0-23.317-4.469-32.264-13.414-8.945-8.946-13.417-19.698-13.417-32.262V155.31c0-12.562 4.471-23.313 13.417-32.259 8.947-8.947 19.702-13.418 32.264-13.418h200.994c2.669 0 4.859-.859 6.57-2.57 1.711-1.713 2.566-3.9 2.566-6.567V82.221c0-2.662-.855-4.853-2.566-6.563-1.711-1.713-3.901-2.568-6.57-2.568H82.224c-22.648 0-42.016 8.042-58.102 24.125C8.042 113.297 0 132.665 0 155.313v237.542c0 22.647 8.042 42.018 24.123 58.095 16.086 16.084 35.454 24.13 58.102 24.13h237.543c22.647 0 42.017-8.046 58.101-24.13 16.085-16.077 24.127-35.447 24.127-58.095v-91.358c0-2.669-.856-4.859-2.574-6.57-1.713-1.718-3.903-2.573-6.565-2.573z"/><path fill="#0e97c0" d="M506.199 41.971c-3.617-3.617-7.905-5.424-12.85-5.424H347.171c-4.948 0-9.233 1.807-12.847 5.424-3.617 3.615-5.428 7.898-5.428 12.847s1.811 9.233 5.428 12.85l50.247 50.248-186.147 186.151c-1.906 1.903-2.856 4.093-2.856 6.563 0 2.479.953 4.668 2.856 6.571l32.548 32.544c1.903 1.903 4.093 2.852 6.567 2.852s4.665-.948 6.567-2.852l186.148-186.148 50.251 50.248c3.614 3.617 7.898 5.426 12.847 5.426s9.233-1.809 12.851-5.426c3.617-3.616 5.424-7.898 5.424-12.847V54.818c-.001-4.952-1.814-9.232-5.428-12.847z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,314 @@
// @flow
import './DeterministicWalletsModal.scss';
import React from 'react';
import { connect } from 'react-redux';
import Modal from 'components/ui/Modal';
import {
getDeterministicWallets,
setDesiredToken
} from 'actions/deterministicWallets';
import { toUnit } from 'libs/units';
import { getNetworkConfig } from 'selectors/config';
import { getTokens } from 'selectors/wallet';
import { isValidPath } from 'libs/validators';
import type {
DeterministicWalletData,
GetDeterministicWalletsArgs,
GetDeterministicWalletsAction,
SetDesiredTokenAction
} from 'actions/deterministicWallets';
import type { NetworkConfig, Token } from 'config/data';
const WALLETS_PER_PAGE = 5;
type Props = {
// Redux state
wallets: DeterministicWalletData[],
desiredToken: string,
network: NetworkConfig,
tokens: Token[],
// Redux actions
getDeterministicWallets: GetDeterministicWalletsArgs => GetDeterministicWalletsAction,
setDesiredToken: (tkn: ?string) => SetDesiredTokenAction,
// Passed props
isOpen?: boolean,
walletType: ?string,
dPath: string,
dPaths: { label: string, value: string }[],
publicKey: string,
chainCode: string,
onCancel: () => void,
onConfirmAddress: string => void,
onPathChange: string => void
};
type State = {
selectedAddress: string,
isCustomPath: boolean,
customPath: string,
page: number
};
class DeterministicWalletsModal extends React.Component {
props: Props;
state: State = {
selectedAddress: '',
isCustomPath: false,
customPath: '',
page: 0
};
componentDidMount() {
this._getAddresses();
}
componentWillReceiveProps(nextProps) {
const { publicKey, chainCode } = this.props;
if (
nextProps.publicKey !== publicKey ||
nextProps.chainCode !== chainCode
) {
this._getAddresses(nextProps);
}
}
_getAddresses(props: Props = this.props) {
const { dPath, publicKey, chainCode } = props;
if (dPath && publicKey && chainCode && isValidPath(dPath)) {
this.props.getDeterministicWallets({
dPath,
publicKey,
chainCode,
limit: WALLETS_PER_PAGE,
offset: WALLETS_PER_PAGE * this.state.page
});
}
}
_handleChangePath = (ev: SyntheticInputEvent) => {
const { value } = ev.target;
if (value === 'custom') {
this.setState({ isCustomPath: true });
} else {
this.setState({ isCustomPath: false });
if (this.props.dPath !== value) {
this.props.onPathChange(value);
}
}
};
_handleChangeCustomPath = (ev: SyntheticInputEvent) => {
this.setState({ customPath: ev.target.value });
};
_handleSubmitCustomPath = (ev: SyntheticInputEvent) => {
ev.preventDefault();
if (!isValidPath(this.state.customPath)) return;
this.props.onPathChange(this.state.customPath);
};
_handleChangeToken = (ev: SyntheticInputEvent) => {
this.props.setDesiredToken(ev.target.value || null);
};
_handleConfirmAddress = () => {
if (this.state.selectedAddress) {
this.props.onConfirmAddress(this.state.selectedAddress);
}
};
_selectAddress(selectedAddress) {
this.setState({ selectedAddress });
}
_nextPage = () => {
this.setState({ page: this.state.page + 1 }, this._getAddresses);
};
_prevPage = () => {
this.setState(
{ page: Math.max(this.state.page - 1, 0) },
this._getAddresses
);
};
_renderWalletRow(wallet) {
const { desiredToken, network } = this.props;
const { selectedAddress } = this.state;
// Get renderable values, but keep 'em short
const value = wallet.value
? toUnit(wallet.value, 'wei', 'ether').toPrecision(4)
: '';
const tokenValue = wallet.tokenValues[desiredToken]
? wallet.tokenValues[desiredToken].toPrecision(4)
: '';
return (
<tr
key={wallet.address}
onClick={this._selectAddress.bind(this, wallet.address)}
>
<td>
{wallet.index + 1}
</td>
<td className="DWModal-addresses-table-address">
<input
type="radio"
name="selectedAddress"
checked={selectedAddress === wallet.address}
value={wallet.address}
/>
{wallet.address}
</td>
<td>
{value} {network.unit}
</td>
<td>
{tokenValue} {desiredToken}
</td>
<td>
<a
target="_blank"
href={`https://ethplorer.io/address/${wallet.address}`}
>
<i className="DWModal-addresses-table-more" />
</a>
</td>
</tr>
);
}
render() {
const {
wallets,
desiredToken,
network,
tokens,
dPath,
dPaths,
onCancel,
walletType
} = this.props;
const { selectedAddress, isCustomPath, customPath, page } = this.state;
const validPathClass = isValidPath(customPath) ? 'is-valid' : 'is-invalid';
const buttons = [
{
text: 'Unlock this Address',
type: 'primary',
onClick: this._handleConfirmAddress,
disabled: !selectedAddress
},
{
text: 'Cancel',
type: 'default',
onClick: onCancel
}
];
return (
<Modal
title={`Unlock your ${walletType || ''} Wallet`}
isOpen={this.props.isOpen}
buttons={buttons}
handleClose={onCancel}
>
<div className="DWModal">
<form
className="DWModal-path form-group-sm"
onSubmit={this._handleSubmitCustomPath}
>
<span className="DWModal-path-label">Addresses for</span>
<select
className="form-control"
onChange={this._handleChangePath}
value={isCustomPath ? 'custom' : dPath}
>
{dPaths.map(dp =>
<option key={dp.value} value={dp.value}>
{dp.label}
</option>
)}
<option value="custom">Custom path...</option>
</select>
{isCustomPath &&
<input
className={`form-control ${validPathClass}`}
value={customPath}
placeholder="m/44'/60'/0'/0"
onChange={this._handleChangeCustomPath}
/>}
</form>
<div className="DWModal-addresses">
<table className="DWModal-addresses-table table table-striped table-hover">
<thead>
<tr>
<td>#</td>
<td>Address</td>
<td>
{network.unit}
</td>
<td>
<select
className="DWModal-addresses-table-token"
value={desiredToken}
onChange={this._handleChangeToken}
>
<option value="">-Token-</option>
{tokens.map(t =>
<option key={t.symbol} value={t.symbol}>
{t.symbol}
</option>
)}
</select>
</td>
<td>More</td>
</tr>
</thead>
<tbody>
{wallets.map(wallet => this._renderWalletRow(wallet))}
</tbody>
</table>
<div className="DWModal-addresses-nav">
<button
className="DWModal-addresses-nav-btn btn btn-sm btn-default"
disabled={page === 0}
onClick={this._prevPage}
>
Back
</button>
<button
className="DWModal-addresses-nav-btn btn btn-sm btn-default"
onClick={this._nextPage}
>
More
</button>
</div>
</div>
</div>
</Modal>
);
}
}
function mapStateToProps(state) {
return {
wallets: state.deterministicWallets.wallets,
desiredToken: state.deterministicWallets.desiredToken,
network: getNetworkConfig(state),
tokens: getTokens(state)
};
}
export default connect(mapStateToProps, {
getDeterministicWallets,
setDesiredToken
})(DeterministicWalletsModal);

View File

@ -0,0 +1,72 @@
@import "common/sass/variables";
@import "common/sass/mixins";
.DWModal {
width: 690px;
&-path {
display: block;
margin-bottom: 20px;
&-label {
font-size: $font-size-medium;
}
.form-control {
display: inline-block;
width: auto;
margin: 0 0 0 10px;
}
}
&-addresses {
&-table {
width: 100%;
text-align: center;
&-token {
width: 82px;
}
&-address {
font-size: 13px;
text-align: left;
font-family: $font-family-monospace;
}
&-more {
display: inline-block;
width: 16px;
height: 16px;
background-image: url('~assets/images/icon-external-link.svg');
}
tbody {
tr {
cursor: pointer;
}
td {
vertical-align: middle;
}
}
}
&-nav {
&-btn {
display: inline-block;
margin: 0;
width: 49.9%;
width: calc(50% - 5px);
margin: 0 5px;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
}
}
}

View File

@ -1,26 +1,123 @@
// @flow
import './Trezor.scss';
import React, { Component } from 'react'; import React, { Component } from 'react';
import translate from 'translations'; import translate from 'translations';
import TrezorConnect from 'vendor/trezor-connect';
import DeterministicWalletsModal from './DeterministicWalletsModal';
import TrezorWallet from 'libs/wallet/trezor';
import DPATHS from 'config/dpaths.json';
const DEFAULT_PATH = DPATHS.TREZOR[0].value;
type State = {
publicKey: string,
chainCode: string,
dPath: string,
error: ?string,
isLoading: boolean
};
export default class TrezorDecrypt extends Component { export default class TrezorDecrypt extends Component {
props: { onUnlock: any => void };
state: State = {
publicKey: '',
chainCode: '',
dPath: DEFAULT_PATH,
error: null,
isLoading: false
};
_handlePathChange = (dPath: string) => {
this._handleConnect(dPath);
};
_handleConnect = (dPath: string = this.state.dPath) => {
this.setState({
isLoading: true,
error: null
});
TrezorConnect.getXPubKey(
dPath,
res => {
if (res.success) {
this.setState({
dPath,
publicKey: res.publicKey,
chainCode: res.chainCode,
isLoading: false
});
} else {
this.setState({
error: res.error,
isLoading: false
});
}
},
'1.5.2'
);
};
_handleCancel = () => {
this.setState({
publicKey: '',
chainCode: '',
dPath: DEFAULT_PATH
});
};
_handleUnlock = (address: string) => {
this.props.onUnlock(new TrezorWallet(address, this.state.dPath));
};
render() { render() {
const { dPath, publicKey, chainCode, error, isLoading } = this.state;
const showErr = error ? 'is-showing' : '';
return ( return (
<section className="col-md-4 col-sm-6"> <section className="TrezorDecrypt col-md-4 col-sm-6">
<div id="selectedUploadKey"> <button
<h4>{translate('ADD_Radio_2_alt')}</h4> className="TrezorDecrypt-decrypt btn btn-primary btn-lg"
onClick={() => this._handleConnect()}
<div className="form-group"> disabled={isLoading}
<input type="file" id="fselector" />
<a
className="btn-file marg-v-sm"
id="aria1"
tabIndex="0"
role="button"
> >
{translate('ADD_Radio_2_short')} {isLoading ? 'Unlocking...' : translate('ADD_Trezor_scan')}
</button>
<div className="TrezorDecrypt-help">
Guide:{' '}
<a
href="https://blog.trezor.io/trezor-integration-with-myetherwallet-3e217a652e08"
target="_blank"
rel="noopener"
>
How to use TREZOR with MyEtherWallet
</a> </a>
</div> </div>
<div className={`TrezorDecrypt-error alert alert-danger ${showErr}`}>
{error || '-'}
</div> </div>
<a
className="TrezorDecrypt-buy btn btn-sm btn-default"
href="https://trezor.io/?a=myetherwallet.com"
target="_blank"
rel="noopener"
>
{translate('Dont have a TREZOR? Order one now!')}
</a>
<DeterministicWalletsModal
isOpen={!!publicKey && !!chainCode}
publicKey={publicKey}
chainCode={chainCode}
dPath={dPath}
dPaths={DPATHS.TREZOR}
onCancel={this._handleCancel}
onConfirmAddress={this._handleUnlock}
onPathChange={this._handlePathChange}
walletType={translate('x_Trezor')}
/>
</section> </section>
); );
} }

View File

@ -0,0 +1,26 @@
.TrezorDecrypt {
text-align: center;
padding-top: 30px;
&-decrypt {
width: 100%;
}
&-help {
margin-top: 10px;
font-size: 13px;
}
&-error {
opacity: 0;
transition: none;
&.is-showing {
opacity: 1;
}
}
&-buy {
margin-top: 10px;
}
}

View File

@ -9,7 +9,7 @@ import LedgerNanoSDecrypt from './LedgerNano';
import TrezorDecrypt from './Trezor'; import TrezorDecrypt from './Trezor';
import ViewOnlyDecrypt from './ViewOnly'; import ViewOnlyDecrypt from './ViewOnly';
import map from 'lodash/map'; import map from 'lodash/map';
import { unlockPrivateKey, unlockKeystore } from 'actions/wallet'; import { unlockPrivateKey, unlockKeystore, setWallet } from 'actions/wallet';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
const WALLETS = { const WALLETS = {
@ -44,7 +44,8 @@ const WALLETS = {
trezor: { trezor: {
lid: 'x_Trezor', lid: 'x_Trezor',
component: TrezorDecrypt, component: TrezorDecrypt,
disabled: true initialParams: {},
unlock: setWallet
}, },
'view-only': { 'view-only': {
lid: 'View with Address Only', lid: 'View with Address Only',
@ -162,9 +163,9 @@ export class WalletDecrypt extends Component {
this.setState({ value }); this.setState({ value });
}; };
onUnlock = () => { onUnlock = (payload: any) => {
this.props.dispatch( this.props.dispatch(
WALLETS[this.state.selectedWalletKey].unlock(this.state.value) WALLETS[this.state.selectedWalletKey].unlock(payload || this.state.value)
); );
}; };
} }

View File

@ -64,6 +64,7 @@ $m-anim-speed: 400ms;
display: none; display: none;
flex-direction: column; flex-direction: column;
animation: modal-open $m-anim-speed ease 1; animation: modal-open $m-anim-speed ease 1;
text-align: left;
&.is-open { &.is-open {
display: flex; display: flex;

16
common/config/dpaths.json Normal file
View File

@ -0,0 +1,16 @@
{
"TREZOR": [
{
"label": "TREZOR (ETH)",
"value": "m/44'/60'/0'/0"
},
{
"label": "TREZOR (ETC)",
"value": "m/44'/61'/0'/0"
},
{
"label": "Testnet",
"value": "m/44'/1'/0'/0"
}
]
}

View File

@ -40,9 +40,7 @@ export default class RpcNode extends BaseNode {
if (response.error) { if (response.error) {
return Big(0); return Big(0);
} }
return new Big(Number(response.result)).div( return new Big(response.result).div(new Big(10).pow(token.decimal));
new Big(10).pow(token.decimal)
);
}); });
} }
@ -55,9 +53,7 @@ export default class RpcNode extends BaseNode {
if (item.error) { if (item.error) {
return new Big(0); return new Big(0);
} }
return new Big(Number(item.result)).div( return new Big(item.result).div(new Big(10).pow(tokens[idx].decimal));
new Big(10).pow(tokens[idx].decimal)
);
}); });
}); });
} }

View File

@ -1,10 +1,11 @@
// @flow // @flow
import Big from 'bignumber.js'; import Big from 'bignumber.js';
import translate from 'translations'; import translate from 'translations';
import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util'; import { padToEven, addHexPrefix, toChecksumAddress } from 'ethereumjs-util';
import { isValidETHAddress } from 'libs/validators'; import { isValidETHAddress } from 'libs/validators';
import ERC20 from 'libs/erc20'; import ERC20 from 'libs/erc20';
import { toTokenUnit } from 'libs/units'; import { toTokenUnit } from 'libs/units';
import { stripHex } from 'libs/values';
import type BaseNode from 'libs/nodes/base'; import type BaseNode from 'libs/nodes/base';
import type { BaseWallet } from 'libs/wallet'; import type { BaseWallet } from 'libs/wallet';
import type { Token } from 'config/data'; import type { Token } from 'config/data';
@ -47,8 +48,8 @@ export function getTransactionFields(tx: EthTx) {
const [nonce, gasPrice, gasLimit, to, value, data, v, r, s] = tx.toJSON(); const [nonce, gasPrice, gasLimit, to, value, data, v, r, s] = tx.toJSON();
return { return {
// No value comes back as '0x', but most things expect '0x0' // No value comes back as '0x', but most things expect '0x00'
value: value === '0x' ? '0x0' : value, value: value === '0x' ? '0x00' : value,
// If data is 0x, it might as well not be there // If data is 0x, it might as well not be there
data: data === '0x' ? null : data, data: data === '0x' ? null : data,
// To address is unchecksummed, which could cause mismatches in comparisons // To address is unchecksummed, which could cause mismatches in comparisons
@ -119,15 +120,19 @@ export async function generateTransaction(
throw new Error(translate('GETH_Balance')); throw new Error(translate('GETH_Balance'));
} }
// Taken from v3's `sanitizeHex`, ensures that the value is a %2 === 0
// prefix'd hex value.
const cleanHex = hex => addHexPrefix(padToEven(stripHex(hex)));
// Generate the raw transaction // Generate the raw transaction
const txCount = await node.getTransactionCount(tx.from); const txCount = await node.getTransactionCount(tx.from);
const rawTx = { const rawTx = {
nonce: addHexPrefix(txCount), nonce: cleanHex(txCount),
gasPrice: addHexPrefix(new Big(tx.gasPrice).toString(16)), gasPrice: cleanHex(new Big(tx.gasPrice).toString(16)),
gasLimit: addHexPrefix(new Big(tx.gasLimit).toString(16)), gasLimit: cleanHex(new Big(tx.gasLimit).toString(16)),
to: addHexPrefix(tx.to), to: cleanHex(tx.to),
value: token ? '0x0' : addHexPrefix(value.toString(16)), value: token ? '0x00' : cleanHex(value.toString(16)),
data: tx.data ? addHexPrefix(tx.data) : '', data: tx.data ? cleanHex(tx.data) : '',
chainId: tx.chainId || 1 chainId: tx.chainId || 1
}; };

View File

@ -139,3 +139,9 @@ export function isValidRawTx(rawTx: RawTransaction): boolean {
return true; return true;
} }
// Full length deterministic wallet paths from BIP32
// https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
export function isValidPath(dPath: string) {
return dPath.split('\'/').length === 4;
}

View File

@ -0,0 +1,21 @@
// @flow
import BaseWallet from './base';
export default class DeterministicWallet extends BaseWallet {
address: string;
dPath: string;
constructor(address: string, dPath: string) {
super();
this.address = address;
this.dPath = dPath;
}
getAddress(): Promise<string> {
return Promise.resolve(this.address);
}
getPath(): string {
return this.dPath;
}
}

View File

@ -0,0 +1,45 @@
// @flow
import TrezorConnect from 'vendor/trezor-connect';
import EthTx from 'ethereumjs-tx';
import Big from 'bignumber.js';
import { addHexPrefix } from 'ethereumjs-util';
import DeterministicWallet from './deterministic';
import { stripHex } from 'libs/values';
import type { RawTransaction } from 'libs/transaction';
export default class TrezorWallet extends DeterministicWallet {
signRawTransaction(tx: RawTransaction): Promise<string> {
return new Promise((resolve, reject) => {
TrezorConnect.ethereumSignTx(
// Args
this.getPath(),
stripHex(tx.nonce),
stripHex(tx.gasPrice),
stripHex(tx.gasLimit),
stripHex(tx.to),
stripHex(tx.value),
stripHex(tx.data),
tx.chainId,
// Callback
result => {
if (!result.success) {
return reject(new Error(result.error));
}
// TODO: Explain what's going on here? Add tests? Adapted from:
// https://github.com/kvhnuke/etherwallet/blob/v3.10.2.6/app/scripts/uiFuncs.js#L24
const txToSerialize = {
...tx,
v: addHexPrefix(new Big(result.v).toString(16)),
r: addHexPrefix(result.r),
s: addHexPrefix(result.s)
};
const eTx = new EthTx(txToSerialize);
const signedTx = addHexPrefix(eTx.serialize().toString('hex'));
resolve(signedTx);
}
);
});
}
}

View File

@ -0,0 +1,55 @@
import type {
DeterministicWalletData,
DeterministicWalletAction
} from 'actions/deterministicWallets';
export type State = {
wallets: DeterministicWalletData[],
desiredToken: string
};
export const INITIAL_STATE: State = {
wallets: [],
desiredToken: ''
};
export function deterministicWallets(
state: State = INITIAL_STATE,
action: DeterministicWalletAction
): State {
switch (action.type) {
case 'DW_SET_WALLETS':
return {
...state,
wallets: action.payload
};
case 'DW_SET_DESIRED_TOKEN':
return {
...state,
desiredToken: action.payload
};
case 'DW_UPDATE_WALLET':
return {
...state,
wallets: updateWalletValues(state.wallets, action.payload)
};
default:
return state;
}
}
function updateWalletValues(wallets, newWallet) {
return wallets.map(w => {
if (w.address === newWallet.address) {
return {
...w,
...newWallet
};
}
return w;
});
}

View File

@ -25,6 +25,9 @@ import type { State as RatesState } from './rates';
import * as contracts from './contracts'; import * as contracts from './contracts';
import type { State as ContractsState } from './contracts'; import type { State as ContractsState } from './contracts';
import * as deterministicWallets from './deterministicWallets';
import type { State as DeterministicWalletsState } from './deterministicWallets';
import { reducer as formReducer } from 'redux-form'; import { reducer as formReducer } from 'redux-form';
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux'; import { routerReducer } from 'react-router-redux';
@ -39,6 +42,7 @@ export type State = {
customTokens: CustomTokensState, customTokens: CustomTokensState,
rates: RatesState, rates: RatesState,
contracts: ContractsState, contracts: ContractsState,
deterministicWallets: DeterministicWalletsState,
// Third party reducers (TODO: Fill these out) // Third party reducers (TODO: Fill these out)
form: Object, form: Object,
routing: Object routing: Object
@ -54,6 +58,7 @@ export default combineReducers({
...customTokens, ...customTokens,
...rates, ...rates,
...contracts, ...contracts,
...deterministicWallets,
form: formReducer, form: formReducer,
routing: routerReducer routing: routerReducer
}); });

View File

@ -0,0 +1,111 @@
// @flow
import {
takeLatest,
takeEvery,
select,
put,
apply,
fork,
// $FlowFixMe - I guarantee you ,it's in there.
all
} from 'redux-saga/effects';
import HDKey from 'hdkey';
import { publicToAddress, toChecksumAddress } from 'ethereumjs-util';
import {
setDeterministicWallets,
updateDeterministicWallet
} from 'actions/deterministicWallets';
import { getWallets, getDesiredToken } from 'selectors/deterministicWallets';
import { getNodeLib } from 'selectors/config';
import { getTokens } from 'selectors/wallet';
import type {
DeterministicWalletData,
GetDeterministicWalletsAction
} from 'actions/deterministicWallets';
import type { Effect } from 'redux-saga/effects';
import type { BaseNode } from 'libs/nodes';
import type { Token } from 'config/data';
// TODO: BIP39 for mnemonic wallets?
function* getDeterministicWallets(
action?: GetDeterministicWalletsAction
): Generator<Effect, void, any> {
if (!action) return;
const { publicKey, chainCode, limit, offset } = action.payload;
const hdk = new HDKey();
hdk.publicKey = new Buffer(publicKey, 'hex');
hdk.chainCode = new Buffer(chainCode, 'hex');
const wallets = [];
for (let i = 0; i < limit; i++) {
const index = i + offset;
const dkey = hdk.derive(`m/${index}`);
const address = publicToAddress(dkey.publicKey, true).toString('hex');
wallets.push({
index,
address: toChecksumAddress(address),
tokenValues: {}
});
}
yield put(setDeterministicWallets(wallets));
yield fork(updateWalletValues);
yield fork(updateWalletTokenValues);
}
// Grab each wallet's main network token, and update it with it
function* updateWalletValues() {
const node: BaseNode = yield select(getNodeLib);
const wallets: DeterministicWalletData[] = yield select(getWallets);
const calls = wallets.map(w => apply(node, node.getBalance, [w.address]));
const balances = yield all(calls);
for (let i = 0; i < wallets.length; i++) {
yield put(
updateDeterministicWallet({
...wallets[i],
value: balances[i]
})
);
}
}
// Grab the current desired token, and update the wallet with it
function* updateWalletTokenValues() {
const desiredToken: string = yield select(getDesiredToken);
if (!desiredToken) return;
const tokens: Token[] = yield select(getTokens);
const token = tokens.find(t => t.symbol === desiredToken);
if (!token) return;
const node: BaseNode = yield select(getNodeLib);
const wallets: DeterministicWalletData[] = yield select(getWallets);
const calls = wallets.map(w => {
return apply(node, node.getTokenBalance, [w.address, token]);
});
const tokenBalances = yield all(calls);
for (let i = 0; i < wallets.length; i++) {
yield put(
updateDeterministicWallet({
...wallets[i],
tokenValues: {
...wallets[i].tokenValues,
[desiredToken]: tokenBalances[i]
}
})
);
}
}
export default function* deterministicWalletsSaga(): Generator<
Effect,
void,
any
> {
yield takeLatest('DW_GET_WALLETS', getDeterministicWallets);
yield takeEvery('DW_SET_DESIRED_TOKEN', updateWalletTokenValues);
}

View File

@ -9,6 +9,7 @@ import ens from './ens';
import notifications from './notifications'; import notifications from './notifications';
import rates from './rates'; import rates from './rates';
import wallet from './wallet'; import wallet from './wallet';
import deterministicWallets from './deterministicWallets';
export default { export default {
bityTimeRemaining, bityTimeRemaining,
@ -19,5 +20,6 @@ export default {
ens, ens,
notifications, notifications,
rates, rates,
wallet wallet,
deterministicWallets
}; };

View File

@ -81,7 +81,6 @@ export function* unlockPrivateKey(
return; return;
} }
yield put(setWallet(wallet)); yield put(setWallet(wallet));
yield call(updateBalances);
} }
export function* unlockKeystore( export function* unlockKeystore(
@ -124,7 +123,6 @@ export function* unlockKeystore(
// TODO: provide a more descriptive error than the two 'ERROR_6' (invalid pass) messages above // TODO: provide a more descriptive error than the two 'ERROR_6' (invalid pass) messages above
yield put(setWallet(wallet)); yield put(setWallet(wallet));
yield call(updateBalances);
} }
export default function* walletSaga(): Generator<Effect | Effect[], void, any> { export default function* walletSaga(): Generator<Effect | Effect[], void, any> {
@ -133,6 +131,7 @@ export default function* walletSaga(): Generator<Effect | Effect[], void, any> {
yield [ yield [
takeEvery('WALLET_UNLOCK_PRIVATE_KEY', unlockPrivateKey), takeEvery('WALLET_UNLOCK_PRIVATE_KEY', unlockPrivateKey),
takeEvery('WALLET_UNLOCK_KEYSTORE', unlockKeystore), takeEvery('WALLET_UNLOCK_KEYSTORE', unlockKeystore),
takeEvery('WALLET_SET', updateBalances),
takeEvery('CUSTOM_TOKEN_ADD', updateTokenBalances) takeEvery('CUSTOM_TOKEN_ADD', updateTokenBalances)
]; ];
} }

View File

@ -12,3 +12,11 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
} }
@mixin clearfix {
&:after {
content: "";
display: table;
clear: both;
}
}

View File

@ -0,0 +1,11 @@
// @flow
import type { State } from 'reducers';
import type { DeterministicWalletData } from 'actions/deterministicWallets';
export function getWallets(state: State): DeterministicWalletData[] {
return state.deterministicWallets.wallets;
}
export function getDesiredToken(state: State): string {
return state.deterministicWallets.desiredToken;
}

1118
common/vendor/trezor-connect.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
"ethereumjs-util": "^5.1.2", "ethereumjs-util": "^5.1.2",
"ethereumjs-wallet": "^0.6.0", "ethereumjs-wallet": "^0.6.0",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"hdkey": "^0.7.1",
"idna-uts46": "^1.1.0", "idna-uts46": "^1.1.0",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"moment": "^2.18.1", "moment": "^2.18.1",

View File

@ -1,6 +1,7 @@
import { import {
isValidBTCAddress, isValidBTCAddress,
isValidETHAddress isValidETHAddress,
isValidPath
} from '../../common/libs/validators'; } from '../../common/libs/validators';
const VALID_BTC_ADDRESS = '1MEWT2SGbqtz6mPCgFcnea8XmWV5Z4Wc6'; const VALID_BTC_ADDRESS = '1MEWT2SGbqtz6mPCgFcnea8XmWV5Z4Wc6';
@ -24,4 +25,11 @@ describe('Validator', () => {
isValidETHAddress('nonsense' + VALID_ETH_ADDRESS + 'nonsense') isValidETHAddress('nonsense' + VALID_ETH_ADDRESS + 'nonsense')
).toBeFalsy(); ).toBeFalsy();
}); });
it('should validate a correct DPath as true', () => {
expect(isValidPath('m/44\'/60\'/0\'/0')).toBeTruthy();
});
it('should validate an incorrect DPath as false', () => {
expect(isValidPath('m/44/60/0/0')).toBeFalsy();
});
}); });