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

18
src/api/Ethereum.js

@ -1,7 +1,7 @@
// @flow
import axios from 'axios'
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 Tx = {
@ -34,7 +34,7 @@ export type API = {
txs: Tx[],
}>,
getCurrentBlock: () => Promise<Block>,
getAccountNonce: (address: string) => Promise<string>,
getAccountNonce: (address: string) => Promise<number>,
broadcastTransaction: (signedTransaction: string) => Promise<string>,
getAccountBalance: (address: string) => Promise<number>,
}
@ -44,25 +44,27 @@ export const apiForCurrency = (currency: CryptoCurrency): API => {
return {
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 },
})
}),
)
return data
},
async getCurrentBlock() {
const { data } = await axios.get(`${baseURL}/blocks/current`)
const { data } = await userFriendlyError(axios.get(`${baseURL}/blocks/current`))
return data
},
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
},
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
},
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
},
}

4
src/api/Fees.js

@ -1,14 +1,14 @@
// @flow
import axios from 'axios'
import type { Currency } from '@ledgerhq/live-common/lib/types'
import { blockchainBaseURL } from './Ledger'
import { blockchainBaseURL, userFriendlyError } from './Ledger'
export type Fees = {
[_: string]: number,
}
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) {
return data
}

34
src/api/Ledger.js

@ -14,5 +14,39 @@ export const currencyToFeeTicker = (currency: Currency) => {
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) =>
`${BASE_URL}blockchain/v2/${currencyToFeeTicker(currency)}`

174
src/bridge/EthereumJSBridge.js

