Browse Source

Merge pull request #1545 from LedgerHQ/develop

Prepare for 1.2.0
master
Meriadec Pillet 6 years ago
committed by GitHub
parent
commit
c865d01775
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .gitignore
  2. 1
      .prettierignore
  3. 3
      package.json
  4. 34
      src/bridge/EthereumJSBridge.js
  5. 54
      src/bridge/LibcoreBridge.js
  6. 100
      src/bridge/RippleJSBridge.js
  7. 4
      src/bridge/UnsupportedBridge.js
  8. 8
      src/bridge/makeMockBridge.js
  9. 7
      src/bridge/types.js
  10. 2
      src/commands/index.js
  11. 18
      src/commands/killInternalProcess.js
  12. 19
      src/components/AdvancedOptions/RippleKind.js
  13. 11
      src/components/ExchangePage/index.js
  14. 30
      src/components/FeesField/BitcoinKind.js
  15. 9
      src/components/FeesField/EthereumKind.js
  16. 9
      src/components/FeesField/RippleKind.js
  17. 62
      src/components/ManagerPage/AppsList.js
  18. 7
      src/components/ManagerPage/index.js
  19. 2
      src/components/Onboarding/steps/Analytics.js
  20. 26
      src/components/QRCodeExporter.js
  21. 8
      src/components/RequestAmount/index.js
  22. 4
      src/components/SettingsPage/sections/Tools.js
  23. 23
      src/components/base/Input/index.js
  24. 23
      src/components/base/InputCurrency/index.js
  25. 1
      src/components/base/Modal/ConfirmModal.js
  26. 6
      src/components/base/QRCode/index.js
  27. 23
      src/components/modals/Send/fields/AmountField.js
  28. 10
      src/components/modals/Send/steps/01-step-amount.js
  29. 4
      src/config/errors.js
  30. 1
      src/config/urls.js
  31. 3
      src/helpers/anonymizer.js
  32. 29
      src/helpers/countervalues.js
  33. 4
      src/helpers/reset.js
  34. 8
      src/logger/logger.js
  35. 2
      src/main/app.js
  36. 2
      src/reducers/settings.js
  37. 5
      static/i18n/en/app.json
  38. 6
      static/i18n/en/errors.json
  39. 13
      static/images/logos/exchanges/kyber.svg
  40. 15
      test-e2e/README.md
  41. 4
      test-e2e/enable-dev-mode.spec.js
  42. 0
      test-e2e/helpers.js
  43. 93
      test-e2e/password-lock-check.spec.js
  44. 1
      test-e2e/sync/data/empty-app.json
  45. 1
      test-e2e/sync/data/expected-app.json
  46. 47
      test-e2e/sync/launch.sh
  47. 64
      test-e2e/sync/sync-accounts.spec.js
  48. 52
      test-e2e/sync/wait-sync.js
  49. 6
      yarn.lock

2
.gitignore

@ -10,3 +10,5 @@
/build/linux/arch/src
/build/linux/arch/*.tar.gz
/build/linux/arch/*.tar.xz
/test-e2e/sync/data/actual_app.json

1
.prettierignore

@ -1 +1,2 @@
package.json
test-e2e/**/*.json

3
package.json

@ -17,6 +17,7 @@
"flow": "flow",
"test": "jest src",
"test-e2e": "jest test-e2e",
"test-sync": "bash test-e2e/sync/launch.sh",
"prettier": "prettier --write \"{src,webpack,.storybook,test-e2e}/**/*.{js,json}\"",
"ci": "yarn lint && yarn flow && yarn prettier && yarn test",
"storybook": "NODE_ENV=development STORYBOOK_ENV=1 start-storybook -s ./static -p 4444",
@ -39,7 +40,7 @@
"@ledgerhq/hw-transport": "^4.13.0",
"@ledgerhq/hw-transport-node-hid": "4.22.0",
"@ledgerhq/ledger-core": "2.0.0-rc.7",
"@ledgerhq/live-common": "^3.5.1",
"@ledgerhq/live-common": "^3.7.1",
"animated": "^0.2.2",
"async": "^2.6.1",
"axios": "^0.18.0",

34
src/bridge/EthereumJSBridge.js

