Browse Source

Merge branch 'master' of github.com:ledgerhq/ledger-live-desktop into onboarding

master
Gaëtan Renaudeau 7 years ago
parent
commit
1d0fe8fac9
  1. 5
      package.json
  2. 18
      src/api/Ethereum.js
  3. 4
      src/api/Fees.js
  4. 34
      src/api/Ledger.js
  5. 174
      src/bridge/EthereumJSBridge.js
  6. 22
      src/bridge/EthereumMockJSBridge.js
  7. 17
      src/bridge/LibcoreBridge.js
  8. 131
      src/bridge/RippleJSBridge.js
  9. 2
      src/bridge/UnsupportedBridge.js
  10. 16
      src/bridge/makeMockBridge.js
  11. 16
      src/bridge/types.js
  12. 4
      src/commands/getAddress.js
  13. 4
      src/commands/signTransaction.js
  14. 22
      src/components/BalanceSummary/index.js
  15. 22
      src/components/CalculateBalance.js
  16. 4
      src/components/CounterValue/index.js
  17. 2
      src/components/CurrentAddress/stories.js
  18. 9
      src/components/CurrentAddressForAccount.js
  19. 4
      src/components/DashboardPage/AccountCard.js
  20. 20
      src/components/DevTools.js
  21. 12
      src/components/DeviceCheckAddress.js
  22. 10
      src/components/DeviceSignTransaction.js
  23. 8
      src/components/EnsureDeviceApp/index.js
  24. 12
      src/components/FeesField/EthereumKind.js
  25. 20
      src/components/OperationsList/ConfirmationCheck.js
  26. 50
      src/components/OperationsList/Operation.js
  27. 11
      src/components/SettingsPage/sections/Currencies.js
  28. 10
      src/components/SettingsPage/sections/Display.js
  29. 6
      src/components/TopBar/ItemContainer.js
  30. 4
      src/components/TopBar/index.js
  31. 88
      src/components/base/Chart/Tooltip.js
  32. 25
      src/components/base/Chart/handleMouseEvents.js
  33. 2
      src/components/base/Chart/helpers.js
  34. 15
      src/components/base/Chart/index.js
  35. 12
      src/components/base/Chart/refreshDraw.js
  36. 23
      src/components/base/Chart/refreshNodes.js
  37. 28
      src/components/base/Chart/stories.js
  38. 1
      src/components/base/Chart/types.js
  39. 13
      src/components/base/InputCurrency/index.js
  40. 19
      src/components/modals/AddAccount/index.js
  41. 20
      src/components/modals/OperationDetails.js
  42. 8
      src/components/modals/Send/03-step-verification.js
  43. 17
      src/components/modals/Send/04-step-confirmation.js
  44. 7
      src/components/modals/Send/ConfirmationFooter.js
  45. 16
      src/components/modals/Send/Footer.js
  46. 42
      src/components/modals/Send/SendModalBody.js
  47. 51
      src/helpers/deviceAccess.js
  48. 4
      src/helpers/getAddressForCurrency/btc.js
  49. 5
      src/helpers/getAddressForCurrency/index.js
  50. 79
      src/internals/accounts/scanAccountsOnDevice.js
  51. 12
      src/internals/accounts/signAndBroadcastTransaction/btc.js
  52. 7
      src/internals/manager/helpers.js
  53. 4
      static/i18n/en/operationsList.yml
  54. 2
      static/i18n/fr/common.yml
  55. 44
      static/i18n/fr/onboarding.yml
  56. 4
      static/i18n/fr/operationsList.yml
  57. 1
      static/i18n/fr/send.yml
  58. 1323
      yarn.lock

5
package.json

@ -41,8 +41,8 @@
"@ledgerhq/hw-app-xrp": "^4.12.0", "@ledgerhq/hw-app-xrp": "^4.12.0",
"@ledgerhq/hw-transport": "^4.12.0", "@ledgerhq/hw-transport": "^4.12.0",
"@ledgerhq/hw-transport-node-hid": "^4.12.0", "@ledgerhq/hw-transport-node-hid": "^4.12.0",
"@ledgerhq/ledger-core": "^1.2.0", "@ledgerhq/ledger-core": "^1.2.1",
"@ledgerhq/live-common": "^2.7.5", "@ledgerhq/live-common": "^2.9.1",
"axios": "^0.18.0", "axios": "^0.18.0",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -90,6 +90,7 @@
"ripple-lib": "^1.0.0-beta.0", "ripple-lib": "^1.0.0-beta.0",
"rxjs": "^6.2.0", "rxjs": "^6.2.0",
"rxjs-compat": "^6.1.0", "rxjs-compat": "^6.1.0",
"semaphore": "^1.1.0",
"smooth-scrollbar": "^8.2.7", "smooth-scrollbar": "^8.2.7",
"source-map": "0.7.2", "source-map": "0.7.2",
"source-map-support": "^0.5.4", "source-map-support": "^0.5.4",

18
src/api/Ethereum.js

@ -1,7 +1,7 @@
// @flow // @flow
import axios from 'axios' import axios from 'axios'
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import { blockchainBaseURL } from './Ledger' import { blockchainBaseURL, userFriendlyError } from './Ledger'
export type Block = { height: number } // TODO more fields actually export type Block = { height: number } // TODO more fields actually
export type Tx = { export type Tx = {
@ -34,7 +34,7 @@ export type API = {
txs: Tx[], txs: Tx[],
}>, }>,
getCurrentBlock: () => Promise<Block>, getCurrentBlock: () => Promise<Block>,
getAccountNonce: (address: string) => Promise<string>, getAccountNonce: (address: string) => Promise<number>,
broadcastTransaction: (signedTransaction: string) => Promise<string>, broadcastTransaction: (signedTransaction: string) => Promise<string>,
getAccountBalance: (address: string) => Promise<number>, getAccountBalance: (address: string) => Promise<number>,
} }
@ -44,25 +44,27 @@ export const apiForCurrency = (currency: CryptoCurrency): API => {
return { return {
async getTransactions(address, blockHash) { async getTransactions(address, blockHash) {
const { data } = await axios.get(`${baseURL}/addresses/${address}/transactions`, { const { data } = await userFriendlyError(
axios.get(`${baseURL}/addresses/${address}/transactions`, {
params: { blockHash, noToken: 1 }, params: { blockHash, noToken: 1 },
}) }),
)
return data return data
}, },
async getCurrentBlock() { async getCurrentBlock() {
const { data } = await axios.get(`${baseURL}/blocks/current`) const { data } = await userFriendlyError(axios.get(`${baseURL}/blocks/current`))
return data return data
}, },
async getAccountNonce(address) { async getAccountNonce(address) {
const { data } = await axios.get(`${baseURL}/addresses/${address}/nonce`) const { data } = await userFriendlyError(axios.get(`${baseURL}/addresses/${address}/nonce`))
return data[0].nonce return data[0].nonce
}, },
async broadcastTransaction(tx) { async broadcastTransaction(tx) {
const { data } = await axios.post(`${baseURL}/transactions/send`, { tx }) const { data } = await userFriendlyError(axios.post(`${baseURL}/transactions/send`, { tx }))
return data.result return data.result
}, },
async getAccountBalance(address) { async getAccountBalance(address) {
const { data } = await axios.get(`${baseURL}/addresses/${address}/balance`) const { data } = await userFriendlyError(axios.get(`${baseURL}/addresses/${address}/balance`))
return data[0].balance return data[0].balance
}, },
} }

4
src/api/Fees.js

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

34
src/api/Ledger.js

@ -14,5 +14,39 @@ export const currencyToFeeTicker = (currency: Currency) => {
return mapping[currency.id] || tickerLowerCase return mapping[currency.id] || tickerLowerCase
} }
export const userFriendlyError = <A>(p: Promise<A>): Promise<A> =>
p.catch(error => {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const { data } = error.response
if (data && typeof data.error === 'string') {
const msg = data.error || data.message
if (typeof msg === 'string') {
const m = msg.match(/^JsDefined\((.*)\)$/)
if (m) {
try {
const { message } = JSON.parse(m[1])
if (typeof message === 'string') {
throw new Error(message)
}
} catch (e) {
console.log(e)
}
}
throw new Error(msg)
}
}
console.log('Ledger API: HTTP status', error.response.status, 'data: ', error.response.data)
throw new Error('A problem occurred with Ledger Servers. Please try again later.')
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
throw new Error('Your network is down. Please try again later.')
}
throw error
})
export const blockchainBaseURL = (currency: Currency) => export const blockchainBaseURL = (currency: Currency) =>
`${BASE_URL}blockchain/v2/${currencyToFeeTicker(currency)}` `${BASE_URL}blockchain/v2/${currencyToFeeTicker(currency)}`

174
src/bridge/EthereumJSBridge.js