@ -2,6 +2,8 @@
import React from 'react'
import EthereumKind from 'components/FeesField/EthereumKind'
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 { apiForCurrency } 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 => {
const sending = account.address.toLowerCase() === tx.from.toLowerCase()
return {
id: tx.hash,
// in case of a SELF send, 2 ops are returned.
const txToOps = (account: Account) => (tx: Tx): Operation[] => {
const freshAddress = account.freshAddress.toLowerCase()
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,
address: sending ? tx.to : tx.from,
amount: (sending ? -1 : 1) * tx.value,
blockHeight: (tx.block && tx.block.height) || 0, // FIXME will be optional field
blockHash: (tx.block && tx.block.hash) || '', // FIXME will be optional field
type: 'IN',
value: tx.value,
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),
})
}
return ops
}
function isRecipientValid(currency, recipient) {
@ -45,26 +70,13 @@ function isRecipientValid(currency, recipient) {
}
function mergeOps(existing: Operation[], newFetched: Operation[]) {
const ids = existing.map(o => o.id)
const all = existing.concat(newFetched.filter(o => !ids.includes(o.id)))
return all.sort((a, b) => a.date - b.date)
}
const paginateMoreTransactions = async (
account: Account,
acc: Operation[],
): Promise<Operation[]> => {
const api = apiForCurrency(account.currency)
const { txs } = await api.getTransactions(
account.address,
acc.length ? acc[acc.length - 1].blockHash : undefined,
)
if (txs.length === 0) return acc
return mergeOps(acc, txs.map(toAccountOperation(account)))
const ids = newFetched.map(o => o.id)
const all = newFetched.concat(existing.filter(o => !ids.includes(o.id)))
return uniqBy(all.sort((a, b) => a.date - b.date), 'id')
}
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 f = throttle(
() =>
@ -75,7 +87,7 @@ const fetchCurrentBlock = (perCurrencyId => currency => {
5000,
)
perCurrencyId[currency.id] = f
return f
return f()
})({})
const EthereumBridge: WalletBridge<Transaction> = {
@ -93,38 +105,39 @@ const EthereumBridge: WalletBridge<Transaction> = {
async function stepAddress(
index,
{ address, path },
{ address, path: freshAddressPath },
isStandard,
): { account?: Account, complete?: boolean } {
const balance = await api.getAccountBalance(address)
if (finished) return { complete: true }
const currentBlock = await fetchCurrentBlock(currency)
if (finished) return { complete: true }
const { txs } = await api.getTransactions(address)
let { txs } = await api.getTransactions(address)
if (finished) return { complete: true }
const path = freshAddressPath // FIXME
const freshAddress = address
if (txs.length === 0) {
// this is an empty account
if (isStandard) {
if (newAccountCount === 0) {
// 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 account: Account = {
const account: $Exact<Account> = {
id: accountId,
xpub: '',
path, // FIXME we probably not want the address path in the account.path
walletPath: String(index),
freshAddress,
freshAddressPath,
name: 'New Account',
isSegwit: false,
address,
addresses: [{ str: address, path }],
balance,
blockHeight: currentBlock.height,
archived: true,
index,
currency,
operations: [],
pendingOperations: [],
unit: currency.units[0],
lastSyncDate: new Date(),
}
@ -137,25 +150,34 @@ const EthereumBridge: WalletBridge<Transaction> = {
}
const accountId = `${currency.id}_${address}`
const account: Account = {
const account: $Exact<Account> = {
id: accountId,
xpub: '',
path, // FIXME we probably not want the address path in the account.path
walletPath: String(index),
freshAddress,
freshAddressPath,
name: address.slice(32),
isSegwit: false,
address,
addresses: [{ str: address, path }],
balance,
blockHeight: currentBlock.height,
archived: true,
index,
currency,
operations: [],
pendingOperations: [],
unit: currency.units[0],
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 }
}
@ -166,9 +188,9 @@ const EthereumBridge: WalletBridge<Transaction> = {
for (const derivation of derivations) {
const isStandard = last === derivation
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
.send({ currencyId: currency.id, devicePath: deviceId, path })
.send({ currencyId: currency.id, devicePath: deviceId, path: freshAddressPath })
.toPromise()
const r = await stepAddress(index, res, isStandard)
if (r.account) next(r.account)
@ -188,7 +210,7 @@ const EthereumBridge: WalletBridge<Transaction> = {
return { unsubscribe }
},
synchronize({ address, blockHeight, currency }, { next, complete, error }) {
synchronize({ freshAddress, blockHeight, currency, operations }, { next, complete, error }) {
let unsubscribed = false
const api = apiForCurrency(currency)
async function main() {
@ -198,26 +220,30 @@ const EthereumBridge: WalletBridge<Transaction> = {
if (block.height === blockHeight) {
complete()
} 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
const { txs } = await api.getTransactions(address)
const nonce = await api.getAccountNonce(freshAddress)
if (unsubscribed) return
next(a => {
const currentOps = a.operations
const newOps = txs.map(toAccountOperation(a))
const { length: newLength } = newOps
const { length } = currentOps
if (
// still empty
(length === 0 && newLength === 0) ||
// latest is still same
(length > 0 && newLength > 0 && currentOps[0].id === newOps[0].id)
) {
return a
}
const newOps = flatMap(txs, txToOps(a))
const operations = mergeOps(currentOps, newOps)
const pendingOperations = a.pendingOperations.filter(
o =>
o.transactionSequenceNumber &&
o.transactionSequenceNumber >= nonce &&
!operations.some(op => o.hash === op.hash),
)
return {
...a,
pendingOperations,
operations,
balance,
blockHeight: block.height,
@ -231,6 +257,7 @@ const EthereumBridge: WalletBridge<Transaction> = {
}
}
main()
return {
unsubscribe() {
unsubscribed = true
@ -238,10 +265,7 @@ const EthereumBridge: WalletBridge<Transaction> = {
}
},
pullMoreOperations: async account => {
const operations = await paginateMoreTransactions(account, account.operations)
return a => ({ ...a, operations })
},
pullMoreOperations: () => Promise.resolve(a => a), // NOT IMPLEMENTED
isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)),
@ -270,14 +294,15 @@ const EthereumBridge: WalletBridge<Transaction> = {
// $FlowFixMe
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),
getMaxAmount: (a, t) => Promise.resolve(a.balance - t.gasPrice),
signAndBroadcast: async (a, t, deviceId) => {
const api = apiForCurrency(a.currency)
const nonce = await api.getAccountNonce(a.address)
const nonce = await api.getAccountNonce(a.freshAddress)
const transaction = await signTransactionCommand
.send({
@ -288,10 +313,31 @@ const EthereumBridge: WalletBridge<Transaction> = {
})
.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

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) {
case 'account.sync.progress': {
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
}
case 'account.sync.fail': {
@ -150,9 +159,11 @@ const LibcoreBridge: WalletBridge<Transaction> = {
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) => {
const rawAccount = encodeAccount(account)

131
src/bridge/RippleJSBridge.js

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

2
src/bridge/UnsupportedBridge.js

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

16
src/bridge/makeMockBridge.js

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

16
src/bridge/types.js

@ -1,6 +1,6 @@
// @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.
// 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
EditAdvancedOptions?: React$ComponentType<EditProps<Transaction>>;
canBeSpent(account: Account, transaction: Transaction): Promise<boolean>;
getTotalSpent(account: Account, transaction: Transaction): Promise<number>;
// 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
* - signing it with the ledger device
* - 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...
* 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 { fromPromise } from 'rxjs/observable/fromPromise'
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
import { withDevice } from 'helpers/deviceAccess'
import getAddressForCurrency from 'helpers/getAddressForCurrency'
type Input = {
@ -24,7 +24,7 @@ const cmd: Command<Input, Result> = createCommand(
'getAddress',
({ currencyId, devicePath, path, ...options }) =>
fromPromise(
CommNodeHid.open(devicePath).then(transport =>
withDevice(devicePath)(transport =>
getAddressForCurrency(currencyId)(transport, currencyId, path, options),
),
),

4
src/commands/signTransaction.js

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

22
src/components/BalanceSummary/index.js

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

22
src/components/CalculateBalance.js

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

4
src/components/CounterValue/index.js

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

2
src/components/CurrentAddress/stories.js

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

9
src/components/CurrentAddressForAccount.js

@ -11,12 +11,5 @@ type Props = {
export default function CurrentAddressForAccount(props: Props) {
const { account, ...p } = props
// 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} />
return <CurrentAddress accountName={account.name} address={account.freshAddress} {...p} />
}

4
src/components/DashboardPage/AccountCard.js

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

20
src/components/DevTools.js

@ -254,26 +254,8 @@ class DevTools extends PureComponent<any, State> {
color="#8884d8"
height={50}
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>
))}

12
src/components/DeviceCheckAddress.js

@ -42,23 +42,17 @@ class CheckAddress extends PureComponent<Props, State> {
verifyAddress = async ({ device, account }: { device: Device, account: Account }) => {
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
.send({
currencyId: account.currency.id,
devicePath: device.path,
path: freshAddress.path,
segwit: account.isSegwit,
path: account.freshAddressPath,
segwit: !!account.isSegwit,
verify: true,
})
.toPromise()
if (address !== freshAddress.str) {
if (address !== account.freshAddress) {
throw new Error('Confirmed address is different')
}

10
src/components/DeviceSignTransaction.js

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

8
src/components/EnsureDeviceApp/index.js

@ -110,9 +110,9 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
appOptions = {
devicePath: deviceSelected.path,
currencyId: account.currency.id,
path: account.path,
accountAddress: account.address,
segwit: account.path.startsWith("49'"), // TODO: store segwit info in account
path: account.freshAddressPath,
accountAddress: account.freshAddress,
segwit: !!account.isSegwit,
}
} else if (currency) {
appOptions = {
@ -125,7 +125,7 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
try {
if (appOptions) {
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')
}
} else {

12
src/components/FeesField/EthereumKind.js

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

20
src/components/OperationsList/ConfirmationCheck.js

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

50
src/components/OperationsList/Operation.js

@ -7,10 +7,11 @@ import { createStructuredSelector } from 'reselect'
import moment from 'moment'
import noop from 'lodash/noop'
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 { rgba, getMarketColor } from 'styles/helpers'
@ -32,7 +33,7 @@ const ACCOUNT_COL_SIZE = 150
const AMOUNT_COL_SIZE = 150
const CONFIRMATION_COL_SIZE = 44
const OperationRaw = styled(Box).attrs({
const OperationRow = styled(Box).attrs({
horizontal: true,
alignItems: 'center',
})`
@ -102,16 +103,16 @@ const Cell = styled(Box).attrs({
type Props = {
account: Account,
currencySettings: *,
onAccountClick: Function,
onOperationClick: Function,
currencySettings: CurrencySettings,
onAccountClick: (account: Account) => void,
onOperationClick: ({ operation: Operation, account: Account, marketColor: string }) => void,
marketIndicator: string,
t: T,
op: OperationType,
op: Operation, // FIXME rename it operation
withAccount: boolean,
}
class Operation extends PureComponent<Props> {
class OperationComponent extends PureComponent<Props> {
static defaultProps = {
onAccountClick: noop,
onOperationClick: noop,
@ -132,21 +133,28 @@ class Operation extends PureComponent<Props> {
const { unit, currency } = account
const time = moment(op.date)
const Icon = getCryptoCurrencyIcon(account.currency)
const isNegative = op.amount < 0
const type = !isNegative ? 'from' : 'to'
const amount = getOperationAmountNumber(op)
const isNegative = amount < 0
const isOptimistic = op.blockHeight === null
const isConfirmed =
(op.blockHeight ? account.blockHeight - op.blockHeight : 0) > currencySettings.confirmationsNb
const marketColor = getMarketColor({
marketIndicator,
isNegative,
})
// FIXME each cell in a component
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">
<ConfirmationCheck
type={type}
minConfirmations={currencySettings.minConfirmations}
confirmations={account.blockHeight - op.blockHeight}
type={op.type}
isConfirmed={isConfirmed}
marketColor={marketColor}
t={t}
/>
@ -154,7 +162,7 @@ class Operation extends PureComponent<Props> {
<Cell size={DATE_COL_SIZE} justifyContent="space-between" px={3}>
<Box>
<Box ff="Open Sans|SemiBold" fontSize={3} color="smoke">
{t(`operationsList:${type}`)}
{t(`operationsList:${op.type}`)}
</Box>
<Hour>{time.format('HH:mm')}</Hour>
</Box>
@ -185,31 +193,31 @@ class Operation extends PureComponent<Props> {
</Cell>
)}
<Cell grow shrink style={{ display: 'block' }}>
<Address value={op.address} />
<Address value={op.type === 'IN' ? op.senders[0] : op.recipients[0]} />
</Cell>
<Cell size={AMOUNT_COL_SIZE} justify="flex-end">
<Box alignItems="flex-end">
<FormattedVal
val={op.amount}
val={amount}
unit={unit}
showCode
fontSize={4}
alwaysShowSign
color={op.amount < 0 ? 'smoke' : undefined}
color={amount < 0 ? 'smoke' : undefined}
/>
<CounterValue
color="grey"
fontSize={3}
date={time.toDate()}
currency={currency}
value={op.amount}
value={amount}
exchange={currencySettings.exchange}
/>
</Box>
</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 { currenciesSelector } from 'reducers/accounts'
import CounterValues from 'helpers/countervalues'
import SelectCurrency from 'components/SelectCurrency'
import StepperNumber from 'components/base/StepperNumber'
@ -138,21 +137,13 @@ class TabCurrencies extends PureComponent<Props, State> {
/>
<Body>
<Row title="Exchange" desc="The exchange to use">
<CounterValues.PollingConsumer>
{polling => (
// TODO move to a dedicated "row" component
<ExchangeSelect
from={currency}
to={counterValueCurrency}
exchangeId={exchange}
onChange={exchange => {
this.handleChangeExchange(exchange)
polling.poll()
}}
onChange={this.handleChangeExchange}
style={{ minWidth: 200 }}
/>
)}
</CounterValues.PollingConsumer>
</Row>
<Row
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 IconDisplay from 'icons/Display'
import languageKeys from 'config/languages'
import CounterValues from 'helpers/countervalues'
import regionsByKey from 'helpers/regions.json'
@ -135,22 +134,15 @@ class TabProfile extends PureComponent<Props, State> {
title={t('settings:display.counterValue')}
desc={t('settings:display.counterValueDesc')}
>
<CounterValues.PollingConsumer>
{polling => (
<Select
style={{ minWidth: 250 }}
small
onChange={item => {
this.handleChangeCounterValue(item)
polling.poll()
}}
onChange={this.handleChangeCounterValue}
itemToString={item => (item ? item.name : '')}
renderSelected={item => item && item.name}
items={fiats}
value={cachedCounterValue}
/>
)}
</CounterValues.PollingConsumer>
</Row>
<Row title={t('settings:display.language')} desc={t('settings:display.languageDesc')}>
<Select

6
src/components/TopBar/ItemContainer.js

@ -8,13 +8,13 @@ export default styled(Box).attrs({
px: 2,
ml: 0,
justifyContent: 'center',
cursor: p => (p.interactive ? 'pointer' : 'default'),
cursor: p => (p.isInteractive ? 'pointer' : 'default'),
})`
opacity: 0.7;
&:hover {
opacity: ${p => (p.interactive ? 0.85 : 0.7)};
opacity: ${p => (p.isInteractive ? 0.85 : 0.7)};
}
&: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">
<Bar />
</Box>
<ItemContainer interactive onClick={this.navigateToSettings}>
<ItemContainer isInteractive onClick={this.navigateToSettings}>
<IconSettings size={16} />
</ItemContainer>
{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">
<Bar />
</Box>
<ItemContainer interactive justifyContent="center" onClick={this.handleLock}>
<ItemContainer isInteractive justifyContent="center" onClick={this.handleLock}>
<IconLock size={16} />
</ItemContainer>
</Fragment>

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

@ -1,47 +1,35 @@
// @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 Box from 'components/base/Box'
import type { Item } from './types'
/**
* we use inline style for more perfs, as tooltip may re-render numerous times
*/
const Arrow = () => (
<svg
style={{
display: 'block',
position: 'absolute',
left: '50%',
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 Container = styled(Box).attrs({
px: 4,
py: 3,
align: 'center',
})`
background: white;
border: 1px solid #d8d8d8;
border-radius: 4px;
width: 150px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.03);
`
const Tooltip = ({
d,
item,
renderTooltip,
fiat,
unit,
account,
}: {
d: Item,
item: Item,
renderTooltip?: Function,
fiat?: string,
unit?: Unit,
account?: Account,
}) => (
<div style={{ position: 'relative' }}>
<div
@ -51,32 +39,40 @@ const Tooltip = ({
left: 0,
transform: `translate3d(-50%, 0, 0)`,
whiteSpace: 'nowrap',
marginBottom: 5,
marginBottom: -5,
}}
>
<TooltipContainer style={{ textAlign: 'center' }}>
<Container style={{ textAlign: 'center' }}>
{renderTooltip ? (
renderTooltip(d)
renderTooltip(item)
) : (
<Fragment>
<FormattedVal
color="dark"
fontSize={5}
alwaysShowSign={false}
color="white"
showCode
fiat={fiat}
unit={unit}
val={d.value}
fiat="USD"
val={item.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>
<Arrow />
</Container>
</div>
</div>
)
Tooltip.defaultProps = {
renderTooltip: undefined,
fiat: undefined,
unit: undefined,
}
export default Tooltip

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

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

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

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

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

@ -18,7 +18,7 @@
*
* <Chart
* data={data}
* interactive // Handle mouse events, display tooltip etc.
* isInteractive // Handle mouse events, display tooltip etc.
* color="#5f8ced" // Main color for line, gradient, etc.
* height={300} // Fix height. Width is responsive to container.
* />
@ -37,7 +37,7 @@ import React, { PureComponent } from 'react'
import * as d3 from 'd3'
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 refreshDraw from './refreshDraw'
@ -48,7 +48,7 @@ import type { Data } from './types'
export type Props = {
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
height?: number,
@ -56,7 +56,7 @@ export type Props = {
color?: string, // 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
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
}
@ -67,9 +67,8 @@ class Chart extends PureComponent<Props> {
height: 400,
hideAxis: false,
id: 'chart',
interactive: true,
isInteractive: true,
tickXScale: 'month',
unit: undefined,
}
componentDidMount() {
@ -113,7 +112,7 @@ class Chart extends PureComponent<Props> {
this.refreshChart = prevProps => {
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)
@ -157,7 +156,7 @@ class Chart extends PureComponent<Props> {
// Mouse handler
mouseHandler && mouseHandler.remove() // eslint-disable-line no-unused-expressions
if (interactive) {
if (isInteractive) {
mouseHandler = handleMouseEvents({
ctx,
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 }) {
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 renderTickX = getRenderTickX(tickXScale)
const renderTickY = t => (unit ? formatShort(unit, t) : t)
const renderTickY = t => (account ? formatShort(account.unit, t) : t)
const area = d3
.area()
@ -62,12 +62,11 @@ export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props })
}
if (INVALIDATED.color) {
if (interactive) {
if (isInteractive) {
// Update focus bar colors
NODES.xBar.attr('stroke', COLORS.focusBar)
NODES.yBar.attr('stroke', COLORS.focusBar)
// Update dot color
NODES.focus.attr('fill', COLORS.focus)
NODES.focus.attr('stroke', COLORS.focus)
}
// Update gradient color
NODES.gradientStart.attr('stop-color', COLORS.gradientStart)
@ -78,11 +77,10 @@ export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props })
}
// Hide interactive things
if (interactive) {
if (isInteractive) {
NODES.focus.style('opacity', 0)
NODES.tooltip.style('opacity', 0)
NODES.xBar.style('opacity', 0)
NODES.yBar.style('opacity', 0)
}
// 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 }) {
const { NODES, COLORS } = ctx
const { hideAxis, interactive, id } = props
const { hideAxis, isInteractive, id } = props
// Container
@ -26,20 +26,11 @@ export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any
// Focus bars
ensure({ onlyIf: interactive, NODES, key: 'xBar' }, () =>
ensure({ onlyIf: isInteractive, NODES, key: 'xBar' }, () =>
NODES.wrapper
.append('line')
.attr('stroke', COLORS.focusBar)
.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'),
.attr('stroke-width', '1px'),
)
// Gradient
@ -84,7 +75,7 @@ export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any
// Tooltip & focus point
ensure({ onlyIf: interactive, NODES, key: 'tooltip' }, () =>
ensure({ onlyIf: isInteractive, NODES, key: 'tooltip' }, () =>
d3
.select(node)
.append('div')
@ -94,11 +85,13 @@ export default function refreshNodes({ ctx, node, props }: { ctx: CTX, node: any
.style('pointer-events', 'none'),
)
ensure({ onlyIf: interactive, NODES, key: 'focus' }, () =>
ensure({ onlyIf: isInteractive, NODES, key: 'focus' }, () =>
NODES.wrapper
.append('g')
.append('circle')
.attr('fill', COLORS.focus)
.attr('fill', 'white')
.attr('stroke', COLORS.focus)
.attr('stroke-width', 2)
.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 Chart from 'components/base/Chart'
import Box from 'components/base/Box'
const stories = storiesOf('Components/base', module)
const data = generateRandomData(365)
const unit = getCryptoCurrencyById('bitcoin').units[0]
const currency = getCryptoCurrencyById('bitcoin')
// $FlowFixMe
const fakeAccount = {
currency,
}
type State = {
start: number,
@ -32,7 +38,14 @@ class Wrapper extends Component<any, State> {
const { start, stop } = this.state
return (
<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
type="range"
value={stop}
@ -41,14 +54,15 @@ class Wrapper extends Component<any, State> {
min={0}
max={365}
/>
</Box>
<Chart
interactive={boolean('interactive', true)}
hideAxis={boolean('hideAxis', false)}
isInteractive={boolean('isInteractive', true)}
hideAxis={boolean('hideAxis', true)}
color={color('color', '#5f8ced')}
data={data.slice(start, stop)}
height={number('height', 300)}
unit={unit}
account={fakeAccount}
/>
</Fragment>
)
@ -63,9 +77,11 @@ function generateRandomData(n) {
const data = []
const chance = new Chance()
while (!day.isSame(today)) {
const value = chance.integer({ min: 0.5e8, max: 1e8 })
data.push({
date: day.toDate(),
value: chance.integer({ min: 0.5e8, max: 1e8 }),
value,
originalValue: value,
})
day.add(1, 'day')
}

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

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

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

@ -45,6 +45,7 @@ function stopPropagation(e) {
}
type Props = {
onChangeFocus: boolean => void,
onChange: (number, Unit) => void, // FIXME Unit shouldn't be provided (this is not "standard" onChange)
onChangeUnit: Unit => void,
renderRight: any,
@ -61,6 +62,7 @@ type State = {
class InputCurrency extends PureComponent<Props, State> {
static defaultProps = {
onChangeFocus: noop,
onChange: noop,
renderRight: null,
units: [],
@ -122,8 +124,15 @@ class InputCurrency extends PureComponent<Props, State> {
this.setState({ displayValue: v || '' })
}
handleBlur = () => this.syncInput({ isFocused: false })
handleFocus = () => this.syncInput({ isFocused: true })
handleBlur = () => {
this.syncInput({ isFocused: false })
this.props.onChangeFocus(false)
}
handleFocus = () => {
this.syncInput({ isFocused: true })
this.props.onChangeFocus(true)
}
syncInput = ({ isFocused }: { isFocused: boolean }) => {
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 StepConnectDevice from 'components/modals/StepConnectDevice'
import { getBridgeForCurrency } from 'bridge'
import CounterValues from 'helpers/countervalues'
import StepCurrency from './01-step-currency'
import StepImport from './03-step-import'
@ -62,7 +61,6 @@ type Props = {
closeModal: Function,
t: T,
updateAccount: Function,
counterValuesPolling: *,
}
type State = {
@ -173,8 +171,6 @@ class AddAccountModal extends PureComponent<Props, State> {
selectedAccounts.forEach(a => addAccount({ ...a, archived: false }))
this.setState({ selectedAccounts: [] })
closeModal(MODAL_ADD_ACCOUNT)
this.props.counterValuesPolling.poll()
this.props.counterValuesPolling.flush()
}
handleNextStep = () => {
@ -298,17 +294,4 @@ class AddAccountModal extends PureComponent<Props, State> {
)
}
}
// 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,
)
export default compose(connect(mapStateToProps, mapDispatchToProps), translate())(AddAccountModal)

20
src/components/modals/OperationDetails.js

@ -6,9 +6,10 @@ import { shell } from 'electron'
import { translate } from 'react-i18next'
import styled from 'styled-components'
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 { T } from 'types/common'
import type { T, CurrencySettings } from 'types/common'
import { MODAL_OPERATION_DETAILS } from 'config/constants'
@ -61,19 +62,19 @@ type Props = {
t: T,
operation: Operation,
account: Account,
type: 'from' | 'to',
onClose: Function,
currencySettings: *,
onClose: () => void,
currencySettings: CurrencySettings,
marketColor: string,
}
const OperationDetails = connect(mapStateToProps)((props: Props) => {
const { t, type, onClose, operation, account, marketColor, currencySettings } = props
const { id, hash, amount, date, senders, recipients } = operation
const { t, onClose, operation, account, marketColor, currencySettings } = props
const { id, hash, date, senders, recipients, type } = operation
const amount = getOperationAmountNumber(operation)
const { name, unit, currency } = account
const confirmations = account.blockHeight - operation.blockHeight
const isConfirmed = confirmations >= currencySettings.minConfirmations
const confirmations = operation.blockHeight ? account.blockHeight - operation.blockHeight : 0
const isConfirmed = confirmations >= currencySettings.confirmationsNb
return (
<ModalBody onClose={onClose}>
<ModalTitle>Operation details</ModalTitle>
@ -81,8 +82,7 @@ const OperationDetails = connect(mapStateToProps)((props: Props) => {
<Box alignItems="center" mt={3}>
<ConfirmationCheck
marketColor={marketColor}
confirmations={confirmations}
minConfirmations={currencySettings.minConfirmations}
isConfirmed={isConfirmed}
style={{
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 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'
const Container = styled(Box).attrs({
@ -34,11 +34,11 @@ type Props = {
device: ?Device,
bridge: ?WalletBridge<*>,
transaction: *,
onValidate: Function,
onOperationBroadcasted: (op: Operation) => void,
t: T,
}
export default ({ account, device, bridge, transaction, onValidate, t }: Props) => (
export default ({ account, device, bridge, transaction, onOperationBroadcasted, t }: Props) => (
<Container>
<WarnBox>{multiline(t('send:steps.verification.warning'))}</WarnBox>
<Info>{t('send:steps.verification.body')}</Info>
@ -51,7 +51,7 @@ export default ({ account, device, bridge, transaction, onValidate, t }: Props)
device={device}
transaction={transaction}
bridge={bridge}
onSuccess={onValidate}
onOperationBroadcasted={onOperationBroadcasted}
render={({ error }) => (
// FIXME we really really REALLY should use error for the display. otherwise we are completely blind on error cases..
<DeviceConfirm notValid={!!error} />

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

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

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

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

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

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

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

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

5
src/helpers/getAddressForCurrency/index.js

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

79
src/internals/accounts/scanAccountsOnDevice.js

@ -9,12 +9,10 @@
//
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 type Transport from '@ledgerhq/hw-transport'
import type { AccountRaw } from '@ledgerhq/live-common/lib/types'
import type { AccountRaw, OperationRaw, OperationType } from '@ledgerhq/live-common/lib/types'
import type { NJSAccount, NJSOperation } from '@ledgerhq/ledger-core/src/ledgercore_doc'
type Props = {
@ -23,11 +21,10 @@ type Props = {
onAccountScanned: Function,
}
export default async function scanAccountsOnDevice(props: Props): Promise<AccountRaw[]> {
export default function scanAccountsOnDevice(props: Props): Promise<AccountRaw[]> {
const { devicePath, currencyId, onAccountScanned } = props
// instanciate app on device
const transport: Transport<*> = await CommNodeHid.open(devicePath)
return withDevice(devicePath)(async transport => {
const hwApp = new Btc(transport)
const commonParams = {
@ -38,12 +35,16 @@ export default async function scanAccountsOnDevice(props: Props): Promise<Accoun
}
// scan segwit AND non-segwit accounts
const nonSegwitAccounts = await scanAccountsOnDeviceBySegwit({
...commonParams,
isSegwit: false,
})
const segwitAccounts = await scanAccountsOnDeviceBySegwit({ ...commonParams, isSegwit: true })
const nonSegwitAccounts = await scanAccountsOnDeviceBySegwit({ ...commonParams, isSegwit: false })
const accounts = [...segwitAccounts, ...nonSegwitAccounts]
const accounts = [...nonSegwitAccounts, ...segwitAccounts]
return accounts
})
}
export async function getWalletIdentifier({
@ -153,16 +154,18 @@ async function scanNextAccount(props: {
ops,
})
// returns if the current index points on an account with no ops
if (ops.length === 0) {
return accounts
}
const isEmpty = ops.length === 0
// trigger event
onAccountScanned(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 })
}
@ -211,6 +214,7 @@ async function buildAccountRaw({
// $FlowFixMe
ops: NJSOperation[],
}): Promise<AccountRaw> {
/*
const balanceByDay = ops.length
? await getBalanceByDaySinceOperation({
njsAccount,
@ -218,6 +222,7 @@ async function buildAccountRaw({
core,
})
: {}
*/
const njsBalance = await njsAccount.getBalance()
const balance = njsBalance.toLong()
@ -244,23 +249,30 @@ async function buildAccountRaw({
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 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,
path: accountPath,
walletPath,
name: `Account ${accountIndex}${isSegwit ? ' (segwit)' : ''}`, // TODO: placeholder name?
path: walletPath,
name: `${operations.length === 0 ? 'New ' : ''}Account ${accountIndex}${
isSegwit ? ' (segwit)' : ''
}`, // TODO: placeholder name?
isSegwit,
address: bitcoinAddress,
addresses,
freshAddress,
freshAddressPath,
balance,
blockHeight,
archived: false,
index: accountIndex,
balanceByDay,
operations,
pendingOperations: [],
currencyId,
unitMagnitude: jsCurrency.units[0].magnitude,
lastSyncDate: new Date().toISOString(),
@ -269,31 +281,45 @@ async function buildAccountRaw({
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 bitcoinLikeOperation = op.asBitcoinLikeOperation()
const bitcoinLikeTransaction = bitcoinLikeOperation.getTransaction()
const hash = bitcoinLikeTransaction.getHash()
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
const amount = operationType === core.OPERATION_TYPES.SEND ? -absoluteAmount : absoluteAmount
const type = OperationTypeMap[operationType]
return {
id,
hash,
address: '',
type,
value,
senders: op.getSenders(),
recipients: op.getRecipients(),
blockHeight: op.getBlockHeight(),
blockHash: '',
blockHash: null,
accountId: xpub,
date: op.getDate().toISOString(),
amount,
}
}
/*
async function getBalanceByDaySinceOperation({
njsAccount,
njsOperation,
@ -336,3 +362,4 @@ function areSameDay(date1: Date, date2: Date): boolean {
date1.getDate() === date2.getDate()
)
}
*/

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

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

7
src/internals/manager/helpers.js

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

4
static/i18n/en/operationsList.yml

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

2
static/i18n/fr/common.yml

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

44
static/i18n/fr/onboarding.yml

@ -26,25 +26,49 @@ selectDevice:
title: Ledger Blue
desc: Please replace it with the final wording once it’s done.
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
instructions:
step1: Connect your Ledger Nano S to your computer using the supplied micro USB cable.
step2: Press both buttons simultaneously as instructed on your Ledger Nano S screen.
step3: Select Configure as new device on your Ledger Nano S by pressing the right button, located above the validation icon.
step1: Connect the Ledger Nano S to your computer.
step2: Press both buttons simultaneously as instructed on the screen.
step3: Press the right button to select Configure as new device.
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:
title: 24-Word 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.
title: Save your recovery phrase
desc: Your recovery phrase is formed by 24 words. They will be displayed only once.
instructions:
step1: Copy the first word (Word \
step2: Move to Word \
step3: Repeat the process until all 24 words are copied on the Recovery sheet.
step4: To confirm, use the right or left button to select each of the 24 words in the right order.
step5: Validate each word by simultaneously pressing both buttons.
step2: Press the right button to display Word \
step3: Confirm your recovery phrase press both buttons to validate each word displayed on the screen.
disclaimer:
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:
title: Check PIN / Seed / Authenticity
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:
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

4
static/i18n/fr/operationsList.yml

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

1
static/i18n/fr/send.yml

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

1323
yarn.lock

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