From 65bb796d453719c049f2b28c2a3e84a82379c093 Mon Sep 17 00:00:00 2001 From: meriadec Date: Sun, 9 Sep 2018 17:20:54 +0200 Subject: [PATCH] Add DevTools page and ability to import accounts via xpub --- src/commands/index.js | 2 + src/commands/libcoreScanFromXPUB.js | 29 +++ .../DevToolsPage/AccountImporter.js | 207 ++++++++++++++++++ src/components/DevToolsPage/index.js | 11 + src/components/MainSideBar/index.js | 32 ++- src/components/base/Input/index.js | 2 +- src/components/layout/Default.js | 2 + src/config/cryptocurrencies.js | 9 + src/helpers/libcore.js | 77 +++++-- static/i18n/en/app.json | 3 +- 10 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 src/commands/libcoreScanFromXPUB.js create mode 100644 src/components/DevToolsPage/AccountImporter.js create mode 100644 src/components/DevToolsPage/index.js diff --git a/src/commands/index.js b/src/commands/index.js index 8b1ff369..117a8b3f 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -18,6 +18,7 @@ import isDashboardOpen from 'commands/isDashboardOpen' import libcoreGetFees from 'commands/libcoreGetFees' import libcoreGetVersion from 'commands/libcoreGetVersion' import libcoreScanAccounts from 'commands/libcoreScanAccounts' +import libcoreScanFromXPUB from 'commands/libcoreScanFromXPUB' import libcoreSignAndBroadcast from 'commands/libcoreSignAndBroadcast' import libcoreSyncAccount from 'commands/libcoreSyncAccount' import libcoreValidAddress from 'commands/libcoreValidAddress' @@ -49,6 +50,7 @@ const all: Array> = [ libcoreGetFees, libcoreGetVersion, libcoreScanAccounts, + libcoreScanFromXPUB, libcoreSignAndBroadcast, libcoreSyncAccount, libcoreValidAddress, diff --git a/src/commands/libcoreScanFromXPUB.js b/src/commands/libcoreScanFromXPUB.js new file mode 100644 index 00000000..1c595861 --- /dev/null +++ b/src/commands/libcoreScanFromXPUB.js @@ -0,0 +1,29 @@ +// @flow + +import { fromPromise } from 'rxjs/observable/fromPromise' +import type { AccountRaw } from '@ledgerhq/live-common/lib/types' + +import { createCommand, Command } from 'helpers/ipc' +import withLibcore from 'helpers/withLibcore' +import { scanAccountsFromXPUB } from 'helpers/libcore' + +type Input = { + currencyId: string, + xpub: string, + isSegwit: boolean, + isUnsplit: boolean, +} + +type Result = AccountRaw + +const cmd: Command = createCommand( + 'libcoreScanFromXPUB', + ({ currencyId, xpub, isSegwit, isUnsplit }) => + fromPromise( + withLibcore(async core => + scanAccountsFromXPUB({ core, currencyId, xpub, isSegwit, isUnsplit }), + ), + ), +) + +export default cmd diff --git a/src/components/DevToolsPage/AccountImporter.js b/src/components/DevToolsPage/AccountImporter.js new file mode 100644 index 00000000..88afd7c9 --- /dev/null +++ b/src/components/DevToolsPage/AccountImporter.js @@ -0,0 +1,207 @@ +// @flow + +import React, { PureComponent, Fragment } from 'react' +import invariant from 'invariant' +import { connect } from 'react-redux' + +import type { Currency, Account } from '@ledgerhq/live-common/lib/types' + +import { decodeAccount } from 'reducers/accounts' +import { addAccount } from 'actions/accounts' + +import FormattedVal from 'components/base/FormattedVal' +import Switch from 'components/base/Switch' +import Spinner from 'components/base/Spinner' +import Box, { Card } from 'components/base/Box' +import TranslatedError from 'components/TranslatedError' +import Button from 'components/base/Button' +import Input from 'components/base/Input' +import Label from 'components/base/Label' +import SelectCurrency from 'components/SelectCurrency' +import { CurrencyCircleIcon } from 'components/base/CurrencyBadge' + +import { idleCallback } from 'helpers/promise' +import { splittedCurrencies } from 'config/cryptocurrencies' + +import scanFromXPUB from 'commands/libcoreScanFromXPUB' + +const mapDispatchToProps = { + addAccount, +} + +type Props = { + addAccount: Account => void, +} + +const INITIAL_STATE = { + status: 'idle', + currency: null, + xpub: '', + account: null, + isSegwit: true, + isUnsplit: false, + error: null, +} + +type State = { + status: string, + currency: ?Currency, + xpub: string, + account: ?Account, + isSegwit: boolean, + isUnsplit: boolean, + error: ?Error, +} + +class AccountImporter extends PureComponent { + state = INITIAL_STATE + + onChangeCurrency = currency => { + if (currency.family !== 'bitcoin') return + this.setState({ + currency, + isSegwit: !!currency.supportsSegwit, + isUnsplit: false, + }) + } + + onChangeXPUB = xpub => this.setState({ xpub }) + onChangeSegwit = isSegwit => this.setState({ isSegwit }) + onChangeUnsplit = isUnsplit => this.setState({ isUnsplit }) + + isValid = () => { + const { currency, xpub } = this.state + return !!currency && !!xpub + } + + scan = async () => { + if (!this.isValid()) return + this.setState({ status: 'scanning' }) + try { + const { currency, xpub, isSegwit, isUnsplit } = this.state + invariant(currency, 'no currency') + const rawAccount = await scanFromXPUB + .send({ + currencyId: currency.id, + xpub, + isSegwit, + isUnsplit, + }) + .toPromise() + const account = decodeAccount(rawAccount) + this.setState({ status: 'finish', account }) + } catch (error) { + this.setState({ status: 'error', error }) + } + } + + import = async () => { + const { account } = this.state + invariant(account, 'no account') + await idleCallback() + this.props.addAccount(account) + this.reset() + } + + reset = () => this.setState(INITIAL_STATE) + + render() { + const { currency, xpub, isSegwit, isUnsplit, status, account, error } = this.state + const supportsSplit = !!currency && !!splittedCurrencies[currency.id] + return ( + + {status === 'idle' ? ( + + + + + + {currency && (currency.supportsSegwit || supportsSplit) ? ( + + {supportsSplit && ( + + + {'unsplit'} + + + + )} + {currency.supportsSegwit && ( + + + {'segwit'} + + + + )} + + ) : null} + + + + + + + + + ) : status === 'scanning' ? ( + + + + ) : status === 'finish' ? ( + account ? ( + + + {currency && } + + {account.name} + + {`${account.operations.length} operation(s)`} + + + + + + ) : ( + + {'No accounts found or wrong xpub'} + + + ) + ) : status === 'error' ? ( + + + + + + + ) : null} + + ) + } +} + +export default connect( + null, + mapDispatchToProps, +)(AccountImporter) diff --git a/src/components/DevToolsPage/index.js b/src/components/DevToolsPage/index.js new file mode 100644 index 00000000..bc849837 --- /dev/null +++ b/src/components/DevToolsPage/index.js @@ -0,0 +1,11 @@ +import React from 'react' + +import Box from 'components/base/Box' + +import AccountImporter from './AccountImporter' + +export default () => ( + + + +) diff --git a/src/components/MainSideBar/index.js b/src/components/MainSideBar/index.js index 1c67e87f..e1cb9051 100644 --- a/src/components/MainSideBar/index.js +++ b/src/components/MainSideBar/index.js @@ -19,6 +19,7 @@ import { i } from 'helpers/staticPath' import { accountsSelector } from 'reducers/accounts' import { openModal } from 'reducers/modals' import { getUpdateStatus } from 'reducers/update' +import { developerModeSelector } from 'reducers/settings' import { SideBarList, SideBarListItem } from 'components/base/SideBar' import Box from 'components/base/Box' @@ -38,6 +39,7 @@ import TopGradient from './TopGradient' const mapStateToProps = state => ({ accounts: accountsSelector(state), updateStatus: getUpdateStatus(state), + developerMode: developerModeSelector(state), }) const mapDispatchToProps = { @@ -52,8 +54,26 @@ type Props = { push: string => void, openModal: string => void, updateStatus: UpdateStatus, + developerMode: boolean, } +const IconDev = () => ( +
+ {'DEV'} +
+) + class MainSideBar extends PureComponent { push = (to: string) => { const { push } = this.props @@ -78,10 +98,11 @@ class MainSideBar extends PureComponent { handleOpenReceiveModal = () => this.props.openModal(MODAL_RECEIVE) handleClickManager = () => this.push('/manager') handleClickExchange = () => this.push('/exchange') + handleClickDev = () => this.push('/dev') handleOpenImportModal = () => this.props.openModal(MODAL_ADD_ACCOUNTS) render() { - const { t, accounts, location, updateStatus } = this.props + const { t, accounts, location, updateStatus, developerMode } = this.props const { pathname } = location const addAccountButton = ( @@ -133,6 +154,15 @@ class MainSideBar extends PureComponent { onClick={this.handleClickExchange} isActive={pathname === '/exchange'} /> + {developerMode && ( + + )} ) => void, onChange?: Function, - onEnter?: (SyntheticKeyboardEvent) => void, + onEnter?: (SyntheticKeyboardEvent) => *, onEsc?: (SyntheticKeyboardEvent) => void, onFocus: (SyntheticInputEvent) => void, renderLeft?: any, diff --git a/src/components/layout/Default.js b/src/components/layout/Default.js index 5d70f1db..ca47557f 100644 --- a/src/components/layout/Default.js +++ b/src/components/layout/Default.js @@ -19,6 +19,7 @@ import AccountPage from 'components/AccountPage' import DashboardPage from 'components/DashboardPage' import ManagerPage from 'components/ManagerPage' import ExchangePage from 'components/ExchangePage' +import DevToolsPage from 'components/DevToolsPage' import SettingsPage from 'components/SettingsPage' import KeyboardContent from 'components/KeyboardContent' import PerfIndicator from 'components/PerfIndicator' @@ -110,6 +111,7 @@ class Default extends Component { + diff --git a/src/config/cryptocurrencies.js b/src/config/cryptocurrencies.js index 2667ddb7..21f56322 100644 --- a/src/config/cryptocurrencies.js +++ b/src/config/cryptocurrencies.js @@ -35,3 +35,12 @@ export const listCryptoCurrencies = memoize((withDevCrypto?: boolean) => .filter(c => supported.includes(c.id)) .sort((a, b) => a.name.localeCompare(b.name)), ) + +export const splittedCurrencies = { + bitcoin_cash: { + coinType: 0, + }, + bitcoin_gold: { + coinType: 0, + }, +} diff --git a/src/helpers/libcore.js b/src/helpers/libcore.js index 240eba5f..5f7f20e5 100644 --- a/src/helpers/libcore.js +++ b/src/helpers/libcore.js @@ -15,20 +15,11 @@ import type { NJSAccount, NJSOperation } from '@ledgerhq/ledger-core/src/ledgerc import { isSegwitPath, isUnsplitPath } from 'helpers/bip32' import * as accountIdHelper from 'helpers/accountId' import { NoAddressesFound } from 'config/errors' +import { splittedCurrencies } from 'config/cryptocurrencies' import { deserializeError } from './errors' import { getAccountPlaceholderName, getNewAccountPlaceholderName } from './accountName' import { timeoutTagged } from './promise' -// TODO: put that info inside currency itself -const SPLITTED_CURRENCIES = { - bitcoin_cash: { - coinType: 0, - }, - bitcoin_gold: { - coinType: 0, - }, -} - // each time there is a breaking change that needs use to clear cache on both libcore and js side, // we should bump a nonce in this map // tech notes: @@ -84,7 +75,7 @@ export async function scanAccountsOnDevice(props: Props): Promise } // TODO: put that info inside currency itself - if (currencyId in SPLITTED_CURRENCIES) { + if (currencyId in splittedCurrencies) { const splittedAccounts = await scanAccountsOnDeviceBySegwit({ ...commonParams, isSegwit: false, @@ -118,7 +109,7 @@ function encodeWalletName({ isSegwit: boolean, isUnsplit: boolean, }) { - const splitConfig = isUnsplit ? SPLITTED_CURRENCIES[currencyId] || null : null + const splitConfig = isUnsplit ? splittedCurrencies[currencyId] || null : null return `${publicKey}__${currencyId}${isSegwit ? '_segwit' : ''}${splitConfig ? '_unsplit' : ''}` } @@ -142,7 +133,7 @@ async function scanAccountsOnDeviceBySegwit({ isUnsplit: boolean, }): Promise { const customOpts = - isUnsplit && SPLITTED_CURRENCIES[currencyId] ? SPLITTED_CURRENCIES[currencyId] : null + isUnsplit && splittedCurrencies[currencyId] ? splittedCurrencies[currencyId] : null const { coinType } = customOpts ? customOpts.coinType : getCryptoCurrencyById(currencyId) const path = `${isSegwit ? '49' : '44'}'/${coinType}'` @@ -345,7 +336,7 @@ async function getOrCreateWallet( return wallet } catch (err) { const currency = await timeoutTagged('getCurrency', 5000, pool.getCurrency(currencyId)) - const splitConfig = isUnsplit ? SPLITTED_CURRENCIES[currencyId] || null : null + const splitConfig = isUnsplit ? splittedCurrencies[currencyId] || null : null const coinType = splitConfig ? splitConfig.coinType : '' const walletConfig = isSegwit ? { @@ -530,7 +521,7 @@ export async function syncAccount({ const decodedAccountId = accountIdHelper.decode(accountId) const { walletName } = decodedAccountId const isSegwit = isSegwitPath(freshAddressPath) - const isUnsplit = isUnsplitPath(freshAddressPath, SPLITTED_CURRENCIES[currencyId]) + const isUnsplit = isUnsplitPath(freshAddressPath, splittedCurrencies[currencyId]) const njsWallet = await getOrCreateWallet(core, walletName, currencyId, isSegwit, isUnsplit) let njsAccount @@ -584,3 +575,59 @@ export function libcoreAmountToBigNumber(njsAmount: *): BigNumber { export function bigNumberToLibcoreAmount(core: *, njsWalletCurrency: *, bigNumber: BigNumber) { return new core.NJSAmount(njsWalletCurrency, 0).fromHex(njsWalletCurrency, bigNumber.toString(16)) } + +export async function scanAccountsFromXPUB({ + core, + currencyId, + xpub, + isSegwit, + isUnsplit, +}: { + core: *, + currencyId: string, + xpub: string, + isSegwit: boolean, + isUnsplit: boolean, +}) { + const currency = getCryptoCurrencyById(currencyId) + const walletName = encodeWalletName({ + publicKey: `debug_${xpub}`, + currencyId, + isSegwit, + isUnsplit, + }) + + const wallet = await getOrCreateWallet(core, walletName, currencyId, isSegwit, isUnsplit) + + await wallet.eraseDataSince(new Date(0)) + + const index = 0 + + const extendedInfos = { + index, + owners: ['main'], + derivations: [ + `${isSegwit ? '49' : '44'}'/${currency.coinType}'`, + `${isSegwit ? '49' : '44'}'/${currency.coinType}'/0`, + ], + extendedKeys: [xpub], + } + + const account = await wallet.newAccountWithExtendedKeyInfo(extendedInfos) + await coreSyncAccount(core, account) + const query = account.queryOperations() + const ops = await query.complete().execute() + const rawAccount = await buildAccountRaw({ + njsAccount: account, + isSegwit, + isUnsplit, + accountIndex: index, + wallet, + walletName, + currencyId, + core, + ops, + }) + + return rawAccount +} diff --git a/static/i18n/en/app.json b/static/i18n/en/app.json index 873ea635..b94db001 100644 --- a/static/i18n/en/app.json +++ b/static/i18n/en/app.json @@ -78,7 +78,8 @@ "menu": "Menu", "accounts": "Accounts ({{count}})", "manager": "Manager", - "exchange": "Buy/Trade" + "exchange": "Buy/Trade", + "developer": "Dev tools" }, "account": { "lastOperations": "Last operations",