diff --git a/common/actions/deterministicWallets.js b/common/actions/deterministicWallets.js
new file mode 100644
index 00000000..c6f277f2
--- /dev/null
+++ b/common/actions/deterministicWallets.js
@@ -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;
diff --git a/common/assets/images/icon-external-link.svg b/common/assets/images/icon-external-link.svg
new file mode 100644
index 00000000..241e9d97
--- /dev/null
+++ b/common/assets/images/icon-external-link.svg
@@ -0,0 +1 @@
+
diff --git a/common/components/WalletDecrypt/DeterministicWalletsModal.jsx b/common/components/WalletDecrypt/DeterministicWalletsModal.jsx
new file mode 100644
index 00000000..d0f18482
--- /dev/null
+++ b/common/components/WalletDecrypt/DeterministicWalletsModal.jsx
@@ -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 (
+
+
+ {wallet.index + 1}
+ |
+
+
+ {wallet.address}
+ |
+
+ {value} {network.unit}
+ |
+
+ {tokenValue} {desiredToken}
+ |
+
+
+
+
+ |
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+
+
+
+ # |
+ Address |
+
+ {network.unit}
+ |
+
+
+ |
+ More |
+
+
+
+ {wallets.map(wallet => this._renderWalletRow(wallet))}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+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);
diff --git a/common/components/WalletDecrypt/DeterministicWalletsModal.scss b/common/components/WalletDecrypt/DeterministicWalletsModal.scss
new file mode 100644
index 00000000..997e6c7f
--- /dev/null
+++ b/common/components/WalletDecrypt/DeterministicWalletsModal.scss
@@ -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;
+ }
+ }
+ }
+ }
+}
diff --git a/common/components/WalletDecrypt/Trezor.jsx b/common/components/WalletDecrypt/Trezor.jsx
index ce4267f0..28a0ec12 100644
--- a/common/components/WalletDecrypt/Trezor.jsx
+++ b/common/components/WalletDecrypt/Trezor.jsx
@@ -1,26 +1,123 @@
+// @flow
+import './Trezor.scss';
import React, { Component } from 'react';
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 {
+ 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() {
+ const { dPath, publicKey, chainCode, error, isLoading } = this.state;
+ const showErr = error ? 'is-showing' : '';
+
return (
-
-
-
{translate('ADD_Radio_2_alt')}
+
);
}
diff --git a/common/components/WalletDecrypt/Trezor.scss b/common/components/WalletDecrypt/Trezor.scss
new file mode 100644
index 00000000..acc62967
--- /dev/null
+++ b/common/components/WalletDecrypt/Trezor.scss
@@ -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;
+ }
+}
diff --git a/common/components/WalletDecrypt/index.jsx b/common/components/WalletDecrypt/index.jsx
index 0a2affe3..deb4056c 100644
--- a/common/components/WalletDecrypt/index.jsx
+++ b/common/components/WalletDecrypt/index.jsx
@@ -9,7 +9,7 @@ import LedgerNanoSDecrypt from './LedgerNano';
import TrezorDecrypt from './Trezor';
import ViewOnlyDecrypt from './ViewOnly';
import map from 'lodash/map';
-import { unlockPrivateKey, unlockKeystore } from 'actions/wallet';
+import { unlockPrivateKey, unlockKeystore, setWallet } from 'actions/wallet';
import { connect } from 'react-redux';
const WALLETS = {
@@ -44,7 +44,8 @@ const WALLETS = {
trezor: {
lid: 'x_Trezor',
component: TrezorDecrypt,
- disabled: true
+ initialParams: {},
+ unlock: setWallet
},
'view-only': {
lid: 'View with Address Only',
@@ -162,9 +163,9 @@ export class WalletDecrypt extends Component {
this.setState({ value });
};
- onUnlock = () => {
+ onUnlock = (payload: any) => {
this.props.dispatch(
- WALLETS[this.state.selectedWalletKey].unlock(this.state.value)
+ WALLETS[this.state.selectedWalletKey].unlock(payload || this.state.value)
);
};
}
diff --git a/common/components/ui/Modal.scss b/common/components/ui/Modal.scss
index cc96325d..a1ab0333 100644
--- a/common/components/ui/Modal.scss
+++ b/common/components/ui/Modal.scss
@@ -64,6 +64,7 @@ $m-anim-speed: 400ms;
display: none;
flex-direction: column;
animation: modal-open $m-anim-speed ease 1;
+ text-align: left;
&.is-open {
display: flex;
diff --git a/common/config/dpaths.json b/common/config/dpaths.json
new file mode 100644
index 00000000..10460eb3
--- /dev/null
+++ b/common/config/dpaths.json
@@ -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"
+ }
+ ]
+}
diff --git a/common/libs/nodes/rpc/index.js b/common/libs/nodes/rpc/index.js
index 211a6df3..5c4a2de2 100644
--- a/common/libs/nodes/rpc/index.js
+++ b/common/libs/nodes/rpc/index.js
@@ -40,9 +40,7 @@ export default class RpcNode extends BaseNode {
if (response.error) {
return Big(0);
}
- return new Big(Number(response.result)).div(
- new Big(10).pow(token.decimal)
- );
+ return new Big(response.result).div(new Big(10).pow(token.decimal));
});
}
@@ -55,9 +53,7 @@ export default class RpcNode extends BaseNode {
if (item.error) {
return new Big(0);
}
- return new Big(Number(item.result)).div(
- new Big(10).pow(tokens[idx].decimal)
- );
+ return new Big(item.result).div(new Big(10).pow(tokens[idx].decimal));
});
});
}
diff --git a/common/libs/transaction.js b/common/libs/transaction.js
index 10a59fd2..0a87b372 100644
--- a/common/libs/transaction.js
+++ b/common/libs/transaction.js
@@ -1,10 +1,11 @@
// @flow
import Big from 'bignumber.js';
import translate from 'translations';
-import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util';
+import { padToEven, addHexPrefix, toChecksumAddress } from 'ethereumjs-util';
import { isValidETHAddress } from 'libs/validators';
import ERC20 from 'libs/erc20';
import { toTokenUnit } from 'libs/units';
+import { stripHex } from 'libs/values';
import type BaseNode from 'libs/nodes/base';
import type { BaseWallet } from 'libs/wallet';
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();
return {
- // No value comes back as '0x', but most things expect '0x0'
- value: value === '0x' ? '0x0' : value,
+ // No value comes back as '0x', but most things expect '0x00'
+ value: value === '0x' ? '0x00' : value,
// If data is 0x, it might as well not be there
data: data === '0x' ? null : data,
// To address is unchecksummed, which could cause mismatches in comparisons
@@ -119,15 +120,19 @@ export async function generateTransaction(
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
const txCount = await node.getTransactionCount(tx.from);
const rawTx = {
- nonce: addHexPrefix(txCount),
- gasPrice: addHexPrefix(new Big(tx.gasPrice).toString(16)),
- gasLimit: addHexPrefix(new Big(tx.gasLimit).toString(16)),
- to: addHexPrefix(tx.to),
- value: token ? '0x0' : addHexPrefix(value.toString(16)),
- data: tx.data ? addHexPrefix(tx.data) : '',
+ nonce: cleanHex(txCount),
+ gasPrice: cleanHex(new Big(tx.gasPrice).toString(16)),
+ gasLimit: cleanHex(new Big(tx.gasLimit).toString(16)),
+ to: cleanHex(tx.to),
+ value: token ? '0x00' : cleanHex(value.toString(16)),
+ data: tx.data ? cleanHex(tx.data) : '',
chainId: tx.chainId || 1
};
diff --git a/common/libs/validators.js b/common/libs/validators.js
index f57d63e4..0798a70b 100644
--- a/common/libs/validators.js
+++ b/common/libs/validators.js
@@ -139,3 +139,9 @@ export function isValidRawTx(rawTx: RawTransaction): boolean {
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;
+}
diff --git a/common/libs/wallet/deterministic.js b/common/libs/wallet/deterministic.js
new file mode 100644
index 00000000..960ab549
--- /dev/null
+++ b/common/libs/wallet/deterministic.js
@@ -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
{
+ return Promise.resolve(this.address);
+ }
+
+ getPath(): string {
+ return this.dPath;
+ }
+}
diff --git a/common/libs/wallet/trezor.js b/common/libs/wallet/trezor.js
new file mode 100644
index 00000000..30c5e3e6
--- /dev/null
+++ b/common/libs/wallet/trezor.js
@@ -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 {
+ 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);
+ }
+ );
+ });
+ }
+}
diff --git a/common/reducers/deterministicWallets.js b/common/reducers/deterministicWallets.js
new file mode 100644
index 00000000..aea4d023
--- /dev/null
+++ b/common/reducers/deterministicWallets.js
@@ -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;
+ });
+}
diff --git a/common/reducers/index.js b/common/reducers/index.js
index 571fd715..e7705f6d 100644
--- a/common/reducers/index.js
+++ b/common/reducers/index.js
@@ -25,6 +25,9 @@ import type { State as RatesState } from './rates';
import * as contracts 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 { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
@@ -39,6 +42,7 @@ export type State = {
customTokens: CustomTokensState,
rates: RatesState,
contracts: ContractsState,
+ deterministicWallets: DeterministicWalletsState,
// Third party reducers (TODO: Fill these out)
form: Object,
routing: Object
@@ -54,6 +58,7 @@ export default combineReducers({
...customTokens,
...rates,
...contracts,
+ ...deterministicWallets,
form: formReducer,
routing: routerReducer
});
diff --git a/common/sagas/deterministicWallets.js b/common/sagas/deterministicWallets.js
new file mode 100644
index 00000000..96a15963
--- /dev/null
+++ b/common/sagas/deterministicWallets.js
@@ -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 {
+ 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);
+}
diff --git a/common/sagas/index.js b/common/sagas/index.js
index 6d130bb4..52b66d50 100644
--- a/common/sagas/index.js
+++ b/common/sagas/index.js
@@ -9,6 +9,7 @@ import ens from './ens';
import notifications from './notifications';
import rates from './rates';
import wallet from './wallet';
+import deterministicWallets from './deterministicWallets';
export default {
bityTimeRemaining,
@@ -19,5 +20,6 @@ export default {
ens,
notifications,
rates,
- wallet
+ wallet,
+ deterministicWallets
};
diff --git a/common/sagas/wallet.js b/common/sagas/wallet.js
index 4d93d100..317274af 100644
--- a/common/sagas/wallet.js
+++ b/common/sagas/wallet.js
@@ -81,7 +81,6 @@ export function* unlockPrivateKey(
return;
}
yield put(setWallet(wallet));
- yield call(updateBalances);
}
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
yield put(setWallet(wallet));
- yield call(updateBalances);
}
export default function* walletSaga(): Generator {
@@ -133,6 +131,7 @@ export default function* walletSaga(): Generator {
yield [
takeEvery('WALLET_UNLOCK_PRIVATE_KEY', unlockPrivateKey),
takeEvery('WALLET_UNLOCK_KEYSTORE', unlockKeystore),
+ takeEvery('WALLET_SET', updateBalances),
takeEvery('CUSTOM_TOKEN_ADD', updateTokenBalances)
];
}
diff --git a/common/sass/mixins.scss b/common/sass/mixins.scss
index 78f727a0..055a1e69 100644
--- a/common/sass/mixins.scss
+++ b/common/sass/mixins.scss
@@ -12,3 +12,11 @@
border: none;
cursor: pointer;
}
+
+@mixin clearfix {
+ &:after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+}
diff --git a/common/selectors/deterministicWallets.js b/common/selectors/deterministicWallets.js
new file mode 100644
index 00000000..f5e315d7
--- /dev/null
+++ b/common/selectors/deterministicWallets.js
@@ -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;
+}
diff --git a/common/vendor/trezor-connect.js b/common/vendor/trezor-connect.js
new file mode 100644
index 00000000..2cd3f46b
--- /dev/null
+++ b/common/vendor/trezor-connect.js
@@ -0,0 +1,1118 @@
+/* prettier-ignore */
+/* eslint-ignore */
+
+/**
+ * (C) 2017 SatoshiLabs
+ *
+ * GPLv3
+ */
+var VERSION = 3;
+
+if (!Array.isArray) {
+ Array.isArray = function(arg) {
+ return Object.prototype.toString.call(arg) === '[object Array]';
+ };
+}
+
+var HD_HARDENED = 0x80000000;
+
+// react sometimes adds some other parameters that should not be there
+function _fwStrFix(obj, fw) {
+ if (typeof fw === 'string') {
+ obj.requiredFirmware = fw;
+ }
+ return obj;
+}
+
+('use strict');
+
+var chrome = window.chrome;
+var IS_CHROME_APP = chrome && chrome.app && chrome.app.window;
+
+var ERR_TIMED_OUT = 'Loading timed out';
+var ERR_WINDOW_CLOSED = 'Window closed';
+var ERR_WINDOW_BLOCKED = 'Window blocked';
+var ERR_ALREADY_WAITING = 'Already waiting for a response';
+var ERR_CHROME_NOT_CONNECTED = 'Internal Chrome popup is not responding.';
+
+var DISABLE_LOGIN_BUTTONS = window.TREZOR_DISABLE_LOGIN_BUTTONS || false;
+var CHROME_URL = window.TREZOR_CHROME_URL || './chrome/wrapper.html';
+var POPUP_URL =
+ window.TREZOR_POPUP_URL ||
+ 'https://connect.trezor.io/' + VERSION + '/popup/popup.html';
+var POPUP_PATH =
+ window.TREZOR_POPUP_PATH || 'https://connect.trezor.io/' + VERSION;
+var POPUP_ORIGIN = window.TREZOR_POPUP_ORIGIN || 'https://connect.trezor.io';
+
+var INSIGHT_URLS = window.TREZOR_INSIGHT_URLS || [
+ 'https://btc-bitcore1.trezor.io/api/',
+ 'https://btc-bitcore3.trezor.io/api/'
+];
+
+var POPUP_INIT_TIMEOUT = 15000;
+
+/**
+ * Public API.
+ */
+function TrezorConnect() {
+ var manager = new PopupManager();
+
+ /**
+ * Popup errors.
+ */
+ this.ERR_TIMED_OUT = ERR_TIMED_OUT;
+ this.ERR_WINDOW_CLOSED = ERR_WINDOW_CLOSED;
+ this.ERR_WINDOW_BLOCKED = ERR_WINDOW_BLOCKED;
+ this.ERR_ALREADY_WAITING = ERR_ALREADY_WAITING;
+ this.ERR_CHROME_NOT_CONNECTED = ERR_CHROME_NOT_CONNECTED;
+
+ /**
+ * Open the popup for further communication. All API functions open the
+ * popup automatically, but if you need to generate some parameters
+ * asynchronously, use `open` first to avoid popup blockers.
+ * @param {function(?Error)} callback
+ */
+ this.open = function(callback) {
+ var onchannel = function(result) {
+ if (result instanceof Error) {
+ callback(result);
+ } else {
+ callback();
+ }
+ };
+ manager.waitForChannel(onchannel);
+ };
+
+ /**
+ * Close the opened popup, if any.
+ */
+ this.close = function() {
+ manager.close();
+ };
+
+ /**
+ * Enable or disable closing the opened popup after a successful call.
+ * @param {boolean} value
+ */
+ this.closeAfterSuccess = function(value) {
+ manager.closeAfterSuccess = value;
+ };
+
+ /**
+ * Enable or disable closing the opened popup after a failed call.
+ * @param {boolean} value
+ */
+ this.closeAfterFailure = function(value) {
+ manager.closeAfterFailure = value;
+ };
+
+ /**
+ * Set bitcore server
+ * @param {string|Array} value
+ */
+ this.setBitcoreURLS = function(value) {
+ if (typeof value === 'string') {
+ manager.bitcoreURLS = [value];
+ } else if (value instanceof Array) {
+ manager.bitcoreURLS = value;
+ }
+ };
+
+ /**
+ * Set max. limit for account discovery
+ * @param {number} value
+ */
+ this.setAccountDiscoveryLimit = function(value) {
+ if (!isNaN(value)) manager.accountDiscoveryLimit = value;
+ };
+
+ /**
+ * Set max. gap for account discovery
+ * @param {number} value
+ */
+ this.setAccountDiscoveryGapLength = function(value) {
+ if (!isNaN(value)) manager.accountDiscoveryGapLength = value;
+ };
+
+ /**
+ * Set discovery BIP44 coin type
+ * @param {number} value
+ */
+ this.setAccountDiscoveryBip44CoinType = function(value) {
+ if (!isNaN(value)) manager.accountDiscoveryBip44CoinType = value;
+ };
+
+ /**
+ * @typedef XPubKeyResult
+ * @param {boolean} success
+ * @param {?string} error
+ * @param {?string} xpubkey serialized extended public key
+ * @param {?string} path BIP32 serializd path of the key
+ */
+
+ /**
+ * Load BIP32 extended public key by path.
+ *
+ * Path can be specified either in the string form ("m/44'/1/0") or as
+ * raw integer array. In case you omit the path, user is asked to select
+ * a BIP32 account to export, and the result contains m/44'/0'/x' node
+ * of the account.
+ *
+ * @param {?(string|array)} path
+ * @param {function(XPubKeyResult)} callback
+ * @param {?(string|array)} requiredFirmware
+ */
+ this.getXPubKey = function(path, callback, requiredFirmware) {
+ if (typeof path === 'string') {
+ path = parseHDPath(path);
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'xpubkey',
+ path: path
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ this.getFreshAddress = function(callback, requiredFirmware) {
+ var wrapperCallback = function(result) {
+ if (result.success) {
+ callback({ success: true, address: result.freshAddress });
+ } else {
+ callback(result);
+ }
+ };
+
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'accountinfo'
+ },
+ requiredFirmware
+ ),
+ wrapperCallback
+ );
+ };
+
+ this.getAccountInfo = function(input, callback, requiredFirmware) {
+ try {
+ var description = parseAccountInfoInput(input);
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'accountinfo',
+ description: description
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ } catch (e) {
+ callback({ success: false, error: e });
+ }
+ };
+
+ this.getAllAccountsInfo = function(callback, requiredFirmware) {
+ try {
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'allaccountsinfo',
+ description: 'all'
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ } catch (e) {
+ callback({ success: false, error: e });
+ }
+ };
+
+ this.getBalance = function(callback, requiredFirmware) {
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'accountinfo'
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * @typedef SignTxResult
+ * @param {boolean} success
+ * @param {?string} error
+ * @param {?string} serialized_tx serialized tx, in hex, including signatures
+ * @param {?array} signatures array of input signatures, in hex
+ */
+
+ /**
+ * Sign a transaction in the device and return both serialized
+ * transaction and the signatures.
+ *
+ * @param {array} inputs
+ * @param {array} outputs
+ * @param {function(SignTxResult)} callback
+ * @param {?(string|array)} requiredFirmware
+ *
+ * @see https://github.com/trezor/trezor-common/blob/master/protob/types.proto
+ */
+ this.signTx = function(inputs, outputs, callback, requiredFirmware, coin) {
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'signtx',
+ inputs: inputs,
+ outputs: outputs,
+ coin: coin
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ // new implementation with ethereum at beginnig
+ this.ethereumSignTx = function() {
+ this.signEthereumTx.apply(this, arguments);
+ };
+
+ // old fallback
+ this.signEthereumTx = function(
+ address_n,
+ nonce,
+ gas_price,
+ gas_limit,
+ to,
+ value,
+ data,
+ chain_id,
+ callback,
+ requiredFirmware
+ ) {
+ if (requiredFirmware == null) {
+ requiredFirmware = '1.4.0'; // first firmware that supports ethereum
+ }
+ if (typeof address_n === 'string') {
+ address_n = parseHDPath(address_n);
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'signethtx',
+ address_n: address_n,
+ nonce: nonce,
+ gas_price: gas_price,
+ gas_limit: gas_limit,
+ to: to,
+ value: value,
+ data: data,
+ chain_id: chain_id
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * @typedef TxRecipient
+ * @param {number} amount the amount to send, in satoshis
+ * @param {string} address the address of the recipient
+ */
+
+ /**
+ * Compose a transaction by doing BIP-0044 discovery, letting the user
+ * select an account, and picking UTXO by internal preferences.
+ * Transaction is then signed and returned in the same format as
+ * `signTx`. Only supports BIP-0044 accounts (single-signature).
+ *
+ * @param {array} recipients
+ * @param {function(SignTxResult)} callback
+ * @param {?(string|array)} requiredFirmware
+ */
+ this.composeAndSignTx = function(recipients, callback, requiredFirmware) {
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'composetx',
+ recipients: recipients
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * @typedef RequestLoginResult
+ * @param {boolean} success
+ * @param {?string} error
+ * @param {?string} public_key public key used for signing, in hex
+ * @param {?string} signature signature, in hex
+ */
+
+ /**
+ * Sign a login challenge for active origin.
+ *
+ * @param {?string} hosticon
+ * @param {string} challenge_hidden
+ * @param {string} challenge_visual
+ * @param {string|function(RequestLoginResult)} callback
+ * @param {?(string|array)} requiredFirmware
+ *
+ * @see https://github.com/trezor/trezor-common/blob/master/protob/messages.proto
+ */
+ this.requestLogin = function(
+ hosticon,
+ challenge_hidden,
+ challenge_visual,
+ callback,
+ requiredFirmware
+ ) {
+ if (typeof callback === 'string') {
+ // special case for a login through button.
+ // `callback` is name of global var
+ callback = window[callback];
+ }
+ if (!callback) {
+ throw new TypeError('TrezorConnect: login callback not found');
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'login',
+ icon: hosticon,
+ challenge_hidden: challenge_hidden,
+ challenge_visual: challenge_visual
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * @typedef SignMessageResult
+ * @param {boolean} success
+ * @param {?string} error
+ * @param {?string} address address (in base58check)
+ * @param {?string} signature signature, in base64
+ */
+
+ /**
+ * Sign a message
+ *
+ * @param {string|array} path
+ * @param {string} message to sign (ascii)
+ * @param {string|function(SignMessageResult)} callback
+ * @param {?string} opt_coin - (optional) name of coin (default Bitcoin)
+ * @param {?(string|array)} requiredFirmware
+ *
+ */
+ this.signMessage = function(
+ path,
+ message,
+ callback,
+ opt_coin,
+ requiredFirmware
+ ) {
+ if (typeof path === 'string') {
+ path = parseHDPath(path);
+ }
+ if (!opt_coin) {
+ opt_coin = 'Bitcoin';
+ }
+ if (!callback) {
+ throw new TypeError('TrezorConnect: callback not found');
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'signmsg',
+ path: path,
+ message: message,
+ coin: { coin_name: opt_coin }
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * Sign an Ethereum message
+ *
+ * @param {string|array} path
+ * @param {string} message to sign (ascii)
+ * @param {string|function(SignMessageResult)} callback
+ * @param {?(string|array)} requiredFirmware
+ *
+ */
+ this.ethereumSignMessage = function(
+ path,
+ message,
+ callback,
+ requiredFirmware
+ ) {
+ if (typeof path === 'string') {
+ path = parseHDPath(path);
+ }
+ if (!callback) {
+ throw new TypeError('TrezorConnect: callback not found');
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'signethmsg',
+ path: path,
+ message: message
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * Verify message
+ *
+ * @param {string} address
+ * @param {string} signature (base64)
+ * @param {string} message (string)
+ * @param {string|function()} callback
+ * @param {?string} opt_coin - (optional) name of coin (default Bitcoin)
+ * @param {?(string|array)} requiredFirmware
+ *
+ */
+ this.verifyMessage = function(
+ address,
+ signature,
+ message,
+ callback,
+ opt_coin,
+ requiredFirmware
+ ) {
+ if (!opt_coin) {
+ opt_coin = 'Bitcoin';
+ }
+ if (!callback) {
+ throw new TypeError('TrezorConnect: callback not found');
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'verifymsg',
+ address: address,
+ signature: signature,
+ message: message,
+ coin: { coin_name: opt_coin }
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * Verify ethereum message
+ *
+ * @param {string} address
+ * @param {string} signature (base64)
+ * @param {string} message (string)
+ * @param {string|function()} callback
+ * @param {?(string|array)} requiredFirmware
+ *
+ */
+ this.ethereumVerifyMessage = function(
+ address,
+ signature,
+ message,
+ callback,
+ requiredFirmware
+ ) {
+ if (!callback) {
+ throw new TypeError('TrezorConnect: callback not found');
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'verifyethmsg',
+ address: address,
+ signature: signature,
+ message: message
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * Symmetric key-value encryption
+ *
+ * @param {string|array} path
+ * @param {string} key to show on device display
+ * @param {string} value hexadecimal value, length a multiple of 16 bytes
+ * @param {boolean} encrypt / decrypt direction
+ * @param {boolean} ask_on_encrypt (should user confirm on encrypt?)
+ * @param {boolean} ask_on_decrypt (should user confirm on decrypt?)
+ * @param {string|function()} callback
+ * @param {?(string|array)} requiredFirmware
+ *
+ */
+ this.cipherKeyValue = function(
+ path,
+ key,
+ value,
+ encrypt,
+ ask_on_encrypt,
+ ask_on_decrypt,
+ callback,
+ requiredFirmware
+ ) {
+ if (typeof path === 'string') {
+ path = parseHDPath(path);
+ }
+ if (typeof value !== 'string') {
+ throw new TypeError('TrezorConnect: Value must be a string');
+ }
+ if (!/^[0-9A-Fa-f]*$/.test(value)) {
+ throw new TypeError('TrezorConnect: Value must be hexadecimal');
+ }
+ if (value.length % 32 !== 0) {
+ // 1 byte == 2 hex strings
+ throw new TypeError(
+ 'TrezorConnect: Value length must be multiple of 16 bytes'
+ );
+ }
+ if (!callback) {
+ throw new TypeError('TrezorConnect: callback not found');
+ }
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'cipherkeyvalue',
+ path: path,
+ key: key,
+ value: value,
+ encrypt: !!encrypt,
+ ask_on_encrypt: !!ask_on_encrypt,
+ ask_on_decrypt: !!ask_on_decrypt
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ this.pushTransaction = function(rawTx, callback) {
+ if (!/^[0-9A-Fa-f]*$/.test(rawTx)) {
+ throw new TypeError('TrezorConnect: Transaction must be hexadecimal');
+ }
+ if (!callback) {
+ throw new TypeError('TrezorConnect: callback not found');
+ }
+
+ var tryUrl = function(i) {
+ var insight_url = INSIGHT_URLS[i];
+ var xhr = new XMLHttpRequest();
+ var method = 'POST';
+ var url = insight_url + '/tx/send';
+ var data = {
+ rawtx: rawTx
+ };
+
+ xhr.open(method, url, true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ var txid = JSON.parse(xhr.responseText).txid;
+ callback({ success: true, txid: txid });
+ } else {
+ if (i === INSIGHT_URLS.length - 1) {
+ callback({ error: new Error(xhr.responseText) });
+ } else {
+ tryUrl(i + 1);
+ }
+ }
+ }
+ };
+ xhr.send(JSON.stringify(data));
+ };
+
+ tryUrl(0);
+ };
+
+ /**
+ * Display address on device
+ *
+ * @param {array} address
+ * @param {string} coin
+ * @param {boolean} segwit
+ * @param {?(string|array)} requiredFirmware
+ *
+ */
+ this.getAddress = function(
+ address,
+ coin,
+ segwit,
+ callback,
+ requiredFirmware
+ ) {
+ if (typeof address === 'string') {
+ address = parseHDPath(address);
+ }
+
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'getaddress',
+ address_n: address,
+ coin: coin,
+ segwit: segwit
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ /**
+ * Display ethereum address on device
+ *
+ * @param {array} address
+ * @param {?(string|array)} requiredFirmware
+ *
+ */
+ this.ethereumGetAddress = function(address, callback, requiredFirmware) {
+ if (typeof address === 'string') {
+ address = parseHDPath(address);
+ }
+
+ manager.sendWithChannel(
+ _fwStrFix(
+ {
+ type: 'ethgetaddress',
+ address_n: address
+ },
+ requiredFirmware
+ ),
+ callback
+ );
+ };
+
+ var LOGIN_CSS =
+ '';
+
+ var LOGIN_ONCLICK =
+ 'TrezorConnect.requestLogin(' +
+ '\'@hosticon@\',\'@challenge_hidden@\',\'@challenge_visual@\',\'@callback@\'' +
+ ')';
+
+ var LOGIN_HTML =
+ '';
+
+ /**
+ * Find elements and replace them with login buttons.
+ * It's not required to use these special elements, feel free to call
+ * `TrezorConnect.requestLogin` directly.
+ */
+ this.renderLoginButtons = function() {
+ var elements = document.getElementsByTagName('trezor:login');
+
+ for (var i = 0; i < elements.length; i++) {
+ var e = elements[i];
+ var text = e.getAttribute('text') || 'Sign in with TREZOR';
+ var callback = e.getAttribute('callback') || '';
+ var hosticon = e.getAttribute('icon') || '';
+ var challenge_hidden = e.getAttribute('challenge_hidden') || '';
+ var challenge_visual = e.getAttribute('challenge_visual') || '';
+
+ // it's not valid to put markup into attributes, so let users
+ // supply a raw text and make TREZOR bold
+ text = text.replace('TREZOR', 'TREZOR');
+ e.outerHTML = (LOGIN_CSS + LOGIN_HTML)
+ .replace('@text@', text)
+ .replace('@callback@', callback)
+ .replace('@hosticon@', hosticon)
+ .replace('@challenge_hidden@', challenge_hidden)
+ .replace('@challenge_visual@', challenge_visual)
+ .replace('@connect_path@', POPUP_PATH);
+ }
+ };
+}
+
+/*
+ * `getXPubKey()`
+ */
+
+function parseHDPath(string) {
+ return string
+ .toLowerCase()
+ .split('/')
+ .filter(function(p) {
+ return p !== 'm';
+ })
+ .map(function(p) {
+ var hardened = false;
+ if (p[p.length - 1] === '\'') {
+ hardened = true;
+ p = p.substr(0, p.length - 1);
+ }
+ if (isNaN(p)) {
+ throw new Error('Not a valid path.');
+ }
+ var n = parseInt(p);
+ if (hardened) {
+ // hardened index
+ n = (n | 0x80000000) >>> 0;
+ }
+ return n;
+ });
+}
+
+function getIdFromPath(path) {
+ if (path.length !== 3) {
+ throw new Error();
+ }
+ if (path[0] >>> 0 !== (44 | HD_HARDENED) >>> 0) {
+ throw new Error();
+ }
+ if (path[1] >>> 0 !== (0 | HD_HARDENED) >>> 0) {
+ throw new Error();
+ }
+ return (path[2] & ~HD_HARDENED) >>> 0;
+}
+
+// parses first argument from getAccountInfo
+function parseAccountInfoInput(input) {
+ if (input == null) {
+ return null;
+ }
+
+ if (typeof input === 'string') {
+ if (input.substr(0, 4) === 'xpub') {
+ return input;
+ }
+ if (isNaN(input)) {
+ var parsedPath = parseHDPath(input);
+ return getIdFromPath(parsedPath);
+ } else {
+ return parseInt(input);
+ }
+ } else if (Array.isArray(input)) {
+ return getIdFromPath(input);
+ } else if (typeof input === 'number') {
+ return input;
+ }
+ throw new Error('Unknown input format.');
+}
+
+/*
+ * Popup management
+ */
+
+function ChromePopup(url, name, width, height) {
+ var left = (screen.width - width) / 2;
+ var top = (screen.height - height) / 2;
+ var opts = {
+ id: name,
+ innerBounds: {
+ width: width,
+ height: height,
+ left: left,
+ top: top
+ }
+ };
+
+ var closed = function() {
+ if (this.onclose) {
+ this.onclose(false); // never report as blocked
+ }
+ }.bind(this);
+
+ var opened = function(w) {
+ this.window = w;
+ this.window.onClosed.addListener(closed);
+ }.bind(this);
+
+ chrome.app.window.create(url, opts, opened);
+
+ this.name = name;
+ this.window = null;
+ this.onclose = null;
+}
+
+function ChromeChannel(popup, waiting) {
+ var port = null;
+
+ var respond = function(data) {
+ if (waiting) {
+ var w = waiting;
+ waiting = null;
+ w(data);
+ }
+ };
+
+ var setup = function(p) {
+ if (p.name === popup.name) {
+ port = p;
+ port.onMessage.addListener(respond);
+ chrome.runtime.onConnect.removeListener(setup);
+ }
+ };
+
+ chrome.runtime.onConnect.addListener(setup);
+
+ this.respond = respond;
+
+ this.close = function() {
+ chrome.runtime.onConnect.removeListener(setup);
+ port.onMessage.removeListener(respond);
+ port.disconnect();
+ port = null;
+ };
+
+ this.send = function(value, callback) {
+ if (waiting === null) {
+ waiting = callback;
+
+ if (port) {
+ port.postMessage(value);
+ } else {
+ throw new Error(ERR_CHROME_NOT_CONNECTED);
+ }
+ } else {
+ throw new Error(ERR_ALREADY_WAITING);
+ }
+ };
+}
+
+function Popup(url, origin, name, width, height) {
+ var left = (screen.width - width) / 2;
+ var top = (screen.height - height) / 2;
+ var opts =
+ 'width=' +
+ width +
+ ',height=' +
+ height +
+ ',left=' +
+ left +
+ ',top=' +
+ top +
+ ',menubar=no' +
+ ',toolbar=no' +
+ ',location=no' +
+ ',personalbar=no' +
+ ',status=no';
+ var w = window.open(url, name, opts);
+
+ var interval;
+ var blocked = w.closed;
+ var iterate = function() {
+ if (w.closed) {
+ clearInterval(interval);
+ if (this.onclose) {
+ this.onclose(blocked);
+ }
+ }
+ }.bind(this);
+ interval = setInterval(iterate, 100);
+
+ this.window = w;
+ this.origin = origin;
+ this.onclose = null;
+}
+
+function Channel(popup, waiting) {
+ var respond = function(data) {
+ if (waiting) {
+ var w = waiting;
+ waiting = null;
+ w(data);
+ }
+ };
+
+ var receive = function(event) {
+ if (event.source === popup.window && event.origin === popup.origin) {
+ respond(event.data);
+ }
+ };
+
+ window.addEventListener('message', receive);
+
+ this.respond = respond;
+
+ this.close = function() {
+ window.removeEventListener('message', receive);
+ };
+
+ this.send = function(value, callback) {
+ if (waiting === null) {
+ waiting = callback;
+ popup.window.postMessage(value, popup.origin);
+ } else {
+ throw new Error(ERR_ALREADY_WAITING);
+ }
+ };
+}
+
+function ConnectedChannel(p) {
+ var ready = function() {
+ clearTimeout(this.timeout);
+ this.popup.onclose = null;
+ this.ready = true;
+ this.onready();
+ }.bind(this);
+
+ var closed = function(blocked) {
+ clearTimeout(this.timeout);
+ this.channel.close();
+ if (blocked) {
+ this.onerror(new Error(ERR_WINDOW_BLOCKED));
+ } else {
+ this.onerror(new Error(ERR_WINDOW_CLOSED));
+ }
+ }.bind(this);
+
+ var timedout = function() {
+ this.popup.onclose = null;
+ if (this.popup.window) {
+ this.popup.window.close();
+ }
+ this.channel.close();
+ this.onerror(new Error(ERR_TIMED_OUT));
+ }.bind(this);
+
+ if (IS_CHROME_APP) {
+ this.popup = new ChromePopup(p.chromeUrl, p.name, p.width, p.height);
+ this.channel = new ChromeChannel(this.popup, ready);
+ } else {
+ this.popup = new Popup(p.url, p.origin, p.name, p.width, p.height);
+ this.channel = new Channel(this.popup, ready);
+ }
+
+ this.timeout = setTimeout(timedout, POPUP_INIT_TIMEOUT);
+
+ this.popup.onclose = closed;
+
+ this.ready = false;
+ this.onready = null;
+ this.onerror = null;
+}
+
+function PopupManager() {
+ var cc = null;
+
+ var closed = function() {
+ cc.channel.respond(new Error(ERR_WINDOW_CLOSED));
+ cc.channel.close();
+ cc = null;
+ };
+
+ var open = function(callback) {
+ cc = new ConnectedChannel({
+ name: 'trezor-connect',
+ width: 600,
+ height: 500,
+ origin: POPUP_ORIGIN,
+ path: POPUP_PATH,
+ url: POPUP_URL,
+ chromeUrl: CHROME_URL
+ });
+ cc.onready = function() {
+ cc.popup.onclose = closed;
+ callback(cc.channel);
+ };
+ cc.onerror = function(error) {
+ cc = null;
+ callback(error);
+ };
+ }.bind(this);
+
+ this.closeAfterSuccess = true;
+ this.closeAfterFailure = true;
+
+ this.close = function() {
+ if (cc && cc.popup.window) {
+ cc.popup.window.close();
+ }
+ };
+
+ this.waitForChannel = function(callback) {
+ if (cc) {
+ if (cc.ready) {
+ callback(cc.channel);
+ } else {
+ callback(new Error(ERR_ALREADY_WAITING));
+ }
+ } else {
+ try {
+ open(callback);
+ } catch (e) {
+ callback(new Error(ERR_WINDOW_BLOCKED));
+ }
+ }
+ };
+
+ this.sendWithChannel = function(message, callback) {
+ message.bitcoreURLS = this.bitcoreURLS || null;
+ message.accountDiscoveryLimit = this.accountDiscoveryLimit || null;
+ message.accountDiscoveryGapLength = this.accountDiscoveryGapLength || null;
+ message.accountDiscoveryBip44CoinType =
+ this.accountDiscoveryBip44CoinType || null;
+
+ var respond = function(response) {
+ var succ = response.success && this.closeAfterSuccess;
+ var fail = !response.success && this.closeAfterFailure;
+ if (succ || fail) {
+ this.close();
+ }
+ callback(response);
+ }.bind(this);
+
+ var onresponse = function(response) {
+ if (response instanceof Error) {
+ var error = response;
+ respond({ success: false, error: error.message });
+ } else {
+ respond(response);
+ }
+ };
+
+ var onchannel = function(channel) {
+ if (channel instanceof Error) {
+ var error = channel;
+ respond({ success: false, error: error.message });
+ } else {
+ channel.send(message, onresponse);
+ }
+ };
+
+ this.waitForChannel(onchannel);
+ };
+}
+
+var connect = new TrezorConnect();
+
+module.exports = connect;
diff --git a/package.json b/package.json
index eede7dbc..1926e075 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"ethereumjs-util": "^5.1.2",
"ethereumjs-wallet": "^0.6.0",
"font-awesome": "^4.7.0",
+ "hdkey": "^0.7.1",
"idna-uts46": "^1.1.0",
"lodash": "^4.17.4",
"moment": "^2.18.1",
diff --git a/spec/libs/validators.spec.js b/spec/libs/validators.spec.js
index 99fb2265..3b8663bb 100644
--- a/spec/libs/validators.spec.js
+++ b/spec/libs/validators.spec.js
@@ -1,6 +1,7 @@
import {
isValidBTCAddress,
- isValidETHAddress
+ isValidETHAddress,
+ isValidPath
} from '../../common/libs/validators';
const VALID_BTC_ADDRESS = '1MEWT2SGbqtz6mPCgFcnea8XmWV5Z4Wc6';
@@ -24,4 +25,11 @@ describe('Validator', () => {
isValidETHAddress('nonsense' + VALID_ETH_ADDRESS + 'nonsense')
).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();
+ });
});