Browse Source

Merge pull request #346 from gre/ethereum

Implement Ethereum
master
Gaëtan Renaudeau 7 years ago
committed by GitHub
parent
commit
8ff54e8590
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      package.json
  2. 69
      src/api/Ethereum.js
  3. 13
      src/api/Fees.js
  4. 19
      src/api/Ledger.js
  5. 274
      src/bridge/EthereumJSBridge.js
  6. 22
      src/bridge/EthereumMockJSBridge.js
  7. 2
      src/bridge/LibcoreBridge.js
  8. 2
      src/bridge/UnsupportedBridge.js
  9. 8
      src/bridge/index.js
  10. 33
      src/commands/getAddress.js
  11. 28
      src/commands/signTransaction.js
  12. 47
      src/components/DeviceCheckAddress.js
  13. 24
      src/components/DeviceConnect/index.js
  14. 65
      src/components/EnsureDeviceApp/index.js
  15. 184
      src/components/ReceiveBox.js
  16. 27
      src/components/modals/AddAccount/index.js
  17. 3
      src/components/modals/StepConnectDevice.js
  18. 32
      src/helpers/bip32path.js
  19. 8
      src/helpers/getAddressForCurrency/btc.js
  20. 8
      src/helpers/getAddressForCurrency/ethereum.js
  21. 11
      src/helpers/getAddressForCurrency/index.js
  22. 118
      src/helpers/ipc.js
  23. 68
      src/helpers/signTransactionForCurrency/ethereum.js
  24. 27
      src/helpers/signTransactionForCurrency/index.js
  25. 59
      src/internals/accounts/helpers.js
  26. 34
      src/internals/accounts/index.js
  27. 1
      src/internals/accounts/scanAccountsOnDevice.js
  28. 36
      src/internals/devices/ensureDeviceApp.js
  29. 13
      src/internals/devices/index.js
  30. 13
      src/internals/index.js
  31. 6
      src/renderer/events.js
  32. 561
      yarn.lock

6
package.json

@ -40,7 +40,7 @@
"@ledgerhq/hw-transport": "^4.12.0",
"@ledgerhq/hw-transport-node-hid": "^4.12.0",
"@ledgerhq/ledger-core": "^1.0.1",
"@ledgerhq/live-common": "^2.6.0",
"@ledgerhq/live-common": "^2.7.2",
"axios": "^0.18.0",
"babel-runtime": "^6.26.0",
"bcryptjs": "^2.4.3",
@ -53,6 +53,7 @@
"downshift": "^1.31.9",
"electron-store": "^1.3.0",
"electron-updater": "^2.21.8",
"ethereumjs-tx": "^1.3.4",
"fuse.js": "^3.2.0",
"history": "^4.7.2",
"i18next": "^11.2.2",
@ -81,6 +82,8 @@
"redux-actions": "^2.3.0",
"redux-thunk": "^2.2.0",
"reselect": "^3.0.1",
"rxjs": "^6.2.0",
"rxjs-compat": "^6.2.0",
"smooth-scrollbar": "^8.2.7",
"source-map": "0.7.2",
"source-map-support": "^0.5.4",
@ -88,6 +91,7 @@
"styled-system": "^2.2.1",
"tippy.js": "^2.5.2",
"uncontrollable": "^6.0.0",
"uuid": "^3.2.1",
"ws": "^5.1.1",
"zxcvbn": "^4.4.2"
},

69
src/api/Ethereum.js

@ -0,0 +1,69 @@
// @flow
import axios from 'axios'
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import { blockchainBaseURL } from './Ledger'
export type Block = { height: number } // TODO more fields actually
export type Tx = {
hash: string,
received_at: string,
nonce: string,
value: number,
gas: number,
gas_price: number,
cumulative_gas_used: number,
gas_used: number,
from: string,
to: string,
input: string,
index: number,
block: {
hash: string,
height: number,
time: string,
},
confirmations: number,
}
export type API = {
getTransactions: (
address: string,
blockHash: ?string,
) => Promise<{
truncated: boolean,
txs: Tx[],
}>,
getCurrentBlock: () => Promise<Block>,
getAccountNonce: (address: string) => Promise<string>,
broadcastTransaction: (signedTransaction: string) => Promise<string>,
getAccountBalance: (address: string) => Promise<number>,
}
export const apiForCurrency = (currency: CryptoCurrency): API => {
const baseURL = blockchainBaseURL(currency)
return {
async getTransactions(address, blockHash) {
const { data } = await axios.get(`${baseURL}/addresses/${address}/transactions`, {
params: { blockHash, noToken: 1 },
})
return data
},
async getCurrentBlock() {
const { data } = await axios.get(`${baseURL}/blocks/current`)
return data
},
async getAccountNonce(address) {
const { data } = await axios.get(`${baseURL}/addresses/${address}/nonce`)
return data[0].nonce
},
async broadcastTransaction(tx) {
const { data } = await axios.post(`${baseURL}/transactions/send`, { tx })
return data.result
},
async getAccountBalance(address) {
const { data } = await axios.get(`${baseURL}/addresses/${address}/balance`)
return data[0].balance
},
}
}

13
src/api/Fees.js

