diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f7b9cc84..3092eec3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -79,6 +79,8 @@ deploy review: - mkdir -p "${DEPLOY_BASE_DIR}/${CI_BUILD_REF_NAME}" - echo "Copy dev build to web server ${DEPLOY_BASE_DIR}/${CI_BUILD_REF_NAME}..." - rsync --delete -va build/dev/ "${DEPLOY_BASE_DIR}/${CI_BUILD_REF_NAME}/" + - curl "https://api.telegram.org/bot699197118:AAGXNTaC5Q-ljmy_dMvaIvAKy1XjlkA3Iss/sendMessage?chat_id=-1001354778014&text=https://trezor-wallet-dev.trezor.io/${CI_BUILD_REF_NAME}" + - 'echo "Remove working dir, workaround for cache" && rm -r ./*' only: - branches tags: diff --git a/CHANGELOG.md b/CHANGELOG.md index e529b40a..7175fa1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## feature/ripple +__changed__ +- Split code to coin specific components. actions and reducers +- Use TrezorConnect to communicate with trezor-blockchain-link + +__added__ +- Ripple support + ## 1.0.2-beta __changed__ - Fiat rates from coingecko (https://github.com/trezor/trezor-wallet/pull/242) diff --git a/README.md b/README.md index 0b2f694d..f016fcf9 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,14 @@ At the root of the `src/` folder are all files or folders that are shared. Component is what you'd intuitively think it is. It's a regular React component (doesn't matter whether statefull or stateless). ### **Global components** -All global components are are stored in `src/views/components/` folder. +All global components are stored in `src/views/components/` folder. Global components are such components that are shared across multiple different components or views. - For example there's a `Button` component that is used in both `ConnectDevice` and `AccountSend`. `ConnectDevice` and `AccountSend` are both placed accross different views so the `Button` component they're both using must be stored in the global `components` folder. ### **Naming & structure convention** Each component has it's own folder. Name of the folder is same as is the name of the component (camel case and first letter is capitalized, e.g.: *MyComponent*). -If you want to create multiple components of the same type you should put them into a common folder with a lowercase name like this `views/componets/type/MyComponent`. +If you want to create multiple components of the same type you should put them into a common folder with a lowercase name like this `views/components/type/MyComponent`. - For example there are different types of modals like `confirm` or `device`. Because the `confirm` and `device` modals are subtypes of modal the folder structure looks like this @@ -64,7 +64,7 @@ Both naming and structure conventions are similar to components conventions. Each view has its own folder in `views/` folder. Name of this folder is same as is the view's name (camel case and first letter is capitalized, e.g.: *MyView*). Inside the view's folder is always an `index.js` file containing view's code itself. -View may contain own components inside view's folder - in the `components/` folder. One of the differences between a component and a view is that view can hav another views. Of course those views may have their own components and views, etc. +View may contain own components inside view's folder - in the `components/` folder. One of the differences between a component and a view is that view can have another views. Of course those views may have their own components and views, etc. ``` views/ diff --git a/package.json b/package.json index 77576772..6574fdd0 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,9 @@ "redux-thunk": "^2.2.0", "rimraf": "^2.6.2", "styled-components": "^4.1.2", + "styled-media-query": "^2.0.2", "styled-normalize": "^8.0.4", - "trezor-connect": "6.0.2", + "trezor-connect": "6.0.3-beta.4", "web3": "1.0.0-beta.35", "webpack": "^4.16.3", "webpack-build-notifier": "^0.1.29", diff --git a/public/data/appConfig.json b/public/data/appConfig.json index ecce7034..2eaed83e 100644 --- a/public/data/appConfig.json +++ b/public/data/appConfig.json @@ -1,6 +1,19 @@ { "networks": [ { + "type": "ripple", + "name": "Ripple Testnet", + "testnet": true, + "symbol": "XRP", + "shortcut": "xrp", + "bip44": "m/44'/144'/a'/0/0", + "explorer": { + "tx": "https://sisyfos.trezor.io/ripple-testnet-explorer/tx/", + "address": "https://sisyfos.trezor.io/ripple-testnet-explorer/address/" + } + }, + { + "type": "ethereum", "name": "Ethereum", "symbol": "ETH", "shortcut": "eth", @@ -19,6 +32,7 @@ } }, { + "type": "ethereum", "name": "Ethereum Classic", "symbol": "ETC", "shortcut": "etc", @@ -37,7 +51,9 @@ } }, { + "type": "ethereum", "name": "Ethereum Ropsten", + "testnet": true, "symbol": "tROP", "shortcut": "trop", "chainId": 3, @@ -64,6 +80,10 @@ { "network": "etc", "url": "https://api.coingecko.com/api/v3/coins/ethereum-classic" + }, + { + "network": "xrp", + "url": "https://api.coingecko.com/api/v3/coins/ripple" } ], diff --git a/src/actions/BlockchainActions.js b/src/actions/BlockchainActions.js index 978bf43b..e0efb270 100644 --- a/src/actions/BlockchainActions.js +++ b/src/actions/BlockchainActions.js @@ -1,184 +1,25 @@ /* @flow */ -import TrezorConnect from 'trezor-connect'; -import BigNumber from 'bignumber.js'; import * as BLOCKCHAIN from 'actions/constants/blockchain'; +import * as EthereumBlockchainActions from 'actions/ethereum/BlockchainActions'; +import * as RippleBlockchainActions from 'actions/ripple/BlockchainActions'; import type { - TrezorDevice, Dispatch, GetState, PromiseAction, } from 'flowtype'; -import type { EthereumAccount } from 'trezor-connect'; -import type { Token } from 'reducers/TokensReducer'; -import type { NetworkToken } from 'reducers/LocalStorageReducer'; -import * as Web3Actions from './Web3Actions'; -import * as AccountsActions from './AccountsActions'; +import type { BlockchainBlock, BlockchainNotification, BlockchainError } from 'trezor-connect'; + export type BlockchainAction = { type: typeof BLOCKCHAIN.READY, +} | { + type: typeof BLOCKCHAIN.UPDATE_FEE, + shortcut: string, + fee: string, } -export const discoverAccount = (device: TrezorDevice, address: string, network: string): PromiseAction => async (dispatch: Dispatch): Promise => { - // get data from connect - // Temporary disabled, enable after trezor-connect@5.0.32 release - const txs = await TrezorConnect.ethereumGetAccountInfo({ - account: { - address, - block: 0, - transactions: 0, - balance: '0', - nonce: 0, - }, - coin: network, - }); - - if (!txs.success) { - throw new Error(txs.payload.error); - } - - // blockbook web3 fallback - const web3account = await dispatch(Web3Actions.discoverAccount(address, network)); - // return { transactions: txs.payload, ...web3account }; - return { - address, - transactions: txs.payload.transactions, - block: txs.payload.block, - balance: web3account.balance, - nonce: web3account.nonce, - }; -}; - -export const getTokenInfo = (input: string, network: string): PromiseAction => async (dispatch: Dispatch): Promise => dispatch(Web3Actions.getTokenInfo(input, network)); - -export const getTokenBalance = (token: Token): PromiseAction => async (dispatch: Dispatch): Promise => dispatch(Web3Actions.getTokenBalance(token)); - -export const getGasPrice = (network: string, defaultGasPrice: number): PromiseAction => async (dispatch: Dispatch): Promise => { - try { - const gasPrice = await dispatch(Web3Actions.getCurrentGasPrice(network)); - return gasPrice === '0' ? new BigNumber(defaultGasPrice) : new BigNumber(gasPrice); - } catch (error) { - return new BigNumber(defaultGasPrice); - } -}; - -const estimateProxy: Array> = []; -export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction => async (dispatch: Dispatch): Promise => { - // Since this method could be called multiple times in short period of time - // check for pending calls in proxy and if there more than two (first is current running and the second is waiting for result of first) - // TODO: should reject second call immediately? - if (estimateProxy.length > 0) { - // wait for proxy result (but do not process it) - await estimateProxy[0]; - } - - const call = dispatch(Web3Actions.estimateGasLimit(network, { - to: '', - data, - value, - gasPrice, - })); - // add current call to proxy - estimateProxy.push(call); - // wait for result - const result = await call; - // remove current call from proxy - estimateProxy.splice(0, 1); - // return result - return result; -}; - -export const onBlockMined = (coinInfo: any): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - // incoming "coinInfo" from TrezorConnect is CoinInfo | EthereumNetwork type - const network: string = coinInfo.shortcut.toLowerCase(); - - // try to resolve pending transactions - await dispatch(Web3Actions.resolvePendingTransactions(network)); - - await dispatch(Web3Actions.updateGasPrice(network)); - - const accounts: Array = getState().accounts.filter(a => a.network === network); - if (accounts.length > 0) { - // find out which account changed - const response = await TrezorConnect.ethereumGetAccountInfo({ - accounts, - coin: network, - }); - - if (response.success) { - response.payload.forEach((a, i) => { - if (a.transactions > 0) { - // load additional data from Web3 (balance, nonce, tokens) - dispatch(Web3Actions.updateAccount(accounts[i], a, network)); - } else { - // there are no new txs, just update block - dispatch(AccountsActions.update({ ...accounts[i], block: a.block })); - - // HACK: since blockbook can't work with smart contracts for now - // try to update tokens balances added to this account using Web3 - dispatch(Web3Actions.updateAccountTokens(accounts[i])); - } - }); - } - } -}; - - -// not used for now, waiting for fix in blockbook -/* -export const onNotification = (payload: any): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - // this event can be triggered multiple times - // 1. check if pair [txid + address] is already in reducer - const network: string = payload.coin.shortcut.toLowerCase(); - const address: string = EthereumjsUtil.toChecksumAddress(payload.tx.address); - const txInfo = await dispatch(Web3Actions.getPendingInfo(network, payload.tx.txid)); - - // const exists = getState().pending.filter(p => p.id === payload.tx.txid && p.address === address); - const exists = getState().pending.filter(p => p.address === address); - if (exists.length < 1) { - if (txInfo) { - dispatch({ - type: PENDING.ADD, - payload: { - type: 'send', - id: payload.tx.txid, - network, - currency: 'tETH', - amount: txInfo.value, - total: '0', - tx: {}, - nonce: txInfo.nonce, - address, - rejected: false, - }, - }); - } else { - // tx info not found (yet?) - // dispatch({ - // type: PENDING.ADD_UNKNOWN, - // payload: { - // network, - // ...payload.tx, - // } - // }); - } - } -}; -*/ - - -export const subscribe = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const addresses: Array = getState().accounts.filter(a => a.network === network).map(a => a.address); // eslint-disable-line no-unused-vars - await TrezorConnect.blockchainSubscribe({ - // accounts: addresses, - accounts: [], - coin: network, - }); - // init web3 instance if not exists - await dispatch(Web3Actions.initWeb3(network)); -}; - // Conditionally subscribe to blockchain backend // called after TrezorConnect.init successfully emits TRANSPORT.START event // checks if there are discovery processes loaded from LocalStorage @@ -205,8 +46,75 @@ export const init = (): PromiseAction => async (dispatch: Dispatch, getSta }); }; +export const subscribe = (networkName: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { config } = getState().localStorage; + const network = config.networks.find(c => c.shortcut === networkName); + if (!network) return; + + switch (network.type) { + case 'ethereum': + await dispatch(EthereumBlockchainActions.subscribe(networkName)); + break; + case 'ripple': + await dispatch(RippleBlockchainActions.subscribe(networkName)); + break; + default: break; + } +}; + +export const onBlockMined = (payload: $ElementType): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const shortcut = payload.coin.shortcut.toLowerCase(); + if (getState().router.location.state.network !== shortcut) return; + + const { config } = getState().localStorage; + const network = config.networks.find(c => c.shortcut === shortcut); + if (!network) return; + + switch (network.type) { + case 'ethereum': + await dispatch(EthereumBlockchainActions.onBlockMined(shortcut)); + break; + case 'ripple': + await dispatch(RippleBlockchainActions.onBlockMined(shortcut)); + break; + default: break; + } +}; + +export const onNotification = (payload: $ElementType): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const shortcut = payload.coin.shortcut.toLowerCase(); + const { config } = getState().localStorage; + const network = config.networks.find(c => c.shortcut === shortcut); + if (!network) return; + + switch (network.type) { + case 'ethereum': + // this is not working until blockchain-link will start support blockbook backends + await dispatch(EthereumBlockchainActions.onNotification()); + break; + case 'ripple': + await dispatch(RippleBlockchainActions.onNotification(payload)); + break; + default: break; + } +}; + // Handle BLOCKCHAIN.ERROR event from TrezorConnect // disconnect and remove Web3 webscocket instance if exists -export const error = (payload: any): PromiseAction => async (dispatch: Dispatch): Promise => { - dispatch(Web3Actions.disconnect(payload.coin)); +export const onError = (payload: $ElementType): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const shortcut = payload.coin.shortcut.toLowerCase(); + const { config } = getState().localStorage; + const network = config.networks.find(c => c.shortcut === shortcut); + if (!network) return; + + switch (network.type) { + case 'ethereum': + await dispatch(EthereumBlockchainActions.onError(shortcut)); + break; + case 'ripple': + // this error is handled in BlockchainReducer + // await dispatch(RippleBlockchainActions.onBlockMined(shortcut)); + break; + default: break; + } }; \ No newline at end of file diff --git a/src/actions/DiscoveryActions.js b/src/actions/DiscoveryActions.js index 3c497f2e..be0ddb03 100644 --- a/src/actions/DiscoveryActions.js +++ b/src/actions/DiscoveryActions.js @@ -1,7 +1,6 @@ /* @flow */ -import TrezorConnect from 'trezor-connect'; -import EthereumjsUtil from 'ethereumjs-util'; +import TrezorConnect, { UI } from 'trezor-connect'; import * as DISCOVERY from 'actions/constants/discovery'; import * as ACCOUNT from 'actions/constants/account'; import * as NOTIFICATION from 'actions/constants/notification'; @@ -14,29 +13,25 @@ import type { GetState, Dispatch, TrezorDevice, + Account, } from 'flowtype'; import type { Discovery, State } from 'reducers/DiscoveryReducer'; import * as BlockchainActions from './BlockchainActions'; +import * as EthereumDiscoveryActions from './ethereum/DiscoveryActions'; +import * as RippleDiscoveryActions from './ripple/DiscoveryActions'; -export type DiscoveryStartAction = { - type: typeof DISCOVERY.START, - device: TrezorDevice, - network: string, - publicKey: string, - chainCode: string, - basePath: Array, -} +export type DiscoveryStartAction = EthereumDiscoveryActions.DiscoveryStartAction | RippleDiscoveryActions.DiscoveryStartAction; export type DiscoveryWaitingAction = { - type: typeof DISCOVERY.WAITING_FOR_DEVICE | typeof DISCOVERY.WAITING_FOR_BLOCKCHAIN, + type: typeof DISCOVERY.WAITING_FOR_DEVICE | typeof DISCOVERY.WAITING_FOR_BLOCKCHAIN | typeof DISCOVERY.FIRMWARE_NOT_SUPPORTED | typeof DISCOVERY.FIRMWARE_OUTDATED, device: TrezorDevice, - network: string + network: string, } export type DiscoveryCompleteAction = { type: typeof DISCOVERY.COMPLETE, device: TrezorDevice, - network: string + network: string, } export type DiscoveryAction = { @@ -122,44 +117,43 @@ const start = (device: TrezorDevice, network: string, ignoreCompleted?: boolean) // first iteration // generate public key for this account // start discovery process -const begin = (device: TrezorDevice, network: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { +const begin = (device: TrezorDevice, networkName: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const { config } = getState().localStorage; - const networkData = config.networks.find(c => c.shortcut === network); - if (!networkData) return; + const network = config.networks.find(c => c.shortcut === networkName); + if (!network) return; dispatch({ type: DISCOVERY.WAITING_FOR_DEVICE, device, - network, + network: networkName, }); - // get xpub from TREZOR - const response = await TrezorConnect.getPublicKey({ - device: { - path: device.path, - instance: device.instance, - state: device.state, - }, - path: networkData.bip44, - keepSession: true, // acquire and hold session - //useEmptyPassphrase: !device.instance, - useEmptyPassphrase: device.useEmptyPassphrase, - }); + let startAction: DiscoveryStartAction; - // handle TREZOR response error - if (!response.success) { + try { + switch (network.type) { + case 'ethereum': + startAction = await dispatch(EthereumDiscoveryActions.begin(device, network)); + break; + case 'ripple': + startAction = await dispatch(RippleDiscoveryActions.begin(device, network)); + break; + default: + throw new Error(`DiscoveryActions.begin: Unknown network type: ${network.type}`); + } + } catch (error) { dispatch({ type: NOTIFICATION.ADD, payload: { type: 'error', title: 'Discovery error', - message: response.payload.error, + message: error.message, cancelable: true, actions: [ { label: 'Try again', callback: () => { - dispatch(start(device, network)); + dispatch(start(device, networkName)); }, }, ], @@ -174,64 +168,52 @@ const begin = (device: TrezorDevice, network: string): AsyncAction => async (dis const discoveryProcess = getState().discovery.find(d => d.deviceState === device.state && d.network === network); if (dispatch(isProcessInterrupted(discoveryProcess))) return; - const basePath: Array = response.payload.path; - // send data to reducer - dispatch({ - type: DISCOVERY.START, - network, - device, - publicKey: response.payload.publicKey, - chainCode: response.payload.chainCode, - basePath, - }); + dispatch(startAction); // next iteration - dispatch(start(device, network)); + dispatch(start(device, networkName)); }; -const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch): Promise => { - const { completed } = discoveryProcess; +const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { config } = getState().localStorage; + const network = config.networks.find(c => c.shortcut === discoveryProcess.network); + if (!network) return; - const derivedKey = discoveryProcess.hdKey.derive(`m/${discoveryProcess.accountIndex}`); - const path = discoveryProcess.basePath.concat(discoveryProcess.accountIndex); - const publicAddress: string = EthereumjsUtil.publicToAddress(derivedKey.publicKey, true).toString('hex'); - const ethAddress: string = EthereumjsUtil.toChecksumAddress(publicAddress); - const { network } = discoveryProcess; - - // TODO: check if address was created before + const { completed, accountIndex } = discoveryProcess; + let account: Account; try { - const account = await dispatch(BlockchainActions.discoverAccount(device, ethAddress, network)); - // check for interruption - if (dispatch(isProcessInterrupted(discoveryProcess))) return; - - // const accountIsEmpty = account.transactions <= 0 && account.nonce <= 0 && account.balance === '0'; - const accountIsEmpty = account.nonce <= 0 && account.balance === '0'; - if (!accountIsEmpty || (accountIsEmpty && completed) || (accountIsEmpty && discoveryProcess.accountIndex === 0)) { - dispatch({ - type: ACCOUNT.CREATE, - payload: { - index: discoveryProcess.accountIndex, - loaded: true, - network, - deviceID: device.features ? device.features.device_id : '0', - deviceState: device.state || '0', - addressPath: path, - address: ethAddress, - balance: account.balance, - nonce: account.nonce, - block: account.block, - transactions: account.transactions, - }, - }); - } - - if (accountIsEmpty) { - dispatch(finish(device, discoveryProcess)); - } else if (!completed) { - dispatch(discoverAccount(device, discoveryProcess)); + switch (network.type) { + case 'ethereum': + account = await dispatch(EthereumDiscoveryActions.discoverAccount(device, discoveryProcess)); + break; + case 'ripple': + account = await dispatch(RippleDiscoveryActions.discoverAccount(device, discoveryProcess)); + break; + default: + throw new Error(`DiscoveryActions.discoverAccount: Unknown network type: ${network.type}`); } } catch (error) { + // handle not supported firmware error + if (error.message === UI.FIRMWARE_NOT_SUPPORTED) { + dispatch({ + type: DISCOVERY.FIRMWARE_NOT_SUPPORTED, + device, + network: discoveryProcess.network, + }); + return; + } + + // handle outdated firmware error + if (error.message === UI.FIRMWARE) { + dispatch({ + type: DISCOVERY.FIRMWARE_OUTDATED, + device, + network: discoveryProcess.network, + }); + return; + } + dispatch({ type: DISCOVERY.STOP, device, @@ -254,6 +236,23 @@ const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): Asy ], }, }); + return; + } + + if (dispatch(isProcessInterrupted(discoveryProcess))) return; + + const accountIsEmpty = account.empty; + if (!accountIsEmpty || (accountIsEmpty && completed) || (accountIsEmpty && accountIndex === 0)) { + dispatch({ + type: ACCOUNT.CREATE, + payload: account, + }); + } + + if (accountIsEmpty) { + dispatch(finish(device, discoveryProcess)); + } else if (!completed) { + dispatch(discoverAccount(device, discoveryProcess)); } }; diff --git a/src/actions/LocalStorageActions.js b/src/actions/LocalStorageActions.js index 786da55c..b95e4ba4 100644 --- a/src/actions/LocalStorageActions.js +++ b/src/actions/LocalStorageActions.js @@ -141,10 +141,9 @@ const loadJSON = (): AsyncAction => async (dispatch: Dispatch): Promise => try { const config: Config = await httpRequest(AppConfigJSON, 'json'); - // remove ropsten testnet from config networks + // remove testnets from config networks if (!buildUtils.isDev()) { - const index = config.networks.findIndex(c => c.shortcut === 'trop'); - delete config.networks[index]; + config.networks = config.networks.filter(n => !n.testnet); } const ERC20Abi = await httpRequest(Erc20AbiJSON, 'json'); @@ -156,8 +155,10 @@ const loadJSON = (): AsyncAction => async (dispatch: Dispatch): Promise => // load tokens const tokens = await config.networks.reduce(async (promise: Promise, network: Network): Promise => { const collection: TokensCollection = await promise; - const json = await httpRequest(network.tokens, 'json'); - collection[network.shortcut] = json; + if (network.tokens) { + const json = await httpRequest(network.tokens, 'json'); + collection[network.shortcut] = json; + } return collection; }, Promise.resolve({})); @@ -175,7 +176,7 @@ const loadJSON = (): AsyncAction => async (dispatch: Dispatch): Promise => } }; -const VERSION: string = '1'; +const VERSION: string = '2'; const loadStorageData = (): ThunkAction => (dispatch: Dispatch): void => { // validate version diff --git a/src/actions/PendingTxActions.js b/src/actions/PendingTxActions.js index fd9e3763..a300937c 100644 --- a/src/actions/PendingTxActions.js +++ b/src/actions/PendingTxActions.js @@ -13,12 +13,11 @@ export type PendingTxAction = { payload: PendingTx } | { type: typeof PENDING.TX_RESOLVED, - tx: PendingTx, - receipt?: Object, + hash: string, } | { type: typeof PENDING.TX_REJECTED, - tx: PendingTx, + hash: string, } | { type: typeof PENDING.TX_TOKEN_ERROR, - tx: PendingTx, + hash: string, } \ No newline at end of file diff --git a/src/actions/ReceiveActions.js b/src/actions/ReceiveActions.js index 95d56119..116a5c15 100644 --- a/src/actions/ReceiveActions.js +++ b/src/actions/ReceiveActions.js @@ -50,7 +50,9 @@ export const showUnverifiedAddress = (): Action => ({ //export const showAddress = (address_n: string): AsyncAction => { export const showAddress = (path: Array): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const selected = getState().wallet.selectedDevice; - if (!selected) return; + const { network } = getState().selectedAccount; + + if (!selected || !network) return; if (selected && (!selected.connected || !selected.available)) { dispatch({ @@ -60,18 +62,30 @@ export const showAddress = (path: Array): AsyncAction => async (dispatch return; } - const response = await TrezorConnect.ethereumGetAddress({ + const params = { device: { path: selected.path, instance: selected.instance, state: selected.state, }, path, - // useEmptyPassphrase: !selected.instance, useEmptyPassphrase: selected.useEmptyPassphrase, - }); + }; - if (response && response.success) { + let response; + switch (network.type) { + case 'ethereum': + response = await TrezorConnect.ethereumGetAddress(params); + break; + case 'ripple': + response = await TrezorConnect.rippleGetAddress(params); + break; + default: + response = { payload: { error: `ReceiveActions.showAddress: Unknown network type: ${network.type}` } }; + break; + } + + if (response.success) { dispatch({ type: RECEIVE.SHOW_ADDRESS, }); diff --git a/src/actions/SelectedAccountActions.js b/src/actions/SelectedAccountActions.js index af73d0cd..8f7d9445 100644 --- a/src/actions/SelectedAccountActions.js +++ b/src/actions/SelectedAccountActions.js @@ -6,7 +6,6 @@ import * as ACCOUNT from 'actions/constants/account'; import * as DISCOVERY from 'actions/constants/discovery'; import * as TOKEN from 'actions/constants/token'; import * as PENDING from 'actions/constants/pendingTx'; -import * as SEND from 'actions/constants/send'; import * as reducerUtils from 'reducers/utils'; @@ -67,6 +66,24 @@ const getAccountLoader = (state: State, selectedAccount: SelectedAccountState): if (account) return null; // account not found (yet). checking why... + if (discovery && discovery.fwOutdated) { + return { + type: 'info', + title: `Device ${device.instanceLabel} firmware is outdated`, + message: 'TODO: update firmware explanation', + shouldRender: false, + }; + } + + if (discovery && discovery.fwNotSupported) { + return { + type: 'info', + title: `Device ${device.instanceLabel} is not supported`, + message: 'TODO: model T1 not supported explanation', + shouldRender: false, + }; + } + if (!discovery || (discovery.waitingForDevice || discovery.interrupted)) { if (device.connected) { // case 1: device is connected but discovery not started yet (probably waiting for auth) @@ -166,7 +183,6 @@ const getAccountNotification = (state: State, selectedAccount: SelectedAccountSt const actions = [ LOCATION_CHANGE, ...Object.values(BLOCKCHAIN).filter(v => typeof v === 'string'), - SEND.TX_COMPLETE, WALLET.SET_SELECTED_DEVICE, WALLET.UPDATE_SELECTED_DEVICE, ...Object.values(ACCOUNT).filter(v => typeof v === 'string' && v !== ACCOUNT.UPDATE_SELECTED_ACCOUNT && v !== ACCOUNT.DISPOSE), // exported values got unwanted "__esModule: true" as first element diff --git a/src/actions/SendFormActions.js b/src/actions/SendFormActions.js index 1a052615..187b09cb 100644 --- a/src/actions/SendFormActions.js +++ b/src/actions/SendFormActions.js @@ -1,18 +1,7 @@ /* @flow */ -import React from 'react'; -import Link from 'components/Link'; -import TrezorConnect from 'trezor-connect'; -import BigNumber from 'bignumber.js'; import * as ACCOUNT from 'actions/constants/account'; -import * as NOTIFICATION from 'actions/constants/notification'; import * as SEND from 'actions/constants/send'; import * as WEB3 from 'actions/constants/web3'; -import * as ValidationActions from 'actions/SendFormValidationActions'; - -import { initialState } from 'reducers/SendFormReducer'; -import { findToken } from 'reducers/TokensReducer'; -import * as reducerUtils from 'reducers/utils'; -import * as ethUtils from 'utils/ethUtils'; import type { Dispatch, @@ -20,33 +9,27 @@ import type { State as ReducersState, Action, ThunkAction, - AsyncAction, - TrezorDevice, } from 'flowtype'; -import type { State, FeeLevel } from 'reducers/SendFormReducer'; -import type { Account } from 'reducers/AccountsReducer'; -import * as SessionStorageActions from './SessionStorageActions'; -import { prepareEthereumTx, serializeEthereumTx } from './TxActions'; -import * as BlockchainActions from './BlockchainActions'; +import type { State as EthereumState } from 'reducers/SendFormEthereumReducer'; +import type { State as RippleState } from 'reducers/SendFormRippleReducer'; -export type SendTxAction = { - type: typeof SEND.TX_COMPLETE, - account: Account, - selectedCurrency: string, - amount: string, - total: string, - tx: any, - nonce: number, - txid: string, - txData: any, -}; +import * as EthereumSendFormActions from './ethereum/SendFormActions'; +import * as RippleSendFormActions from './ripple/SendFormActions'; export type SendFormAction = { type: typeof SEND.INIT | typeof SEND.VALIDATION | typeof SEND.CHANGE, - state: State, + networkType: 'ethereum', + state: EthereumState, +} | { + type: typeof SEND.INIT | typeof SEND.VALIDATION | typeof SEND.CHANGE, + networkType: 'ripple', + state: RippleState, } | { type: typeof SEND.TOGGLE_ADVANCED | typeof SEND.TX_SENDING | typeof SEND.TX_ERROR, -} | SendTxAction; +} | { + type: typeof SEND.TX_COMPLETE, +}; + // list of all actions which has influence on "sendForm" reducer // other actions will be ignored @@ -67,521 +50,16 @@ export const observe = (prevState: ReducersState, action: Action): ThunkAction = // do not proceed if it's not "send" url if (!currentState.router.location.state.send) return; - // if action type is SEND.VALIDATION which is called as result of this process - // save data to session storage - if (action.type === SEND.VALIDATION) { - dispatch(SessionStorageActions.saveDraftTransaction()); - return; - } - - // if send form was not initialized - if (currentState.sendForm.currency === '') { - dispatch(init()); - return; - } - - // handle gasPrice update from backend - // recalculate fee levels if needed - if (action.type === WEB3.GAS_PRICE_UPDATED) { - dispatch(ValidationActions.onGasPriceUpdated(action.network, action.gasPrice)); - return; - } - - let shouldUpdate: boolean = false; - // check if "selectedAccount" reducer changed - shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, { - account: ['balance', 'nonce', 'tokens'], - }); - if (shouldUpdate && currentState.sendForm.currency !== currentState.sendForm.networkSymbol) { - // make sure that this token is added into account - const { account, tokens } = getState().selectedAccount; - if (!account) return; - const token = findToken(tokens, account.address, currentState.sendForm.currency, account.deviceState); - if (!token) { - // token not found, re-init form - dispatch(init()); - return; - } - } - - - // check if "sendForm" reducer changed - if (!shouldUpdate) { - shouldUpdate = reducerUtils.observeChanges(prevState.sendForm, currentState.sendForm); - } - - if (shouldUpdate) { - const validated = dispatch(ValidationActions.validation()); - dispatch({ - type: SEND.VALIDATION, - state: validated, - }); - } -}; - -/* -* Called from "observe" action -* Initialize "sendForm" reducer data -* Get data either from session storage or "selectedAccount" reducer -*/ -export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const { - account, - network, - } = getState().selectedAccount; - - if (!account || !network) return; - - const stateFromStorage = dispatch(SessionStorageActions.loadDraftTransaction()); - if (stateFromStorage) { - // TODO: consider if current gasPrice should be set here as "recommendedGasPrice" - dispatch({ - type: SEND.INIT, - state: stateFromStorage, - }); - return; - } - - const gasPrice: BigNumber = await dispatch(BlockchainActions.getGasPrice(network.shortcut, network.defaultGasPrice)); - const gasLimit = network.defaultGasLimit.toString(); - const feeLevels = ValidationActions.getFeeLevels(network.symbol, gasPrice, gasLimit); - const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel); - - dispatch({ - type: SEND.INIT, - state: { - ...initialState, - networkName: network.shortcut, - networkSymbol: network.symbol, - currency: network.symbol, - feeLevels, - selectedFeeLevel, - recommendedGasPrice: gasPrice.toString(), - gasLimit, - gasPrice: gasPrice.toString(), - }, - }); -}; - -/* -* Called from UI from "advanced" button -*/ -export const toggleAdvanced = (): Action => ({ - type: SEND.TOGGLE_ADVANCED, -}); - -/* -* Called from UI on "address" field change -*/ -export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state: State = getState().sendForm; - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - untouched: false, - touched: { ...state.touched, address: true }, - address, - }, - }); -}; - -/* -* Called from UI on "amount" field change -*/ -export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state = getState().sendForm; - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - untouched: false, - touched: { ...state.touched, amount: true }, - setMax: false, - amount, - }, - }); -}; - -/* -* Called from UI on "currency" selection change -*/ -export const onCurrencyChange = (currency: { value: string, label: string }): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const { - account, - network, - } = getState().selectedAccount; - if (!account || !network) return; - - const state = getState().sendForm; - - const isToken = currency.value !== state.networkSymbol; - const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); - - const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel); - const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); - - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - currency: currency.value, - feeLevels, - selectedFeeLevel, - gasLimit, - }, - }); -}; - -/* -* Called from UI from "set max" button -*/ -export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state = getState().sendForm; - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - untouched: false, - touched: { ...state.touched, amount: true }, - setMax: !state.setMax, - }, - }); -}; - -/* -* Called from UI on "fee" selection change -*/ -export const onFeeLevelChange = (feeLevel: FeeLevel): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state = getState().sendForm; - - const isCustom = feeLevel.value === 'Custom'; - let newGasLimit = state.gasLimit; - let newGasPrice = state.gasPrice; - const advanced = isCustom ? true : state.advanced; - - if (!isCustom) { - // if selected fee is not custom - // update gasLimit to default and gasPrice to selected value - const { network } = getState().selectedAccount; - if (!network) return; - const isToken = state.currency !== state.networkSymbol; - if (isToken) { - newGasLimit = network.defaultGasLimitTokens.toString(); - } else { - // corner case: gas limit was changed by user OR by "estimateGasPrice" action - // leave gasLimit as it is - newGasLimit = state.touched.gasLimit ? state.gasLimit : network.defaultGasLimit.toString(); - } - newGasPrice = feeLevel.gasPrice; - } - - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - advanced, - selectedFeeLevel: feeLevel, - gasLimit: newGasLimit, - gasPrice: newGasPrice, - }, - }); -}; - -/* -* Called from UI from "update recommended fees" button -*/ -export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const { - account, - network, - } = getState().selectedAccount; - if (!account || !network) return; - - const state: State = getState().sendForm; - const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, state.gasLimit, state.selectedFeeLevel); - const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); - - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - feeLevels, - selectedFeeLevel, - gasPrice: selectedFeeLevel.gasPrice, - gasPriceNeedsUpdate: false, - }, - }); -}; - -/* -* Called from UI on "gas price" field change -*/ -export const onGasPriceChange = (gasPrice: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state: State = getState().sendForm; - // switch to custom fee level - let newSelectedFeeLevel = state.selectedFeeLevel; - if (state.selectedFeeLevel.value !== 'Custom') newSelectedFeeLevel = state.feeLevels.find(f => f.value === 'Custom'); - - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - untouched: false, - touched: { ...state.touched, gasPrice: true }, - gasPrice, - selectedFeeLevel: newSelectedFeeLevel, - }, - }); -}; - -/* -* Called from UI on "data" field change -* OR from "estimateGasPrice" action -*/ -export const onGasLimitChange = (gasLimit: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const { network } = getState().selectedAccount; - if (!network) return; - const state: State = getState().sendForm; - // recalculate feeLevels with recommended gasPrice - const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel); - const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); - - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - calculatingGasLimit: false, - untouched: false, - touched: { ...state.touched, gasLimit: true }, - gasLimit, - feeLevels, - selectedFeeLevel, - }, - }); -}; - -/* -* Called from UI on "nonce" field change -*/ -export const onNonceChange = (nonce: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state: State = getState().sendForm; - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - untouched: false, - touched: { ...state.touched, nonce: true }, - nonce, - }, - }); -}; - -/* -* Called from UI on "data" field change -*/ -export const onDataChange = (data: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state: State = getState().sendForm; - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - calculatingGasLimit: true, - untouched: false, - touched: { ...state.touched, data: true }, - data, - }, - }); - - dispatch(estimateGasPrice()); -}; - -export const setDefaultGasLimit = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state: State = getState().sendForm; - const { network } = getState().selectedAccount; + const { network } = currentState.selectedAccount; if (!network) return; - const isToken = state.currency !== state.networkSymbol; - const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); - - dispatch({ - type: SEND.CHANGE, - state: { - ...state, - calculatingGasLimit: false, - untouched: false, - touched: { ...state.touched, gasLimit: false }, - gasLimit, - }, - }); -}; - -/* -* Internal method -* Called from "onDataChange" action -* try to asynchronously download data from backend -*/ -const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const state: State = getState().sendForm; - const { network } = getState().selectedAccount; - if (!network) { - // stop "calculatingGasLimit" process - dispatch(onGasLimitChange(state.gasLimit)); - return; - } - - const requestedData = state.data; - if (!ethUtils.isHex(requestedData)) { - // stop "calculatingGasLimit" process - dispatch(onGasLimitChange(requestedData.length > 0 ? state.gasLimit : network.defaultGasLimit.toString())); - return; - } - - if (state.data.length < 1) { - // set default - dispatch(onGasLimitChange(network.defaultGasLimit.toString())); - return; - } - - const gasLimit = await dispatch(BlockchainActions.estimateGasLimit(network.shortcut, state.data, state.amount, state.gasPrice)); - - // double check "data" field - // possible race condition when data changed before backend respond - if (getState().sendForm.data === requestedData) { - dispatch(onGasLimitChange(gasLimit)); + switch (network.type) { + case 'ethereum': + dispatch(EthereumSendFormActions.observe(prevState, action)); + break; + case 'ripple': + dispatch(RippleSendFormActions.observe(prevState, action)); + break; + default: break; } }; - -/* -* Called from UI from "send" button -*/ -export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const { - account, - network, - pending, - } = getState().selectedAccount; - - if (!account || !network) return; - - const currentState: State = getState().sendForm; - - const isToken: boolean = currentState.currency !== currentState.networkSymbol; - const pendingNonce: number = reducerUtils.getPendingNonce(pending); - const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce; - - const txData = await dispatch(prepareEthereumTx({ - network: network.shortcut, - token: isToken ? findToken(getState().tokens, account.address, currentState.currency, account.deviceState) : null, - from: account.address, - to: currentState.address, - amount: currentState.amount, - data: currentState.data, - gasLimit: currentState.gasLimit, - gasPrice: currentState.gasPrice, - nonce, - })); - - const selected: ?TrezorDevice = getState().wallet.selectedDevice; - if (!selected) return; - - const signedTransaction = await TrezorConnect.ethereumSignTransaction({ - device: { - path: selected.path, - instance: selected.instance, - state: selected.state, - }, - // useEmptyPassphrase: !selected.instance, - useEmptyPassphrase: selected.useEmptyPassphrase, - path: account.addressPath, - transaction: txData, - }); - - if (!signedTransaction || !signedTransaction.success) { - dispatch({ - type: NOTIFICATION.ADD, - payload: { - type: 'error', - title: 'Transaction error', - message: signedTransaction.payload.error, - cancelable: true, - actions: [], - }, - }); - return; - } - - txData.r = signedTransaction.payload.r; - txData.s = signedTransaction.payload.s; - txData.v = signedTransaction.payload.v; - - try { - const serializedTx: string = await dispatch(serializeEthereumTx(txData)); - const push = await TrezorConnect.pushTransaction({ - tx: serializedTx, - coin: network.shortcut, - }); - - if (!push.success) { - throw new Error(push.payload.error); - } - - const { txid } = push.payload; - - dispatch({ - type: SEND.TX_COMPLETE, - account, - selectedCurrency: currentState.currency, - amount: currentState.amount, - total: currentState.total, - tx: txData, - nonce, - txid, - txData, - }); - - // clear session storage - dispatch(SessionStorageActions.clear()); - - // reset form - dispatch(init()); - - dispatch({ - type: NOTIFICATION.ADD, - payload: { - type: 'success', - title: 'Transaction success', - message: See transaction detail, - cancelable: true, - actions: [], - }, - }); - } catch (error) { - dispatch({ - type: NOTIFICATION.ADD, - payload: { - type: 'error', - title: 'Transaction error', - message: error.message || error, - cancelable: true, - actions: [], - }, - }); - } -}; - -export default { - toggleAdvanced, - onAddressChange, - onAmountChange, - onCurrencyChange, - onSetMax, - onFeeLevelChange, - updateFeeLevels, - onGasPriceChange, - onGasLimitChange, - setDefaultGasLimit, - onNonceChange, - onDataChange, - onSend, -}; \ No newline at end of file diff --git a/src/actions/SessionStorageActions.js b/src/actions/SessionStorageActions.js index 6a163b08..3686d8f4 100644 --- a/src/actions/SessionStorageActions.js +++ b/src/actions/SessionStorageActions.js @@ -2,7 +2,8 @@ import * as storageUtils from 'utils/storage'; import { findToken } from 'reducers/TokensReducer'; -import type { State as SendFormState } from 'reducers/SendFormReducer'; +import type { State as EthereumSendFormState } from 'reducers/SendFormEthereumReducer'; +import type { State as RippleSendFormState } from 'reducers/SendFormRippleReducer'; import type { ThunkAction, PayloadAction, @@ -20,18 +21,18 @@ const getTxDraftKey = (getState: GetState): string => { }; export const saveDraftTransaction = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const state = getState().sendForm; + const state = getState().sendFormEthereum; if (state.untouched) return; const key = getTxDraftKey(getState); storageUtils.set(TYPE, key, JSON.stringify(state)); }; -export const loadDraftTransaction = (): PayloadAction => (dispatch: Dispatch, getState: GetState): ?SendFormState => { +export const loadEthereumDraftTransaction = (): PayloadAction => (dispatch: Dispatch, getState: GetState): ?EthereumSendFormState => { const key = getTxDraftKey(getState); const value: ?string = storageUtils.get(TYPE, key); if (!value) return null; - const state: ?SendFormState = JSON.parse(value); + const state: ?EthereumSendFormState = JSON.parse(value); if (!state) return null; // decide if draft is valid and should be returned // ignore this draft if has any error @@ -52,6 +53,21 @@ export const loadDraftTransaction = (): PayloadAction => (dispat return state; }; +export const loadRippleDraftTransaction = (): PayloadAction => (dispatch: Dispatch, getState: GetState): ?RippleSendFormState => { + const key = getTxDraftKey(getState); + const value: ?string = storageUtils.get(TYPE, key); + if (!value) return null; + const state: ?RippleSendFormState = JSON.parse(value); + if (!state) return null; + // decide if draft is valid and should be returned + // ignore this draft if has any error + if (Object.keys(state.errors).length > 0) { + storageUtils.remove(TYPE, key); + return null; + } + return state; +}; + export const clear = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const key = getTxDraftKey(getState); storageUtils.remove(TYPE, key); diff --git a/src/actions/TokenActions.js b/src/actions/TokenActions.js index d0528ca1..6f592d6b 100644 --- a/src/actions/TokenActions.js +++ b/src/actions/TokenActions.js @@ -9,7 +9,7 @@ import type { import type { State, Token } from 'reducers/TokensReducer'; import type { Account } from 'reducers/AccountsReducer'; import type { NetworkToken } from 'reducers/LocalStorageReducer'; -import * as BlockchainActions from './BlockchainActions'; +import * as BlockchainActions from 'actions/ethereum/BlockchainActions'; export type TokenAction = { type: typeof TOKEN.FROM_STORAGE, diff --git a/src/actions/TrezorConnectActions.js b/src/actions/TrezorConnectActions.js index d90530fb..dfbde361 100644 --- a/src/actions/TrezorConnectActions.js +++ b/src/actions/TrezorConnectActions.js @@ -17,8 +17,7 @@ import type { UiMessageType, TransportMessage, TransportMessageType, - BlockchainMessage, - BlockchainMessageType, + BlockchainEvent, } from 'trezor-connect'; import type { @@ -114,13 +113,9 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS }); }); - TrezorConnect.on(BLOCKCHAIN_EVENT, (event: BlockchainMessage): void => { - // post event to reducers - const type: BlockchainMessageType = event.type; // eslint-disable-line prefer-destructuring - dispatch({ - type, - payload: event.payload, - }); + // post event to reducers + TrezorConnect.on(BLOCKCHAIN_EVENT, (event: BlockchainEvent): void => { + dispatch(event); }); if (buildUtils.isDev()) { diff --git a/src/actions/Web3Actions.js b/src/actions/Web3Actions.js index 5cf2607e..94030727 100644 --- a/src/actions/Web3Actions.js +++ b/src/actions/Web3Actions.js @@ -137,25 +137,24 @@ export const resolvePendingTransactions = (network: string): PromiseAction const instance: Web3Instance = await dispatch(initWeb3(network)); const pending = getState().pending.filter(p => p.network === network); pending.forEach(async (tx) => { - const status = await instance.web3.eth.getTransaction(tx.id); + const status = await instance.web3.eth.getTransaction(tx.hash); if (!status) { dispatch({ type: PENDING.TX_REJECTED, - tx, + hash: tx.hash, }); } else { - const receipt = await instance.web3.eth.getTransactionReceipt(tx.id); + const receipt = await instance.web3.eth.getTransactionReceipt(tx.hash); if (receipt) { if (status.gas !== receipt.gasUsed) { dispatch({ type: PENDING.TX_TOKEN_ERROR, - tx, + hash: tx.hash, }); } dispatch({ type: PENDING.TX_RESOLVED, - tx, - receipt, + hash: tx.hash, }); } } @@ -197,7 +196,11 @@ export const updateAccount = (account: Account, newAccount: EthereumAccount, net const balance = await instance.web3.eth.getBalance(account.address); const nonce = await instance.web3.eth.getTransactionCount(account.address); dispatch(AccountsActions.update({ - ...account, ...newAccount, balance: EthereumjsUnits.convert(balance, 'wei', 'ether'), nonce, + ...account, + ...newAccount, + nonce, + balance: EthereumjsUnits.convert(balance, 'wei', 'ether'), + availableBalance: EthereumjsUnits.convert(balance, 'wei', 'ether'), })); // update tokens for this account diff --git a/src/actions/constants/blockchain.js b/src/actions/constants/blockchain.js index ff0d9b36..9e19d3e3 100644 --- a/src/actions/constants/blockchain.js +++ b/src/actions/constants/blockchain.js @@ -1,6 +1,4 @@ /* @flow */ export const READY: 'blockchain__ready' = 'blockchain__ready'; -export const CONNECTING: 'blockchain__connecting' = 'blockchain__connecting'; -export const CONNECTED: 'blockchain__connected' = 'blockchain__connected'; -export const DISCONNECTED: 'blockchain__disconnected' = 'blockchain__disconnected'; \ No newline at end of file +export const UPDATE_FEE: 'blockchain__update_fee' = 'blockchain__update_fee'; \ No newline at end of file diff --git a/src/actions/constants/discovery.js b/src/actions/constants/discovery.js index e7287b1e..a1b2a65e 100644 --- a/src/actions/constants/discovery.js +++ b/src/actions/constants/discovery.js @@ -3,6 +3,8 @@ export const START: 'discovery__start' = 'discovery__start'; export const STOP: 'discovery__stop' = 'discovery__stop'; +export const FIRMWARE_NOT_SUPPORTED: 'discovery__fw_not_supported' = 'discovery__fw_not_supported'; +export const FIRMWARE_OUTDATED: 'discovery__fw_outdated' = 'discovery__fw_outdated'; export const COMPLETE: 'discovery__complete' = 'discovery__complete'; export const WAITING_FOR_DEVICE: 'discovery__waiting_for_device' = 'discovery__waiting_for_device'; export const WAITING_FOR_BLOCKCHAIN: 'discovery__waiting_for_blockchain' = 'discovery__waiting_for_blockchain'; diff --git a/src/actions/constants/web3.js b/src/actions/constants/web3.js index 54b8836f..bed19c1f 100644 --- a/src/actions/constants/web3.js +++ b/src/actions/constants/web3.js @@ -7,5 +7,4 @@ export const CREATE: 'web3__create' = 'web3__create'; export const READY: 'web3__ready' = 'web3__ready'; export const BLOCK_UPDATED: 'web3__block_updated' = 'web3__block_updated'; export const GAS_PRICE_UPDATED: 'web3__gas_price_updated' = 'web3__gas_price_updated'; -export const PENDING_TX_RESOLVED: 'web3__pending_tx_resolved' = 'web3__pending_tx_resolved'; export const DISCONNECT: 'web3__disconnect' = 'web3__disconnect'; \ No newline at end of file diff --git a/src/actions/ethereum/BlockchainActions.js b/src/actions/ethereum/BlockchainActions.js new file mode 100644 index 00000000..fc27ce05 --- /dev/null +++ b/src/actions/ethereum/BlockchainActions.js @@ -0,0 +1,171 @@ +/* @flow */ + +import TrezorConnect from 'trezor-connect'; +import BigNumber from 'bignumber.js'; + +import type { + TrezorDevice, + Dispatch, + GetState, + PromiseAction, +} from 'flowtype'; +import type { EthereumAccount } from 'trezor-connect'; +import type { Token } from 'reducers/TokensReducer'; +import type { NetworkToken } from 'reducers/LocalStorageReducer'; +import * as Web3Actions from 'actions/Web3Actions'; +import * as AccountsActions from 'actions/AccountsActions'; + +export const discoverAccount = (device: TrezorDevice, address: string, network: string): PromiseAction => async (dispatch: Dispatch): Promise => { + // get data from connect + const txs = await TrezorConnect.ethereumGetAccountInfo({ + account: { + address, + block: 0, + transactions: 0, + balance: '0', + nonce: 0, + }, + coin: network, + }); + + if (!txs.success) { + throw new Error(txs.payload.error); + } + + // blockbook web3 fallback + const web3account = await dispatch(Web3Actions.discoverAccount(address, network)); + return { + address, + transactions: txs.payload.transactions, + block: txs.payload.block, + balance: web3account.balance, + nonce: web3account.nonce, + }; +}; + +export const getTokenInfo = (input: string, network: string): PromiseAction => async (dispatch: Dispatch): Promise => dispatch(Web3Actions.getTokenInfo(input, network)); + +export const getTokenBalance = (token: Token): PromiseAction => async (dispatch: Dispatch): Promise => dispatch(Web3Actions.getTokenBalance(token)); + + +export const getGasPrice = (network: string, defaultGasPrice: number): PromiseAction => async (dispatch: Dispatch): Promise => { + try { + const gasPrice = await dispatch(Web3Actions.getCurrentGasPrice(network)); + return gasPrice === '0' ? new BigNumber(defaultGasPrice) : new BigNumber(gasPrice); + } catch (error) { + return new BigNumber(defaultGasPrice); + } +}; + +const estimateProxy: Array> = []; +export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction => async (dispatch: Dispatch): Promise => { + // Since this method could be called multiple times in short period of time + // check for pending calls in proxy and if there more than two (first is current running and the second is waiting for result of first) + // TODO: should reject second call immediately? + if (estimateProxy.length > 0) { + // wait for proxy result (but do not process it) + await estimateProxy[0]; + } + + const call = dispatch(Web3Actions.estimateGasLimit(network, { + to: '', + data, + value, + gasPrice, + })); + // add current call to proxy + estimateProxy.push(call); + // wait for result + const result = await call; + // remove current call from proxy + estimateProxy.splice(0, 1); + // return result + return result; +}; + +export const subscribe = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const accounts: Array = getState().accounts.filter(a => a.network === network).map(a => a.address); // eslint-disable-line no-unused-vars + await TrezorConnect.blockchainSubscribe({ + accounts, + coin: network, + }); + // init web3 instance if not exists + await dispatch(Web3Actions.initWeb3(network)); +}; + +export const onBlockMined = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + // try to resolve pending transactions + await dispatch(Web3Actions.resolvePendingTransactions(network)); + + await dispatch(Web3Actions.updateGasPrice(network)); + + const accounts: Array = getState().accounts.filter(a => a.network === network); + if (accounts.length > 0) { + // find out which account changed + const response = await TrezorConnect.ethereumGetAccountInfo({ + accounts, + coin: network, + }); + + if (response.success) { + response.payload.forEach((a, i) => { + if (a.transactions > 0) { + // load additional data from Web3 (balance, nonce, tokens) + dispatch(Web3Actions.updateAccount(accounts[i], a, network)); + } else { + // there are no new txs, just update block + dispatch(AccountsActions.update({ ...accounts[i], block: a.block })); + + // HACK: since blockbook can't work with smart contracts for now + // try to update tokens balances added to this account using Web3 + dispatch(Web3Actions.updateAccountTokens(accounts[i])); + } + }); + } + } +}; + +export const onNotification = (/*network: string*/): PromiseAction => async (): Promise => { + // todo: get transaction history here + // console.warn("OnBlAccount", account); + // this event can be triggered multiple times + // // 1. check if pair [txid + address] is already in reducer + // const network: string = payload.coin.shortcut.toLowerCase(); + // const address: string = EthereumjsUtil.toChecksumAddress(payload.tx.address); + // const txInfo = await dispatch(Web3Actions.getPendingInfo(network, payload.tx.txid)); + + // // const exists = getState().pending.filter(p => p.id === payload.tx.txid && p.address === address); + // const exists = getState().pending.filter(p => p.address === address); + // if (exists.length < 1) { + // if (txInfo) { + // dispatch({ + // type: PENDING.ADD, + // payload: { + // type: 'send', + // id: payload.tx.txid, + // network, + // currency: 'tETH', + // amount: txInfo.value, + // total: '0', + // tx: {}, + // nonce: txInfo.nonce, + // address, + // rejected: false, + // }, + // }); + // } else { + // // tx info not found (yet?) + // // dispatch({ + // // type: PENDING.ADD_UNKNOWN, + // // payload: { + // // network, + // // ...payload.tx, + // // } + // // }); + // } + // } +}; + +export const onError = (network: string): PromiseAction => async (dispatch: Dispatch): Promise => { + dispatch(Web3Actions.disconnect(network)); +}; diff --git a/src/actions/ethereum/DiscoveryActions.js b/src/actions/ethereum/DiscoveryActions.js new file mode 100644 index 00000000..e4b302b2 --- /dev/null +++ b/src/actions/ethereum/DiscoveryActions.js @@ -0,0 +1,93 @@ +/* @flow */ + +import TrezorConnect from 'trezor-connect'; +import EthereumjsUtil from 'ethereumjs-util'; +import * as DISCOVERY from 'actions/constants/discovery'; +import * as BlockchainActions from 'actions/ethereum/BlockchainActions'; + +import type { + PromiseAction, + Dispatch, + TrezorDevice, + Network, + Account, +} from 'flowtype'; +import type { Discovery } from 'reducers/DiscoveryReducer'; + + +export type DiscoveryStartAction = { + type: typeof DISCOVERY.START, + networkType: 'ethereum', + network: Network, + device: TrezorDevice, + publicKey: string, + chainCode: string, + basePath: Array, +}; + +// first iteration +// generate public key for this account +// start discovery process +export const begin = (device: TrezorDevice, network: Network): PromiseAction => async (): Promise => { + // get xpub from TREZOR + const response = await TrezorConnect.getPublicKey({ + device: { + path: device.path, + instance: device.instance, + state: device.state, + }, + path: network.bip44, + keepSession: true, // acquire and hold session + //useEmptyPassphrase: !device.instance, + useEmptyPassphrase: device.useEmptyPassphrase, + network: network.name, + }); + + // handle TREZOR response error + if (!response.success) { + throw new Error(response.payload.error); + } + + const basePath: Array = response.payload.path; + + return { + type: DISCOVERY.START, + networkType: 'ethereum', + network, + device, + publicKey: response.payload.publicKey, + chainCode: response.payload.chainCode, + basePath, + }; +}; + +export const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): PromiseAction => async (dispatch: Dispatch): Promise => { + const derivedKey = discoveryProcess.hdKey.derive(`m/${discoveryProcess.accountIndex}`); + const path = discoveryProcess.basePath.concat(discoveryProcess.accountIndex); + const publicAddress: string = EthereumjsUtil.publicToAddress(derivedKey.publicKey, true).toString('hex'); + const ethAddress: string = EthereumjsUtil.toChecksumAddress(publicAddress); + const { network } = discoveryProcess; + + // TODO: check if address was created before + const account = await dispatch(BlockchainActions.discoverAccount(device, ethAddress, network)); + + // const accountIsEmpty = account.transactions <= 0 && account.nonce <= 0 && account.balance === '0'; + const empty = account.nonce <= 0 && account.balance === '0'; + + return { + index: discoveryProcess.accountIndex, + loaded: true, + network, + deviceID: device.features ? device.features.device_id : '0', + deviceState: device.state || '0', + addressPath: path, + address: ethAddress, + balance: account.balance, + availableBalance: account.balance, + sequence: account.nonce, + nonce: account.nonce, + block: account.block, + transactions: account.transactions, + empty, + }; +}; \ No newline at end of file diff --git a/src/actions/ethereum/SendFormActions.js b/src/actions/ethereum/SendFormActions.js new file mode 100644 index 00000000..60d02132 --- /dev/null +++ b/src/actions/ethereum/SendFormActions.js @@ -0,0 +1,587 @@ +/* @flow */ +import React from 'react'; +import Link from 'components/Link'; +import TrezorConnect from 'trezor-connect'; +import BigNumber from 'bignumber.js'; +import * as ACCOUNT from 'actions/constants/account'; +import * as NOTIFICATION from 'actions/constants/notification'; +import * as SEND from 'actions/constants/send'; +import * as PENDING from 'actions/constants/pendingTx'; +import * as WEB3 from 'actions/constants/web3'; +import { initialState } from 'reducers/SendFormEthereumReducer'; +import { findToken } from 'reducers/TokensReducer'; +import * as reducerUtils from 'reducers/utils'; +import * as ethUtils from 'utils/ethUtils'; + +import type { + Dispatch, + GetState, + State as ReducersState, + Action, + ThunkAction, + AsyncAction, + TrezorDevice, +} from 'flowtype'; +import type { State, FeeLevel } from 'reducers/SendFormEthereumReducer'; +import * as SessionStorageActions from '../SessionStorageActions'; +import { prepareEthereumTx, serializeEthereumTx } from '../TxActions'; +import * as BlockchainActions from './BlockchainActions'; + +import * as ValidationActions from './SendFormValidationActions'; + +// list of all actions which has influence on "sendFormEthereum" reducer +// other actions will be ignored +const actions = [ + ACCOUNT.UPDATE_SELECTED_ACCOUNT, + WEB3.GAS_PRICE_UPDATED, + ...Object.values(SEND).filter(v => typeof v === 'string'), +]; + +/* +* Called from WalletService +*/ +export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + // ignore not listed actions + if (actions.indexOf(action.type) < 0) return; + + const currentState = getState(); + // do not proceed if it's not "send" url + if (!currentState.router.location.state.send) return; + + // if action type is SEND.VALIDATION which is called as result of this process + // save data to session storage + if (action.type === SEND.VALIDATION) { + dispatch(SessionStorageActions.saveDraftTransaction()); + return; + } + + // if send form was not initialized + if (currentState.sendFormEthereum.currency === '') { + dispatch(init()); + return; + } + + // handle gasPrice update from backend + // recalculate fee levels if needed + if (action.type === WEB3.GAS_PRICE_UPDATED) { + dispatch(ValidationActions.onGasPriceUpdated(action.network, action.gasPrice)); + return; + } + + let shouldUpdate: boolean = false; + // check if "selectedAccount" reducer changed + shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, { + account: ['balance', 'nonce', 'tokens'], + }); + if (shouldUpdate && currentState.sendFormEthereum.currency !== currentState.sendFormEthereum.networkSymbol) { + // make sure that this token is added into account + const { account, tokens } = getState().selectedAccount; + if (!account) return; + const token = findToken(tokens, account.address, currentState.sendFormEthereum.currency, account.deviceState); + if (!token) { + // token not found, re-init form + dispatch(init()); + return; + } + } + + // check if "sendFormEthereum" reducer changed + if (!shouldUpdate) { + shouldUpdate = reducerUtils.observeChanges(prevState.sendFormEthereum, currentState.sendFormEthereum); + } + + if (shouldUpdate) { + const validated = dispatch(ValidationActions.validation()); + dispatch({ + type: SEND.VALIDATION, + networkType: 'ethereum', + state: validated, + }); + } +}; + +/* +* Called from "observe" action +* Initialize "sendFormEthereum" reducer data +* Get data either from session storage or "selectedAccount" reducer +*/ +export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { + account, + network, + } = getState().selectedAccount; + + if (!account || !network) return; + + const stateFromStorage = dispatch(SessionStorageActions.loadEthereumDraftTransaction()); + if (stateFromStorage) { + // TODO: consider if current gasPrice should be set here as "recommendedGasPrice" + dispatch({ + type: SEND.INIT, + networkType: 'ethereum', + state: stateFromStorage, + }); + return; + } + + const gasPrice: BigNumber = await dispatch(BlockchainActions.getGasPrice(network.shortcut, network.defaultGasPrice)); + const gasLimit = network.defaultGasLimit.toString(); + const feeLevels = ValidationActions.getFeeLevels(network.symbol, gasPrice, gasLimit); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel); + + dispatch({ + type: SEND.INIT, + networkType: 'ethereum', + state: { + ...initialState, + networkName: network.shortcut, + networkSymbol: network.symbol, + currency: network.symbol, + feeLevels, + selectedFeeLevel, + recommendedGasPrice: gasPrice.toString(), + gasLimit, + gasPrice: gasPrice.toString(), + }, + }); +}; + +/* +* Called from UI from "advanced" button +*/ +export const toggleAdvanced = (): Action => ({ + type: SEND.TOGGLE_ADVANCED, +}); + +/* +* Called from UI on "address" field change +*/ +export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendFormEthereum; + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + untouched: false, + touched: { ...state.touched, address: true }, + address, + }, + }); +}; + +/* +* Called from UI on "amount" field change +*/ +export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendFormEthereum; + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + untouched: false, + touched: { ...state.touched, amount: true }, + setMax: false, + amount, + }, + }); +}; + +/* +* Called from UI on "currency" selection change +*/ +export const onCurrencyChange = (currency: { value: string, label: string }): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { + account, + network, + } = getState().selectedAccount; + if (!account || !network) return; + + const state = getState().sendFormEthereum; + + const isToken = currency.value !== state.networkSymbol; + const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); + + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + currency: currency.value, + feeLevels, + selectedFeeLevel, + gasLimit, + }, + }); +}; + +/* +* Called from UI from "set max" button +*/ +export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendFormEthereum; + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + untouched: false, + touched: { ...state.touched, amount: true }, + setMax: !state.setMax, + }, + }); +}; + +/* +* Called from UI on "fee" selection change +*/ +export const onFeeLevelChange = (feeLevel: FeeLevel): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendFormEthereum; + + const isCustom = feeLevel.value === 'Custom'; + let newGasLimit = state.gasLimit; + let newGasPrice = state.gasPrice; + const advanced = isCustom ? true : state.advanced; + + if (!isCustom) { + // if selected fee is not custom + // update gasLimit to default and gasPrice to selected value + const { network } = getState().selectedAccount; + if (!network) return; + const isToken = state.currency !== state.networkSymbol; + if (isToken) { + newGasLimit = network.defaultGasLimitTokens.toString(); + } else { + // corner case: gas limit was changed by user OR by "estimateGasPrice" action + // leave gasLimit as it is + newGasLimit = state.touched.gasLimit ? state.gasLimit : network.defaultGasLimit.toString(); + } + newGasPrice = feeLevel.gasPrice; + } + + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + advanced, + selectedFeeLevel: feeLevel, + gasLimit: newGasLimit, + gasPrice: newGasPrice, + }, + }); +}; + +/* +* Called from UI from "update recommended fees" button +*/ +export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { + account, + network, + } = getState().selectedAccount; + if (!account || !network) return; + + const state: State = getState().sendFormEthereum; + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, state.gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + feeLevels, + selectedFeeLevel, + gasPrice: selectedFeeLevel.gasPrice, + gasPriceNeedsUpdate: false, + }, + }); +}; + +/* +* Called from UI on "gas price" field change +*/ +export const onGasPriceChange = (gasPrice: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendFormEthereum; + // switch to custom fee level + let newSelectedFeeLevel = state.selectedFeeLevel; + if (state.selectedFeeLevel.value !== 'Custom') newSelectedFeeLevel = state.feeLevels.find(f => f.value === 'Custom'); + + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + untouched: false, + touched: { ...state.touched, gasPrice: true }, + gasPrice, + selectedFeeLevel: newSelectedFeeLevel, + }, + }); +}; + +/* +* Called from UI on "data" field change +* OR from "estimateGasPrice" action +*/ +export const onGasLimitChange = (gasLimit: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { network } = getState().selectedAccount; + if (!network) return; + const state: State = getState().sendFormEthereum; + // recalculate feeLevels with recommended gasPrice + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + calculatingGasLimit: false, + untouched: false, + touched: { ...state.touched, gasLimit: true }, + gasLimit, + feeLevels, + selectedFeeLevel, + }, + }); +}; + +/* +* Called from UI on "nonce" field change +*/ +export const onNonceChange = (nonce: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendFormEthereum; + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + untouched: false, + touched: { ...state.touched, nonce: true }, + nonce, + }, + }); +}; + +/* +* Called from UI on "data" field change +*/ +export const onDataChange = (data: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendFormEthereum; + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + calculatingGasLimit: true, + untouched: false, + touched: { ...state.touched, data: true }, + data, + }, + }); + + dispatch(estimateGasPrice()); +}; + +export const setDefaultGasLimit = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendFormEthereum; + const { network } = getState().selectedAccount; + if (!network) return; + + const isToken = state.currency !== state.networkSymbol; + const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); + + dispatch({ + type: SEND.CHANGE, + networkType: 'ethereum', + state: { + ...state, + calculatingGasLimit: false, + untouched: false, + touched: { ...state.touched, gasLimit: false }, + gasLimit, + }, + }); +}; + +/* +* Internal method +* Called from "onDataChange" action +* try to asynchronously download data from backend +*/ +const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const state: State = getState().sendFormEthereum; + const { network } = getState().selectedAccount; + if (!network) { + // stop "calculatingGasLimit" process + dispatch(onGasLimitChange(state.gasLimit)); + return; + } + + const requestedData = state.data; + if (!ethUtils.isHex(requestedData)) { + // stop "calculatingGasLimit" process + dispatch(onGasLimitChange(requestedData.length > 0 ? state.gasLimit : network.defaultGasLimit.toString())); + return; + } + + if (state.data.length < 1) { + // set default + dispatch(onGasLimitChange(network.defaultGasLimit.toString())); + return; + } + + const gasLimit = await dispatch(BlockchainActions.estimateGasLimit(network.shortcut, state.data, state.amount, state.gasPrice)); + + // double check "data" field + // possible race condition when data changed before backend respond + if (getState().sendFormEthereum.data === requestedData) { + dispatch(onGasLimitChange(gasLimit)); + } +}; + +/* +* Called from UI from "send" button +*/ +export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { + account, + network, + pending, + } = getState().selectedAccount; + + if (!account || !network) return; + + const currentState: State = getState().sendFormEthereum; + + const isToken: boolean = currentState.currency !== currentState.networkSymbol; + const pendingNonce: number = reducerUtils.getPendingSequence(pending); + const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce; + + const txData = await dispatch(prepareEthereumTx({ + network: network.shortcut, + token: isToken ? findToken(getState().tokens, account.address, currentState.currency, account.deviceState) : null, + from: account.address, + to: currentState.address, + amount: currentState.amount, + data: currentState.data, + gasLimit: currentState.gasLimit, + gasPrice: currentState.gasPrice, + nonce, + })); + + const selected: ?TrezorDevice = getState().wallet.selectedDevice; + if (!selected) return; + + const signedTransaction = await TrezorConnect.ethereumSignTransaction({ + device: { + path: selected.path, + instance: selected.instance, + state: selected.state, + }, + // useEmptyPassphrase: !selected.instance, + useEmptyPassphrase: selected.useEmptyPassphrase, + path: account.addressPath, + transaction: txData, + }); + + if (!signedTransaction || !signedTransaction.success) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: signedTransaction.payload.error, + cancelable: true, + actions: [], + }, + }); + return; + } + + txData.r = signedTransaction.payload.r; + txData.s = signedTransaction.payload.s; + txData.v = signedTransaction.payload.v; + + try { + const serializedTx: string = await dispatch(serializeEthereumTx(txData)); + const push = await TrezorConnect.pushTransaction({ + tx: serializedTx, + coin: network.shortcut, + }); + + if (!push.success) { + throw new Error(push.payload.error); + } + + const { txid } = push.payload; + + dispatch({ type: SEND.TX_COMPLETE }); + + dispatch({ + type: PENDING.ADD, + payload: { + type: 'send', + deviceState: account.deviceState, + sequence: nonce, + hash: txid, + network: account.network, + address: account.address, + currency: currentState.currency, + amount: currentState.amount, + total: currentState.total, + fee: '0', // TODO: calculate fee + }, + }); + + // clear session storage + dispatch(SessionStorageActions.clear()); + + // reset form + dispatch(init()); + + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'success', + title: 'Transaction success', + message: See transaction detail, + cancelable: true, + actions: [], + }, + }); + } catch (error) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: error.message || error, + cancelable: true, + actions: [], + }, + }); + } +}; + +export default { + toggleAdvanced, + onAddressChange, + onAmountChange, + onCurrencyChange, + onSetMax, + onFeeLevelChange, + updateFeeLevels, + onGasPriceChange, + onGasLimitChange, + setDefaultGasLimit, + onNonceChange, + onDataChange, + onSend, +}; \ No newline at end of file diff --git a/src/actions/SendFormValidationActions.js b/src/actions/ethereum/SendFormValidationActions.js similarity index 98% rename from src/actions/SendFormValidationActions.js rename to src/actions/ethereum/SendFormValidationActions.js index c1a4fe9c..b7985b09 100644 --- a/src/actions/SendFormValidationActions.js +++ b/src/actions/ethereum/SendFormValidationActions.js @@ -13,7 +13,7 @@ import type { GetState, PayloadAction, } from 'flowtype'; -import type { State, FeeLevel } from 'reducers/SendFormReducer'; +import type { State, FeeLevel } from 'reducers/SendFormEthereumReducer'; // general regular expressions const NUMBER_RE: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$'); @@ -38,7 +38,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct // } // const newPrice = getRandomInt(10, 50).toString(); - const state = getState().sendForm; + const state = getState().sendFormEthereum; if (network === state.networkSymbol) return; // check if new price is different then currently recommended @@ -50,6 +50,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct // and let him update manually dispatch({ type: SEND.CHANGE, + networkType: 'ethereum', state: { ...state, gasPriceNeedsUpdate: true, @@ -62,6 +63,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct const selectedFeeLevel = getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); dispatch({ type: SEND.CHANGE, + networkType: 'ethereum', state: { ...state, gasPriceNeedsUpdate: false, @@ -81,7 +83,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct export const validation = (): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { // clone deep nested object // to avoid overrides across state history - let state: State = JSON.parse(JSON.stringify(getState().sendForm)); + let state: State = JSON.parse(JSON.stringify(getState().sendFormEthereum)); // reset errors state.errors = {}; state.warnings = {}; diff --git a/src/actions/ripple/BlockchainActions.js b/src/actions/ripple/BlockchainActions.js new file mode 100644 index 00000000..3f4cf884 --- /dev/null +++ b/src/actions/ripple/BlockchainActions.js @@ -0,0 +1,90 @@ +/* @flow */ + +import TrezorConnect from 'trezor-connect'; +import * as BLOCKCHAIN from 'actions/constants/blockchain'; +import * as PENDING from 'actions/constants/pendingTx'; +import * as AccountsActions from 'actions/AccountsActions'; +import { toDecimalAmount } from 'utils/formatUtils'; + +import type { BlockchainNotification } from 'trezor-connect'; +import type { + Dispatch, + GetState, + PromiseAction, +} from 'flowtype'; + + +export const subscribe = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const accounts: Array = getState().accounts.filter(a => a.network === network).map(a => a.address); + await TrezorConnect.blockchainSubscribe({ + accounts, + coin: network, + }); +}; + + +export const onBlockMined = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const fee = await TrezorConnect.blockchainGetFee({ + coin: network, + }); + if (!fee.success) return; + + const blockchain = getState().blockchain.find(b => b.shortcut === network); + if (!blockchain) return; + + if (fee.payload !== blockchain.fee) { + dispatch({ + type: BLOCKCHAIN.UPDATE_FEE, + shortcut: network, + fee: fee.payload, + }); + } +}; + +export const onNotification = (payload: $ElementType): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { notification } = payload; + const account = getState().accounts.find(a => a.address === notification.address); + if (!account) return; + + if (notification.status === 'pending') { + dispatch({ + type: PENDING.ADD, + payload: { + type: notification.type, + deviceState: account.deviceState, + sequence: account.sequence, + hash: notification.hash, + network: account.network, + address: account.address, + currency: account.network, + amount: notification.amount, + total: notification.amount, + fee: notification.fee, + }, + }); + + // todo: replace "send success" notification with link to explorer + } else if (notification.status === 'confirmed') { + dispatch({ + type: PENDING.TX_RESOLVED, + hash: notification.hash, + }); + } + + const updatedAccount = await TrezorConnect.rippleGetAccountInfo({ + account: { + address: account.address, + block: account.block, + history: false, + }, + }); + if (!updatedAccount.success) return; + + dispatch(AccountsActions.update({ + ...account, + balance: toDecimalAmount(updatedAccount.payload.balance, 6), + availableDevice: toDecimalAmount(updatedAccount.payload.availableBalance, 6), + block: updatedAccount.payload.block, + sequence: updatedAccount.payload.sequence, + })); +}; diff --git a/src/actions/ripple/DiscoveryActions.js b/src/actions/ripple/DiscoveryActions.js new file mode 100644 index 00000000..b87c7310 --- /dev/null +++ b/src/actions/ripple/DiscoveryActions.js @@ -0,0 +1,77 @@ +/* @flow */ + +import TrezorConnect from 'trezor-connect'; +import * as DISCOVERY from 'actions/constants/discovery'; +import { toDecimalAmount } from 'utils/formatUtils'; + +import type { + PromiseAction, + GetState, + Dispatch, + TrezorDevice, + Network, + Account, +} from 'flowtype'; +import type { Discovery } from 'reducers/DiscoveryReducer'; + +export type DiscoveryStartAction = { + type: typeof DISCOVERY.START, + networkType: 'ripple', + network: Network, + device: TrezorDevice, +}; + +export const begin = (device: TrezorDevice, network: Network): PromiseAction => async (): Promise => ({ + type: DISCOVERY.START, + networkType: 'ripple', + network, + device, +}); + +export const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { config } = getState().localStorage; + const network = config.networks.find(c => c.shortcut === discoveryProcess.network); + if (!network) throw new Error('Discovery network not found'); + + const { accountIndex } = discoveryProcess; + const path = network.bip44.slice(0).replace('a', accountIndex.toString()); + + const response = await TrezorConnect.rippleGetAccountInfo({ + device: { + path: device.path, + instance: device.instance, + state: device.state, + }, + account: { + path, + block: 0, + }, + keepSession: true, // acquire and hold session + useEmptyPassphrase: device.useEmptyPassphrase, + }); + + // handle TREZOR response error + if (!response.success) { + throw new Error(response.payload.error); + } + + const account = response.payload; + const empty = account.sequence <= 0 && account.balance === '0'; + + return { + index: discoveryProcess.accountIndex, + loaded: true, + network: network.shortcut, + deviceID: device.features ? device.features.device_id : '0', + deviceState: device.state || '0', + addressPath: account.path || [], + address: account.address, + balance: toDecimalAmount(account.balance, 6), + availableBalance: toDecimalAmount(account.availableBalance, 6), + sequence: account.sequence, + nonce: account.sequence, + block: account.block, + transactions: account.transactions, + empty, + }; +}; \ No newline at end of file diff --git a/src/actions/ripple/SendFormActions.js b/src/actions/ripple/SendFormActions.js new file mode 100644 index 00000000..c4ae7304 --- /dev/null +++ b/src/actions/ripple/SendFormActions.js @@ -0,0 +1,265 @@ +/* @flow */ +import React from 'react'; +import Link from 'components/Link'; +import TrezorConnect from 'trezor-connect'; +import * as NOTIFICATION from 'actions/constants/notification'; +import * as SEND from 'actions/constants/send'; +import * as BLOCKCHAIN from 'actions/constants/blockchain'; +import { initialState } from 'reducers/SendFormRippleReducer'; +import * as reducerUtils from 'reducers/utils'; +import { fromDecimalAmount } from 'utils/formatUtils'; + +import type { + Dispatch, + GetState, + State as ReducersState, + Action, + ThunkAction, + AsyncAction, + TrezorDevice, +} from 'flowtype'; +import type { State } from 'reducers/SendFormRippleReducer'; +import * as SessionStorageActions from '../SessionStorageActions'; + +import * as ValidationActions from './SendFormValidationActions'; + +/* +* Called from WalletService +*/ +export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const currentState = getState(); + + // if action type is SEND.VALIDATION which is called as result of this process + // save data to session storage + if (action.type === SEND.VALIDATION) { + dispatch(SessionStorageActions.saveDraftTransaction()); + return; + } + + // if send form was not initialized + if (currentState.sendFormRipple.networkSymbol === '') { + dispatch(init()); + return; + } + + // handle gasPrice update from backend + // recalculate fee levels if needed + if (action.type === BLOCKCHAIN.UPDATE_FEE) { + dispatch(ValidationActions.onFeeUpdated(action.shortcut, action.fee)); + return; + } + + let shouldUpdate: boolean = false; + // check if "selectedAccount" reducer changed + shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, { + account: ['balance', 'nonce'], + }); + + // check if "sendForm" reducer changed + if (!shouldUpdate) { + shouldUpdate = reducerUtils.observeChanges(prevState.sendFormRipple, currentState.sendFormRipple); + } + + if (shouldUpdate) { + const validated = dispatch(ValidationActions.validation()); + dispatch({ + type: SEND.VALIDATION, + networkType: 'ripple', + state: validated, + }); + } +}; + +/* +* Called from "observe" action +* Initialize "sendFormRipple" reducer data +* Get data either from session storage or "selectedAccount" reducer +*/ +export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { + account, + network, + } = getState().selectedAccount; + + if (!account || !network) return; + + const stateFromStorage = dispatch(SessionStorageActions.loadRippleDraftTransaction()); + if (stateFromStorage) { + dispatch({ + type: SEND.INIT, + networkType: 'ripple', + state: stateFromStorage, + }); + return; + } + + const feeLevels = dispatch(ValidationActions.getFeeLevels(network.symbol)); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel); + + dispatch({ + type: SEND.INIT, + networkType: 'ripple', + state: { + ...initialState, + networkName: network.shortcut, + networkSymbol: network.symbol, + feeLevels, + selectedFeeLevel, + sequence: '1', + }, + }); +}; + +/* +* Called from UI on "address" field change +*/ +export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendFormRipple; + dispatch({ + type: SEND.CHANGE, + networkType: 'ripple', + state: { + ...state, + untouched: false, + touched: { ...state.touched, address: true }, + address, + }, + }); +}; + +/* +* Called from UI on "amount" field change +*/ +export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendFormRipple; + dispatch({ + type: SEND.CHANGE, + networkType: 'ripple', + state: { + ...state, + untouched: false, + touched: { ...state.touched, amount: true }, + setMax: false, + amount, + }, + }); +}; + +/* +* Called from UI from "set max" button +*/ +export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendFormRipple; + dispatch({ + type: SEND.CHANGE, + networkType: 'ripple', + state: { + ...state, + untouched: false, + touched: { ...state.touched, amount: true }, + setMax: !state.setMax, + }, + }); +}; + + +/* +* Called from UI from "send" button +*/ +export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { + account, + network, + } = getState().selectedAccount; + + const selected: ?TrezorDevice = getState().wallet.selectedDevice; + if (!selected) return; + + if (!account || !network) return; + + const blockchain = getState().blockchain.find(b => b.shortcut === account.network); + if (!blockchain) return; + + const currentState: State = getState().sendFormRipple; + const amount = fromDecimalAmount(currentState.amount, 6); + + const signedTransaction = await TrezorConnect.rippleSignTransaction({ + device: { + path: selected.path, + instance: selected.instance, + state: selected.state, + }, + useEmptyPassphrase: selected.useEmptyPassphrase, + path: account.addressPath, + transaction: { + fee: blockchain.fee, // Fee must be in the range of 10 to 10,000 drops + flags: 0x80000000, + sequence: account.sequence, + payment: { + amount, + destination: currentState.address, + }, + }, + }); + + if (!signedTransaction || !signedTransaction.success) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: signedTransaction.payload.error, + cancelable: true, + actions: [], + }, + }); + return; + } + + const push = await TrezorConnect.pushTransaction({ + tx: signedTransaction.payload.serializedTx, + coin: network.shortcut, + }); + + if (!push.success) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: push.payload.error, + cancelable: true, + actions: [], + }, + }); + return; + } + + const { txid } = push.payload; + + dispatch({ type: SEND.TX_COMPLETE }); + + // clear session storage + dispatch(SessionStorageActions.clear()); + + // reset form + dispatch(init()); + + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'success', + title: 'Transaction success', + message: See transaction detail, + cancelable: true, + actions: [], + }, + }); +}; + +export default { + onAddressChange, + onAmountChange, + onSetMax, + onSend, +}; \ No newline at end of file diff --git a/src/actions/ripple/SendFormValidationActions.js b/src/actions/ripple/SendFormValidationActions.js new file mode 100644 index 00000000..c573997a --- /dev/null +++ b/src/actions/ripple/SendFormValidationActions.js @@ -0,0 +1,254 @@ +/* @flow */ + +import BigNumber from 'bignumber.js'; +import * as SEND from 'actions/constants/send'; +import { findDevice, getPendingAmount } from 'reducers/utils'; +import { toDecimalAmount } from 'utils/formatUtils'; + +import type { + Dispatch, + GetState, + PayloadAction, +} from 'flowtype'; +import type { State, FeeLevel } from 'reducers/SendFormRippleReducer'; + +// general regular expressions +const XRP_ADDRESS_RE = new RegExp('^r[1-9A-HJ-NP-Za-km-z]{25,34}$'); +const NUMBER_RE: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$'); +const XRP_6_RE = new RegExp('^(0|0\\.([0-9]{0,6})?|[1-9][0-9]*\\.?([0-9]{0,6})?|\\.[0-9]{0,6})$'); + +/* +* Called from SendFormActions.observe +* Reaction for BLOCKCHAIN.FEE_UPDATED action +*/ +export const onFeeUpdated = (network: string, fee: string): PayloadAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendFormRipple; + if (network === state.networkSymbol) return; + + + if (!state.untouched) { + // if there is a transaction draft let the user know + // and let him update manually + dispatch({ + type: SEND.CHANGE, + networkType: 'ripple', + state: { + ...state, + feeNeedsUpdate: true, + recommendedFee: fee, + }, + }); + return; + } + + // automatically update feeLevels and gasPrice + const feeLevels = dispatch(getFeeLevels(state.networkSymbol)); + const selectedFeeLevel = getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + dispatch({ + type: SEND.CHANGE, + networkType: 'ripple', + state: { + ...state, + feeNeedsUpdate: false, + recommendedFee: fee, + gasPrice: selectedFeeLevel.gasPrice, + feeLevels, + selectedFeeLevel, + }, + }); +}; + +/* +* Recalculate amount, total and fees +*/ +export const validation = (): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + // clone deep nested object + // to avoid overrides across state history + let state: State = JSON.parse(JSON.stringify(getState().sendFormRipple)); + // reset errors + state.errors = {}; + state.warnings = {}; + state.infos = {}; + state = dispatch(recalculateTotalAmount(state)); + state = dispatch(addressValidation(state)); + state = dispatch(addressLabel(state)); + state = dispatch(amountValidation(state)); + return state; +}; + +const recalculateTotalAmount = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const { + account, + pending, + } = getState().selectedAccount; + if (!account) return $state; + + const blockchain = getState().blockchain.find(b => b.shortcut === account.network); + if (!blockchain) return $state; + const fee = toDecimalAmount(blockchain.fee, 6); + + const state = { ...$state }; + + if (state.setMax) { + const pendingAmount = getPendingAmount(pending, state.networkSymbol, false); + const b = new BigNumber(account.balance).minus(pendingAmount); + state.amount = calculateMaxAmount(b, fee); + } + + state.total = calculateTotal(state.amount, fee); + return state; +}; + +/* +* Address value validation +*/ +const addressValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.address) return state; + + const { account, network } = getState().selectedAccount; + if (!account || !network) return state; + + const { address } = state; + + if (address.length < 1) { + state.errors.address = 'Address is not set'; + } else if (!address.match(XRP_ADDRESS_RE)) { + state.errors.address = 'Address is not valid'; + } else if (address.toLowerCase() === account.address.toLowerCase()) { + state.errors.address = 'Cannot send to myself'; + } + return state; +}; + +/* +* Address label assignation +*/ +const addressLabel = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.address || state.errors.address) return state; + + const { + account, + network, + } = getState().selectedAccount; + if (!account || !network) return state; + const { address } = state; + + const savedAccounts = getState().accounts.filter(a => a.address.toLowerCase() === address.toLowerCase()); + if (savedAccounts.length > 0) { + // check if found account belongs to this network + const currentNetworkAccount = savedAccounts.find(a => a.network === network.shortcut); + if (currentNetworkAccount) { + const device = findDevice(getState().devices, currentNetworkAccount.deviceID, currentNetworkAccount.deviceState); + if (device) { + state.infos.address = `${device.instanceLabel} Account #${(currentNetworkAccount.index + 1)}`; + } + } else { + // corner-case: the same derivation path is used on different networks + const otherNetworkAccount = savedAccounts[0]; + const device = findDevice(getState().devices, otherNetworkAccount.deviceID, otherNetworkAccount.deviceState); + const { networks } = getState().localStorage.config; + const otherNetwork = networks.find(c => c.shortcut === otherNetworkAccount.network); + if (device && otherNetwork) { + state.warnings.address = `Looks like it's ${device.instanceLabel} Account #${(otherNetworkAccount.index + 1)} address of ${otherNetwork.name} network`; + } + } + } + + return state; +}; + +/* +* Amount value validation +*/ +const amountValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.amount) return state; + + const { + account, + pending, + } = getState().selectedAccount; + if (!account) return state; + + const { amount } = state; + if (amount.length < 1) { + state.errors.amount = 'Amount is not set'; + } else if (amount.length > 0 && !amount.match(NUMBER_RE)) { + state.errors.amount = 'Amount is not a number'; + } else { + const pendingAmount: BigNumber = getPendingAmount(pending, state.networkSymbol); + + if (!state.amount.match(XRP_6_RE)) { + state.errors.amount = 'Maximum 6 decimals allowed'; + } else if (new BigNumber(state.total).greaterThan(new BigNumber(account.balance).minus(pendingAmount))) { + state.errors.amount = 'Not enough funds'; + } + } + return state; +}; + +/* +* UTILITIES +*/ + +const calculateTotal = (amount: string, fee: string): string => { + try { + return new BigNumber(amount).plus(fee).toString(10); + } catch (error) { + return '0'; + } +}; + +const calculateMaxAmount = (balance: BigNumber, fee: string): string => { + try { + // TODO - minus pendings + const max = balance.minus(fee); + if (max.lessThan(0)) return '0'; + return max.toString(10); + } catch (error) { + return '0'; + } +}; + +export const getFeeLevels = (symbol: string): PayloadAction> => (dispatch: Dispatch, getState: GetState): Array => { + const blockchain = getState().blockchain.find(b => b.shortcut === symbol.toLowerCase()); + if (!blockchain) { + // return default fee levels (TODO: get them from config) + return [{ + value: 'Normal', + gasPrice: '0.000012', + label: `0.000012 ${symbol}`, + }]; + } + + const xrpDrops = toDecimalAmount(blockchain.fee, 6); + + // TODO: calc fee levels + return [{ + value: 'Normal', + gasPrice: xrpDrops, + label: `${xrpDrops} ${symbol}`, + }]; +}; + +// export const getFeeLevels = (shortcut: string): Array => ([ +// { +// value: 'Normal', +// gasPrice: '1', +// label: `1 ${shortcut}`, +// }, +// ]); + + +export const getSelectedFeeLevel = (feeLevels: Array, selected: FeeLevel): FeeLevel => { + const { value } = selected; + let selectedFeeLevel: ?FeeLevel; + selectedFeeLevel = feeLevels.find(f => f.value === value); + if (!selectedFeeLevel) { + // fallback to default + selectedFeeLevel = feeLevels.find(f => f.value === 'Normal'); + } + return selectedFeeLevel || selected; +}; diff --git a/src/components/images/CoinLogo/images/ada.png b/src/components/images/CoinLogo/images/ada.png new file mode 100644 index 00000000..b07b5c1b Binary files /dev/null and b/src/components/images/CoinLogo/images/ada.png differ diff --git a/src/components/images/CoinLogo/images/xlm.png b/src/components/images/CoinLogo/images/xlm.png new file mode 100644 index 00000000..7c0deeb3 Binary files /dev/null and b/src/components/images/CoinLogo/images/xlm.png differ diff --git a/src/components/images/CoinLogo/images/xrp.png b/src/components/images/CoinLogo/images/xrp.png new file mode 100644 index 00000000..e7f8e702 Binary files /dev/null and b/src/components/images/CoinLogo/images/xrp.png differ diff --git a/src/components/modals/Container.js b/src/components/modals/Container.js index 62944a43..fa95094e 100644 --- a/src/components/modals/Container.js +++ b/src/components/modals/Container.js @@ -11,7 +11,7 @@ import type { State, Dispatch } from 'flowtype'; import Modal from './index'; -type OwnProps = { } +type OwnProps = {}; type StateProps = { modal: $ElementType, @@ -19,16 +19,17 @@ type StateProps = { devices: $ElementType, connect: $ElementType, selectedAccount: $ElementType, - sendForm: $ElementType, + sendFormEthereum: $ElementType, + sendFormRipple: $ElementType, receive: $ElementType, localStorage: $ElementType, wallet: $ElementType, -} +}; type DispatchProps = { modalActions: typeof ModalActions, receiveActions: typeof ReceiveActions, -} +}; export type Props = StateProps & DispatchProps; @@ -38,7 +39,8 @@ const mapStateToProps: MapStateToProps = (state: St devices: state.devices, connect: state.connect, selectedAccount: state.selectedAccount, - sendForm: state.sendForm, + sendFormEthereum: state.sendFormEthereum, + sendFormRipple: state.sendFormRipple, receive: state.receive, localStorage: state.localStorage, wallet: state.wallet, diff --git a/src/components/modals/confirm/SignTx/index.js b/src/components/modals/confirm/SignTx/index.js index fd7becf5..354c046e 100644 --- a/src/components/modals/confirm/SignTx/index.js +++ b/src/components/modals/confirm/SignTx/index.js @@ -12,12 +12,11 @@ import P from 'components/Paragraph'; import Icon from 'components/Icon'; import { H3 } from 'components/Heading'; -import type { TrezorDevice } from 'flowtype'; -import type { Props as BaseProps } from '../../Container'; +import type { TrezorDevice, State } from 'flowtype'; type Props = { device: TrezorDevice; - sendForm: $ElementType; + sendForm: $ElementType | $ElementType; } const Wrapper = styled.div` @@ -51,10 +50,11 @@ const ConfirmSignTx = (props: Props) => { const { amount, address, - currency, selectedFeeLevel, } = props.sendForm; + const currency: string = typeof props.sendForm.currency === 'string' ? props.sendForm.currency : props.sendForm.networkSymbol; + return (
diff --git a/src/components/modals/external/Cardano/images/cardano.png b/src/components/modals/external/Cardano/images/cardano.png new file mode 100644 index 00000000..b07b5c1b Binary files /dev/null and b/src/components/modals/external/Cardano/images/cardano.png differ diff --git a/src/components/modals/external/Cardano/index.js b/src/components/modals/external/Cardano/index.js new file mode 100644 index 00000000..bb413f36 --- /dev/null +++ b/src/components/modals/external/Cardano/index.js @@ -0,0 +1,70 @@ +/* @flow */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import colors from 'config/colors'; +import icons from 'config/icons'; +import Icon from 'components/Icon'; +import Link from 'components/Link'; +import Button from 'components/Button'; +import { H2 } from 'components/Heading'; +import P from 'components/Paragraph'; +import coins from 'constants/coins'; + +import CardanoImage from './images/cardano.png'; +import type { Props as BaseProps } from '../../Container'; + +type Props = { + onCancel: $ElementType<$ElementType, 'onCancel'>; +} + +const Wrapper = styled.div` + width: 100%; + max-width: 620px; + padding: 30px 48px; +`; + +const StyledButton = styled(Button)` + margin: 10px 0 10px 0; + width: 100%; +`; + +const StyledLink = styled(Link)` + position: absolute; + right: 15px; + top: 10px; +`; + +const Img = styled.img` + display: block; + max-width: 100px; + margin: 0 auto; + height: auto; + padding-bottom: 20px; +`; + +const CardanoWallet = (props: Props) => ( + + + + + +

Cardano wallet

+

You will be redirected to external wallet

+ + i.id === 'ada').url}> + Go to external wallet + +
+); + +CardanoWallet.propTypes = { + onCancel: PropTypes.func.isRequired, +}; + +export default CardanoWallet; \ No newline at end of file diff --git a/src/components/modals/external/NemWallet/images/nem-download.png b/src/components/modals/external/Nem/images/nem-download.png similarity index 100% rename from src/components/modals/external/NemWallet/images/nem-download.png rename to src/components/modals/external/Nem/images/nem-download.png diff --git a/src/components/modals/external/NemWallet/index.js b/src/components/modals/external/Nem/index.js similarity index 100% rename from src/components/modals/external/NemWallet/index.js rename to src/components/modals/external/Nem/index.js diff --git a/src/components/modals/external/Stellar/images/xlm.png b/src/components/modals/external/Stellar/images/xlm.png new file mode 100644 index 00000000..7c0deeb3 Binary files /dev/null and b/src/components/modals/external/Stellar/images/xlm.png differ diff --git a/src/components/modals/external/Stellar/index.js b/src/components/modals/external/Stellar/index.js new file mode 100644 index 00000000..c9a005a0 --- /dev/null +++ b/src/components/modals/external/Stellar/index.js @@ -0,0 +1,70 @@ +/* @flow */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import colors from 'config/colors'; +import icons from 'config/icons'; +import Icon from 'components/Icon'; +import Link from 'components/Link'; +import Button from 'components/Button'; +import { H2 } from 'components/Heading'; +import P from 'components/Paragraph'; +import coins from 'constants/coins'; + +import StellarImage from './images/xlm.png'; +import type { Props as BaseProps } from '../../Container'; + +type Props = { + onCancel: $ElementType<$ElementType, 'onCancel'>; +} + +const Wrapper = styled.div` + width: 100%; + max-width: 620px; + padding: 30px 48px; +`; + +const StyledButton = styled(Button)` + margin: 10px 0 10px 0; + width: 100%; +`; + +const StyledLink = styled(Link)` + position: absolute; + right: 15px; + top: 10px; +`; + +const Img = styled.img` + display: block; + max-width: 100px; + margin: 0 auto; + height: auto; + padding-bottom: 20px; +`; + +const StellarWallet = (props: Props) => ( + + + + + +

Stellar wallet

+

You will be redirected to external wallet

+ + i.id === 'xlm').url}> + Go to external wallet + +
+); + +StellarWallet.propTypes = { + onCancel: PropTypes.func.isRequired, +}; + +export default StellarWallet; \ No newline at end of file diff --git a/src/components/modals/index.js b/src/components/modals/index.js index 9e0a6330..0e5c5782 100644 --- a/src/components/modals/index.js +++ b/src/components/modals/index.js @@ -1,10 +1,10 @@ /* @flow */ import * as React from 'react'; -import { CSSTransition } from 'react-transition-group'; import styled from 'styled-components'; import colors from 'config/colors'; +import { FADE_IN } from 'config/animations'; import { UI } from 'trezor-connect'; import * as MODAL from 'actions/constants/modal'; @@ -25,19 +25,12 @@ import DuplicateDevice from 'components/modals/device/Duplicate'; import WalletType from 'components/modals/device/WalletType'; // external context -import NemWallet from 'components/modals/external/NemWallet'; +import Nem from 'components/modals/external/Nem'; +import Cardano from 'components/modals/external/Cardano'; +import Stellar from 'components/modals/external/Stellar'; import type { Props } from './Container'; -const Fade = (props: { children: React.Node}) => ( - { props.children } - -); - const ModalContainer = styled.div` position: fixed; z-index: 10000; @@ -51,6 +44,7 @@ const ModalContainer = styled.div` align-items: center; overflow: auto; padding: 20px; + animation: ${FADE_IN} 0.3s; `; const ModalWindow = styled.div` @@ -88,13 +82,22 @@ const getDeviceContextModal = (props: Props) => { case 'ButtonRequest_PassphraseType': return ; - case 'ButtonRequest_SignTx': - return ; + case 'ButtonRequest_SignTx': { + if (!props.selectedAccount.network) return null; + switch (props.selectedAccount.network.type) { + case 'ethereum': + return ; + case 'ripple': + return ; + default: return null; + } + } case 'ButtonRequest_ProtectCall': return ; case 'ButtonRequest_Other': + case 'ButtonRequest_ConfirmOutput': return ; case RECEIVE.REQUEST_UNVERIFIED: @@ -153,7 +156,11 @@ const getExternalContextModal = (props: Props) => { switch (modal.windowType) { case 'xem': - return (); + return (); + case 'xlm': + return (); + case 'ada': + return (); default: return null; } @@ -177,13 +184,11 @@ const Modal = (props: Props) => { } return ( - - - - { component } - - - + + + { component } + + ); }; diff --git a/src/config/animations.js b/src/config/animations.js index ea316217..866af919 100644 --- a/src/config/animations.js +++ b/src/config/animations.js @@ -56,4 +56,13 @@ export const PULSATE = keyframes` 50% { opacity: 1.0; } +`; + +export const FADE_IN = keyframes` + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } `; \ No newline at end of file diff --git a/src/constants/coins.js b/src/constants/coins.js index ec89bbce..13753734 100644 --- a/src/constants/coins.js +++ b/src/constants/coins.js @@ -55,4 +55,16 @@ export default [ url: 'https://nem.io/downloads/', external: true, }, + { + id: 'xlm', + coinName: 'Stellar', + url: 'https://trezor.io/stellar', + external: true, + }, + { + id: 'ada', + coinName: 'Cardano', + url: 'https://adalite.io/app', + external: true, + }, ]; \ No newline at end of file diff --git a/src/flowtype/index.js b/src/flowtype/index.js index bbcfdd86..f486b32c 100644 --- a/src/flowtype/index.js +++ b/src/flowtype/index.js @@ -42,8 +42,8 @@ import type { DeviceMode, DeviceMessageType, TransportMessageType, - BlockchainMessageType, UiMessageType, + BlockchainEvent, } from 'trezor-connect'; import type { RouterAction, LocationState } from 'react-router-redux'; @@ -111,11 +111,6 @@ type UiEventAction = { // }, } -type BlockchainEventAction = { - type: BlockchainMessageType, - payload: any, -} - // TODO: join this message with uiMessage type IFrameHandshake = { type: 'iframe_handshake', @@ -128,7 +123,7 @@ export type Action = | TransportEventAction | DeviceEventAction | UiEventAction - | BlockchainEventAction + | BlockchainEvent | SelectedAccountAction | AccountAction diff --git a/src/reducers/AccountsReducer.js b/src/reducers/AccountsReducer.js index 48883d61..e2fb75f3 100644 --- a/src/reducers/AccountsReducer.js +++ b/src/reducers/AccountsReducer.js @@ -20,9 +20,12 @@ export type Account = { +addressPath: Array; +address: string; balance: string; + availableBalance: string; + sequence: number; nonce: number; block: number; transactions: number; + empty: boolean; } export type State = Array; diff --git a/src/reducers/BlockchainReducer.js b/src/reducers/BlockchainReducer.js index 1b863232..e92d9468 100644 --- a/src/reducers/BlockchainReducer.js +++ b/src/reducers/BlockchainReducer.js @@ -1,59 +1,94 @@ /* @flow */ -import { BLOCKCHAIN } from 'trezor-connect'; +import { BLOCKCHAIN as BLOCKCHAIN_EVENT } from 'trezor-connect'; +import * as BLOCKCHAIN_ACTION from 'actions/constants/blockchain'; import type { Action } from 'flowtype'; +import type { BlockchainConnect, BlockchainError, BlockchainBlock } from 'trezor-connect'; export type BlockchainNetwork = { - +shortcut: string; - connected: boolean; -} + +shortcut: string, + connected: boolean, + fee: string, + block: number, +}; export type State = Array; export const initialState: State = []; -const find = (state: State, shortcut: string): number => state.findIndex(b => b.shortcut === shortcut); - -const connect = (state: State, action: any): State => { +const onConnect = (state: State, action: BlockchainConnect): State => { const shortcut = action.payload.coin.shortcut.toLowerCase(); - const network: BlockchainNetwork = { + const network = state.find(b => b.shortcut === shortcut); + const { info } = action.payload; + if (network) { + const others = state.filter(b => b !== network); + return others.concat([{ + ...network, + connected: true, + fee: info.fee, + block: info.block, + }]); + } + + return state.concat([{ shortcut, connected: true, - }; - const newState: State = [...state]; - const index: number = find(newState, shortcut); - if (index >= 0) { - newState[index] = network; - } else { - newState.push(network); - } - return newState; + fee: info.fee, + block: info.block, + }]); }; -const disconnect = (state: State, action: any): State => { +const onError = (state: State, action: BlockchainError): State => { const shortcut = action.payload.coin.shortcut.toLowerCase(); - const network: BlockchainNetwork = { - shortcut, - connected: false, - }; - const newState: State = [...state]; - const index: number = find(newState, shortcut); - if (index >= 0) { - newState[index] = network; - } else { - newState.push(network); + const network = state.find(b => b.shortcut === shortcut); + if (network) { + const others = state.filter(b => b !== network); + return others.concat([{ + ...network, + connected: false, + }]); } - return newState; + + return state; +}; + +const onBlock = (state: State, action: BlockchainBlock): State => { + const shortcut = action.payload.coin.shortcut.toLowerCase(); + const network = state.find(b => b.shortcut === shortcut); + if (network) { + const others = state.filter(b => b !== network); + return others.concat([{ + ...network, + block: action.payload.block, + }]); + } + + return state; +}; + +const updateFee = (state: State, shortcut: string, fee: string): State => { + const network = state.find(b => b.shortcut === shortcut); + if (!network) return state; + + const others = state.filter(b => b !== network); + return others.concat([{ + ...network, + fee, + }]); }; export default (state: State = initialState, action: Action): State => { switch (action.type) { - case BLOCKCHAIN.CONNECT: - return connect(state, action); - case BLOCKCHAIN.ERROR: - return disconnect(state, action); + case BLOCKCHAIN_EVENT.CONNECT: + return onConnect(state, action); + case BLOCKCHAIN_EVENT.ERROR: + return onError(state, action); + case BLOCKCHAIN_EVENT.BLOCK: + return onBlock(state, action); + case BLOCKCHAIN_ACTION.UPDATE_FEE: + return updateFee(state, action.shortcut, action.fee); default: return state; diff --git a/src/reducers/DiscoveryReducer.js b/src/reducers/DiscoveryReducer.js index 28294b2d..a490e4a4 100644 --- a/src/reducers/DiscoveryReducer.js +++ b/src/reducers/DiscoveryReducer.js @@ -19,9 +19,6 @@ import type { Account } from './AccountsReducer'; export type Discovery = { network: string; - publicKey: string; - chainCode: string; - hdKey: HDKey; basePath: Array; deviceState: string; accountIndex: number; @@ -29,34 +26,57 @@ export type Discovery = { completed: boolean; waitingForDevice: boolean; waitingForBlockchain: boolean; -} + fwNotSupported: boolean; + fwOutdated: boolean; + + publicKey: string; // used in ethereum only + chainCode: string; // used in ethereum only + hdKey: HDKey; // used in ethereum only +}; export type State = Array; const initialState: State = []; +const defaultDiscovery: Discovery = { + network: '', + deviceState: '', + basePath: [], + accountIndex: 0, + interrupted: false, + completed: false, + waitingForDevice: false, + waitingForBlockchain: false, + fwNotSupported: false, + fwOutdated: false, + + publicKey: '', + chainCode: '', + hdKey: null, +}; const findIndex = (state: State, network: string, deviceState: string): number => state.findIndex(d => d.network === network && d.deviceState === deviceState); const start = (state: State, action: DiscoveryStartAction): State => { const deviceState: string = action.device.state || '0'; - const hdKey: HDKey = new HDKey(); - hdKey.publicKey = Buffer.from(action.publicKey, 'hex'); - hdKey.chainCode = Buffer.from(action.chainCode, 'hex'); const instance: Discovery = { - network: action.network, - publicKey: action.publicKey, - chainCode: action.chainCode, - hdKey, - basePath: action.basePath, + ...defaultDiscovery, + network: action.network.shortcut, deviceState, - accountIndex: 0, - interrupted: false, - completed: false, - waitingForDevice: false, - waitingForBlockchain: false, }; + if (action.networkType === 'ethereum') { + const hdKey = new HDKey(); + hdKey.publicKey = Buffer.from(action.publicKey, 'hex'); + hdKey.chainCode = Buffer.from(action.chainCode, 'hex'); + + instance.hdKey = hdKey; + instance.publicKey = action.publicKey; + instance.chainCode = action.chainCode; + + instance.basePath = action.basePath; + } + const newState: State = [...state]; - const index: number = findIndex(state, action.network, deviceState); + const index: number = findIndex(state, action.network.shortcut, deviceState); if (index >= 0) { newState[index] = instance; } else { @@ -105,17 +125,10 @@ const stop = (state: State, device: TrezorDevice): State => { const waitingForDevice = (state: State, action: DiscoveryWaitingAction): State => { const deviceState: string = action.device.state || '0'; const instance: Discovery = { + ...defaultDiscovery, network: action.network, deviceState, - publicKey: '', - chainCode: '', - hdKey: null, - basePath: [], - accountIndex: 0, - interrupted: false, - completed: false, waitingForDevice: true, - waitingForBlockchain: false, }; const index: number = findIndex(state, action.network, deviceState); @@ -132,16 +145,9 @@ const waitingForDevice = (state: State, action: DiscoveryWaitingAction): State = const waitingForBlockchain = (state: State, action: DiscoveryWaitingAction): State => { const deviceState: string = action.device.state || '0'; const instance: Discovery = { + ...defaultDiscovery, network: action.network, deviceState, - publicKey: '', - chainCode: '', - hdKey: null, - basePath: [], - accountIndex: 0, - interrupted: false, - completed: false, - waitingForDevice: false, waitingForBlockchain: true, }; @@ -156,6 +162,19 @@ const waitingForBlockchain = (state: State, action: DiscoveryWaitingAction): Sta return newState; }; +const notSupported = (state: State, action: DiscoveryWaitingAction): State => { + const affectedProcesses = state.filter(d => d.deviceState === action.device.state && d.network === action.network); + const otherProcesses = state.filter(d => affectedProcesses.indexOf(d) === -1); + + const changedProcesses = affectedProcesses.map(d => ({ + ...d, + fwOutdated: action.type === DISCOVERY.FIRMWARE_OUTDATED, + fwNotSupported: action.type === DISCOVERY.FIRMWARE_NOT_SUPPORTED, + })); + + return otherProcesses.concat(changedProcesses); +}; + export default function discovery(state: State = initialState, action: Action): State { switch (action.type) { case DISCOVERY.START: @@ -170,6 +189,10 @@ export default function discovery(state: State = initialState, action: Action): return waitingForDevice(state, action); case DISCOVERY.WAITING_FOR_BLOCKCHAIN: return waitingForBlockchain(state, action); + case DISCOVERY.FIRMWARE_NOT_SUPPORTED: + return notSupported(state, action); + case DISCOVERY.FIRMWARE_OUTDATED: + return notSupported(state, action); case DISCOVERY.FROM_STORAGE: return action.payload.map((d) => { const hdKey: HDKey = new HDKey(); diff --git a/src/reducers/LocalStorageReducer.js b/src/reducers/LocalStorageReducer.js index c9d81925..976de717 100644 --- a/src/reducers/LocalStorageReducer.js +++ b/src/reducers/LocalStorageReducer.js @@ -6,7 +6,9 @@ import * as STORAGE from 'actions/constants/localStorage'; import type { Action } from 'flowtype'; export type Network = { + type: string; name: string; + testnet?: boolean; shortcut: string; symbol: string; bip44: string; diff --git a/src/reducers/PendingTxReducer.js b/src/reducers/PendingTxReducer.js index 7493c4c9..ed1f8bdd 100644 --- a/src/reducers/PendingTxReducer.js +++ b/src/reducers/PendingTxReducer.js @@ -1,62 +1,39 @@ /* @flow */ import * as CONNECT from 'actions/constants/TrezorConnect'; import * as PENDING from 'actions/constants/pendingTx'; -import * as SEND from 'actions/constants/send'; -import type { TrezorDevice, Action } from 'flowtype'; -import type { SendTxAction } from 'actions/SendFormActions'; +import type { Action } from 'flowtype'; export type PendingTx = { - +type: 'send' | 'receive'; - +id: string; - +network: string; - +address: string; - +deviceState: string; - +currency: string; - +amount: string; - +total: string; - +tx: any; - +nonce: number; - rejected: boolean; -} + +type: 'send' | 'recv', + +deviceState: string, + +sequence: number, + +hash: string, + +network: string, + +address: string, + +currency: string, + +amount: string, + +total: string, + +fee: string, + rejected?: boolean, +}; export type State = Array; const initialState: State = []; -const add = (state: State, action: SendTxAction): State => { - const newState = [...state]; - newState.push({ - type: 'send', - id: action.txid, - network: action.account.network, - address: action.account.address, - deviceState: action.account.deviceState, - - currency: action.selectedCurrency, - amount: action.amount, - total: action.total, - tx: action.tx, - nonce: action.nonce, - rejected: false, - }); - return newState; -}; - -/* -const addFromBloockbokNotifiaction = (state: State, payload: any): State => { +const add = (state: State, payload: PendingTx): State => { const newState = [...state]; newState.push(payload); return newState; }; -*/ -const clear = (state: State, device: TrezorDevice): State => state.filter(tx => tx.deviceState !== device.state); +const removeByDeviceState = (state: State, deviceState: ?string): State => state.filter(tx => tx.deviceState !== deviceState); -const remove = (state: State, id: string): State => state.filter(tx => tx.id !== id); +const removeByHash = (state: State, hash: string): State => state.filter(tx => tx.hash !== hash); -const reject = (state: State, id: string): State => state.map((tx) => { - if (tx.id === id && !tx.rejected) { +const reject = (state: State, hash: string): State => state.map((tx) => { + if (tx.hash === hash && !tx.rejected) { return { ...tx, rejected: true }; } return tx; @@ -64,21 +41,18 @@ const reject = (state: State, id: string): State => state.map((tx) => { export default function pending(state: State = initialState, action: Action): State { switch (action.type) { - case SEND.TX_COMPLETE: - return add(state, action); - case CONNECT.FORGET: case CONNECT.FORGET_SINGLE: case CONNECT.FORGET_SILENT: case CONNECT.RECEIVE_WALLET_TYPE: - return clear(state, action.device); + return removeByDeviceState(state, action.device.state); - // case PENDING.ADD: - // return add(state, action.payload); + case PENDING.ADD: + return add(state, action.payload); case PENDING.TX_RESOLVED: - return remove(state, action.tx.id); + return removeByHash(state, action.hash); case PENDING.TX_REJECTED: - return reject(state, action.tx.id); + return reject(state, action.hash); case PENDING.FROM_STORAGE: return action.payload; diff --git a/src/reducers/SendFormReducer.js b/src/reducers/SendFormEthereumReducer.js similarity index 94% rename from src/reducers/SendFormReducer.js rename to src/reducers/SendFormEthereumReducer.js index b370be6e..5053f673 100644 --- a/src/reducers/SendFormReducer.js +++ b/src/reducers/SendFormEthereumReducer.js @@ -75,16 +75,15 @@ export const initialState: State = { }; export default (state: State = initialState, action: Action): State => { + if (action.type === ACCOUNT.DISPOSE) return initialState; + if (!action.networkType || action.networkType !== 'ethereum') return state; + switch (action.type) { case SEND.INIT: case SEND.CHANGE: case SEND.VALIDATION: return action.state; - case ACCOUNT.DISPOSE: - return initialState; - - case SEND.TOGGLE_ADVANCED: return { ...state, diff --git a/src/reducers/SendFormRippleReducer.js b/src/reducers/SendFormRippleReducer.js new file mode 100644 index 00000000..f340936c --- /dev/null +++ b/src/reducers/SendFormRippleReducer.js @@ -0,0 +1,99 @@ +/* @flow */ + +import * as SEND from 'actions/constants/send'; +import * as ACCOUNT from 'actions/constants/account'; + +import type { Action } from 'flowtype'; + +export type FeeLevel = { + label: string; + gasPrice: string; + value: string; +} + +export type State = { + +networkName: string; + +networkSymbol: string; + + // form fields + advanced: boolean; + untouched: boolean; // set to true when user made any changes in form + touched: {[k: string]: boolean}; + address: string; + amount: string; + setMax: boolean; + feeLevels: Array; + selectedFeeLevel: FeeLevel; + recommendedFee: string; + feeNeedsUpdate: boolean; + sequence: string; + total: string; + + errors: {[k: string]: string}; + warnings: {[k: string]: string}; + infos: {[k: string]: string}; + + sending: boolean; +} + + +export const initialState: State = { + networkName: '', + networkSymbol: '', + + advanced: false, + untouched: true, + touched: {}, + address: '', + amount: '', + setMax: false, + feeLevels: [], + selectedFeeLevel: { + label: 'Normal', + gasPrice: '0', + value: 'Normal', + }, + recommendedFee: '0', + feeNeedsUpdate: false, + sequence: '0', + total: '0', + + errors: {}, + warnings: {}, + infos: {}, + + sending: false, +}; + +export default (state: State = initialState, action: Action): State => { + if (action.type === ACCOUNT.DISPOSE) return initialState; + if (!action.networkType || action.networkType !== 'ripple') return state; + + switch (action.type) { + case SEND.INIT: + case SEND.CHANGE: + case SEND.VALIDATION: + return action.state; + + case SEND.TOGGLE_ADVANCED: + return { + ...state, + advanced: !state.advanced, + }; + + case SEND.TX_SENDING: + return { + ...state, + sending: true, + }; + + case SEND.TX_ERROR: + return { + ...state, + sending: false, + }; + + default: + return state; + } +}; \ No newline at end of file diff --git a/src/reducers/index.js b/src/reducers/index.js index a6406053..498e5a70 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -10,7 +10,8 @@ import modal from 'reducers/ModalReducer'; import web3 from 'reducers/Web3Reducer'; import accounts from 'reducers/AccountsReducer'; import selectedAccount from 'reducers/SelectedAccountReducer'; -import sendForm from 'reducers/SendFormReducer'; +import sendFormEthereum from 'reducers/SendFormEthereumReducer'; +import sendFormRipple from 'reducers/SendFormRippleReducer'; import receive from 'reducers/ReceiveReducer'; import summary from 'reducers/SummaryReducer'; import tokens from 'reducers/TokensReducer'; @@ -32,7 +33,8 @@ const reducers = { web3, accounts, selectedAccount, - sendForm, + sendFormEthereum, + sendFormRipple, receive, summary, tokens, diff --git a/src/reducers/utils/index.js b/src/reducers/utils/index.js index c56cebdd..078535aa 100644 --- a/src/reducers/utils/index.js +++ b/src/reducers/utils/index.js @@ -89,9 +89,9 @@ export const getAccountPendingTx = (pending: Array, account: ?Account return pending.filter(p => p.network === a.network && p.address === a.address); }; -export const getPendingNonce = (pending: Array): number => pending.reduce((value: number, tx: PendingTx) => { +export const getPendingSequence = (pending: Array): number => pending.reduce((value: number, tx: PendingTx) => { if (tx.rejected) return value; - return Math.max(value, tx.nonce + 1); + return Math.max(value, tx.sequence + 1); }, 0); export const getPendingAmount = (pending: Array, currency: string, token: boolean = false): BigNumber => pending.reduce((value: BigNumber, tx: PendingTx) => { diff --git a/src/services/LocalStorageService.js b/src/services/LocalStorageService.js index ffd984e0..3f9775be 100644 --- a/src/services/LocalStorageService.js +++ b/src/services/LocalStorageService.js @@ -6,7 +6,6 @@ import * as CONNECT from 'actions/constants/TrezorConnect'; import * as TOKEN from 'actions/constants/token'; import * as ACCOUNT from 'actions/constants/account'; import * as DISCOVERY from 'actions/constants/discovery'; -import * as SEND from 'actions/constants/send'; import * as PENDING from 'actions/constants/pendingTx'; import * as WALLET from 'actions/constants/wallet'; @@ -59,7 +58,7 @@ const LocalStorageService: Middleware = (api: MiddlewareAPI) => (next: Middlewar api.dispatch(LocalStorageActions.save()); break; - case SEND.TX_COMPLETE: + case PENDING.ADD: case PENDING.TX_RESOLVED: case PENDING.TX_REJECTED: api.dispatch(LocalStorageActions.save()); diff --git a/src/services/TrezorConnectService.js b/src/services/TrezorConnectService.js index 397f9e0d..6984091f 100644 --- a/src/services/TrezorConnectService.js +++ b/src/services/TrezorConnectService.js @@ -58,11 +58,11 @@ const TrezorConnectService: Middleware = (api: MiddlewareAPI) => (next: Middlewa } else if (action.type === CONNECT.DUPLICATE) { api.dispatch(RouterActions.selectDevice(action.device)); } else if (action.type === BLOCKCHAIN.BLOCK) { - api.dispatch(BlockchainActions.onBlockMined(action.payload.coin)); + api.dispatch(BlockchainActions.onBlockMined(action.payload)); } else if (action.type === BLOCKCHAIN.NOTIFICATION) { - // api.dispatch(BlockchainActions.onNotification(action.payload)); + api.dispatch(BlockchainActions.onNotification(action.payload)); } else if (action.type === BLOCKCHAIN.ERROR) { - api.dispatch(BlockchainActions.error(action.payload)); + api.dispatch(BlockchainActions.onError(action.payload)); } return action; diff --git a/src/services/WalletService.js b/src/services/WalletService.js index b892a0c9..0c44a3a3 100644 --- a/src/services/WalletService.js +++ b/src/services/WalletService.js @@ -9,7 +9,7 @@ import * as NotificationActions from 'actions/NotificationActions'; import * as LocalStorageActions from 'actions/LocalStorageActions'; import * as TrezorConnectActions from 'actions/TrezorConnectActions'; import * as SelectedAccountActions from 'actions/SelectedAccountActions'; -import * as SendFormActionActions from 'actions/SendFormActions'; +import * as SendFormActions from 'actions/SendFormActions'; import * as DiscoveryActions from 'actions/DiscoveryActions'; import * as RouterActions from 'actions/RouterActions'; @@ -102,7 +102,7 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa // if "selectedDevice" didn't change observe common values in SelectedAccountReducer if (!await api.dispatch(SelectedAccountActions.observe(prevState, action))) { // if "selectedAccount" didn't change observe send form props changes - api.dispatch(SendFormActionActions.observe(prevState, action)); + api.dispatch(SendFormActions.observe(prevState, action)); } } else { // no changes in common values diff --git a/src/utils/formatUtils.js b/src/utils/formatUtils.js index 9009cc7e..f7e48b52 100644 --- a/src/utils/formatUtils.js +++ b/src/utils/formatUtils.js @@ -1,6 +1,7 @@ /* @flow */ -// TODO: chagne currency units +import BigNumber from 'bignumber.js'; + const currencyUnitsConstant: string = 'mbtc2'; export const formatAmount = (n: number, coinInfo: any, currencyUnits: string = currencyUnitsConstant): string => { @@ -52,3 +53,7 @@ export const hexToString = (hex: string): string => { } return str; }; + +export const toDecimalAmount = (amount: string, decimals: number): string => new BigNumber(amount).div(10 ** decimals).toString(10); + +export const fromDecimalAmount = (amount: string, decimals: number): string => new BigNumber(amount).times(10 ** decimals).toString(10); diff --git a/src/views/Wallet/Container.js b/src/views/Wallet/Container.js deleted file mode 100644 index e69de29b..00000000 diff --git a/src/views/Wallet/components/LeftNavigation/components/AccountMenu/index.js b/src/views/Wallet/components/LeftNavigation/components/AccountMenu/index.js index f41efa2a..24da4cdb 100644 --- a/src/views/Wallet/components/LeftNavigation/components/AccountMenu/index.js +++ b/src/views/Wallet/components/LeftNavigation/components/AccountMenu/index.js @@ -229,6 +229,10 @@ const AccountMenu = (props: Props) => { ); } + if (discovery && (discovery.fwNotSupported || discovery.fwOutdated)) { + discoveryStatus = null; + } + return ( diff --git a/src/views/Wallet/views/Account/Receive/Container.js b/src/views/Wallet/views/Account/Receive/ethereum/Container.js similarity index 100% rename from src/views/Wallet/views/Account/Receive/Container.js rename to src/views/Wallet/views/Account/Receive/ethereum/Container.js diff --git a/src/views/Wallet/views/Account/Receive/ethereum/index.js b/src/views/Wallet/views/Account/Receive/ethereum/index.js new file mode 100644 index 00000000..291fbcc8 --- /dev/null +++ b/src/views/Wallet/views/Account/Receive/ethereum/index.js @@ -0,0 +1,183 @@ +/* @flow */ +import React from 'react'; +import { QRCode } from 'react-qr-svg'; +import styled from 'styled-components'; + +import Title from 'views/Wallet/components/Title'; +import Button from 'components/Button'; +import Icon from 'components/Icon'; +import Tooltip from 'components/Tooltip'; +import Input from 'components/inputs/Input'; + +import ICONS from 'config/icons'; +import colors from 'config/colors'; +import { CONTEXT_DEVICE } from 'actions/constants/modal'; + +import Content from 'views/Wallet/components/Content'; +import VerifyAddressTooltip from '../components/VerifyAddressTooltip'; + +import type { Props } from './Container'; + +const Label = styled.div` + padding-bottom: 10px; + color: ${colors.TEXT_SECONDARY}; +`; + +const AddressWrapper = styled.div` + display: flex; + flex-wrap: wrap; + flex-direction: row; +`; + +const StyledQRCode = styled(QRCode)` + padding: 15px; + margin-top: 0 25px; + border: 1px solid ${colors.BODY}; +`; + +const ShowAddressButton = styled(Button)` + min-width: 195px; + padding: 0; + white-space: nowrap; + display: flex; + height: 40px; + align-items: center; + align-self: flex-end; + justify-content: center; + + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (max-width: 795px) { + margin-top: 10px; + align-self: auto; + border-radius: 3px; + } +`; + +const ShowAddressIcon = styled(Icon)` + margin-right: 7px; + position: relative; + top: 2px; +`; + +const EyeButton = styled(Button)` + z-index: 10001; + padding: 0; + width: 30px; + background: transparent; + top: 5px; + position: absolute; + right: 10px; + + &:hover { + background: transparent; + } +`; + +const Row = styled.div` + display: flex; + width: 100%; + padding-bottom: 28px; + + @media screen and (max-width: 795px) { + flex-direction: column; + } +`; + +const QrWrapper = styled.div` + display: flex; + flex-direction: column; +`; + +const AccountReceive = (props: Props) => { + const device = props.wallet.selectedDevice; + const { + account, + discovery, + shouldRender, + loader, + } = props.selectedAccount; + const { type, title, message } = loader; + if (!device || !account || !discovery || !shouldRender) return ; + + const { + addressVerified, + addressUnverified, + } = props.receive; + + const isAddressVerifying = props.modal.context === CONTEXT_DEVICE && props.modal.windowType === 'ButtonRequest_Address'; + const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified; + + let address = `${account.address.substring(0, 20)}...`; + if (addressVerified || addressUnverified || isAddressVerifying) { + ({ address } = account); + } + + return ( + + + Receive Ethereum or tokens + + + + + Check address on your Trezor + + ) : null} + icon={((addressVerified || addressUnverified) && !isAddressVerifying) && ( + + )} + > + props.showAddress(account.addressPath)}> + + + + )} + /> + {!(addressVerified || addressUnverified) && ( + props.showAddress(account.addressPath)} isDisabled={device.connected && !discovery.completed}> + Show full address + + )} + + {(addressVerified || addressUnverified) && !isAddressVerifying && ( + + + + + )} + + + + ); +}; + +export default AccountReceive; \ No newline at end of file diff --git a/src/views/Wallet/views/Account/Receive/index.js b/src/views/Wallet/views/Account/Receive/index.js index 67870d8e..60919d3f 100644 --- a/src/views/Wallet/views/Account/Receive/index.js +++ b/src/views/Wallet/views/Account/Receive/index.js @@ -1,183 +1,28 @@ /* @flow */ import React from 'react'; -import { QRCode } from 'react-qr-svg'; -import styled from 'styled-components'; +import { connect } from 'react-redux'; -import Title from 'views/Wallet/components/Title'; -import Button from 'components/Button'; -import Icon from 'components/Icon'; -import Tooltip from 'components/Tooltip'; -import Input from 'components/inputs/Input'; +import type { State } from 'flowtype'; +import EthereumTypeReceiveForm from './ethereum/Container'; +import RippleTypeReceiveForm from './ripple/Container'; -import ICONS from 'config/icons'; -import colors from 'config/colors'; -import { CONTEXT_DEVICE } from 'actions/constants/modal'; +export type BaseProps = { + selectedAccount: $ElementType, +} -import Content from 'views/Wallet/components/Content'; -import VerifyAddressTooltip from './components/VerifyAddressTooltip'; +// return container for requested network type +export default connect((state: State): BaseProps => ({ + selectedAccount: state.selectedAccount, +}), null)((props) => { + const { network } = props.selectedAccount; + if (!network) return null; -import type { Props } from './Container'; - -const Label = styled.div` - padding-bottom: 10px; - color: ${colors.TEXT_SECONDARY}; -`; - -const AddressWrapper = styled.div` - display: flex; - flex-wrap: wrap; - flex-direction: row; -`; - -const StyledQRCode = styled(QRCode)` - padding: 15px; - margin-top: 0 25px; - border: 1px solid ${colors.BODY}; -`; - -const ShowAddressButton = styled(Button)` - min-width: 195px; - padding: 0; - white-space: nowrap; - display: flex; - height: 40px; - align-items: center; - align-self: flex-end; - justify-content: center; - - border-top-left-radius: 0; - border-bottom-left-radius: 0; - - @media screen and (max-width: 795px) { - margin-top: 10px; - align-self: auto; - border-radius: 3px; + switch (network.type) { + case 'ethereum': + return ; + case 'ripple': + return ; + default: + return null; } -`; - -const ShowAddressIcon = styled(Icon)` - margin-right: 7px; - position: relative; - top: 2px; -`; - -const EyeButton = styled(Button)` - z-index: 10001; - padding: 0; - width: 30px; - background: transparent; - top: 5px; - position: absolute; - right: 10px; - - &:hover { - background: transparent; - } -`; - -const Row = styled.div` - display: flex; - width: 100%; - padding-bottom: 28px; - - @media screen and (max-width: 795px) { - flex-direction: column; - } -`; - -const QrWrapper = styled.div` - display: flex; - flex-direction: column; -`; - -const AccountReceive = (props: Props) => { - const device = props.wallet.selectedDevice; - const { - account, - discovery, - shouldRender, - loader, - } = props.selectedAccount; - const { type, title, message } = loader; - if (!device || !account || !discovery || !shouldRender) return ; - - const { - addressVerified, - addressUnverified, - } = props.receive; - - const isAddressVerifying = props.modal.context === CONTEXT_DEVICE && props.modal.windowType === 'ButtonRequest_Address'; - const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified; - - let address = `${account.address.substring(0, 20)}...`; - if (addressVerified || addressUnverified || isAddressVerifying) { - ({ address } = account); - } - - return ( - - - Receive Ethereum or tokens - - - - - Check address on your Trezor - - ) : null} - icon={((addressVerified || addressUnverified) && !isAddressVerifying) && ( - - )} - > - props.showAddress(account.addressPath)}> - - - - )} - /> - {!(addressVerified || addressUnverified) && ( - props.showAddress(account.addressPath)} isDisabled={device.connected && !discovery.completed}> - Show full address - - )} - - {(addressVerified || addressUnverified) && !isAddressVerifying && ( - - - - - )} - - - - ); -}; - -export default AccountReceive; +}); \ No newline at end of file diff --git a/src/views/Wallet/views/Account/Receive/ripple/Container.js b/src/views/Wallet/views/Account/Receive/ripple/Container.js new file mode 100644 index 00000000..27212056 --- /dev/null +++ b/src/views/Wallet/views/Account/Receive/ripple/Container.js @@ -0,0 +1,36 @@ +/* @flow */ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import { showAddress } from 'actions/ReceiveActions'; +import type { MapStateToProps, MapDispatchToProps } from 'react-redux'; +import type { State, Dispatch } from 'flowtype'; +import Receive from './index'; + +type OwnProps = { } + +type StateProps = { + selectedAccount: $ElementType, + receive: $ElementType, + modal: $ElementType, + wallet: $ElementType, +} + +type DispatchProps = { + showAddress: typeof showAddress +}; + +export type Props = StateProps & DispatchProps; + +const mapStateToProps: MapStateToProps = (state: State): StateProps => ({ + selectedAccount: state.selectedAccount, + receive: state.receive, + modal: state.modal, + wallet: state.wallet, +}); + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + showAddress: bindActionCreators(showAddress, dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Receive); \ No newline at end of file diff --git a/src/views/Wallet/views/Account/Receive/ripple/index.js b/src/views/Wallet/views/Account/Receive/ripple/index.js new file mode 100644 index 00000000..6bc0d7ed --- /dev/null +++ b/src/views/Wallet/views/Account/Receive/ripple/index.js @@ -0,0 +1,183 @@ +/* @flow */ +import React from 'react'; +import { QRCode } from 'react-qr-svg'; +import styled from 'styled-components'; + +import Title from 'views/Wallet/components/Title'; +import Button from 'components/Button'; +import Icon from 'components/Icon'; +import Tooltip from 'components/Tooltip'; +import Input from 'components/inputs/Input'; + +import ICONS from 'config/icons'; +import colors from 'config/colors'; +import { CONTEXT_DEVICE } from 'actions/constants/modal'; + +import Content from 'views/Wallet/components/Content'; +import VerifyAddressTooltip from '../components/VerifyAddressTooltip'; + +import type { Props } from './Container'; + +const Label = styled.div` + padding-bottom: 10px; + color: ${colors.TEXT_SECONDARY}; +`; + +const AddressWrapper = styled.div` + display: flex; + flex-wrap: wrap; + flex-direction: row; +`; + +const StyledQRCode = styled(QRCode)` + padding: 15px; + margin-top: 0 25px; + border: 1px solid ${colors.BODY}; +`; + +const ShowAddressButton = styled(Button)` + min-width: 195px; + padding: 0; + white-space: nowrap; + display: flex; + height: 40px; + align-items: center; + align-self: flex-end; + justify-content: center; + + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (max-width: 795px) { + margin-top: 10px; + align-self: auto; + border-radius: 3px; + } +`; + +const ShowAddressIcon = styled(Icon)` + margin-right: 7px; + position: relative; + top: 2px; +`; + +const EyeButton = styled(Button)` + z-index: 10001; + padding: 0; + width: 30px; + background: transparent; + top: 5px; + position: absolute; + right: 10px; + + &:hover { + background: transparent; + } +`; + +const Row = styled.div` + display: flex; + width: 100%; + padding-bottom: 28px; + + @media screen and (max-width: 795px) { + flex-direction: column; + } +`; + +const QrWrapper = styled.div` + display: flex; + flex-direction: column; +`; + +const AccountReceive = (props: Props) => { + const device = props.wallet.selectedDevice; + const { + account, + discovery, + shouldRender, + loader, + } = props.selectedAccount; + const { type, title, message } = loader; + if (!device || !account || !discovery || !shouldRender) return ; + + const { + addressVerified, + addressUnverified, + } = props.receive; + + const isAddressVerifying = props.modal.context === CONTEXT_DEVICE && props.modal.windowType === 'ButtonRequest_Address'; + const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified; + + let address = `${account.address.substring(0, 20)}...`; + if (addressVerified || addressUnverified || isAddressVerifying) { + ({ address } = account); + } + + return ( + + + Receive Ripple + + + + + Check address on your Trezor + + ) : null} + icon={((addressVerified || addressUnverified) && !isAddressVerifying) && ( + + )} + > + props.showAddress(account.addressPath)}> + + + + )} + /> + {!(addressVerified || addressUnverified) && ( + props.showAddress(account.addressPath)} isDisabled={device.connected && !discovery.completed}> + Show full address + + )} + + {(addressVerified || addressUnverified) && !isAddressVerifying && ( + + + + + )} + + + + ); +}; + +export default AccountReceive; \ No newline at end of file diff --git a/src/views/Wallet/views/Account/Send/components/AdvancedForm/index.js b/src/views/Wallet/views/Account/Send/components/AdvancedForm/index.js index 932e45af..93dafc4d 100644 --- a/src/views/Wallet/views/Account/Send/components/AdvancedForm/index.js +++ b/src/views/Wallet/views/Account/Send/components/AdvancedForm/index.js @@ -11,7 +11,7 @@ import Icon from 'components/Icon'; import ICONS from 'config/icons'; import { FONT_SIZE } from 'config/variables'; -import type { Props as BaseProps } from '../../Container'; +import type { Props as BaseProps } from '../../ethereum/Container'; type Props = BaseProps & { children: React.Node, diff --git a/src/views/Wallet/views/Account/Send/components/PendingTransactions/index.js b/src/views/Wallet/views/Account/Send/components/PendingTransactions/index.js index 63c5dfb8..b8c855ae 100644 --- a/src/views/Wallet/views/Account/Send/components/PendingTransactions/index.js +++ b/src/views/Wallet/views/Account/Send/components/PendingTransactions/index.js @@ -9,7 +9,7 @@ import ScaleText from 'react-scale-text'; import type { Network } from 'reducers/LocalStorageReducer'; import type { Token } from 'reducers/TokensReducer'; -import type { Props as BaseProps } from '../../Container'; +import type { BaseProps } from '../../index'; type Props = { pending: $PropertyType<$ElementType, 'pending'>, @@ -140,7 +140,7 @@ class PendingTransactions extends PureComponent {

