Browse Source

Merge branch 'develop' into issue-1444

gre-patch-1
Gaëtan Renaudeau 6 years ago
parent
commit
cae935b9c1
No known key found for this signature in database GPG Key ID: 7B66B85F042E5451
  1. 4
      .circleci/config.yml
  2. 2
      .gitignore
  3. 10
      package.json
  4. 85
      scripts/create-draft-release.js
  5. 8
      scripts/helpers/display-env.sh
  6. 14
      scripts/release.sh
  7. 4
      src/api/network.js
  8. 6
      src/bridge/EthereumJSBridge.js
  9. 23
      src/bridge/LibcoreBridge.js
  10. 6
      src/bridge/RippleJSBridge.js
  11. 4
      src/bridge/UnsupportedBridge.js
  12. 1
      src/bridge/index.js
  13. 8
      src/bridge/makeMockBridge.js
  14. 7
      src/bridge/types.js
  15. 4
      src/commands/index.js
  16. 32
      src/commands/libcoreGetFees.js
  17. 19
      src/commands/libcoreHardReset.js
  18. 29
      src/commands/libcoreScanFromXPUB.js
  19. 30
      src/commands/libcoreSignAndBroadcast.js
  20. 2
      src/commands/libcoreSyncAccount.js
  21. 50
      src/components/AdvancedOptions/RippleKind.js
  22. 39
      src/components/CurrentAddress/index.js
  23. 2
      src/components/DashboardPage/AccountCard/index.js
  24. 1
      src/components/DashboardPage/AccountCardList.js
  25. 2
      src/components/DashboardPage/AccountCardListHeader.js
  26. 2
      src/components/DashboardPage/AccountCardPlaceholder.js
  27. 2
      src/components/DashboardPage/CurrentGreetings.js
  28. 7
      src/components/DashboardPage/SummaryDesc.js
  29. 207
      src/components/DevToolsPage/AccountImporter.js
  30. 11
      src/components/DevToolsPage/index.js
  31. 6
      src/components/EnsureDeviceApp.js
  32. 2
      src/components/IsUnlocked.js
  33. 2
      src/components/MainSideBar/AddAccountButton.js
  34. 35
      src/components/MainSideBar/index.js
  35. 5
      src/components/ManagerPage/AppsList.js
  36. 26
      src/components/ManagerPage/ManagerApp.js
  37. 14
      src/components/Onboarding/steps/Analytics.js
  38. 2
      src/components/OperationsList/index.js
  39. 56
      src/components/QRCodeExporter.js
  40. 3
      src/components/RecipientAddress/index.js
  41. 2
      src/components/RenderError.js
  42. 8
      src/components/RequestAmount/index.js
  43. 1
      src/components/SelectCurrency/index.js
  44. 20
      src/components/SettingsPage/CleanButton.js
  45. 4
      src/components/SettingsPage/DisablePasswordModal.js
  46. 6
      src/components/SettingsPage/PasswordButton.js
  47. 2
      src/components/SettingsPage/PasswordForm.js
  48. 20
      src/components/SettingsPage/PasswordModal.js
  49. 2
      src/components/SettingsPage/ResetButton.js
  50. 2
      src/components/SettingsPage/SentryLogsButton.js
  51. 2
      src/components/SettingsPage/SettingsSection.js
  52. 6
      src/components/SettingsPage/ShareAnalyticsButton.js
  53. 4
      src/components/SettingsPage/sections/Tools.js
  54. 1
      src/components/TopBar/ActivityIndicator.js
  55. 2
      src/components/base/Input/index.js
  56. 6
      src/components/base/InputCurrency/index.js
  57. 2
      src/components/base/Modal/ModalTitle.js
  58. 5
      src/components/base/QRCode/index.js
  59. 6
      src/components/base/Select/createStyles.js
  60. 6
      src/components/base/Select/index.js
  61. 2
      src/components/layout/Default.js
  62. 2
      src/components/modals/Disclaimer.js
  63. 16
      src/components/modals/Send/fields/AmountField.js
  64. 10
      src/components/modals/Send/steps/01-step-amount.js
  65. 6
      src/components/modals/ShareAnalytics.js
  66. 4
      src/components/modals/TechnicalData.js
  67. 2
      src/config/constants.js
  68. 9
      src/config/cryptocurrencies.js
  69. 1
      src/config/errors.js
  70. 2
      src/helpers/errors.js
  71. 13
      src/helpers/getAddressForCurrency/btc.js
  72. 13
      src/helpers/hardReset.js
  73. 139
      src/helpers/libcore.js
  74. 35
      src/helpers/reset.js
  75. 67
      src/internals/index.js
  76. 11
      src/logger/logger.js
  77. 7
      static/i18n/en/app.json
  78. 14
      static/i18n/en/errors.json
  79. 15
      test-e2e/README.md
  80. 119
      test-e2e/enable-dev-mode.spec.js
  81. 40
      test-e2e/helpers.js
  82. 64
      test-e2e/nav_to_settings.spec.js
  83. 1
      test-e2e/sync/data/empty-app.json
  84. 1
      test-e2e/sync/data/expected-app.json
  85. 47
      test-e2e/sync/launch.sh
  86. 66
      test-e2e/sync/sync-accounts.spec.js
  87. 48
      test-e2e/sync/wait-sync.js
  88. 63
      yarn.lock

4
.circleci/config.yml

@ -13,10 +13,10 @@ jobs:
- checkout
- restore_cache:
keys:
- v8-yarn-packages-{{ checksum "yarn.lock" }}
- v10-yarn-packages-{{ checksum "yarn.lock" }}
- run: yarn install
- save_cache:
key: v8-yarn-packages-{{ checksum "yarn.lock" }}
key: v10-yarn-packages-{{ checksum "yarn.lock" }}
paths:
- node_modules
- run: yarn lint

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

10
package.json