@ -16,20 +16,20 @@ import { getDerivations } from 'helpers/derivations'
import getAddressCommand from 'commands/getAddress'
import signTransactionCommand from 'commands/signTransaction'
import { getAccountPlaceholderName, getNewAccountPlaceholderName } from 'helpers/accountName'
import { NotEnoughBalance, ETHAddressNonEIP } from 'config/errors'
import { NotEnoughBalance, FeeNotLoaded, ETHAddressNonEIP } from 'config/errors'
import type { EditProps, WalletBridge } from './types'
type Transaction = {
recipient: string,
amount: BigNumber,
gasPrice: BigNumber,
gasPrice: ?BigNumber,
gasLimit: BigNumber,
}
const serializeTransaction = t => ({
recipient: t.recipient,
amount: `0x${BigNumber(t.amount).toString(16)}`,
gasPrice: `0x${BigNumber(t.gasPrice).toString(16)}`,
gasPrice: !t.gasPrice ? '0x00' : `0x${BigNumber(t.gasPrice).toString(16)}`,
gasLimit: `0x${BigNumber(t.gasLimit).toString(16)}`,
})
@ -140,6 +140,8 @@ const signAndBroadcast = async ({
onSigned,
onOperationBroadcasted,
}) => {
const { gasPrice, amount, gasLimit } = t
if (!gasPrice) throw new FeeNotLoaded()
const api = apiForCurrency(a.currency)
const nonce = await api.getAccountNonce(a.freshAddress)
@ -162,8 +164,8 @@ const signAndBroadcast = async ({
id: `${a.id}-${hash}-OUT`,
hash,
type: 'OUT',
value: t.amount,
fee: t.gasPrice.times(t.gasLimit),
value: amount,
fee: gasPrice.times(gasLimit),
blockHeight: null,
blockHash: null,
accountId: a.id,
@ -402,7 +404,7 @@ const EthereumBridge: WalletBridge<Transaction> = {
createTransaction: () => ({
amount: BigNumber(0),
recipient: '',
gasPrice: BigNumber(0),
gasPrice: null,
gasLimit: BigNumber(0x5208),
}),
@ -420,23 +422,27 @@ const EthereumBridge: WalletBridge<Transaction> = {
getTransactionRecipient: (a, t) => t.recipient,
isValidTransaction: (a, t) => (!t.amount.isZero() && t.recipient && true) || false,
EditFees,
EditAdvancedOptions,
checkCanBeSpent: (a, t) =>
t.amount.isLessThanOrEqualTo(a.balance)
? Promise.resolve()
: Promise.reject(new NotEnoughBalance()),
checkValidTransaction: (a, t) =>
!t.gasPrice
? Promise.reject(new FeeNotLoaded())
: t.amount.isLessThanOrEqualTo(a.balance)
? Promise.resolve(true)
: Promise.reject(new NotEnoughBalance()),
getTotalSpent: (a, t) =>
t.amount.isGreaterThan(0) && t.gasPrice.isGreaterThan(0) && t.gasLimit.isGreaterThan(0)
t.amount.isGreaterThan(0) &&
t.gasPrice &&
t.gasPrice.isGreaterThan(0) &&
t.gasLimit.isGreaterThan(0)
? Promise.resolve(t.amount.plus(t.gasPrice.times(t.gasLimit)))
: Promise.resolve(BigNumber(0)),
getMaxAmount: (a, t) => Promise.resolve(a.balance.minus(t.gasPrice.times(t.gasLimit))),
getMaxAmount: (a, t) =>
Promise.resolve(a.balance.minus((t.gasPrice || BigNumber(0)).times(t.gasLimit))),
signAndBroadcast: (a, t, deviceId) =>
Observable.create(o => {

54
src/bridge/LibcoreBridge.js

@ -11,7 +11,7 @@ import libcoreSyncAccount from 'commands/libcoreSyncAccount'
import libcoreSignAndBroadcast from 'commands/libcoreSignAndBroadcast'
import libcoreGetFees, { extractGetFeesInputFromAccount } from 'commands/libcoreGetFees'
import libcoreValidAddress from 'commands/libcoreValidAddress'
import { NotEnoughBalance } from 'config/errors'
import { NotEnoughBalance, FeeNotLoaded } from 'config/errors'
import type { WalletBridge, EditProps } from './types'
const NOT_ENOUGH_FUNDS = 52
@ -20,15 +20,18 @@ const notImplemented = new Error('LibcoreBridge: not implemented')
type Transaction = {
amount: BigNumber,
feePerByte: BigNumber,
feePerByte: ?BigNumber,
recipient: string,
}
const serializeTransaction = t => ({
recipient: t.recipient,
amount: t.amount.toString(),
feePerByte: t.feePerByte.toString(),
})
const serializeTransaction = t => {
const { feePerByte } = t
return {
recipient: t.recipient,
amount: t.amount.toString(),
feePerByte: (feePerByte && feePerByte.toString()) || '0',
}
}
const decodeOperation = (encodedAccount, rawOp) =>
decodeAccount({ ...encodedAccount, operations: [rawOp] }).operations[0]
@ -75,7 +78,9 @@ const isRecipientValid = (currency, recipient) => {
const feesLRU = LRU({ max: 100 })
const getFeesKey = (a, t) =>
`${a.id}_${a.blockHeight || 0}_${t.amount.toString()}_${t.recipient}_${t.feePerByte.toString()}`
`${a.id}_${a.blockHeight || 0}_${t.amount.toString()}_${t.recipient}_${
t.feePerByte ? t.feePerByte.toString() : ''
}`
const getFees = async (a, transaction) => {
const isValid = await isRecipientValid(a.currency, transaction.recipient)
@ -83,6 +88,7 @@ const getFees = async (a, transaction) => {
const key = getFeesKey(a, transaction)
let promise = feesLRU.get(key)
if (promise) return promise
promise = libcoreGetFees
.send({
...extractGetFeesInputFromAccount(a),
@ -94,18 +100,20 @@ const getFees = async (a, transaction) => {
return promise
}
const checkCanBeSpent = (a, t) =>
!t.amount
? Promise.resolve()
: getFees(a, t)
.then(() => {})
.catch(e => {
if (e.code === NOT_ENOUGH_FUNDS) {
throw new NotEnoughBalance()
}
feesLRU.del(getFeesKey(a, t))
throw e
})
const checkValidTransaction = (a, t) =>
!t.feePerByte
? Promise.reject(new FeeNotLoaded())
: !t.amount
? Promise.resolve(true)
: getFees(a, t)
.then(() => true)
.catch(e => {
if (e.code === NOT_ENOUGH_FUNDS) {
throw new NotEnoughBalance()
}
feesLRU.del(getFeesKey(a, t))
throw e
})
const LibcoreBridge: WalletBridge<Transaction> = {
scanAccountsOnDevice(currency, devicePath) {
@ -169,7 +177,7 @@ const LibcoreBridge: WalletBridge<Transaction> = {
createTransaction: () => ({
amount: BigNumber(0),
recipient: '',
feePerByte: BigNumber(0),
feePerByte: null,
isRBF: false,
}),
@ -191,9 +199,7 @@ const LibcoreBridge: WalletBridge<Transaction> = {
// EditAdvancedOptions,
isValidTransaction: (a, t) => (!t.amount.isZero() && t.recipient && true) || false,
checkCanBeSpent,
checkValidTransaction,
getTotalSpent: (a, t) =>
t.amount.isZero()

100
src/bridge/RippleJSBridge.js

@ -20,13 +20,17 @@ import {
import FeesRippleKind from 'components/FeesField/RippleKind'
import AdvancedOptionsRippleKind from 'components/AdvancedOptions/RippleKind'
import { getAccountPlaceholderName, getNewAccountPlaceholderName } from 'helpers/accountName'
import { NotEnoughBalance } from 'config/errors'
import {
NotEnoughBalance,
FeeNotLoaded,
NotEnoughBalanceBecauseDestinationNotCreated,
} from 'config/errors'
import type { WalletBridge, EditProps } from './types'
type Transaction = {
amount: BigNumber,
recipient: string,
fee: BigNumber,
fee: ?BigNumber,
tag: ?number,
}
@ -51,6 +55,8 @@ const EditAdvancedOptions = ({ onChange, value }: EditProps<Transaction>) => (
async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }) {
const api = apiForEndpointConfig(a.endpointConfig)
const { fee } = t
if (!fee) throw new FeeNotLoaded()
try {
await api.connect()
const amount = formatAPICurrencyXRP(t.amount)
@ -66,7 +72,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera
},
}
const instruction = {
fee: formatAPICurrencyXRP(t.fee).value,
fee: formatAPICurrencyXRP(fee).value,
maxLedgerVersionOffset: 12,
}
@ -97,7 +103,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera
accountId: a.id,
type: 'OUT',
value: t.amount,
fee: t.fee,
fee,
blockHash: null,
blockHeight: null,
senders: [a.freshAddress],
@ -114,7 +120,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera
}
}
function isRecipientValid(currency, recipient) {
function isRecipientValid(recipient) {
try {
bs58check.decode(recipient)
return true
@ -241,6 +247,31 @@ const getServerInfo = (map => endpointConfig => {
return f()
})({})
const recipientIsNew = async (endpointConfig, recipient) => {
if (!isRecipientValid(recipient)) return false
const api = apiForEndpointConfig(endpointConfig)
try {
await api.connect()
try {
await api.getAccountInfo(recipient)
return false
} catch (e) {
if (e.message !== 'actNotFound') {
throw e
}
return true
}
} finally {
api.disconnect()
}
}
const cacheRecipientsNew = {}
const cachedRecipientIsNew = (endpointConfig, recipient) => {
if (recipient in cacheRecipientsNew) return cacheRecipientsNew[recipient]
return (cacheRecipientsNew[recipient] = recipientIsNew(endpointConfig, recipient))
}
const RippleJSBridge: WalletBridge<Transaction> = {
scanAccountsOnDevice: (currency, deviceId) =>
Observable.create(o => {
@ -446,13 +477,13 @@ const RippleJSBridge: WalletBridge<Transaction> = {
pullMoreOperations: () => Promise.resolve(a => a), // FIXME not implemented
isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)),
isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(recipient)),
getRecipientWarning: () => Promise.resolve(null),
createTransaction: () => ({
amount: BigNumber(0),
recipient: '',
fee: BigNumber(0),
fee: null,
tag: undefined,
}),
@ -463,10 +494,30 @@ const RippleJSBridge: WalletBridge<Transaction> = {
getTransactionAmount: (a, t) => t.amount,
editTransactionRecipient: (account, t, recipient) => ({
...t,
recipient,
}),
editTransactionRecipient: (account, t, recipient) => {
const parts = recipient.split('?')
const params = new URLSearchParams(parts[1])
recipient = parts[0]
// Extract parameters we may need
for (const [key, value] of params.entries()) {
switch (key) {
case 'dt':
t.tag = parseInt(value, 10) || 0
break
case 'amount':
t.amount = parseAPIValue(value || '0')
break
default:
// do nothing
}
}
return {
...t,
recipient,
}
},
EditFees,
@ -474,27 +525,38 @@ const RippleJSBridge: WalletBridge<Transaction> = {
getTransactionRecipient: (a, t) => t.recipient,
isValidTransaction: (a, t) => (!t.amount.isZero() && t.recipient && true) || false,
checkCanBeSpent: async (a, t) => {
checkValidTransaction: async (a, t) => {
if (!t.fee) throw new FeeNotLoaded()
const r = await getServerInfo(a.endpointConfig)
const reserveBaseXRP = parseAPIValue(r.validatedLedger.reserveBaseXRP)
if (t.recipient) {
if (await cachedRecipientIsNew(a.endpointConfig, t.recipient)) {
if (t.amount.lt(reserveBaseXRP)) {
const f = formatAPICurrencyXRP(reserveBaseXRP)
throw new NotEnoughBalanceBecauseDestinationNotCreated('', {
minimalAmount: `${f.currency} ${f.value}`,
})
}
}
}
if (
t.amount
.plus(t.fee)
.plus(parseAPIValue(r.validatedLedger.reserveBaseXRP))
.plus(t.fee || 0)
.plus(reserveBaseXRP)
.isLessThanOrEqualTo(a.balance)
) {
return
return true
}
throw new NotEnoughBalance()
},
getTotalSpent: (a, t) => Promise.resolve(t.amount.plus(t.fee)),
getTotalSpent: (a, t) => Promise.resolve(t.amount.plus(t.fee || 0)),
getMaxAmount: (a, t) => Promise.resolve(a.balance.minus(t.fee)),
getMaxAmount: (a, t) => Promise.resolve(a.balance.minus(t.fee || 0)),
signAndBroadcast: (a, t, deviceId) =>
Observable.create(o => {
delete cacheRecipientsNew[t.recipient]
let cancelled = false
const isCancelled = () => cancelled
const onSigned = () => {

4
src/bridge/UnsupportedBridge.js

@ -27,13 +27,11 @@ const UnsupportedBridge: WalletBridge<*> = {
getTransactionAmount: () => BigNumber(0),
isValidTransaction: () => false,
editTransactionRecipient: () => null,
getTransactionRecipient: () => '',
checkCanBeSpent: () => Promise.resolve(),
checkValidTransaction: () => Promise.resolve(false),
getTotalSpent: () => Promise.resolve(BigNumber(0)),

8
src/bridge/makeMockBridge.js

@ -18,7 +18,7 @@ const defaultOpts = {
scanAccountDeviceSuccessRate: 0.8,
transactionsSizeTarget: 100,
extraInitialTransactionProps: () => null,
checkCanBeSpent: () => Promise.resolve(),
checkValidTransaction: () => Promise.resolve(),
getTotalSpent: (a, t) => Promise.resolve(t.amount),
getMaxAmount: a => Promise.resolve(a.balance),
}
@ -36,7 +36,7 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> {
extraInitialTransactionProps,
getTotalSpent,
getMaxAmount,
checkCanBeSpent,
checkValidTransaction,
} = {
...defaultOpts,
...opts,
@ -155,9 +155,7 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> {
EditAdvancedOptions,
isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false,
checkCanBeSpent,
checkValidTransaction,
getTotalSpent,

7
src/bridge/types.js

@ -76,15 +76,16 @@ export interface WalletBridge<Transaction> {
getTransactionRecipient(account: Account, transaction: Transaction): string;
isValidTransaction(account: Account, transaction: Transaction): boolean;
// render the whole Fees section of the form
EditFees?: *; // React$ComponentType<EditProps<Transaction>>;
// render the whole advanced part of the form
EditAdvancedOptions?: *; // React$ComponentType<EditProps<Transaction>>;
checkCanBeSpent(account: Account, transaction: Transaction): Promise<void>;
// validate the transaction and all currency specific validations here, we can return false
// to disable the button without throwing an error if we are handling the error on a different
// input or throw an error that will highlight the issue on the amount field
checkValidTransaction(account: Account, transaction: Transaction): Promise<boolean>;
getTotalSpent(account: Account, transaction: Transaction): Promise<BigNumber>;

2
src/commands/index.js

@ -15,6 +15,7 @@ import installFinalFirmware from 'commands/installFinalFirmware'
import installMcu from 'commands/installMcu'
import installOsuFirmware from 'commands/installOsuFirmware'
import isDashboardOpen from 'commands/isDashboardOpen'
import killInternalProcess from 'commands/killInternalProcess'
import libcoreGetFees from 'commands/libcoreGetFees'
import libcoreGetVersion from 'commands/libcoreGetVersion'
import libcoreScanAccounts from 'commands/libcoreScanAccounts'
@ -47,6 +48,7 @@ const all: Array<Command<any, any>> = [
installMcu,
installOsuFirmware,
isDashboardOpen,
killInternalProcess,
libcoreGetFees,
libcoreGetVersion,
libcoreScanAccounts,

18
src/commands/killInternalProcess.js

@ -0,0 +1,18 @@
// @flow
import { createCommand, Command } from 'helpers/ipc'
import { of } from 'rxjs'
type Input = void
type Result = boolean
const cmd: Command<Input, Result> = createCommand('killInternalProcess', () => {
setTimeout(() => {
// we assume commands are run on the internal process
// special exit code for better identification
process.exit(42)
})
return of(true)
})
export default cmd

19
src/components/AdvancedOptions/RippleKind.js

@ -6,7 +6,6 @@ import { translate } from 'react-i18next'
import Box from 'components/base/Box'
import Input from 'components/base/Input'
import Label from 'components/base/Label'
import Spoiler from 'components/base/Spoiler'
type Props = {
tag: ?number,
@ -31,18 +30,14 @@ class RippleKind extends Component<Props> {
render() {
const { tag, t } = this.props
return (
<Spoiler title={t('app:send.steps.amount.advancedOptions')}>
<Box horizontal align="center" flow={5}>
<Box style={{ width: 200 }}>
<Label>
<span>{t('app:send.steps.amount.rippleTag')}</span>
</Label>
</Box>
<Box grow>
<Input value={String(tag || '')} onChange={this.onChange} />
</Box>
<Box vertical flow={5}>
<Box grow>
<Label>
<span>{t('app:send.steps.amount.rippleTag')}</span>
</Label>
<Input value={String(tag || '')} onChange={this.onChange} />
</Box>
</Spoiler>
</Box>
)
}
}

11
src/components/ExchangePage/index.js

@ -2,6 +2,7 @@
import React, { PureComponent } from 'react'
import { translate } from 'react-i18next'
import shuffle from 'lodash/shuffle'
import type { T } from 'types/common'
import { urls } from 'config/urls'
@ -21,7 +22,7 @@ type Props = {
t: T,
}
const cards = [
const cards = shuffle([
{
key: 'coinhouse',
id: 'coinhouse',
@ -70,7 +71,13 @@ const cards = [
url: urls.genesis,
logo: <img src={i('logos/exchanges/genesis.svg')} alt="Genesis" width={150} />,
},
]
{
key: 'kyber',
id: 'kyber',
url: urls.kyber,
logo: <img src={i('logos/exchanges/kyber.svg')} alt="KYBER" width={150} />,
},
])
class ExchangePage extends PureComponent<Props> {
render() {

30
src/components/FeesField/BitcoinKind.js

@ -8,6 +8,7 @@ import { translate } from 'react-i18next'
import type { T } from 'types/common'
import { FeeNotLoaded } from 'config/errors'
import InputCurrency from 'components/base/InputCurrency'
import Select from 'components/base/Select'
import type { Fees } from 'api/Fees'
@ -17,7 +18,7 @@ import Box from '../base/Box'
type Props = {
account: Account,
feePerByte: BigNumber,
feePerByte: ?BigNumber,
onChange: BigNumber => void,
t: T,
}
@ -50,6 +51,12 @@ const customItem = {
blockCount: 0,
feePerByte: BigNumber(0),
}
const notLoadedItem = {
label: 'Standard',
value: 'standard',
blockCount: 0,
feePerByte: BigNumber(0),
}
type State = { isFocused: boolean, items: FeeItem[], selectedItem: FeeItem }
@ -57,13 +64,13 @@ type OwnProps = Props & { fees?: Fees, error?: Error }
class FeesField extends Component<OwnProps, State> {
state = {
items: [customItem],
selectedItem: customItem,
items: [notLoadedItem],
selectedItem: notLoadedItem,
isFocused: false,
}
static getDerivedStateFromProps(nextProps, prevState) {
const { fees, feePerByte } = nextProps
const { fees, feePerByte, error } = nextProps
let items: FeeItem[] = []
if (fees) {
for (const key of Object.keys(fees)) {
@ -80,17 +87,18 @@ class FeesField extends Component<OwnProps, State> {
}
items = items.sort((a, b) => a.blockCount - b.blockCount)
}
items.push(customItem)
const selectedItem = prevState.selectedItem.feePerByte.eq(feePerByte)
? prevState.selectedItem
: items.find(f => f.feePerByte.eq(feePerByte)) || items[items.length - 1]
items.push(!feePerByte && !error ? notLoadedItem : customItem)
const selectedItem =
!feePerByte && prevState.selectedItem.feePerByte.eq(feePerByte)
? prevState.selectedItem
: items.find(f => f.feePerByte.eq(feePerByte)) || items[items.length - 1]
return { items, selectedItem }
}
componentDidUpdate({ fees: prevFees }: OwnProps) {
const { feePerByte, fees, onChange } = this.props
const { items, isFocused } = this.state
if (fees && fees !== prevFees && feePerByte.isZero() && !isFocused) {
if (fees && fees !== prevFees && !feePerByte && !isFocused) {
// initialize with the median
const feePerByte = (items.find(item => item.blockCount === defaultBlockCount) || items[0])
.feePerByte
@ -127,7 +135,7 @@ class FeesField extends Component<OwnProps, State> {
const satoshi = units[units.length - 1]
return (
<GenericContainer error={error}>
<GenericContainer>
<Select width={156} options={items} value={selectedItem} onChange={this.onSelectChange} />
<InputCurrency
ref={this.input}
@ -137,6 +145,8 @@ class FeesField extends Component<OwnProps, State> {
value={feePerByte}
onChange={onChange}
onChangeFocus={this.onChangeFocus}
loading={!feePerByte && !error}
error={!feePerByte && error ? new FeeNotLoaded() : null}
renderRight={
<InputRight>
{t('app:send.steps.amount.unitPerByte', { unit: satoshi.code })}

9
src/components/FeesField/EthereumKind.js

@ -4,6 +4,7 @@ import React, { Component } from 'react'
import { BigNumber } from 'bignumber.js'
import type { Account } from '@ledgerhq/live-common/lib/types'
import { FeeNotLoaded } from 'config/errors'
import InputCurrency from 'components/base/InputCurrency'
import type { Fees } from 'api/Fees'
import WithFeesAPI from '../WithFeesAPI'
@ -11,7 +12,7 @@ import GenericContainer from './GenericContainer'
type Props = {
account: Account,
gasPrice: BigNumber,
gasPrice: ?BigNumber,
onChange: BigNumber => void,
}
@ -22,7 +23,7 @@ class FeesField extends Component<Props & { fees?: Fees, error?: Error }, *> {
componentDidUpdate() {
const { gasPrice, fees, onChange } = this.props
const { isFocused } = this.state
if (gasPrice.isZero() && fees && fees.gas_price && !isFocused) {
if (!gasPrice && fees && fees.gas_price && !isFocused) {
onChange(BigNumber(fees.gas_price)) // we want to set the default to gas_price
}
}
@ -33,12 +34,14 @@ class FeesField extends Component<Props & { fees?: Fees, error?: Error }, *> {
const { account, gasPrice, error, onChange } = this.props
const { units } = account.currency
return (
<GenericContainer error={error}>
<GenericContainer>
<InputCurrency
defaultUnit={units.length > 1 ? units[1] : units[0]}
units={units}
containerProps={{ grow: true }}
value={gasPrice}
loading={!error && !gasPrice}
error={!gasPrice && error ? new FeeNotLoaded() : null}
onChange={onChange}
onChangeFocus={this.onChangeFocus}
/>

9
src/components/FeesField/RippleKind.js

@ -4,12 +4,13 @@ import React, { Component } from 'react'
import type { BigNumber } from 'bignumber.js'
import type { Account } from '@ledgerhq/live-common/lib/types'
import { apiForEndpointConfig, parseAPIValue } from 'api/Ripple'
import { FeeNotLoaded } from 'config/errors'
import InputCurrency from 'components/base/InputCurrency'
import GenericContainer from './GenericContainer'
type Props = {
account: Account,
fee: BigNumber,
fee: ?BigNumber,
onChange: BigNumber => void,
}
@ -36,7 +37,7 @@ class FeesField extends Component<Props, State> {
const info = await api.getServerInfo()
if (syncId !== this.syncId) return
const serverFee = parseAPIValue(info.validatedLedger.baseFeeXRP)
if (this.props.fee.isZero()) {
if (!this.props.fee) {
this.props.onChange(serverFee)
}
} catch (error) {
@ -50,11 +51,13 @@ class FeesField extends Component<Props, State> {
const { error } = this.state
const { units } = account.currency
return (
<GenericContainer error={error}>
<GenericContainer>
<InputCurrency
defaultUnit={units[0]}
units={units}
containerProps={{ grow: true }}
loading={!error && !fee}
error={!fee && error ? new FeeNotLoaded() : null}
value={fee}
onChange={onChange}
/>

62
src/components/ManagerPage/AppsList.js

@ -7,15 +7,13 @@ import { translate } from 'react-i18next'
import { connect } from 'react-redux'
import { compose } from 'redux'
import type { Device, T } from 'types/common'
import type { Application, ApplicationVersion, DeviceInfo } from 'helpers/types'
import type { ApplicationVersion, DeviceInfo } from 'helpers/types'
import { getFullListSortedCryptoCurrencies } from 'helpers/countervalues'
import { developerModeSelector } from 'reducers/settings'
import listApps from 'commands/listApps'
import listAppVersions from 'commands/listAppVersions'
import installApp from 'commands/installApp'
import uninstallApp from 'commands/uninstallApp'
import Box from 'components/base/Box'
import Space from 'components/base/Space'
import Modal, { ModalBody, ModalFooter, ModalTitle, ModalContent } from 'components/base/Modal'
@ -26,13 +24,11 @@ import Spinner from 'components/base/Spinner'
import Button from 'components/base/Button'
import TranslatedError from 'components/TranslatedError'
import TrackPage from 'analytics/TrackPage'
import IconInfoCircle from 'icons/InfoCircle'
import ExclamationCircleThin from 'icons/ExclamationCircleThin'
import Update from 'icons/Update'
import Trash from 'icons/Trash'
import CheckCircle from 'icons/CheckCircle'
import { FreezeDeviceChangeEvents } from './HookDeviceChange'
import ManagerApp, { Container as FakeManagerAppContainer } from './ManagerApp'
import AppSearchBar from './AppSearchBar'
@ -102,29 +98,53 @@ class AppsList extends PureComponent<Props, State> {
_unmounted = false
filterAppVersions = (applicationsList, compatibleAppVersionsList) => {
if (!this.props.isDevMode) {
return compatibleAppVersionsList.filter(version => {
const app = applicationsList.find(e => e.id === version.app)
if (app) {
return app.category !== 2
}
prepareAppList = ({ applicationsList, compatibleAppVersionsList, sortedCryptoCurrencies }) => {
const filtered = this.props.isDevMode
? compatibleAppVersionsList.slice(0)
: compatibleAppVersionsList.filter(version => {
const app = applicationsList.find(e => e.id === version.app)
if (app) {
return app.category !== 2
}
return false
})
}
return compatibleAppVersionsList
return false
})
const sortedCryptoApps = []
// sort by crypto first
sortedCryptoCurrencies.forEach(crypto => {
const app = filtered.find(
item => item.name.toLowerCase() === crypto.managerAppName.toLowerCase(),
)
if (app) {
filtered.splice(filtered.indexOf(app), 1)
sortedCryptoApps.push(app)
}
})
return sortedCryptoApps.concat(filtered)
}
async fetchAppList() {
try {
const { deviceInfo } = this.props
const applicationsList: Array<Application> = await listApps.send().toPromise()
const compatibleAppVersionsList = await listAppVersions.send(deviceInfo).toPromise()
const filteredAppVersionsList = this.filterAppVersions(
const [
applicationsList,
compatibleAppVersionsList,
)
sortedCryptoCurrencies,
] = await Promise.all([
listApps.send().toPromise(),
listAppVersions.send(deviceInfo).toPromise(),
getFullListSortedCryptoCurrencies(),
])
const filteredAppVersionsList = this.prepareAppList({
applicationsList,
compatibleAppVersionsList,
sortedCryptoCurrencies,
})
if (!this._unmounted) {
this.setState({

7
src/components/ManagerPage/index.js

@ -4,12 +4,11 @@ import React, { PureComponent, Fragment } from 'react'
import invariant from 'invariant'
import { openURL } from 'helpers/linking'
import { urls } from 'config/urls'
import type { Device } from 'types/common'
import type { DeviceInfo } from 'helpers/types'
import { getFullListSortedCryptoCurrencies } from 'helpers/countervalues'
import Dashboard from './Dashboard'
import ManagerGenuineCheck from './ManagerGenuineCheck'
import HookDeviceChange from './HookDeviceChange'
@ -30,6 +29,10 @@ const INITIAL_STATE = {
class ManagerPage extends PureComponent<Props, State> {
state = INITIAL_STATE
componentDidMount() {
getFullListSortedCryptoCurrencies() // start fetching the crypto currencies ordering
}
// prettier-ignore
handleSuccessGenuine = ({ device, deviceInfo }: { device: Device, deviceInfo: DeviceInfo }) => { // eslint-disable-line react/no-unused-prop-types
this.setState({ isGenuine: true, device, deviceInfo })

2
src/components/Onboarding/steps/Analytics.js

@ -26,7 +26,7 @@ type State = {
}
const INITIAL_STATE = {
analyticsToggle: false,
analyticsToggle: true,
sentryLogsToggle: true,
}

26
src/components/QRCodeExporter.js

@ -17,34 +17,44 @@ const mapStateToProps = createSelector(accountsSelector, accounts => ({
}),
}))
const LOW_FPS = 2
const HIGH_FPS = 8
class QRCodeExporter extends PureComponent<
{
chunks: string[],
fps: number,
size: number,
},
{
frame: number,
fps: number,
},
> {
static defaultProps = {
fps: 4,
size: 480,
size: 440,
}
state = {
frame: 0,
fps: HIGH_FPS,
}
componentDidMount() {
const nextFrame = ({ frame }, { chunks }) => ({
frame: (frame + 1) % chunks.length,
})
console.log(`BRIDGESTREAM_DATA=${btoa(JSON.stringify(this.props.chunks))}`) // eslint-disable-line
const nextFrame = ({ frame, fps }, { chunks }) => {
frame = (frame + 1) % chunks.length
return {
frame,
fps: frame === 0 ? (fps === LOW_FPS ? HIGH_FPS : LOW_FPS) : fps,
}
}
let lastT
const loop = t => {
this._raf = requestAnimationFrame(loop)
if (!lastT) lastT = t
if ((t - lastT) * this.props.fps < 1000) return
if ((t - lastT) * this.state.fps < 1000) return
lastT = t
this.setState(nextFrame)
}
@ -64,7 +74,7 @@ class QRCodeExporter extends PureComponent<
<div style={{ position: 'relative', width: size, height: size }}>
{chunks.map((chunk, i) => (
<div key={String(i)} style={{ position: 'absolute', opacity: i === frame ? 1 : 0 }}>
<QRCode data={chunk} size={size} />
<QRCode data={chunk} size={size} errorCorrectionLevel="M" />
</div>
))}
</div>

8
src/components/RequestAmount/index.js

@ -48,7 +48,7 @@ type OwnProps = {
// left value (always the one which is returned)
value: BigNumber,
canBeSpentError: ?Error,
validTransactionError: ?Error,
// max left value
max: BigNumber,
@ -113,7 +113,7 @@ const mapStateToProps = (state: State, props: OwnProps) => {
export class RequestAmount extends PureComponent<Props> {
static defaultProps = {
max: BigNumber(Infinity),
canBeSpent: true,
validTransaction: true,
withMax: true,
}
@ -139,14 +139,14 @@ export class RequestAmount extends PureComponent<Props> {
renderInputs(containerProps: Object) {
// TODO move this inlined into render() for less spaghetti
const { value, account, rightCurrency, getCounterValue, canBeSpentError } = this.props
const { value, account, rightCurrency, getCounterValue, validTransactionError } = this.props
const right = getCounterValue(value) || BigNumber(0)
const rightUnit = rightCurrency.units[0]
// FIXME: no way InputCurrency pure can work here. inlined InputRight (should be static func?), inline containerProps object..
return (
<Box horizontal grow shrink>
<InputCurrency
error={canBeSpentError}
error={validTransactionError}
containerProps={containerProps}
defaultUnit={account.unit}
value={value}

4
src/components/SettingsPage/sections/Tools.js

@ -26,9 +26,7 @@ class TabProfile extends PureComponent<*, *> {
<ModalBody onClose={onClose} justify="center" align="center">
<ModalTitle>{'QRCode Mobile Export'}</ModalTitle>
<ModalContent flow={4}>
<Box>
Open Ledger Wallet Mobile App, go to <strong>Settings {'>'} Import Accounts</strong>
</Box>
<Box>Scan this animated QRCode with Ledger Live Mobile App</Box>
<QRCodeExporter />
</ModalContent>
</ModalBody>

23
src/components/base/Input/index.js

@ -4,9 +4,8 @@ import React, { PureComponent } from 'react'
import styled from 'styled-components'
import { fontSize, space } from 'styled-system'
import noop from 'lodash/noop'
import fontFamily from 'styles/styled/fontFamily'
import Spinner from 'components/base/Spinner'
import Box from 'components/base/Box'
import TranslatedError from 'components/TranslatedError'
@ -44,6 +43,19 @@ const ErrorDisplay = styled(Box)`
color: ${p => p.theme.colors.pearl};
`
const LoadingDisplay = styled(Box)`
position: absolute;
left: 0px;
top: 0px;
bottom: 0px;
background: white;
pointer-events: none;
flex-direction: row;
align-items: center;
padding: 0 15px;
border-radius: 4px;
`
const WarningDisplay = styled(ErrorDisplay)`
color: ${p => p.theme.colors.warning};
`
@ -98,6 +110,7 @@ type Props = {
renderLeft?: any,
renderRight?: any,
containerProps?: Object,
loading?: boolean,
error?: ?Error | boolean,
warning?: ?Error | boolean,
small?: boolean,
@ -182,6 +195,7 @@ class Input extends PureComponent<Props, State> {
editInPlace,
small,
error,
loading,
warning,
...props
} = this.props
@ -217,6 +231,11 @@ class Input extends PureComponent<Props, State> {
<TranslatedError error={warning} />
</WarningDisplay>
) : null}
{loading && !isFocus ? (
<LoadingDisplay>
<Spinner size={16} />
</LoadingDisplay>
) : null}
</Box>
{renderRight}
</Container>

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

@ -81,7 +81,7 @@ type Props = {
renderRight: any,
unit: Unit,
units: Unit[],
value: BigNumber,
value: ?BigNumber,
showAllDigits?: boolean,
subMagnitude: number,
allowZero: boolean,
@ -98,7 +98,7 @@ class InputCurrency extends PureComponent<Props, State> {
onChange: noop,
renderRight: null,
units: [],
value: BigNumber(0),
value: null,
showAllDigits: false,
subMagnitude: 0,
allowZero: false,
@ -123,13 +123,14 @@ class InputCurrency extends PureComponent<Props, State> {
if (needsToBeReformatted) {
const { isFocused } = this.state
this.setState({
displayValue: nextProps.value.isZero()
? ''
: format(nextProps.unit, nextProps.value, {
isFocused,
showAllDigits: nextProps.showAllDigits,
subMagnitude: nextProps.subMagnitude,
}),
displayValue:
!nextProps.value || nextProps.value.isZero()
? ''
: format(nextProps.unit, nextProps.value, {
isFocused,
showAllDigits: nextProps.showAllDigits,
subMagnitude: nextProps.subMagnitude,
}),
})
}
}
@ -138,7 +139,7 @@ class InputCurrency extends PureComponent<Props, State> {
const { onChange, unit, value } = this.props
const r = sanitizeValueString(unit, v)
const satoshiValue = BigNumber(r.value)
if (!value.isEqualTo(satoshiValue)) {
if (!value || !value.isEqualTo(satoshiValue)) {
onChange(satoshiValue, unit)
}
this.setState({ displayValue: r.display })
@ -159,7 +160,7 @@ class InputCurrency extends PureComponent<Props, State> {
this.setState({
isFocused,
displayValue:
(!value || value.isZero()) && !allowZero
!value || (value.isZero() && !allowZero)
? ''
: format(unit, value, { isFocused, showAllDigits, subMagnitude }),
})

1
src/components/base/Modal/ConfirmModal.js

@ -79,6 +79,7 @@ class ConfirmModal extends PureComponent<Props> {
primary={!isDanger}
danger={isDanger}
isLoading={isLoading}
disabled={isLoading}
>
{realConfirmText}
</Button>

6
src/components/base/QRCode/index.js

@ -5,12 +5,14 @@ import qrcode from 'qrcode'
type Props = {
data: string,
errorCorrectionLevel: string,
size: number,
}
class QRCode extends PureComponent<Props> {
static defaultProps = {
size: 200,
errorCorrectionLevel: 'Q',
}
componentDidMount() {
@ -24,11 +26,11 @@ class QRCode extends PureComponent<Props> {
_canvas = null
drawQRCode() {
const { data, size } = this.props
const { data, size, errorCorrectionLevel } = this.props
qrcode.toCanvas(this._canvas, data, {
width: size,
margin: 0,
errorCorrectionLevel: 'Q',
errorCorrectionLevel,
color: {
light: '#ffffff00', // transparent background
},

23
src/components/modals/Send/fields/AmountField.js

@ -4,9 +4,12 @@ import Box from 'components/base/Box'
import Label from 'components/base/Label'
import RequestAmount from 'components/RequestAmount'
class AmountField extends Component<*, { canBeSpentError: ?Error }> {
// list of errors that are handled somewhere else on UI, otherwise the field will catch every other errors.
const blacklistErrorName = ['FeeNotLoaded', 'InvalidAddress']
class AmountField extends Component<*, { validTransactionError: ?Error }> {
state = {
canBeSpentError: null,
validTransactionError: null,
}
componentDidMount() {
this.resync()
@ -27,11 +30,11 @@ class AmountField extends Component<*, { canBeSpentError: ?Error }> {
const { account, bridge, transaction } = this.props
const syncId = ++this.syncId
try {
await bridge.checkCanBeSpent(account, transaction)
await bridge.checkValidTransaction(account, transaction)
if (this.syncId !== syncId) return
this.setState({ canBeSpentError: null })
} catch (canBeSpentError) {
this.setState({ canBeSpentError })
this.setState({ validTransactionError: null })
} catch (validTransactionError) {
this.setState({ validTransactionError })
}
}
@ -42,14 +45,18 @@ class AmountField extends Component<*, { canBeSpentError: ?Error }> {
render() {
const { bridge, account, transaction, t } = this.props
const { canBeSpentError } = this.state
const { validTransactionError } = this.state
return (
<Box flow={1}>
<Label>{t('app:send.steps.amount.amount')}</Label>
<RequestAmount
withMax={false}
account={account}
canBeSpentError={canBeSpentError}
validTransactionError={
validTransactionError && blacklistErrorName.includes(validTransactionError.name)
? null
: validTransactionError
}
onChange={this.onChange}
value={bridge.getTransactionAmount(account, transaction)}
/>

10
src/components/modals/Send/steps/01-step-amount.js

@ -134,11 +134,13 @@ export class StepAmountFooter extends PureComponent<
bridge.getTransactionRecipient(account, transaction),
)
if (syncId !== this.syncId) return
const canBeSpent = await bridge
.checkCanBeSpent(account, transaction)
.then(() => true, () => false)
const isValidTransaction = await bridge
.checkValidTransaction(account, transaction)
.then(result => result, () => false)
if (syncId !== this.syncId) return
const canNext = isRecipientValid && canBeSpent && totalSpent.gt(0)
const canNext =
!transaction.amount.isZero() && isRecipientValid && isValidTransaction && totalSpent.gt(0)
this.setState({ totalSpent, canNext, isSyncing: false })
} catch (err) {
logger.critical(err)

4
src/config/errors.js

@ -30,6 +30,9 @@ export const ManagerUninstallBTCDep = createCustomErrorClass('ManagerUninstallBT
export const NetworkDown = createCustomErrorClass('NetworkDown')
export const NoAddressesFound = createCustomErrorClass('NoAddressesFound')
export const NotEnoughBalance = createCustomErrorClass('NotEnoughBalance')
export const NotEnoughBalanceBecauseDestinationNotCreated = createCustomErrorClass(
'NotEnoughBalanceBecauseDestinationNotCreated',
)
export const PasswordsDontMatchError = createCustomErrorClass('PasswordsDontMatch')
export const PasswordIncorrectError = createCustomErrorClass('PasswordIncorrect')
export const TimeoutTagged = createCustomErrorClass('TimeoutTagged')
@ -42,6 +45,7 @@ export const WebsocketConnectionFailed = createCustomErrorClass('WebsocketConnec
export const WrongDeviceForAccount = createCustomErrorClass('WrongDeviceForAccount')
export const ETHAddressNonEIP = createCustomErrorClass('ETHAddressNonEIP')
export const CantScanQRCode = createCustomErrorClass('CantScanQRCode')
export const FeeNotLoaded = createCustomErrorClass('FeeNotLoaded')
// db stuff, no need to translate
export const NoDBPathGiven = createCustomErrorClass('NoDBPathGiven')

1
src/config/urls.js

@ -30,6 +30,7 @@ export const urls = {
luno: 'http://luno.go2cloud.org/aff_c?offer_id=4&aff_id=1001&source=ledger',
shapeshift: 'https://shapeshift.io/#/coins?affiliate=ledger',
genesis: 'https://genesistrading.com/ledger-live/',
kyber: 'http://kyber.network/swap?ref=0xE2D8481eeF31CDA994833974FFfEccd576f8D71E',
// Errors
errors: {

3
src/helpers/anonymizer.js

@ -46,6 +46,9 @@ function filepathRecursiveReplacer(obj: mixed, seen: Array<*>) {
}
}
} else {
if (obj instanceof Error) {
obj.message = filepathReplace(obj.message)
}
for (const k in obj) {
if (typeof obj.hasOwnProperty === 'function' && obj.hasOwnProperty(k)) {
const value = obj[k]

29
src/helpers/countervalues.js

@ -12,6 +12,8 @@ import {
intermediaryCurrency,
} from 'reducers/settings'
import logger from 'logger'
import { listCryptoCurrencies } from '@ledgerhq/live-common/lib/helpers/currencies'
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types'
const pairsSelector = createSelector(
currenciesSelector,
@ -52,6 +54,7 @@ const addExtraPollingHooks = (schedulePoll, cancelPoll) => {
}
}
// TODO we should be able to pass-in our network() function
const CounterValues = createCounterValues({
log: (...args) => logger.log('CounterValues:', ...args),
getAPIBaseURL: () => LEDGER_COUNTERVALUES_API,
@ -61,4 +64,30 @@ const CounterValues = createCounterValues({
addExtraPollingHooks,
})
let sortCache
export const getFullListSortedCryptoCurrencies: () => Promise<CryptoCurrency[]> = () => {
if (!sortCache) {
sortCache = CounterValues.fetchTickersByMarketcap().then(
tickers => {
const list = listCryptoCurrencies().slice(0)
const prependList = []
tickers.forEach(ticker => {
const item = list.find(c => c.ticker === ticker)
if (item) {
list.splice(list.indexOf(item), 1)
prependList.push(item)
}
})
return prependList.concat(list)
},
() => {
sortCache = null // reset the cache for the next time it comes here to "try again"
return listCryptoCurrencies() // fallback on default sort
},
)
}
return sortCache
}
export default CounterValues

4
src/helpers/reset.js

@ -6,8 +6,10 @@ import resolveUserDataDirectory from 'helpers/resolveUserDataDirectory'
import { disable as disableDBMiddleware } from 'middlewares/db'
import db from 'helpers/db'
import { delay } from 'helpers/promise'
import killInternalProcess from 'commands/killInternalProcess'
function resetLibcoreDatabase() {
async function resetLibcoreDatabase() {
await killInternalProcess.send().toPromise()
const dbpath = path.resolve(resolveUserDataDirectory(), 'sqlite/')
rimraf.sync(dbpath, { glob: false })
}

8
src/logger/logger.js

@ -316,19 +316,19 @@ export default {
analyticsStart: (id: string) => {
if (logAnalytics) {
logger.log('info', `△ start() with user id ${id}`, { type: 'anaytics-start', id })
logger.log('info', `△ start() with user id ${id}`, { type: 'analytics-start', id })
}
},
analyticsStop: () => {
if (logAnalytics) {
logger.log('info', `△ stop()`, { type: 'anaytics-stop' })
logger.log('info', `△ stop()`, { type: 'analytics-stop' })
}
},
analyticsTrack: (event: string, properties: ?Object) => {
if (logAnalytics) {
logger.log('info', `△ track ${event}`, { type: 'anaytics-track', properties })
logger.log('info', `△ track ${event}`, { type: 'analytics-track', properties })
}
captureBreadcrumb({
category: 'track',
@ -340,7 +340,7 @@ export default {
analyticsPage: (category: string, name: ?string, properties: ?Object) => {
const message = name ? `${category} ${name}` : category
if (logAnalytics) {
logger.log('info', `△ page ${message}`, { type: 'anaytics-page', properties })
logger.log('info', `△ page ${message}`, { type: 'analytics-page', properties })
}
captureBreadcrumb({
category: 'page',

2
src/main/app.js

@ -172,8 +172,6 @@ const installExtensions = async () => {
).catch(console.log) // eslint-disable-line
}
app.setAsDefaultProtocolClient('ledgerhq')
app.on('ready', async () => {
if (__DEV__) {
await installExtensions()

2
src/reducers/settings.js

@ -70,7 +70,7 @@ const INITIAL_STATE: SettingsState = {
currenciesSettings: {},
developerMode: !!process.env.__DEV__,
loaded: false,
shareAnalytics: false,
shareAnalytics: true,
sentryLogs: true,
lastUsedVersion: __APP_VERSION__,
}

5
static/i18n/en/app.json

@ -167,9 +167,10 @@
"coinmama": "Coinmama is a financial service that makes it fast, safe and fun to buy digital assets, anywhere in the world.",
"simplex": "Simplex is a EU licensed financial institution, providing a fraudless credit card payment solution.",
"paybis": "it is safe and easy to Buy Bitcoin with credit card from PayBis. Service operates in US, Canada, Germany, Russia and Saudi Arabia.",
"luno": "Luno makes it safe and easy to buy, store and learn about digital currencies like Bitcoin and Ethreum",
"luno": "Luno makes it safe and easy to buy, store and learn about cryptocurrencies like Bitcoin and Ethereum",
"shapeshift": "ShapeShift is an online marketplace where users can buy and sell digital assets. It is a fast and secure way for the world to buy and sell digital assets, with no lengthy signup process, no counterparty risk, and no friction.",
"genesis": "Genesis is an institutional trading firm offering liquidity and borrow for digital currencies, including bitcoin, bitcoin cash, ethereum, ethereum classic, litecoin, and XRP."
"genesis": "Genesis is an institutional trading firm offering liquidity and borrow for digital currencies, including bitcoin, bitcoin cash, ethereum, ethereum classic, litecoin, and XRP.",
"kyber": "KYBER, his a trading platform for exchange and conversion of ERC-20 tokens"
},
"genuinecheck": {
"modal": {

6
static/i18n/en/errors.json

@ -99,6 +99,9 @@
"title": "Oops, insufficient balance",
"description": "Make sure the account to debit has sufficient balance"
},
"NotEnoughBalanceBecauseDestinationNotCreated": {
"title": "Recipient address is inactive. Send at least {{minimalAmount}} to activate it"
},
"PasswordsDontMatch": {
"title": "Passwords don't match",
"description": "Please try again"
@ -172,5 +175,8 @@
},
"CantScanQRCode": {
"title": "Couldn't scan this QR-code: auto-verification not supported by this address"
},
"FeeNotLoaded": {
"title": "Couldn’t load fee rates. Please set manual fees"
}
}

13
static/images/logos/exchanges/kyber.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

15
test-e2e/README.md

@ -0,0 +1,15 @@
# ledgerLive-QA
Automated tests for Ledger Live Desktop application.
Start Ledger Live Desktop application with accounts for the supported coin. Operations history removed from db. Then sync to retrieve account balance and transactions history.
## Accounts setup and sync
#### Launch test
yarn test-sync
#### Test description
Clean Ledger Live Application settings directory.
Copy app.json init file for testing in a new Ledger Live Application settings directory.
Start Ledger Live Desktop app.
Wait for sync OK.
Compare new app.json with expected app.json file.

4
test-e2e/skipOnboarding_GeneralSettingsCheck.spec.js → test-e2e/enable-dev-mode.spec.js

@ -1,6 +1,6 @@
import { Application } from 'spectron'
import { waitForDisappear, waitForExpectedText } from '../test-e2e/helpers/test_helpers'
import { waitForDisappear, waitForExpectedText } from './helpers'
const os = require('os')
const appVersion = require('../package.json')
@ -14,7 +14,7 @@ const platform = os.platform()
if (platform === 'darwin') {
app_path = `./dist/mac/Ledger Live.app/Contents/MacOS/Ledger Live`
} else if (platform === 'win32') {
app_path = `./dist\\win-unpacked\\Ledger Live.exe`
app_path = `.\\dist\\win-unpacked\\Ledger Live.exe`
} else {
app_path = `./dist/ledger-live-desktop-${appVersion.version}-linux-x86_64.AppImage`
}

0
test-e2e/helpers/test_helpers.js → test-e2e/helpers.js

93
test-e2e/password-lock-check.spec.js

@ -0,0 +1,93 @@
import { Application } from 'spectron'
import { waitForDisappear, waitForExpectedText } from './helpers'
const os = require('os')
const path = require('path')
const fs = require('fs')
const appVersion = require('../package.json')
let app
const TIMEOUT = 50 * 1000
let appPath
let configPath
const platform = os.platform()
if (platform === 'darwin') {
appPath = `./dist/mac/Ledger Live.app/Contents/MacOS/Ledger Live`
configPath = `${os.homedir()}/Library/Application Support/Ledger Live/`
} else if (platform === 'win32') {
appPath = `.\\dist\\win-unpacked\\Ledger Live.exe`
configPath = '%AppData\\Roaming\\Ledger Live'
} else {
appPath = `./dist/ledger-live-desktop-${appVersion.version}-linux-x86_64.AppImage`
configPath = '$HOME/apps/ledger-live-desktop-$ledgerLiveVersion-linux-x86_64.AppImage'
}
describe('Application launch', () => {
beforeEach(async () => {
app = new Application({
path: appPath,
env: {
SKIP_ONBOARDING: '1',
},
})
await app.start()
}, TIMEOUT)
afterEach(async () => {
if (app && app.isRunning()) {
await app.stop()
}
}, TIMEOUT)
test(
'Start app, activate password lock, check app.json, deactivate password lock',
async () => {
const title = await app.client.getTitle()
expect(title).toEqual('Ledger Live')
await app.client.waitUntilWindowLoaded()
await waitForDisappear(app, '#preload')
// Verify Account summary text
// Count user's accounts
const userAccountsList = await app.client.elements('[data-e2e=dashboard_AccountCardWrapper]')
const userAccountsCount = Object.keys(userAccountsList.value).length
// Check account number
const accountSummary = await app.client.getText('[data-e2e=dashboard_accountsSummaryDesc]')
const accountSummaryMessage = `Here's the summary of your ${userAccountsCount} accounts`
expect(accountSummary).toEqual(accountSummaryMessage)
// Go to settings
await app.client.click('[data-e2e=setting_button]')
await waitForExpectedText(app, '[data-e2e=settings_title]', 'Settings')
// Enable lock password
await app.client.click('[data-e2e=passwordLock_button]')
await waitForExpectedText(app, '[data-e2e=setPassword_modalTitle]', 'Set a password')
await app.client.setValue('[data-e2e=setPassword_NewPassword]', 5)
await app.client.setValue('[data-e2e=setPassword_ConfirmPassword]', 5)
await app.client.keys('Enter')
await waitForExpectedText(app, '[data-e2e=settings_title]', 'Settings')
await app.client.pause(2000)
// Verify in app.json that accounts data are encrypted
const tmpAppJSONPath = path.resolve(configPath, 'app.json')
const LockedfileContent = fs.readFileSync(tmpAppJSONPath, 'utf-8')
const accountsOperations = '"operations":[{'
await expect(LockedfileContent).not.toContain(accountsOperations)
// Disable password lock
await app.client.click('[data-e2e=passwordLock_button]')
await waitForExpectedText(app, '[data-e2e=modal_title]', 'Disable password lock')
await app.client.setValue('#password', 5)
await app.client.pause(500)
await app.client.keys('Enter')
await waitForExpectedText(app, '[data-e2e=settings_title]', 'Settings')
await app.client.pause(3000)
const UnlockedfileContent = fs.readFileSync(tmpAppJSONPath, 'utf-8')
// Verify in app.json that accounts data are not encrypted
await expect(UnlockedfileContent).toContain(accountsOperations)
await app.client.pause(1000)
},
TIMEOUT,
)
})

1
test-e2e/sync/data/empty-app.json

File diff suppressed because one or more lines are too long

1
test-e2e/sync/data/expected-app.json

File diff suppressed because one or more lines are too long

47
test-e2e/sync/launch.sh

@ -0,0 +1,47 @@
#!/bin/bash
# get app version
ledgerLiveVersion=$(grep version package.json | cut -d : -f 2 | sed -E 's/.*"([^"]*)".*/\1/g')
# OS settings
if [[ $(uname) == 'Darwin' ]]; then \
settingsPath=~/Library/Application\ Support/Ledger\ Live/
appPath="/Applications/Ledger Live.app/Contents/MacOS/Ledger Live"
elif [[ $(uname) == 'Linux' ]]; then \
settingsPath="$HOME/.config/Ledger Live"
appPath="$HOME/apps/ledger-live-desktop-$ledgerLiveVersion-linux-x86_64.AppImage"
else \
settingsPath="%AppData\\Roaming\\Ledger Live"
appPath="C:\\Program Files\\Ledger Live\\Ledger Live.exe"
fi
# clean Ledger Live Application settings directory
rm -rf "$settingsPath"
mkdir "$settingsPath"
# Copy app.json init file for testing
cp test-e2e/sync/data/empty-app.json "$settingsPath/app.json"
# Start Ledger Live Desktop app
"$appPath" &
lastPid=$!
# wait for sync
electron ./test-e2e/sync/wait-sync.js
returnCode=$?
# kill Ledger Live Desktop process
kill -9 $lastPid
if [[ $returnCode = 0 ]]; then
echo "[OK] Sync finished"
else
echo "[x] Sync failed"
exit 1
fi
# Copy app.json file to test folder
cp "$settingsPath"/app.json test-e2e/sync/data/actual-app.json
# compare new app.json with expected_app.json
./node_modules/.bin/jest test-e2e/sync/sync-accounts.spec.js

64
test-e2e/sync/sync-accounts.spec.js

@ -0,0 +1,64 @@
const pick = require('lodash/pick')
const ACCOUNTS_FIELDS = [
'archived',
'freshAddress',
'freshAddressPath',
'id',
'index',
'isSegwit',
'name',
'path',
'xpub',
'operations',
'currencyId',
'unitMagnitude',
'balance',
]
const OPS_FIELDS = ['id', 'hash', 'accountId', 'type', 'senders', 'recipients', 'value', 'fee']
const OP_SORT = (a, b) => {
const aHash = getOpHash(a)
const bHash = getOpHash(b)
if (aHash < bHash) return -1
if (aHash > bHash) return 1
return 0
}
const ACCOUNT_SORT = (a, b) => {
const aHash = getAccountHash(a)
const bHash = getAccountHash(b)
if (aHash < bHash) return -1
if (aHash > bHash) return 1
return 0
}
describe('sync accounts', () => {
test('should give the same app.json', () => {
const expected = getSanitized('./data/expected-app.json')
const actual = getSanitized('./data/actual-app.json')
expect(actual).toEqual(expected)
})
})
function getSanitized(filePath) {
const data = require(`${filePath}`) // eslint-disable-line import/no-dynamic-require
const accounts = data.data.accounts.map(a => a.data)
accounts.sort(ACCOUNT_SORT)
return accounts.map(a => pick(a, ACCOUNTS_FIELDS)).map(a => {
a.operations.sort(OP_SORT)
return {
...a,
operations: a.operations.map(o => pick(o, OPS_FIELDS)),
}
})
}
function getOpHash(op) {
return `${op.accountId}--${op.hash}--${op.type}`
}
function getAccountHash(account) {
return `${account.name}`
}

52
test-e2e/sync/wait-sync.js

@ -0,0 +1,52 @@
/* eslint-disable no-console */
const electron = require('electron')
const fs = require('fs')
const path = require('path')
const moment = require('moment')
const delay = ms => new Promise(f => setTimeout(f, ms))
const MIN_TIME_DIFF = 1 * 1000 * 90 // 1.5 minute
const PING_INTERVAL = 1 * 1000 // 1 seconds
async function waitForSync() {
let MAX_RETRIES = 100
const userDataDirectory = electron.app.getPath('userData')
const tmpAppJSONPath = path.resolve(userDataDirectory, 'app.json')
const appJSONPath = tmpAppJSONPath.replace('/Electron/', '/Ledger Live/')
function check() {
const appJSONContent = fs.readFileSync(appJSONPath, 'utf-8')
const appJSONParsed = JSON.parse(appJSONContent)
const mapped = appJSONParsed.data.accounts.map(a => ({
name: a.data.name,
lastSyncDate: a.data.lastSyncDate,
}))
const now = Date.now()
const areAllSync = mapped.every(account => {
const diff = now - new Date(account.lastSyncDate).getTime()
if (diff <= MIN_TIME_DIFF) return true
console.log(
`[${account.name}] synced ${moment(account.lastSyncDate).fromNow()} (${moment(
account.lastSyncDate,
).format('YYYY-MM-DD HH:mm:ss')})`,
)
return false
})
return areAllSync
}
while (!check()) {
MAX_RETRIES--
if (!MAX_RETRIES) {
console.log(`x Too much retries. Exitting.`)
process.exit(1)
}
await delay(PING_INTERVAL)
}
process.exit(0)
}
waitForSync()

6
yarn.lock

@ -1549,9 +1549,9 @@
npm "^5.7.1"
prebuild-install "^2.2.2"
"@ledgerhq/live-common@^3.5.1":
version "3.5.1"
resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-3.5.1.tgz#dab3eb061f361999a9e04ef564808831faac61ea"
"@ledgerhq/live-common@^3.7.1":
version "3.7.1"
resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-3.7.1.tgz#5ce1895920d2eae6c454c2c72612dc9afd11adec"
dependencies:
axios "^0.18.0"
bignumber.js "^7.2.1"

Loading…
Cancel
Save