@ -1,21 +1,14 @@
// @flow
import axios from 'axios'
import type { Currency } from '@ledgerhq/live-common/lib/types'
import { get } from './Ledger'
const mapping = {
bch: 'abc',
}
const currencyToFeeTicker = (currency: Currency) => {
const tickerLowerCase = currency.ticker.toLowerCase()
return mapping[tickerLowerCase] || tickerLowerCase
}
import { blockchainBaseURL } from './Ledger'
export type Fees = {
[_: string]: number,
}
export const getEstimatedFees = async (currency: Currency): Promise<Fees> => {
const { data, status } = await get(`blockchain/v2/${currencyToFeeTicker(currency)}/fees`)
const { data, status } = await axios.get(`${blockchainBaseURL(currency)}/fees`)
if (data) {
return data
}

19
src/api/Ledger.js

@ -1,9 +1,18 @@
// @flow
import axios from 'axios'
import type { Currency } from '@ledgerhq/live-common/lib/types'
const BASE_URL = process.env.LEDGER_REST_API_BASE || 'https://api.ledgerwallet.com/'
export const get = (url: string, config: *): Promise<*> =>
axios.get(`${BASE_URL}${url}`, {
...config,
})
const mapping = {
bitcoin_cash: 'abc',
ethereum_classic: 'ethc',
ethereum_testnet: 'eth_testnet',
}
export const currencyToFeeTicker = (currency: Currency) => {
const tickerLowerCase = currency.ticker.toLowerCase()
return mapping[currency.id] || tickerLowerCase
}
export const blockchainBaseURL = (currency: Currency) =>
`${BASE_URL}blockchain/v2/${currencyToFeeTicker(currency)}`

274
src/bridge/EthereumJSBridge.js

@ -1,10 +1,19 @@
// @flow
import React from 'react'
import EthereumKind from 'components/FeesField/EthereumKind'
import type { EditProps } from './types'
import makeMockBridge from './makeMockBridge'
import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import { apiForCurrency } from 'api/Ethereum'
import type { Tx } from 'api/Ethereum'
import { makeBip44Path } from 'helpers/bip32path'
import getAddressCommand from 'commands/getAddress'
import signTransactionCommand from 'commands/signTransaction'
import type { EditProps, WalletBridge } from './types'
const EditFees = ({ account, onChange, value }: EditProps<*>) => (
// TODO in future it would be neat to support eip55
type Transaction = *
const EditFees = ({ account, onChange, value }: EditProps<Transaction>) => (
<EthereumKind
onChange={gasPrice => {
onChange({ ...value, gasPrice })
@ -14,9 +23,264 @@ const EditFees = ({ account, onChange, value }: EditProps<*>) => (
/>
)
export default makeMockBridge({
extraInitialTransactionProps: () => ({ gasPrice: 0 }),
const toAccountOperation = (account: Account) => (tx: Tx): Operation => {
const sending = account.address.toLowerCase() === tx.from.toLowerCase()
return {
id: tx.hash,
hash: tx.hash,
address: sending ? tx.to : tx.from,
amount: (sending ? -1 : 1) * tx.value,
blockHeight: tx.block.height,
blockHash: tx.block.hash,
accountId: account.id,
senders: [tx.from],
recipients: [tx.to],
date: new Date(tx.received_at),
}
}
function isRecipientValid(currency, recipient) {
return !!recipient.match(/^0x[0-9a-fA-F]{40}$/)
}
function mergeOps(existing: Operation[], newFetched: Operation[]) {
const ids = existing.map(o => o.id)
const all = existing.concat(newFetched.filter(o => !ids.includes(o.id)))
return all.sort((a, b) => a.date - b.date)
}
const paginateMoreTransactions = async (
account: Account,
acc: Operation[],
): Promise<Operation[]> => {
const api = apiForCurrency(account.currency)
const { txs } = await api.getTransactions(
account.address,
acc.length ? acc[acc.length - 1].blockHash : undefined,
)
if (txs.length === 0) return acc
return mergeOps(acc, txs.map(toAccountOperation(account)))
}
const EthereumBridge: WalletBridge<Transaction> = {
scanAccountsOnDevice(currency, deviceId, { next, complete, error }) {
let finished = false
const unsubscribe = () => {
finished = true
}
const api = apiForCurrency(currency)
// in future ideally what we want is:
// return mergeMap(addressesObservable, address => fetchAccount(address))
let balanceZerosCount = 0
let currentBlockPromise
function lazyCurrentBlock() {
if (!currentBlockPromise) {
currentBlockPromise = api.getCurrentBlock()
}
return currentBlockPromise
}
async function stepAddress(
index,
{ address, path },
): { account?: Account, complete?: boolean } {
const balance = await api.getAccountBalance(address)
if (finished) return {}
if (balance === 0) {
if (balanceZerosCount === 0) {
// first zero account will emit one account as opportunity to create a new account..
const currentBlock = await lazyCurrentBlock()
const accountId = `${currency.id}_${address}`
const account: Account = {
id: accountId,
xpub: '',
path,
walletPath: String(index),
name: 'New Account',
isSegwit: false,
address,
addresses: [address],
balance,
blockHeight: currentBlock.height,
archived: true,
index,
currency,
operations: [],
unit: currency.units[0],
lastSyncDate: new Date(),
}
// NB we currently stop earlier. in future we shouldn't stop here, just continue & user will stop at the end!
// NB (what's the max tho?)
return { account, complete: true }
}
balanceZerosCount++
return { complete: true }
}
const currentBlock = await lazyCurrentBlock()
if (finished) return {}
const { txs } = await api.getTransactions(address)
if (finished) return {}
const accountId = `${currency.id}_${address}`
const account: Account = {
id: accountId,
xpub: '',
path,
walletPath: String(index),
name: address.slice(32),
isSegwit: false,
address,
addresses: [address],
balance,
blockHeight: currentBlock.height,
archived: true,
index,
currency,
operations: [],
unit: currency.units[0],
lastSyncDate: new Date(),
}
account.operations = txs.map(toAccountOperation(account))
return { account }
}
async function main() {
try {
for (let index = 0; index < 255; index++) {
const res = await getAddressCommand
.send({
currencyId: currency.id,
devicePath: deviceId,
path: makeBip44Path({
currency,
x: index,
}),
})
.toPromise()
const r = await stepAddress(index, res)
if (r.account) next(r.account)
if (r.complete) {
complete()
break
}
}
} catch (e) {
error(e)
}
}
main()
return { unsubscribe }
},
synchronize({ address, blockHeight, currency }, { next, complete, error }) {
let unsubscribed = false
const api = apiForCurrency(currency)
async function main() {
try {
const block = await api.getCurrentBlock()
if (unsubscribed) return
if (block.height === blockHeight) {
complete()
} else {
const balance = await api.getAccountBalance(address)
if (unsubscribed) return
const { txs } = await api.getTransactions(address)
if (unsubscribed) return
next(a => {
const currentOps = a.operations
const newOps = txs.map(toAccountOperation(a))
const { length: newLength } = newOps
const { length } = currentOps
if (
// still empty
(length === 0 && newLength === 0) ||
// latest is still same
(length > 0 && newLength > 0 && currentOps[0].id === newOps[0].id)
) {
return a
}
const operations = mergeOps(currentOps, newOps)
return {
...a,
operations,
balance,
blockHeight: block.height,
lastSyncDate: new Date(),
}
})
complete()
}
} catch (e) {
error(e)
}
}
main()
return {
unsubscribe() {
unsubscribed = true
},
}
},
pullMoreOperations: async account => {
const operations = await paginateMoreTransactions(account, account.operations)
return a => ({ ...a, operations })
},
isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)),
createTransaction: () => ({
amount: 0,
recipient: '',
gasPrice: 0,
}),
editTransactionAmount: (account, t, amount) => ({
...t,
amount,
}),
getTransactionAmount: (a, t) => t.amount,
editTransactionRecipient: (account, t, recipient) => ({
...t,
recipient,
}),
getTransactionRecipient: (a, t) => t.recipient,
isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false,
// $FlowFixMe
EditFees,
getTotalSpent: (a, t) => Promise.resolve(t.amount + t.gasPrice),
getMaxAmount: (a, t) => Promise.resolve(a.balance - t.gasPrice),
signAndBroadcast: async (a, t, deviceId) => {
const api = apiForCurrency(a.currency)
const nonce = await api.getAccountNonce(a.address)
const transaction = await signTransactionCommand
.send({
currencyId: a.currency.id,
devicePath: deviceId,
path: a.path,
transaction: { ...t, nonce },
})
.toPromise()
const result = await api.broadcastTransaction(transaction)
return result
},
}
export default EthereumBridge

22
src/bridge/EthereumMockJSBridge.js

@ -0,0 +1,22 @@
// @flow
import React from 'react'
import EthereumKind from 'components/FeesField/EthereumKind'
import type { EditProps } from './types'
import makeMockBridge from './makeMockBridge'
const EditFees = ({ account, onChange, value }: EditProps<*>) => (
<EthereumKind
onChange={gasPrice => {
onChange({ ...value, gasPrice })
}}
gasPrice={value.gasPrice}
account={account}
/>
)
export default makeMockBridge({
extraInitialTransactionProps: () => ({ gasPrice: 0 }),
EditFees,
getTotalSpent: (a, t) => Promise.resolve(t.amount + t.gasPrice),
getMaxAmount: (a, t) => Promise.resolve(a.balance - t.gasPrice),
})

2
src/bridge/LibcoreBridge.js

@ -116,8 +116,6 @@ const LibcoreBridge: WalletBridge<Transaction> = {
}
},
refreshLastOperations: () => Promise.reject(notImplemented),
pullMoreOperations: () => Promise.reject(notImplemented),
isRecipientValid: (currency, recipient) => Promise.resolve(recipient.length > 0),

2
src/bridge/UnsupportedBridge.js

@ -14,8 +14,6 @@ const UnsupportedBridge: WalletBridge<*> = {
return { unsubscribe() {} }
},
refreshLastOperations: () => Promise.reject(genericError),
pullMoreOperations: () => Promise.reject(genericError),
isRecipientValid: () => Promise.reject(genericError),

8
src/bridge/index.js

@ -8,7 +8,11 @@ import EthereumJSBridge from './EthereumJSBridge'
const RippleJSBridge = UnsupportedBridge
export const getBridgeForCurrency = (currency: Currency): WalletBridge<any> => {
if (currency.id === 'ethereum') return EthereumJSBridge // polyfill js
if (currency.id === 'ripple') return RippleJSBridge // polyfill js
if (currency.id.indexOf('ethereum') === 0) {
return EthereumJSBridge // polyfill js
}
if (currency.id === 'ripple') {
return RippleJSBridge // polyfill js
}
return LibcoreBridge // libcore for the rest
}

33
src/commands/getAddress.js

@ -0,0 +1,33 @@
// @flow
import { createCommand, Command } from 'helpers/ipc'
import { fromPromise } from 'rxjs/observable/fromPromise'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
import getAddressForCurrency from 'helpers/getAddressForCurrency'
type Input = {
currencyId: string,
devicePath: string,
path: string,
verify?: boolean,
segwit?: boolean,
}
type Result = {
address: string,
path: string,
publicKey: string,
}
const cmd: Command<Input, Result> = createCommand(
'devices',
'getAddress',
({ currencyId, devicePath, path, ...options }) =>
fromPromise(
CommNodeHid.open(devicePath).then(transport =>
getAddressForCurrency(currencyId)(transport, currencyId, path, options),
),
),
)
export default cmd

28
src/commands/signTransaction.js

@ -0,0 +1,28 @@
// @flow
import { createCommand, Command } from 'helpers/ipc'
import { fromPromise } from 'rxjs/observable/fromPromise'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
import signTransactionForCurrency from 'helpers/signTransactionForCurrency'
type Input = {
currencyId: string,
devicePath: string,
path: string,
transaction: *,
}
type Result = string
const cmd: Command<Input, Result> = createCommand(
'devices',
'signTransaction',
({ currencyId, devicePath, path, transaction }) =>
fromPromise(
CommNodeHid.open(devicePath).then(transport =>
signTransactionForCurrency(currencyId)(transport, currencyId, path, transaction),
),
),
)
export default cmd

47
src/components/DeviceCheckAddress.js

@ -1,16 +1,14 @@
// @flow
import { PureComponent } from 'react'
import { ipcRenderer } from 'electron'
import type { Account } from '@ledgerhq/live-common/lib/types'
import type { Device } from 'types/common'
import { sendEvent } from 'renderer/events'
import getAddress from 'commands/getAddress'
type Props = {
onCheck: Function,
render: Function,
onCheck: boolean => void,
render: ({ isVerified?: ?boolean }) => *,
account: Account,
device: Device,
}
@ -26,38 +24,39 @@ class CheckAddress extends PureComponent<Props, State> {
componentDidMount() {
const { device, account } = this.props
ipcRenderer.on('msg', this.handleMsgEvent)
this.verifyAddress({ device, account })
}
componentWillUnmount() {
ipcRenderer.removeListener('msg', this.handleMsgEvent)
componentDidUnmount() {
if (this.sub) this.sub.unsubscribe()
}
handleMsgEvent = (e: any, { type }: { type: string }) => {
const { onCheck } = this.props
sub: *
if (type === 'accounts.verifyAddress.success') {
verifyAddress = ({ device, account }: { device: Device, account: Account }) => {
this.sub = getAddress
.send({
currencyId: account.currency.id,
devicePath: device.path,
path: account.path,
segwit: account.isSegwit,
verify: true,
})
.subscribe({
next: () => {
this.setState({
isVerified: true,
})
onCheck(true)
}
if (type === 'accounts.verifyAddress.fail') {
this.props.onCheck(true)
},
error: () => {
this.setState({
isVerified: false,
})
onCheck(false)
}
}
verifyAddress = ({ device, account }: { device: Device, account: Account }) =>
sendEvent('accounts', 'verifyAddress', {
pathDevice: device.path,
path: account.path,
segwit: account.path.startsWith("49'"), // TODO: store segwit info in account
this.props.onCheck(false)
},
})
}
render() {
const { render } = this.props

24
src/components/DeviceConnect/index.js

@ -143,6 +143,7 @@ type Props = {
deviceSelected: ?Device,
onChangeDevice: Device => void,
t: T,
errorMessage: ?string,
}
const emitChangeDevice = props => {
@ -180,7 +181,15 @@ class DeviceConnect extends PureComponent<Props> {
}
render() {
const { deviceSelected, accountName, currency, t, onChangeDevice, devices } = this.props
const {
deviceSelected,
errorMessage,
accountName,
currency,
t,
onChangeDevice,
devices,
} = this.props
const appState = this.getAppState()
@ -250,19 +259,24 @@ class DeviceConnect extends PureComponent<Props> {
<StepCheck checked={appState.success} hasErrors={appState.fail} />
</StepContent>
</Step>
{accountName !== null && (
<Info hasErrors={appState.fail}>
{appState.fail ? (
<Info hasErrors>
<Box>
<IconInfoCircle size={12} />
</Box>
<Box>
<Box style={{ userSelect: 'text' }}>
{accountName ? (
<Trans i18nKey="deviceConnect:info" parent="div">
{'You must use the device associated to the account '}
<strong>{accountName}</strong>
</Trans>
) : (
String(errorMessage || '')
)}
</Box>
</Info>
)}
) : null}
</Box>
)
}

65
src/components/EnsureDeviceApp/index.js

@ -2,20 +2,20 @@
import invariant from 'invariant'
import { PureComponent } from 'react'
import { connect } from 'react-redux'
import { ipcRenderer } from 'electron'
import { makeBip44Path } from 'helpers/bip32path'
import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import type { Device } from 'types/common'
import { sendEvent } from 'renderer/events'
import { getDevices } from 'reducers/devices'
import type { State as StoreState } from 'reducers/index'
import getAddress from 'commands/getAddress'
type OwnProps = {
currency: ?CryptoCurrency,
deviceSelected: ?Device,
account: ?Account,
onStatusChange?: (DeviceStatus, AppStatus) => void,
onStatusChange?: (DeviceStatus, AppStatus, ?string) => void,
// TODO prefer children function
render?: ({
appStatus: AppStatus,
@ -23,6 +23,7 @@ type OwnProps = {
devices: Device[],
deviceSelected: ?Device,
deviceStatus: DeviceStatus,
errorMessage: ?string,
}) => React$Element<*>,
}
@ -37,6 +38,7 @@ type AppStatus = 'success' | 'fail' | 'progress'
type State = {
deviceStatus: DeviceStatus,
appStatus: AppStatus,
errorMessage: ?string,
}
const mapStateToProps = (state: StoreState) => ({
@ -47,10 +49,10 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
state = {
appStatus: 'progress',
deviceStatus: this.props.deviceSelected ? 'connected' : 'unconnected',
errorMessage: null,
}
componentDidMount() {
ipcRenderer.on('msg', this.handleMsgEvent)
if (this.props.deviceSelected !== null) {
this.checkAppOpened()
}
@ -84,69 +86,61 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
}
componentWillUnmount() {
ipcRenderer.removeListener('msg', this.handleMsgEvent)
clearTimeout(this._timeout)
}
checkAppOpened = () => {
checkAppOpened = async () => {
const { deviceSelected, account, currency } = this.props
if (!deviceSelected) {
return
}
let options = null
let options
if (account) {
options = {
devicePath: deviceSelected.path,
currencyId: account.currency.id,
accountPath: account.path,
path: account.path,
accountAddress: account.address,
segwit: account.path.startsWith("49'"), // TODO: store segwit info in account
}
} else if (currency) {
options = {
devicePath: deviceSelected.path,
currencyId: currency.id,
path: makeBip44Path({ currency }),
}
} else {
throw new Error('either currency or account is required')
}
sendEvent('devices', 'ensureDeviceApp', {
devicePath: deviceSelected.path,
...options,
})
try {
const { address } = await getAddress.send(options).toPromise()
if (account && account.address !== address) {
throw new Error('Account address is different than device address')
}
_timeout: *
handleStatusChange = (deviceStatus, appStatus) => {
const { onStatusChange } = this.props
clearTimeout(this._timeout)
this.setState({ deviceStatus, appStatus })
onStatusChange && onStatusChange(deviceStatus, appStatus)
}
handleMsgEvent = (e, { type, data }) => {
const { deviceStatus } = this.state
const { deviceSelected } = this.props
if (!deviceSelected) {
return
this.handleStatusChange(this.state.deviceStatus, 'success')
} catch (e) {
this.handleStatusChange(this.state.deviceStatus, 'fail', e.message)
}
if (type === 'devices.ensureDeviceApp.success' && deviceSelected.path === data.devicePath) {
this.handleStatusChange(deviceStatus, 'success')
this._timeout = setTimeout(this.checkAppOpened, 1e3)
}
if (type === 'devices.ensureDeviceApp.fail' && deviceSelected.path === data.devicePath) {
this.handleStatusChange(deviceStatus, 'fail')
this._timeout = setTimeout(this.checkAppOpened, 1e3)
}
_timeout: *
handleStatusChange = (deviceStatus, appStatus, errorMessage = null) => {
const { onStatusChange } = this.props
clearTimeout(this._timeout)
this.setState({ deviceStatus, appStatus, errorMessage })
onStatusChange && onStatusChange(deviceStatus, appStatus, errorMessage)
}
render() {
const { currency, account, devices, deviceSelected, render } = this.props
const { appStatus, deviceStatus } = this.state
const { appStatus, deviceStatus, errorMessage } = this.state
if (render) {
const cur = account ? account.currency : currency
@ -157,6 +151,7 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
devices,
deviceSelected: deviceStatus === 'connected' ? deviceSelected : null,
deviceStatus,
errorMessage,
})
}

184
src/components/ReceiveBox.js

@ -1,184 +0,0 @@
// @flow
import React, { PureComponent } from 'react'
import { connect } from 'react-redux'
import styled from 'styled-components'
import { ipcRenderer } from 'electron'
import type { Account } from '@ledgerhq/live-common/lib/types'
import type { Device } from 'types/common'
import { getCurrentDevice } from 'reducers/devices'
import { sendEvent } from 'renderer/events'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import CopyToClipboard from 'components/base/CopyToClipboard'
import Print from 'components/base/Print'
import QRCode from 'components/base/QRCode'
import Text from 'components/base/Text'
export const AddressBox = styled(Box).attrs({
bg: 'lightGrey',
p: 2,
})`
border-radius: ${p => p.theme.radii[1]}px;
border: 1px solid ${p => p.theme.colors.fog};
cursor: text;
text-align: center;
user-select: text;
word-break: break-all;
`
const Action = styled(Box).attrs({
alignItems: 'center',
color: 'fog',
flex: 1,
flow: 1,
fontSize: 0,
})`
font-weight: bold;
text-align: center;
cursor: pointer;
text-transform: uppercase;
&:hover {
color: ${p => p.theme.colors.grey};
}
`
const mapStateToProps = state => ({
currentDevice: getCurrentDevice(state),
})
type Props = {
currentDevice: Device | null,
account: Account,
amount?: string,
}
type State = {
isVerified: null | boolean,
isDisplay: boolean,
}
const defaultState = {
isVerified: null,
isDisplay: false,
}
class ReceiveBox extends PureComponent<Props, State> {
static defaultProps = {
amount: undefined,
}
state = {
...defaultState,
}
componentDidMount() {
ipcRenderer.on('msg', this.handleMsgEvent)
}
componentWillReceiveProps(nextProps: Props) {
if (this.props.account !== nextProps.account) {
this.setState({
...defaultState,
})
}
}
componentWillUnmount() {
ipcRenderer.removeListener('msg', this.handleMsgEvent)
this.setState({
...defaultState,
})
}
handleMsgEvent = (e, { type }) => {
if (type === 'wallet.verifyAddress.success') {
this.setState({
isVerified: true,
})
}
if (type === 'wallet.verifyAddress.fail') {
this.setState({
isVerified: false,
})
}
}
handleVerifyAddress = () => {
const { currentDevice, account } = this.props
if (currentDevice !== null) {
sendEvent('usb', 'wallet.verifyAddress', {
pathDevice: currentDevice.path,
path: `${account.walletPath}${account.path}`,
})
this.setState({
isDisplay: true,
})
}
}
render() {
const { amount, account } = this.props
const { isVerified, isDisplay } = this.state
if (!isDisplay) {
return (
<Box grow alignItems="center" justifyContent="center">
<Button onClick={this.handleVerifyAddress}>Display address on device</Button>
</Box>
)
}
const { address } = account
return (
<Box flow={3}>
<Box>
isVerified:{' '}
{isVerified === null
? 'not yet...'
: isVerified === true
? 'ok!'
: '/!\\ contact support'}
</Box>
<Box alignItems="center">
<QRCode size={150} data={`bitcoin:${address}${amount ? `?amount=${amount}` : ''}`} />
</Box>
<Box alignItems="center" flow={2}>
<Text fontSize={1}>{'Current address'}</Text>
<AddressBox>{address}</AddressBox>
</Box>
<Box horizontal>
<CopyToClipboard
data={address}
render={copy => (
<Action onClick={copy}>
<span>{'Copy'}</span>
</Action>
)}
/>
<Print
data={{ address, amount }}
render={(print, isLoading) => (
<Action onClick={print}>
<span>{isLoading ? '...' : 'Print'}</span>
</Action>
)}
/>
<Action>
<span>{'Share'}</span>
</Action>
</Box>
</Box>
)
}
}
export default connect(mapStateToProps, null)(ReceiveBox)

27
src/components/modals/AddAccount/index.js

@ -4,6 +4,7 @@ import React, { PureComponent } from 'react'
import { connect } from 'react-redux'
import { compose } from 'redux'
import { translate } from 'react-i18next'
import { createStructuredSelector } from 'reselect'
import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types'
@ -12,7 +13,12 @@ import type { Device, T } from 'types/common'
import { MODAL_ADD_ACCOUNT } from 'config/constants'
import { closeModal } from 'reducers/modals'
import { canCreateAccount, getAccounts, getArchivedAccounts } from 'reducers/accounts'
import {
canCreateAccount,
getAccounts,
getVisibleAccounts,
getArchivedAccounts,
} from 'reducers/accounts'
import { addAccount, updateAccount } from 'actions/accounts'
@ -34,10 +40,11 @@ const GET_STEPS = t => [
{ label: t('addAccount:steps.importAccounts.title'), Comp: StepImport },
]
const mapStateToProps = state => ({
existingAccounts: getAccounts(state),
archivedAccounts: getArchivedAccounts(state),
canCreateAccount: canCreateAccount(state),
const mapStateToProps = createStructuredSelector({
existingAccounts: getAccounts,
visibleAccounts: getVisibleAccounts,
archivedAccounts: getArchivedAccounts,
canCreateAccount,
})
const mapDispatchToProps = {
@ -49,6 +56,7 @@ const mapDispatchToProps = {
type Props = {
existingAccounts: Account[],
addAccount: Function,
visibleAccounts: Account[],
archivedAccounts: Account[],
canCreateAccount: boolean,
closeModal: Function,
@ -93,7 +101,7 @@ class AddAccountModal extends PureComponent<Props, State> {
scanSubscription: *
startScanAccountsDevice() {
const { existingAccounts, addAccount } = this.props
const { visibleAccounts } = this.props
const { deviceSelected, currency } = this.state
if (!deviceSelected || !currency) {
@ -102,8 +110,7 @@ class AddAccountModal extends PureComponent<Props, State> {
const bridge = getBridgeForCurrency(currency)
this.scanSubscription = bridge.scanAccountsOnDevice(currency, deviceSelected.path, {
next: account => {
if (!existingAccounts.some(a => a.id === account.id)) {
addAccount(account)
if (!visibleAccounts.some(a => a.id === account.id)) {
this.setState(state => ({
scannedAccounts: [...state.scannedAccounts, account],
}))
@ -161,9 +168,9 @@ class AddAccountModal extends PureComponent<Props, State> {
handleChangeStatus = (deviceStatus, appStatus) => this.setState({ appStatus })
handleImportAccount = () => {
const { updateAccount } = this.props
const { addAccount } = this.props
const { selectedAccounts } = this.state
selectedAccounts.forEach(a => updateAccount({ ...a, archived: false }))
selectedAccounts.forEach(a => addAccount({ ...a, archived: false }))
this.setState({ selectedAccounts: [] })
closeModal(MODAL_ADD_ACCOUNT)
this.props.counterValuesPolling.poll()

3
src/components/modals/StepConnectDevice.js

@ -30,7 +30,7 @@ const StepConnectDevice = ({
currency={currency}
deviceSelected={deviceSelected}
onStatusChange={onStatusChange}
render={({ currency, appStatus, devices, deviceSelected }) => (
render={({ currency, appStatus, devices, deviceSelected, errorMessage }) => (
<DeviceConnect
accountName={accountName}
currency={currency}
@ -38,6 +38,7 @@ const StepConnectDevice = ({
devices={devices}
deviceSelected={deviceSelected}
onChangeDevice={onChangeDevice}
errorMessage={errorMessage}
/>
)}
/>

32
src/helpers/bip32path.js

@ -0,0 +1,32 @@
// @flow
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types'
function shouldDerivateChangeFieldInsteadOfAccount(c: CryptoCurrency) {
// ethereum have a special way of derivating things
return c.id.indexOf('ethereum') === 0
}
// https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki
// x is a derivation index. we don't always derivate the same part of the path
export function makeBip44Path({
currency,
segwit,
x,
}: {
currency: CryptoCurrency,
segwit?: boolean,
x?: number,
}): string {
const purpose = segwit ? 49 : 44
const coinType = currency.coinType
let path = `${purpose}'/${coinType}'`
if (shouldDerivateChangeFieldInsteadOfAccount(currency)) {
path += "/0'"
if (x !== undefined) {
path += `/${x}`
}
} else if (x !== undefined) {
path += `/${x}'`
}
return path
}

8
src/internals/devices/getAddressForCurrency/btc.js → src/helpers/getAddressForCurrency/btc.js

@ -2,12 +2,11 @@
import Btc from '@ledgerhq/hw-app-btc'
import type Transport from '@ledgerhq/hw-transport'
import { getPath } from 'internals/accounts/helpers'
export default async (
transport: Transport<*>,
currencyId: string,
bip32path: ?string,
path: string,
{
segwit = true,
verify = false,
@ -17,7 +16,6 @@ export default async (
},
) => {
const btc = new Btc(transport)
const path = bip32path || getPath({ currencyId, segwit })
const { bitcoinAddress } = await btc.getWalletPublicKey(path, verify, segwit)
return bitcoinAddress
const { bitcoinAddress, publicKey } = await btc.getWalletPublicKey(path, verify, segwit)
return { address: bitcoinAddress, path, publicKey }
}

8
src/internals/devices/getAddressForCurrency/ethereum.js → src/helpers/getAddressForCurrency/ethereum.js

@ -2,16 +2,14 @@
import Eth from '@ledgerhq/hw-app-eth'
import type Transport from '@ledgerhq/hw-transport'
import { getPath } from 'internals/accounts/helpers'
export default async (
transport: Transport<*>,
currencyId: string,
bip32path: ?string,
path: string,
{ verify = false }: { verify: boolean },
) => {
const eth = new Eth(transport)
const path = bip32path || getPath({ currencyId })
const { address } = await eth.getAddress(path, verify)
return address
const { address, publicKey } = await eth.getAddress(path, verify)
return { path, address, publicKey }
}

11
src/internals/devices/getAddressForCurrency/index.js → src/helpers/getAddressForCurrency/index.js

@ -2,13 +2,14 @@
import type Transport from '@ledgerhq/hw-transport'
import btc from './btc'
import ethereum from './ethereum'
type Resolver = (
transport: Transport<*>,
currencyId: string,
bip32path: ?string, // if provided use this path, otherwise resolve it
path: string,
options: *,
) => Promise<string>
) => Promise<{ address: string, path: string, publicKey: string }>
type Module = (currencyId: string) => Resolver
@ -19,8 +20,10 @@ const all = {
bitcoin: btc,
bitcoin_testnet: btc,
ethereum: btc,
ethereum_testnet: btc,
ethereum,
ethereum_testnet: ethereum,
ethereum_classic: ethereum,
ethereum_classic_testnet: ethereum,
}
const getAddressForCurrency: Module = (currencyId: string) => all[currencyId] || fallback(currencyId)

118
src/helpers/ipc.js

@ -0,0 +1,118 @@
// @flow
import { ipcRenderer } from 'electron'
import { Observable } from 'rxjs'
import uuidv4 from 'uuid/v4'
type Msg<A> = {
type: string,
data?: A,
options?: *,
}
function send<A>(msg: Msg<A>) {
process.send(msg)
}
export class Command<In, A> {
channel: string
type: string
id: string
impl: In => Observable<A>
constructor(channel: string, type: string, impl: In => Observable<A>) {
this.channel = channel
this.type = type
this.id = `${channel}.${type}`
this.impl = impl
}
// ~~~ On exec side we can:
exec(data: In, requestId: string) {
return this.impl(data).subscribe({
next: (data: A) => {
send({
type: `NEXT_${requestId}`,
data,
})
},
complete: () => {
send({
type: `COMPLETE_${requestId}`,
options: { kill: true },
})
},
error: error => {
send({
type: `ERROR_${requestId}`,
data: {
name: error && error.name,
message: error && error.message,
},
options: { kill: true },
})
},
})
}
// ~~~ On renderer side we can:
/**
* Usage example:
* sub = send(data).subscribe({ next: ... })
* // or
* const res = await send(data).toPromise()
*/
send(data: In): Observable<A> {
return Observable.create(o => {
const { channel, type, id } = this
const requestId: string = uuidv4()
const unsubscribe = () => {
ipcRenderer.removeListener('msg', handleMsgEvent)
}
function handleMsgEvent(e, msg: Msg<A>) {
switch (msg.type) {
case `NEXT_${requestId}`:
if (msg.data) {
o.next(msg.data)
}
break
case `COMPLETE_${requestId}`:
o.complete()
unsubscribe()
break
case `ERROR_${requestId}`:
o.error(msg.data)
unsubscribe()
break
default:
}
}
ipcRenderer.on('msg', handleMsgEvent)
ipcRenderer.send(channel, {
type,
data: {
id,
data,
requestId,
},
})
return unsubscribe
})
}
}
export function createCommand<In, A>(
channel: string,
type: string,
impl: In => Observable<A>,
): Command<In, A> {
return new Command(channel, type, impl)
}

68
src/helpers/signTransactionForCurrency/ethereum.js

@ -0,0 +1,68 @@
// @flow
import Eth from '@ledgerhq/hw-app-eth'
import type Transport from '@ledgerhq/hw-transport'
import EthereumTx from 'ethereumjs-tx'
// see https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md
function getNetworkId(currencyId: string): ?number {
switch (currencyId) {
case 'ethereum':
return 1
case 'ethereum_classic':
return 61
case 'ethereum_classic_testnet':
return 62
case 'ethereum_testnet':
return 3 // Ropsten by convention
default:
return null
}
}
export default async (
transport: Transport<*>,
currencyId: string,
path: string,
t: {
nonce: string,
recipient: string,
gasPrice: number,
amount: number,
},
) => {
// First, we need to create a partial tx and send to the device
const chainId = getNetworkId(currencyId)
if (!chainId) throw new Error(`chainId not found for currency=${currencyId}`)
const gasLimit = '0x5208' // cost of a simple send
const tx = new EthereumTx({
nonce: t.nonce,
gasPrice: `0x${t.gasPrice.toString(16)}`,
gasLimit,
to: t.recipient,
value: `0x${t.amount.toString(16)}`,
chainId,
})
tx.raw[6] = Buffer.from([chainId]) // v
tx.raw[7] = Buffer.from([]) // r
tx.raw[8] = Buffer.from([]) // s
const eth = new Eth(transport)
const result = await eth.signTransaction(path, tx.serialize().toString('hex'))
// Second, we re-set some tx fields from the device signature
tx.v = Buffer.from(result.v, 'hex')
tx.r = Buffer.from(result.r, 'hex')
tx.s = Buffer.from(result.s, 'hex')
const signedChainId = Math.floor((tx.v[0] - 35) / 2) // EIP155: v should be chain_id * 2 + {35, 36}
const validChainId = chainId & 0xff // eslint-disable-line no-bitwise
if (signedChainId !== validChainId) {
throw new Error(
`Invalid chainId signature returned. Expected: ${chainId}, Got: ${signedChainId}`,
)
}
// Finally, we can send the transaction string to broadcast
return `0x${tx.serialize().toString('hex')}`
}

27
src/helpers/signTransactionForCurrency/index.js

@ -0,0 +1,27 @@
// @flow
import type Transport from '@ledgerhq/hw-transport'
import ethereum from './ethereum'
type Resolver = (
transport: Transport<*>,
currencyId: string,
path: string, // if provided use this path, otherwise resolve it
transaction: *, // any data
) => Promise<string>
type Module = (currencyId: string) => Resolver
const fallback: string => Resolver = currencyId => () =>
Promise.reject(new Error(`${currencyId} device support not implemented`))
const all = {
ethereum,
ethereum_testnet: ethereum,
ethereum_classic: ethereum,
ethereum_classic_testnet: ethereum,
}
const m: Module = (currencyId: string) => all[currencyId] || fallback(currencyId)
export default m

59
src/internals/accounts/helpers.js

@ -1,59 +0,0 @@
// @flow
/* eslint-disable no-bitwise */
import Btc from '@ledgerhq/hw-app-btc'
import { findCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies'
export function coinTypeForId(id: string) {
const currency = findCryptoCurrencyById(id)
return currency ? currency.coinType : 0
}
export function getPath({
currencyId,
account,
segwit = true,
}: {
currencyId: string,
account?: any,
segwit?: boolean,
}) {
return `${segwit ? 49 : 44}'/${coinTypeForId(currencyId)}'${
account !== undefined ? `/${account}'` : ''
}`
}
export async function getFreshReceiveAddress({
currencyId,
accountIndex,
}: {
currencyId: string,
accountIndex: number,
}) {
// TODO: investigate why importing it on file scope causes trouble
const core = require('init-ledger-core')()
const wallet = await core.getWallet(currencyId)
const account = await wallet.getAccount(accountIndex)
const addresses = await account.getFreshPublicAddresses()
if (!addresses.length) {
throw new Error('No fresh addresses')
}
return addresses[0]
}
export function verifyAddress({
transport,
path,
segwit = true,
}: {
transport: Object,
path: string,
segwit?: boolean,
}) {
const btc = new Btc(transport)
return btc.getWalletPublicKey(path, true, segwit)
}

34
src/internals/accounts/index.js

@ -1,11 +1,8 @@
// @flow
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
import type { IPCSend } from 'types/electron'
import scanAccountsOnDevice from './scanAccountsOnDevice'
import { verifyAddress, getFreshReceiveAddress } from './helpers'
import sync from './sync'
@ -36,37 +33,6 @@ export default {
send('accounts.scanAccountsOnDevice.fail', formatErr(err))
}
},
getFreshReceiveAddress: async (
send: IPCSend,
{
currencyId,
accountIndex,
}: {
currencyId: string,
accountIndex: number,
},
) => {
try {
const freshAddress = await getFreshReceiveAddress({ currencyId, accountIndex })
send('accounts.getFreshReceiveAddress.success', freshAddress)
} catch (err) {
send('accounts.getFreshReceiveAddress.fail', err)
}
},
verifyAddress: async (
send: IPCSend,
{ pathDevice, path }: { pathDevice: string, path: string },
) => {
const transport = await CommNodeHid.open(pathDevice)
try {
await verifyAddress({ transport, path })
send('accounts.verifyAddress.success')
} catch (err) {
send('accounts.verifyAddress.fail')
}
},
}
// TODO: move this to a helper

1
src/internals/accounts/scanAccountsOnDevice.js

@ -253,6 +253,7 @@ function buildOperationRaw({ core, op, xpub }: { core: Object, op: NJSOperation,
senders: op.getSenders(),
recipients: op.getRecipients(),
blockHeight: op.getBlockHeight(),
blockHash: '',
accountId: xpub,
date: op.getDate().toISOString(),
amount,

36
src/internals/devices/ensureDeviceApp.js

@ -1,36 +0,0 @@
// @flow
import invariant from 'invariant'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
import type Transport from '@ledgerhq/hw-transport'
import type { IPCSend } from 'types/electron'
import getAddressForCurrency from './getAddressForCurrency'
export default async (
send: IPCSend,
{
currencyId,
devicePath,
accountPath,
accountAddress,
...options
}: {
currencyId: string,
devicePath: string,
accountPath: ?string,
accountAddress: ?string,
},
) => {
try {
invariant(currencyId, 'currencyId "%s" not defined', currencyId)
const transport: Transport<*> = await CommNodeHid.open(devicePath)
const resolver = getAddressForCurrency(currencyId)
const address = await resolver(transport, currencyId, accountPath, options)
if (accountPath && accountAddress && address !== accountAddress) {
throw new Error('Account address is different than device address')
}
send('devices.ensureDeviceApp.success', { devicePath })
} catch (err) {
send('devices.ensureDeviceApp.fail', { devicePath })
}
}

13
src/internals/devices/index.js

@ -1,2 +1,11 @@
export listen from './listen'
export ensureDeviceApp from './ensureDeviceApp'
// @flow
import type { Command } from 'helpers/ipc'
import getAddress from 'commands/getAddress'
import signTransaction from 'commands/signTransaction'
import listen from './listen'
// TODO port these to commands
export { listen }
export const commands: Array<Command<any, any>> = [getAddress, signTransaction]

13
src/internals/index.js

@ -24,6 +24,18 @@ if (handlers.default) {
}
process.on('message', payload => {
console.log(payload)
if (payload.data && payload.data.requestId) {
const { data, requestId, id } = payload.data
// this is the new type of "command" payload!
const cmd = (handlers.commands || []).find(cmd => cmd.id === id)
if (!cmd) {
console.warn(`command ${id} not found`)
} else {
cmd.exec(data, requestId)
}
} else {
// this will be deprecated!
const { type, data } = payload
const handler = objectPath.get(handlers, type)
if (!handler) {
@ -31,6 +43,7 @@ process.on('message', payload => {
return
}
handler(sendEvent, data)
}
})
if (__DEV__ || DEV_TOOLS) {

6
src/renderer/events.js

@ -2,6 +2,12 @@
// FIXME this file is spaghetti. we need one file per usecase.
// TODO to improve current state:
// a sendEventPromise version that returns a promise
// a sendEventObserver version that takes an observer & return a Subscription
// both of these implementation should have a unique requestId to ensure there is no collision
// events should all appear in the promise result / observer msgs as soon as they have this requestId
import { ipcRenderer } from 'electron'
import objectPath from 'object-path'
import debug from 'debug'

561
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save