Gaëtan Renaudeau
7 years ago
committed by
GitHub
32 changed files with 1142 additions and 709 deletions
@ -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 |
|||
}, |
|||
} |
|||
} |
@ -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)}` |
|||
|
@ -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), |
|||
}) |
@ -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 |
@ -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 |
@ -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) |
@ -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 |
|||
} |
@ -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) |
|||
} |
@ -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')}` |
|||
} |
@ -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 |
@ -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) |
|||
} |
@ -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 }) |
|||
} |
|||
} |
@ -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] |
|||
|
File diff suppressed because it is too large
Loading…
Reference in new issue