@ -3,7 +3,7 @@
"productName": "Ledger Live",
"description": "Ledger Live - Desktop",
"repository": "https://github.com/LedgerHQ/ledger-live-desktop",
"version": "1.1.7",
"version": "1.1.11",
"author": "Ledger",
"license": "MIT",
"scripts": {
@ -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",
@ -38,8 +39,8 @@
"@ledgerhq/hw-app-xrp": "^4.13.0",
"@ledgerhq/hw-transport": "^4.13.0",
"@ledgerhq/hw-transport-node-hid": "4.22.0",
"@ledgerhq/ledger-core": "2.0.0-rc.6",
"@ledgerhq/live-common": "3.0.2",
"@ledgerhq/ledger-core": "2.0.0-rc.7",
"@ledgerhq/live-common": "^3.5.1",
"animated": "^0.2.2",
"async": "^2.6.1",
"axios": "^0.18.0",
@ -119,6 +120,7 @@
"@babel/preset-flow": "7.0.0-beta.42",
"@babel/preset-react": "7.0.0-beta.42",
"@babel/preset-stage-0": "7.0.0-beta.42",
"@octokit/rest": "^15.10.0",
"@storybook/addon-actions": "^3.4.7",
"@storybook/addon-knobs": "^3.4.7",
"@storybook/addon-links": "^3.4.7",
@ -135,7 +137,7 @@
"chance": "^1.0.13",
"concurrently": "3.5.1",
"dotenv": "^5.0.1",
"electron": "1.8.7",
"electron": "1.8.8",
"electron-builder": "20.14.7",
"electron-devtools-installer": "^2.2.3",
"electron-rebuild": "^1.7.3",

85
scripts/create-draft-release.js

@ -0,0 +1,85 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const util = require('util')
const exec = util.promisify(require('child_process').exec)
const octokit = require('@octokit/rest')()
const repo = {
owner: 'LedgerHQ',
repo: 'ledger-live-desktop',
}
async function getTag() {
const { stdout } = await exec('git tag --points-at HEAD')
const tag = stdout.replace('\n', '')
if (!tag) {
throw new Error(`Unable to get current tag. Is your HEAD on a tagged commit?`)
}
return tag
}
async function checkDraft(tag) {
const { status, data } = await octokit.repos.getReleases(repo)
if (status !== 200) {
throw new Error(`Got HTTP status ${status} when trying to fetch releases list.`)
}
for (const release of data) {
if (release.tag_name === tag) {
if (release.draft) {
return true
}
throw new Error(`A release tagged ${tag} exists but is not a draft.`)
}
}
return false
}
async function createDraft(tag) {
const params = {
...repo,
tag_name: tag,
name: tag,
draft: true,
prerelease: true,
}
const { status } = await octokit.repos.createRelease(params)
if (status !== 201) {
throw new Error(`Got HTTP status ${status} when trying to create the release draft.`)
}
}
async function main() {
try {
const token = process.env.GH_TOKEN
const tag = await getTag()
octokit.authenticate({
type: 'token',
token,
})
const existingDraft = await checkDraft(tag)
if (!existingDraft) {
console.log(`No draft exists for ${tag}, creating...`)
createDraft(tag)
} else {
console.log(`A draft already exists for ${tag}, nothing to do.`)
}
} catch (e) {
console.error(e)
process.exit(1)
}
}
main()

8
scripts/helpers/display-env.sh

@ -7,9 +7,15 @@ if [ "$GIT_REVISION" == "" ]; then
GIT_REVISION=$(git rev-parse HEAD)
fi
if [[ $(uname) == 'Darwin' ]]; then
osVersion="$(sw_vers -productName) $(sw_vers -productVersion)"
else
osVersion="$(uname -srmo)"
fi
echo
printf " │ \\e[4;1m%s\\e[0;0m\\n" "Ledger Live Desktop - ${GIT_REVISION}"
printf " │ \\e[1;30m%s\\e[1;0m\\n" "$(uname -srmo)"
printf " │ \\e[1;30m%s\\e[1;0m\\n" "${osVersion}"
printf " │ \\e[2;1mcommit \\e[0;33m%s\\e[0;0m\\n" "$(git rev-parse HEAD)"
echo

14
scripts/release.sh

@ -25,6 +25,11 @@ fi
if [ ! -d "static/fonts/museosans" ]; then
if ! command -v aws ; then
if ! command -v apt ; then
echo "Museo Sans is missing, and I can't fetch it (no aws, no apt)" >&2
exit 1
fi
runJob "sudo apt install awscli" "installing aws cli..." "installed aws cli" "failed to install aws cli"
fi
@ -52,6 +57,15 @@ fi
# exit 1
# fi
if [[ $(uname) == 'Linux' ]]; then # only run it on one target, to prevent race conditions
runJob \
"node scripts/create-draft-release.js" \
"creating a draft release on GitHub (if needed)..." \
"draft release ready" \
"failed to create a draft release"
fi
runJob "yarn compile" "compiling..." "compiled" "failed to compile" "verbose"
runJob \

4
src/api/network.js

@ -6,7 +6,7 @@ import logger from 'logger'
import { LedgerAPIErrorWithMessage, LedgerAPIError, NetworkDown } from 'config/errors'
import anonymizer from 'helpers/anonymizer'
const userFriendlyError = <A>(p: Promise<A>, { url, method, startTime }): Promise<A> =>
const userFriendlyError = <A>(p: Promise<A>, { url, method, startTime, ...rest }): Promise<A> =>
p.catch(error => {
let errorToThrow
if (error.response) {
@ -47,6 +47,7 @@ const userFriendlyError = <A>(p: Promise<A>, { url, method, startTime }): Promis
})
}
logger.networkError({
...rest,
status,
url,
method,
@ -80,6 +81,7 @@ let implementation = (arg: Object) => {
const meta = {
url: arg.url,
method: arg.method,
data: arg.data,
startTime: Date.now(),
}
logger.network(meta)

6
src/bridge/EthereumJSBridge.js

@ -420,15 +420,13 @@ const EthereumBridge: WalletBridge<Transaction> = {
getTransactionRecipient: (a, t) => t.recipient,
isValidTransaction: (a, t) => (!t.amount.isZero() && t.recipient && true) || false,
EditFees,
EditAdvancedOptions,
checkCanBeSpent: (a, t) =>
checkValidTransaction: (a, t) =>
t.amount.isLessThanOrEqualTo(a.balance)
? Promise.resolve()
? Promise.resolve(true)
: Promise.reject(new NotEnoughBalance()),
getTotalSpent: (a, t) =>

23
src/bridge/LibcoreBridge.js

@ -9,7 +9,7 @@ import FeesBitcoinKind from 'components/FeesField/BitcoinKind'
import libcoreScanAccounts from 'commands/libcoreScanAccounts'
import libcoreSyncAccount from 'commands/libcoreSyncAccount'
import libcoreSignAndBroadcast from 'commands/libcoreSignAndBroadcast'
import libcoreGetFees from 'commands/libcoreGetFees'
import libcoreGetFees, { extractGetFeesInputFromAccount } from 'commands/libcoreGetFees'
import libcoreValidAddress from 'commands/libcoreValidAddress'
import { NotEnoughBalance } from 'config/errors'
import type { WalletBridge, EditProps } from './types'
@ -85,8 +85,7 @@ const getFees = async (a, transaction) => {
if (promise) return promise
promise = libcoreGetFees
.send({
accountId: a.id,
accountIndex: a.index,
...extractGetFeesInputFromAccount(a),
transaction: serializeTransaction(transaction),
})
.toPromise()
@ -95,11 +94,11 @@ const getFees = async (a, transaction) => {
return promise
}
const checkCanBeSpent = (a, t) =>
const checkValidTransaction = (a, t) =>
!t.amount
? Promise.resolve()
? Promise.resolve(true)
: getFees(a, t)
.then(() => {})
.then(() => true)
.catch(e => {
if (e.code === NOT_ENOUGH_FUNDS) {
throw new NotEnoughBalance()
@ -127,8 +126,8 @@ const LibcoreBridge: WalletBridge<Transaction> = {
currencyId: account.currency.id,
})
.pipe(
map(rawSyncedAccount => {
const syncedAccount = decodeAccount(rawSyncedAccount)
map(({ rawAccount, requiresCacheFlush }) => {
const syncedAccount = decodeAccount(rawAccount)
return account => {
const accountOps = account.operations
const syncedOps = syncedAccount.operations
@ -142,11 +141,11 @@ const LibcoreBridge: WalletBridge<Transaction> = {
}
const hasChanged =
requiresCacheFlush ||
accountOps.length !== syncedOps.length || // size change, we do a full refresh for now...
(accountOps.length > 0 &&
syncedOps.length > 0 &&
(accountOps[0].accountId !== syncedOps[0].accountId ||
accountOps[0].id !== syncedOps[0].id || // if same size, only check if the last item has changed.
(accountOps[0].id !== syncedOps[0].id || // if same size, only check if the last item has changed.
accountOps[0].blockHeight !== syncedOps[0].blockHeight))
if (hasChanged) {
@ -192,9 +191,7 @@ const LibcoreBridge: WalletBridge<Transaction> = {
// EditAdvancedOptions,
isValidTransaction: (a, t) => (!t.amount.isZero() && t.recipient && true) || false,
checkCanBeSpent,
checkValidTransaction,
getTotalSpent: (a, t) =>
t.amount.isZero()

6
src/bridge/RippleJSBridge.js

@ -494,9 +494,7 @@ 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) => {
const r = await getServerInfo(a.endpointConfig)
if (
t.amount
@ -504,7 +502,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
.plus(parseAPIValue(r.validatedLedger.reserveBaseXRP))
.isLessThanOrEqualTo(a.balance)
) {
return
return true
}
throw new NotEnoughBalance()
},

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)),

1
src/bridge/index.js

@ -12,6 +12,7 @@ const perFamily = {
bitcoin: LibcoreBridge,
ripple: RippleJSBridge,
ethereum: EthereumJSBridge,
stellar: null,
}
if (USE_MOCK_DATA) {
const mockBridge = makeMockBridge()

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>;

4
src/commands/index.js

@ -17,8 +17,8 @@ import installOsuFirmware from 'commands/installOsuFirmware'
import isDashboardOpen from 'commands/isDashboardOpen'
import libcoreGetFees from 'commands/libcoreGetFees'
import libcoreGetVersion from 'commands/libcoreGetVersion'
import libcoreHardReset from 'commands/libcoreHardReset'
import libcoreScanAccounts from 'commands/libcoreScanAccounts'
import libcoreScanFromXPUB from 'commands/libcoreScanFromXPUB'
import libcoreSignAndBroadcast from 'commands/libcoreSignAndBroadcast'
import libcoreSyncAccount from 'commands/libcoreSyncAccount'
import libcoreValidAddress from 'commands/libcoreValidAddress'
@ -49,8 +49,8 @@ const all: Array<Command<any, any>> = [
isDashboardOpen,
libcoreGetFees,
libcoreGetVersion,
libcoreHardReset,
libcoreScanAccounts,
libcoreScanFromXPUB,
libcoreSignAndBroadcast,
libcoreSyncAccount,
libcoreValidAddress,

32
src/commands/libcoreGetFees.js

@ -4,9 +4,17 @@ import { Observable } from 'rxjs'
import { BigNumber } from 'bignumber.js'
import withLibcore from 'helpers/withLibcore'
import { createCommand, Command } from 'helpers/ipc'
import type { Account } from '@ledgerhq/live-common/lib/types'
import * as accountIdHelper from 'helpers/accountId'
import { isValidAddress, libcoreAmountToBigNumber, bigNumberToLibcoreAmount } from 'helpers/libcore'
import {
isValidAddress,
libcoreAmountToBigNumber,
bigNumberToLibcoreAmount,
getOrCreateWallet,
} from 'helpers/libcore'
import { isSegwitPath, isUnsplitPath } from 'helpers/bip32'
import { InvalidAddress } from 'config/errors'
import { splittedCurrencies } from 'config/cryptocurrencies'
type BitcoinLikeTransaction = {
// TODO we rename this Transaction concept into transactionInput
@ -19,20 +27,38 @@ type Input = {
accountId: string,
accountIndex: number,
transaction: BitcoinLikeTransaction,
currencyId: string,
isSegwit: boolean,
isUnsplit: boolean,
}
export const extractGetFeesInputFromAccount = (a: Account) => {
const currencyId = a.currency.id
return {
accountId: a.id,
accountIndex: a.index,
currencyId,
isSegwit: isSegwitPath(a.freshAddressPath),
isUnsplit: isUnsplitPath(a.freshAddressPath, splittedCurrencies[currencyId]),
}
}
type Result = { totalFees: string }
const cmd: Command<Input, Result> = createCommand(
'libcoreGetFees',
({ accountId, accountIndex, transaction }) =>
({ accountId, currencyId, isSegwit, isUnsplit, accountIndex, transaction }) =>
Observable.create(o => {
let unsubscribed = false
const isCancelled = () => unsubscribed
withLibcore(async core => {
const { walletName } = accountIdHelper.decode(accountId)
const njsWallet = await core.getPoolInstance().getWallet(walletName)
const njsWallet = await getOrCreateWallet(core, walletName, {
currencyId,
isSegwit,
isUnsplit,
})
if (isCancelled()) return
const njsAccount = await njsWallet.getAccount(accountIndex)
if (isCancelled()) return

19
src/commands/libcoreHardReset.js

@ -1,19 +0,0 @@
// @flow
import { createCommand } from 'helpers/ipc'
import { fromPromise } from 'rxjs/observable/fromPromise'
import withLibcore from 'helpers/withLibcore'
import { HardResetFail } from 'config/errors'
const cmd = createCommand('libcoreHardReset', () =>
fromPromise(
withLibcore(async core => {
const result = await core.getPoolInstance().eraseDataSince(new Date(0))
if (result !== core.ERROR_CODE.FUTURE_WAS_SUCCESSFULL) {
throw new HardResetFail(`Hard reset fail with ${result} (check core.ERROR_CODE)`)
}
}),
),
)
export default cmd

29
src/commands/libcoreScanFromXPUB.js

@ -0,0 +1,29 @@
// @flow
import { fromPromise } from 'rxjs/observable/fromPromise'
import type { AccountRaw } from '@ledgerhq/live-common/lib/types'
import { createCommand, Command } from 'helpers/ipc'
import withLibcore from 'helpers/withLibcore'
import { scanAccountsFromXPUB } from 'helpers/libcore'
type Input = {
currencyId: string,
xpub: string,
isSegwit: boolean,
isUnsplit: boolean,
}
type Result = AccountRaw
const cmd: Command<Input, Result> = createCommand(
'libcoreScanFromXPUB',
({ currencyId, xpub, isSegwit, isUnsplit }) =>
fromPromise(
withLibcore(async core =>
scanAccountsFromXPUB({ core, currencyId, xpub, isSegwit, isUnsplit }),
),
),
)
export default cmd

30
src/commands/libcoreSignAndBroadcast.js

@ -6,8 +6,13 @@ import type { OperationRaw } from '@ledgerhq/live-common/lib/types'
import Btc from '@ledgerhq/hw-app-btc'
import { Observable } from 'rxjs'
import { getCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies'
import { isSegwitPath } from 'helpers/bip32'
import { libcoreAmountToBigNumber, bigNumberToLibcoreAmount } from 'helpers/libcore'
import { isSegwitPath, isUnsplitPath } from 'helpers/bip32'
import {
libcoreAmountToBigNumber,
bigNumberToLibcoreAmount,
getOrCreateWallet,
} from 'helpers/libcore'
import { splittedCurrencies } from 'config/cryptocurrencies'
import withLibcore from 'helpers/withLibcore'
import { createCommand, Command } from 'helpers/ipc'
@ -164,7 +169,6 @@ export async function doSignAndBroadcast({
accountId,
currencyId,
xpub,
freshAddress,
freshAddressPath,
index,
transaction,
@ -188,7 +192,10 @@ export async function doSignAndBroadcast({
onOperationBroadcasted: (optimisticOp: $Exact<OperationRaw>) => void,
}): Promise<void> {
const { walletName } = accountIdHelper.decode(accountId)
const njsWallet = await core.getPoolInstance().getWallet(walletName)
const isSegwit = isSegwitPath(freshAddressPath)
const isUnsplit = isUnsplitPath(freshAddressPath, splittedCurrencies[currencyId])
const njsWallet = await getOrCreateWallet(core, walletName, { currencyId, isSegwit, isUnsplit })
if (isCancelled()) return
const njsAccount = await njsWallet.getAccount(index)
if (isCancelled()) return
@ -237,6 +244,16 @@ export async function doSignAndBroadcast({
.asBitcoinLikeAccount()
.broadcastRawTransaction(Array.from(Buffer.from(signedTransaction, 'hex')))
const senders = builded
.getInputs()
.map(input => input.getAddress())
.filter(a => a)
const recipients = builded
.getOutputs()
.map(output => output.getAddress())
.filter(a => a)
const fee = libcoreAmountToBigNumber(builded.getFees())
// NB we don't check isCancelled() because the broadcast is not cancellable now!
@ -250,9 +267,8 @@ export async function doSignAndBroadcast({
fee: fee.toString(),
blockHash: null,
blockHeight: null,
// FIXME for senders and recipients, can we ask the libcore?
senders: [freshAddress],
recipients: [transaction.recipient],
senders,
recipients,
accountId,
date: new Date().toISOString(),
})

2
src/commands/libcoreSyncAccount.js

@ -14,7 +14,7 @@ type Input = {
index: number,
}
type Result = AccountRaw
type Result = { rawAccount: AccountRaw, requiresCacheFlush: boolean }
const cmd: Command<Input, Result> = createCommand('libcoreSyncAccount', accountInfos =>
fromPromise(withLibcore(core => syncAccount({ ...accountInfos, core }))),

50
src/components/AdvancedOptions/RippleKind.js

@ -1,5 +1,6 @@
// @flow
import React from 'react'
import React, { Component } from 'react'
import { BigNumber } from 'bignumber.js'
import { translate } from 'react-i18next'
import Box from 'components/base/Box'
@ -12,20 +13,33 @@ type Props = {
t: *,
}
export default translate()(({ tag, onChangeTag, t }: Props) => (
<Box vertical flow={5}>
<Box grow>
<Label>
<span>{t('app:send.steps.amount.rippleTag')}</span>
</Label>
<Input
value={String(tag || '')}
onChange={str => {
const tag = parseInt(str, 10)
if (!isNaN(tag) && isFinite(tag)) onChangeTag(tag)
else onChangeTag(undefined)
}}
/>
</Box>
</Box>
))
const uint32maxPlus1 = BigNumber(2).pow(32)
class RippleKind extends Component<Props> {
onChange = str => {
const { onChangeTag } = this.props
const tag = BigNumber(str.replace(/[^0-9]/g, ''))
if (!tag.isNaN() && tag.isFinite()) {
if (tag.isInteger() && tag.isPositive() && tag.lt(uint32maxPlus1)) {
onChangeTag(tag.toNumber())
}
} else {
onChangeTag(undefined)
}
}
render() {
const { tag, t } = this.props
return (
<Box vertical flow={5}>
<Box grow>
<Label>
<span>{t('app:send.steps.amount.rippleTag')}</span>
</Label>
<Input value={String(tag || '')} onChange={this.onChange} />
</Box>
</Box>
)
}
}
export default translate()(RippleKind)

39
src/components/CurrentAddress/index.js

@ -143,7 +143,26 @@ class CurrentAddress extends PureComponent<Props, { copyFeedback: boolean }> {
copyFeedback: false,
}
_isUnmounted = false
componentWillUnmount() {
if (this._timeout) clearTimeout(this._timeout)
}
renderCopy = copy => {
const { t } = this.props
return (
<FooterButton
icon={<IconCopy size={16} />}
label={t('app:common.copyAddress')}
onClick={() => {
this.setState({ copyFeedback: true })
this._timeout = setTimeout(() => this.setState({ copyFeedback: false }), 1e3)
copy()
}}
/>
)
}
_timeout: ?TimeoutID = null
render() {
const {
@ -214,23 +233,7 @@ class CurrentAddress extends PureComponent<Props, { copyFeedback: boolean }> {
onClick={onVerify}
/>
) : null}
<CopyToClipboard
data={address}
render={copy => (
<FooterButton
icon={<IconCopy size={16} />}
label={t('app:common.copyAddress')}
onClick={() => {
this.setState({ copyFeedback: true })
setTimeout(() => {
if (this._isUnmounted) return
this.setState({ copyFeedback: false })
}, 1e3)
copy()
}}
/>
)}
/>
<CopyToClipboard data={address} render={this.renderCopy} />
</Footer>
</Container>
)

2
src/components/DashboardPage/AccountCard/index.js

@ -69,7 +69,7 @@ class AccountCard extends PureComponent<{
render() {
const { counterValue, account, onClick, daysCount, ...props } = this.props
return (
<Wrapper onClick={this.onClick} {...props}>
<Wrapper onClick={this.onClick} {...props} data-e2e="dashboard_AccountCardWrapper">
<Box flow={4}>
<AccountCardHeader accountName={account.name} currency={account.currency} />
<Bar size={1} color="fog" />

1
src/components/DashboardPage/AccountCardList.js

@ -28,6 +28,7 @@ class AccountCardList extends Component<Props> {
justifyContent="flex-start"
alignItems="center"
style={{ margin: '0 -16px' }}
data-e2e="dashboard_AccountList"
>
{accounts
.map(account => ({

2
src/components/DashboardPage/AccountCardListHeader.js

@ -19,7 +19,7 @@ class AccountCardListHeader extends PureComponent<Props> {
return (
<Box horizontal alignItems="flex-end">
<Text color="dark" ff="Museo Sans" fontSize={6}>
<Text color="dark" ff="Museo Sans" fontSize={6} data-e2e="dashboard_AccountCount">
{t('app:dashboard.accounts.title', { count: accountsLength })}
</Text>
<Box ml="auto" horizontal flow={1}>

2
src/components/DashboardPage/AccountCardPlaceholder.js

@ -31,7 +31,7 @@ class AccountCardPlaceholder extends PureComponent<{
render() {
const { t } = this.props
return (
<Wrapper>
<Wrapper data-e2e="dashboard_AccountPlaceOrder">
<Box mt={2}>
<img alt="" src={i('empty-account-tile.svg')} />
</Box>

2
src/components/DashboardPage/CurrentGreetings.js

@ -21,7 +21,7 @@ class CurrentGettings extends PureComponent<{ t: T }> {
render() {
const { t } = this.props
return (
<Text color="dark" ff="Museo Sans" fontSize={7}>
<Text color="dark" ff="Museo Sans" fontSize={7} data-e2e="dashboard_currentGettings">
{t(getCurrentGreetings())}
</Text>
)

7
src/components/DashboardPage/SummaryDesc.js

@ -12,7 +12,12 @@ class SummaryDesc extends PureComponent<{
render() {
const { totalAccounts, t } = this.props
return (
<Text color="grey" fontSize={5} ff="Museo Sans|Light">
<Text
color="grey"
fontSize={5}
ff="Museo Sans|Light"
data-e2e="dashboard_accountsSummaryDesc"
>
{t('app:dashboard.summary', { count: totalAccounts })}
</Text>
)

207
src/components/DevToolsPage/AccountImporter.js

@ -0,0 +1,207 @@
// @flow
import React, { PureComponent, Fragment } from 'react'
import invariant from 'invariant'
import { connect } from 'react-redux'
import type { Currency, Account } from '@ledgerhq/live-common/lib/types'
import { decodeAccount } from 'reducers/accounts'
import { addAccount } from 'actions/accounts'
import FormattedVal from 'components/base/FormattedVal'
import Switch from 'components/base/Switch'
import Spinner from 'components/base/Spinner'
import Box, { Card } from 'components/base/Box'
import TranslatedError from 'components/TranslatedError'
import Button from 'components/base/Button'
import Input from 'components/base/Input'
import Label from 'components/base/Label'
import SelectCurrency from 'components/SelectCurrency'
import { CurrencyCircleIcon } from 'components/base/CurrencyBadge'
import { idleCallback } from 'helpers/promise'
import { splittedCurrencies } from 'config/cryptocurrencies'
import scanFromXPUB from 'commands/libcoreScanFromXPUB'
const mapDispatchToProps = {
addAccount,
}
type Props = {
addAccount: Account => void,
}
const INITIAL_STATE = {
status: 'idle',
currency: null,
xpub: '',
account: null,
isSegwit: true,
isUnsplit: false,
error: null,
}
type State = {
status: string,
currency: ?Currency,
xpub: string,
account: ?Account,
isSegwit: boolean,
isUnsplit: boolean,
error: ?Error,
}
class AccountImporter extends PureComponent<Props, State> {
state = INITIAL_STATE
onChangeCurrency = currency => {
if (currency.family !== 'bitcoin') return
this.setState({
currency,
isSegwit: !!currency.supportsSegwit,
isUnsplit: false,
})
}
onChangeXPUB = xpub => this.setState({ xpub })
onChangeSegwit = isSegwit => this.setState({ isSegwit })
onChangeUnsplit = isUnsplit => this.setState({ isUnsplit })
isValid = () => {
const { currency, xpub } = this.state
return !!currency && !!xpub
}
scan = async () => {
if (!this.isValid()) return
this.setState({ status: 'scanning' })
try {
const { currency, xpub, isSegwit, isUnsplit } = this.state
invariant(currency, 'no currency')
const rawAccount = await scanFromXPUB
.send({
currencyId: currency.id,
xpub,
isSegwit,
isUnsplit,
})
.toPromise()
const account = decodeAccount(rawAccount)
this.setState({ status: 'finish', account })
} catch (error) {
this.setState({ status: 'error', error })
}
}
import = async () => {
const { account } = this.state
invariant(account, 'no account')
await idleCallback()
this.props.addAccount(account)
this.reset()
}
reset = () => this.setState(INITIAL_STATE)
render() {
const { currency, xpub, isSegwit, isUnsplit, status, account, error } = this.state
const supportsSplit = !!currency && !!splittedCurrencies[currency.id]
return (
<Card title="Import from xpub" flow={3}>
{status === 'idle' ? (
<Fragment>
<Box flow={1}>
<Label>{'currency'}</Label>
<SelectCurrency autoFocus value={currency} onChange={this.onChangeCurrency} />
</Box>
{currency && (currency.supportsSegwit || supportsSplit) ? (
<Box horizontal justify="flex-end" align="center" flow={3}>
{supportsSplit && (
<Box horizontal align="center" flow={1}>
<Box ff="Museo Sans|Bold" fontSize={4}>
{'unsplit'}
</Box>
<Switch isChecked={isUnsplit} onChange={this.onChangeUnsplit} />
</Box>
)}
{currency.supportsSegwit && (
<Box horizontal align="center" flow={1}>
<Box ff="Museo Sans|Bold" fontSize={4}>
{'segwit'}
</Box>
<Switch isChecked={isSegwit} onChange={this.onChangeSegwit} />
</Box>
)}
</Box>
) : null}
<Box flow={1}>
<Label>{'xpub'}</Label>
<Input
placeholder="xpub"
value={xpub}
onChange={this.onChangeXPUB}
onEnter={this.scan}
/>
</Box>
<Box align="flex-end">
<Button primary small disabled={!this.isValid()} onClick={this.scan}>
{'scan'}
</Button>
</Box>
</Fragment>
) : status === 'scanning' ? (
<Box align="center" justify="center" p={5}>
<Spinner size={16} />
</Box>
) : status === 'finish' ? (
account ? (
<Box p={8} align="center" justify="center" flow={5} horizontal>
<Box horizontal flow={4} color="graphite" align="center">
{currency && <CurrencyCircleIcon size={64} currency={currency} />}
<Box>
<Box ff="Museo Sans|Bold">{account.name}</Box>
<FormattedVal
fontSize={2}
alwaysShowSign={false}
color="graphite"
unit={account.unit}
showCode
val={account.balance || 0}
/>
<Box fontSize={2}>{`${account.operations.length} operation(s)`}</Box>
</Box>
</Box>
<Button outline small disabled={!account} onClick={this.import}>
{'import'}
</Button>
</Box>
) : (
<Box align="center" justify="center" p={5} flow={4}>
<Box>{'No accounts found or wrong xpub'}</Box>
<Button primary onClick={this.reset} small autoFocus>
{'Reset'}
</Button>
</Box>
)
) : status === 'error' ? (
<Box align="center" justify="center" p={5} flow={4}>
<Box>
<TranslatedError error={error} />
</Box>
<Button primary onClick={this.reset} small autoFocus>
{'Reset'}
</Button>
</Box>
) : null}
</Card>
)
}
}
export default connect(
null,
mapDispatchToProps,
)(AccountImporter)

11
src/components/DevToolsPage/index.js

@ -0,0 +1,11 @@
import React from 'react'
import Box from 'components/base/Box'
import AccountImporter from './AccountImporter'
export default () => (
<Box flow={2}>
<AccountImporter />
</Box>
)

6
src/components/EnsureDeviceApp.js

@ -20,7 +20,7 @@ import IconUsb from 'icons/Usb'
import type { Device } from 'types/common'
import { WrongDeviceForAccount, CantOpenDevice, BtcUnmatchedApp } from 'config/errors'
import { WrongDeviceForAccount, CantOpenDevice, UpdateYourApp } from 'config/errors'
import { getCurrentDevice } from 'reducers/devices'
const usbIcon = <IconUsb size={16} />
@ -61,10 +61,10 @@ class EnsureDeviceApp extends Component<{
},
{
shouldThrow: (err: Error) => {
const isWrongApp = err instanceof BtcUnmatchedApp
const isWrongDevice = err instanceof WrongDeviceForAccount
const isCantOpenDevice = err instanceof CantOpenDevice
return isWrongApp || isWrongDevice || isCantOpenDevice
const isUpdateYourApp = err instanceof UpdateYourApp
return isWrongDevice || isCantOpenDevice || isUpdateYourApp
},
},
)

2
src/components/IsUnlocked.js

@ -12,7 +12,7 @@ import { i } from 'helpers/staticPath'
import IconTriangleWarning from 'icons/TriangleWarning'
import db from 'helpers/db'
import hardReset from 'helpers/hardReset'
import { hardReset } from 'helpers/reset'
import { fetchAccounts } from 'actions/accounts'
import { isLocked, unlock } from 'reducers/application'

2
src/components/MainSideBar/AddAccountButton.js

@ -33,7 +33,7 @@ export default class AddAccountButton extends PureComponent<{
return (
<Tooltip render={() => tooltipText}>
<PlusWrapper onClick={onClick}>
<IconCirclePlus size={16} />
<IconCirclePlus size={16} data-e2e="menuAddAccount_button" />
</PlusWrapper>
</Tooltip>
)

35
src/components/MainSideBar/index.js

@ -19,6 +19,7 @@ import { i } from 'helpers/staticPath'
import { accountsSelector } from 'reducers/accounts'
import { openModal } from 'reducers/modals'
import { getUpdateStatus } from 'reducers/update'
import { developerModeSelector } from 'reducers/settings'
import { SideBarList, SideBarListItem } from 'components/base/SideBar'
import Box from 'components/base/Box'
@ -34,10 +35,12 @@ import IconExchange from 'icons/Exchange'
import AccountListItem from './AccountListItem'
import AddAccountButton from './AddAccountButton'
import TopGradient from './TopGradient'
import KeyboardContent from '../KeyboardContent'
const mapStateToProps = state => ({
accounts: accountsSelector(state),
updateStatus: getUpdateStatus(state),
developerMode: developerModeSelector(state),
})
const mapDispatchToProps = {
@ -52,8 +55,26 @@ type Props = {
push: string => void,
openModal: string => void,
updateStatus: UpdateStatus,
developerMode: boolean,
}
const IconDev = () => (
<div
style={{
width: 16,
height: 16,
fontSize: 10,
fontFamily: 'monospace',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{'DEV'}
</div>
)
class MainSideBar extends PureComponent<Props> {
push = (to: string) => {
const { push } = this.props
@ -78,10 +99,11 @@ class MainSideBar extends PureComponent<Props> {
handleOpenReceiveModal = () => this.props.openModal(MODAL_RECEIVE)
handleClickManager = () => this.push('/manager')
handleClickExchange = () => this.push('/exchange')
handleClickDev = () => this.push('/dev')
handleOpenImportModal = () => this.props.openModal(MODAL_ADD_ACCOUNTS)
render() {
const { t, accounts, location, updateStatus } = this.props
const { t, accounts, location, updateStatus, developerMode } = this.props
const { pathname } = location
const addAccountButton = (
@ -133,6 +155,17 @@ class MainSideBar extends PureComponent<Props> {
onClick={this.handleClickExchange}
isActive={pathname === '/exchange'}
/>
{developerMode && (
<KeyboardContent sequence="DEVTOOLS">
<SideBarListItem
label={t('app:sidebar.developer')}
icon={IconDev}
iconActiveColor="wallet"
onClick={this.handleClickDev}
isActive={pathname === '/dev'}
/>
</KeyboardContent>
)}
</SideBarList>
<Space of={40} />
<SideBarList

5
src/components/ManagerPage/AppsList.js

@ -71,6 +71,9 @@ type State = {
mode: Mode,
}
const oldAppsInstallDisabled = ['ZenCash', 'Ripple']
const canHandleInstall = c => !oldAppsInstallDisabled.includes(c.name)
const LoadingApp = () => (
<FakeManagerAppContainer noShadow align="center" justify="center" style={{ height: 90 }}>
<Spinner size={16} color="rgba(0, 0, 0, 0.3)" />
@ -285,7 +288,7 @@ class AppsList extends PureComponent<Props, State> {
name={c.name}
version={`Version ${c.version}`}
icon={ICONS_FALLBACK[c.icon] || c.icon}
onInstall={this.handleInstallApp(c)}
onInstall={canHandleInstall(c) ? this.handleInstallApp(c) : null}
onUninstall={this.handleUninstallApp(c)}
/>
))}

26
src/components/ManagerPage/ManagerApp.js

@ -49,7 +49,7 @@ type Props = {
name: string,
version: string,
icon: string,
onInstall: Function,
onInstall?: Function,
onUninstall: Function,
}
@ -64,17 +64,19 @@ function ManagerApp({ name, version, icon, onInstall, onUninstall, t }: Props) {
{version}
</Text>
</Box>
<Button
outline
onClick={onInstall}
event={'Manager Install Click'}
eventProperties={{
appName: name,
appVersion: version,
}}
>
{t('app:manager.apps.install')}
</Button>
{onInstall ? (
<Button
outline
onClick={onInstall}
event={'Manager Install Click'}
eventProperties={{
appName: name,
appVersion: version,
}}
>
{t('app:manager.apps.install')}
</Button>
) : null}
<Button
outline
onClick={onUninstall}

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

@ -76,7 +76,9 @@ class Analytics extends PureComponent<StepProps, State> {
<Container>
<Box>
<Box horizontal mb={1}>
<AnalyticsTitle>{t('onboarding:analytics.technicalData.title')}</AnalyticsTitle>
<AnalyticsTitle data-e2e="analytics_techData">
{t('onboarding:analytics.technicalData.title')}
</AnalyticsTitle>
<LearnMoreWrapper>
<FakeLink
underline
@ -84,6 +86,7 @@ class Analytics extends PureComponent<StepProps, State> {
color="smoke"
ml={2}
onClick={this.handleTechnicalDataModal}
data-e2e="analytics_techData_Link"
>
{t('app:common.learnMore')}
</FakeLink>
@ -102,7 +105,9 @@ class Analytics extends PureComponent<StepProps, State> {
<Container>
<Box>
<Box horizontal mb={1}>
<AnalyticsTitle>{t('onboarding:analytics.shareAnalytics.title')}</AnalyticsTitle>
<AnalyticsTitle data-e2e="analytics_shareAnalytics">
{t('onboarding:analytics.shareAnalytics.title')}
</AnalyticsTitle>
<LearnMoreWrapper>
<FakeLink
style={{ textDecoration: 'underline' }}
@ -110,6 +115,7 @@ class Analytics extends PureComponent<StepProps, State> {
color="smoke"
ml={2}
onClick={this.handleShareAnalyticsModal}
data-e2e="analytics_shareAnalytics_Link"
>
{t('app:common.learnMore')}
</FakeLink>
@ -133,7 +139,9 @@ class Analytics extends PureComponent<StepProps, State> {
<Container>
<Box>
<Box mb={1}>
<AnalyticsTitle>{t('onboarding:analytics.sentryLogs.title')}</AnalyticsTitle>
<AnalyticsTitle data-e2e="analytics_reportBugs">
{t('onboarding:analytics.sentryLogs.title')}
</AnalyticsTitle>
</Box>
<AnalyticsText>{t('onboarding:analytics.sentryLogs.desc')}</AnalyticsText>
</Box>

2
src/components/OperationsList/index.js

@ -102,7 +102,7 @@ export class OperationsList extends PureComponent<Props, State> {
return (
<Box flow={4}>
{title && (
<Text color="dark" ff="Museo Sans" fontSize={6}>
<Text color="dark" ff="Museo Sans" fontSize={6} data-e2e="dashboard_OperationList">
{title}
</Text>
)}

56
src/components/QRCodeExporter.js

@ -1,60 +1,60 @@
// @flow
import React, { PureComponent } from 'react'
import { createSelector } from 'reselect'
import { connect } from 'react-redux'
import type { State } from 'reducers'
import { accountsSelector } from 'reducers/accounts'
import { makeChunks } from '@ledgerhq/live-common/lib/bridgestream/exporter'
import QRCode from './base/QRCode'
// encode the app state to export into an array of chunks for the mobile app to understand.
// returned data frames are json stringified array with format: [ datalength, index, type, ...rest ]
// NB as soon as we have common types we'll move this in a ledgerhq/common project
function makeChunks(state: State): Array<string> {
const chunksFormatVersion = 1
const desktopVersion = __APP_VERSION__
const data = [
['meta', chunksFormatVersion, 'desktop', desktopVersion],
...accountsSelector(state).map(account => [
'account',
account.id,
account.name,
account.currency.id,
]),
]
return data.map((arr, i) => JSON.stringify([data.length, i, ...arr]))
}
const mapStateToProps = createSelector(accountsSelector, accounts => ({
chunks: makeChunks({
accounts,
exporterName: 'desktop',
exporterVersion: __APP_VERSION__,
pad: true,
}),
}))
const mapStateToProps = (state: State) => ({ chunks: makeChunks(state) })
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: 10,
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)
}
@ -74,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>

3
src/components/RecipientAddress/index.js

@ -101,9 +101,10 @@ class RecipientAddress extends PureComponent<Props, State> {
</Right>
) : null
const preOnChange = text => onChange((text && text.replace(/\s/g, '')) || '')
return (
<Box relative justifyContent="center">
<Input {...rest} value={value} onChange={onChange} renderRight={renderRight} />
<Input {...rest} value={value} onChange={preOnChange} renderRight={renderRight} />
</Box>
)
}

2
src/components/RenderError.js

@ -8,7 +8,7 @@ import { translate } from 'react-i18next'
import { urls } from 'config/urls'
import { i } from 'helpers/staticPath'
import hardReset from 'helpers/hardReset'
import { hardReset } from 'helpers/reset'
import type { T } from 'types/common'

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}

1
src/components/SelectCurrency/index.js

@ -40,6 +40,7 @@ const SelectCurrency = ({ onChange, value, t, placeholder, currencies, ...props
renderValue={renderOption}
options={options}
placeholder={placeholder || t('app:common.selectCurrency')}
data-e2e="test"
noOptionsMessage={({ inputValue }: { inputValue: string }) =>
t('app:common.selectCurrencyNoOption', { currencyName: inputValue })
}

20
src/components/SettingsPage/CleanButton.js

@ -4,12 +4,10 @@ import React, { Fragment, PureComponent } from 'react'
import { connect } from 'react-redux'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
import { remote } from 'electron'
import { cleanAccountsCache } from 'actions/accounts'
import db from 'helpers/db'
import { delay } from 'helpers/promise'
import Button from 'components/base/Button'
import { ConfirmModal } from 'components/base/Modal'
import { softReset } from 'helpers/reset'
const mapDispatchToProps = {
cleanAccountsCache,
@ -22,11 +20,13 @@ type Props = {
type State = {
opened: boolean,
isLoading: boolean,
}
class CleanButton extends PureComponent<Props, State> {
state = {
opened: false,
isLoading: false,
}
open = () => this.setState({ opened: true })
@ -34,15 +34,18 @@ class CleanButton extends PureComponent<Props, State> {
close = () => this.setState({ opened: false })
action = async () => {
this.props.cleanAccountsCache()
await delay(500)
db.cleanCache()
remote.getCurrentWindow().webContents.reload()
if (this.state.isLoading) return
try {
this.setState({ isLoading: true })
await softReset({ cleanAccountsCache: this.props.cleanAccountsCache })
} finally {
this.setState({ isLoading: false })
}
}
render() {
const { t } = this.props
const { opened } = this.state
const { opened, isLoading } = this.state
return (
<Fragment>
<Button small primary onClick={this.open} event="ClearCacheIntent">
@ -55,6 +58,7 @@ class CleanButton extends PureComponent<Props, State> {
onClose={this.close}
onReject={this.close}
onConfirm={this.action}
isLoading={isLoading}
title={t('app:settings.softResetModal.title')}
subTitle={t('app:common.areYouSure')}
desc={t('app:settings.softResetModal.desc')}

4
src/components/SettingsPage/DisablePasswordModal.js

@ -68,7 +68,9 @@ class DisablePasswordModal extends PureComponent<Props, State> {
render={({ onClose }) => (
<form onSubmit={this.disablePassword}>
<ModalBody onClose={onClose}>
<ModalTitle>{t('app:password.disablePassword.title')}</ModalTitle>
<ModalTitle data-e2e="disablePassword_modalTitle">
{t('app:password.disablePassword.title')}
</ModalTitle>
<ModalContent>
<Box ff="Open Sans" color="smoke" fontSize={4} textAlign="center" px={4}>
{t('app:password.disablePassword.desc')}

6
src/components/SettingsPage/PasswordButton.js

@ -88,7 +88,11 @@ class PasswordButton extends PureComponent<Props, State> {
{t('app:settings.profile.changePassword')}
</Button>
)}
<Switch isChecked={hasPassword} onChange={this.handleChangePasswordCheck} />
<Switch
isChecked={hasPassword}
onChange={this.handleChangePasswordCheck}
data-e2e="passwordLock_button"
/>
</Box>
<PasswordModal

2
src/components/SettingsPage/PasswordForm.js

@ -61,6 +61,7 @@ class PasswordForm extends PureComponent<Props> {
id="newPassword"
onChange={onChange('newPassword')}
value={newPassword}
data-e2e="setPassword_NewPassword"
/>
</Box>
<Box flow={1}>
@ -73,6 +74,7 @@ class PasswordForm extends PureComponent<Props> {
onChange={onChange('confirmPassword')}
value={confirmPassword}
error={!isValid() && confirmPassword.length > 0 && new PasswordsDontMatchError()}
data-e2e="setPassword_ConfirmPassword"
/>
</Box>
<button hidden type="submit" />

20
src/components/SettingsPage/PasswordModal.js

@ -85,10 +85,19 @@ class PasswordModal extends PureComponent<Props, State> {
{hasPassword ? (
<ModalTitle>{t('app:password.changePassword.title')}</ModalTitle>
) : (
<ModalTitle>{t('app:password.setPassword.title')}</ModalTitle>
<ModalTitle data-e2e="enablePassword_modal">
{t('app:password.setPassword.title')}
</ModalTitle>
)}
<ModalContent>
<Box ff="Museo Sans|Regular" color="dark" textAlign="center" mb={2} mt={3}>
<Box
ff="Museo Sans|Regular"
color="dark"
textAlign="center"
mb={2}
mt={3}
data-e2e="setPassword_modalTitle"
>
{hasPassword
? t('app:password.changePassword.subTitle')
: t('app:password.setPassword.subTitle')}
@ -109,7 +118,12 @@ class PasswordModal extends PureComponent<Props, State> {
/>
</ModalContent>
<ModalFooter horizontal align="center" justify="flex-end" flow={2}>
<Button small type="button" onClick={onClose}>
<Button
small
type="button"
onClick={onClose}
data-e2e="setPassword_modalCancel_button"
>
{t('app:common.cancel')}
</Button>
<Button

2
src/components/SettingsPage/ResetButton.js

@ -5,7 +5,7 @@ import styled from 'styled-components'
import { remote } from 'electron'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
import hardReset from 'helpers/hardReset'
import { hardReset } from 'helpers/reset'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import { ConfirmModal } from 'components/base/Modal'

2
src/components/SettingsPage/SentryLogsButton.js

@ -27,7 +27,7 @@ class SentryLogsButton extends PureComponent<Props> {
return (
<Fragment>
<Track onUpdate event={sentryLogs ? 'SentryEnabled' : 'SentryDisabled'} />
<Switch isChecked={sentryLogs} onChange={setSentryLogs} />
<Switch isChecked={sentryLogs} onChange={setSentryLogs} data-e2e="reportBugs_button" />
</Fragment>
)
}

2
src/components/SettingsPage/SettingsSection.js

@ -59,7 +59,7 @@ export function SettingsSectionHeader({
<SettingsSectionHeaderContainer tabIndex={-1}>
<RoundIconContainer mr={3}>{icon}</RoundIconContainer>
<Box grow>
<Box ff="Museo Sans|Regular" color="dark">
<Box ff="Museo Sans|Regular" color="dark" data-e2e="settingsGeneral_title">
{title}
</Box>
<Box ff="Open Sans" fontSize={3} mt={1}>

6
src/components/SettingsPage/ShareAnalyticsButton.js

@ -26,7 +26,11 @@ class ShareAnalytics extends PureComponent<Props> {
return (
<Fragment>
<Track onUpdate event={shareAnalytics ? 'AnalyticsEnabled' : 'AnalyticsDisabled'} />
<Switch isChecked={shareAnalytics} onChange={setShareAnalytics} />
<Switch
isChecked={shareAnalytics}
onChange={setShareAnalytics}
data-e2e="shareAnalytics_button"
/>
</Fragment>
)
}

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>

1
src/components/TopBar/ActivityIndicator.js

@ -76,6 +76,7 @@ class ActivityIndicatorInner extends PureComponent<Props, { lastClickTime: numbe
)}
</Rotating>
<Box
data-e2e="syncButton"
ml={isRotating ? 2 : 1}
ff="Open Sans|SemiBold"
color={isError ? 'alertRed' : undefined}

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

@ -92,7 +92,7 @@ type Props = {
keepEvent?: boolean,
onBlur: (SyntheticInputEvent<HTMLInputElement>) => void,
onChange?: Function,
onEnter?: (SyntheticKeyboardEvent<HTMLInputElement>) => void,
onEnter?: (SyntheticKeyboardEvent<HTMLInputElement>) => *,
onEsc?: (SyntheticKeyboardEvent<HTMLInputElement>) => void,
onFocus: (SyntheticInputEvent<HTMLInputElement>) => void,
renderLeft?: any,

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

@ -172,7 +172,7 @@ class InputCurrency extends PureComponent<Props, State> {
renderListUnits = () => {
const { units, onChangeUnit, unit } = this.props
const { isFocused } = this.state
const avoidEmptyValue = value => value && onChangeUnit(value)
if (units.length <= 1) {
return null
}
@ -180,13 +180,14 @@ class InputCurrency extends PureComponent<Props, State> {
return (
<Currencies onClick={stopPropagation}>
<Select
onChange={onChangeUnit}
onChange={avoidEmptyValue}
options={units}
value={unit}
getOptionValue={unitGetOptionValue}
renderOption={this.renderOption}
renderValue={this.renderValue}
fakeFocusRight={isFocused}
isRight
/>
</Currencies>
)
@ -208,6 +209,7 @@ class InputCurrency extends PureComponent<Props, State> {
return (
<Input
data-e2e="addAccount_currencyInput"
{...this.props}
ff="Rubik"
ref={this.onRef}

2
src/components/base/Modal/ModalTitle.js

@ -60,7 +60,7 @@ function ModalTitle({
children: any,
}) {
return (
<Container {...props}>
<Container {...props} data-e2e="modal_title">
{onBack && (
<Back onClick={onBack}>
<IconAngleLeft size={16} />

5
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,10 +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,
color: {
light: '#ffffff00', // transparent background
},

6
src/components/base/Select/createStyles.js

@ -7,10 +7,14 @@ export default ({
width,
minWidth,
small,
isRight,
isLeft,
}: {
width: number,
minWidth: number,
small: boolean,
isRight: boolean,
isLeft: boolean,
}) => ({
control: (styles: Object, { isFocused }: Object) => ({
...styles,
@ -19,6 +23,8 @@ export default ({
...ff('Open Sans|SemiBold'),
height: small ? 34 : 40,
minHeight: 'unset',
borderRadius: isRight ? '0 4px 4px 0' : isLeft ? '4px 0 0 4px' : 4,
borderColor: colors.fog,
backgroundColor: 'white',
...(isFocused

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

@ -21,6 +21,8 @@ type Props = {
placeholder: string,
isClearable: boolean,
isDisabled: boolean,
isRight: boolean,
isLeft: boolean,
isLoading: boolean,
isSearchable: boolean,
small: boolean,
@ -52,6 +54,8 @@ class Select extends PureComponent<Props> {
isSearchable,
isDisabled,
isLoading,
isRight,
isLeft,
placeholder,
options,
renderOption,
@ -69,7 +73,7 @@ class Select extends PureComponent<Props> {
classNamePrefix="select"
options={options}
components={createRenderers({ renderOption, renderValue })}
styles={createStyles({ width, minWidth, small })}
styles={createStyles({ width, minWidth, small, isRight, isLeft })}
placeholder={placeholder}
isDisabled={isDisabled}
isLoading={isLoading}

2
src/components/layout/Default.js

@ -19,6 +19,7 @@ import AccountPage from 'components/AccountPage'
import DashboardPage from 'components/DashboardPage'
import ManagerPage from 'components/ManagerPage'
import ExchangePage from 'components/ExchangePage'
import DevToolsPage from 'components/DevToolsPage'
import SettingsPage from 'components/SettingsPage'
import KeyboardContent from 'components/KeyboardContent'
import PerfIndicator from 'components/PerfIndicator'
@ -110,6 +111,7 @@ class Default extends Component<Props> {
<Route path="/manager" component={ManagerPage} />
<Route path="/exchange" component={ExchangePage} />
<Route path="/account/:id" component={AccountPage} />
<Route path="/dev" component={DevToolsPage} />
</Main>
</Box>
</Box>

2
src/components/modals/Disclaimer.js

@ -25,7 +25,7 @@ class DisclaimerModal extends PureComponent<Props> {
name={MODAL_DISCLAIMER}
render={({ onClose }) => (
<ModalBody onClose={onClose}>
<ModalTitle data-e2e="disclaimer_title">{t('app:disclaimerModal.title')}</ModalTitle>
<ModalTitle>{t('app:disclaimerModal.title')}</ModalTitle>
<ModalContent flow={4} ff="Open Sans|Regular" fontSize={4} color="smoke">
<Box align="center" mt={4} pb={4}>
<HandShield size={55} />

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

@ -4,9 +4,9 @@ import Box from 'components/base/Box'
import Label from 'components/base/Label'
import RequestAmount from 'components/RequestAmount'
class AmountField extends Component<*, { canBeSpentError: ?Error }> {
class AmountField extends Component<*, { validTransactionError: ?Error }> {
state = {
canBeSpentError: null,
validTransactionError: null,
}
componentDidMount() {
this.resync()
@ -27,11 +27,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 +42,14 @@ 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}
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)

6
src/components/modals/ShareAnalytics.js

@ -64,13 +64,15 @@ class ShareAnalytics extends PureComponent<Props, *> {
name={MODAL_SHARE_ANALYTICS}
render={({ onClose }) => (
<ModalBody onClose={onClose}>
<ModalTitle>{t('onboarding:analytics.shareAnalytics.title')}</ModalTitle>
<ModalTitle data-e2e="modal_title_shareAnalytics">
{t('onboarding:analytics.shareAnalytics.title')}
</ModalTitle>
<InlineDesc>{t('onboarding:analytics.shareAnalytics.desc')}</InlineDesc>
<ModalContent mx={5}>
<Ul>{items.map(item => <li key={item.key}>{item.desc}</li>)}</Ul>
</ModalContent>
<ModalFooter horizontal justifyContent="flex-end">
<Button onClick={onClose} primary>
<Button onClick={onClose} primary data-e2e="modal_buttonClose_shareAnalytics">
{t('app:common.close')}
</Button>
</ModalFooter>

4
src/components/modals/TechnicalData.js

@ -45,7 +45,7 @@ class TechnicalData extends PureComponent<Props, *> {
name={MODAL_TECHNICAL_DATA}
render={({ onClose }) => (
<ModalBody onClose={onClose}>
<ModalTitle>
<ModalTitle data-e2e="modal_title_TechData">
{t('onboarding:analytics.technicalData.mandatoryContextual.title')}
</ModalTitle>
<InlineDesc>{t('onboarding:analytics.technicalData.desc')}</InlineDesc>
@ -53,7 +53,7 @@ class TechnicalData extends PureComponent<Props, *> {
<Ul>{items.map(item => <li key={item.key}>{item.desc}</li>)}</Ul>
</ModalContent>
<ModalFooter horizontal justifyContent="flex-end">
<Button onClick={onClose} primary>
<Button onClick={onClose} primary data-e2e="modal_buttonClose_techData">
{t('app:common.close')}
</Button>
</ModalFooter>

2
src/config/constants.js

@ -45,7 +45,7 @@ export const SYNC_ALL_INTERVAL = 120 * 1000
export const SYNC_BOOT_DELAY = 2 * 1000
export const SYNC_PENDING_INTERVAL = 10 * 1000
export const SYNC_MAX_CONCURRENT = intFromEnv('LEDGER_SYNC_MAX_CONCURRENT', 1)
export const SYNC_TIMEOUT = intFromEnv('SYNC_TIMEOUT', 60 * 1000)
export const SYNC_TIMEOUT = intFromEnv('SYNC_TIMEOUT', 5 * 60 * 1000)
// Endpoints...

9
src/config/cryptocurrencies.js

@ -35,3 +35,12 @@ export const listCryptoCurrencies = memoize((withDevCrypto?: boolean) =>
.filter(c => supported.includes(c.id))
.sort((a, b) => a.name.localeCompare(b.name)),
)
export const splittedCurrencies = {
bitcoin_cash: {
coinType: 0,
},
bitcoin_gold: {
coinType: 0,
},
}

1
src/config/errors.js

@ -33,6 +33,7 @@ export const NotEnoughBalance = createCustomErrorClass('NotEnoughBalance')
export const PasswordsDontMatchError = createCustomErrorClass('PasswordsDontMatch')
export const PasswordIncorrectError = createCustomErrorClass('PasswordIncorrect')
export const TimeoutTagged = createCustomErrorClass('TimeoutTagged')
export const UpdateYourApp = createCustomErrorClass('UpdateYourApp')
export const UserRefusedAddress = createCustomErrorClass('UserRefusedAddress')
export const UserRefusedFirmwareUpdate = createCustomErrorClass('UserRefusedFirmwareUpdate')
export const UserRefusedOnDevice = createCustomErrorClass('UserRefusedOnDevice') // TODO rename because it's just for transaction refusal

2
src/helpers/errors.js

@ -6,10 +6,10 @@ const errorClasses = {}
export const createCustomErrorClass = (name: string): Class<any> => {
const C = function CustomError(message?: string, fields?: Object) {
Object.assign(this, fields)
this.name = name
this.message = message || name
this.stack = new Error().stack
Object.assign(this, fields)
}
// $FlowFixMe
C.prototype = new Error()

13
src/helpers/getAddressForCurrency/btc.js

@ -3,9 +3,13 @@
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import Btc from '@ledgerhq/hw-app-btc'
import type Transport from '@ledgerhq/hw-transport'
import { BtcUnmatchedApp } from 'config/errors'
import { BtcUnmatchedApp, UpdateYourApp } from 'config/errors'
import getBitcoinLikeInfo from '../devices/getBitcoinLikeInfo'
const oldP2SH = {
digibyte: 5,
}
export default async (
transport: Transport<*>,
currency: CryptoCurrency,
@ -25,6 +29,13 @@ export default async (
if (bitcoinLikeInfo) {
const { P2SH, P2PKH } = await getBitcoinLikeInfo(transport)
if (P2SH !== bitcoinLikeInfo.P2SH || P2PKH !== bitcoinLikeInfo.P2PKH) {
if (
currency.id in oldP2SH &&
P2SH === oldP2SH[currency.id] &&
P2PKH === bitcoinLikeInfo.P2PKH
) {
throw new UpdateYourApp(`UpdateYourApp ${currency.id}`, currency)
}
throw new BtcUnmatchedApp(`BtcUnmatchedApp ${currency.id}`, currency)
}
}

13
src/helpers/hardReset.js

@ -1,13 +0,0 @@
import libcoreHardReset from 'commands/libcoreHardReset'
import { disable as disableDBMiddleware } from 'middlewares/db'
import db from 'helpers/db'
import { delay } from 'helpers/promise'
export default async function hardReset() {
await libcoreHardReset.send()
disableDBMiddleware()
db.resetAll()
await delay(500)
window.location.href = ''
}

139
src/helpers/libcore.js

@ -7,7 +7,7 @@ import { BigNumber } from 'bignumber.js'
import Btc from '@ledgerhq/hw-app-btc'
import { withDevice } from 'helpers/deviceAccess'
import { getCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies'
import { SHOW_LEGACY_NEW_ACCOUNT } from 'config/constants'
import { SHOW_LEGACY_NEW_ACCOUNT, SYNC_TIMEOUT } from 'config/constants'
import type { AccountRaw, OperationRaw, OperationType } from '@ledgerhq/live-common/lib/types'
import type { NJSAccount, NJSOperation } from '@ledgerhq/ledger-core/src/ledgercore_doc'
@ -15,20 +15,11 @@ import type { NJSAccount, NJSOperation } from '@ledgerhq/ledger-core/src/ledgerc
import { isSegwitPath, isUnsplitPath } from 'helpers/bip32'
import * as accountIdHelper from 'helpers/accountId'
import { NoAddressesFound } from 'config/errors'
import { splittedCurrencies } from 'config/cryptocurrencies'
import { deserializeError } from './errors'
import { getAccountPlaceholderName, getNewAccountPlaceholderName } from './accountName'
import { timeoutTagged } from './promise'
// TODO: put that info inside currency itself
const SPLITTED_CURRENCIES = {
bitcoin_cash: {
coinType: 0,
},
bitcoin_gold: {
coinType: 0,
},
}
export function isValidAddress(core: *, currency: *, address: string): boolean {
const addr = new core.NJSAddress(address, currency)
return addr.isValid(address, currency)
@ -75,7 +66,7 @@ export async function scanAccountsOnDevice(props: Props): Promise<AccountRaw[]>
}
// TODO: put that info inside currency itself
if (currencyId in SPLITTED_CURRENCIES) {
if (currencyId in splittedCurrencies) {
const splittedAccounts = await scanAccountsOnDeviceBySegwit({
...commonParams,
isSegwit: false,
@ -109,7 +100,7 @@ function encodeWalletName({
isSegwit: boolean,
isUnsplit: boolean,
}) {
const splitConfig = isUnsplit ? SPLITTED_CURRENCIES[currencyId] || null : null
const splitConfig = isUnsplit ? splittedCurrencies[currencyId] || null : null
return `${publicKey}__${currencyId}${isSegwit ? '_segwit' : ''}${splitConfig ? '_unsplit' : ''}`
}
@ -133,7 +124,7 @@ async function scanAccountsOnDeviceBySegwit({
isUnsplit: boolean,
}): Promise<AccountRaw[]> {
const customOpts =
isUnsplit && SPLITTED_CURRENCIES[currencyId] ? SPLITTED_CURRENCIES[currencyId] : null
isUnsplit && splittedCurrencies[currencyId] ? splittedCurrencies[currencyId] : null
const { coinType } = customOpts ? customOpts.coinType : getCryptoCurrencyById(currencyId)
const path = `${isSegwit ? '49' : '44'}'/${coinType}'`
@ -147,7 +138,7 @@ async function scanAccountsOnDeviceBySegwit({
const walletName = encodeWalletName({ publicKey, currencyId, isSegwit, isUnsplit })
// retrieve or create the wallet
const wallet = await getOrCreateWallet(core, walletName, currencyId, isSegwit, isUnsplit)
const wallet = await getOrCreateWallet(core, walletName, { currencyId, isSegwit, isUnsplit })
const accountsCount = await wallet.getAccountCount()
// recursively scan all accounts on device on the given app
@ -155,6 +146,7 @@ async function scanAccountsOnDeviceBySegwit({
const accounts = await scanNextAccount({
core,
wallet,
walletName,
devicePath,
currencyId,
accountsCount,
@ -232,6 +224,7 @@ const coreSyncAccount = (core, account) =>
async function scanNextAccount(props: {
// $FlowFixMe
wallet: NJSWallet,
walletName: string,
core: *,
devicePath: string,
currencyId: string,
@ -247,6 +240,7 @@ async function scanNextAccount(props: {
const {
core,
wallet,
walletName,
devicePath,
currencyId,
accountsCount,
@ -271,7 +265,7 @@ async function scanNextAccount(props: {
const shouldSyncAccount = true // TODO: let's sync everytime. maybe in the future we can optimize.
if (shouldSyncAccount) {
await timeoutTagged('coreSyncAccount', 30000, coreSyncAccount(core, njsAccount))
await coreSyncAccount(core, njsAccount)
}
if (isUnsubscribed()) return []
@ -285,6 +279,7 @@ async function scanNextAccount(props: {
isUnsplit,
accountIndex,
wallet,
walletName,
currencyId,
core,
ops,
@ -317,20 +312,26 @@ const createWalletConfig = (core, configMap = {}) => {
return config
}
async function getOrCreateWallet(
export async function getOrCreateWallet(
core: *,
WALLET_IDENTIFIER: string,
currencyId: string,
isSegwit: boolean,
isUnsplit: boolean,
walletName: string,
{
currencyId,
isSegwit,
isUnsplit,
}: {
currencyId: string,
isSegwit: boolean,
isUnsplit: boolean,
},
): NJSWallet {
const pool = core.getPoolInstance()
try {
const wallet = await timeoutTagged('getWallet', 5000, pool.getWallet(WALLET_IDENTIFIER))
const wallet = await timeoutTagged('getWallet', 5000, pool.getWallet(walletName))
return wallet
} catch (err) {
const currency = await timeoutTagged('getCurrency', 5000, pool.getCurrency(currencyId))
const splitConfig = isUnsplit ? SPLITTED_CURRENCIES[currencyId] || null : null
const splitConfig = isUnsplit ? splittedCurrencies[currencyId] || null : null
const coinType = splitConfig ? splitConfig.coinType : '<coin_type>'
const walletConfig = isSegwit
? {
@ -346,7 +347,7 @@ async function getOrCreateWallet(
const wallet = await timeoutTagged(
'createWallet',
10000,
core.getPoolInstance().createWallet(WALLET_IDENTIFIER, currency, njsWalletConfig),
core.getPoolInstance().createWallet(walletName, currency, njsWalletConfig),
)
return wallet
}
@ -357,6 +358,7 @@ async function buildAccountRaw({
isSegwit,
isUnsplit,
wallet,
walletName,
currencyId,
core,
accountIndex,
@ -366,6 +368,7 @@ async function buildAccountRaw({
isSegwit: boolean,
isUnsplit: boolean,
wallet: NJSWallet,
walletName: string,
currencyId: string,
accountIndex: number,
core: *,
@ -430,7 +433,7 @@ async function buildAccountRaw({
type: 'libcore',
version: '1',
xpub,
walletName: wallet.getName(),
walletName,
}),
xpub,
path: walletPath,
@ -511,30 +514,17 @@ export async function syncAccount({
index: number,
}) {
const decodedAccountId = accountIdHelper.decode(accountId)
const { walletName } = decodedAccountId
const isSegwit = isSegwitPath(freshAddressPath)
const isUnsplit = isUnsplitPath(freshAddressPath, SPLITTED_CURRENCIES[currencyId])
let njsWallet
try {
njsWallet = await timeoutTagged(
'getWallet',
10000,
core.getPoolInstance().getWallet(decodedAccountId.walletName),
)
} catch (e) {
logger.warn(`Have to reimport the account... (${e})`)
njsWallet = await getOrCreateWallet(
core,
decodedAccountId.walletName,
currencyId,
isSegwit,
isUnsplit,
)
}
const isUnsplit = isUnsplitPath(freshAddressPath, splittedCurrencies[currencyId])
const njsWallet = await getOrCreateWallet(core, walletName, { currencyId, isSegwit, isUnsplit })
let njsAccount
let requiresCacheFlush = false
try {
njsAccount = await timeoutTagged('getAccount', 10000, njsWallet.getAccount(index))
} catch (e) {
requiresCacheFlush = true
logger.warn(`Have to recreate the account... (${e.message})`)
const extendedInfos = await timeoutTagged(
'getEKACI',
@ -549,11 +539,11 @@ export async function syncAccount({
)
}
const unsub = await timeoutTagged('coreSyncAccount', 30000, coreSyncAccount(core, njsAccount))
const unsub = await coreSyncAccount(core, njsAccount)
unsub()
const query = njsAccount.queryOperations()
const ops = await timeoutTagged('ops', 30000, query.complete().execute())
const ops = await timeoutTagged('ops', SYNC_TIMEOUT, query.complete().execute())
const njsBalance = await timeoutTagged('getBalance', 10000, njsAccount.getBalance())
const syncedRawAccount = await buildAccountRaw({
@ -562,6 +552,7 @@ export async function syncAccount({
isUnsplit,
accountIndex: index,
wallet: njsWallet,
walletName,
currencyId,
core,
ops,
@ -571,7 +562,7 @@ export async function syncAccount({
logger.log(`Synced account [${syncedRawAccount.name}]: ${syncedRawAccount.balance}`)
return syncedRawAccount
return { rawAccount: syncedRawAccount, requiresCacheFlush }
}
export function libcoreAmountToBigNumber(njsAmount: *): BigNumber {
@ -581,3 +572,59 @@ export function libcoreAmountToBigNumber(njsAmount: *): BigNumber {
export function bigNumberToLibcoreAmount(core: *, njsWalletCurrency: *, bigNumber: BigNumber) {
return new core.NJSAmount(njsWalletCurrency, 0).fromHex(njsWalletCurrency, bigNumber.toString(16))
}
export async function scanAccountsFromXPUB({
core,
currencyId,
xpub,
isSegwit,
isUnsplit,
}: {
core: *,
currencyId: string,
xpub: string,
isSegwit: boolean,
isUnsplit: boolean,
}) {
const currency = getCryptoCurrencyById(currencyId)
const walletName = encodeWalletName({
publicKey: `debug_${xpub}`,
currencyId,
isSegwit,
isUnsplit,
})
const wallet = await getOrCreateWallet(core, walletName, { currencyId, isSegwit, isUnsplit })
await wallet.eraseDataSince(new Date(0))
const index = 0
const extendedInfos = {
index,
owners: ['main'],
derivations: [
`${isSegwit ? '49' : '44'}'/${currency.coinType}'`,
`${isSegwit ? '49' : '44'}'/${currency.coinType}'/0`,
],
extendedKeys: [xpub],
}
const account = await wallet.newAccountWithExtendedKeyInfo(extendedInfos)
await coreSyncAccount(core, account)
const query = account.queryOperations()
const ops = await query.complete().execute()
const rawAccount = await buildAccountRaw({
njsAccount: account,
isSegwit,
isUnsplit,
accountIndex: index,
wallet,
walletName,
currencyId,
core,
ops,
})
return rawAccount
}

35
src/helpers/reset.js

@ -0,0 +1,35 @@
// @flow
import path from 'path'
import rimraf from 'rimraf'
import resolveUserDataDirectory from 'helpers/resolveUserDataDirectory'
import { disable as disableDBMiddleware } from 'middlewares/db'
import db from 'helpers/db'
import { delay } from 'helpers/promise'
function resetLibcoreDatabase() {
const dbpath = path.resolve(resolveUserDataDirectory(), 'sqlite/')
rimraf.sync(dbpath, { glob: false })
}
function reload() {
require('electron')
.remote.getCurrentWindow()
.webContents.reload()
}
export async function hardReset() {
disableDBMiddleware()
db.resetAll()
await delay(500)
resetLibcoreDatabase()
reload()
}
export async function softReset({ cleanAccountsCache }: *) {
cleanAccountsCache()
await delay(500)
await db.cleanCache()
resetLibcoreDatabase()
reload()
}

67
src/internals/index.js

@ -63,34 +63,45 @@ process.on('message', m => {
}
const startTime = Date.now()
logger.onCmd('cmd.START', id, 0, data)
subscriptions[requestId] = cmd.impl(data).subscribe({
next: data => {
logger.onCmd('cmd.NEXT', id, Date.now() - startTime, data)
process.send({
type: 'cmd.NEXT',
requestId,
data,
})
},
complete: () => {
delete subscriptions[requestId]
logger.onCmd('cmd.COMPLETE', id, Date.now() - startTime)
process.send({
type: 'cmd.COMPLETE',
requestId,
})
},
error: error => {
logger.warn('Command error:', error)
delete subscriptions[requestId]
logger.onCmd('cmd.ERROR', id, Date.now() - startTime, error)
process.send({
type: 'cmd.ERROR',
requestId,
data: serializeError(error),
})
},
})
try {
subscriptions[requestId] = cmd.impl(data).subscribe({
next: data => {
logger.onCmd('cmd.NEXT', id, Date.now() - startTime, data)
process.send({
type: 'cmd.NEXT',
requestId,
data,
})
},
complete: () => {
delete subscriptions[requestId]
logger.onCmd('cmd.COMPLETE', id, Date.now() - startTime)
process.send({
type: 'cmd.COMPLETE',
requestId,
})
},
error: error => {
logger.warn('Command error:', error)
delete subscriptions[requestId]
logger.onCmd('cmd.ERROR', id, Date.now() - startTime, error)
process.send({
type: 'cmd.ERROR',
requestId,
data: serializeError(error),
})
},
})
} catch (error) {
logger.warn('Command error:', error)
delete subscriptions[requestId]
logger.onCmd('cmd.ERROR', id, Date.now() - startTime, error)
process.send({
type: 'cmd.ERROR',
requestId,
data: serializeError(error),
})
}
} else if (m.type === 'command-unsubscribe') {
const { requestId } = m
const sub = subscriptions[requestId]

11
src/logger/logger.js

@ -273,6 +273,7 @@ export default {
status,
error,
responseTime,
...rest
}: {
method: string,
url: string,
@ -285,7 +286,7 @@ export default {
0,
)}ms`
if (logNetwork) {
logger.log('info', log, { type: 'network-error', status, method })
logger.log('info', log, { type: 'network-error', status, method, ...rest })
}
captureBreadcrumb({
category: 'network',
@ -315,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',
@ -339,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',

7
static/i18n/en/app.json

@ -78,7 +78,8 @@
"menu": "Menu",
"accounts": "Accounts ({{count}})",
"manager": "Manager",
"exchange": "Buy/Trade"
"exchange": "Buy/Trade",
"developer": "Dev tools"
},
"account": {
"lastOperations": "Last operations",
@ -133,7 +134,7 @@
"title": "Current address",
"for": "Address for account <1><0>{{accountName}}</0></1>",
"messageIfUnverified": "Verify the address on your device for optimal security. Press the right button to confirm.",
"messageIfAccepted": "{{currencyName}} address confirmed on your device. Carefully verify when you copy and paste it.",
"messageIfAccepted": "{{currencyName}} address confirmed. Please verify the address if you copy/paste it or if you scan the QR code.",
"messageIfSkipped": "Your receive address has not been confirmed on your Ledger device. Please verify your {{currencyName}} address for optimal security."
},
"deviceConnect": {
@ -315,7 +316,7 @@
},
"verification": {
"title": "Verification",
"warning": "Carefully verify all transaction details now displayed on your device screen\n",
"warning": "Please verify all transaction details now displayed on your device\n",
"body": "Once verified, press the right button to confirm and sign the transaction"
},
"confirmation": {

14
static/i18n/en/errors.json

@ -5,7 +5,7 @@
},
"AccountNameRequired": {
"title": "An account name is required",
"description": "Please provide with an account name"
"description": "Please provide an account name"
},
"BtcUnmatchedApp": {
"title": "That's the wrong app",
@ -72,16 +72,16 @@
"description": "Check your device to see which apps are already installed."
},
"ManagerAppRelyOnBTC": {
"title": "Bitcoin or Ethereum app required",
"description": "Either install the latest Ethereum app (for ETC/UBIQ/EXP/RSK/WAN/kUSD/POA), or the latest Bitcoin app."
"title": "Bitcoin and Ethereum apps required",
"description": "Install the latest Bitcoin and Ethereum apps first."
},
"ManagerDeviceLocked": {
"title": "Please unlock your device",
"description": "Your device was locked. Please unlock it."
},
"ManagerNotEnoughSpace": {
"title": "Sorry, insufficient device storage",
"description": "Uninstall some apps to increase available storage and try again."
"title": "Sorry, not enough storage left",
"description": "Please uninstall some apps to make space. This will not affect your crypto assets."
},
"ManagerUninstallBTCDep": {
"title": "Sorry, this app is required",
@ -139,6 +139,10 @@
"title": "Receive address rejected",
"description": "Please try again or contact Ledger Support"
},
"UpdateYourApp": {
"title": "App update required. Uninstall and reinstall the {{managerAppName}} app in the Manager",
"description": null
},
"WebsocketConnectionError": {
"title": "Sorry, try again (websocket error).",
"description": null

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.

119
test-e2e/enable-dev-mode.spec.js

@ -0,0 +1,119 @@
import { Application } from 'spectron'
import { waitForDisappear, waitForExpectedText } from './helpers'
const os = require('os')
const appVersion = require('../package.json')
let app
const TIMEOUT = 50 * 1000
let app_path
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`
} else {
app_path = `./dist/ledger-live-desktop-${appVersion.version}-linux-x86_64.AppImage`
}
describe('Application launch', () => {
beforeEach(async () => {
app = new Application({
path: app_path,
env: {
SKIP_ONBOARDING: '1',
},
})
await app.start()
}, TIMEOUT)
afterEach(async () => {
if (app && app.isRunning()) {
await app.stop()
}
}, TIMEOUT)
test(
'Start app, skip onboarding, check Empty State, check General Settings and verify Developer mode',
async () => {
const title = await app.client.getTitle()
expect(title).toEqual('Ledger Live')
await app.client.waitUntilWindowLoaded()
await waitForDisappear(app, '#preload')
// Post Onboarding (Analytics)
const analytics_title = await waitForExpectedText(
app,
'[data-e2e=onboarding_title]',
'Analytics and bug reports',
)
// Verify "Technical Data" + Link "Learn more"
const analytics_techData_title = await app.client.getText('[data-e2e=analytics_techData]')
expect(analytics_techData_title).toEqual('Technical data *')
await app.client.click('[data-e2e=analytics_techData_Link]')
await waitForExpectedText(app, '[data-e2e=modal_title]', 'Technical data')
await app.client.click('[data-e2e=modal_buttonClose_techData]')
analytics_title
// Verify "Share analytics" + Link "Learn more"
const analytics_shareAnalytics_title = await app.client.getText(
'[data-e2e=analytics_shareAnalytics]',
)
expect(analytics_shareAnalytics_title).toEqual('Share analytics')
await app.client.click('[data-e2e=analytics_shareAnalytics_Link]')
await waitForExpectedText(app, '[data-e2e=modal_title]', 'Share analytics')
await app.client.click('[data-e2e=modal_buttonClose_shareAnalytics]')
analytics_title
// Verify "Report bugs"
const analytics_reportBugs_title = await app.client.getText('[data-e2e=analytics_reportBugs]')
expect(analytics_reportBugs_title).toEqual('Report bugs')
await app.client.click('[data-e2e=continue_button]')
// Finish Onboarding
await waitForExpectedText(app, '[data-e2e=finish_title]', 'Your device is ready!')
await app.client.click('[data-e2e=continue_button]')
await waitForExpectedText(app, '[data-e2e=modal_title]', 'Trade safely')
await app.client.click('[data-e2e=continue_button]')
// Dashboard EmptyState
await waitForExpectedText(
app,
'[data-e2e=dashboard_empty_title]',
'Add accounts to your portfolio',
)
const openManager_button = await app.client.getText('[data-e2e=dashboard_empty_OpenManager]')
expect(openManager_button).toEqual('Open Manager')
const addAccount_button = await app.client.getText('[data-e2e=dashboard_empty_AddAccounts]')
expect(addAccount_button).toEqual('Add accounts')
// Open Settings
await app.client.click('[data-e2e=setting_button]')
await waitForExpectedText(app, '[data-e2e=settings_title]', 'Settings')
// Verify settings General section
const settingsGeneral_title = await app.client.getText('[data-e2e=settingsGeneral_title]')
expect(settingsGeneral_title).toEqual('General')
// TO ADD : VERIFY PASSWORD LOCK VALUE = DISABLE ???
// Report bugs = OFF
await app.client.click('[data-e2e=reportBugs_button]')
// Analytics = ON
await app.client.click('[data-e2e=shareAnalytics_button]')
// DevMode = ON
await app.client.click('[data-e2e=devMode_button]')
// Verify Dev mode
// Add New Account
await app.client.click('[data-e2e=menuAddAccount_button]')
await waitForExpectedText(app, '[data-e2e=modal_title]', 'Add accounts')
},
TIMEOUT,
)
})

40
test-e2e/helpers.js

@ -0,0 +1,40 @@
import { delay } from 'helpers/promise'
// Wait for an element to be present then continue
export function waitForExpectedText(app, selector, expected, maxRetry = 5) {
async function check() {
if (!maxRetry) {
throw new Error(`Cant find the element ${selector} in the page`)
}
try {
const str = await app.client.getText(selector)
if (str === expected) {
return true
}
} catch (err) {} // eslint-disable-line
await delay(500)
--maxRetry
return check()
}
return check()
}
// Wait for an element to disappear then continue
export function waitForDisappear(app, selector, maxRetry = 5) {
async function check() {
if (!maxRetry) {
throw new Error('Too many retries for waiting element to disappear')
}
try {
await app.client.getText(selector)
} catch (err) {
if (err.message.startsWith('An element could not be located')) {
return true
}
}
await delay(500)
--maxRetry
return check()
}
return check()
}

64
test-e2e/nav_to_settings.spec.js

@ -1,64 +0,0 @@
const Application = require('spectron').Application
let app
const TIMEOUT = 50 * 1000
describe('Application launch', () => {
beforeEach(async () => {
app = new Application({
path: './dist/ledger-live-desktop-1.1.0-linux-x86_64.AppImage',
env: {
SKIP_ONBOARDING: '1',
},
})
await app.start()
}, TIMEOUT)
afterEach(async () => {
if (app && app.isRunning()) {
await app.stop()
}
}, TIMEOUT)
test(
'Start app and set developper mode ',
async () => {
const title = await app.client.getTitle()
expect(title).toEqual('Ledger Live')
await app.client.waitUntilWindowLoaded()
await app.client.pause(2000)
// Post Onboarding
const title_onboarding = await app.client.getText('[data-e2e=onboarding_title]')
expect(title_onboarding).toEqual('Analytics and bug reports')
await app.client.click('[data-e2e=continue_button]')
await app.client.pause(1000)
const title_finish = await app.client.getText('[data-e2e=finish_title]')
expect(title_finish).toEqual('Your device is ready!')
await app.client.click('[data-e2e=continue_button]')
await app.client.pause(1000)
const title_disclaimer = await app.client.getText('[data-e2e=disclaimer_title]')
expect(title_disclaimer).toEqual('Trade safely')
await app.client.click('[data-e2e=continue_button]')
await app.client.pause(1000)
// Dashboard EmptyState
const title_dashboard_empty = await app.client.getText('[data-e2e=dashboard_empty_title]')
expect(title_dashboard_empty).toEqual('Add accounts to your portfolio')
// Open Settings
await app.client.click('[data-e2e=setting_button]')
await app.client.pause(1000)
const title_settings = await app.client.getText('[data-e2e=settings_title]')
expect(title_settings).toEqual('Settings')
// DevMode ON
await app.client.click('[data-e2e=devMode_button]')
await app.client.pause(500)
},
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

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

@ -0,0 +1,66 @@
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}`
}

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

@ -0,0 +1,48 @@
/* 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()

63
yarn.lock

@ -1474,6 +1474,13 @@
version "0.7.1"
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.7.1.tgz#e44e596d03c9f16ba3b127ad333a8a072bcb5a0a"
"@gimenete/type-writer@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@gimenete/type-writer/-/type-writer-0.1.3.tgz#2d4f26118b18d71f5b34ca24fdd6d1fd455c05b6"
dependencies:
camelcase "^5.0.0"
prettier "^1.13.7"
"@ledgerhq/hw-app-btc@4.21.0":
version "4.21.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/hw-app-btc/-/hw-app-btc-4.21.0.tgz#4f94571bb3d63cd785e31a7e1f77ce597c344516"
@ -1529,9 +1536,9 @@
dependencies:
events "^2.0.0"
"@ledgerhq/ledger-core@2.0.0-rc.6":
version "2.0.0-rc.6"
resolved "https://registry.yarnpkg.com/@ledgerhq/ledger-core/-/ledger-core-2.0.0-rc.6.tgz#a08c84bd91c680cd731e1030ce081a9b568a2c47"
"@ledgerhq/ledger-core@2.0.0-rc.7":
version "2.0.0-rc.7"
resolved "https://registry.yarnpkg.com/@ledgerhq/ledger-core/-/ledger-core-2.0.0-rc.7.tgz#36a2573f01a1e19c51c6e39692e6b0f8be6c3a77"
dependencies:
"@ledgerhq/hw-app-btc" "^4.7.3"
"@ledgerhq/hw-transport-node-hid" "^4.7.6"
@ -1542,9 +1549,9 @@
npm "^5.7.1"
prebuild-install "^2.2.2"
"@ledgerhq/live-common@3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-3.0.2.tgz#1ee5fcc6044c5a049c067978d81892f79789863c"
"@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"
dependencies:
axios "^0.18.0"
bignumber.js "^7.2.1"
@ -1568,6 +1575,20 @@
version "1.1.0"
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.0.tgz#50c1e2260ac0ed9439a181de3725a0168d59c48a"
"@octokit/rest@^15.10.0":
version "15.10.0"
resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-15.10.0.tgz#9baf7430e55edf1a1024c35ae72ed2f5fc6e90e9"
dependencies:
"@gimenete/type-writer" "^0.1.3"
before-after-hook "^1.1.0"
btoa-lite "^1.0.0"
debug "^3.1.0"
http-proxy-agent "^2.1.0"
https-proxy-agent "^2.2.0"
lodash "^4.17.4"
node-fetch "^2.1.1"
url-template "^2.0.8"
"@posthtml/esm@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@posthtml/esm/-/esm-1.0.0.tgz#09bcb28a02438dcee22ad1970ca1d85a000ae0cf"
@ -3584,6 +3605,10 @@ bech32@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.3.tgz#bd47a8986bbb3eec34a56a097a84b8d3e9a2dfcd"
before-after-hook@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.1.0.tgz#83165e15a59460d13702cb8febd6a1807896db5a"
bfj-node4@^5.2.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/bfj-node4/-/bfj-node4-5.3.1.tgz#e23d8b27057f1d0214fc561142ad9db998f26830"
@ -3902,6 +3927,10 @@ bser@^2.0.0:
dependencies:
node-int64 "^0.4.0"
btoa-lite@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
buffer-alloc-unsafe@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
@ -4162,6 +4191,10 @@ camelcase@^4.0.0, camelcase@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
camelcase@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42"
can-promise@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/can-promise/-/can-promise-0.0.1.tgz#7a7597ad801fb14c8b22341dfec314b6bd6ad8d3"
@ -6072,9 +6105,9 @@ electron-webpack@^2.1.0:
webpack-merge "^4.1.2"
yargs "^11.1.0"
electron@1.8.7:
version "1.8.7"
resolved "https://registry.yarnpkg.com/electron/-/electron-1.8.7.tgz#373c1dc4589d7ab4acd49aff8db4a1c0a6c3bcc1"
electron@1.8.8:
version "1.8.8"
resolved "https://registry.yarnpkg.com/electron/-/electron-1.8.8.tgz#a90cddb075291f49576993e6f5c8bb4439301cae"
dependencies:
"@types/node" "^8.0.24"
electron-download "^3.0.1"
@ -10132,6 +10165,10 @@ node-fetch@^1.0.1:
encoding "^0.1.11"
is-stream "^1.0.1"
node-fetch@^2.1.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.2.0.tgz#4ee79bde909262f9775f731e3656d0db55ced5b5"
node-forge@0.7.5:
version "0.7.5"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df"
@ -11521,6 +11558,10 @@ prettier@^1.12.1, prettier@^1.13.5:
version "1.13.7"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.13.7.tgz#850f3b8af784a49a6ea2d2eaa7ed1428a34b7281"
prettier@^1.13.7:
version "1.14.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.14.2.tgz#0ac1c6e1a90baa22a62925f41963c841983282f9"
pretty-bytes@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-1.0.4.tgz#0a22e8210609ad35542f8c8d5d2159aff0751c84"
@ -14515,6 +14556,10 @@ url-parse@^1.1.8, url-parse@~1.4.0:
querystringify "^2.0.0"
requires-port "^1.0.0"
url-template@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
url-to-options@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"

Loading…
Cancel
Save