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 ( + +
+
+ Addresses for + + {isCustomPath && + } +
+ +
+ + + + + + + + + + + + {wallets.map(wallet => this._renderWalletRow(wallet))} + +
#Address + {network.unit} + + + More
+ +
+ + +
+
+
+
+ ); + } +} + +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')}

+
+ -
- - - - {translate('ADD_Radio_2_short')} - -
+
+ Guide:{' '} + + How to use TREZOR with MyEtherWallet +
+ +
+ {error || '-'} +
+ + + {translate('Don’t have a TREZOR? Order one now!')} + + +
); } 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 = + '
' + + ' ' + + ' ' + + ' @text@' + + ' ' + + ' ' + + ' What is TREZOR?' + + ' ' + + '
'; + + /** + * 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(); + }); });