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/src
/build/linux/arch/*.tar.gz /build/linux/arch/*.tar.gz
/build/linux/arch/*.tar.xz /build/linux/arch/*.tar.xz
/test-e2e/sync/data/actual_app.json

1
.prettierignore

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

3
package.json

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

34
src/bridge/EthereumJSBridge.js

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

54
src/bridge/LibcoreBridge.js

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

100
src/bridge/RippleJSBridge.js

@ -20,13 +20,17 @@ import {
import FeesRippleKind from 'components/FeesField/RippleKind' import FeesRippleKind from 'components/FeesField/RippleKind'
import AdvancedOptionsRippleKind from 'components/AdvancedOptions/RippleKind' import AdvancedOptionsRippleKind from 'components/AdvancedOptions/RippleKind'
import { getAccountPlaceholderName, getNewAccountPlaceholderName } from 'helpers/accountName' import { getAccountPlaceholderName, getNewAccountPlaceholderName } from 'helpers/accountName'
import { NotEnoughBalance } from 'config/errors' import {
NotEnoughBalance,
FeeNotLoaded,
NotEnoughBalanceBecauseDestinationNotCreated,
} from 'config/errors'
import type { WalletBridge, EditProps } from './types' import type { WalletBridge, EditProps } from './types'
type Transaction = { type Transaction = {
amount: BigNumber, amount: BigNumber,
recipient: string, recipient: string,
fee: BigNumber, fee: ?BigNumber,
tag: ?number, tag: ?number,
} }
@ -51,6 +55,8 @@ const EditAdvancedOptions = ({ onChange, value }: EditProps<Transaction>) => (
async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }) { async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }) {
const api = apiForEndpointConfig(a.endpointConfig) const api = apiForEndpointConfig(a.endpointConfig)
const { fee } = t
if (!fee) throw new FeeNotLoaded()
try { try {
await api.connect() await api.connect()
const amount = formatAPICurrencyXRP(t.amount) const amount = formatAPICurrencyXRP(t.amount)
@ -66,7 +72,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera
}, },
} }
const instruction = { const instruction = {
fee: formatAPICurrencyXRP(t.fee).value, fee: formatAPICurrencyXRP(fee).value,
maxLedgerVersionOffset: 12, maxLedgerVersionOffset: 12,
} }
@ -97,7 +103,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera
accountId: a.id, accountId: a.id,
type: 'OUT', type: 'OUT',
value: t.amount, value: t.amount,
fee: t.fee, fee,
blockHash: null, blockHash: null,
blockHeight: null, blockHeight: null,
senders: [a.freshAddress], senders: [a.freshAddress],
@ -114,7 +120,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera
} }
} }
function isRecipientValid(currency, recipient) { function isRecipientValid(recipient) {
try { try {
bs58check.decode(recipient) bs58check.decode(recipient)
return true return true
@ -241,6 +247,31 @@ const getServerInfo = (map => endpointConfig => {
return f() 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> = { const RippleJSBridge: WalletBridge<Transaction> = {
scanAccountsOnDevice: (currency, deviceId) => scanAccountsOnDevice: (currency, deviceId) =>
Observable.create(o => { Observable.create(o => {
@ -446,13 +477,13 @@ const RippleJSBridge: WalletBridge<Transaction> = {
pullMoreOperations: () => Promise.resolve(a => a), // FIXME not implemented 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), getRecipientWarning: () => Promise.resolve(null),
createTransaction: () => ({ createTransaction: () => ({
amount: BigNumber(0), amount: BigNumber(0),
recipient: '', recipient: '',
fee: BigNumber(0), fee: null,
tag: undefined, tag: undefined,
}), }),
@ -463,10 +494,30 @@ const RippleJSBridge: WalletBridge<Transaction> = {
getTransactionAmount: (a, t) => t.amount, getTransactionAmount: (a, t) => t.amount,
editTransactionRecipient: (account, t, recipient) => ({ editTransactionRecipient: (account, t, recipient) => {
...t, const parts = recipient.split('?')
recipient, 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, EditFees,
@ -474,27 +525,38 @@ const RippleJSBridge: WalletBridge<Transaction> = {
getTransactionRecipient: (a, t) => t.recipient, getTransactionRecipient: (a, t) => t.recipient,
isValidTransaction: (a, t) => (!t.amount.isZero() && t.recipient && true) || false, checkValidTransaction: async (a, t) => {
if (!t.fee) throw new FeeNotLoaded()
checkCanBeSpent: async (a, t) => {
const r = await getServerInfo(a.endpointConfig) 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 ( if (
t.amount t.amount
.plus(t.fee) .plus(t.fee || 0)
.plus(parseAPIValue(r.validatedLedger.reserveBaseXRP)) .plus(reserveBaseXRP)
.isLessThanOrEqualTo(a.balance) .isLessThanOrEqualTo(a.balance)
) { ) {
return return true
} }
throw new NotEnoughBalance() 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) => signAndBroadcast: (a, t, deviceId) =>
Observable.create(o => { Observable.create(o => {
delete cacheRecipientsNew[t.recipient]
let cancelled = false let cancelled = false
const isCancelled = () => cancelled const isCancelled = () => cancelled
const onSigned = () => { const onSigned = () => {

4
src/bridge/UnsupportedBridge.js

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

8
src/bridge/makeMockBridge.js

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

7
src/bridge/types.js

@ -76,15 +76,16 @@ export interface WalletBridge<Transaction> {
getTransactionRecipient(account: Account, transaction: Transaction): string; getTransactionRecipient(account: Account, transaction: Transaction): string;
isValidTransaction(account: Account, transaction: Transaction): boolean;
// render the whole Fees section of the form // render the whole Fees section of the form
EditFees?: *; // React$ComponentType<EditProps<Transaction>>; EditFees?: *; // React$ComponentType<EditProps<Transaction>>;
// render the whole advanced part of the form // render the whole advanced part of the form
EditAdvancedOptions?: *; // React$ComponentType<EditProps<Transaction>>; EditAdvancedOptions?: *; // React$ComponentType<EditProps<Transaction>>;
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>; 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 installMcu from 'commands/installMcu'
import installOsuFirmware from 'commands/installOsuFirmware' import installOsuFirmware from 'commands/installOsuFirmware'
import isDashboardOpen from 'commands/isDashboardOpen' import isDashboardOpen from 'commands/isDashboardOpen'
import killInternalProcess from 'commands/killInternalProcess'
import libcoreGetFees from 'commands/libcoreGetFees' import libcoreGetFees from 'commands/libcoreGetFees'
import libcoreGetVersion from 'commands/libcoreGetVersion' import libcoreGetVersion from 'commands/libcoreGetVersion'
import libcoreScanAccounts from 'commands/libcoreScanAccounts' import libcoreScanAccounts from 'commands/libcoreScanAccounts'
@ -47,6 +48,7 @@ const all: Array<Command<any, any>> = [
installMcu, installMcu,
installOsuFirmware, installOsuFirmware,
isDashboardOpen, isDashboardOpen,
killInternalProcess,
libcoreGetFees, libcoreGetFees,
libcoreGetVersion, libcoreGetVersion,
libcoreScanAccounts, 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 Box from 'components/base/Box'
import Input from 'components/base/Input' import Input from 'components/base/Input'
import Label from 'components/base/Label' import Label from 'components/base/Label'
import Spoiler from 'components/base/Spoiler'
type Props = { type Props = {
tag: ?number, tag: ?number,
@ -31,18 +30,14 @@ class RippleKind extends Component<Props> {
render() { render() {
const { tag, t } = this.props const { tag, t } = this.props
return ( return (
<Spoiler title={t('app:send.steps.amount.advancedOptions')}> <Box vertical flow={5}>
<Box horizontal align="center" flow={5}> <Box grow>
<Box style={{ width: 200 }}> <Label>
<Label> <span>{t('app:send.steps.amount.rippleTag')}</span>
<span>{t('app:send.steps.amount.rippleTag')}</span> </Label>
</Label> <Input value={String(tag || '')} onChange={this.onChange} />
</Box>
<Box grow>
<Input value={String(tag || '')} onChange={this.onChange} />
</Box>
</Box> </Box>
</Spoiler> </Box>
) )
} }
} }

11
src/components/ExchangePage/index.js

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

30
src/components/FeesField/BitcoinKind.js

@ -8,6 +8,7 @@ import { translate } from 'react-i18next'
import type { T } from 'types/common' import type { T } from 'types/common'
import { FeeNotLoaded } from 'config/errors'
import InputCurrency from 'components/base/InputCurrency' import InputCurrency from 'components/base/InputCurrency'
import Select from 'components/base/Select' import Select from 'components/base/Select'
import type { Fees } from 'api/Fees' import type { Fees } from 'api/Fees'
@ -17,7 +18,7 @@ import Box from '../base/Box'
type Props = { type Props = {
account: Account, account: Account,
feePerByte: BigNumber, feePerByte: ?BigNumber,
onChange: BigNumber => void, onChange: BigNumber => void,
t: T, t: T,
} }
@ -50,6 +51,12 @@ const customItem = {
blockCount: 0, blockCount: 0,
feePerByte: BigNumber(0), feePerByte: BigNumber(0),
} }
const notLoadedItem = {
label: 'Standard',
value: 'standard',
blockCount: 0,
feePerByte: BigNumber(0),
}
type State = { isFocused: boolean, items: FeeItem[], selectedItem: FeeItem } 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> { class FeesField extends Component<OwnProps, State> {
state = { state = {
items: [customItem], items: [notLoadedItem],
selectedItem: customItem, selectedItem: notLoadedItem,
isFocused: false, isFocused: false,
} }
static getDerivedStateFromProps(nextProps, prevState) { static getDerivedStateFromProps(nextProps, prevState) {
const { fees, feePerByte } = nextProps const { fees, feePerByte, error } = nextProps
let items: FeeItem[] = [] let items: FeeItem[] = []
if (fees) { if (fees) {
for (const key of Object.keys(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 = items.sort((a, b) => a.blockCount - b.blockCount)
} }
items.push(customItem) items.push(!feePerByte && !error ? notLoadedItem : customItem)
const selectedItem = prevState.selectedItem.feePerByte.eq(feePerByte) const selectedItem =
? prevState.selectedItem !feePerByte && prevState.selectedItem.feePerByte.eq(feePerByte)
: items.find(f => f.feePerByte.eq(feePerByte)) || items[items.length - 1] ? prevState.selectedItem
: items.find(f => f.feePerByte.eq(feePerByte)) || items[items.length - 1]
return { items, selectedItem } return { items, selectedItem }
} }
componentDidUpdate({ fees: prevFees }: OwnProps) { componentDidUpdate({ fees: prevFees }: OwnProps) {
const { feePerByte, fees, onChange } = this.props const { feePerByte, fees, onChange } = this.props
const { items, isFocused } = this.state const { items, isFocused } = this.state
if (fees && fees !== prevFees && feePerByte.isZero() && !isFocused) { if (fees && fees !== prevFees && !feePerByte && !isFocused) {
// initialize with the median // initialize with the median
const feePerByte = (items.find(item => item.blockCount === defaultBlockCount) || items[0]) const feePerByte = (items.find(item => item.blockCount === defaultBlockCount) || items[0])
.feePerByte .feePerByte
@ -127,7 +135,7 @@ class FeesField extends Component<OwnProps, State> {
const satoshi = units[units.length - 1] const satoshi = units[units.length - 1]
return ( return (
<GenericContainer error={error}> <GenericContainer>
<Select width={156} options={items} value={selectedItem} onChange={this.onSelectChange} /> <Select width={156} options={items} value={selectedItem} onChange={this.onSelectChange} />
<InputCurrency <InputCurrency
ref={this.input} ref={this.input}
@ -137,6 +145,8 @@ class FeesField extends Component<OwnProps, State> {
value={feePerByte} value={feePerByte}
onChange={onChange} onChange={onChange}
onChangeFocus={this.onChangeFocus} onChangeFocus={this.onChangeFocus}
loading={!feePerByte && !error}
error={!feePerByte && error ? new FeeNotLoaded() : null}
renderRight={ renderRight={
<InputRight> <InputRight>
{t('app:send.steps.amount.unitPerByte', { unit: satoshi.code })} {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 { BigNumber } from 'bignumber.js'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import { FeeNotLoaded } from 'config/errors'
import InputCurrency from 'components/base/InputCurrency' import InputCurrency from 'components/base/InputCurrency'
import type { Fees } from 'api/Fees' import type { Fees } from 'api/Fees'
import WithFeesAPI from '../WithFeesAPI' import WithFeesAPI from '../WithFeesAPI'
@ -11,7 +12,7 @@ import GenericContainer from './GenericContainer'
type Props = { type Props = {
account: Account, account: Account,
gasPrice: BigNumber, gasPrice: ?BigNumber,
onChange: BigNumber => void, onChange: BigNumber => void,
} }
@ -22,7 +23,7 @@ class FeesField extends Component<Props & { fees?: Fees, error?: Error }, *> {
componentDidUpdate() { componentDidUpdate() {
const { gasPrice, fees, onChange } = this.props const { gasPrice, fees, onChange } = this.props
const { isFocused } = this.state 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 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 { account, gasPrice, error, onChange } = this.props
const { units } = account.currency const { units } = account.currency
return ( return (
<GenericContainer error={error}> <GenericContainer>
<InputCurrency <InputCurrency
defaultUnit={units.length > 1 ? units[1] : units[0]} defaultUnit={units.length > 1 ? units[1] : units[0]}
units={units} units={units}
containerProps={{ grow: true }} containerProps={{ grow: true }}
value={gasPrice} value={gasPrice}
loading={!error && !gasPrice}
error={!gasPrice && error ? new FeeNotLoaded() : null}
onChange={onChange} onChange={onChange}
onChangeFocus={this.onChangeFocus} 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 { BigNumber } from 'bignumber.js'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import { apiForEndpointConfig, parseAPIValue } from 'api/Ripple' import { apiForEndpointConfig, parseAPIValue } from 'api/Ripple'
import { FeeNotLoaded } from 'config/errors'
import InputCurrency from 'components/base/InputCurrency' import InputCurrency from 'components/base/InputCurrency'
import GenericContainer from './GenericContainer' import GenericContainer from './GenericContainer'
type Props = { type Props = {
account: Account, account: Account,
fee: BigNumber, fee: ?BigNumber,
onChange: BigNumber => void, onChange: BigNumber => void,
} }
@ -36,7 +37,7 @@ class FeesField extends Component<Props, State> {
const info = await api.getServerInfo() const info = await api.getServerInfo()
if (syncId !== this.syncId) return if (syncId !== this.syncId) return
const serverFee = parseAPIValue(info.validatedLedger.baseFeeXRP) const serverFee = parseAPIValue(info.validatedLedger.baseFeeXRP)
if (this.props.fee.isZero()) { if (!this.props.fee) {
this.props.onChange(serverFee) this.props.onChange(serverFee)
} }
} catch (error) { } catch (error) {
@ -50,11 +51,13 @@ class FeesField extends Component<Props, State> {
const { error } = this.state const { error } = this.state
const { units } = account.currency const { units } = account.currency
return ( return (
<GenericContainer error={error}> <GenericContainer>
<InputCurrency <InputCurrency
defaultUnit={units[0]} defaultUnit={units[0]}
units={units} units={units}
containerProps={{ grow: true }} containerProps={{ grow: true }}
loading={!error && !fee}
error={!fee && error ? new FeeNotLoaded() : null}
value={fee} value={fee}
onChange={onChange} onChange={onChange}
/> />

62
src/components/ManagerPage/AppsList.js

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

7
src/components/ManagerPage/index.js

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

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

@ -26,7 +26,7 @@ type State = {
} }
const INITIAL_STATE = { const INITIAL_STATE = {
analyticsToggle: false, analyticsToggle: true,
sentryLogsToggle: 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< class QRCodeExporter extends PureComponent<
{ {
chunks: string[], chunks: string[],
fps: number,
size: number, size: number,
}, },
{ {
frame: number, frame: number,
fps: number,
}, },
> { > {
static defaultProps = { static defaultProps = {
fps: 4, size: 440,
size: 480,
} }
state = { state = {
frame: 0, frame: 0,
fps: HIGH_FPS,
} }
componentDidMount() { componentDidMount() {
const nextFrame = ({ frame }, { chunks }) => ({ console.log(`BRIDGESTREAM_DATA=${btoa(JSON.stringify(this.props.chunks))}`) // eslint-disable-line
frame: (frame + 1) % chunks.length,
}) 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 let lastT
const loop = t => { const loop = t => {
this._raf = requestAnimationFrame(loop) this._raf = requestAnimationFrame(loop)
if (!lastT) lastT = t if (!lastT) lastT = t
if ((t - lastT) * this.props.fps < 1000) return if ((t - lastT) * this.state.fps < 1000) return
lastT = t lastT = t
this.setState(nextFrame) this.setState(nextFrame)
} }
@ -64,7 +74,7 @@ class QRCodeExporter extends PureComponent<
<div style={{ position: 'relative', width: size, height: size }}> <div style={{ position: 'relative', width: size, height: size }}>
{chunks.map((chunk, i) => ( {chunks.map((chunk, i) => (
<div key={String(i)} style={{ position: 'absolute', opacity: i === frame ? 1 : 0 }}> <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>
))} ))}
</div> </div>

8
src/components/RequestAmount/index.js

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

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

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

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

@ -4,9 +4,8 @@ import React, { PureComponent } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { fontSize, space } from 'styled-system' import { fontSize, space } from 'styled-system'
import noop from 'lodash/noop' import noop from 'lodash/noop'
import fontFamily from 'styles/styled/fontFamily' import fontFamily from 'styles/styled/fontFamily'
import Spinner from 'components/base/Spinner'
import Box from 'components/base/Box' import Box from 'components/base/Box'
import TranslatedError from 'components/TranslatedError' import TranslatedError from 'components/TranslatedError'
@ -44,6 +43,19 @@ const ErrorDisplay = styled(Box)`
color: ${p => p.theme.colors.pearl}; 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)` const WarningDisplay = styled(ErrorDisplay)`
color: ${p => p.theme.colors.warning}; color: ${p => p.theme.colors.warning};
` `
@ -98,6 +110,7 @@ type Props = {
renderLeft?: any, renderLeft?: any,
renderRight?: any, renderRight?: any,
containerProps?: Object, containerProps?: Object,
loading?: boolean,
error?: ?Error | boolean, error?: ?Error | boolean,
warning?: ?Error | boolean, warning?: ?Error | boolean,
small?: boolean, small?: boolean,
@ -182,6 +195,7 @@ class Input extends PureComponent<Props, State> {
editInPlace, editInPlace,
small, small,
error, error,
loading,
warning, warning,
...props ...props
} = this.props } = this.props
@ -217,6 +231,11 @@ class Input extends PureComponent<Props, State> {
<TranslatedError error={warning} /> <TranslatedError error={warning} />
</WarningDisplay> </WarningDisplay>
) : null} ) : null}
{loading && !isFocus ? (
<LoadingDisplay>
<Spinner size={16} />
</LoadingDisplay>
) : null}
</Box> </Box>
{renderRight} {renderRight}
</Container> </Container>

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

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

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

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

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

@ -5,12 +5,14 @@ import qrcode from 'qrcode'
type Props = { type Props = {
data: string, data: string,
errorCorrectionLevel: string,
size: number, size: number,
} }
class QRCode extends PureComponent<Props> { class QRCode extends PureComponent<Props> {
static defaultProps = { static defaultProps = {
size: 200, size: 200,
errorCorrectionLevel: 'Q',
} }
componentDidMount() { componentDidMount() {
@ -24,11 +26,11 @@ class QRCode extends PureComponent<Props> {
_canvas = null _canvas = null
drawQRCode() { drawQRCode() {
const { data, size } = this.props const { data, size, errorCorrectionLevel } = this.props
qrcode.toCanvas(this._canvas, data, { qrcode.toCanvas(this._canvas, data, {
width: size, width: size,
margin: 0, margin: 0,
errorCorrectionLevel: 'Q', errorCorrectionLevel,
color: { color: {
light: '#ffffff00', // transparent background 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 Label from 'components/base/Label'
import RequestAmount from 'components/RequestAmount' 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 = { state = {
canBeSpentError: null, validTransactionError: null,
} }
componentDidMount() { componentDidMount() {
this.resync() this.resync()
@ -27,11 +30,11 @@ class AmountField extends Component<*, { canBeSpentError: ?Error }> {
const { account, bridge, transaction } = this.props const { account, bridge, transaction } = this.props
const syncId = ++this.syncId const syncId = ++this.syncId
try { try {
await bridge.checkCanBeSpent(account, transaction) await bridge.checkValidTransaction(account, transaction)
if (this.syncId !== syncId) return if (this.syncId !== syncId) return
this.setState({ canBeSpentError: null }) this.setState({ validTransactionError: null })
} catch (canBeSpentError) { } catch (validTransactionError) {
this.setState({ canBeSpentError }) this.setState({ validTransactionError })
} }
} }
@ -42,14 +45,18 @@ class AmountField extends Component<*, { canBeSpentError: ?Error }> {
render() { render() {
const { bridge, account, transaction, t } = this.props const { bridge, account, transaction, t } = this.props
const { canBeSpentError } = this.state const { validTransactionError } = this.state
return ( return (
<Box flow={1}> <Box flow={1}>
<Label>{t('app:send.steps.amount.amount')}</Label> <Label>{t('app:send.steps.amount.amount')}</Label>
<RequestAmount <RequestAmount
withMax={false} withMax={false}
account={account} account={account}
canBeSpentError={canBeSpentError} validTransactionError={
validTransactionError && blacklistErrorName.includes(validTransactionError.name)
? null
: validTransactionError
}
onChange={this.onChange} onChange={this.onChange}
value={bridge.getTransactionAmount(account, transaction)} 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), bridge.getTransactionRecipient(account, transaction),
) )
if (syncId !== this.syncId) return if (syncId !== this.syncId) return
const canBeSpent = await bridge const isValidTransaction = await bridge
.checkCanBeSpent(account, transaction) .checkValidTransaction(account, transaction)
.then(() => true, () => false) .then(result => result, () => false)
if (syncId !== this.syncId) return 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 }) this.setState({ totalSpent, canNext, isSyncing: false })
} catch (err) { } catch (err) {
logger.critical(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 NetworkDown = createCustomErrorClass('NetworkDown')
export const NoAddressesFound = createCustomErrorClass('NoAddressesFound') export const NoAddressesFound = createCustomErrorClass('NoAddressesFound')
export const NotEnoughBalance = createCustomErrorClass('NotEnoughBalance') export const NotEnoughBalance = createCustomErrorClass('NotEnoughBalance')
export const NotEnoughBalanceBecauseDestinationNotCreated = createCustomErrorClass(
'NotEnoughBalanceBecauseDestinationNotCreated',
)
export const PasswordsDontMatchError = createCustomErrorClass('PasswordsDontMatch') export const PasswordsDontMatchError = createCustomErrorClass('PasswordsDontMatch')
export const PasswordIncorrectError = createCustomErrorClass('PasswordIncorrect') export const PasswordIncorrectError = createCustomErrorClass('PasswordIncorrect')
export const TimeoutTagged = createCustomErrorClass('TimeoutTagged') export const TimeoutTagged = createCustomErrorClass('TimeoutTagged')
@ -42,6 +45,7 @@ export const WebsocketConnectionFailed = createCustomErrorClass('WebsocketConnec
export const WrongDeviceForAccount = createCustomErrorClass('WrongDeviceForAccount') export const WrongDeviceForAccount = createCustomErrorClass('WrongDeviceForAccount')
export const ETHAddressNonEIP = createCustomErrorClass('ETHAddressNonEIP') export const ETHAddressNonEIP = createCustomErrorClass('ETHAddressNonEIP')
export const CantScanQRCode = createCustomErrorClass('CantScanQRCode') export const CantScanQRCode = createCustomErrorClass('CantScanQRCode')
export const FeeNotLoaded = createCustomErrorClass('FeeNotLoaded')
// db stuff, no need to translate // db stuff, no need to translate
export const NoDBPathGiven = createCustomErrorClass('NoDBPathGiven') 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', luno: 'http://luno.go2cloud.org/aff_c?offer_id=4&aff_id=1001&source=ledger',
shapeshift: 'https://shapeshift.io/#/coins?affiliate=ledger', shapeshift: 'https://shapeshift.io/#/coins?affiliate=ledger',
genesis: 'https://genesistrading.com/ledger-live/', genesis: 'https://genesistrading.com/ledger-live/',
kyber: 'http://kyber.network/swap?ref=0xE2D8481eeF31CDA994833974FFfEccd576f8D71E',
// Errors // Errors
errors: { errors: {

3
src/helpers/anonymizer.js

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

29
src/helpers/countervalues.js

@ -12,6 +12,8 @@ import {
intermediaryCurrency, intermediaryCurrency,
} from 'reducers/settings' } from 'reducers/settings'
import logger from 'logger' 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( const pairsSelector = createSelector(
currenciesSelector, currenciesSelector,
@ -52,6 +54,7 @@ const addExtraPollingHooks = (schedulePoll, cancelPoll) => {
} }
} }
// TODO we should be able to pass-in our network() function
const CounterValues = createCounterValues({ const CounterValues = createCounterValues({
log: (...args) => logger.log('CounterValues:', ...args), log: (...args) => logger.log('CounterValues:', ...args),
getAPIBaseURL: () => LEDGER_COUNTERVALUES_API, getAPIBaseURL: () => LEDGER_COUNTERVALUES_API,
@ -61,4 +64,30 @@ const CounterValues = createCounterValues({
addExtraPollingHooks, 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 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 { disable as disableDBMiddleware } from 'middlewares/db'
import db from 'helpers/db' import db from 'helpers/db'
import { delay } from 'helpers/promise' 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/') const dbpath = path.resolve(resolveUserDataDirectory(), 'sqlite/')
rimraf.sync(dbpath, { glob: false }) rimraf.sync(dbpath, { glob: false })
} }

8
src/logger/logger.js

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

2
src/main/app.js

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

2
src/reducers/settings.js

@ -70,7 +70,7 @@ const INITIAL_STATE: SettingsState = {
currenciesSettings: {}, currenciesSettings: {},
developerMode: !!process.env.__DEV__, developerMode: !!process.env.__DEV__,
loaded: false, loaded: false,
shareAnalytics: false, shareAnalytics: true,
sentryLogs: true, sentryLogs: true,
lastUsedVersion: __APP_VERSION__, 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.", "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.", "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.", "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.", "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": { "genuinecheck": {
"modal": { "modal": {

6
static/i18n/en/errors.json

@ -99,6 +99,9 @@
"title": "Oops, insufficient balance", "title": "Oops, insufficient balance",
"description": "Make sure the account to debit has sufficient 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": { "PasswordsDontMatch": {
"title": "Passwords don't match", "title": "Passwords don't match",
"description": "Please try again" "description": "Please try again"
@ -172,5 +175,8 @@
}, },
"CantScanQRCode": { "CantScanQRCode": {
"title": "Couldn't scan this QR-code: auto-verification not supported by this address" "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 { Application } from 'spectron'
import { waitForDisappear, waitForExpectedText } from '../test-e2e/helpers/test_helpers' import { waitForDisappear, waitForExpectedText } from './helpers'
const os = require('os') const os = require('os')
const appVersion = require('../package.json') const appVersion = require('../package.json')
@ -14,7 +14,7 @@ const platform = os.platform()
if (platform === 'darwin') { if (platform === 'darwin') {
app_path = `./dist/mac/Ledger Live.app/Contents/MacOS/Ledger Live` app_path = `./dist/mac/Ledger Live.app/Contents/MacOS/Ledger Live`
} else if (platform === 'win32') { } else if (platform === 'win32') {
app_path = `./dist\\win-unpacked\\Ledger Live.exe` app_path = `.\\dist\\win-unpacked\\Ledger Live.exe`
} else { } else {
app_path = `./dist/ledger-live-desktop-${appVersion.version}-linux-x86_64.AppImage` 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" npm "^5.7.1"
prebuild-install "^2.2.2" prebuild-install "^2.2.2"
"@ledgerhq/live-common@^3.5.1": "@ledgerhq/live-common@^3.7.1":
version "3.5.1" version "3.7.1"
resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-3.5.1.tgz#dab3eb061f361999a9e04ef564808831faac61ea" resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-3.7.1.tgz#5ce1895920d2eae6c454c2c72612dc9afd11adec"
dependencies: dependencies:
axios "^0.18.0" axios "^0.18.0"
bignumber.js "^7.2.1" bignumber.js "^7.2.1"

Loading…
Cancel
Save