@ -2,6 +2,8 @@
import React from 'react' import React from 'react'
import EthereumKind from 'components/FeesField/EthereumKind' import EthereumKind from 'components/FeesField/EthereumKind'
import throttle from 'lodash/throttle' import throttle from 'lodash/throttle'
import flatMap from 'lodash/flatMap'
import uniqBy from 'lodash/uniqBy'
import type { Account, Operation } from '@ledgerhq/live-common/lib/types' import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import { apiForCurrency } from 'api/Ethereum' import { apiForCurrency } from 'api/Ethereum'
import type { Tx } from 'api/Ethereum' import type { Tx } from 'api/Ethereum'
@ -24,20 +26,43 @@ const EditFees = ({ account, onChange, value }: EditProps<Transaction>) => (
/> />
) )
const toAccountOperation = (account: Account) => (tx: Tx): Operation => { // in case of a SELF send, 2 ops are returned.
const sending = account.address.toLowerCase() === tx.from.toLowerCase() const txToOps = (account: Account) => (tx: Tx): Operation[] => {
return { const freshAddress = account.freshAddress.toLowerCase()
id: tx.hash, const from = tx.from.toLowerCase()
const to = tx.to.toLowerCase()
const sending = freshAddress === from
const receiving = freshAddress === to
const ops = []
if (sending) {
ops.push({
id: `${account.id}-${tx.hash}-OUT`,
hash: tx.hash,
type: 'OUT',
value: tx.value + tx.gas_price * tx.gas_used,
blockHeight: tx.block && tx.block.height,
blockHash: tx.block && tx.block.hash,
accountId: account.id,
senders: [tx.from],
recipients: [tx.to],
date: new Date(tx.received_at),
})
}
if (receiving) {
ops.push({
id: `${account.id}-${tx.hash}-IN`,
hash: tx.hash, hash: tx.hash,
address: sending ? tx.to : tx.from, type: 'IN',
amount: (sending ? -1 : 1) * tx.value, value: tx.value,
blockHeight: (tx.block && tx.block.height) || 0, // FIXME will be optional field blockHeight: tx.block && tx.block.height,
blockHash: (tx.block && tx.block.hash) || '', // FIXME will be optional field blockHash: tx.block && tx.block.hash,
accountId: account.id, accountId: account.id,
senders: [tx.from], senders: [tx.from],
recipients: [tx.to], recipients: [tx.to],
date: new Date(tx.received_at), date: new Date(tx.received_at),
})
} }
return ops
} }
function isRecipientValid(currency, recipient) { function isRecipientValid(currency, recipient) {
@ -45,26 +70,13 @@ function isRecipientValid(currency, recipient) {
} }
function mergeOps(existing: Operation[], newFetched: Operation[]) { function mergeOps(existing: Operation[], newFetched: Operation[]) {
const ids = existing.map(o => o.id) const ids = newFetched.map(o => o.id)
const all = existing.concat(newFetched.filter(o => !ids.includes(o.id))) const all = newFetched.concat(existing.filter(o => !ids.includes(o.id)))
return all.sort((a, b) => a.date - b.date) return uniqBy(all.sort((a, b) => a.date - b.date), 'id')
}
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 fetchCurrentBlock = (perCurrencyId => currency => { const fetchCurrentBlock = (perCurrencyId => currency => {
if (perCurrencyId[currency.id]) return perCurrencyId[currency.id] if (perCurrencyId[currency.id]) return perCurrencyId[currency.id]()
const api = apiForCurrency(currency) const api = apiForCurrency(currency)
const f = throttle( const f = throttle(
() => () =>
@ -75,7 +87,7 @@ const fetchCurrentBlock = (perCurrencyId => currency => {
5000, 5000,
) )
perCurrencyId[currency.id] = f perCurrencyId[currency.id] = f
return f return f()
})({}) })({})
const EthereumBridge: WalletBridge<Transaction> = { const EthereumBridge: WalletBridge<Transaction> = {
@ -93,38 +105,39 @@ const EthereumBridge: WalletBridge<Transaction> = {
async function stepAddress( async function stepAddress(
index, index,
{ address, path }, { address, path: freshAddressPath },
isStandard, isStandard,
): { account?: Account, complete?: boolean } { ): { account?: Account, complete?: boolean } {
const balance = await api.getAccountBalance(address) const balance = await api.getAccountBalance(address)
if (finished) return { complete: true } if (finished) return { complete: true }
const currentBlock = await fetchCurrentBlock(currency) const currentBlock = await fetchCurrentBlock(currency)
if (finished) return { complete: true } if (finished) return { complete: true }
const { txs } = await api.getTransactions(address) let { txs } = await api.getTransactions(address)
if (finished) return { complete: true } if (finished) return { complete: true }
const path = freshAddressPath // FIXME
const freshAddress = address
if (txs.length === 0) { if (txs.length === 0) {
// this is an empty account // this is an empty account
if (isStandard) { if (isStandard) {
if (newAccountCount === 0) { if (newAccountCount === 0) {
// first zero account will emit one account as opportunity to create a new account.. // first zero account will emit one account as opportunity to create a new account..
const currentBlock = await fetchCurrentBlock(currency)
const accountId = `${currency.id}_${address}` const accountId = `${currency.id}_${address}`
const account: Account = { const account: $Exact<Account> = {
id: accountId, id: accountId,
xpub: '', xpub: '',
path, // FIXME we probably not want the address path in the account.path path, // FIXME we probably not want the address path in the account.path
walletPath: String(index), freshAddress,
freshAddressPath,
name: 'New Account', name: 'New Account',
isSegwit: false,
address,
addresses: [{ str: address, path }],
balance, balance,
blockHeight: currentBlock.height, blockHeight: currentBlock.height,
archived: true, archived: true,
index, index,
currency, currency,
operations: [], operations: [],
pendingOperations: [],
unit: currency.units[0], unit: currency.units[0],
lastSyncDate: new Date(), lastSyncDate: new Date(),
} }
@ -137,25 +150,34 @@ const EthereumBridge: WalletBridge<Transaction> = {
} }
const accountId = `${currency.id}_${address}` const accountId = `${currency.id}_${address}`
const account: Account = { const account: $Exact<Account> = {
id: accountId, id: accountId,
xpub: '', xpub: '',
path, // FIXME we probably not want the address path in the account.path path, // FIXME we probably not want the address path in the account.path
walletPath: String(index), freshAddress,
freshAddressPath,
name: address.slice(32), name: address.slice(32),
isSegwit: false,
address,
addresses: [{ str: address, path }],
balance, balance,
blockHeight: currentBlock.height, blockHeight: currentBlock.height,
archived: true, archived: true,
index, index,
currency, currency,
operations: [], operations: [],
pendingOperations: [],
unit: currency.units[0], unit: currency.units[0],
lastSyncDate: new Date(), lastSyncDate: new Date(),
} }
account.operations = txs.map(toAccountOperation(account)) for (let i = 0; i < 50; i++) {
const api = apiForCurrency(account.currency)
const { block } = txs[txs.length - 1]
if (!block) break
const next = await api.getTransactions(account.freshAddress, block.hash)
if (next.txs.length === 0) break
txs = txs.concat(next.txs)
}
txs.reverse()
account.operations = mergeOps([], flatMap(txs, txToOps(account)))
console.log(account)
return { account } return { account }
} }
@ -166,9 +188,9 @@ const EthereumBridge: WalletBridge<Transaction> = {
for (const derivation of derivations) { for (const derivation of derivations) {
const isStandard = last === derivation const isStandard = last === derivation
for (let index = 0; index < 255; index++) { for (let index = 0; index < 255; index++) {
const path = derivation({ currency, x: index, segwit: false }) const freshAddressPath = derivation({ currency, x: index, segwit: false })
const res = await getAddressCommand const res = await getAddressCommand
.send({ currencyId: currency.id, devicePath: deviceId, path }) .send({ currencyId: currency.id, devicePath: deviceId, path: freshAddressPath })
.toPromise() .toPromise()
const r = await stepAddress(index, res, isStandard) const r = await stepAddress(index, res, isStandard)
if (r.account) next(r.account) if (r.account) next(r.account)
@ -188,7 +210,7 @@ const EthereumBridge: WalletBridge<Transaction> = {
return { unsubscribe } return { unsubscribe }
}, },
synchronize({ address, blockHeight, currency }, { next, complete, error }) { synchronize({ freshAddress, blockHeight, currency, operations }, { next, complete, error }) {
let unsubscribed = false let unsubscribed = false
const api = apiForCurrency(currency) const api = apiForCurrency(currency)
async function main() { async function main() {
@ -198,26 +220,30 @@ const EthereumBridge: WalletBridge<Transaction> = {
if (block.height === blockHeight) { if (block.height === blockHeight) {
complete() complete()
} else { } else {
const balance = await api.getAccountBalance(address) const blockHash = operations.length > 0 ? operations[0].blockHash : undefined
const { txs } = await api.getTransactions(freshAddress, blockHash)
if (unsubscribed) return
if (txs.length === 0) {
complete()
return
}
const balance = await api.getAccountBalance(freshAddress)
if (unsubscribed) return if (unsubscribed) return
const { txs } = await api.getTransactions(address) const nonce = await api.getAccountNonce(freshAddress)
if (unsubscribed) return if (unsubscribed) return
next(a => { next(a => {
const currentOps = a.operations const currentOps = a.operations
const newOps = txs.map(toAccountOperation(a)) const newOps = flatMap(txs, txToOps(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) const operations = mergeOps(currentOps, newOps)
const pendingOperations = a.pendingOperations.filter(
o =>
o.transactionSequenceNumber &&
o.transactionSequenceNumber >= nonce &&
!operations.some(op => o.hash === op.hash),
)
return { return {
...a, ...a,
pendingOperations,
operations, operations,
balance, balance,
blockHeight: block.height, blockHeight: block.height,
@ -231,6 +257,7 @@ const EthereumBridge: WalletBridge<Transaction> = {
} }
} }
main() main()
return { return {
unsubscribe() { unsubscribe() {
unsubscribed = true unsubscribed = true
@ -238,10 +265,7 @@ const EthereumBridge: WalletBridge<Transaction> = {
} }
}, },
pullMoreOperations: async account => { pullMoreOperations: () => Promise.resolve(a => a), // NOT IMPLEMENTED
const operations = await paginateMoreTransactions(account, account.operations)
return a => ({ ...a, operations })
},
isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)), isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)),
@ -270,14 +294,15 @@ const EthereumBridge: WalletBridge<Transaction> = {
// $FlowFixMe // $FlowFixMe
EditFees, EditFees,
// FIXME gasPrice calc is wrong... need to multiply with gasLimit I guess ?
canBeSpent: (a, t) => Promise.resolve(t.amount + t.gasPrice <= a.balance),
getTotalSpent: (a, t) => Promise.resolve(t.amount + t.gasPrice), getTotalSpent: (a, t) => Promise.resolve(t.amount + t.gasPrice),
getMaxAmount: (a, t) => Promise.resolve(a.balance - t.gasPrice), getMaxAmount: (a, t) => Promise.resolve(a.balance - t.gasPrice),
signAndBroadcast: async (a, t, deviceId) => { signAndBroadcast: async (a, t, deviceId) => {
const api = apiForCurrency(a.currency) const api = apiForCurrency(a.currency)
const nonce = await api.getAccountNonce(a.address) const nonce = await api.getAccountNonce(a.freshAddress)
const transaction = await signTransactionCommand const transaction = await signTransactionCommand
.send({ .send({
@ -288,10 +313,31 @@ const EthereumBridge: WalletBridge<Transaction> = {
}) })
.toPromise() .toPromise()
const result = await api.broadcastTransaction(transaction) const hash = await api.broadcastTransaction(transaction)
return result return {
id: `${a.id}-${hash}-OUT`,
hash,
type: 'OUT',
value: t.amount,
blockHeight: null,
blockHash: null,
accountId: a.id,
senders: [a.freshAddress],
recipients: [t.recipient],
transactionSequenceNumber: nonce,
date: new Date(),
}
}, },
addPendingOperation: (account, operation) => ({
...account,
pendingOperations: [operation].concat(
account.pendingOperations.filter(
o => o.transactionSequenceNumber === operation.transactionSequenceNumber,
),
),
}),
} }
export default EthereumBridge export default EthereumBridge

22
src/bridge/EthereumMockJSBridge.js

@ -1,22 +0,0 @@
// @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),
})

17
src/bridge/LibcoreBridge.js

@ -44,7 +44,16 @@ const LibcoreBridge: WalletBridge<Transaction> = {
switch (msg.type) { switch (msg.type) {
case 'account.sync.progress': { case 'account.sync.progress': {
next(a => a) next(a => a)
// use next(), to actually emit account updates..... // FIXME TODO: use next(), to actually emit account updates.....
// - need to sync the balance
// - need to sync block height & block hash
// - need to sync operations.
// - once all that, need to set lastSyncDate to new Date()
// - when you implement addPendingOperation you also here need to:
// - if there were pendingOperations that are now in operations, remove them as well.
// - if there are pendingOperations that is older than a threshold (that depends on blockchain speed typically)
// then we probably should trash them out? it's a complex question for UI
break break
} }
case 'account.sync.fail': { case 'account.sync.fail': {
@ -150,9 +159,11 @@ const LibcoreBridge: WalletBridge<Transaction> = {
isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false, isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false,
getTotalSpent: (a, t) => Promise.resolve(t.amount + t.feePerByte), canBeSpent: (a, t) => Promise.resolve(t.amount <= a.balance), // FIXME
getMaxAmount: (a, t) => Promise.resolve(a.balance - t.feePerByte), getTotalSpent: (a, t) => Promise.resolve(t.amount), // FIXME
getMaxAmount: (a, _t) => Promise.resolve(a.balance), // FIXME
signAndBroadcast: (account, transaction, deviceId) => { signAndBroadcast: (account, transaction, deviceId) => {
const rawAccount = encodeAccount(account) const rawAccount = encodeAccount(account)

131
src/bridge/RippleJSBridge.js

@ -2,6 +2,7 @@
import React from 'react' import React from 'react'
import bs58check from 'ripple-bs58check' import bs58check from 'ripple-bs58check'
import { computeBinaryTransactionHash } from 'ripple-hashes' import { computeBinaryTransactionHash } from 'ripple-hashes'
import throttle from 'lodash/throttle'
import type { Account, Operation } from '@ledgerhq/live-common/lib/types' import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import { getDerivations } from 'helpers/derivations' import { getDerivations } from 'helpers/derivations'
import getAddress from 'commands/getAddress' import getAddress from 'commands/getAddress'
@ -119,27 +120,54 @@ type Tx = {
const txToOperation = (account: Account) => ({ const txToOperation = (account: Account) => ({
id, id,
outcome: { deliveredAmount, ledgerVersion, timestamp }, sequence,
outcome: { fee, deliveredAmount, ledgerVersion, timestamp },
specification: { source, destination }, specification: { source, destination },
}: Tx): Operation => { }: Tx): Operation => {
const sending = source.address === account.address const type = source.address === account.freshAddress ? 'OUT' : 'IN'
const amount = let value = deliveredAmount ? parseAPICurrencyObject(deliveredAmount) : 0
(sending ? -1 : 1) * (deliveredAmount ? parseAPICurrencyObject(deliveredAmount) : 0) if (type === 'OUT') {
const op: Operation = { const feeValue = parseAPIValue(fee)
if (!isNaN(feeValue)) {
value += feeValue
}
}
const op: $Exact<Operation> = {
id, id,
hash: id, hash: id,
accountId: account.id, accountId: account.id,
blockHash: '', type,
address: sending ? destination.address : source.address, value,
amount, blockHash: null,
blockHeight: ledgerVersion, blockHeight: ledgerVersion,
senders: [sending ? destination.address : source.address], senders: [source.address],
recipients: [!sending ? destination.address : source.address], recipients: [destination.address],
date: new Date(timestamp), date: new Date(timestamp),
transactionSequenceNumber: sequence,
} }
return op return op
} }
const getServerInfo = (perCurrencyId => currency => {
if (perCurrencyId[currency.id]) return perCurrencyId[currency.id]()
const f = throttle(async () => {
const api = apiForCurrency(currency)
try {
await api.connect()
const res = await api.getServerInfo()
return res
} catch (e) {
f.cancel()
throw e
} finally {
api.disconnect()
}
}, 60000)
perCurrencyId[currency.id] = f
return f()
})({})
const RippleJSBridge: WalletBridge<Transaction> = { const RippleJSBridge: WalletBridge<Transaction> = {
scanAccountsOnDevice(currency, deviceId, { next, complete, error }) { scanAccountsOnDevice(currency, deviceId, { next, complete, error }) {
let finished = false let finished = false
@ -151,7 +179,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
const api = apiForCurrency(currency) const api = apiForCurrency(currency)
try { try {
await api.connect() await api.connect()
const serverInfo = await api.getServerInfo() const serverInfo = await getServerInfo(currency)
const ledgers = serverInfo.completeLedgers.split('-') const ledgers = serverInfo.completeLedgers.split('-')
const minLedgerVersion = Number(ledgers[0]) const minLedgerVersion = Number(ledgers[0])
const maxLedgerVersion = Number(ledgers[1]) const maxLedgerVersion = Number(ledgers[1])
@ -159,9 +187,11 @@ const RippleJSBridge: WalletBridge<Transaction> = {
const derivations = getDerivations(currency) const derivations = getDerivations(currency)
for (const derivation of derivations) { for (const derivation of derivations) {
for (let index = 0; index < 255; index++) { for (let index = 0; index < 255; index++) {
const path = derivation({ currency, x: index, segwit: false }) const freshAddressPath = derivation({ currency, x: index, segwit: false })
const path = freshAddressPath
// FIXME^ we need the account path, not the address path
const { address } = await await getAddress const { address } = await await getAddress
.send({ currencyId: currency.id, devicePath: deviceId, path }) .send({ currencyId: currency.id, devicePath: deviceId, path: freshAddressPath })
.toPromise() .toPromise()
if (finished) return if (finished) return
@ -176,6 +206,9 @@ const RippleJSBridge: WalletBridge<Transaction> = {
} }
} }
// fresh address is address. ripple never changes.
const freshAddress = address
if (!info) { if (!info) {
// account does not exist in Ripple server // account does not exist in Ripple server
// we are generating a new account locally // we are generating a new account locally
@ -183,16 +216,15 @@ const RippleJSBridge: WalletBridge<Transaction> = {
id: accountId, id: accountId,
xpub: '', xpub: '',
path, path,
walletPath: '',
name: 'New Account', name: 'New Account',
isSegwit: false, freshAddress,
address, freshAddressPath,
addresses: [address],
balance: 0, balance: 0,
blockHeight: maxLedgerVersion, blockHeight: maxLedgerVersion,
index, index,
currency, currency,
operations: [], operations: [],
pendingOperations: [],
unit: currency.units[0], unit: currency.units[0],
archived: true, archived: true,
lastSyncDate: new Date(), lastSyncDate: new Date(),
@ -212,20 +244,19 @@ const RippleJSBridge: WalletBridge<Transaction> = {
}) })
if (finished) return if (finished) return
const account: Account = { const account: $Exact<Account> = {
id: accountId, id: accountId,
xpub: '', xpub: '',
path, path,
walletPath: '',
name: address.slice(0, 8), name: address.slice(0, 8),
isSegwit: false, freshAddress,
address, freshAddressPath,
addresses: [address],
balance, balance,
blockHeight: maxLedgerVersion, blockHeight: maxLedgerVersion,
index, index,
currency, currency,
operations: [], operations: [],
pendingOperations: [],
unit: currency.units[0], unit: currency.units[0],
archived: true, archived: true,
lastSyncDate: new Date(), lastSyncDate: new Date(),
@ -247,7 +278,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
return { unsubscribe } return { unsubscribe }
}, },
synchronize({ currency, address, blockHeight }, { next, error, complete }) { synchronize({ currency, freshAddress, blockHeight }, { next, error, complete }) {
let finished = false let finished = false
const unsubscribe = () => { const unsubscribe = () => {
finished = true finished = true
@ -258,7 +289,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
try { try {
await api.connect() await api.connect()
if (finished) return if (finished) return
const serverInfo = await api.getServerInfo() const serverInfo = await getServerInfo(currency)
if (finished) return if (finished) return
const ledgers = serverInfo.completeLedgers.split('-') const ledgers = serverInfo.completeLedgers.split('-')
const minLedgerVersion = Number(ledgers[0]) const minLedgerVersion = Number(ledgers[0])
@ -266,7 +297,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
let info let info
try { try {
info = await api.getAccountInfo(address) info = await api.getAccountInfo(freshAddress)
} catch (e) { } catch (e) {
if (e.message !== 'actNotFound') { if (e.message !== 'actNotFound') {
throw e throw e
@ -282,12 +313,12 @@ const RippleJSBridge: WalletBridge<Transaction> = {
const balance = parseAPIValue(info.xrpBalance) const balance = parseAPIValue(info.xrpBalance)
if (isNaN(balance) || !isFinite(balance)) { if (isNaN(balance) || !isFinite(balance)) {
throw new Error(`Ripple: invalid balance=${balance} for address ${address}`) throw new Error(`Ripple: invalid balance=${balance} for address ${freshAddress}`)
} }
next(a => ({ ...a, balance })) next(a => ({ ...a, balance }))
const transactions = await api.getTransactions(address, { const transactions = await api.getTransactions(freshAddress, {
minLedgerVersion: Math.max(blockHeight, minLedgerVersion), minLedgerVersion: Math.max(blockHeight, minLedgerVersion),
maxLedgerVersion, maxLedgerVersion,
}) })
@ -297,9 +328,18 @@ const RippleJSBridge: WalletBridge<Transaction> = {
next(a => { next(a => {
const newOps = transactions.map(txToOperation(a)) const newOps = transactions.map(txToOperation(a))
const operations = mergeOps(a.operations, newOps) const operations = mergeOps(a.operations, newOps)
const [last] = operations
const pendingOperations = a.pendingOperations.filter(
o =>
last &&
last.transactionSequenceNumber &&
o.transactionSequenceNumber &&
o.transactionSequenceNumber > last.transactionSequenceNumber,
)
return { return {
...a, ...a,
operations, operations,
pendingOperations,
blockHeight: maxLedgerVersion, blockHeight: maxLedgerVersion,
lastSyncDate: new Date(), lastSyncDate: new Date(),
} }
@ -351,6 +391,11 @@ const RippleJSBridge: WalletBridge<Transaction> = {
isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false, isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false,
canBeSpent: async (a, t) => {
const r = await getServerInfo(a.currency)
return t.amount + t.fee + parseAPIValue(r.validatedLedger.reserveBaseXRP) <= a.balance
},
getTotalSpent: (a, t) => Promise.resolve(t.amount + t.fee), getTotalSpent: (a, t) => Promise.resolve(t.amount + t.fee),
getMaxAmount: (a, t) => Promise.resolve(a.balance - t.fee), getMaxAmount: (a, t) => Promise.resolve(a.balance - t.fee),
@ -362,7 +407,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
const amount = formatAPICurrencyXRP(t.amount) const amount = formatAPICurrencyXRP(t.amount)
const payment = { const payment = {
source: { source: {
address: a.address, address: a.freshAddress,
amount, amount,
}, },
destination: { destination: {
@ -375,7 +420,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
fee: formatAPICurrencyXRP(t.fee).value, fee: formatAPICurrencyXRP(t.fee).value,
} }
const prepared = await api.preparePayment(a.address, payment, instruction) const prepared = await api.preparePayment(a.freshAddress, payment, instruction)
const transaction = await signTransaction const transaction = await signTransaction
.send({ .send({
@ -392,11 +437,37 @@ const RippleJSBridge: WalletBridge<Transaction> = {
throw new Error(submittedPayment.resultMessage) throw new Error(submittedPayment.resultMessage)
} }
return computeBinaryTransactionHash(transaction) const hash = computeBinaryTransactionHash(transaction)
return {
id: `${a.id}-${hash}-OUT`,
hash,
accountId: a.id,
type: 'OUT',
value: t.amount,
blockHash: null,
blockHeight: null,
senders: [a.freshAddress],
recipients: [t.recipient],
date: new Date(),
// we probably can't get it so it's a predictive value
transactionSequenceNumber:
(a.operations.length > 0 ? a.operations[0].transactionSequenceNumber : 0) +
a.pendingOperations.length,
}
} finally { } finally {
api.disconnect() api.disconnect()
} }
}, },
addPendingOperation: (account, operation) => ({
...account,
pendingOperations: [operation].concat(
account.pendingOperations.filter(
o => o.transactionSequenceNumber === operation.transactionSequenceNumber,
),
),
}),
} }
export default RippleJSBridge export default RippleJSBridge

2
src/bridge/UnsupportedBridge.js

@ -30,6 +30,8 @@ const UnsupportedBridge: WalletBridge<*> = {
getTransactionRecipient: () => '', getTransactionRecipient: () => '',
canBeSpent: () => Promise.resolve(false),
getTotalSpent: () => Promise.resolve(0), getTotalSpent: () => Promise.resolve(0),
getMaxAmount: () => Promise.resolve(0), getMaxAmount: () => Promise.resolve(0),

16
src/bridge/makeMockBridge.js

@ -4,6 +4,7 @@ import {
genAddingOperationsInAccount, genAddingOperationsInAccount,
genOperation, genOperation,
} from '@ledgerhq/live-common/lib/mock/account' } from '@ledgerhq/live-common/lib/mock/account'
import { getOperationAmountNumber } from '@ledgerhq/live-common/lib/helpers/operation'
import Prando from 'prando' import Prando from 'prando'
import type { Operation } from '@ledgerhq/live-common/lib/types' import type { Operation } from '@ledgerhq/live-common/lib/types'
import type { WalletBridge } from './types' import type { WalletBridge } from './types'
@ -29,6 +30,7 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> {
extraInitialTransactionProps, extraInitialTransactionProps,
getTotalSpent, getTotalSpent,
getMaxAmount, getMaxAmount,
canBeSpent,
} = { } = {
...defaultOpts, ...defaultOpts,
...opts, ...opts,
@ -53,7 +55,7 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> {
account = { ...account } account = { ...account }
account.blockHeight++ account.blockHeight++
for (const op of ops) { for (const op of ops) {
account.balance += op.amount account.balance += getOperationAmountNumber(op)
} }
return account return account
}) })
@ -142,6 +144,8 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> {
isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false, isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false,
canBeSpent,
getTotalSpent, getTotalSpent,
getMaxAmount, getMaxAmount,
@ -149,12 +153,16 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> {
signAndBroadcast: async (account, t) => { signAndBroadcast: async (account, t) => {
const rng = new Prando() const rng = new Prando()
const op = genOperation(account, account.operations, account.currency, rng) const op = genOperation(account, account.operations, account.currency, rng)
op.amount = -t.amount op.type = 'OUT'
op.address = t.recipient op.value = t.amount
op.blockHash = null
op.blockHeight = null
op.senders = [account.freshAddress]
op.recipients = [t.recipient]
op.blockHeight = account.blockHeight op.blockHeight = account.blockHeight
op.date = new Date() op.date = new Date()
broadcasted[account.id] = (broadcasted[account.id] || []).concat(op) broadcasted[account.id] = (broadcasted[account.id] || []).concat(op)
return op.id return { ...op }
}, },
} }
} }

16
src/bridge/types.js

@ -1,6 +1,6 @@
// @flow // @flow
import type { Account, Currency } from '@ledgerhq/live-common/lib/types' import type { Account, Operation, Currency } from '@ledgerhq/live-common/lib/types'
// a WalletBridge is implemented on renderer side. // a WalletBridge is implemented on renderer side.
// this is an abstraction on top of libcore / ethereumjs / ripple js / ... // this is an abstraction on top of libcore / ethereumjs / ripple js / ...
@ -84,6 +84,8 @@ export interface WalletBridge<Transaction> {
// render the whole advanced part of the form // render the whole advanced part of the form
EditAdvancedOptions?: React$ComponentType<EditProps<Transaction>>; EditAdvancedOptions?: React$ComponentType<EditProps<Transaction>>;
canBeSpent(account: Account, transaction: Transaction): Promise<boolean>;
getTotalSpent(account: Account, transaction: Transaction): Promise<number>; getTotalSpent(account: Account, transaction: Transaction): Promise<number>;
// NB this is not used yet but we'll use it when we have MAX // NB this is not used yet but we'll use it when we have MAX
@ -93,10 +95,18 @@ export interface WalletBridge<Transaction> {
* finalize the transaction by * finalize the transaction by
* - signing it with the ledger device * - signing it with the ledger device
* - broadcasting it to network * - broadcasting it to network
* - retrieve and return the id related to this transaction (typically a tx id hash) * - retrieve and return the optimistic Operation that this transaction is likely to create in the future
* *
* NOTE: in future, when transaction balance is close to account.balance, we could wipe it all at this level... * NOTE: in future, when transaction balance is close to account.balance, we could wipe it all at this level...
* to implement that, we might want to have special logic `account.balance-transaction.amount < dust` but not sure where this should leave (i would say on UI side because we need to inform user visually). * to implement that, we might want to have special logic `account.balance-transaction.amount < dust` but not sure where this should leave (i would say on UI side because we need to inform user visually).
*/ */
signAndBroadcast(account: Account, transaction: Transaction, deviceId: DeviceId): Promise<string>; signAndBroadcast(
account: Account,
transaction: Transaction,
deviceId: DeviceId,
): Promise<Operation>;
// Implement an optimistic response for signAndBroadcast.
// you likely should add the operation in account.pendingOperations but maybe you want to clean it (because maybe some are replaced / cancelled by this one?)
addPendingOperation?: (account: Account, optimisticOperation: Operation) => Account;
} }

4
src/commands/getAddress.js

@ -2,7 +2,7 @@
import { createCommand, Command } from 'helpers/ipc' import { createCommand, Command } from 'helpers/ipc'
import { fromPromise } from 'rxjs/observable/fromPromise' import { fromPromise } from 'rxjs/observable/fromPromise'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid' import { withDevice } from 'helpers/deviceAccess'
import getAddressForCurrency from 'helpers/getAddressForCurrency' import getAddressForCurrency from 'helpers/getAddressForCurrency'
type Input = { type Input = {
@ -24,7 +24,7 @@ const cmd: Command<Input, Result> = createCommand(
'getAddress', 'getAddress',
({ currencyId, devicePath, path, ...options }) => ({ currencyId, devicePath, path, ...options }) =>
fromPromise( fromPromise(
CommNodeHid.open(devicePath).then(transport => withDevice(devicePath)(transport =>
getAddressForCurrency(currencyId)(transport, currencyId, path, options), getAddressForCurrency(currencyId)(transport, currencyId, path, options),
), ),
), ),

4
src/commands/signTransaction.js

@ -2,7 +2,7 @@
import { createCommand, Command } from 'helpers/ipc' import { createCommand, Command } from 'helpers/ipc'
import { fromPromise } from 'rxjs/observable/fromPromise' import { fromPromise } from 'rxjs/observable/fromPromise'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid' import { withDevice } from 'helpers/deviceAccess'
import signTransactionForCurrency from 'helpers/signTransactionForCurrency' import signTransactionForCurrency from 'helpers/signTransactionForCurrency'
type Input = { type Input = {
@ -19,7 +19,7 @@ const cmd: Command<Input, Result> = createCommand(
'signTransaction', 'signTransaction',
({ currencyId, devicePath, path, transaction }) => ({ currencyId, devicePath, path, transaction }) =>
fromPromise( fromPromise(
CommNodeHid.open(devicePath).then(transport => withDevice(devicePath)(transport =>
signTransactionForCurrency(currencyId)(transport, currencyId, path, transaction), signTransactionForCurrency(currencyId)(transport, currencyId, path, transaction),
), ),
), ),

22
src/components/BalanceSummary/index.js

@ -35,7 +35,8 @@ const BalanceSummary = ({
renderHeader, renderHeader,
selectedTime, selectedTime,
}: Props) => { }: Props) => {
const unit = getFiatCurrencyByTicker(counterValue).units[0] const currency = getFiatCurrencyByTicker(counterValue)
const account = accounts.length === 1 ? accounts[0] : undefined
return ( return (
<Card p={0} py={6}> <Card p={0} py={6}>
<CalculateBalance <CalculateBalance
@ -61,21 +62,30 @@ const BalanceSummary = ({
<Box ff="Open Sans" fontSize={4} color="graphite" pt={6}> <Box ff="Open Sans" fontSize={4} color="graphite" pt={6}>
<Chart <Chart
id={chartId} id={chartId}
account={account}
color={chartColor} color={chartColor}
data={balanceHistory} data={balanceHistory}
height={250} height={250}
unit={unit} currency={currency}
tickXScale={selectedTime} tickXScale={selectedTime}
renderTooltip={d => renderTooltip={
isAvailable ? ( isAvailable && !account
? d => (
<Fragment>
<FormattedVal <FormattedVal
alwaysShowSign={false} alwaysShowSign={false}
color="white" fontSize={5}
color="dark"
showCode showCode
fiat={counterValue} fiat={counterValue}
val={d.value} val={d.value}
/> />
) : null <Box ff="Open Sans|Regular" color="grey" fontSize={3} mt={2}>
{d.date.toISOString().substr(0, 10)}
</Box>
</Fragment>
)
: undefined
} }
/> />
</Box> </Box>

22
src/components/CalculateBalance.js

@ -4,7 +4,7 @@
import { PureComponent } from 'react' import { PureComponent } from 'react'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import type { Account, BalanceHistory } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import { getBalanceHistorySum } from '@ledgerhq/live-common/lib/helpers/account' import { getBalanceHistorySum } from '@ledgerhq/live-common/lib/helpers/account'
import CounterValues from 'helpers/countervalues' import CounterValues from 'helpers/countervalues'
import { exchangeSettingsForAccountSelector, counterValueCurrencySelector } from 'reducers/settings' import { exchangeSettingsForAccountSelector, counterValueCurrencySelector } from 'reducers/settings'
@ -16,8 +16,14 @@ type OwnProps = {
children: Props => *, children: Props => *,
} }
type Item = {
date: Date,
value: number,
originalValue: number,
}
type Props = OwnProps & { type Props = OwnProps & {
balanceHistory: BalanceHistory, balanceHistory: Item[],
balanceStart: number, balanceStart: number,
balanceEnd: number, balanceEnd: number,
isAvailable: boolean, isAvailable: boolean,
@ -26,10 +32,18 @@ type Props = OwnProps & {
const mapStateToProps = (state: State, props: OwnProps) => { const mapStateToProps = (state: State, props: OwnProps) => {
const counterValueCurrency = counterValueCurrencySelector(state) const counterValueCurrency = counterValueCurrencySelector(state)
let isAvailable = true let isAvailable = true
// create array of original values, used to reconciliate
// with counter values after calculation
const originalValues = []
const balanceHistory = getBalanceHistorySum( const balanceHistory = getBalanceHistorySum(
props.accounts, props.accounts,
props.daysCount, props.daysCount,
(account, value, date) => { (account, value, date) => {
// keep track of original value
originalValues.push(value)
const cv = CounterValues.calculateSelector(state, { const cv = CounterValues.calculateSelector(state, {
value, value,
date, date,
@ -43,7 +57,11 @@ const mapStateToProps = (state: State, props: OwnProps) => {
} }
return cv return cv
}, },
).map((item, i) =>
// reconciliate balance history with original values
({ ...item, originalValue: originalValues[i] || 0 }),
) )
return { return {
isAvailable, isAvailable,
balanceHistory, balanceHistory,

4
src/components/CounterValue/index.js

@ -49,7 +49,9 @@ const mapStateToProps = (state: State, props: OwnProps) => {
class CounterValue extends PureComponent<Props> { class CounterValue extends PureComponent<Props> {
render() { render() {
const { value, counterValueCurrency, date, ...props } = this.props const { value, counterValueCurrency, date, ...props } = this.props
if (!value && value !== 0) return null if (!value && value !== 0) {
return null
}
return ( return (
<FormattedVal <FormattedVal
val={value} val={value}

2
src/components/CurrentAddress/stories.js

@ -13,7 +13,7 @@ const stories = storiesOf('Components', module)
stories.add('CurrentAddress', () => ( stories.add('CurrentAddress', () => (
<CurrentAddress <CurrentAddress
accountName={text('accountName', '')} accountName={text('accountName', '')}
address={accounts[0].address} address={accounts[0].freshAddress}
addressVerified={boolean('addressVerified', true)} addressVerified={boolean('addressVerified', true)}
withBadge={boolean('withBadge', false)} withBadge={boolean('withBadge', false)}
withFooter={boolean('withFooter', false)} withFooter={boolean('withFooter', false)}

9
src/components/CurrentAddressForAccount.js

@ -11,12 +11,5 @@ type Props = {
export default function CurrentAddressForAccount(props: Props) { export default function CurrentAddressForAccount(props: Props) {
const { account, ...p } = props const { account, ...p } = props
return <CurrentAddress accountName={account.name} address={account.freshAddress} {...p} />
// TODO: handle other cryptos than BTC-like
let freshAddress = account.addresses[0]
if (!freshAddress) {
freshAddress = { str: '', path: '' }
}
return <CurrentAddress accountName={account.name} address={freshAddress.str} {...p} />
} }

4
src/components/DashboardPage/AccountCard.js

@ -86,9 +86,9 @@ const AccountCard = ({
color={account.currency.color} color={account.currency.color}
height={52} height={52}
hideAxis hideAxis
interactive={false} isInteractive={false}
id={`account-chart-${account.id}`} id={`account-chart-${account.id}`}
unit={account.unit} account={account}
/> />
</Box> </Box>
)} )}

20
src/components/DevTools.js

@ -254,26 +254,8 @@ class DevTools extends PureComponent<any, State> {
color="#8884d8" color="#8884d8"
height={50} height={50}
hideAxis hideAxis
interactive={false} isInteractive={false}
/> />
{/* <WrapperChart
height={50}
render={({ height, width }) => (
<VictoryArea
data={cpuUsage[k]}
y="value"
style={{
data: {
stroke: '#8884d8',
fill: '#8884d8',
},
}}
height={height}
width={width}
padding={{ top: 10, right: 0, left: 0, bottom: 0 }}
/>
)}
/> */}
</Box> </Box>
</Box> </Box>
))} ))}

12
src/components/DeviceCheckAddress.js

@ -42,23 +42,17 @@ class CheckAddress extends PureComponent<Props, State> {
verifyAddress = async ({ device, account }: { device: Device, account: Account }) => { verifyAddress = async ({ device, account }: { device: Device, account: Account }) => {
try { try {
// TODO: this will work only for BTC-like accounts
const freshAddress = account.addresses[0]
if (!freshAddress) {
throw new Error('Account doesnt have fresh addresses')
}
const { address } = await getAddress const { address } = await getAddress
.send({ .send({
currencyId: account.currency.id, currencyId: account.currency.id,
devicePath: device.path, devicePath: device.path,
path: freshAddress.path, path: account.freshAddressPath,
segwit: account.isSegwit, segwit: !!account.isSegwit,
verify: true, verify: true,
}) })
.toPromise() .toPromise()
if (address !== freshAddress.str) { if (address !== account.freshAddress) {
throw new Error('Confirmed address is different') throw new Error('Confirmed address is different')
} }

10
src/components/DeviceSignTransaction.js

@ -1,11 +1,11 @@
// @flow // @flow
import { PureComponent } from 'react' import { PureComponent } from 'react'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import type { Device } from 'types/common' import type { Device } from 'types/common'
import type { WalletBridge } from 'bridge/types' import type { WalletBridge } from 'bridge/types'
type Props = { type Props = {
onSuccess: (txid: string) => void, onOperationBroadcasted: (op: Operation) => void,
render: ({ error: ?Error }) => React$Node, render: ({ error: ?Error }) => React$Node,
device: Device, device: Device,
account: Account, account: Account,
@ -32,10 +32,10 @@ class DeviceSignTransaction extends PureComponent<Props, State> {
unmount = false unmount = false
sign = async () => { sign = async () => {
const { device, account, transaction, bridge, onSuccess } = this.props const { device, account, transaction, bridge, onOperationBroadcasted } = this.props
try { try {
const txid = await bridge.signAndBroadcast(account, transaction, device.path) const optimisticOperation = await bridge.signAndBroadcast(account, transaction, device.path)
onSuccess(txid) onOperationBroadcasted(optimisticOperation)
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
this.setState({ error }) this.setState({ error })

8
src/components/EnsureDeviceApp/index.js

@ -110,9 +110,9 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
appOptions = { appOptions = {
devicePath: deviceSelected.path, devicePath: deviceSelected.path,
currencyId: account.currency.id, currencyId: account.currency.id,
path: account.path, path: account.freshAddressPath,
accountAddress: account.address, accountAddress: account.freshAddress,
segwit: account.path.startsWith("49'"), // TODO: store segwit info in account segwit: !!account.isSegwit,
} }
} else if (currency) { } else if (currency) {
appOptions = { appOptions = {
@ -125,7 +125,7 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
try { try {
if (appOptions) { if (appOptions) {
const { address } = await getAddress.send(appOptions).toPromise() const { address } = await getAddress.send(appOptions).toPromise()
if (account && account.address !== address) { if (account && account.freshAddress !== address) {
throw new Error('Account address is different than device address') throw new Error('Account address is different than device address')
} }
} else { } else {

12
src/components/FeesField/EthereumKind.js

@ -15,14 +15,21 @@ type Props = {
} }
class FeesField extends Component<Props & { fees?: Fees, error?: Error }, *> { class FeesField extends Component<Props & { fees?: Fees, error?: Error }, *> {
state = {
isFocused: false,
}
componentDidUpdate() { componentDidUpdate() {
const { gasPrice, fees, onChange } = this.props const { gasPrice, fees, onChange } = this.props
if (!gasPrice && fees && fees.gas_price) { const { isFocused } = this.state
if (!gasPrice && fees && fees.gas_price && !isFocused) {
onChange(fees.gas_price) // we want to set the default to gas_price onChange(fees.gas_price) // we want to set the default to gas_price
} }
} }
onChangeFocus = isFocused => {
this.setState({ isFocused })
}
render() { render() {
const { account, gasPrice, onChange, error } = this.props const { account, gasPrice, error, onChange } = this.props
const { units } = account.currency const { units } = account.currency
return ( return (
<GenericContainer error={error} help="Gas"> <GenericContainer error={error} help="Gas">
@ -32,6 +39,7 @@ class FeesField extends Component<Props & { fees?: Fees, error?: Error }, *> {
containerProps={{ grow: true }} containerProps={{ grow: true }}
value={gasPrice} value={gasPrice}
onChange={onChange} onChange={onChange}
onChangeFocus={this.onChangeFocus}
/> />
</GenericContainer> </GenericContainer>
) )

20
src/components/OperationsList/ConfirmationCheck.js

@ -3,6 +3,8 @@
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import type { OperationType } from '@ledgerhq/live-common/lib/types'
import { rgba } from 'styles/helpers' import { rgba } from 'styles/helpers'
import type { T } from 'types/common' import type { T } from 'types/common'
@ -16,14 +18,14 @@ import Tooltip from 'components/base/Tooltip'
const Container = styled(Box).attrs({ const Container = styled(Box).attrs({
bg: p => bg: p =>
p.isConfirmed ? rgba(p.type === 'from' ? p.marketColor : p.theme.colors.grey, 0.2) : 'none', p.isConfirmed ? rgba(p.type === 'IN' ? p.marketColor : p.theme.colors.grey, 0.2) : 'none',
color: p => (p.type === 'from' ? p.marketColor : p.theme.colors.grey), color: p => (p.type === 'IN' ? p.marketColor : p.theme.colors.grey),
align: 'center', align: 'center',
justify: 'center', justify: 'center',
})` })`
border: ${p => border: ${p =>
!p.isConfirmed !p.isConfirmed
? `1px solid ${p.type === 'from' ? p.marketColor : rgba(p.theme.colors.grey, 0.2)}` ? `1px solid ${p.type === 'IN' ? p.marketColor : rgba(p.theme.colors.grey, 0.2)}`
: 0}; : 0};
border-radius: 50%; border-radius: 50%;
position: relative; position: relative;
@ -44,25 +46,21 @@ const WrapperClock = styled(Box).attrs({
const ConfirmationCheck = ({ const ConfirmationCheck = ({
marketColor, marketColor,
confirmations, isConfirmed,
minConfirmations,
t, t,
type, type,
withTooltip, withTooltip,
...props ...props
}: { }: {
marketColor: string, marketColor: string,
confirmations: number, isConfirmed: boolean,
minConfirmations: number,
t: T, t: T,
type: 'to' | 'from', type: OperationType,
withTooltip?: boolean, withTooltip?: boolean,
}) => { }) => {
const isConfirmed = confirmations >= minConfirmations
const renderContent = () => ( const renderContent = () => (
<Container type={type} isConfirmed={isConfirmed} marketColor={marketColor} {...props}> <Container type={type} isConfirmed={isConfirmed} marketColor={marketColor} {...props}>
{type === 'from' ? <IconReceive size={12} /> : <IconSend size={12} />} {type === 'IN' ? <IconReceive size={12} /> : <IconSend size={12} />}
{!isConfirmed && ( {!isConfirmed && (
<WrapperClock> <WrapperClock>
<IconClock size={10} /> <IconClock size={10} />

50
src/components/OperationsList/Operation.js

@ -7,10 +7,11 @@ import { createStructuredSelector } from 'reselect'
import moment from 'moment' import moment from 'moment'
import noop from 'lodash/noop' import noop from 'lodash/noop'
import { getCryptoCurrencyIcon } from '@ledgerhq/live-common/lib/react' import { getCryptoCurrencyIcon } from '@ledgerhq/live-common/lib/react'
import { getOperationAmountNumber } from '@ledgerhq/live-common/lib/helpers/operation'
import type { Account, Operation as OperationType } from '@ledgerhq/live-common/lib/types' import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common' import type { T, CurrencySettings } from 'types/common'
import { currencySettingsForAccountSelector, marketIndicatorSelector } from 'reducers/settings' import { currencySettingsForAccountSelector, marketIndicatorSelector } from 'reducers/settings'
import { rgba, getMarketColor } from 'styles/helpers' import { rgba, getMarketColor } from 'styles/helpers'
@ -32,7 +33,7 @@ const ACCOUNT_COL_SIZE = 150
const AMOUNT_COL_SIZE = 150 const AMOUNT_COL_SIZE = 150
const CONFIRMATION_COL_SIZE = 44 const CONFIRMATION_COL_SIZE = 44
const OperationRaw = styled(Box).attrs({ const OperationRow = styled(Box).attrs({
horizontal: true, horizontal: true,
alignItems: 'center', alignItems: 'center',
})` })`
@ -102,16 +103,16 @@ const Cell = styled(Box).attrs({
type Props = { type Props = {
account: Account, account: Account,
currencySettings: *, currencySettings: CurrencySettings,
onAccountClick: Function, onAccountClick: (account: Account) => void,
onOperationClick: Function, onOperationClick: ({ operation: Operation, account: Account, marketColor: string }) => void,
marketIndicator: string, marketIndicator: string,
t: T, t: T,
op: OperationType, op: Operation, // FIXME rename it operation
withAccount: boolean, withAccount: boolean,
} }
class Operation extends PureComponent<Props> { class OperationComponent extends PureComponent<Props> {
static defaultProps = { static defaultProps = {
onAccountClick: noop, onAccountClick: noop,
onOperationClick: noop, onOperationClick: noop,
@ -132,21 +133,28 @@ class Operation extends PureComponent<Props> {
const { unit, currency } = account const { unit, currency } = account
const time = moment(op.date) const time = moment(op.date)
const Icon = getCryptoCurrencyIcon(account.currency) const Icon = getCryptoCurrencyIcon(account.currency)
const isNegative = op.amount < 0 const amount = getOperationAmountNumber(op)
const type = !isNegative ? 'from' : 'to' const isNegative = amount < 0
const isOptimistic = op.blockHeight === null
const isConfirmed =
(op.blockHeight ? account.blockHeight - op.blockHeight : 0) > currencySettings.confirmationsNb
const marketColor = getMarketColor({ const marketColor = getMarketColor({
marketIndicator, marketIndicator,
isNegative, isNegative,
}) })
// FIXME each cell in a component
return ( return (
<OperationRaw onClick={() => onOperationClick({ operation: op, account, type, marketColor })}> <OperationRow
style={{ opacity: isOptimistic ? 0.5 : 1 }}
onClick={() => onOperationClick({ operation: op, account, marketColor })}
>
<Cell size={CONFIRMATION_COL_SIZE} align="center" justify="flex-start"> <Cell size={CONFIRMATION_COL_SIZE} align="center" justify="flex-start">
<ConfirmationCheck <ConfirmationCheck
type={type} type={op.type}
minConfirmations={currencySettings.minConfirmations} isConfirmed={isConfirmed}
confirmations={account.blockHeight - op.blockHeight}
marketColor={marketColor} marketColor={marketColor}
t={t} t={t}
/> />
@ -154,7 +162,7 @@ class Operation extends PureComponent<Props> {
<Cell size={DATE_COL_SIZE} justifyContent="space-between" px={3}> <Cell size={DATE_COL_SIZE} justifyContent="space-between" px={3}>
<Box> <Box>
<Box ff="Open Sans|SemiBold" fontSize={3} color="smoke"> <Box ff="Open Sans|SemiBold" fontSize={3} color="smoke">
{t(`operationsList:${type}`)} {t(`operationsList:${op.type}`)}
</Box> </Box>
<Hour>{time.format('HH:mm')}</Hour> <Hour>{time.format('HH:mm')}</Hour>
</Box> </Box>
@ -185,31 +193,31 @@ class Operation extends PureComponent<Props> {
</Cell> </Cell>
)} )}
<Cell grow shrink style={{ display: 'block' }}> <Cell grow shrink style={{ display: 'block' }}>
<Address value={op.address} /> <Address value={op.type === 'IN' ? op.senders[0] : op.recipients[0]} />
</Cell> </Cell>
<Cell size={AMOUNT_COL_SIZE} justify="flex-end"> <Cell size={AMOUNT_COL_SIZE} justify="flex-end">
<Box alignItems="flex-end"> <Box alignItems="flex-end">
<FormattedVal <FormattedVal
val={op.amount} val={amount}
unit={unit} unit={unit}
showCode showCode
fontSize={4} fontSize={4}
alwaysShowSign alwaysShowSign
color={op.amount < 0 ? 'smoke' : undefined} color={amount < 0 ? 'smoke' : undefined}
/> />
<CounterValue <CounterValue
color="grey" color="grey"
fontSize={3} fontSize={3}
date={time.toDate()} date={time.toDate()}
currency={currency} currency={currency}
value={op.amount} value={amount}
exchange={currencySettings.exchange} exchange={currencySettings.exchange}
/> />
</Box> </Box>
</Cell> </Cell>
</OperationRaw> </OperationRow>
) )
} }
} }
export default connect(mapStateToProps)(Operation) export default connect(mapStateToProps)(OperationComponent)

11
src/components/SettingsPage/sections/Currencies.js

@ -15,7 +15,6 @@ import type { Settings, CurrencySettings, T } from 'types/common'
import { counterValueCurrencySelector } from 'reducers/settings' import { counterValueCurrencySelector } from 'reducers/settings'
import { currenciesSelector } from 'reducers/accounts' import { currenciesSelector } from 'reducers/accounts'
import CounterValues from 'helpers/countervalues'
import SelectCurrency from 'components/SelectCurrency' import SelectCurrency from 'components/SelectCurrency'
import StepperNumber from 'components/base/StepperNumber' import StepperNumber from 'components/base/StepperNumber'
@ -138,21 +137,13 @@ class TabCurrencies extends PureComponent<Props, State> {
/> />
<Body> <Body>
<Row title="Exchange" desc="The exchange to use"> <Row title="Exchange" desc="The exchange to use">
<CounterValues.PollingConsumer>
{polling => (
// TODO move to a dedicated "row" component
<ExchangeSelect <ExchangeSelect
from={currency} from={currency}
to={counterValueCurrency} to={counterValueCurrency}
exchangeId={exchange} exchangeId={exchange}
onChange={exchange => { onChange={this.handleChangeExchange}
this.handleChangeExchange(exchange)
polling.poll()
}}
style={{ minWidth: 200 }} style={{ minWidth: 200 }}
/> />
)}
</CounterValues.PollingConsumer>
</Row> </Row>
<Row <Row
title={t('settings:currencies.confirmationsToSpend')} title={t('settings:currencies.confirmationsToSpend')}

10
src/components/SettingsPage/sections/Display.js

@ -10,7 +10,6 @@ import Select from 'components/base/Select'
import RadioGroup from 'components/base/RadioGroup' import RadioGroup from 'components/base/RadioGroup'
import IconDisplay from 'icons/Display' import IconDisplay from 'icons/Display'
import languageKeys from 'config/languages' import languageKeys from 'config/languages'
import CounterValues from 'helpers/countervalues'
import regionsByKey from 'helpers/regions.json' import regionsByKey from 'helpers/regions.json'
@ -135,22 +134,15 @@ class TabProfile extends PureComponent<Props, State> {
title={t('settings:display.counterValue')} title={t('settings:display.counterValue')}
desc={t('settings:display.counterValueDesc')} desc={t('settings:display.counterValueDesc')}
> >
<CounterValues.PollingConsumer>
{polling => (
<Select <Select
style={{ minWidth: 250 }} style={{ minWidth: 250 }}
small small
onChange={item => { onChange={this.handleChangeCounterValue}
this.handleChangeCounterValue(item)
polling.poll()
}}
itemToString={item => (item ? item.name : '')} itemToString={item => (item ? item.name : '')}
renderSelected={item => item && item.name} renderSelected={item => item && item.name}
items={fiats} items={fiats}
value={cachedCounterValue} value={cachedCounterValue}
/> />
)}
</CounterValues.PollingConsumer>
</Row> </Row>
<Row title={t('settings:display.language')} desc={t('settings:display.languageDesc')}> <Row title={t('settings:display.language')} desc={t('settings:display.languageDesc')}>
<Select <Select

6
src/components/TopBar/ItemContainer.js

@ -8,13 +8,13 @@ export default styled(Box).attrs({
px: 2, px: 2,
ml: 0, ml: 0,
justifyContent: 'center', justifyContent: 'center',
cursor: p => (p.interactive ? 'pointer' : 'default'), cursor: p => (p.isInteractive ? 'pointer' : 'default'),
})` })`
opacity: 0.7; opacity: 0.7;
&:hover { &:hover {
opacity: ${p => (p.interactive ? 0.85 : 0.7)}; opacity: ${p => (p.isInteractive ? 0.85 : 0.7)};
} }
&:active { &:active {
opacity: ${p => (p.interactive ? 1 : 0.7)}; opacity: ${p => (p.isInteractive ? 1 : 0.7)};
} }
` `

4
src/components/TopBar/index.js

@ -93,7 +93,7 @@ class TopBar extends PureComponent<Props> {
<Box justifyContent="center"> <Box justifyContent="center">
<Bar /> <Bar />
</Box> </Box>
<ItemContainer interactive onClick={this.navigateToSettings}> <ItemContainer isInteractive onClick={this.navigateToSettings}>
<IconSettings size={16} /> <IconSettings size={16} />
</ItemContainer> </ItemContainer>
{hasPassword && ( // FIXME this should be a dedicated component. therefore this component don't need to connect() {hasPassword && ( // FIXME this should be a dedicated component. therefore this component don't need to connect()
@ -101,7 +101,7 @@ class TopBar extends PureComponent<Props> {
<Box justifyContent="center"> <Box justifyContent="center">
<Bar /> <Bar />
</Box> </Box>
<ItemContainer interactive justifyContent="center" onClick={this.handleLock}> <ItemContainer isInteractive justifyContent="center" onClick={this.handleLock}>
<IconLock size={16} /> <IconLock size={16} />
</ItemContainer> </ItemContainer>
</Fragment> </Fragment>

88
src/components/base/Chart/Tooltip.js

@ -1,47 +1,35 @@
// @flow // @flow
import React from 'react' import React, { Fragment } from 'react'
import styled from 'styled-components'
import type { Unit } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import { colors as themeColors } from 'styles/theme'
import { TooltipContainer } from 'components/base/Tooltip'
import FormattedVal from 'components/base/FormattedVal' import FormattedVal from 'components/base/FormattedVal'
import Box from 'components/base/Box'
import type { Item } from './types' import type { Item } from './types'
/** const Container = styled(Box).attrs({
* we use inline style for more perfs, as tooltip may re-render numerous times px: 4,
*/ py: 3,
align: 'center',
const Arrow = () => ( })`
<svg background: white;
style={{ border: 1px solid #d8d8d8;
display: 'block', border-radius: 4px;
position: 'absolute', width: 150px;
left: '50%', box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.03);
bottom: 0, `
marginBottom: -10,
transform: 'translate(-50%, 0)',
}}
viewBox="0 0 14 6.2"
width={16}
height={16}
>
<path fill={themeColors.dark} d="m14 0-5.5 5.6c-0.8 0.8-2 0.8-2.8 0l-5.7-5.6" />
</svg>
)
const Tooltip = ({ const Tooltip = ({
d, item,
renderTooltip, renderTooltip,
fiat, account,
unit,
}: { }: {
d: Item, item: Item,
renderTooltip?: Function, renderTooltip?: Function,
fiat?: string, account?: Account,
unit?: Unit,
}) => ( }) => (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<div <div
@ -51,32 +39,40 @@ const Tooltip = ({
left: 0, left: 0,
transform: `translate3d(-50%, 0, 0)`, transform: `translate3d(-50%, 0, 0)`,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
marginBottom: 5, marginBottom: -5,
}} }}
> >
<TooltipContainer style={{ textAlign: 'center' }}> <Container style={{ textAlign: 'center' }}>
{renderTooltip ? ( {renderTooltip ? (
renderTooltip(d) renderTooltip(item)
) : ( ) : (
<Fragment>
<FormattedVal <FormattedVal
color="dark"
fontSize={5}
alwaysShowSign={false} alwaysShowSign={false}
color="white"
showCode showCode
fiat={fiat} fiat="USD"
unit={unit} val={item.value}
val={d.value}
/> />
{account && (
<FormattedVal
color="grey"
fontSize={3}
alwaysShowSign={false}
showCode
unit={account.unit}
val={item.originalValue}
/>
)}
<Box ff="Open Sans|Regular" color="grey" fontSize={3} mt={2}>
{item.date.toISOString().substr(0, 10)}
</Box>
</Fragment>
)} )}
</TooltipContainer> </Container>
<Arrow />
</div> </div>
</div> </div>
) )
Tooltip.defaultProps = {
renderTooltip: undefined,
fiat: undefined,
unit: undefined,
}
export default Tooltip export default Tooltip

25
src/components/base/Chart/handleMouseEvents.js

@ -29,7 +29,7 @@ export default function handleMouseEvents({
renderTooltip?: Function, renderTooltip?: Function,
}) { }) {
const { MARGINS, HEIGHT, WIDTH, NODES, DATA, x, y } = ctx const { MARGINS, HEIGHT, WIDTH, NODES, DATA, x, y } = ctx
const { hideAxis, unit } = props const { account } = props
const bisectDate = d3.bisector(d => d.parsedDate).left const bisectDate = d3.bisector(d => d.parsedDate).left
@ -65,21 +65,15 @@ export default function handleMouseEvents({
NODES.tooltip NODES.tooltip
.style('transition', '100ms cubic-bezier(.61,1,.53,1) opacity') .style('transition', '100ms cubic-bezier(.61,1,.53,1) opacity')
.style('opacity', 1) .style('opacity', 1)
.style('transform', `translate3d(${MARGINS.left + x(d.parsedDate)}px, ${y(d.value)}px, 0)`) .style('transform', `translate3d(${MARGINS.left + x(d.parsedDate)}px, 0, 0)`)
NODES.focus.style('opacity', 1) NODES.focus.style('opacity', 1)
if (!hideAxis) {
NODES.xBar.style('opacity', 1) NODES.xBar.style('opacity', 1)
NODES.yBar.style('opacity', 1)
}
} }
function mouseOut() { function mouseOut() {
NODES.tooltip.style('opacity', 0).style('transition', '100ms linear opacity') NODES.tooltip.style('opacity', 0).style('transition', '100ms linear opacity')
NODES.focus.style('opacity', 0) NODES.focus.style('opacity', 0)
if (!hideAxis) {
NODES.xBar.style('opacity', 0) NODES.xBar.style('opacity', 0)
NODES.yBar.style('opacity', 0)
}
} }
function mouseMove() { function mouseMove() {
@ -97,24 +91,17 @@ export default function handleMouseEvents({
renderToString( renderToString(
<Provider store={createStore({})}> <Provider store={createStore({})}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Tooltip unit={unit} renderTooltip={renderTooltip} d={d.ref} /> <Tooltip account={account} renderTooltip={renderTooltip} item={d.ref} />
</ThemeProvider> </ThemeProvider>
</Provider>, </Provider>,
), ),
) )
.style('transform', `translate3d(${MARGINS.left + x(d.parsedDate)}px, ${y(d.value)}px, 0)`) .style('transform', `translate3d(${MARGINS.left + x(d.parsedDate)}px, 0, 0)`)
if (!hideAxis) {
NODES.xBar NODES.xBar
.attr('x1', x(d.parsedDate)) .attr('x1', x(d.parsedDate))
.attr('x2', x(d.parsedDate)) .attr('x2', x(d.parsedDate))
.attr('y1', HEIGHT) .attr('y1', -30) // ensure that xbar is covered
.attr('y2', y(d.value)) .attr('y2', HEIGHT)
NODES.yBar
.attr('x1', 0)
.attr('x2', x(d.parsedDate))
.attr('y1', y(d.value))
.attr('y2', y(d.value))
}
} }
return node return node

2
src/components/base/Chart/helpers.js

@ -16,7 +16,7 @@ export function generateColors(color) {
focus: color, focus: color,
gradientStart: cColor.fade(0.7), gradientStart: cColor.fade(0.7),
gradientStop: cColor.fade(1), gradientStop: cColor.fade(1),
focusBar: cColor.fade(0.5), focusBar: '#d8d8d8',
} }
} }

15
src/components/base/Chart/index.js

@ -18,7 +18,7 @@
* *
* <Chart * <Chart
* data={data} * data={data}
* interactive // Handle mouse events, display tooltip etc. * isInteractive // Handle mouse events, display tooltip etc.
* color="#5f8ced" // Main color for line, gradient, etc. * color="#5f8ced" // Main color for line, gradient, etc.
* height={300} // Fix height. Width is responsive to container. * height={300} // Fix height. Width is responsive to container.
* /> * />
@ -37,7 +37,7 @@ import React, { PureComponent } from 'react'
import * as d3 from 'd3' import * as d3 from 'd3'
import noop from 'lodash/noop' import noop from 'lodash/noop'
import type { Unit } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import refreshNodes from './refreshNodes' import refreshNodes from './refreshNodes'
import refreshDraw from './refreshDraw' import refreshDraw from './refreshDraw'
@ -48,7 +48,7 @@ import type { Data } from './types'
export type Props = { export type Props = {
data: Data, // eslint-disable-line react/no-unused-prop-types data: Data, // eslint-disable-line react/no-unused-prop-types
unit?: Unit, // eslint-disable-line react/no-unused-prop-types account?: Account, // eslint-disable-line react/no-unused-prop-types
id?: string, // eslint-disable-line react/no-unused-prop-types id?: string, // eslint-disable-line react/no-unused-prop-types
height?: number, height?: number,
@ -56,7 +56,7 @@ export type Props = {
color?: string, // eslint-disable-line react/no-unused-prop-types color?: string, // eslint-disable-line react/no-unused-prop-types
hideAxis?: boolean, // eslint-disable-line react/no-unused-prop-types hideAxis?: boolean, // eslint-disable-line react/no-unused-prop-types
dateFormat?: string, // eslint-disable-line react/no-unused-prop-types dateFormat?: string, // eslint-disable-line react/no-unused-prop-types
interactive?: boolean, // eslint-disable-line react/no-unused-prop-types isInteractive?: boolean, // eslint-disable-line react/no-unused-prop-types
renderTooltip?: Function, // eslint-disable-line react/no-unused-prop-types renderTooltip?: Function, // eslint-disable-line react/no-unused-prop-types
} }
@ -67,9 +67,8 @@ class Chart extends PureComponent<Props> {
height: 400, height: 400,
hideAxis: false, hideAxis: false,
id: 'chart', id: 'chart',
interactive: true, isInteractive: true,
tickXScale: 'month', tickXScale: 'month',
unit: undefined,
} }
componentDidMount() { componentDidMount() {
@ -113,7 +112,7 @@ class Chart extends PureComponent<Props> {
this.refreshChart = prevProps => { this.refreshChart = prevProps => {
const { _node: node, props } = this const { _node: node, props } = this
const { data: raw, color, height, hideAxis, interactive, renderTooltip } = props const { data: raw, color, height, hideAxis, isInteractive, renderTooltip } = props
ctx.DATA = enrichData(raw) ctx.DATA = enrichData(raw)
@ -157,7 +156,7 @@ class Chart extends PureComponent<Props> {
// Mouse handler // Mouse handler
mouseHandler && mouseHandler.remove() // eslint-disable-line no-unused-expressions mouseHandler && mouseHandler.remove() // eslint-disable-line no-unused-expressions
if (interactive) { if (isInteractive) {
mouseHandler = handleMouseEvents({ mouseHandler = handleMouseEvents({
ctx, ctx,
props, props,

12
src/components/base/Chart/refreshDraw.js

@ -31,11 +31,11 @@ function getRenderTickX(selectedTime) {
export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props }) { export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props }) {
const { NODES, WIDTH, HEIGHT, MARGINS, COLORS, INVALIDATED, DATA, x, y } = ctx const { NODES, WIDTH, HEIGHT, MARGINS, COLORS, INVALIDATED, DATA, x, y } = ctx
const { hideAxis, interactive, tickXScale, unit } = props const { hideAxis, isInteractive, tickXScale, account } = props
const nbTicksX = getTickXCount(tickXScale) const nbTicksX = getTickXCount(tickXScale)
const renderTickX = getRenderTickX(tickXScale) const renderTickX = getRenderTickX(tickXScale)
const renderTickY = t => (unit ? formatShort(unit, t) : t) const renderTickY = t => (account ? formatShort(account.unit, t) : t)
const area = d3 const area = d3
.area() .area()
@ -62,12 +62,11 @@ export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props })
} }
if (INVALIDATED.color) { if (INVALIDATED.color) {
if (interactive) { if (isInteractive) {
// Update focus bar colors // Update focus bar colors
NODES.xBar.attr('stroke', COLORS.focusBar) NODES.xBar.attr('stroke', COLORS.focusBar)
NODES.yBar.attr('stroke', COLORS.focusBar)
// Update dot color // Update dot color
NODES.focus.attr('fill', COLORS.focus) NODES.focus.attr('stroke', COLORS.focus)
} }
// Update gradient color // Update gradient color
NODES.gradientStart.attr('stop-color', COLORS.gradientStart) NODES.gradientStart.attr('stop-color', COLORS.gradientStart)
@ -78,11 +77,10 @@ export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props })
} }
// Hide interactive things // Hide interactive things
if (interactive) { if (isInteractive) {
NODES.focus.style('opacity', 0) NODES.focus.style('opacity', 0)
NODES.tooltip.style('opacity', 0) NODES.tooltip.style('opacity', 0)
NODES.xBar.style('opacity', 0) NODES.xBar.style('opacity', 0)
NODES.yBar.style('opacity', 0)
} }
// Draw axis // Draw axis

23
src/components/base/Chart/refreshNodes.js

@ -10,7 +10,7 @@ const debug = d('Chart')
export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any, props: Props }) { export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any, props: Props }) {
const { NODES, COLORS } = ctx const { NODES, COLORS } = ctx
const { hideAxis, interactive, id } = props const { hideAxis, isInteractive, id } = props
// Container // Container
@ -26,20 +26,11 @@ export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any
// Focus bars // Focus bars
ensure({ onlyIf: interactive, NODES, key: 'xBar' }, () => ensure({ onlyIf: isInteractive, NODES, key: 'xBar' }, () =>
NODES.wrapper NODES.wrapper
.append('line') .append('line')
.attr('stroke', COLORS.focusBar) .attr('stroke', COLORS.focusBar)
.attr('stroke-width', '1px') .attr('stroke-width', '1px'),
.attr('stroke-dasharray', '3, 2'),
)
ensure({ onlyIf: interactive, NODES, key: 'yBar' }, () =>
NODES.wrapper
.append('line')
.attr('stroke', COLORS.focusBar)
.attr('stroke-width', '1px')
.attr('stroke-dasharray', '3, 2'),
) )
// Gradient // Gradient
@ -84,7 +75,7 @@ export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any
// Tooltip & focus point // Tooltip & focus point
ensure({ onlyIf: interactive, NODES, key: 'tooltip' }, () => ensure({ onlyIf: isInteractive, NODES, key: 'tooltip' }, () =>
d3 d3
.select(node) .select(node)
.append('div') .append('div')
@ -94,11 +85,13 @@ export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any
.style('pointer-events', 'none'), .style('pointer-events', 'none'),
) )
ensure({ onlyIf: interactive, NODES, key: 'focus' }, () => ensure({ onlyIf: isInteractive, NODES, key: 'focus' }, () =>
NODES.wrapper NODES.wrapper
.append('g') .append('g')
.append('circle') .append('circle')
.attr('fill', COLORS.focus) .attr('fill', 'white')
.attr('stroke', COLORS.focus)
.attr('stroke-width', 2)
.attr('r', 4), .attr('r', 4),
) )

28
src/components/base/Chart/stories.js

@ -9,11 +9,17 @@ import { boolean, number } from '@storybook/addon-knobs'
import { color } from '@storybook/addon-knobs/react' import { color } from '@storybook/addon-knobs/react'
import Chart from 'components/base/Chart' import Chart from 'components/base/Chart'
import Box from 'components/base/Box'
const stories = storiesOf('Components/base', module) const stories = storiesOf('Components/base', module)
const data = generateRandomData(365) const data = generateRandomData(365)
const unit = getCryptoCurrencyById('bitcoin').units[0] const currency = getCryptoCurrencyById('bitcoin')
// $FlowFixMe
const fakeAccount = {
currency,
}
type State = { type State = {
start: number, start: number,
@ -32,7 +38,14 @@ class Wrapper extends Component<any, State> {
const { start, stop } = this.state const { start, stop } = this.state
return ( return (
<Fragment> <Fragment>
<input type="range" value={start} onChange={this.handleChange('start')} min={0} max={365} /> <Box mb={8} horizontal>
<input
type="range"
value={start}
onChange={this.handleChange('start')}
min={0}
max={365}
/>
<input <input
type="range" type="range"
value={stop} value={stop}
@ -41,14 +54,15 @@ class Wrapper extends Component<any, State> {
min={0} min={0}
max={365} max={365}
/> />
</Box>
<Chart <Chart
interactive={boolean('interactive', true)} isInteractive={boolean('isInteractive', true)}
hideAxis={boolean('hideAxis', false)} hideAxis={boolean('hideAxis', true)}
color={color('color', '#5f8ced')} color={color('color', '#5f8ced')}
data={data.slice(start, stop)} data={data.slice(start, stop)}
height={number('height', 300)} height={number('height', 300)}
unit={unit} account={fakeAccount}
/> />
</Fragment> </Fragment>
) )
@ -63,9 +77,11 @@ function generateRandomData(n) {
const data = [] const data = []
const chance = new Chance() const chance = new Chance()
while (!day.isSame(today)) { while (!day.isSame(today)) {
const value = chance.integer({ min: 0.5e8, max: 1e8 })
data.push({ data.push({
date: day.toDate(), date: day.toDate(),
value: chance.integer({ min: 0.5e8, max: 1e8 }), value,
originalValue: value,
}) })
day.add(1, 'day') day.add(1, 'day')
} }

1
src/components/base/Chart/types.js

@ -3,6 +3,7 @@
export type Item = { export type Item = {
date: Date, date: Date,
value: number, value: number,
originalValue: number,
} }
type EnrichedItem = { type EnrichedItem = {

13
src/components/base/InputCurrency/index.js

@ -45,6 +45,7 @@ function stopPropagation(e) {
} }
type Props = { type Props = {
onChangeFocus: boolean => void,
onChange: (number, Unit) => void, // FIXME Unit shouldn't be provided (this is not "standard" onChange) onChange: (number, Unit) => void, // FIXME Unit shouldn't be provided (this is not "standard" onChange)
onChangeUnit: Unit => void, onChangeUnit: Unit => void,
renderRight: any, renderRight: any,
@ -61,6 +62,7 @@ type State = {
class InputCurrency extends PureComponent<Props, State> { class InputCurrency extends PureComponent<Props, State> {
static defaultProps = { static defaultProps = {
onChangeFocus: noop,
onChange: noop, onChange: noop,
renderRight: null, renderRight: null,
units: [], units: [],
@ -122,8 +124,15 @@ class InputCurrency extends PureComponent<Props, State> {
this.setState({ displayValue: v || '' }) this.setState({ displayValue: v || '' })
} }
handleBlur = () => this.syncInput({ isFocused: false }) handleBlur = () => {
handleFocus = () => this.syncInput({ isFocused: true }) this.syncInput({ isFocused: false })
this.props.onChangeFocus(false)
}
handleFocus = () => {
this.syncInput({ isFocused: true })
this.props.onChangeFocus(true)
}
syncInput = ({ isFocused }: { isFocused: boolean }) => { syncInput = ({ isFocused }: { isFocused: boolean }) => {
const { value, showAllDigits, unit } = this.props const { value, showAllDigits, unit } = this.props

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

@ -28,7 +28,6 @@ import Button from 'components/base/Button'
import Modal, { ModalContent, ModalTitle, ModalFooter, ModalBody } from 'components/base/Modal' import Modal, { ModalContent, ModalTitle, ModalFooter, ModalBody } from 'components/base/Modal'
import StepConnectDevice from 'components/modals/StepConnectDevice' import StepConnectDevice from 'components/modals/StepConnectDevice'
import { getBridgeForCurrency } from 'bridge' import { getBridgeForCurrency } from 'bridge'
import CounterValues from 'helpers/countervalues'
import StepCurrency from './01-step-currency' import StepCurrency from './01-step-currency'
import StepImport from './03-step-import' import StepImport from './03-step-import'
@ -62,7 +61,6 @@ type Props = {
closeModal: Function, closeModal: Function,
t: T, t: T,
updateAccount: Function, updateAccount: Function,
counterValuesPolling: *,
} }
type State = { type State = {
@ -173,8 +171,6 @@ class AddAccountModal extends PureComponent<Props, State> {
selectedAccounts.forEach(a => addAccount({ ...a, archived: false })) selectedAccounts.forEach(a => addAccount({ ...a, archived: false }))
this.setState({ selectedAccounts: [] }) this.setState({ selectedAccounts: [] })
closeModal(MODAL_ADD_ACCOUNT) closeModal(MODAL_ADD_ACCOUNT)
this.props.counterValuesPolling.poll()
this.props.counterValuesPolling.flush()
} }
handleNextStep = () => { handleNextStep = () => {
@ -298,17 +294,4 @@ class AddAccountModal extends PureComponent<Props, State> {
) )
} }
} }
export default compose(connect(mapStateToProps, mapDispatchToProps), translate())(AddAccountModal)
// FIXME This is kinda ugly architecture right now.
// I think we should delegate more work to individual steps
// e.g. each step is responsible to connect to redux, not at top level.
const AddAccountModalAndCounterValues = props => (
<CounterValues.PollingConsumer>
{cvPolling => <AddAccountModal counterValuesPolling={cvPolling} {...props} />}
</CounterValues.PollingConsumer>
)
export default compose(connect(mapStateToProps, mapDispatchToProps), translate())(
AddAccountModalAndCounterValues,
)

20
src/components/modals/OperationDetails.js

@ -6,9 +6,10 @@ import { shell } from 'electron'
import { translate } from 'react-i18next' import { translate } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import moment from 'moment' import moment from 'moment'
import { getOperationAmountNumber } from '@ledgerhq/live-common/lib/helpers/operation'
import type { Account, Operation } from '@ledgerhq/live-common/lib/types' import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common' import type { T, CurrencySettings } from 'types/common'
import { MODAL_OPERATION_DETAILS } from 'config/constants' import { MODAL_OPERATION_DETAILS } from 'config/constants'
@ -61,19 +62,19 @@ type Props = {
t: T, t: T,
operation: Operation, operation: Operation,
account: Account, account: Account,
type: 'from' | 'to', onClose: () => void,
onClose: Function, currencySettings: CurrencySettings,
currencySettings: *,
marketColor: string, marketColor: string,
} }
const OperationDetails = connect(mapStateToProps)((props: Props) => { const OperationDetails = connect(mapStateToProps)((props: Props) => {
const { t, type, onClose, operation, account, marketColor, currencySettings } = props const { t, onClose, operation, account, marketColor, currencySettings } = props
const { id, hash, amount, date, senders, recipients } = operation const { id, hash, date, senders, recipients, type } = operation
const amount = getOperationAmountNumber(operation)
const { name, unit, currency } = account const { name, unit, currency } = account
const confirmations = account.blockHeight - operation.blockHeight const confirmations = operation.blockHeight ? account.blockHeight - operation.blockHeight : 0
const isConfirmed = confirmations >= currencySettings.minConfirmations const isConfirmed = confirmations >= currencySettings.confirmationsNb
return ( return (
<ModalBody onClose={onClose}> <ModalBody onClose={onClose}>
<ModalTitle>Operation details</ModalTitle> <ModalTitle>Operation details</ModalTitle>
@ -81,8 +82,7 @@ const OperationDetails = connect(mapStateToProps)((props: Props) => {
<Box alignItems="center" mt={3}> <Box alignItems="center" mt={3}>
<ConfirmationCheck <ConfirmationCheck
marketColor={marketColor} marketColor={marketColor}
confirmations={confirmations} isConfirmed={isConfirmed}
minConfirmations={currencySettings.minConfirmations}
style={{ style={{
transform: 'scale(2)', transform: 'scale(2)',
}} }}

8
src/components/modals/Send/03-step-verification.js

@ -10,7 +10,7 @@ import DeviceSignTransaction from 'components/DeviceSignTransaction'
import DeviceConfirm from 'components/DeviceConfirm' import DeviceConfirm from 'components/DeviceConfirm'
import type { WalletBridge } from 'bridge/types' import type { WalletBridge } from 'bridge/types'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import type { Device, T } from 'types/common' import type { Device, T } from 'types/common'
const Container = styled(Box).attrs({ const Container = styled(Box).attrs({
@ -34,11 +34,11 @@ type Props = {
device: ?Device, device: ?Device,
bridge: ?WalletBridge<*>, bridge: ?WalletBridge<*>,
transaction: *, transaction: *,
onValidate: Function, onOperationBroadcasted: (op: Operation) => void,
t: T, t: T,
} }
export default ({ account, device, bridge, transaction, onValidate, t }: Props) => ( export default ({ account, device, bridge, transaction, onOperationBroadcasted, t }: Props) => (
<Container> <Container>
<WarnBox>{multiline(t('send:steps.verification.warning'))}</WarnBox> <WarnBox>{multiline(t('send:steps.verification.warning'))}</WarnBox>
<Info>{t('send:steps.verification.body')}</Info> <Info>{t('send:steps.verification.body')}</Info>
@ -51,7 +51,7 @@ export default ({ account, device, bridge, transaction, onValidate, t }: Props)
device={device} device={device}
transaction={transaction} transaction={transaction}
bridge={bridge} bridge={bridge}
onSuccess={onValidate} onOperationBroadcasted={onOperationBroadcasted}
render={({ error }) => ( render={({ error }) => (
// FIXME we really really REALLY should use error for the display. otherwise we are completely blind on error cases.. // FIXME we really really REALLY should use error for the display. otherwise we are completely blind on error cases..
<DeviceConfirm notValid={!!error} /> <DeviceConfirm notValid={!!error} />

17
src/components/modals/Send/04-step-confirmation.js

@ -1,6 +1,7 @@
// @flow // @flow
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import type { Operation } from '@ledgerhq/live-common/lib/types'
import IconCheckCircle from 'icons/CheckCircle' import IconCheckCircle from 'icons/CheckCircle'
import IconExclamationCircleThin from 'icons/ExclamationCircleThin' import IconExclamationCircleThin from 'icons/ExclamationCircleThin'
@ -36,15 +37,17 @@ const Text = styled(Box).attrs({
` `
type Props = { type Props = {
txValidated: ?string, optimisticOperation: ?Operation,
t: T, t: T,
} }
function StepConfirmation(props: Props) { function StepConfirmation(props: Props) {
const { t, txValidated } = props const { t, optimisticOperation } = props
const Icon = txValidated ? IconCheckCircle : IconExclamationCircleThin const Icon = optimisticOperation ? IconCheckCircle : IconExclamationCircleThin
const iconColor = txValidated ? colors.positiveGreen : colors.alertRed const iconColor = optimisticOperation ? colors.positiveGreen : colors.alertRed
const tPrefix = txValidated ? 'send:steps.confirmation.success' : 'send:steps.confirmation.error' const tPrefix = optimisticOperation
? 'send:steps.confirmation.success'
: 'send:steps.confirmation.error'
return ( return (
<Container> <Container>
@ -53,7 +56,9 @@ function StepConfirmation(props: Props) {
</span> </span>
<Title>{t(`${tPrefix}.title`)}</Title> <Title>{t(`${tPrefix}.title`)}</Title>
<Text>{multiline(t(`${tPrefix}.text`))}</Text> <Text>{multiline(t(`${tPrefix}.text`))}</Text>
<Text>{txValidated || ''}</Text> <Text style={{ userSelect: 'text' }}>
{optimisticOperation ? optimisticOperation.hash : ''}
</Text>
</Container> </Container>
) )
} }

7
src/components/modals/Send/ConfirmationFooter.js

@ -1,23 +1,24 @@
// @flow // @flow
import React from 'react' import React from 'react'
import type { Operation } from '@ledgerhq/live-common/lib/types'
import Button from 'components/base/Button' import Button from 'components/base/Button'
import { ModalFooter } from 'components/base/Modal' import { ModalFooter } from 'components/base/Modal'
import type { T } from 'types/common' import type { T } from 'types/common'
export default ({ export default ({
t, t,
txValidated, optimisticOperation,
onClose, onClose,
onGoToFirstStep, onGoToFirstStep,
}: { }: {
t: T, t: T,
txValidated: ?string, optimisticOperation: ?Operation,
onClose: () => void, onClose: () => void,
onGoToFirstStep: () => void, onGoToFirstStep: () => void,
}) => ( }) => (
<ModalFooter horizontal alignItems="center" justifyContent="flex-end" flow={2}> <ModalFooter horizontal alignItems="center" justifyContent="flex-end" flow={2}>
<Button onClick={onClose}>{t('common:close')}</Button> <Button onClick={onClose}>{t('common:close')}</Button>
{txValidated ? ( {optimisticOperation ? (
// TODO: actually go to operations details // TODO: actually go to operations details
<Button onClick={onClose} primary> <Button onClick={onClose} primary>
{t('send:steps.confirmation.success.cta')} {t('send:steps.confirmation.success.cta')}

16
src/components/modals/Send/Footer.js

@ -32,9 +32,16 @@ const mapStateToProps = createStructuredSelector({
exchange: exchangeSettingsForAccountSelector, exchange: exchangeSettingsForAccountSelector,
}) })
class Footer extends PureComponent<Props, { totalSpent: number }> { class Footer extends PureComponent<
Props,
{
totalSpent: number,
canBeSpent: boolean,
},
> {
state = { state = {
totalSpent: 0, totalSpent: 0,
canBeSpent: true,
} }
componentDidMount() { componentDidMount() {
this.resync() this.resync()
@ -54,12 +61,13 @@ class Footer extends PureComponent<Props, { totalSpent: number }> {
async resync() { async resync() {
const { account, bridge, transaction } = this.props const { account, bridge, transaction } = this.props
const totalSpent = await bridge.getTotalSpent(account, transaction) const totalSpent = await bridge.getTotalSpent(account, transaction)
const canBeSpent = await bridge.canBeSpent(account, transaction)
if (this.unmount) return if (this.unmount) return
this.setState({ totalSpent }) this.setState({ totalSpent, canBeSpent })
} }
render() { render() {
const { exchange, account, t, onNext, canNext, showTotal } = this.props const { exchange, account, t, onNext, canNext, showTotal } = this.props
const { totalSpent } = this.state const { totalSpent, canBeSpent } = this.state
return ( return (
<ModalFooter> <ModalFooter>
<Box horizontal alignItems="center" justifyContent="flex-end" flow={2}> <Box horizontal alignItems="center" justifyContent="flex-end" flow={2}>
@ -69,7 +77,7 @@ class Footer extends PureComponent<Props, { totalSpent: number }> {
<Box horizontal flow={2} align="center"> <Box horizontal flow={2} align="center">
<FormattedVal <FormattedVal
disableRounding disableRounding
color={totalSpent > account.balance ? 'pearl' : 'dark'} color={!canBeSpent ? 'pearl' : 'dark'}
val={totalSpent} val={totalSpent}
unit={account.unit} unit={account.unit}
showCode showCode

42
src/components/modals/Send/SendModalBody.js

@ -6,12 +6,13 @@ import { connect } from 'react-redux'
import { compose } from 'redux' import { compose } from 'redux'
import { createStructuredSelector } from 'reselect' import { createStructuredSelector } from 'reselect'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import type { T, Device } from 'types/common' import type { T, Device } from 'types/common'
import type { WalletBridge } from 'bridge/types' import type { WalletBridge } from 'bridge/types'
import { getBridgeForCurrency } from 'bridge' import { getBridgeForCurrency } from 'bridge'
import { getVisibleAccounts } from 'reducers/accounts' import { getVisibleAccounts } from 'reducers/accounts'
import { updateAccountWithUpdater } from 'actions/accounts'
import Breadcrumb from 'components/Breadcrumb' import Breadcrumb from 'components/Breadcrumb'
import { ModalBody, ModalTitle, ModalContent } from 'components/base/Modal' import { ModalBody, ModalTitle, ModalContent } from 'components/base/Modal'
@ -29,6 +30,7 @@ import StepConfirmation from './04-step-confirmation'
type Props = { type Props = {
initialAccount: ?Account, initialAccount: ?Account,
onClose: () => void, onClose: () => void,
updateAccountWithUpdater: (string, (Account) => Account) => void,
accounts: Account[], accounts: Account[],
t: T, t: T,
} }
@ -40,7 +42,7 @@ type State<T> = {
stepIndex: number, stepIndex: number,
appStatus: ?string, appStatus: ?string,
deviceSelected: ?Device, deviceSelected: ?Device,
txValidated: ?string, optimisticOperation: ?Operation,
} }
type Step = { type Step = {
@ -53,6 +55,10 @@ const mapStateToProps = createStructuredSelector({
accounts: getVisibleAccounts, accounts: getVisibleAccounts,
}) })
const mapDispatchToProps = {
updateAccountWithUpdater,
}
class SendModalBody extends PureComponent<Props, State<*>> { class SendModalBody extends PureComponent<Props, State<*>> {
constructor({ t, initialAccount, accounts }: Props) { constructor({ t, initialAccount, accounts }: Props) {
super() super()
@ -64,7 +70,7 @@ class SendModalBody extends PureComponent<Props, State<*>> {
txOperation: null, txOperation: null,
appStatus: null, appStatus: null,
deviceSelected: null, deviceSelected: null,
txValidated: null, optimisticOperation: null,
account, account,
bridge, bridge,
transaction, transaction,
@ -124,10 +130,17 @@ class SendModalBody extends PureComponent<Props, State<*>> {
} }
} }
onValidate = (txid: ?string) => { onOperationBroadcasted = (optimisticOperation: Operation) => {
const { stepIndex } = this.state const { stepIndex, account, bridge } = this.state
if (!account || !bridge) return
const { addPendingOperation } = bridge
if (addPendingOperation) {
this.props.updateAccountWithUpdater(account.id, account =>
addPendingOperation(account, optimisticOperation),
)
}
this.setState({ this.setState({
txValidated: txid, optimisticOperation,
stepIndex: stepIndex + 1, stepIndex: stepIndex + 1,
}) })
} }
@ -153,7 +166,14 @@ class SendModalBody extends PureComponent<Props, State<*>> {
render() { render() {
const { t, onClose } = this.props const { t, onClose } = this.props
const { stepIndex, account, transaction, bridge, txValidated, deviceSelected } = this.state const {
stepIndex,
account,
transaction,
bridge,
optimisticOperation,
deviceSelected,
} = this.state
const step = this.steps[stepIndex] const step = this.steps[stepIndex]
if (!step) return null if (!step) return null
@ -195,17 +215,17 @@ class SendModalBody extends PureComponent<Props, State<*>> {
bridge={bridge} bridge={bridge}
transaction={transaction} transaction={transaction}
device={deviceSelected} device={deviceSelected}
onValidate={this.onValidate} onOperationBroadcasted={this.onOperationBroadcasted}
/> />
<StepConfirmation t={t} txValidated={txValidated} /> <StepConfirmation t={t} optimisticOperation={optimisticOperation} />
</ChildSwitch> </ChildSwitch>
</ModalContent> </ModalContent>
{stepIndex === 3 ? ( {stepIndex === 3 ? (
<ConfirmationFooter <ConfirmationFooter
t={t} t={t}
txValidated={txValidated} optimisticOperation={optimisticOperation}
onClose={onClose} onClose={onClose}
onGoToFirstStep={this.onGoToFirstStep} onGoToFirstStep={this.onGoToFirstStep}
/> />
@ -230,4 +250,4 @@ class SendModalBody extends PureComponent<Props, State<*>> {
} }
} }
export default compose(connect(mapStateToProps), translate())(SendModalBody) export default compose(connect(mapStateToProps, mapDispatchToProps), translate())(SendModalBody)

51
src/helpers/deviceAccess.js

@ -0,0 +1,51 @@
// @flow
import createSemaphore from 'semaphore'
import type Transport from '@ledgerhq/hw-transport'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
// all open to device must use openDevice so we can prevent race conditions
// and guarantee we do one device access at a time. It also will handle the .close()
// NOTE optim: in the future we can debounce the close & reuse the same transport instance.
type WithDevice = (devicePath: string) => <T>(job: (Transport<*>) => Promise<T>) => Promise<T>
const semaphorePerDevice = {}
export const withDevice: WithDevice = devicePath => {
const { FORK_TYPE } = process.env
if (FORK_TYPE !== 'devices') {
console.warn(
`deviceAccess is only expected to be used in process 'devices'. Any other usage may lead to race conditions. (Got: '${FORK_TYPE}')`,
)
}
const sem =
semaphorePerDevice[devicePath] || (semaphorePerDevice[devicePath] = createSemaphore(1))
return job =>
takeSemaphorePromise(sem, async () => {
const t = await CommNodeHid.open(devicePath)
try {
const res = await job(t)
// $FlowFixMe
return res
} finally {
t.close()
}
})
}
function takeSemaphorePromise<T>(sem, f: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
sem.take(() => {
f().then(
r => {
sem.leave()
resolve(r)
},
e => {
sem.leave()
reject(e)
},
)
})
})
}

4
src/helpers/getAddressForCurrency/btc.js

@ -11,8 +11,8 @@ export default async (
segwit = true, segwit = true,
verify = false, verify = false,
}: { }: {
segwit: boolean, segwit?: boolean,
verify: boolean, verify?: boolean,
}, },
) => { ) => {
const btc = new Btc(transport) const btc = new Btc(transport)

5
src/helpers/getAddressForCurrency/index.js

@ -21,6 +21,11 @@ const all = {
bitcoin: btc, bitcoin: btc,
bitcoin_testnet: btc, bitcoin_testnet: btc,
zcash: btc,
bitcoin_cash: btc,
bitcoin_gold: btc,
zencash: btc,
ethereum, ethereum,
ethereum_testnet: ethereum, ethereum_testnet: ethereum,
ethereum_classic: ethereum, ethereum_classic: ethereum,

79
src/internals/accounts/scanAccountsOnDevice.js

@ -9,12 +9,10 @@
// //
import Btc from '@ledgerhq/hw-app-btc' import Btc from '@ledgerhq/hw-app-btc'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid' import { withDevice } from 'helpers/deviceAccess'
import { getCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies' import { getCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies'
import type Transport from '@ledgerhq/hw-transport' import type { AccountRaw, OperationRaw, OperationType } from '@ledgerhq/live-common/lib/types'
import type { AccountRaw } from '@ledgerhq/live-common/lib/types'
import type { NJSAccount, NJSOperation } from '@ledgerhq/ledger-core/src/ledgercore_doc' import type { NJSAccount, NJSOperation } from '@ledgerhq/ledger-core/src/ledgercore_doc'
type Props = { type Props = {
@ -23,11 +21,10 @@ type Props = {
onAccountScanned: Function, onAccountScanned: Function,
} }
export default async function scanAccountsOnDevice(props: Props): Promise<AccountRaw[]> { export default function scanAccountsOnDevice(props: Props): Promise<AccountRaw[]> {
const { devicePath, currencyId, onAccountScanned } = props const { devicePath, currencyId, onAccountScanned } = props
// instanciate app on device return withDevice(devicePath)(async transport => {
const transport: Transport<*> = await CommNodeHid.open(devicePath)
const hwApp = new Btc(transport) const hwApp = new Btc(transport)
const commonParams = { const commonParams = {
@ -38,12 +35,16 @@ export default async function scanAccountsOnDevice(props: Props): Promise<Accoun
} }
// scan segwit AND non-segwit accounts // scan segwit AND non-segwit accounts
const nonSegwitAccounts = await scanAccountsOnDeviceBySegwit({
...commonParams,
isSegwit: false,
})
const segwitAccounts = await scanAccountsOnDeviceBySegwit({ ...commonParams, isSegwit: true }) const segwitAccounts = await scanAccountsOnDeviceBySegwit({ ...commonParams, isSegwit: true })
const nonSegwitAccounts = await scanAccountsOnDeviceBySegwit({ ...commonParams, isSegwit: false })
const accounts = [...segwitAccounts, ...nonSegwitAccounts] const accounts = [...nonSegwitAccounts, ...segwitAccounts]
return accounts return accounts
})
} }
export async function getWalletIdentifier({ export async function getWalletIdentifier({
@ -153,16 +154,18 @@ async function scanNextAccount(props: {
ops, ops,
}) })
// returns if the current index points on an account with no ops const isEmpty = ops.length === 0
if (ops.length === 0) {
return accounts
}
// trigger event // trigger event
onAccountScanned(account) onAccountScanned(account)
accounts.push(account) accounts.push(account)
// returns if the current index points on an account with no ops
if (isEmpty) {
return accounts
}
return scanNextAccount({ ...props, accountIndex: accountIndex + 1 }) return scanNextAccount({ ...props, accountIndex: accountIndex + 1 })
} }
@ -211,6 +214,7 @@ async function buildAccountRaw({
// $FlowFixMe // $FlowFixMe
ops: NJSOperation[], ops: NJSOperation[],
}): Promise<AccountRaw> { }): Promise<AccountRaw> {
/*
const balanceByDay = ops.length const balanceByDay = ops.length
? await getBalanceByDaySinceOperation({ ? await getBalanceByDaySinceOperation({
njsAccount, njsAccount,
@ -218,6 +222,7 @@ async function buildAccountRaw({
core, core,
}) })
: {} : {}
*/
const njsBalance = await njsAccount.getBalance() const njsBalance = await njsAccount.getBalance()
const balance = njsBalance.toLong() const balance = njsBalance.toLong()
@ -244,23 +249,30 @@ async function buildAccountRaw({
path: `${accountPath}/${njsAddress.getDerivationPath()}`, path: `${accountPath}/${njsAddress.getDerivationPath()}`,
})) }))
if (addresses.length === 0) {
throw new Error('no addresses found')
}
const { str: freshAddress, path: freshAddressPath } = addresses[0]
const operations = ops.map(op => buildOperationRaw({ core, op, xpub })) const operations = ops.map(op => buildOperationRaw({ core, op, xpub }))
const rawAccount: AccountRaw = { const rawAccount: AccountRaw = {
id: xpub, id: xpub, // FIXME for account id you might want to prepend the crypto currency id to this because it's not gonna be unique.
xpub, xpub,
path: accountPath, path: walletPath,
walletPath, name: `${operations.length === 0 ? 'New ' : ''}Account ${accountIndex}${
name: `Account ${accountIndex}${isSegwit ? ' (segwit)' : ''}`, // TODO: placeholder name? isSegwit ? ' (segwit)' : ''
}`, // TODO: placeholder name?
isSegwit, isSegwit,
address: bitcoinAddress, freshAddress,
addresses, freshAddressPath,
balance, balance,
blockHeight, blockHeight,
archived: false, archived: false,
index: accountIndex, index: accountIndex,
balanceByDay,
operations, operations,
pendingOperations: [],
currencyId, currencyId,
unitMagnitude: jsCurrency.units[0].magnitude, unitMagnitude: jsCurrency.units[0].magnitude,
lastSyncDate: new Date().toISOString(), lastSyncDate: new Date().toISOString(),
@ -269,31 +281,45 @@ async function buildAccountRaw({
return rawAccount return rawAccount
} }
function buildOperationRaw({ core, op, xpub }: { core: Object, op: NJSOperation, xpub: string }) { function buildOperationRaw({
core,
op,
xpub,
}: {
core: Object,
op: NJSOperation,
xpub: string,
}): OperationRaw {
const id = op.getUid() const id = op.getUid()
const bitcoinLikeOperation = op.asBitcoinLikeOperation() const bitcoinLikeOperation = op.asBitcoinLikeOperation()
const bitcoinLikeTransaction = bitcoinLikeOperation.getTransaction() const bitcoinLikeTransaction = bitcoinLikeOperation.getTransaction()
const hash = bitcoinLikeTransaction.getHash() const hash = bitcoinLikeTransaction.getHash()
const operationType = op.getOperationType() const operationType = op.getOperationType()
const absoluteAmount = op.getAmount().toLong() const value = op.getAmount().toLong()
const OperationTypeMap: { [_: $Keys<typeof core.OPERATION_TYPES>]: OperationType } = {
[core.OPERATION_TYPES.SEND]: 'OUT',
[core.OPERATION_TYPES.RECEIVE]: 'IN',
}
// if transaction is a send, amount becomes negative // if transaction is a send, amount becomes negative
const amount = operationType === core.OPERATION_TYPES.SEND ? -absoluteAmount : absoluteAmount const type = OperationTypeMap[operationType]
return { return {
id, id,
hash, hash,
address: '', type,
value,
senders: op.getSenders(), senders: op.getSenders(),
recipients: op.getRecipients(), recipients: op.getRecipients(),
blockHeight: op.getBlockHeight(), blockHeight: op.getBlockHeight(),
blockHash: '', blockHash: null,
accountId: xpub, accountId: xpub,
date: op.getDate().toISOString(), date: op.getDate().toISOString(),
amount,
} }
} }
/*
async function getBalanceByDaySinceOperation({ async function getBalanceByDaySinceOperation({
njsAccount, njsAccount,
njsOperation, njsOperation,
@ -336,3 +362,4 @@ function areSameDay(date1: Date, date2: Date): boolean {
date1.getDate() === date2.getDate() date1.getDate() === date2.getDate()
) )
} }
*/

12
src/internals/accounts/signAndBroadcastTransaction/btc.js

@ -1,11 +1,9 @@
// @flow // @flow
import Btc from '@ledgerhq/hw-app-btc' import Btc from '@ledgerhq/hw-app-btc'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid' import { withDevice } from 'helpers/deviceAccess'
import type { AccountRaw } from '@ledgerhq/live-common/lib/types' import type { AccountRaw } from '@ledgerhq/live-common/lib/types'
import type Transport from '@ledgerhq/hw-transport'
import type { IPCSend } from 'types/electron' import type { IPCSend } from 'types/electron'
import { getWalletIdentifier } from '../scanAccountsOnDevice' import { getWalletIdentifier } from '../scanAccountsOnDevice'
@ -31,13 +29,12 @@ export default async function signAndBroadcastTransactionBTCLike(
// TODO: investigate why importing it on file scope causes trouble // TODO: investigate why importing it on file scope causes trouble
const core = require('init-ledger-core')() const core = require('init-ledger-core')()
// instanciate app on device const txHash = await withDevice(deviceId)(async transport => {
const transport: Transport<*> = await CommNodeHid.open(deviceId)
const hwApp = new Btc(transport) const hwApp = new Btc(transport)
const WALLET_IDENTIFIER = await getWalletIdentifier({ const WALLET_IDENTIFIER = await getWalletIdentifier({
hwApp, hwApp,
isSegwit: account.isSegwit, isSegwit: !!account.isSegwit,
currencyId: account.currencyId, currencyId: account.currencyId,
devicePath: deviceId, devicePath: deviceId,
}) })
@ -64,6 +61,9 @@ export default async function signAndBroadcastTransactionBTCLike(
.asBitcoinLikeAccount() .asBitcoinLikeAccount()
.broadcastRawTransaction(signedTransaction) .broadcastRawTransaction(signedTransaction)
return txHash
})
send('accounts.signAndBroadcastTransactionBTCLike.success', txHash) send('accounts.signAndBroadcastTransactionBTCLike.success', txHash)
} catch (err) { } catch (err) {
send('accounts.signAndBroadcastTransactionBTCLike.fail', err) send('accounts.signAndBroadcastTransactionBTCLike.fail', err)

7
src/internals/manager/helpers.js

@ -1,6 +1,6 @@
// @flow // @flow
import CommNodeHid from '@ledgerhq/hw-transport-node-hid' import { withDevice } from 'helpers/deviceAccess'
import chalk from 'chalk' import chalk from 'chalk'
import Websocket from 'ws' import Websocket from 'ws'
import qs from 'qs' import qs from 'qs'
@ -44,6 +44,7 @@ export function createTransportHandler(
errorResponse: string, errorResponse: string,
}, },
) { ) {
console.log('DEPRECATED: createTransportHandler use withDevice and commands/*')
return async function transportHandler({ return async function transportHandler({
devicePath, devicePath,
...params ...params
@ -51,9 +52,7 @@ export function createTransportHandler(
devicePath: string, devicePath: string,
}): Promise<void> { }): Promise<void> {
try { try {
const transport: Transport<*> = await CommNodeHid.open(devicePath) const data = await withDevice(devicePath)(transport => action(transport, params))
// $FlowFixMe
const data = await action(transport, params)
send(successResponse, data) send(successResponse, data)
} catch (err) { } catch (err) {
if (!err) { if (!err) {

4
static/i18n/en/operationsList.yml

@ -2,8 +2,8 @@ date: Date
account: Account account: Account
address: Address address: Address
amount: Amount amount: Amount
from: Receive funds IN: Receive funds
to: Sent funds OUT: Sent funds
showMore: Show more showMore: Show more
confirmed: Confirmed confirmed: Confirmed
notConfirmed: Not confirmed notConfirmed: Not confirmed

2
static/i18n/fr/common.yml

@ -1,5 +1,7 @@
--- ---
ok: Ok ok: Ok
yes: true
no: false
confirm: Confirmer confirm: Confirmer
cancel: Annuler cancel: Annuler
continue: Continue continue: Continue

44
static/i18n/fr/onboarding.yml

@ -26,25 +26,49 @@ selectDevice:
title: Ledger Blue title: Ledger Blue
desc: Please replace it with the final wording once it’s done. desc: Please replace it with the final wording once it’s done.
selectPIN: selectPIN:
title: Select PIN code title: Choose your PIN code
desc: This is a long text, please replace it with the final wording once it’s done. Lorem ipsum dolor amet ledger lorem dolor ipsum amet desc: This is a long text, please replace it with the final wording once it’s done. Lorem ipsum dolor amet ledger lorem dolor ipsum amet
instructions: instructions:
step1: Connect your Ledger Nano S to your computer using the supplied micro USB cable. step1: Connect the Ledger Nano S to your computer.
step2: Press both buttons simultaneously as instructed on your Ledger Nano S screen. step2: Press both buttons simultaneously as instructed on the screen.
step3: Select Configure as new device on your Ledger Nano S by pressing the right button, located above the validation icon. step3: Press the right button to select Configure as new device.
step4: Choose a PIN code between 4 and 8 digits long. step4: Choose a PIN code between 4 and 8 digits long.
disclaimer:
note1: Choose your own PIN code. This code unlocks your device.
note2: An 8-digit PIN code offers an optimum level of security.
note3: Never use a device supplied with a PIN code and/or a 24-word recovery phrase.
writeSeed: writeSeed:
title: 24-Word Recovery phrase title: Save your recovery phrase
desc: The 24 words that constitute your recovery phrase will now be displayed one by one on the Ledger Nano S screen. These 24 words will be displayed only once during this initialization. desc: Your recovery phrase is formed by 24 words. They will be displayed only once.
instructions: instructions:
step1: Copy the first word (Word \ step1: Copy the first word (Word \
step2: Move to Word \ step2: Press the right button to display Word \
step3: Repeat the process until all 24 words are copied on the Recovery sheet. step3: Confirm your recovery phrase press both buttons to validate each word displayed on the screen.
step4: To confirm, use the right or left button to select each of the 24 words in the right order. disclaimer:
step5: Validate each word by simultaneously pressing both buttons. note1: Carefully secure your 24 words out of sight.
note2: Ledger does not keep any backup of your 24 words.
note3: Make sure you are the sole holder of the 24-word recovery phrase.
note4: Never use a device supplied with a recovery phrase and/or a PIN code.
genuineCheck: genuineCheck:
title: Check PIN / Seed / Authenticity title: Check PIN / Seed / Authenticity
desc: Your Ledger Nano S should now display Your device is now ready. Before getting started, please confirm that desc: Your Ledger Nano S should now display Your device is now ready. Before getting started, please confirm that
steps:
step1:
title: You alone have chosen your PIN code
desc: This is a long text, please replace it with the final wording once it’s done. Lorem ipsum dolor amet ledger lorem dolor
step2:
title: You alone have initialized your recovery phrase
desc: This is a long text, please replace it with the final wording once it’s done. Lorem ipsum dolor amet ledger lorem dolor
step3:
title: Your device is a genuine Ledger device
desc: This is a long text, please replace it with the final wording once it’s done. Lorem ipsum dolor amet ledger lorem dolor
buttons:
genuineCheck: Genuine check
contactSupport: Contact Support
errorPage:
ledgerNano:
title: Something is wrong with your Ledger Nano S
desc: Your Ledger Nano S should now display Your device is now ready. Before getting started, please confirm that
setPassword: setPassword:
title: This is the title of the screen. 1 line is the maximum title: This is the title of the screen. 1 line is the maximum
desc: This is a long text, please replace it with the final wording once it’s done. Lorem ipsum dolor amet ledger lorem dolor ipsum amet desc: This is a long text, please replace it with the final wording once it’s done. Lorem ipsum dolor amet ledger lorem dolor ipsum amet

4
static/i18n/fr/operationsList.yml

@ -3,8 +3,8 @@ date: Date
account: Compte account: Compte
address: Adresse address: Adresse
amount: Montant amount: Montant
from: Fonds reçus IN: Fonds reçus
to: Fonds envoyés OUT: Fonds envoyés
showMore: Voir plus showMore: Voir plus
confirmed: Confirmée confirmed: Confirmée
notConfirmed: Non confirmée notConfirmed: Non confirmée

1
static/i18n/fr/send.yml

@ -12,6 +12,7 @@ steps:
advancedOptions: Options avancées advancedOptions: Options avancées
useRBF: Utiliser la transaction RBF useRBF: Utiliser la transaction RBF
message: Laisser un message (140) message: Laisser un message (140)
rippleTag: Tag
connectDevice: connectDevice:
title: Connecter l'appareil title: Connecter l'appareil
verification: verification:

1323
yarn.lock

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