Pending transactions

{this.getPendingTransactions().map(tx => ( this.getTransactionIconColors(tx).textColor} @@ -154,7 +154,7 @@ class PendingTransactions extends PureComponent { {this.getTransactionName(tx)} diff --git a/src/views/Wallet/views/Account/Send/ethereum/Container.js b/src/views/Wallet/views/Account/Send/ethereum/Container.js new file mode 100644 index 00000000..19982c8f --- /dev/null +++ b/src/views/Wallet/views/Account/Send/ethereum/Container.js @@ -0,0 +1,39 @@ +/* @flow */ + +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import SendFormActions from 'actions/ethereum/SendFormActions'; +import type { MapStateToProps, MapDispatchToProps } from 'react-redux'; +import type { State, Dispatch } from 'flowtype'; +import AccountSend from './index'; + +type OwnProps = {} + +export type StateProps = { + selectedAccount: $ElementType, + sendForm: $ElementType, + wallet: $ElementType, + fiat: $ElementType, + localStorage: $ElementType, +} + +export type DispatchProps = { + sendFormActions: typeof SendFormActions, +} + +export type Props = StateProps & DispatchProps; + +const mapStateToProps: MapStateToProps = (state: State): StateProps => ({ + selectedAccount: state.selectedAccount, + sendForm: state.sendFormEthereum, + wallet: state.wallet, + fiat: state.fiat, + localStorage: state.localStorage, +}); + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + sendFormActions: bindActionCreators(SendFormActions, dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(AccountSend); \ No newline at end of file diff --git a/src/views/Wallet/views/Account/Send/ethereum/index.js b/src/views/Wallet/views/Account/Send/ethereum/index.js new file mode 100644 index 00000000..4a97f416 --- /dev/null +++ b/src/views/Wallet/views/Account/Send/ethereum/index.js @@ -0,0 +1,403 @@ +/* @flow */ + +import React from 'react'; +import styled, { css } from 'styled-components'; +import { Select } from 'components/Select'; +import Button from 'components/Button'; +import Input from 'components/inputs/Input'; +import Icon from 'components/Icon'; +import Link from 'components/Link'; +import ICONS from 'config/icons'; +import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables'; +import colors from 'config/colors'; +import Title from 'views/Wallet/components/Title'; +import P from 'components/Paragraph'; +import Content from 'views/Wallet/components/Content'; +import type { Token } from 'flowtype'; +import AdvancedForm from '../components/AdvancedForm'; +import PendingTransactions from '../components/PendingTransactions'; + +import type { Props } from './Container'; + +// TODO: Decide on a small screen width for the whole app +// and put it inside config/variables.js +const SmallScreenWidth = '850px'; + +const AmountInputLabelWrapper = styled.div` + display: flex; + justify-content: space-between; +`; + +const AmountInputLabel = styled.span` + text-align: right; + color: ${colors.TEXT_SECONDARY}; +`; + +const InputRow = styled.div` + padding-bottom: 28px; +`; + +const SetMaxAmountButton = styled(Button)` + height: 40px; + padding: 0 10px; + display: flex; + align-items: center; + justify-content: center; + + font-size: ${FONT_SIZE.SMALLER}; + font-weight: ${FONT_WEIGHT.SMALLEST}; + color: ${colors.TEXT_SECONDARY}; + + border-radius: 0; + border: 1px solid ${colors.DIVIDER}; + border-right: 0; + border-left: 0; + background: transparent; + transition: ${TRANSITION.HOVER}; + + &:hover { + background: ${colors.GRAY_LIGHT}; + } + + ${props => props.isActive && css` + color: ${colors.WHITE}; + background: ${colors.GREEN_PRIMARY}; + border-color: ${colors.GREEN_PRIMARY}; + + &:hover { + background: ${colors.GREEN_SECONDARY}; + } + + &:active { + background: ${colors.GREEN_TERTIARY}; + } + `} +`; + +const CurrencySelect = styled(Select)` + min-width: 77px; + height: 40px; + flex: 0.2; +`; + +const FeeOptionWrapper = styled.div` + display: flex; + justify-content: space-between; +`; + +const FeeLabelWrapper = styled.div` + display: flex; + align-items: center; + padding-bottom: 10px; +`; + +const FeeLabel = styled.span` + color: ${colors.TEXT_SECONDARY}; +`; + +const UpdateFeeWrapper = styled.span` + margin-left: 8px; + display: flex; + align-items: center; + font-size: ${FONT_SIZE.SMALLER}; + color: ${colors.WARNING_PRIMARY}; +`; + +const StyledLink = styled(Link)` + margin-left: 4px; + white-space: nowrap; +`; + +const ToggleAdvancedSettingsWrapper = styled.div` + min-height: 40px; + margin-bottom: 20px; + display: flex; + flex-direction: row; + justify-content: space-between; + + @media screen and (max-width: ${SmallScreenWidth}) { + ${props => (props.isAdvancedSettingsHidden && css` + flex-direction: column; + `)} + } +`; + +const ToggleAdvancedSettingsButton = styled(Button)` + min-height: 40px; + padding: 0; + display: flex; + align-items: center; + font-weight: ${FONT_WEIGHT.BIGGER}; +`; + +const SendButton = styled(Button)` + min-width: ${props => (props.isAdvancedSettingsHidden ? '50%' : '100%')}; + font-size: 13px; + + @media screen and (max-width: ${SmallScreenWidth}) { + margin-top: ${props => (props.isAdvancedSettingsHidden ? '10px' : 0)}; + } +`; + +const AdvancedSettingsIcon = styled(Icon)` + margin-left: 10px; +`; + +// render helpers +const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => { + let state = ''; + if (address && !addressErrors) { + state = 'success'; + } + if (addressWarnings && !addressErrors) { + state = 'warning'; + } + if (addressErrors) { + state = 'error'; + } + return state; +}; + +const getAmountInputState = (amountErrors: string, amountWarnings: string): string => { + let state = ''; + if (amountWarnings && !amountErrors) { + state = 'warning'; + } + if (amountErrors) { + state = 'error'; + } + return state; +}; + +const getTokensSelectData = (tokens: Array, accountNetwork: any): Array<{ value: string, label: string }> => { + const tokensSelectData: Array<{ value: string, label: string }> = tokens.map(t => ({ value: t.symbol, label: t.symbol })); + tokensSelectData.unshift({ value: accountNetwork.symbol, label: accountNetwork.symbol }); + + return tokensSelectData; +}; + +// stateless component +const AccountSend = (props: Props) => { + const device = props.wallet.selectedDevice; + const { + account, + network, + discovery, + tokens, + shouldRender, + loader, + } = props.selectedAccount; + const { + address, + amount, + setMax, + networkSymbol, + currency, + feeLevels, + selectedFeeLevel, + gasPriceNeedsUpdate, + total, + errors, + warnings, + infos, + sending, + advanced, + } = props.sendForm; + + const { + toggleAdvanced, + onAddressChange, + onAmountChange, + onSetMax, + onCurrencyChange, + onFeeLevelChange, + updateFeeLevels, + onSend, + } = props.sendFormActions; + const { type, title, message } = loader; + if (!device || !account || !discovery || !network || !shouldRender) return ; + + const isCurrentCurrencyToken = networkSymbol !== currency; + + let selectedTokenBalance = 0; + const selectedToken = tokens.find(t => t.symbol === currency); + if (selectedToken) { + selectedTokenBalance = selectedToken.balance; + } + + let isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending; + let sendButtonText: string = 'Send'; + if (networkSymbol !== currency && amount.length > 0 && !errors.amount) { + sendButtonText += ` ${amount} ${currency.toUpperCase()}`; + } else if (networkSymbol === currency && total !== '0') { + sendButtonText += ` ${total} ${network.symbol}`; + } + + if (!device.connected) { + sendButtonText = 'Device is not connected'; + isSendButtonDisabled = true; + } else if (!device.available) { + sendButtonText = 'Device is unavailable'; + isSendButtonDisabled = true; + } else if (!discovery.completed) { + sendButtonText = 'Loading accounts'; + isSendButtonDisabled = true; + } + + const tokensSelectData = getTokensSelectData(tokens, network); + const tokensSelectValue = tokensSelectData.find(t => t.value === currency); + const isAdvancedSettingsHidden = !advanced; + + return ( + + Send Ethereum or tokens + + onAddressChange(event.target.value)} + /> + + + + Amount + {(isCurrentCurrencyToken && selectedToken) && ( + You have: {selectedTokenBalance} {selectedToken.symbol} + )} + + )} + value={amount} + onChange={event => onAmountChange(event.target.value)} + bottomText={errors.amount || warnings.amount || infos.amount} + sideAddons={[ + ( + onSetMax()} + isActive={setMax} + > + {!setMax && ( + + )} + {setMax && ( + + )} + Set max + + ), + ( + + ), + ]} + /> + + + + + Fee + {gasPriceNeedsUpdate && ( + + + Recommended fees updated. Click here to use them + + )} + + onAddressChange(event.target.value)} - /> - - - - Amount - {(isCurrentCurrencyToken && selectedToken) && ( - You have: {selectedTokenBalance} {selectedToken.symbol} - )} - - )} - value={amount} - onChange={event => onAmountChange(event.target.value)} - bottomText={errors.amount || warnings.amount || infos.amount} - sideAddons={[ - ( - onSetMax()} - isActive={setMax} - > - {!setMax && ( - - )} - {setMax && ( - - )} - Set max - - ), - ( - - ), - ]} - /> - - - - - Fee - {gasPriceNeedsUpdate && ( - - - Recommended fees updated. Click here to use them - - )} - - onAddressChange(event.target.value)} + /> + + + onAmountChange(event.target.value)} + bottomText={errors.amount || warnings.amount || infos.amount} + sideAddons={[ + ( + onSetMax()} + isActive={setMax} + > + {!setMax && ( + + )} + {setMax && ( + + )} + Set max + + ), + ( + + ), + ]} + /> + + + + onSend()} + > + {sendButtonText} + + + + + {props.selectedAccount.pending.length > 0 && ( + + )} + + ); +}; + +export default AccountSend; diff --git a/src/views/Wallet/views/Account/SignVerify/index.js b/src/views/Wallet/views/Account/SignVerify/index.js index d9b75cef..96e7e527 100644 --- a/src/views/Wallet/views/Account/SignVerify/index.js +++ b/src/views/Wallet/views/Account/SignVerify/index.js @@ -75,7 +75,7 @@ class SignVerify extends Component { const verifyAddressError = this.getError('verifyAddress'); return ( - Sign & Verify + Sign & Verify diff --git a/src/views/Wallet/views/Account/Summary/components/Balance/index.js b/src/views/Wallet/views/Account/Summary/components/Balance/index.js index dff913a7..64a4455f 100644 --- a/src/views/Wallet/views/Account/Summary/components/Balance/index.js +++ b/src/views/Wallet/views/Account/Summary/components/Balance/index.js @@ -7,13 +7,12 @@ import colors from 'config/colors'; import ICONS from 'config/icons'; import { FONT_SIZE, FONT_WEIGHT } from 'config/variables'; -import type { Network } from 'flowtype'; -import type { Props as BaseProps } from '../../Container'; +import type { Network, State as ReducersState } from 'flowtype'; type Props = { network: Network, balance: string, - fiat: $ElementType, + fiat: $ElementType, } type State = { diff --git a/src/views/Wallet/views/Account/Summary/Container.js b/src/views/Wallet/views/Account/Summary/ethereum/Container.js similarity index 100% rename from src/views/Wallet/views/Account/Summary/Container.js rename to src/views/Wallet/views/Account/Summary/ethereum/Container.js diff --git a/src/views/Wallet/views/Account/Summary/ethereum/index.js b/src/views/Wallet/views/Account/Summary/ethereum/index.js new file mode 100644 index 00000000..770fb273 --- /dev/null +++ b/src/views/Wallet/views/Account/Summary/ethereum/index.js @@ -0,0 +1,165 @@ +/* @flow */ +import styled from 'styled-components'; +import React from 'react'; +import { H2 } from 'components/Heading'; +import BigNumber from 'bignumber.js'; +import Icon from 'components/Icon'; +import { AsyncSelect } from 'components/Select'; +import ICONS from 'config/icons'; +import colors from 'config/colors'; +import Tooltip from 'components/Tooltip'; +import Content from 'views/Wallet/components/Content'; + +import CoinLogo from 'components/images/CoinLogo'; +import * as stateUtils from 'reducers/utils'; +import Link from 'components/Link'; +import { FONT_WEIGHT, FONT_SIZE } from 'config/variables'; +import AccountBalance from '../components/Balance'; +import AddedToken from '../components/Token'; +import AddTokenMessage from '../components/AddTokenMessage'; + +import type { Props } from './Container'; + +const AccountHeading = styled.div` + padding-bottom: 35px; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const H2Wrapper = styled.div` + display: flex; + align-items: center; + padding: 20px 0; +`; + +const StyledTooltip = styled(Tooltip)` + position: relative; + top: 2px; +`; + +const AccountName = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +const AccountTitle = styled.div` + font-size: ${FONT_SIZE.WALLET_TITLE}; + font-weight: ${FONT_WEIGHT.BASE}; + color: ${colors.WALLET_TITLE}; +`; + +const StyledCoinLogo = styled(CoinLogo)` + margin-right: 10px; +`; + +const StyledIcon = styled(Icon)` + position: relative; + top: -7px; + + &:hover { + cursor: pointer; + } +`; + +const AsyncSelectWrapper = styled.div` + padding-bottom: 32px; +`; + +const AddedTokensWrapper = styled.div``; + +const AccountSummary = (props: Props) => { + const device = props.wallet.selectedDevice; + const { + account, + network, + tokens, + pending, + loader, + shouldRender, + } = props.selectedAccount; + + const { type, title, message } = loader; + + if (!device || !account || !network || !shouldRender) return ; + + const explorerLink: string = `${network.explorer.address}${account.address}`; + const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol); + const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10); + + return ( + + + + + + Account #{parseInt(account.index, 10) + 1} + + See full transaction history + + + +

Tokens

+ + + +
+ + 'Loading...'} + noOptionsMessage={() => 'Token not found'} + onChange={(token) => { + if (token.name) { + const isAdded = tokens.find(t => t.symbol === token.symbol); + if (!isAdded) { + props.addToken(token, account); + } + } + }} + loadOptions={input => props.loadTokens(input, account.network)} + formatOptionLabel={(option) => { + const isAdded = tokens.find(t => t.symbol === option.symbol); + if (isAdded) { + return `${option.name} (Already added)`; + } + return option.name; + }} + getOptionLabel={option => option.name} + getOptionValue={option => option.symbol} + /> + + + { tokens.length < 1 && () } + {tokens.map(token => ( + + ))} + +
+
+ ); +}; + +export default AccountSummary; \ No newline at end of file diff --git a/src/views/Wallet/views/Account/Summary/index.js b/src/views/Wallet/views/Account/Summary/index.js index c179f9c0..7c5af467 100644 --- a/src/views/Wallet/views/Account/Summary/index.js +++ b/src/views/Wallet/views/Account/Summary/index.js @@ -1,165 +1,28 @@ /* @flow */ -import styled from 'styled-components'; import React from 'react'; -import { H2 } from 'components/Heading'; -import BigNumber from 'bignumber.js'; -import Icon from 'components/Icon'; -import { AsyncSelect } from 'components/Select'; -import ICONS from 'config/icons'; -import colors from 'config/colors'; -import Tooltip from 'components/Tooltip'; -import Content from 'views/Wallet/components/Content'; +import { connect } from 'react-redux'; -import CoinLogo from 'components/images/CoinLogo'; -import * as stateUtils from 'reducers/utils'; -import Link from 'components/Link'; -import { FONT_WEIGHT, FONT_SIZE } from 'config/variables'; -import AccountBalance from './components/Balance'; -import AddedToken from './components/Token'; -import AddTokenMessage from './components/AddTokenMessage'; +import type { State } from 'flowtype'; +import EthereumTypeSummary from './ethereum/Container'; +import RippleTypeSummary from './ripple/Container'; -import type { Props } from './Container'; +type WrapperProps = { + selectedAccount: $ElementType, +} -const AccountHeading = styled.div` - padding-bottom: 35px; - display: flex; - justify-content: space-between; - align-items: center; -`; +// return container for requested network type +export default connect((state: State): WrapperProps => ({ + selectedAccount: state.selectedAccount, +}), null)((props) => { + const { network } = props.selectedAccount; + if (!network) return null; -const H2Wrapper = styled.div` - display: flex; - align-items: center; - padding: 20px 0; -`; - -const StyledTooltip = styled(Tooltip)` - position: relative; - top: 2px; -`; - -const AccountName = styled.div` - display: flex; - justify-content: center; - align-items: center; -`; - -const AccountTitle = styled.div` - font-size: ${FONT_SIZE.WALLET_TITLE}; - font-weight: ${FONT_WEIGHT.MEDIUM}; - color: ${colors.WALLET_TITLE}; -`; - -const StyledCoinLogo = styled(CoinLogo)` - margin-right: 10px; -`; - -const StyledIcon = styled(Icon)` - position: relative; - top: -7px; - - &:hover { - cursor: pointer; + switch (network.type) { + case 'ethereum': + return ; + case 'ripple': + return ; + default: + return null; } -`; - -const AsyncSelectWrapper = styled.div` - padding-bottom: 32px; -`; - -const AddedTokensWrapper = styled.div``; - -const AccountSummary = (props: Props) => { - const device = props.wallet.selectedDevice; - const { - account, - network, - tokens, - pending, - loader, - shouldRender, - } = props.selectedAccount; - - const { type, title, message } = loader; - - if (!device || !account || !network || !shouldRender) return ; - - const explorerLink: string = `${network.explorer.address}${account.address}`; - const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol); - const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10); - - return ( - - - - - - Account #{parseInt(account.index, 10) + 1} - - See full transaction history - - - -

Tokens

- - - -
- - 'Loading...'} - noOptionsMessage={() => 'Token not found'} - onChange={(token) => { - if (token.name) { - const isAdded = tokens.find(t => t.symbol === token.symbol); - if (!isAdded) { - props.addToken(token, account); - } - } - }} - loadOptions={input => props.loadTokens(input, account.network)} - formatOptionLabel={(option) => { - const isAdded = tokens.find(t => t.symbol === option.symbol); - if (isAdded) { - return `${option.name} (Already added)`; - } - return option.name; - }} - getOptionLabel={option => option.name} - getOptionValue={option => option.symbol} - /> - - - { tokens.length < 1 && () } - {tokens.map(token => ( - - ))} - -
-
- ); -}; - -export default AccountSummary; +}); diff --git a/src/views/Wallet/views/Account/Summary/ripple/Container.js b/src/views/Wallet/views/Account/Summary/ripple/Container.js new file mode 100644 index 00000000..18d18175 --- /dev/null +++ b/src/views/Wallet/views/Account/Summary/ripple/Container.js @@ -0,0 +1,45 @@ +/* @flow */ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import type { MapStateToProps, MapDispatchToProps } from 'react-redux'; +import * as TokenActions from 'actions/TokenActions'; + +import type { State, Dispatch } from 'flowtype'; +import Summary from './index'; + +type OwnProps = { } + +type StateProps = { + selectedAccount: $ElementType, + summary: $ElementType, + wallet: $ElementType, + tokens: $ElementType, + fiat: $ElementType, + localStorage: $ElementType, +}; + +type DispatchProps = { + addToken: typeof TokenActions.add, + loadTokens: typeof TokenActions.load, + removeToken: typeof TokenActions.remove, +} + +export type Props = StateProps & DispatchProps; + +const mapStateToProps: MapStateToProps = (state: State): StateProps => ({ + selectedAccount: state.selectedAccount, + summary: state.summary, + wallet: state.wallet, + tokens: state.tokens, + fiat: state.fiat, + localStorage: state.localStorage, +}); + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + addToken: bindActionCreators(TokenActions.add, dispatch), + loadTokens: bindActionCreators(TokenActions.load, dispatch), + removeToken: bindActionCreators(TokenActions.remove, dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Summary); \ No newline at end of file diff --git a/src/views/Wallet/views/Account/Summary/ripple/index.js b/src/views/Wallet/views/Account/Summary/ripple/index.js new file mode 100644 index 00000000..1b97e841 --- /dev/null +++ b/src/views/Wallet/views/Account/Summary/ripple/index.js @@ -0,0 +1,115 @@ +/* @flow */ +import styled from 'styled-components'; +import React from 'react'; +import { H2 } from 'components/Heading'; +import BigNumber from 'bignumber.js'; +import Icon from 'components/Icon'; +import ICONS from 'config/icons'; +import colors from 'config/colors'; +import Tooltip from 'components/Tooltip'; +import Content from 'views/Wallet/components/Content'; + +import CoinLogo from 'components/images/CoinLogo'; +import * as stateUtils from 'reducers/utils'; +import Link from 'components/Link'; +import { FONT_WEIGHT, FONT_SIZE } from 'config/variables'; +import AccountBalance from '../components/Balance'; + +import type { Props } from './Container'; + +const AccountHeading = styled.div` + padding-bottom: 35px; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const H2Wrapper = styled.div` + display: flex; + align-items: center; + padding: 20px 0; +`; + +const StyledTooltip = styled(Tooltip)` + position: relative; + top: 2px; +`; + +const AccountName = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +const AccountTitle = styled.div` + font-size: ${FONT_SIZE.WALLET_TITLE}; + font-weight: ${FONT_WEIGHT.BASE}; + color: ${colors.WALLET_TITLE}; +`; + +const StyledCoinLogo = styled(CoinLogo)` + margin-right: 10px; +`; + +const StyledIcon = styled(Icon)` + position: relative; + top: -7px; + + &:hover { + cursor: pointer; + } +`; + +const AccountSummary = (props: Props) => { + const device = props.wallet.selectedDevice; + const { + account, + network, + pending, + loader, + shouldRender, + } = props.selectedAccount; + + const { type, title, message } = loader; + + if (!device || !account || !network || !shouldRender) return ; + + const explorerLink: string = `${network.explorer.address}${account.address}`; + const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol); + const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10); + + return ( + + + + + + Account #{parseInt(account.index, 10) + 1} + + See full transaction history + + + +

History

+ + + +
+
+
+ ); +}; + +export default AccountSummary; \ No newline at end of file diff --git a/src/views/index.js b/src/views/index.js index 20542adf..6e37aef5 100644 --- a/src/views/index.js +++ b/src/views/index.js @@ -16,9 +16,9 @@ import ImportView from 'views/Landing/views/Import/Container'; // wallet views import WalletContainer from 'views/Wallet'; -import AccountSummary from 'views/Wallet/views/Account/Summary/Container'; -import AccountSend from 'views/Wallet/views/Account/Send/Container'; -import AccountReceive from 'views/Wallet/views/Account/Receive/Container'; +import AccountSummary from 'views/Wallet/views/Account/Summary'; +import AccountSend from 'views/Wallet/views/Account/Send'; +import AccountReceive from 'views/Wallet/views/Account/Receive'; import AccountSignVerify from 'views/Wallet/views/Account/SignVerify/Container'; import WalletDashboard from 'views/Wallet/views/Dashboard'; diff --git a/webpack/local.babel.js b/webpack/local.babel.js index 3c4fe7eb..b6d1894e 100644 --- a/webpack/local.babel.js +++ b/webpack/local.babel.js @@ -50,7 +50,7 @@ module.exports = { rules: [ { test: /\.js?$/, - exclude: /node_modules/, + exclude: [/node_modules/, /trezor-blockchain-link\/build\/workers/], use: ['babel-loader'], }, { diff --git a/yarn.lock b/yarn.lock index a0e29534..52f0eb23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11896,10 +11896,9 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" -trezor-connect@6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/trezor-connect/-/trezor-connect-6.0.2.tgz#a4ca892cc4a167b34b97644e1404a56f6a110379" - integrity sha512-oSYTPnQD9ZT3vO2hBRdgealHG7t8Wu3oTZXX/U4eRxJZ0WqdEcXG7Nvqe1BL6Rl3VTuj7ALT9DL1Uq3QFYAc3g== +trezor-connect@6.0.3-beta.4: + version "6.0.3-beta.4" + resolved "https://registry.yarnpkg.com/trezor-connect/-/trezor-connect-6.0.3-beta.4.tgz#c0b3dfe809756b455e5b5b34fb2a104846da4f72" dependencies: babel-runtime "^6.26.0" events "^1.1.1"