diff --git a/.eslintignore b/.eslintignore index 340b57df..cb7239b8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,6 @@ public +build +build-devel coverage images node_modules diff --git a/.eslintrc b/.eslintrc index 7c00e41a..9f709b13 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,7 @@ "jest": true }, "rules": { + "import/prefer-default-export": 0, "no-use-before-define": 0, "no-plusplus": 0, "class-methods-use-this": 0, diff --git a/.flowconfig b/.flowconfig index 9a632b96..52287bb3 100644 --- a/.flowconfig +++ b/.flowconfig @@ -43,4 +43,5 @@ module.name_mapper='^data' -> '/src/data' module.name_mapper='^services' -> '/src/services' module.name_mapper='^support' -> '/src/support' module.name_mapper='^public' -> '/public' +module.name_mapper='^images' -> '/src/images' module.system=haste diff --git a/package.json b/package.json index 1f37e8ec..8f0cc7ab 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "scripts": { "dev": "npx webpack-dev-server --config ./webpack/dev.babel.js", "dev:local": "npx webpack-dev-server --config ./webpack/local.babel.js", - "build:clean": "rm -rf build", - "build:production": "npx webpack --config ./webpack/production.babel.js --progress --bail", + "build:prod": "rimraf build && npx webpack --config ./webpack/production.babel.js --progress --bail", + "build:dev": "rimraf build-devel && cross-env BUILD=development npx webpack --config ./webpack/production.babel.js --output-path build-devel --progress --bail", "build": "run-s build:*", "flow": "flow check src", "lint": "run-s lint:*", @@ -19,7 +19,9 @@ "lint:css": "npx stylelint './src/**/*.js'", "test": "run-s test:*", "test:unit": "npx jest", - "test-unit:watch": "npx jest -o --watch" + "test-unit:watch": "npx jest -o --watch", + "prod-server": "npx http-server ./build -a localhost -S -C ./server/cert.pem -K ./server/key.pem -o", + "prod-server-dev": "npx http-server ./build-devel -a localhost -S -C ./server/cert.pem -K ./server/key.pem -o" }, "dependencies": { "babel": "^6.23.0", @@ -27,6 +29,7 @@ "bignumber.js": "2.4.0", "color-hash": "^1.0.3", "copy-webpack-plugin": "^4.5.2", + "cross-env": "^5.2.0", "date-fns": "^1.29.0", "ethereumjs-tx": "^1.3.3", "ethereumjs-units": "^0.2.0", @@ -35,6 +38,7 @@ "git-revision-webpack-plugin": "^3.0.3", "hdkey": "^0.8.0", "html-webpack-plugin": "^3.2.0", + "http-server": "^0.11.1", "jest-fetch-mock": "^1.6.5", "npm-run-all": "^4.1.3", "prop-types": "^15.6.2", @@ -57,11 +61,13 @@ "redux-raven-middleware": "^1.2.0", "redux-thunk": "^2.2.0", "styled-components": "^3.4.9", + "rimraf": "^2.6.2", "styled-media-query": "^2.0.2", "styled-normalize": "^8.0.0", "trezor-connect": "^5.0.32", "web3": "1.0.0-beta.35", "webpack": "^4.16.3", + "webpack-build-notifier": "^0.1.29", "webpack-bundle-analyzer": "^2.13.1", "whatwg-fetch": "^2.0.4", "yarn-run-all": "^3.1.1" diff --git a/server/cert.pem b/server/cert.pem new file mode 100644 index 00000000..31a57cf6 --- /dev/null +++ b/server/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDMjCCAhoCCQD/Ey0A0Ll1FjANBgkqhkiG9w0BAQsFADBbMQswCQYDVQQGEwJj +ejEKMAgGA1UECAwBczEKMAgGA1UEBwwBYTEKMAgGA1UECgwBczEKMAgGA1UECwwB +czEKMAgGA1UEAwwBczEQMA4GCSqGSIb3DQEJARYBczAeFw0xODA3MjUxMTUyMzla +Fw0xODA4MjQxMTUyMzlaMFsxCzAJBgNVBAYTAmN6MQowCAYDVQQIDAFzMQowCAYD +VQQHDAFhMQowCAYDVQQKDAFzMQowCAYDVQQLDAFzMQowCAYDVQQDDAFzMRAwDgYJ +KoZIhvcNAQkBFgFzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1sON +vf+ny+KASV8pCT83fJNyAV8uAvg/Ur0B/bf26onvhoUJ74w6lFfJTyw0WjOzXR/G +reXg4yO8aGHTwSuYifY0Uj1Lm4cZYz3n96X37cKyviNg8zp+QXKgESwnxJ/VyLf+ +rYc/DzTQsXFs7irDXsGCq+8z56ljZ788axd5wip2AGBAnTzH9OeHD8sRIYNqFKNm +S2Rx1Ev3QIIwqng3e6vX4/kk7Zz380z1MpB2wbEsUQxx27hT4Z2tZRyn0L/AXQBL +H3zxA5FNVxgQaUAFWoqS6cv8nJtIRbnvoiwNygDMyxhrB+CbUCtCVDm8W94TE+Qn ++BdZzfPg/fQbv4B6owIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQC/g7yIDluDPOKP +mo37+hI4vQT90HnSzccMhelBoqHSw2OFN3O+eantCy9eNr9UNHeZP7KyTsvRW5nW +L48Ps1eAPC66QCNvkLmpJ3YRHa157722cPdgV0jYm5njV+9pdjNFFD/yF43nNm/r +IdiOwXak4qqajDeug9oeZh/sGPZ888gnZMaYhON8zzL+CR+xKv45ORVpHY+fQp4f +xv8195pqcehFy2RYniDzcU/cy3IfOJaxYYbV5mlQtFvY8plhC3QtcJebS0nei4J+ +oX2Wh1dxKUNGxqPUjwfyYxPtxPTUEsOJAtTtwcrQ2ShKCzFSP9xdZ9i4WviV84pU +9RGGV7yC +-----END CERTIFICATE----- diff --git a/server/key.pem b/server/key.pem new file mode 100644 index 00000000..587de4a2 --- /dev/null +++ b/server/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWw429/6fL4oBJ +XykJPzd8k3IBXy4C+D9SvQH9t/bqie+GhQnvjDqUV8lPLDRaM7NdH8at5eDjI7xo +YdPBK5iJ9jRSPUubhxljPef3pfftwrK+I2DzOn5BcqARLCfEn9XIt/6thz8PNNCx +cWzuKsNewYKr7zPnqWNnvzxrF3nCKnYAYECdPMf054cPyxEhg2oUo2ZLZHHUS/dA +gjCqeDd7q9fj+STtnPfzTPUykHbBsSxRDHHbuFPhna1lHKfQv8BdAEsffPEDkU1X +GBBpQAVaipLpy/ycm0hFue+iLA3KAMzLGGsH4JtQK0JUObxb3hMT5Cf4F1nN8+D9 +9Bu/gHqjAgMBAAECggEBAIbMgXgjMn/vcBQdjZVHP52KsoEX67pjdOOKzOgigvHd +mCE3+e+IdfBMVYfDOCzxzIAEBOF7qzcGZCikVpQlt/3IMjj4Ti+VkaLP5Xx0iPSM +Q0LC1AR2z25m8v80VtW8eSQeENV8UWFLBj6J8hRfdPdRwKIIZuzeTg19Y//X4U2z +0AQHc1askJc2Hu+FEm5GajurR4CEawrgHXtBaY8F96lGPRWXJThZhB8GemKkN8Ws +i8uMgdB4dcO7iGa1GpiSErCbJxC74/FA2XKf9eVYL7tRm1Bc/hojl1FxbWVnlC+W +RXqhdcKTHQ6LODYUovBCp5A5p5S/+zB24tnMgfIhYikCgYEA+1bzT4P2XuP4/A2N +yH3Da/fWlsoVo7/c9vy0AQhWo/ZH9mHiP1A7qC6b98T2DL0qsv0viGKBKPw0J1Cj +R4Tn3aKwPxsceHOEZ9zh1qApvaQH2kVMYrxbGCYGMIcj2Ps6HjPJGy3wH5T1+sco +u0qhIKs07TtoQe767HV+XWohxacCgYEA2r78nPhKpAQDiJ1uuqNPHybttnHX2OTv +f/LCM3B4pmORCSVostjqN6yUAUYBjvEczCkcyW3mTim7H9C92bePZmRrC8/7csYm +2ymJAzRMFlbnjIzF3EkBTcWermcrXwBchDQ6LlaKATT3qbxlewplFTE7h/3wa7da +kUPwHbJx+qUCgYAZCwbfS2TG+6wZYThZW76XCXDGQYh6cmmP6on8+Fm5qJZvBD3I +1TO8hDhiLavehRK2FugfjMEV1ltT94LtY16/BLDO+OKTVd9Bgg62lerSzH9DzlfY +FrB07YT8XNrDifS2ga5uGNuuKeeAf0udrcf0O1rgsGSo/SjfWq2mnSaUTQKBgHZl +iTUs7rl3srHvBE/gtKKX33IwjDPJNhh6vMI6zhLBMW9R4CltXthjgHhv+8fymTOn +zPz5jv4feDjwMtH0mJlDIO1z1RV6Su20vYQOemBdCVb5mt5wZVRC8nBTRxZUi77C +xfruvCOLF8G3RvYh2jRuQVqKB+dFhq+5pe1s+GRBAoGBAKX8oBMbm3e2eC3FzCKE +sB4BKLzGnQmoU4E/xUYLU+h5TTht4orsCil4Y9kkIE+jPP4Z84xqpfwN4QHw4Ls+ +SwBi+3C0HIo2Evss1D4ELs58N9nNnJRsGkqBqQkJIygpUVgYOTsCBOmjjm/5+Cd3 +RUE7p2ym8WCZmDkVkhFmZk1J +-----END PRIVATE KEY----- diff --git a/src/actions/AccountsActions.js b/src/actions/AccountsActions.js index 8c14e594..27d4c46b 100644 --- a/src/actions/AccountsActions.js +++ b/src/actions/AccountsActions.js @@ -1,7 +1,7 @@ /* @flow */ import * as ACCOUNT from 'actions/constants/account'; -import type { Action, TrezorDevice } from 'flowtype'; +import type { Action } from 'flowtype'; import type { Account, State } from 'reducers/AccountsReducer'; export type AccountFromStorageAction = { @@ -60,5 +60,5 @@ export const setNonce = (address: string, network: string, deviceState: string, export const update = (account: Account): Action => ({ type: ACCOUNT.UPDATE, - payload: account + payload: account, }); diff --git a/src/actions/BlockchainActions.js b/src/actions/BlockchainActions.js index 5414df9d..24c50251 100644 --- a/src/actions/BlockchainActions.js +++ b/src/actions/BlockchainActions.js @@ -1,42 +1,26 @@ /* @flow */ -import Web3 from 'web3'; -import HDKey from 'hdkey'; - -import EthereumjsUtil from 'ethereumjs-util'; -import EthereumjsUnits from 'ethereumjs-units'; -import EthereumjsTx from 'ethereumjs-tx'; import TrezorConnect from 'trezor-connect'; import BigNumber from 'bignumber.js'; - -import type { EstimateGasOptions } from 'web3'; -import type { TransactionStatus, TransactionReceipt } from 'web3'; -import { strip } from 'utils/ethUtils'; import * as BLOCKCHAIN from 'actions/constants/blockchain'; -import * as WEB3 from 'actions/constants/web3'; -import * as PENDING from 'actions/constants/pendingTx'; - -import * as AccountsActions from './AccountsActions'; -import * as Web3Actions from './Web3Actions'; import type { TrezorDevice, Dispatch, GetState, - Action, - AsyncAction, PromiseAction, - ThunkAction, } 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'; export type BlockchainAction = { type: typeof BLOCKCHAIN.READY, } -export const discoverAccount = (device: TrezorDevice, address: string, network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { +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({ @@ -44,8 +28,8 @@ export const discoverAccount = (device: TrezorDevice, address: string, network: address, block: 0, transactions: 0, - balance: "0", - nonce: 0 + balance: '0', + nonce: 0, }, coin: network, }); @@ -55,9 +39,9 @@ export const discoverAccount = (device: TrezorDevice, address: string, network: } // blockbook web3 fallback - const web3account = await dispatch( Web3Actions.discoverAccount(address, network) ); + const web3account = await dispatch(Web3Actions.discoverAccount(address, network)); // return { transactions: txs.payload, ...web3account }; - return { + return { address, transactions: txs.payload.transactions, block: txs.payload.block, @@ -66,35 +50,53 @@ export const discoverAccount = (device: TrezorDevice, address: string, network: }; }; -export const getTokenInfo = (input: string, network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - return await dispatch( Web3Actions.getTokenInfo(input, network) ); -} +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, getState: GetState): Promise => { - return await dispatch( Web3Actions.getTokenBalance(token) ); -} +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, getState: GetState): Promise => { +export const getGasPrice = (network: string, defaultGasPrice: number): PromiseAction => async (dispatch: Dispatch): Promise => { try { - const gasPrice = await dispatch( Web3Actions.getCurrentGasPrice(network) ); + const gasPrice = await dispatch(Web3Actions.getCurrentGasPrice(network)); return new BigNumber(gasPrice); } catch (error) { return new BigNumber(defaultGasPrice); } -} +}; -export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - return await dispatch( Web3Actions.estimateGasLimit(network, { to: '', data, value, gasPrice }) ); -} +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.resolvePendingTransactions(network)); - await dispatch( Web3Actions.updateGasPrice(network) ); + await dispatch(Web3Actions.updateGasPrice(network)); const accounts: Array = getState().accounts.filter(a => a.network === network); if (accounts.length > 0) { @@ -103,39 +105,37 @@ export const onBlockMined = (coinInfo: any): PromiseAction => async (dispa 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) ) + dispatch(Web3Actions.updateAccount(accounts[i], a, network)); } else { // there are no new txs, just update block - dispatch( AccountsActions.update( { ...accounts[i], block: a.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]) ); + 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 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 => { - return p.address === address; - }); + const exists = getState().pending.filter(p => p.address === address); if (exists.length < 1) { if (txInfo) { dispatch({ @@ -144,14 +144,14 @@ export const onNotification = (payload: any): PromiseAction => async (disp type: 'send', id: payload.tx.txid, network, - currency: "tETH", + currency: 'tETH', amount: txInfo.value, - total: "0", + total: '0', tx: {}, nonce: txInfo.nonce, address, - rejected: false - } + rejected: false, + }, }); } else { // tx info not found (yet?) @@ -164,18 +164,18 @@ export const onNotification = (payload: any): PromiseAction => async (disp // }); } } -} +}; +*/ 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); - // $FlowIssue: trezor-connect@5.0.32 - return await TrezorConnect.blockchainSubscribe({ + 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 + coin: network, }); -} +}; // Conditionally subscribe to blockchain backend // called after TrezorConnect.init successfully emits TRANSPORT.START event @@ -185,26 +185,26 @@ export const init = (): PromiseAction => async (dispatch: Dispatch, getSta if (getState().discovery.length > 0) { // get unique networks const networks: Array = []; - getState().discovery.forEach(discovery => { + getState().discovery.forEach((discovery) => { if (networks.indexOf(discovery.network) < 0) { networks.push(discovery.network); } }); // subscribe - for (let i = 0; i < networks.length; i++) { - await dispatch( subscribe(networks[i]) ); - } + const results = networks.map(n => dispatch(subscribe(n))); + // wait for all subscriptions + await Promise.all(results); } // continue wallet initialization dispatch({ - type: BLOCKCHAIN.READY + type: BLOCKCHAIN.READY, }); -} +}; // Handle BLOCKCHAIN.ERROR event from TrezorConnect // disconnect and remove Web3 webscocket instance if exists -export const error = (payload: any): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - dispatch( Web3Actions.disconnect(payload.coin) ); -} \ No newline at end of file +export const error = (payload: any): PromiseAction => async (dispatch: Dispatch): Promise => { + dispatch(Web3Actions.disconnect(payload.coin)); +}; \ No newline at end of file diff --git a/src/actions/DiscoveryActions.js b/src/actions/DiscoveryActions.js index 0daea108..cda570ed 100644 --- a/src/actions/DiscoveryActions.js +++ b/src/actions/DiscoveryActions.js @@ -9,10 +9,7 @@ import type { ThunkAction, AsyncAction, PromiseAction, Action, GetState, Dispatch, TrezorDevice, } from 'flowtype'; import type { Discovery, State } from 'reducers/DiscoveryReducer'; -import * as AccountsActions from './AccountsActions'; import * as BlockchainActions from './BlockchainActions'; -import { setBalance as setTokenBalance } from './TokenActions'; - export type DiscoveryStartAction = { type: typeof DISCOVERY.START, @@ -65,7 +62,7 @@ export const start = (device: TrezorDevice, network: string, ignoreCompleted?: b return; } - const discovery: State = getState().discovery; + const { discovery } = getState(); const discoveryProcess: ?Discovery = discovery.find(d => d.deviceState === device.state && d.network === network); if (!selected.connected && (!discoveryProcess || !discoveryProcess.completed)) { @@ -88,7 +85,7 @@ export const start = (device: TrezorDevice, network: string, ignoreCompleted?: b } if (!discoveryProcess) { - dispatch(begin(device, network)) + dispatch(begin(device, network)); } else if (discoveryProcess.completed && !ignoreCompleted) { dispatch({ type: DISCOVERY.COMPLETE, @@ -172,9 +169,8 @@ const begin = (device: TrezorDevice, network: string): AsyncAction => async (dis dispatch(start(device, network)); }; -const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { +const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch): Promise => { const { completed } = discoveryProcess; - discoveryProcess.completed = false; const derivedKey = discoveryProcess.hdKey.derive(`m/${discoveryProcess.accountIndex}`); const path = discoveryProcess.basePath.concat(discoveryProcess.accountIndex); @@ -183,15 +179,13 @@ const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): Asy const { network } = discoveryProcess; // TODO: check if address was created before - try { - const account = await dispatch( BlockchainActions.discoverAccount(device, ethAddress, network) ); + const account = await dispatch(BlockchainActions.discoverAccount(device, ethAddress, network)); if (discoveryProcess.interrupted) 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: { @@ -205,22 +199,20 @@ const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): Asy balance: account.balance, nonce: account.nonce, block: account.block, - transactions: account.transactions - } + transactions: account.transactions, + }, }); } if (accountIsEmpty) { - dispatch( finish(device, discoveryProcess) ); - } else { - if (!completed) { dispatch( discoverAccount(device, discoveryProcess) ); } + dispatch(finish(device, discoveryProcess)); + } else if (!completed) { + dispatch(discoverAccount(device, discoveryProcess)); } - } catch (error) { - dispatch({ type: DISCOVERY.STOP, - device + device, }); dispatch({ @@ -243,7 +235,7 @@ const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): Asy } }; -const finish = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { +const finish = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch): Promise => { await TrezorConnect.getFeatures({ device: { path: device.path, @@ -254,7 +246,7 @@ const finish = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction useEmptyPassphrase: !device.instance, }); - await dispatch( BlockchainActions.subscribe(discoveryProcess.network) ); + await dispatch(BlockchainActions.subscribe(discoveryProcess.network)); if (discoveryProcess.interrupted) return; @@ -263,13 +255,12 @@ const finish = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction device, network: discoveryProcess.network, }); +}; -} - -export const reconnect = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { +export const reconnect = (network: string): PromiseAction => async (dispatch: Dispatch): Promise => { await dispatch(BlockchainActions.subscribe(network)); dispatch(restore()); -} +}; export const restore = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const selected = getState().wallet.selectedDevice; diff --git a/src/actions/ModalActions.js b/src/actions/ModalActions.js index cc55e4af..25bebdfc 100644 --- a/src/actions/ModalActions.js +++ b/src/actions/ModalActions.js @@ -25,7 +25,7 @@ export const onPinSubmit = (value: string): Action => { }; export const onPassphraseSubmit = (passphrase: string): AsyncAction => async (dispatch: Dispatch): Promise => { - const resp = await TrezorConnect.uiResponse({ + await TrezorConnect.uiResponse({ type: UI.RECEIVE_PASSPHRASE, payload: { value: passphrase, diff --git a/src/actions/ReceiveActions.js b/src/actions/ReceiveActions.js index 51b7f548..6f193e48 100644 --- a/src/actions/ReceiveActions.js +++ b/src/actions/ReceiveActions.js @@ -48,7 +48,7 @@ export const showUnverifiedAddress = (): Action => ({ }); //export const showAddress = (address_n: string): AsyncAction => { -export const showAddress = (address_n: Array): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { +export const showAddress = (path: Array): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const selected = getState().wallet.selectedDevice; if (!selected) return; @@ -66,7 +66,7 @@ export const showAddress = (address_n: Array): AsyncAction => async (dis instance: selected.instance, state: selected.state, }, - path: address_n, + path, useEmptyPassphrase: !selected.instance, }); @@ -90,7 +90,7 @@ export const showAddress = (address_n: Array): AsyncAction => async (dis { label: 'Try again', callback: () => { - dispatch(showAddress(address_n)); + dispatch(showAddress(path)); }, }, ], diff --git a/src/actions/SelectedAccountActions.js b/src/actions/SelectedAccountActions.js index dd54a365..4e01c1a5 100644 --- a/src/actions/SelectedAccountActions.js +++ b/src/actions/SelectedAccountActions.js @@ -3,13 +3,9 @@ import { LOCATION_CHANGE } from 'react-router-redux'; import * as ACCOUNT from 'actions/constants/account'; -import * as SEND from 'actions/constants/send'; import * as NOTIFICATION from 'actions/constants/notification'; import * as PENDING from 'actions/constants/pendingTx'; -import * as SendFormActions from 'actions/SendFormActions'; -import * as SessionStorageActions from 'actions/SessionStorageActions'; - import * as stateUtils from 'reducers/utils'; import type { @@ -36,17 +32,6 @@ export const updateSelectedValues = (prevState: State, action: Action): AsyncAct const state: State = getState(); const { location } = state.router; - // reset form to default - if (action.type === SEND.TX_COMPLETE) { - // dispatch( SendFormActions.init() ); - // linear action - // SessionStorageActions.clear(location.pathname); - } - - if (prevState.sendForm !== state.sendForm) { - dispatch(SessionStorageActions.save()); - } - // handle devices state change (from trezor-connect events or location change) if (locationChange || prevState.accounts !== state.accounts @@ -85,11 +70,6 @@ export const updateSelectedValues = (prevState: State, action: Action): AsyncAct payload, }); - // initialize SendFormReducer - if (location.state.send && getState().sendForm.currency === '') { - dispatch(SendFormActions.init()); - } - if (location.state.send) { const rejectedTxs = pending.filter(tx => tx.rejected); rejectedTxs.forEach((tx) => { diff --git a/src/actions/SendFormActions.js b/src/actions/SendFormActions.js index eab6a786..1302d611 100644 --- a/src/actions/SendFormActions.js +++ b/src/actions/SendFormActions.js @@ -1,33 +1,27 @@ /* @flow */ - -import EthereumjsUtil from 'ethereumjs-util'; -import EthereumjsUnits from 'ethereumjs-units'; -import EthereumjsTx from 'ethereumjs-tx'; import TrezorConnect from 'trezor-connect'; import BigNumber from 'bignumber.js'; 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 { findDevice, getPendingAmount, getPendingNonce } from 'reducers/utils'; -import * as stateUtils from 'reducers/utils'; -import { validateAddress } from 'utils/ethUtils'; +import * as reducerUtils from 'reducers/utils'; import type { Dispatch, GetState, + State as ReducersState, Action, ThunkAction, AsyncAction, TrezorDevice, } from 'flowtype'; -import type { Coin } from 'reducers/LocalStorageReducer'; -import type { Token } from 'reducers/TokensReducer'; import type { State, FeeLevel } from 'reducers/SendFormReducer'; import type { Account } from 'reducers/AccountsReducer'; -import type { Props } from 'views/Wallet/views/AccountSend/Container'; import * as SessionStorageActions from './SessionStorageActions'; import { prepareEthereumTx, serializeEthereumTx } from './TxActions'; import * as BlockchainActions from './BlockchainActions'; @@ -44,188 +38,73 @@ export type SendTxAction = { txData: any, }; -export type SendFormAction = SendTxAction | { - type: typeof SEND.INIT, - state: State -} | { - type: typeof SEND.DISPOSE -} | { - type: typeof SEND.TOGGLE_ADVANCED -} | { - type: typeof SEND.VALIDATION, - errors: {[k: string]: string}, - warnings: {[k: string]: string}, - infos: {[k: string]: string} -} | { - type: typeof SEND.ADDRESS_VALIDATION, - state: State -} | { - type: typeof SEND.ADDRESS_CHANGE, - state: State -} | { - type: typeof SEND.AMOUNT_CHANGE, - state: State -} | { - type: typeof SEND.CURRENCY_CHANGE, - state: State -} | { - type: typeof SEND.SET_MAX, - state: State -} | { - type: typeof SEND.FEE_LEVEL_CHANGE, - state: State -} | { - type: typeof SEND.UPDATE_FEE_LEVELS, - state: State -} | { - type: typeof SEND.FEE_LEVEL_CHANGE, - state: State -} | { - type: typeof SEND.GAS_PRICE_CHANGE, - state: State -} | { - type: typeof SEND.GAS_LIMIT_CHANGE, - state: State +export type SendFormAction = { + type: typeof SEND.INIT | typeof SEND.VALIDATION | typeof SEND.CHANGE, + state: State, } | { - type: typeof SEND.NONCE_CHANGE, - state: State -} | { - type: typeof SEND.DATA_CHANGE, - state: State -} | { - type: typeof SEND.SEND, -} | { - type: typeof SEND.TX_ERROR, -} | { - type: typeof SEND.FROM_SESSION_STORAGE, - address: string, - amount: string, - setMax: boolean, - selectedCurrency: string, - selectedFeeLevel: any, - advanced: boolean, - gasLimit: string, - gasPrice: string, - data: string, - nonce: string, - touched: any, -} - -//const numberRegExp = new RegExp('^([0-9]{0,10}\\.)?[0-9]{1,18}$'); -const numberRegExp: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$'); - -export const calculateFee = (gasPrice: string, gasLimit: string): string => { - try { - return EthereumjsUnits.convert(new BigNumber(gasPrice).times(gasLimit), 'gwei', 'ether'); - } catch (error) { - return '0'; + type: typeof SEND.TOGGLE_ADVANCED | typeof SEND.TX_SENDING | typeof SEND.TX_ERROR, +} | SendTxAction; + +/* +* Called from WalletService on EACH action +*/ +export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + 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; } -}; -export const calculateTotal = (amount: string, gasPrice: string, gasLimit: string): string => { - try { - return new BigNumber(amount).plus(calculateFee(gasPrice, gasLimit)).toString(10); - } catch (error) { - return '0'; + // if send form was not initialized + if (currentState.sendForm.currency === '') { + dispatch(init()); + return; } -}; -export const calculateMaxAmount = (balance: BigNumber, gasPrice: string, gasLimit: string): string => { - try { - // TODO - minus pendings - const fee = calculateFee(gasPrice, gasLimit); - const max = balance.minus(fee); - if (max.lessThan(0)) return '0'; - return max.toString(10); - } catch (error) { - return '0'; + // 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; } -}; - -export const calculate = (prevProps: Props, props: Props) => { - const { - account, - tokens, - pending, - } = props.selectedAccount; - if (!account) return; - - const state = props.sendForm; - const isToken: boolean = state.currency !== state.networkSymbol; - - // account balance - // token balance - // gasLimit, gasPrice changed - - // const shouldRecalculateAmount = - // (prevProps.selectedAccount.account !== account) - // || (prevProps.) - - if (state.setMax) { - const pendingAmount: BigNumber = getPendingAmount(pending, state.currency, isToken); - - if (isToken) { - const token: ?Token = findToken(tokens, account.address, state.currency, account.deviceState); - if (token) { - state.amount = new BigNumber(token.balance).minus(pendingAmount).toString(10); - } - } else { - const b = new BigNumber(account.balance).minus(pendingAmount); - state.amount = calculateMaxAmount(b, state.gasPrice, state.gasLimit); - } + let shouldUpdate: boolean = false; + // check if "selectedAccount" reducer changed + const selectedAccountChanged = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, ['account', 'tokens', 'pending']); + if (selectedAccountChanged) { + // double check + // there are only few fields that we are interested in + // check them to avoid unnecessary calculation and validation + const accountChanged = reducerUtils.observeChanges(prevState.selectedAccount.account, currentState.selectedAccount.account, ['balance', 'nonce']); + const tokensChanged = reducerUtils.observeChanges(prevState.selectedAccount.tokens, currentState.selectedAccount.tokens); + const pendingChanged = reducerUtils.observeChanges(prevState.selectedAccount.pending, currentState.selectedAccount.pending); + shouldUpdate = accountChanged || tokensChanged || pendingChanged; } - // amount changed - // fee changed - state.total = calculateTotal(isToken ? '0' : state.amount, state.gasPrice, state.gasLimit); - - if (state.selectedFeeLevel.value === 'Custom') { - state.selectedFeeLevel.label = `${calculateFee(state.gasPrice, state.gasLimit)} ${state.networkSymbol}`; - state.selectedFeeLevel.gasPrice = state.gasPrice; + // check if "sendForm" reducer changed + if (!shouldUpdate) { + shouldUpdate = reducerUtils.observeChanges(prevState.sendForm, currentState.sendForm); } -}; - -export const getFeeLevels = (symbol: string, gasPrice: BigNumber | string, gasLimit: string, selected?: FeeLevel): Array => { - const price: BigNumber = typeof gasPrice === 'string' ? new BigNumber(gasPrice) : gasPrice; - const quarter: BigNumber = price.dividedBy(4); - const high: string = price.plus(quarter.times(2)).toString(10); - const low: string = price.minus(quarter.times(2)).toString(10); - - const customLevel: FeeLevel = selected && selected.value === 'Custom' ? { - value: 'Custom', - gasPrice: selected.gasPrice, - // label: `${ calculateFee(gasPrice, gasLimit) } ${ symbol }` - label: `${calculateFee(selected.gasPrice, gasLimit)} ${symbol}`, - } : { - value: 'Custom', - gasPrice: low, - label: '', - }; - - return [ - { - value: 'High', - gasPrice: high, - label: `${calculateFee(high, gasLimit)} ${symbol}`, - }, - { - value: 'Normal', - gasPrice: gasPrice.toString(), - label: `${calculateFee(price.toString(10), gasLimit)} ${symbol}`, - }, - { - value: 'Low', - gasPrice: low, - label: `${calculateFee(low, gasLimit)} ${symbol}`, - }, - customLevel, - ]; + if (shouldUpdate) { + const validated = dispatch(ValidationActions.validation()); + dispatch({ + type: SEND.VALIDATION, + state: validated, + }); + } }; - -// initialize component +/* +* 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, @@ -234,8 +113,9 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS if (!account || !network) return; - const stateFromStorage = SessionStorageActions.load(getState().router.location.pathname); + const stateFromStorage = dispatch(SessionStorageActions.loadDraftTransaction()); if (stateFromStorage) { + // TODO: consider if current gasPrice should be set here as "recommendedGasPrice" dispatch({ type: SEND.INIT, state: stateFromStorage, @@ -243,269 +123,70 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS return; } - // TODO: check if there are some unfinished tx in localStorage - - - // const gasPrice: BigNumber = new BigNumber(EthereumjsUnits.convert(web3.gasPrice, 'wei', 'gwei')) || new BigNumber(network.defaultGasPrice); const gasPrice: BigNumber = await dispatch(BlockchainActions.getGasPrice(network.network, network.defaultGasPrice)); - // const gasPrice: BigNumber = new BigNumber(network.defaultGasPrice); - const gasLimit: string = network.defaultGasLimit.toString(); - const feeLevels: Array = getFeeLevels(network.symbol, gasPrice, gasLimit); - - // TODO: get nonce - // TODO: LOAD DATA FROM SESSION STORAGE - - const state: State = { - ...initialState, - networkName: network.network, - networkSymbol: network.symbol, - currency: network.symbol, - feeLevels, - selectedFeeLevel: feeLevels.find(f => f.value === 'Normal'), - recommendedGasPrice: gasPrice.toString(), - gasLimit, - gasPrice: gasPrice.toString(), - }; + 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, - }); -}; - -export const toggleAdvanced = (/* address: string */): Action => ({ - type: SEND.TOGGLE_ADVANCED, -}); - - -const addressValidation = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const { - account, - network, - } = getState().selectedAccount; - if (!account || !network) return; - - const state: State = getState().sendForm; - const infos = { ...state.infos }; - const warnings = { ...state.warnings }; - - - if (state.untouched || !state.touched.address) return; - - const savedAccounts = getState().accounts.filter(a => a.address.toLowerCase() === state.address.toLowerCase()); - if (savedAccounts.length > 0) { - // check if found account belongs to this network - // corner-case: when same derivation path is used on different networks - const currentNetworkAccount = savedAccounts.find(a => a.network === network.network); - if (currentNetworkAccount) { - const device: ?TrezorDevice = findDevice(getState().devices, currentNetworkAccount.deviceID, currentNetworkAccount.deviceState); - if (device) { - infos.address = `${device.instanceLabel} Account #${(currentNetworkAccount.index + 1)}`; - } - } else { - const otherNetworkAccount = savedAccounts[0]; - const device: ?TrezorDevice = findDevice(getState().devices, otherNetworkAccount.deviceID, otherNetworkAccount.deviceState); - const { coins } = getState().localStorage.config; - const otherNetwork: ?Coin = coins.find(c => c.network === otherNetworkAccount.network); - if (device && otherNetwork) { - warnings.address = `Looks like it's ${device.instanceLabel} Account #${(otherNetworkAccount.index + 1)} address of ${otherNetwork.name} network`; - } - } - } else { - delete warnings.address; - delete infos.address; - } - - dispatch({ - type: SEND.ADDRESS_VALIDATION, state: { - ...state, - infos, - warnings, + ...initialState, + networkName: network.network, + 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, +}); -export const validation = (props: Props): void => { - const { - account, - network, - tokens, - pending, - } = props.selectedAccount; - if (!account || !network) return; - - - const state: State = props.sendForm; - - const errors: {[k: string]: string} = {}; - const warnings: {[k: string]: string} = {}; - const infos: {[k: string]: string} = {}; - - if (state.untouched) return; - // valid address - if (state.touched.address) { - const addressError = validateAddress(state.address); - if (addressError) { - errors.address = addressError; - } - - // address warning or info may be set in addressValidation ThunkAction - // do not override them - if (state.warnings.address) { - warnings.address = state.warnings.address; - } - - if (state.infos.address) { - infos.address = state.infos.address; - } - } - - // valid amount - // https://stackoverflow.com/a/42701461 - //const regexp = new RegExp('^(?:[0-9]{0,10}\\.)?[0-9]{1,18}$'); - if (state.touched.amount) { - if (state.amount.length < 1) { - errors.amount = 'Amount is not set'; - } else if (state.amount.length > 0 && !state.amount.match(numberRegExp)) { - errors.amount = 'Amount is not a number'; - } else { - let decimalRegExp: RegExp; - const pendingAmount: BigNumber = getPendingAmount(pending, state.currency, state.currency !== state.networkSymbol); - - if (state.currency !== state.networkSymbol) { - const token = findToken(tokens, account.address, state.currency, account.deviceState); - if (token) { - if (parseInt(token.decimals) > 0) { - //decimalRegExp = new RegExp('^(0|0\\.([0-9]{0,' + token.decimals + '})?|[1-9]+\\.?([0-9]{0,' + token.decimals + '})?|\\.[0-9]{1,' + token.decimals + '})$'); - decimalRegExp = new RegExp(`^(0|0\\.([0-9]{0,${token.decimals}})?|[1-9][0-9]*\\.?([0-9]{0,${token.decimals}})?|\\.[0-9]{1,${token.decimals}})$`); - } else { - // decimalRegExp = new RegExp('^(0|0\\.?|[1-9]+\\.?)$'); - decimalRegExp = new RegExp('^[0-9]+$'); - } - - if (!state.amount.match(decimalRegExp)) { - errors.amount = `Maximum ${token.decimals} decimals allowed`; - } else if (new BigNumber(state.total).greaterThan(account.balance)) { - errors.amount = `Not enough ${state.networkSymbol} to cover transaction fee`; - } else if (new BigNumber(state.amount).greaterThan(new BigNumber(token.balance).minus(pendingAmount))) { - errors.amount = 'Not enough funds'; - } else if (new BigNumber(state.amount).lessThanOrEqualTo('0')) { - errors.amount = 'Amount is too low'; - } - } - } else { - decimalRegExp = new RegExp('^(0|0\\.([0-9]{0,18})?|[1-9][0-9]*\\.?([0-9]{0,18})?|\\.[0-9]{0,18})$'); - if (!state.amount.match(decimalRegExp)) { - errors.amount = 'Maximum 18 decimals allowed'; - } else if (new BigNumber(state.total).greaterThan(new BigNumber(account.balance).minus(pendingAmount))) { - errors.amount = 'Not enough funds'; - } - } - } - } - - // valid gas limit - if (state.touched.gasLimit) { - if (state.gasLimit.length < 1) { - errors.gasLimit = 'Gas limit is not set'; - } else if (state.gasLimit.length > 0 && !state.gasLimit.match(numberRegExp)) { - errors.gasLimit = 'Gas limit is not a number'; - } else { - const gl: BigNumber = new BigNumber(state.gasLimit); - if (gl.lessThan(1)) { - errors.gasLimit = 'Gas limit is too low'; - } else if (gl.lessThan(state.currency !== state.networkSymbol ? network.defaultGasLimitTokens : network.defaultGasLimit)) { - warnings.gasLimit = 'Gas limit is below recommended'; - } - } - } - - // valid gas price - if (state.touched.gasPrice) { - if (state.gasPrice.length < 1) { - errors.gasPrice = 'Gas price is not set'; - } else if (state.gasPrice.length > 0 && !state.gasPrice.match(numberRegExp)) { - errors.gasPrice = 'Gas price is not a number'; - } else { - const gp: BigNumber = new BigNumber(state.gasPrice); - if (gp.greaterThan(1000)) { - warnings.gasPrice = 'Gas price is too high'; - } else if (gp.lessThanOrEqualTo('0')) { - errors.gasPrice = 'Gas price is too low'; - } - } - } - - // valid nonce - if (state.touched.nonce) { - const re = new RegExp('^[0-9]+$'); - if (state.nonce.length < 1) { - errors.nonce = 'Nonce is not set'; - } else if (!state.nonce.match(re)) { - errors.nonce = 'Nonce is not a valid number'; - } else { - const n: BigNumber = new BigNumber(state.nonce); - if (n.lessThan(account.nonce)) { - warnings.nonce = 'Nonce is lower than recommended'; - } else if (n.greaterThan(account.nonce)) { - warnings.nonce = 'Nonce is greater than recommended'; - } - } - } - - // valid data - if (state.touched.data && state.data.length > 0) { - const re = /^[0-9A-Fa-f]+$/g; - if (!re.test(state.data)) { - errors.data = 'Data is not valid hexadecimal'; - } - } - - // valid nonce? - - state.errors = errors; - state.warnings = warnings; - state.infos = infos; -}; - - +/* +* Called from UI on "address" field change +*/ export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state: State = getState().sendForm; - const touched = { ...state.touched }; - touched.address = true; - dispatch({ - type: SEND.ADDRESS_CHANGE, + type: SEND.CHANGE, state: { ...state, untouched: false, - touched, + touched: { ...state.touched, address: true }, address, }, }); - - dispatch(addressValidation()); }; +/* +* Called from UI on "amount" field change +*/ export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendForm; - const touched = { ...state.touched }; - touched.amount = true; - dispatch({ - type: SEND.AMOUNT_CHANGE, + type: SEND.CHANGE, state: { ...state, untouched: false, - touched, + 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, @@ -513,85 +194,84 @@ export const onCurrencyChange = (currency: { value: string, label: string }): Th } = getState().selectedAccount; if (!account || !network) return; - const currentState: State = getState().sendForm; - const isToken: boolean = currency.value !== currentState.networkSymbol; - const gasLimit: string = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); - - const feeLevels: Array = getFeeLevels(network.symbol, currentState.recommendedGasPrice, gasLimit, currentState.selectedFeeLevel); - const selectedFeeLevel: ?FeeLevel = feeLevels.find(f => f.value === currentState.selectedFeeLevel.value); - if (!selectedFeeLevel) return; - - const state: State = { - ...currentState, - currency: currency.value, - // amount, - // total, - feeLevels, - selectedFeeLevel, - gasLimit, - }; + 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.CURRENCY_CHANGE, - state, + 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; - const touched = { ...state.touched }; - touched.amount = true; - dispatch({ - type: SEND.SET_MAX, + type: SEND.CHANGE, state: { ...state, untouched: false, - touched, + 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 { - network, - } = getState().selectedAccount; - if (!network) return; - - const currentState: State = getState().sendForm; - const isToken: boolean = currentState.currency !== currentState.networkSymbol; + const state = getState().sendForm; - const state: State = { - ...currentState, - untouched: false, - selectedFeeLevel: feeLevel, - }; - - if (feeLevel.value === 'Custom') { - state.advanced = true; - feeLevel.gasPrice = state.gasPrice; - feeLevel.label = `${calculateFee(state.gasPrice, state.gasLimit)} ${state.networkSymbol}`; - } else { - const customLevel: ?FeeLevel = state.feeLevels.find(f => f.value === 'Custom'); - if (customLevel) customLevel.label = ''; - state.gasPrice = feeLevel.gasPrice; + 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) { - state.gasLimit = network.defaultGasLimitTokens.toString(); + newGasLimit = network.defaultGasLimitTokens.toString(); } else { - state.gasLimit = state.data.length > 0 ? state.gasLimit : network.defaultGasLimit.toString(); + // 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.FEE_LEVEL_CHANGE, - state, + type: SEND.CHANGE, + state: { + ...state, + advanced, + selectedFeeLevel: feeLevel, + gasLimit: newGasLimit, + gasPrice: newGasPrice, + }, }); }; -// Manually triggered from user -// Update gasPrice to recommended value - +/* +* Called from UI from "update recommended fees" button +*/ export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const { account, @@ -599,118 +279,122 @@ export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: } = getState().selectedAccount; if (!account || !network) return; - const currentState: State = getState().sendForm; - const isToken: boolean = currentState.currency !== currentState.networkSymbol; - let gasLimit: string = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); - - // override custom settings - if (currentState.selectedFeeLevel.value === 'Custom') { - // update only gasPrice - currentState.selectedFeeLevel.gasPrice = currentState.recommendedGasPrice; - // leave gas limit as it was - ({ gasLimit } = currentState); - } - - const feeLevels: Array = getFeeLevels(network.symbol, currentState.recommendedGasPrice, gasLimit, currentState.selectedFeeLevel); - const selectedFeeLevel: ?FeeLevel = feeLevels.find(f => f.value === currentState.selectedFeeLevel.value); - if (!selectedFeeLevel) return; - - const state: State = { - ...currentState, - feeLevels, - selectedFeeLevel, - gasPrice: selectedFeeLevel.gasPrice, - gasPriceNeedsUpdate: false, - }; + 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.UPDATE_FEE_LEVELS, - state, + 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 currentState: State = getState().sendForm; - const isToken: boolean = currentState.currency !== currentState.networkSymbol; - - const touched = { ...currentState.touched }; - touched.gasPrice = true; - - const state: State = { - ...currentState, - untouched: false, - touched, - gasPrice, - }; - - if (currentState.selectedFeeLevel.value !== 'Custom') { - const customLevel = currentState.feeLevels.find(f => f.value === 'Custom'); - if (!customLevel) return; - state.selectedFeeLevel = customLevel; - } + 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.GAS_PRICE_CHANGE, - state, + type: SEND.CHANGE, + state: { + ...state, + untouched: false, + touched: { ...state.touched, gasPrice: true }, + gasPrice, + selectedFeeLevel: newSelectedFeeLevel, + }, }); }; -export const onGasLimitChange = (gasLimit: string/* , shouldUpdateFeeLevels: boolean = false */): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const currentState: State = getState().sendForm; - - const touched = { ...currentState.touched }; - touched.gasLimit = true; - - const state: State = { - ...currentState, - calculatingGasLimit: false, - untouched: false, - touched, - gasLimit, - }; - - if (currentState.selectedFeeLevel.value !== 'Custom') { - const customLevel = currentState.feeLevels.find(f => f.value === 'Custom'); - if (!customLevel) return; - state.selectedFeeLevel = customLevel; - } +/* +* 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.GAS_LIMIT_CHANGE, - state, + type: SEND.CHANGE, + state: { + ...state, + calculatingGasLimit: false, + untouched: false, + touched: { ...state.touched, gasLimit: true }, + gasLimit, + feeLevels, + selectedFeeLevel, + }, }); }; -export const onNonceChange = (nonce: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const currentState: State = getState().sendForm; - const touched = { ...currentState.touched }; - touched.nonce = true; - - const state: State = { - ...currentState, - untouched: false, - touched, - nonce, - }; +/* +* 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.NONCE_CHANGE, - state, + type: SEND.CHANGE, + state: { + ...state, + calculatingGasLimit: true, + untouched: false, + touched: { ...state.touched, data: true }, + data, + }, }); + + dispatch(estimateGasPrice()); }; +/* +* Internal method +* Called from "onDataChange" action +* try to asynchronously download data from backend +*/ const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const { - network, - } = getState().selectedAccount; - if (!network) return; - const state: State = getState().sendForm; - const requestedData = state.data; + const { network } = getState().selectedAccount; + if (!network) { + // stop "calculatingGasLimit" process + dispatch(onGasLimitChange(state.gasLimit)); + return; + } - const re = /^[0-9A-Fa-f]+$/g; + const requestedData = state.data; + const re = /^[0-9A-Fa-f]+$/g; // TODO: allow "0x" prefix if (!re.test(requestedData)) { - // to stop calculating + // stop "calculatingGasLimit" process dispatch(onGasLimitChange(requestedData.length > 0 ? state.gasLimit : network.defaultGasLimit.toString())); return; } @@ -721,40 +405,18 @@ const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: return; } - const gasLimit: number = await dispatch(BlockchainActions.estimateGasLimit(network.network, state.data, state.amount, state.gasPrice)); + const gasLimit = await dispatch(BlockchainActions.estimateGasLimit(network.network, 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.toString())); + dispatch(onGasLimitChange(gasLimit)); } }; -export const onDataChange = (data: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const currentState: State = getState().sendForm; - const touched = { ...currentState.touched }; - touched.data = true; - - const state: State = { - ...currentState, - calculatingGasLimit: true, - untouched: false, - touched, - data, - }; - - if (currentState.selectedFeeLevel.value !== 'Custom') { - const customLevel = currentState.feeLevels.find(f => f.value === 'Custom'); - if (!customLevel) return; - state.selectedFeeLevel = customLevel; - } - - dispatch({ - type: SEND.DATA_CHANGE, - state, - }); - - dispatch(estimateGasPrice()); -}; - +/* +* Called from UI from "send" button +*/ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const { account, @@ -767,8 +429,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge const currentState: State = getState().sendForm; const isToken: boolean = currentState.currency !== currentState.networkSymbol; - const address_n = account.addressPath; - const pendingNonce: number = stateUtils.getPendingNonce(pending); + const pendingNonce: number = reducerUtils.getPendingNonce(pending); const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce; const txData = await dispatch(prepareEthereumTx({ @@ -793,7 +454,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge state: selected.state, }, useEmptyPassphrase: !selected.instance, - path: address_n, + path: account.addressPath, transaction: txData, }); @@ -826,7 +487,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge throw new Error(push.payload.error); } - const txid = push.payload.txid; + const { txid } = push.payload; dispatch({ type: SEND.TX_COMPLETE, diff --git a/src/actions/SendFormValidationActions.js b/src/actions/SendFormValidationActions.js new file mode 100644 index 00000000..97c71ed4 --- /dev/null +++ b/src/actions/SendFormValidationActions.js @@ -0,0 +1,413 @@ +/* @flow */ + +import BigNumber from 'bignumber.js'; +import EthereumjsUtil from 'ethereumjs-util'; +import EthereumjsUnits from 'ethereumjs-units'; +import { findToken } from 'reducers/TokensReducer'; +import { findDevice, getPendingAmount } from 'reducers/utils'; +import * as SEND from 'actions/constants/send'; + +import type { + Dispatch, + GetState, + PayloadAction, +} from 'flowtype'; +import type { State, FeeLevel } from 'reducers/SendFormReducer'; + +// general regular expressions +const NUMBER_RE: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$'); +const UPPERCASE_RE = new RegExp('^(.*[A-Z].*)$'); +const ABS_RE = new RegExp('^[0-9]+$'); +const ETH_18_RE = new RegExp('^(0|0\\.([0-9]{0,18})?|[1-9][0-9]*\\.?([0-9]{0,18})?|\\.[0-9]{0,18})$'); +const HEX_RE = new RegExp('^[0-9A-Fa-f]+$'); +const dynamicRegexp = (decimals: number): RegExp => { + if (decimals > 0) { + return new RegExp(`^(0|0\\.([0-9]{0,${decimals}})?|[1-9][0-9]*\\.?([0-9]{0,${decimals}})?|\\.[0-9]{1,${decimals}})$`); + } + return ABS_RE; +}; + +/* +* Called from SendFormActions.observe +* Reaction for WEB3.GAS_PRICE_UPDATED action +*/ +export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAction => (dispatch: Dispatch, getState: GetState): void => { + // testing random data + // function getRandomInt(min, max) { + // return Math.floor(Math.random() * (max - min + 1)) + min; + // } + // const newPrice = getRandomInt(10, 50).toString(); + + const state = getState().sendForm; + if (network === state.networkSymbol) return; + + // check if new price is different then currently recommended + const newPrice: string = EthereumjsUnits.convert(gasPrice, 'wei', 'gwei'); + + if (newPrice !== state.recommendedGasPrice) { + if (!state.untouched) { + // if there is a transaction draft let the user know + // and let him update manually + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + gasPriceNeedsUpdate: true, + recommendedGasPrice: newPrice, + }, + }); + } else { + // automatically update feeLevels and gasPrice + const feeLevels = getFeeLevels(state.networkSymbol, newPrice, state.gasLimit); + const selectedFeeLevel = getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + gasPriceNeedsUpdate: false, + recommendedGasPrice: newPrice, + 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().sendForm)); + // reset errors + state.errors = {}; + state.warnings = {}; + state.infos = {}; + state = dispatch(recalculateTotalAmount(state)); + state = dispatch(updateCustomFeeLabel(state)); + state = dispatch(addressValidation(state)); + state = dispatch(addressLabel(state)); + state = dispatch(amountValidation(state)); + state = dispatch(gasLimitValidation(state)); + state = dispatch(gasPriceValidation(state)); + state = dispatch(nonceValidation(state)); + state = dispatch(dataValidation(state)); + return state; +}; + +export const recalculateTotalAmount = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const { + account, + tokens, + pending, + } = getState().selectedAccount; + if (!account) return $state; + + const state = { ...$state }; + const isToken = state.currency !== state.networkSymbol; + + if (state.setMax) { + const pendingAmount = getPendingAmount(pending, state.currency, isToken); + if (isToken) { + const token = findToken(tokens, account.address, state.currency, account.deviceState); + if (token) { + state.amount = new BigNumber(token.balance).minus(pendingAmount).toString(10); + } + } else { + const b = new BigNumber(account.balance).minus(pendingAmount); + state.amount = calculateMaxAmount(b, state.gasPrice, state.gasLimit); + } + } + + state.total = calculateTotal(isToken ? '0' : state.amount, state.gasPrice, state.gasLimit); + return state; +}; + +export const updateCustomFeeLabel = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if ($state.selectedFeeLevel.value === 'Custom') { + state.selectedFeeLevel = { + ...state.selectedFeeLevel, + gasPrice: state.gasPrice, + label: `${calculateFee(state.gasPrice, state.gasLimit)} ${state.networkSymbol}`, + }; + } + return state; +}; + +/* +* Address value validation +*/ +export const addressValidation = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if (!state.touched.address) return state; + + const { address } = state; + + if (address.length < 1) { + state.errors.address = 'Address is not set'; + } else if (!EthereumjsUtil.isValidAddress(address)) { + state.errors.address = 'Address is not valid'; + } else if (address.match(UPPERCASE_RE) && !EthereumjsUtil.isValidChecksumAddress(address)) { + state.errors.address = 'Address is not a valid checksum'; + } + return state; +}; + +/* +* Address label assignation +*/ +export 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.network); + 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 { coins } = getState().localStorage.config; + const otherNetwork = coins.find(c => c.network === 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 +*/ +export const amountValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.amount) return state; + + const { + account, + tokens, + 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 isToken: boolean = state.currency !== state.networkSymbol; + const pendingAmount: BigNumber = getPendingAmount(pending, state.currency, isToken); + + if (isToken) { + const token = findToken(tokens, account.address, state.currency, account.deviceState); + if (!token) return state; + const decimalRegExp = dynamicRegexp(parseInt(token.decimals, 0)); + + if (!state.amount.match(decimalRegExp)) { + state.errors.amount = `Maximum ${token.decimals} decimals allowed`; + } else if (new BigNumber(state.total).greaterThan(account.balance)) { + state.errors.amount = `Not enough ${state.networkSymbol} to cover transaction fee`; + } else if (new BigNumber(state.amount).greaterThan(new BigNumber(token.balance).minus(pendingAmount))) { + state.errors.amount = 'Not enough funds'; + } else if (new BigNumber(state.amount).lessThanOrEqualTo('0')) { + state.errors.amount = 'Amount is too low'; + } + } else if (!state.amount.match(ETH_18_RE)) { + state.errors.amount = 'Maximum 18 decimals allowed'; + } else if (new BigNumber(state.total).greaterThan(new BigNumber(account.balance).minus(pendingAmount))) { + state.errors.amount = 'Not enough funds'; + } + } + return state; +}; + +/* +* Gas limit value validation +*/ +export const gasLimitValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.gasLimit) return state; + + const { + network, + } = getState().selectedAccount; + if (!network) return state; + + const { gasLimit } = state; + if (gasLimit.length < 1) { + state.errors.gasLimit = 'Gas limit is not set'; + } else if (gasLimit.length > 0 && !gasLimit.match(NUMBER_RE)) { + state.errors.gasLimit = 'Gas limit is not a number'; + } else { + const gl: BigNumber = new BigNumber(gasLimit); + if (gl.lessThan(1)) { + state.errors.gasLimit = 'Gas limit is too low'; + } else if (gl.lessThan(state.currency !== state.networkSymbol ? network.defaultGasLimitTokens : network.defaultGasLimit)) { + state.warnings.gasLimit = 'Gas limit is below recommended'; + } + } + return state; +}; + +/* +* Gas price value validation +*/ +export const gasPriceValidation = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if (!state.touched.gasPrice) return state; + + const { gasPrice } = state; + if (gasPrice.length < 1) { + state.errors.gasPrice = 'Gas price is not set'; + } else if (gasPrice.length > 0 && !gasPrice.match(NUMBER_RE)) { + state.errors.gasPrice = 'Gas price is not a number'; + } else { + const gp: BigNumber = new BigNumber(gasPrice); + if (gp.greaterThan(1000)) { + state.warnings.gasPrice = 'Gas price is too high'; + } else if (gp.lessThanOrEqualTo('0')) { + state.errors.gasPrice = 'Gas price is too low'; + } + } + return state; +}; + +/* +* Nonce value validation +*/ +export const nonceValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.nonce) return state; + + const { + account, + } = getState().selectedAccount; + if (!account) return state; + + const { nonce } = state; + if (nonce.length < 1) { + state.errors.nonce = 'Nonce is not set'; + } else if (!nonce.match(ABS_RE)) { + state.errors.nonce = 'Nonce is not a valid number'; + } else { + const n: BigNumber = new BigNumber(nonce); + if (n.lessThan(account.nonce)) { + state.warnings.nonce = 'Nonce is lower than recommended'; + } else if (n.greaterThan(account.nonce)) { + state.warnings.nonce = 'Nonce is greater than recommended'; + } + } + return state; +}; + +/* +* Gas price value validation +*/ +export const dataValidation = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if (!state.touched.data || state.data.length === 0) return state; + if (!HEX_RE.test(state.data)) { + state.errors.data = 'Data is not valid hexadecimal'; + } + return state; +}; + +/* +* UTILITIES +*/ + +export const calculateFee = (gasPrice: string, gasLimit: string): string => { + try { + return EthereumjsUnits.convert(new BigNumber(gasPrice).times(gasLimit), 'gwei', 'ether'); + } catch (error) { + return '0'; + } +}; + +export const calculateTotal = (amount: string, gasPrice: string, gasLimit: string): string => { + try { + return new BigNumber(amount).plus(calculateFee(gasPrice, gasLimit)).toString(10); + } catch (error) { + return '0'; + } +}; + +export const calculateMaxAmount = (balance: BigNumber, gasPrice: string, gasLimit: string): string => { + try { + // TODO - minus pendings + const fee = calculateFee(gasPrice, gasLimit); + const max = balance.minus(fee); + if (max.lessThan(0)) return '0'; + return max.toString(10); + } catch (error) { + return '0'; + } +}; + +export const getFeeLevels = (symbol: string, gasPrice: BigNumber | string, gasLimit: string, selected?: FeeLevel): Array => { + const price: BigNumber = typeof gasPrice === 'string' ? new BigNumber(gasPrice) : gasPrice; + const quarter: BigNumber = price.dividedBy(4); + const high: string = price.plus(quarter.times(2)).toString(10); + const low: string = price.minus(quarter.times(2)).toString(10); + + const customLevel: FeeLevel = selected && selected.value === 'Custom' ? { + value: 'Custom', + gasPrice: selected.gasPrice, + // label: `${ calculateFee(gasPrice, gasLimit) } ${ symbol }` + label: `${calculateFee(selected.gasPrice, gasLimit)} ${symbol}`, + } : { + value: 'Custom', + gasPrice: low, + label: '', + }; + + return [ + { + value: 'High', + gasPrice: high, + label: `${calculateFee(high, gasLimit)} ${symbol}`, + }, + { + value: 'Normal', + gasPrice: gasPrice.toString(), + label: `${calculateFee(price.toString(10), gasLimit)} ${symbol}`, + }, + { + value: 'Low', + gasPrice: low, + label: `${calculateFee(low, gasLimit)} ${symbol}`, + }, + customLevel, + ]; +}; + +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; +}; \ No newline at end of file diff --git a/src/actions/SessionStorageActions.js b/src/actions/SessionStorageActions.js index 9457aa14..33c953c8 100644 --- a/src/actions/SessionStorageActions.js +++ b/src/actions/SessionStorageActions.js @@ -4,47 +4,56 @@ import type { State as SendFormState } from 'reducers/SendFormReducer'; import type { ThunkAction, + PayloadAction, GetState, Dispatch, } from 'flowtype'; -const PREFIX: string = 'trezor:draft-tx:'; +const TX_PREFIX: string = 'trezor:draft-tx:'; -export const save = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { +export const saveDraftTransaction = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { if (typeof window.localStorage === 'undefined') return; - const location = getState().router.location.pathname; const state = getState().sendForm; - if (!state.untouched) { - try { - window.sessionStorage.setItem(`${PREFIX}${location}`, JSON.stringify(state)); - } catch (error) { - console.error(`Saving sessionStorage error: ${error}`); - } + if (state.untouched) return; + + const location = getState().router.location.pathname; + try { + // save state as it is + // "loadDraftTransaction" will do the validation + window.sessionStorage.setItem(`${TX_PREFIX}${location}`, JSON.stringify(state)); + } catch (error) { + console.error(`Saving sessionStorage error: ${error}`); } }; -export const load = (location: string): ?SendFormState => { - if (typeof window.localStorage === 'undefined') return; +export const loadDraftTransaction = (): PayloadAction => (dispatch: Dispatch, getState: GetState): ?SendFormState => { + if (typeof window.localStorage === 'undefined') return null; try { - const value: string = window.sessionStorage.getItem(`${PREFIX}${location}`); + const location = getState().router.location.pathname; + const value: string = window.sessionStorage.getItem(`${TX_PREFIX}${location}`); const state: ?SendFormState = JSON.parse(value); - if (state && state.address === '' && (state.amount === '' || state.amount === '0')) { - window.sessionStorage.removeItem(`${PREFIX}${location}`); - return; + if (state) { + // decide if draft is valid and should be returned + // ignore this draft if has any error + if (Object.keys(state.errors).length > 0) { + window.sessionStorage.removeItem(`${TX_PREFIX}${location}`); + return null; + } + return state; } - return state; } catch (error) { console.error(`Loading sessionStorage error: ${error}`); } + return null; }; export const clear = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { if (typeof window.localStorage === 'undefined') return; const location = getState().router.location.pathname; try { - window.sessionStorage.removeItem(`${PREFIX}${location}`); + window.sessionStorage.removeItem(`${TX_PREFIX}${location}`); } catch (error) { console.error(`Clearing sessionStorage error: ${error}`); } diff --git a/src/actions/TokenActions.js b/src/actions/TokenActions.js index 68860968..eea041e8 100644 --- a/src/actions/TokenActions.js +++ b/src/actions/TokenActions.js @@ -27,7 +27,8 @@ export type TokenAction = { // action from component -export const load = (input: string, network: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { +export const load = ($input: string, network: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + let input = $input; if (input.length < 1) input = '0x'; const tokens = getState().localStorage.tokens[network]; @@ -43,10 +44,12 @@ export const load = (input: string, network: string): AsyncAction => async (disp return result.slice(0, 100); } - const info = await dispatch( BlockchainActions.getTokenInfo(input, network) ); + const info = await dispatch(BlockchainActions.getTokenInfo(input, network)); if (info) { return [info]; } + + return null; }; export const setBalance = (tokenAddress: string, ethAddress: string, balance: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { @@ -63,7 +66,7 @@ export const setBalance = (tokenAddress: string, ethAddress: string, balance: st }); }; -export const add = (token: NetworkToken, account: Account): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { +export const add = (token: NetworkToken, account: Account): AsyncAction => async (dispatch: Dispatch): Promise => { const tkn: Token = { loaded: false, deviceState: account.deviceState, @@ -81,7 +84,7 @@ export const add = (token: NetworkToken, account: Account): AsyncAction => async payload: tkn, }); - const tokenBalance = await dispatch( BlockchainActions.getTokenBalance(tkn) ); + const tokenBalance = await dispatch(BlockchainActions.getTokenBalance(tkn)); dispatch(setBalance(token.address, account.address, tokenBalance)); }; diff --git a/src/actions/TxActions.js b/src/actions/TxActions.js index 8bdaed0e..67c93c9f 100644 --- a/src/actions/TxActions.js +++ b/src/actions/TxActions.js @@ -3,19 +3,15 @@ import EthereumjsTx from 'ethereumjs-tx'; import EthereumjsUnits from 'ethereumjs-units'; import BigNumber from 'bignumber.js'; -import { toHex } from 'web3-utils'; -import { initWeb3 } from './Web3Actions'; +import { toHex } from 'web3-utils'; // eslint-disable-line import/no-extraneous-dependencies +import { initWeb3 } from 'actions/Web3Actions'; import type { Dispatch, - GetState, PromiseAction, } from 'flowtype'; -import type { - EthereumTransaction -} from 'trezor-connect'; - +import type { EthereumTransaction } from 'trezor-connect'; import type { Token } from 'reducers/TokensReducer'; type EthereumTxRequest = { @@ -30,19 +26,18 @@ type EthereumTxRequest = { nonce: number; } - -export const prepareEthereumTx = (tx: EthereumTxRequest): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance = await dispatch( initWeb3(tx.network) ); - const token = tx.token; +export const prepareEthereumTx = (tx: EthereumTxRequest): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance = await dispatch(initWeb3(tx.network)); + const { token } = tx; let data: string = `0x${tx.data}`; // TODO: check if already prefixed - let value: string = toHex( EthereumjsUnits.convert(tx.amount, 'ether', 'wei') ); - let to: string = tx.to; + let value: string = toHex(EthereumjsUnits.convert(tx.amount, 'ether', 'wei')); + let to: string = tx.to; // eslint-disable-line prefer-destructuring if (token) { // smart contract transaction const contract = instance.erc20.clone(); contract.options.address = token.address; - const tokenAmount: string = new BigNumber(tx.amount).times(Math.pow(10, token.decimals)).toString(10); + const tokenAmount: string = new BigNumber(tx.amount).times(10 ** token.decimals).toString(10); data = instance.erc20.methods.transfer(to, tokenAmount).encodeABI(); value = '0x00'; to = token.address; @@ -55,15 +50,14 @@ export const prepareEthereumTx = (tx: EthereumTxRequest): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { +export const serializeEthereumTx = (tx: EthereumTransaction): PromiseAction => async (): Promise => { const ethTx = new EthereumjsTx(tx); - return `0x${ ethTx.serialize().toString('hex') }`; - // return toHex( ethTx.serialize() ); -} \ No newline at end of file + return `0x${ethTx.serialize().toString('hex')}`; +}; \ No newline at end of file diff --git a/src/actions/WalletActions.js b/src/actions/WalletActions.js index 32830e26..9126c541 100644 --- a/src/actions/WalletActions.js +++ b/src/actions/WalletActions.js @@ -6,10 +6,6 @@ import * as WALLET from 'actions/constants/wallet'; import * as stateUtils from 'reducers/utils'; import type { - Account, - Coin, - Discovery, - Token, Device, TrezorDevice, RouterLocationState, diff --git a/src/actions/Web3Actions.js b/src/actions/Web3Actions.js index b4611bd3..a8d9491c 100644 --- a/src/actions/Web3Actions.js +++ b/src/actions/Web3Actions.js @@ -1,15 +1,10 @@ /* @flow */ import Web3 from 'web3'; -import HDKey from 'hdkey'; import BigNumber from 'bignumber.js'; -import EthereumjsUtil from 'ethereumjs-util'; import EthereumjsUnits from 'ethereumjs-units'; -import EthereumjsTx from 'ethereumjs-tx'; // import InputDataDecoder from 'ethereum-input-data-decoder'; -import TrezorConnect from 'trezor-connect'; -import type { EstimateGasOptions, TransactionStatus, TransactionReceipt } from 'web3'; -import { strip } from 'utils/ethUtils'; +import type { EstimateGasOptions } from 'web3'; import * as WEB3 from 'actions/constants/web3'; import * as PENDING from 'actions/constants/pendingTx'; @@ -17,13 +12,11 @@ import type { Dispatch, GetState, ThunkAction, - AsyncAction, PromiseAction, } from 'flowtype'; import type { EthereumAccount } from 'trezor-connect'; import type { Account } from 'reducers/AccountsReducer'; -import type { PendingTx } from 'reducers/PendingTxReducer'; import type { Web3Instance } from 'reducers/Web3Reducer'; import type { Token } from 'reducers/TokensReducer'; import type { NetworkToken } from 'reducers/LocalStorageReducer'; @@ -52,101 +45,97 @@ export type Web3Action = { } | Web3UpdateBlockAction | Web3UpdateGasPriceAction; -export const initWeb3 = (network: string, urlIndex: number = 0): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - return new Promise(async (resolve, reject) => { - // check if requested web was initialized before - const instance = getState().web3.find(w3 => w3.network === network); - if (instance && instance.web3.currentProvider.connected) { - resolve(instance); - return; - } +export const initWeb3 = (network: string, urlIndex: number = 0): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => new Promise(async (resolve, reject) => { + // check if requested web was initialized before + const instance = getState().web3.find(w3 => w3.network === network); + if (instance && instance.web3.currentProvider.connected) { + resolve(instance); + return; + } - // requested web3 wasn't initialized or is disconnected - // initialize again - const { config, ERC20Abi } = getState().localStorage; - const coin = config.coins.find(c => c.network === network); - if (!coin) { - // coin not found - reject(new Error(`Network ${ network} not found in application config.`)); - return; - } + // requested web3 wasn't initialized or is disconnected + // initialize again + const { config, ERC20Abi } = getState().localStorage; + const coin = config.coins.find(c => c.network === network); + if (!coin) { + // coin not found + reject(new Error(`Network ${network} not found in application config.`)); + return; + } - // get first url - const url = coin.web3[ urlIndex ]; - if (!url) { - reject(new Error('Web3 backend is not responding')); - return; - } + // get first url + const url = coin.web3[urlIndex]; + if (!url) { + reject(new Error('Web3 backend is not responding')); + return; + } - const web3 = new Web3( new Web3.providers.WebsocketProvider(url) ); + const web3 = new Web3(new Web3.providers.WebsocketProvider(url)); - const onConnect = async () => { + const onConnect = async () => { + const latestBlock = await web3.eth.getBlockNumber(); + const gasPrice = await web3.eth.getGasPrice(); - const latestBlock = await web3.eth.getBlockNumber(); - const gasPrice = await web3.eth.getGasPrice(); + const newInstance = { + network, + web3, + chainId: coin.chainId, + erc20: new web3.eth.Contract(ERC20Abi), + latestBlock, + gasPrice, + }; - const instance = { - network, - web3, - chainId: coin.chainId, - erc20: new web3.eth.Contract(ERC20Abi), - latestBlock, - gasPrice, - } + dispatch({ + type: WEB3.CREATE, + instance: newInstance, + }); - dispatch({ - type: WEB3.CREATE, - instance, - }); + resolve(newInstance); + }; - resolve(instance); - } + const onEnd = async () => { + web3.currentProvider.reset(); + const oldInstance = getState().web3.find(w3 => w3.network === network); - const onEnd = async () => { - - web3.currentProvider.reset(); - const instance = getState().web3.find(w3 => w3.network === network); - - if (instance && instance.web3.currentProvider.connected) { - // backend disconnects - // dispatch({ - // type: 'WEB3.DISCONNECT', - // network - // }); - } else { - // backend initialization error for given url, try next one - try { - const web3 = await dispatch( initWeb3(network, urlIndex + 1) ); - resolve(web3); - } catch (error) { - reject(error); - } + if (oldInstance && oldInstance.web3.currentProvider.connected) { + // backend disconnects + // dispatch({ + // type: 'WEB3.DISCONNECT', + // network + // }); + } else { + // backend initialization error for given url, try next one + try { + const otherWeb3 = await dispatch(initWeb3(network, urlIndex + 1)); + resolve(otherWeb3); + } catch (error) { + reject(error); } } + }; - web3.currentProvider.on('connect', onConnect); - web3.currentProvider.on('end', onEnd); - web3.currentProvider.on('error', onEnd); - }); -} + web3.currentProvider.on('connect', onConnect); + web3.currentProvider.on('end', onEnd); + web3.currentProvider.on('error', onEnd); +}); -export const discoverAccount = (address: string, network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance: Web3Instance = await dispatch( initWeb3(network) ); +export const discoverAccount = (address: string, network: string): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance: Web3Instance = await dispatch(initWeb3(network)); const balance = await instance.web3.eth.getBalance(address); const nonce = await instance.web3.eth.getTransactionCount(address); - return { + return { address, transactions: 0, block: 0, balance: EthereumjsUnits.convert(balance, 'wei', 'ether'), - nonce + nonce, }; -} +}; export const resolvePendingTransactions = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance: Web3Instance = await dispatch( initWeb3(network) ); + const instance: Web3Instance = await dispatch(initWeb3(network)); const pending = getState().pending.filter(p => p.network === network); - for (const tx of pending) { + pending.forEach(async (tx) => { const status = await instance.web3.eth.getTransaction(tx.id); if (!status) { dispatch({ @@ -169,14 +158,15 @@ export const resolvePendingTransactions = (network: string): PromiseAction }); } } - } -} + }); +}; -export const getPendingInfo = (network: string, txid: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance: Web3Instance = await dispatch( initWeb3(network) ); +/* +export const getPendingInfo = (network: string, txid: string): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance: Web3Instance = await dispatch(initWeb3(network)); const tx = await instance.web3.eth.getTransaction(txid); - /* + if (tx.input !== "0x") { // find token: // tx.to <= smart contract address @@ -187,34 +177,36 @@ export const getPendingInfo = (network: string, txid: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance: Web3Instance = await dispatch( initWeb3("ropsten") ); +export const getTxInput = (): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance: Web3Instance = await dispatch(initWeb3('ropsten')); // const inputData = instance.web3.utils.hexToAscii("0xa9059cbb00000000000000000000000073d0385f4d8e00c5e6504c6030f47bf6212736a80000000000000000000000000000000000000000000000000000000000000001"); // console.warn("input data!", inputData); -} - +}; +*/ -export const updateAccount = (account: Account, newAccount: EthereumAccount, network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance: Web3Instance = await dispatch( initWeb3(network) ); +export const updateAccount = (account: Account, newAccount: EthereumAccount, network: string): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance: Web3Instance = await dispatch(initWeb3(network)); 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 }) ); + dispatch(AccountsActions.update({ + ...account, ...newAccount, balance: EthereumjsUnits.convert(balance, 'wei', 'ether'), nonce, + })); // update tokens for this account - dispatch( updateAccountTokens(account) ); -} + dispatch(updateAccountTokens(account)); +}; export const updateAccountTokens = (account: Account): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { const tokens = getState().tokens.filter(t => t.network === account.network && t.ethAddress === account.address); - for (const token of tokens) { - const balance = await dispatch( getTokenBalance(token) ); + tokens.forEach(async (token) => { + const balance = await dispatch(getTokenBalance(token)); // const newBalance: string = balance.dividedBy(Math.pow(10, token.decimals)).toString(10); if (balance !== token.balance) { dispatch(TokenActions.setBalance( @@ -223,11 +215,11 @@ export const updateAccountTokens = (account: Account): PromiseAction => as balance, )); } - } -} + }); +}; -export const getTokenInfo = (address: string, network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance: Web3Instance = await dispatch( initWeb3(network) ); +export const getTokenInfo = (address: string, network: string): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance: Web3Instance = await dispatch(initWeb3(network)); const contract = instance.erc20.clone(); contract.options.address = address; @@ -243,68 +235,71 @@ export const getTokenInfo = (address: string, network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance = await dispatch( initWeb3(token.network) ); +export const getTokenBalance = (token: Token): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance = await dispatch(initWeb3(token.network)); const contract = instance.erc20.clone(); contract.options.address = token.address; const balance = await contract.methods.balanceOf(token.ethAddress).call(); - return new BigNumber(balance).dividedBy(Math.pow(10, token.decimals)).toString(10); + return new BigNumber(balance).dividedBy(10 ** token.decimals).toString(10); }; export const getCurrentGasPrice = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { const instance = getState().web3.find(w3 => w3.network === network); if (instance) { return EthereumjsUnits.convert(instance.gasPrice, 'wei', 'gwei'); - } else { - throw "0"; } -} + return '0'; +}; -export const updateGasPrice = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { +export const updateGasPrice = (network: string): PromiseAction => async (dispatch: Dispatch): Promise => { try { - const instance = await dispatch( initWeb3(network) ); + const instance = await dispatch(initWeb3(network)); const gasPrice = await instance.web3.eth.getGasPrice(); if (instance.gasPrice !== gasPrice) { dispatch({ type: WEB3.GAS_PRICE_UPDATED, network, - gasPrice + gasPrice, }); } } catch (e) { // silent action // nothing happens if this fails } -} +}; -export const estimateGasLimit = (network: string, options: EstimateGasOptions): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance = await dispatch( initWeb3(network) ); +export const estimateGasLimit = (network: string, $options: EstimateGasOptions): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance = await dispatch(initWeb3(network)); // TODO: allow data starting with 0x ... - options.to = '0x0000000000000000000000000000000000000000'; - options.data = `0x${options.data.length % 2 === 0 ? options.data : `0${options.data}`}`; - options.value = instance.web3.utils.toHex( EthereumjsUnits.convert(options.value || '0', 'ether', 'wei') ); - options.gasPrice = instance.web3.utils.toHex( EthereumjsUnits.convert(options.gasPrice, 'gwei', 'wei') ); + const data = `0x${$options.data.length % 2 === 0 ? $options.data : `0${$options.data}`}`; + const options = { + ...$options, + to: '0x0000000000000000000000000000000000000000', + data, + value: instance.web3.utils.toHex(EthereumjsUnits.convert($options.value || '0', 'ether', 'wei')), + gasPrice: instance.web3.utils.toHex(EthereumjsUnits.convert($options.gasPrice, 'gwei', 'wei')), + }; const limit = await instance.web3.eth.estimateGas(options); - return limit; + return limit.toString(); }; export const disconnect = (coinInfo: any): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - // incoming "coinInfo" from TrezorConnect is CoinInfo | EthereumNetwork type - const network: string = coinInfo.shortcut.toLowerCase(); + // incoming "coinInfo" from TrezorConnect is CoinInfo | EthereumNetwork type + const network: string = coinInfo.shortcut.toLowerCase(); // check if Web3 was already initialized const instance = getState().web3.find(w3 => w3.network === network); if (instance) { // reset current connection instance.web3.currentProvider.reset(); instance.web3.currentProvider.connection.close(); - + // remove instance from reducer dispatch({ type: WEB3.DISCONNECT, - instance + instance, }); } }; diff --git a/src/actions/constants/send.js b/src/actions/constants/send.js index 08528692..a5d12906 100644 --- a/src/actions/constants/send.js +++ b/src/actions/constants/send.js @@ -1,23 +1,9 @@ /* @flow */ - export const INIT: 'send__init' = 'send__init'; -export const DISPOSE: 'send__dispose' = 'send__dispose'; +export const CHANGE: 'send__change' = 'send__change'; export const VALIDATION: 'send__validation' = 'send__validation'; -export const ADDRESS_VALIDATION: 'send__address_validation' = 'send__address_validation'; -export const ADDRESS_CHANGE: 'send__address_change' = 'send__address_change'; -export const AMOUNT_CHANGE: 'send__amount_change' = 'send__amount_change'; -export const SET_MAX: 'send__set_max' = 'send__set_max'; -export const CURRENCY_CHANGE: 'send__currency_change' = 'send__currency_change'; -export const FEE_LEVEL_CHANGE: 'send__fee_level_change' = 'send__fee_level_change'; -export const GAS_PRICE_CHANGE: 'send__gas_price_change' = 'send__gas_price_change'; -export const GAS_LIMIT_CHANGE: 'send__gas_limit_change' = 'send__gas_limit_change'; -export const NONCE_CHANGE: 'send__nonce_change' = 'send__nonce_change'; -export const UPDATE_FEE_LEVELS: 'send__update_fee_levels' = 'send__update_fee_levels'; -export const DATA_CHANGE: 'send__data_change' = 'send__data_change'; -export const SEND: 'send__submit' = 'send__submit'; +export const TX_SENDING: 'send__tx_sending' = 'send__tx_sending'; export const TX_COMPLETE: 'send__tx_complete' = 'send__tx_complete'; export const TX_ERROR: 'send__tx_error' = 'send__tx_error'; export const TOGGLE_ADVANCED: 'send__toggle_advanced' = 'send__toggle_advanced'; - -export const FROM_SESSION_STORAGE: 'send__from_session_storage' = 'send__from_session_storage'; \ No newline at end of file diff --git a/src/components/DeviceHeader/index.js b/src/components/DeviceHeader/index.js index 2bf43108..c47e3d3b 100644 --- a/src/components/DeviceHeader/index.js +++ b/src/components/DeviceHeader/index.js @@ -56,7 +56,7 @@ const Name = styled.div` display: block; text-overflow: ellipsis; overflow: hidden; - white-space: no-wrap; + white-space: nowrap; font-weight: 500; font-size: 14px; color: ${colors.TEXT_PRIMARY}; diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js index 1879547c..05d5f749 100644 --- a/src/components/Icon/index.js +++ b/src/components/Icon/index.js @@ -3,6 +3,16 @@ import PropTypes from 'prop-types'; import colors from 'config/colors'; import styled, { keyframes } from 'styled-components'; +const chooseIconAnimationType = (canAnimate, isActive) => { + if (canAnimate) { + if (isActive) { + return rotate180up; + } + return rotate180down; + } + return null; +}; + // TODO: make animation of icons better const rotate180up = keyframes` from { @@ -23,7 +33,7 @@ const rotate180down = keyframes` `; const SvgWrapper = styled.svg` - animation: ${props => (props.canAnimate ? (props.isActive ? rotate180up : rotate180down) : null)} 0.2s linear 1 forwards; + animation: ${props => chooseIconAnimationType(props.canAnimate, props.isActive)} 0.2s linear 1 forwards; :hover { path { diff --git a/src/reducers/AccountsReducer.js b/src/reducers/AccountsReducer.js index df05ff00..3e605191 100644 --- a/src/reducers/AccountsReducer.js +++ b/src/reducers/AccountsReducer.js @@ -45,7 +45,7 @@ const createAccount = (state: State, account: Account): State => { if (exist) { return state; } - const newState: State = [ ...state ]; + const newState: State = [...state]; newState.push(account); return newState; }; @@ -66,7 +66,7 @@ const updateAccount = (state: State, account: Account): State => { const newState: State = [...state]; newState[index] = account; return newState; -} +}; const setBalance = (state: State, action: AccountSetBalanceAction): State => { // const index: number = state.findIndex(account => account.address === action.address && account.network === action.network && account.deviceState === action.deviceState); @@ -100,7 +100,7 @@ export default (state: State = initialState, action: Action): State => { //case CONNECT.FORGET_SINGLE : // return forgetAccounts(state, action); - case ACCOUNT.UPDATE : + case ACCOUNT.UPDATE: return updateAccount(state, action.payload); case ACCOUNT.SET_BALANCE: diff --git a/src/reducers/FiatRateReducer.js b/src/reducers/FiatRateReducer.js index aee1ff80..af190d11 100644 --- a/src/reducers/FiatRateReducer.js +++ b/src/reducers/FiatRateReducer.js @@ -1,6 +1,5 @@ /* @flow */ - import { RATE_UPDATE } from 'services/CoinmarketcapService'; import type { Action } from 'flowtype'; @@ -27,7 +26,6 @@ const update = (state: Array, action: FiatRateAction): Array => { return newState; }; - export default (state: Array = initialState, action: Action): Array => { switch (action.type) { case RATE_UPDATE: diff --git a/src/reducers/ModalReducer.js b/src/reducers/ModalReducer.js index bd86b456..49b8bc07 100644 --- a/src/reducers/ModalReducer.js +++ b/src/reducers/ModalReducer.js @@ -94,7 +94,6 @@ export default function modal(state: State = initialState, action: Action): Stat case UI.CLOSE_UI_WINDOW: case MODAL.CLOSE: - case CONNECT.FORGET: case CONNECT.FORGET_SINGLE: case CONNECT.REMEMBER: diff --git a/src/reducers/PendingTxReducer.js b/src/reducers/PendingTxReducer.js index b8c80f57..9f4ab635 100644 --- a/src/reducers/PendingTxReducer.js +++ b/src/reducers/PendingTxReducer.js @@ -39,11 +39,13 @@ const add = (state: State, action: SendTxAction): State => { return newState; }; -const add_NEW = (state: State, payload: any): State => { +/* +const addFromBloockbokNotifiaction = (state: State, payload: any): State => { const newState = [...state]; newState.push(payload); return newState; }; +*/ const remove = (state: State, id: string): State => state.filter(tx => tx.id !== id); diff --git a/src/reducers/SendFormReducer.js b/src/reducers/SendFormReducer.js index df8902e3..b370be6e 100644 --- a/src/reducers/SendFormReducer.js +++ b/src/reducers/SendFormReducer.js @@ -1,17 +1,9 @@ /* @flow */ - -import EthereumjsUnits from 'ethereumjs-units'; import * as SEND from 'actions/constants/send'; -import * as WEB3 from 'actions/constants/web3'; import * as ACCOUNT from 'actions/constants/account'; -import { getFeeLevels } from 'actions/SendFormActions'; - import type { Action } from 'flowtype'; -import type { - Web3UpdateGasPriceAction, -} from 'actions/Web3Actions'; export type FeeLevel = { label: string; @@ -82,46 +74,16 @@ export const initialState: State = { infos: {}, }; - -const onGasPriceUpdated = (state: State, action: Web3UpdateGasPriceAction): State => { - // function getRandomInt(min, max) { - // return Math.floor(Math.random() * (max - min + 1)) + min; - // } - // const newPrice = getRandomInt(10, 50).toString(); - const newPrice: string = EthereumjsUnits.convert(action.gasPrice, 'wei', 'gwei'); - if (action.network === state.networkName && newPrice !== state.recommendedGasPrice) { - const newState: State = { ...state }; - if (!state.untouched) { - newState.gasPriceNeedsUpdate = true; - newState.recommendedGasPrice = newPrice; - } else { - const newFeeLevels = getFeeLevels(state.networkSymbol, newPrice, state.gasLimit); - const selectedFeeLevel: ?FeeLevel = newFeeLevels.find(f => f.value === 'Normal'); - if (!selectedFeeLevel) return state; - newState.recommendedGasPrice = newPrice; - newState.feeLevels = newFeeLevels; - newState.selectedFeeLevel = selectedFeeLevel; - newState.gasPrice = selectedFeeLevel.gasPrice; - } - return newState; - } - return state; -}; - - export default (state: State = initialState, action: Action): State => { switch (action.type) { case SEND.INIT: + case SEND.CHANGE: + case SEND.VALIDATION: return action.state; case ACCOUNT.DISPOSE: return initialState; - // this will be called right after Web3 instance initialization before any view is shown - // and async during app live time - case WEB3.GAS_PRICE_UPDATED: - return onGasPriceUpdated(state, action); - case SEND.TOGGLE_ADVANCED: return { @@ -129,22 +91,7 @@ export default (state: State = initialState, action: Action): State => { advanced: !state.advanced, }; - - // user actions - case SEND.ADDRESS_CHANGE: - case SEND.ADDRESS_VALIDATION: - case SEND.AMOUNT_CHANGE: - case SEND.SET_MAX: - case SEND.CURRENCY_CHANGE: - case SEND.FEE_LEVEL_CHANGE: - case SEND.UPDATE_FEE_LEVELS: - case SEND.GAS_PRICE_CHANGE: - case SEND.GAS_LIMIT_CHANGE: - case SEND.NONCE_CHANGE: - case SEND.DATA_CHANGE: - return action.state; - - case SEND.SEND: + case SEND.TX_SENDING: return { ...state, sending: true, @@ -156,32 +103,6 @@ export default (state: State = initialState, action: Action): State => { sending: false, }; - case SEND.VALIDATION: - return { - ...state, - errors: action.errors, - warnings: action.warnings, - infos: action.infos, - }; - - case SEND.FROM_SESSION_STORAGE: - return { - ...state, - - address: action.address, - amount: action.amount, - setMax: action.setMax, - selectedCurrency: action.selectedCurrency, - selectedFeeLevel: action.selectedFeeLevel, - advanced: action.advanced, - gasLimit: action.gasLimit, - gasPrice: action.gasPrice, - data: action.data, - nonce: action.nonce, - untouched: false, - touched: action.touched, - }; - default: return state; } diff --git a/src/reducers/Web3Reducer.js b/src/reducers/Web3Reducer.js index 9c25d9c8..cc903fdf 100644 --- a/src/reducers/Web3Reducer.js +++ b/src/reducers/Web3Reducer.js @@ -4,7 +4,6 @@ import Web3 from 'web3'; import type { Contract } from 'web3'; -import * as STORAGE from 'actions/constants/localStorage'; import * as WEB3 from 'actions/constants/web3'; import type { Action } from 'flowtype'; diff --git a/src/reducers/index.js b/src/reducers/index.js index ed6f1aa6..a4955da5 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -40,7 +40,7 @@ const reducers = { fiat, wallet, devices, - blockchain + blockchain, }; export type Reducers = typeof reducers; diff --git a/src/reducers/utils/index.js b/src/reducers/utils/index.js index b5c0056c..9b5ba7f5 100644 --- a/src/reducers/utils/index.js +++ b/src/reducers/utils/index.js @@ -115,3 +115,22 @@ export const getWeb3 = (state: State): ?Web3Instance => { if (!locationState.network) return null; return state.web3.find(w3 => w3.network === locationState.network); }; + +export const observeChanges = (prev: ?(Object | Array), current: ?(Object | Array), fields?: Array): boolean => { + if (prev !== current) { + // 1. one of the objects is null/undefined + if (!prev || !current) return true; + // 2. object are Arrays and they have different length + if (Array.isArray(prev) && Array.isArray(current)) return prev.length !== current.length; + // 3. no nested field to check + if (!Array.isArray(fields)) return true; + // 4. validate nested field + if (prev instanceof Object && current instanceof Object) { + for (let i = 0; i < fields.length; i++) { + const key = fields[i]; + if (prev[key] !== current[key]) return true; + } + } + } + return false; +}; diff --git a/src/services/CoinmarketcapService.js b/src/services/CoinmarketcapService.js index af6e914a..d6c0e9ea 100644 --- a/src/services/CoinmarketcapService.js +++ b/src/services/CoinmarketcapService.js @@ -13,7 +13,6 @@ import type { AsyncAction, GetState, } from 'flowtype'; -import type { Config, FiatValueTicker } from 'reducers/LocalStorageReducer'; export const RATE_UPDATE: 'rate__update' = 'rate__update'; @@ -24,11 +23,11 @@ export type FiatRateAction = { } const loadRateAction = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const config: ?Config = getState().localStorage.config; + const { config } = getState().localStorage; if (!config) return; try { - config.fiatValueTickers.forEach(async (ticker: FiatValueTicker) => { + config.fiatValueTickers.forEach(async (ticker) => { // const rate: ?Array = await JSONRequest(`${ticker.url}?convert=USD`, 'json'); const rate: ?Array = await httpRequest(`${ticker.url}?convert=USD`, 'json'); if (rate) { diff --git a/src/services/WalletService.js b/src/services/WalletService.js index ab9f6b95..8856b0c9 100644 --- a/src/services/WalletService.js +++ b/src/services/WalletService.js @@ -10,6 +10,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 type { Middleware, @@ -91,6 +92,8 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa api.dispatch(NotificationActions.clear(prevLocation.state, currentLocation.state)); } + // observe send form props changes + api.dispatch(SendFormActionActions.observe(prevState, action)); // update common values in WallerReducer api.dispatch(WalletActions.updateSelectedValues(prevState, action)); diff --git a/src/store.js b/src/store.js index 30e78995..06257554 100644 --- a/src/store.js +++ b/src/store.js @@ -3,8 +3,6 @@ import { createStore, applyMiddleware, compose } from 'redux'; import { routerMiddleware } from 'react-router-redux'; import thunk from 'redux-thunk'; -// import createHistory from 'history/createBrowserHistory'; -// import { useRouterHistory } from 'react-router'; import createHistory from 'history/createHashHistory'; import { createLogger } from 'redux-logger'; import reducers from 'reducers'; @@ -17,17 +15,21 @@ import type { Action, GetState, Store } from 'flowtype'; export const history: History = createHistory({ queryKey: false }); -const RAVEN_KEY: string = 'https://497392c3ff6e46dc9e54eef123979378@sentry.io/294339'; -Raven.config(RAVEN_KEY).install(); - const initialState: any = {}; const enhancers = []; -const middleware = [ + +const middlewares = [ thunk, - RavenMiddleware(RAVEN_KEY), routerMiddleware(history), ]; +// sentry io middleware only in dev build +if (process.env.BUILD === 'development') { + const RAVEN_KEY = 'https://34b8c09deb6c4cd2a4dc3f0029cd02d8@sentry.io/1279550'; + const ravenMiddleware = RavenMiddleware(RAVEN_KEY); + Raven.config(RAVEN_KEY).install(); + middlewares.push(ravenMiddleware); +} let composedEnhancers: any; if (process.env.NODE_ENV === 'development') { @@ -50,13 +52,12 @@ if (process.env.NODE_ENV === 'development') { } composedEnhancers = compose( - applyMiddleware(logger, ...middleware, ...services), + applyMiddleware(logger, ...middlewares, ...services), ...enhancers, ); - } else { composedEnhancers = compose( - applyMiddleware(...middleware, ...services), + applyMiddleware(...middlewares, ...services), ...enhancers, ); } diff --git a/src/utils/ethUtils.js b/src/utils/ethUtils.js index 82d5744a..2d4b553e 100644 --- a/src/utils/ethUtils.js +++ b/src/utils/ethUtils.js @@ -5,14 +5,11 @@ import EthereumjsUtil from 'ethereumjs-util'; export const decimalToHex = (dec: number): string => new BigNumber(dec).toString(16); -export const padLeftEven = (hex: string): string => { - hex = hex.length % 2 != 0 ? `0${hex}` : hex; - return hex; -}; +export const padLeftEven = (hex: string): string => (hex.length % 2 !== 0 ? `0${hex}` : hex); -export const sanitizeHex = (hex: number | string): ?string => { - if (typeof hex !== 'string') return null; - hex = hex.substring(0, 2) === '0x' ? hex.substring(2) : hex; +export const sanitizeHex = ($hex: number | string): ?string => { + if (typeof $hex !== 'string') return null; + const hex = $hex.substring(0, 2) === '0x' ? $hex.substring(2) : $hex; if (hex === '') return ''; return `0x${padLeftEven(hex)}`; }; @@ -35,10 +32,12 @@ export const validateAddress = (address: string): ?string => { const hasUpperCase = new RegExp('^(.*[A-Z].*)$'); if (address.length < 1) { return 'Address is not set'; - } else if (!EthereumjsUtil.isValidAddress(address)) { + } + if (!EthereumjsUtil.isValidAddress(address)) { return 'Address is not valid'; - } else if (address.match(hasUpperCase) && !EthereumjsUtil.isValidChecksumAddress(address)) { + } + if (address.match(hasUpperCase) && !EthereumjsUtil.isValidChecksumAddress(address)) { return 'Address is not a valid checksum'; } return null; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/utils/promiseUtils.js b/src/utils/promiseUtils.js index 48be95a1..6567d30c 100644 --- a/src/utils/promiseUtils.js +++ b/src/utils/promiseUtils.js @@ -1,12 +1,5 @@ /* @flow */ - -// import root from 'window-or-global'; -// import Promise from 'es6-promise'; - -export async function resolveAfter(msec: number, value?: any): Promise { - await new Promise((resolve) => { - //root.setTimeout(resolve, msec, value); - window.setTimeout(resolve, msec, value); - }); -} \ No newline at end of file +export const resolveAfter = (msec: number, value?: any): Promise => new Promise((resolve) => { + window.setTimeout(resolve, msec, value); +}); diff --git a/src/views/Landing/index.js b/src/views/Landing/index.js index ed2dd87b..3da5dd4a 100644 --- a/src/views/Landing/index.js +++ b/src/views/Landing/index.js @@ -1,6 +1,6 @@ /* @flow */ - import React from 'react'; +import CaseImage from 'images/case.png'; import styled from 'styled-components'; import Header from 'components/Header'; import Footer from 'components/Footer'; @@ -39,11 +39,10 @@ const LandingContent = styled.div` justify-content: center; `; -const LandingImage = styled.div` +const LandingImage = styled.img` width: 777px; min-height: 500px; margin: auto; - background-image: url('../images/case.png'); background-repeat: no-repeat; background-position: center 0px; background-size: contain; @@ -120,7 +119,7 @@ export default (props: Props) => { showDisconnect={shouldShowDisconnectDevice} /> - + {shouldShowConnectDevice && ( diff --git a/src/views/Wallet/views/AccountSend/components/AdvancedForm/index.js b/src/views/Wallet/views/AccountSend/components/AdvancedForm/index.js new file mode 100644 index 00000000..3a87a335 --- /dev/null +++ b/src/views/Wallet/views/AccountSend/components/AdvancedForm/index.js @@ -0,0 +1,219 @@ +/* @flow */ + +import React from 'react'; +import styled from 'styled-components'; +import colors from 'config/colors'; + +import Input from 'components/inputs/Input'; +import Textarea from 'components/Textarea'; +import Tooltip from 'components/Tooltip'; +import Icon from 'components/Icon'; +import Link from 'components/Link'; +import ICONS from 'config/icons'; + +import type { Props } from '../../Container'; + +const InputRow = styled.div` + margin-bottom: 20px; +`; + +const InputLabelWrapper = styled.div` + display: flex; + align-items: center; +`; + +const GreenSpan = styled.span` + color: ${colors.GREEN_PRIMARY}; +`; + +const AdvancedSettingsWrapper = styled.div` + padding: 20px 0; + display: flex; + flex-direction: column; + justify-content: space-between; + + border-top: 1px solid ${colors.DIVIDER}; +`; + +const GasInputRow = styled(InputRow)` + width: 100%; + display: flex; +`; + +const GasInput = styled(Input)` + &:first-child { + padding-right: 20px; + } +`; + +const StyledTextarea = styled(Textarea)` + margin-bottom: 20px; + height: 80px; +`; + +const AdvancedSettingsSendButtonWrapper = styled.div` + width: 100%; + display: flex; + justify-content: flex-end; +`; + +const getGasLimitInputState = (gasLimitErrors: string, gasLimitWarnings: string): string => { + let state = ''; + if (gasLimitWarnings && !gasLimitErrors) { + state = 'warning'; + } + if (gasLimitErrors) { + state = 'error'; + } + return state; +}; + +const getGasPriceInputState = (gasPriceErrors: string, gasPriceWarnings: string): string => { + let state = ''; + if (gasPriceWarnings && !gasPriceErrors) { + state = 'warning'; + } + if (gasPriceErrors) { + state = 'error'; + } + return state; +}; + +// stateless component +const AdvancedForm = (props: Props) => { + const { + network, + } = props.selectedAccount; + if (!network) return null; + const { + networkSymbol, + currency, + recommendedGasPrice, + errors, + warnings, + infos, + data, + gasLimit, + gasPrice, + } = props.sendForm; + const { + onGasLimitChange, + onGasPriceChange, + onDataChange, + } = props.sendFormActions; + + let gasLimitTooltipCurrency: string; + let gasLimitTooltipValue: string; + if (networkSymbol !== currency) { + gasLimitTooltipCurrency = 'tokens'; + gasLimitTooltipValue = network.defaultGasLimitTokens.toString(10); + } else { + gasLimitTooltipCurrency = networkSymbol; + gasLimitTooltipValue = network.defaultGasLimit.toString(10); + } + + return ( + + + + Gas limit + + Gas limit is the amount of gas to send with your transaction.
+ TX fee = gas price * gas limit & is paid to miners for including your TX in a block.
+ Increasing this number will not get your TX mined faster.
+ Default value for sending {gasLimitTooltipCurrency} is {gasLimitTooltipValue} + + )} + placement="top" + > + +
+ + )} + bottomText={errors.gasLimit || warnings.gasLimit || infos.gasLimit} + value={gasLimit} + isDisabled={networkSymbol === currency && data.length > 0} + onChange={event => onGasLimitChange(event.target.value)} + /> + + + Gas price + + Gas Price is the amount you pay per unit of gas.
+ TX fee = gas price * gas limit & is paid to miners for including your TX in a block.
+ Higher the gas price = faster transaction, but more expensive. Recommended is {recommendedGasPrice} GWEI.
+ Read more + + )} + placement="top" + > + +
+ + )} + bottomText={errors.gasPrice || warnings.gasPrice || infos.gasPrice} + value={gasPrice} + onChange={event => onGasPriceChange(event.target.value)} + /> +
+ + + Data + + Data is usually used when you send transactions to contracts. + + )} + placement="top" + > + + + + )} + bottomText={errors.data || warnings.data || infos.data} + disabled={networkSymbol !== currency} + value={networkSymbol !== currency ? '' : data} + onChange={event => onDataChange(event.target.value)} + /> + + + { props.children } + +
+ ); +}; + +export default AdvancedForm; \ No newline at end of file diff --git a/src/views/Wallet/views/AccountSend/index.js b/src/views/Wallet/views/AccountSend/index.js index c2318e72..fa0e2916 100644 --- a/src/views/Wallet/views/AccountSend/index.js +++ b/src/views/Wallet/views/AccountSend/index.js @@ -1,6 +1,6 @@ /* @flow */ -import React, { Component } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { Select } from 'components/Select'; import Button from 'components/Button'; @@ -12,11 +12,9 @@ import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables'; import colors from 'config/colors'; import P from 'components/Paragraph'; import { H2 } from 'components/Heading'; -import Textarea from 'components/Textarea'; -import Tooltip from 'components/Tooltip'; -import { calculate, validation } from 'actions/SendFormActions'; import SelectedAccount from 'views/Wallet/components/SelectedAccount'; import type { Token } from 'flowtype'; +import AdvancedForm from './components/AdvancedForm'; import PendingTransactions from './components/PendingTransactions'; import type { Props } from './Container'; @@ -25,11 +23,6 @@ import type { Props } from './Container'; // and put it inside config/variables.js const SmallScreenWidth = '850px'; -type State = { - isAdvancedSettingsHidden: boolean, - shouldAnimateAdvancedSettingsToggle: boolean, -}; - const Wrapper = styled.section` padding: 0 48px; `; @@ -38,6 +31,16 @@ const StyledH2 = styled(H2)` padding: 20px 0; `; +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` margin-bottom: 20px; `; @@ -143,453 +146,264 @@ const SendButton = styled(Button)` } `; -const AdvancedSettingsWrapper = styled.div` - padding: 20px 0; - display: flex; - flex-direction: column; - justify-content: space-between; - - border-top: 1px solid ${colors.DIVIDER}; -`; - -const GasInputRow = styled(InputRow)` - width: 100%; - display: flex; -`; - -const GasInput = styled(Input)` - &:first-child { - padding-right: 20px; - } -`; - -const AdvancedSettingsSendButtonWrapper = styled.div` - width: 100%; - display: flex; - justify-content: flex-end; -`; - -const StyledTextarea = styled(Textarea)` - margin-bottom: 20px; - height: 80px; -`; - const AdvancedSettingsIcon = styled(Icon)` margin-left: 10px; `; -const GreenSpan = styled.span` - color: ${colors.GREEN_PRIMARY}; -`; - -const InputLabelWrapper = styled.div` - display: flex; - align-items: center; -`; - -class AccountSend extends Component { - constructor(props: Props) { - super(props); - this.state = { - isAdvancedSettingsHidden: true, - shouldAnimateAdvancedSettingsToggle: false, - }; +// render helpers +const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => { + let state = ''; + if (address && !addressErrors) { + state = 'success'; } - - componentWillReceiveProps(newProps: Props) { - calculate(this.props, newProps); - validation(newProps); + if (addressWarnings && !addressErrors) { + state = 'warning'; } - - getAddressInputState(address: string, addressErrors: string, addressWarnings: string) { - let state = ''; - if (address && !addressErrors) { - state = 'success'; - } - if (addressWarnings && !addressErrors) { - state = 'warning'; - } - if (addressErrors) { - state = 'error'; - } - return state; + if (addressErrors) { + state = 'error'; } + return state; +}; - getAmountInputState(amountErrors: string, amountWarnings: string) { - let state = ''; - if (amountWarnings && !amountErrors) { - state = 'warning'; - } - if (amountErrors) { - state = 'error'; - } - return state; +const getAmountInputState = (amountErrors: string, amountWarnings: string): string => { + let state = ''; + if (amountWarnings && !amountErrors) { + state = 'warning'; } - - getGasLimitInputState(gasLimitErrors: string, gasLimitWarnings: string) { - let state = ''; - if (gasLimitWarnings && !gasLimitErrors) { - state = 'warning'; - } - if (gasLimitErrors) { - state = 'error'; - } - return state; + if (amountErrors) { + state = 'error'; } + return state; +}; - getGasPriceInputState(gasPriceErrors: string, gasPriceWarnings: string) { - let state = ''; - if (gasPriceWarnings && !gasPriceErrors) { - state = 'warning'; - } - if (gasPriceErrors) { - 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 }); - getTokensSelectData(tokens: Array, accountNetwork: any) { - 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; +}; - return tokensSelectData; +// stateless component +const AccountSend = (props: Props) => { + const device = props.wallet.selectedDevice; + const { + account, + network, + discovery, + tokens, + } = 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 isCurrentCurrencyToken = networkSymbol !== currency; + + let selectedTokenBalance = 0; + const selectedToken = tokens.find(t => t.symbol === currency); + if (selectedToken) { + selectedTokenBalance = selectedToken.balance; } - handleToggleAdvancedSettingsButton() { - this.toggleAdvancedSettings(); - } + if (!device || !account || !discovery || !network) return null; - toggleAdvancedSettings() { - this.setState(previousState => ({ - isAdvancedSettingsHidden: !previousState.isAdvancedSettingsHidden, - shouldAnimateAdvancedSettingsToggle: true, - })); + 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}`; } - render() { - const device = this.props.wallet.selectedDevice; - const { - account, - network, - discovery, - tokens, - } = this.props.selectedAccount; - const { - address, - amount, - setMax, - networkSymbol, - currency, - feeLevels, - selectedFeeLevel, - recommendedGasPrice, - gasPriceNeedsUpdate, - total, - errors, - warnings, - infos, - data, - sending, - gasLimit, - gasPrice, - } = this.props.sendForm; - - const { - onAddressChange, - onAmountChange, - onSetMax, - onCurrencyChange, - onFeeLevelChange, - updateFeeLevels, - onSend, - onGasLimitChange, - onGasPriceChange, - onDataChange, - } = this.props.sendFormActions; - - if (!device || !account || !discovery || !network) return null; - - 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 = this.getTokensSelectData(tokens, network); - - let gasLimitTooltipCurrency: string; - let gasLimitTooltipValue: string; - if (networkSymbol !== currency) { - gasLimitTooltipCurrency = 'tokens'; - gasLimitTooltipValue = network.defaultGasLimitTokens.toString(10); - } else { - gasLimitTooltipCurrency = networkSymbol; - gasLimitTooltipValue = network.defaultGasLimit.toString(10); - } - - - return ( - - - Send Ethereum or tokens - - onAddressChange(event.target.value)} - /> - - - - 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} + )} + )} - - - {!this.state.isAdvancedSettingsHidden && ( - - - - Gas limit - - Gas limit is the amount of gas to send with your transaction.
- TX fee = gas price * gas limit & is paid to miners for including your TX in a block.
- Increasing this number will not get your TX mined faster.
- Default value for sending {gasLimitTooltipCurrency} is {gasLimitTooltipValue} - - )} - placement="top" - > - -
- + value={amount} + onChange={event => onAmountChange(event.target.value)} + bottomText={errors.amount || warnings.amount || infos.amount} + sideAddons={[ + ( + onSetMax()} + isActive={setMax} + > + {!setMax && ( + )} - bottomText={errors.gasLimit || warnings.gasLimit || infos.gasLimit} - value={gasLimit} - isDisabled={networkSymbol === currency && data.length > 0} - onChange={event => onGasLimitChange(event.target.value)} - /> - - - Gas price - - Gas Price is the amount you pay per unit of gas.
- TX fee = gas price * gas limit & is paid to miners for including your TX in a block.
- Higher the gas price = faster transaction, but more expensive. Recommended is {recommendedGasPrice} GWEI.
- Read more - - )} - placement="top" - > - -
- + {setMax && ( + )} - bottomText={errors.gasPrice || warnings.gasPrice || infos.gasPrice} - value={gasPrice} - onChange={event => onGasPriceChange(event.target.value)} + Set max +
+ ), + ( + -
- - - Data - - Data is usually used when you send transactions to contracts. - - )} - placement="top" - > - - - - )} - disabled={networkSymbol !== currency} - value={networkSymbol !== currency ? '' : data} - onChange={event => onDataChange(event.target.value)} - /> - - - onSend()} - > - {sendButtonText} - - -
- )} - - {this.props.selectedAccount.pending.length > 0 && ( - +
+ + + + Fee + {gasPriceNeedsUpdate && ( + + + Recommended fees updated. Click here to use them + + )} + +