diff --git a/packages/fether-react/package.json b/packages/fether-react/package.json index 112e5a7dded1ab6283312397e38cccde4b14cd36..092a19c2709d53ef414f025099ef664afd408b5c 100644 --- a/packages/fether-react/package.json +++ b/packages/fether-react/package.json @@ -31,7 +31,7 @@ "start": "npm-run-all -p start-*", "start-css": "npm run build-css -- --watch --recursive", "start-js": "react-app-rewired start", - "test": "react-app-rewired test --env=jsdom --coverage" + "test": "react-app-rewired test --env=jsdom" }, "dependencies": { "@parity/api": "^2.1.22", diff --git a/packages/fether-react/src/Send/TxForm/TxForm.js b/packages/fether-react/src/Send/TxForm/TxForm.js index 6a65f3941869d9904f278147d90d7d20367069e4..8d3d9695c08b05bf6fabac4e1a3788a28378c563 100644 --- a/packages/fether-react/src/Send/TxForm/TxForm.js +++ b/packages/fether-react/src/Send/TxForm/TxForm.js @@ -8,14 +8,14 @@ import debounce from 'debounce-promise'; import { estimateGas } from '../../utils/estimateGas'; import { Field, Form } from 'react-final-form'; import { Form as FetherForm, Header } from 'fether-ui'; -import { fromWei, toWei } from '@parity/api/lib/util/wei'; +import { toWei } from '@parity/api/lib/util/wei'; import { inject, observer } from 'mobx-react'; import { isAddress } from '@parity/api/lib/util/address'; import { Link } from 'react-router-dom'; import { withProps } from 'recompose'; import TokenBalance from '../../Tokens/TokensList/TokenBalance'; -import withBalance from '../../utils/withBalance'; +import withBalance, { withEthBalance } from '../../utils/withBalance'; const MAX_GAS_PRICE = 40; // In Gwei const MIN_GAS_PRICE = 3; // Safelow gas price from GasStation, in Gwei @@ -24,7 +24,8 @@ const MIN_GAS_PRICE = 3; // Safelow gas price from GasStation, in Gwei @withProps(({ match: { params: { tokenAddress } }, tokensStore }) => ({ token: tokensStore.tokens[tokenAddress] })) -@withBalance +@withBalance // Balance of current token (can be ETH) +@withEthBalance // ETH balance @observer class Send extends Component { handleSubmit = values => { @@ -87,7 +88,7 @@ class Send extends Component { { try { - const { balance, parityStore, token } = this.props; + const { balance, ethBalance, parityStore, token } = this.props; const amount = +values.amount; if (!amount || isNaN(amount)) { @@ -137,23 +138,19 @@ class Send extends Component { return { amount: `You don't have enough ${token.symbol} balance` }; } - if (token.address !== 'ETH') { - // No need to estimate gas for tokens. - // TODO Make sure that user has enough ETH balance - return; - } - const estimated = await estimateGas(values, token, parityStore.api); - if (!balance || isNaN(estimated)) { - throw new Error('No "balance" or "estimated" value.'); + if (!ethBalance || isNaN(estimated)) { + throw new Error('No "ethBalance" or "estimated" value.'); } - // Calculate the max amount the user can send - const maxAmount = +fromWei( - toWei(balance).minus(estimated.mul(toWei(values.gasPrice, 'shannon'))) - ); - if (amount > maxAmount) { + // Verify that `gas + (eth amount if sending eth) <= ethBalance` + if ( + estimated + .mul(toWei(values.gasPrice, 'shannon')) + .plus(token.address === 'ETH' ? toWei(values.amount) : 0) + .gt(toWei(ethBalance)) + ) { return { amount: "You don't have enough ETH balance" }; } } catch (err) { diff --git a/packages/fether-react/src/assets/sass/modules/_reset.scss b/packages/fether-react/src/assets/sass/modules/_reset.scss index a690078a49d4823893c2dcfd3acfd91ddc62da2e..dee7f26219cb3074853df6aafa895b570ca91378 100644 --- a/packages/fether-react/src/assets/sass/modules/_reset.scss +++ b/packages/fether-react/src/assets/sass/modules/_reset.scss @@ -1,15 +1,74 @@ @mixin reset { @viewport { - zoom: 1.0; + zoom: 1; width: extend-to-zoom; } @-ms-viewport { width: extend-to-zoom; - zoom: 1.0; + zoom: 1; } - html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, font, img, ins, kbd, q, s, samp, small, strike, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td { + html, + body, + div, + span, + applet, + object, + iframe, + h1, + h2, + h3, + h4, + h5, + h6, + p, + blockquote, + pre, + a, + abbr, + acronym, + address, + big, + cite, + code, + del, + dfn, + font, + img, + ins, + kbd, + q, + s, + samp, + small, + strike, + sub, + sup, + tt, + var, + b, + u, + i, + center, + dl, + dt, + dd, + ol, + ul, + li, + fieldset, + form, + label, + legend, + table, + caption, + tbody, + tfoot, + thead, + tr, + th, + td { margin: 0; padding: 0; font-size: 100%; @@ -17,7 +76,7 @@ border: 0; outline: 0; background: transparent; - font-feature-settings: "kern", "liga", "pnum"; + font-feature-settings: 'kern', 'liga', 'pnum'; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; webkit-text-size-adjust: 100%; @@ -25,11 +84,15 @@ text-size-adjust: 100%; } - blockquote, q { + blockquote, + q { quotes: none; } - input:focus, select:focus, button:focus { + button:focus, + input:focus, + select:focus, + textarea:focus { outline: 0; } @@ -38,7 +101,8 @@ border-spacing: 0; } - ul, ol { + ul, + ol { list-style: none; } diff --git a/packages/fether-react/src/stores/sendStore.spec.js b/packages/fether-react/src/stores/sendStore.spec.js index 3eb0bab6cc64a5179ebb25bbc6c65a66200c7d9b..f6b3730c0eb6ebc317688ac92f409249abd83806 100644 --- a/packages/fether-react/src/stores/sendStore.spec.js +++ b/packages/fether-react/src/stores/sendStore.spec.js @@ -5,8 +5,10 @@ /* eslint-env jest */ +import BigNumber from 'bignumber.js'; import lightJs from '@parity/light.js'; // Mocked +import * as mock from '../utils/testHelpers/mock'; import parityStore from './parityStore'; import { SendStore } from './sendStore'; import * as storeTests from '../utils/testHelpers/storeTests'; @@ -18,40 +20,14 @@ jest.mock('@parity/light.js', () => ({ unsubscribe: jest.fn() })) })), - makeContract$: jest.fn(() => ({ - transfer$: jest.fn(() => ({ subscribe: jest.fn() })) - })), - post$: jest.fn(() => ({ - subscribe: jest.fn(callback => { - setTimeout(callback({ estimating: true }), 100); // eslint-disable-line standard/no-callback-literal - setTimeout(callback({ requested: 1 }), 200); // eslint-disable-line standard/no-callback-literal - }) - })) + makeContract$: jest.fn(() => mock.makeContract$), + post$: jest.fn(() => mock.post$) })); jest.mock('./parityStore', () => ({ - api: { - signer: { - confirmRequest: jest.fn(() => Promise.resolve(true)) - } - } + api: mock.api })); -const mockTx = { - amount: 0.01, // In Ether or in token - gasPrice: 4, // in Gwei - to: '0x123' -}; - -const mockErc20Token = { - address: 'foo', - decimals: 18 -}; - -const mockEthToken = { - address: 'ETH' -}; - let sendStore; // Will hold the newly created instance of SendStore in each test beforeEach(() => { sendStore = new SendStore(); @@ -75,7 +51,7 @@ describe('method acceptRequest', () => { describe('method clear', () => { test('should clear tx', () => { - sendStore.setTx(mockTx); + sendStore.setTx(mock.tx); sendStore.clear(); expect(sendStore.tx).toEqual({}); }); @@ -107,32 +83,36 @@ describe('@computed confirmations', () => { describe('method send', () => { beforeEach(() => { - sendStore.setTx(mockTx); + sendStore.setTx(mock.tx); }); - test('should call makeContract$ if the token is Erc20 ', () => { - sendStore.send(mockErc20Token); - expect(lightJs.makeContract$).toHaveBeenCalled(); - }); - - test.skip('should call transfer$ if the token is Erc20 and subscribe to it', () => { - // TODO + test('should call transfer$ if the token is Erc20', () => { + sendStore.send(mock.erc20); + expect(mock.makeContract$.transfer$).toHaveBeenCalledWith( + '0x123', + new BigNumber('10000000000000000'), + { gasPrice: new BigNumber('4000000000') } + ); }); - test('should call post$ if the token is ETH and subscribe to it', () => { - sendStore.send(mockEthToken); - expect(lightJs.post$).toHaveBeenCalled(); + test('should call post$ if the token is ETH', () => { + sendStore.send(mock.eth); + expect(lightJs.post$).toHaveBeenCalledWith({ + gasPrice: new BigNumber('4000000000'), + to: '0x123', + value: new BigNumber('10000000000000000') + }); }); test('should update txStatus', () => { sendStore.setTxStatus = jest.fn(); - sendStore.send(mockEthToken); + sendStore.send(mock.eth); expect(sendStore.setTxStatus).toHaveBeenCalledWith({ estimating: true }); }); test('should call acceptRequest when txStatus is requested', () => { sendStore.acceptRequest = jest.fn(() => Promise.resolve(true)); - sendStore.send(mockEthToken, 'foo'); + sendStore.send(mock.eth, 'foo'); expect(sendStore.acceptRequest).toHaveBeenCalledWith(1, 'foo'); }); }); diff --git a/packages/fether-react/src/utils/estimateGas.js b/packages/fether-react/src/utils/estimateGas.js index 72ad7ec5d095b81f5fa0605d1ddb3c62d281f432..2be93363a818f0a69f71d64a05f3f1ca34c9f668 100644 --- a/packages/fether-react/src/utils/estimateGas.js +++ b/packages/fether-react/src/utils/estimateGas.js @@ -23,7 +23,7 @@ export const contractForToken = memoize(tokenAddress => */ export const estimateGas = (tx, token, api) => { if (!tx || !Object.keys(tx).length) { - return Promise.reject(new Error('Tx not set in sendStore.')); + return Promise.reject(new Error('Tx not set.')); } if (token.address === 'ETH') { diff --git a/packages/fether-react/src/utils/estimateGas.spec.js b/packages/fether-react/src/utils/estimateGas.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3408042913cfd18828225ccccdc5ad0293c33750 --- /dev/null +++ b/packages/fether-react/src/utils/estimateGas.spec.js @@ -0,0 +1,52 @@ +// Copyright 2015-2018 Parity Technologies (UK) Ltd. +// This file is part of Parity. +// +// SPDX-License-Identifier: BSD-3-Clause + +/* eslint-env jest */ + +import abi from '@parity/shared/lib/contracts/abi/eip20'; +import BigNumber from 'bignumber.js'; +import lightJs from '@parity/light.js'; + +import { estimateGas, contractForToken } from './estimateGas'; +import * as mock from './testHelpers/mock'; + +jest.mock('@parity/light.js', () => ({ + makeContract$: jest.fn(() => mock.makeContract$) +})); + +describe('contractForToken', () => { + test('should call makeContract$', () => { + contractForToken('foo'); + expect(lightJs.makeContract$).toHaveBeenCalledWith('foo', abi); + }); + + test('should be memoized', () => { + const a = contractForToken('foo'); + const b = contractForToken('foo'); + expect(a).toBe(b); + }); +}); + +describe('estimateGas', () => { + test('should throw error if no tx is set', () => { + expect(estimateGas(null)).rejects.toHaveProperty('message', 'Tx not set.'); + }); + + test('should throw error if tx is empty', () => { + expect(estimateGas({})).rejects.toHaveProperty('message', 'Tx not set.'); + }); + + test('should call estimateGasForErc20 with token', () => { + expect(estimateGas(mock.tx, mock.erc20, mock.api)).resolves.toEqual( + new BigNumber(153.75) + ); + }); + + test('should call estimateGasForEth with token', () => { + expect(estimateGas(mock.tx, mock.eth, mock.api)).resolves.toEqual( + new BigNumber(570) + ); + }); +}); diff --git a/packages/fether-react/src/utils/testHelpers/mock.js b/packages/fether-react/src/utils/testHelpers/mock.js new file mode 100644 index 0000000000000000000000000000000000000000..08e18c7a029c666e90632b1751a1a200d89bd0b1 --- /dev/null +++ b/packages/fether-react/src/utils/testHelpers/mock.js @@ -0,0 +1,57 @@ +// Copyright 2015-2018 Parity Technologies (UK) Ltd. +// This file is part of Parity. +// +// SPDX-License-Identifier: BSD-3-Clause + +/* eslint-env jest */ + +import BigNumber from 'bignumber.js'; + +export const api = { + eth: { + estimateGas: jest.fn(() => Promise.resolve(new BigNumber(456))) + }, + parity: { + generateSecretPhrase: jest.fn(() => Promise.resolve('foo')), + newAccountFromPhrase: jest.fn(() => Promise.resolve()), + phraseToAddress: jest.fn(() => Promise.resolve('0x123')), + setAccountName: jest.fn(() => Promise.resolve()), + setAccountMeta: jest.fn(() => Promise.resolve()) + }, + signer: { + confirmRequest: jest.fn(() => Promise.resolve(true)) + } +}; + +export const erc20 = { + address: 'foo', + decimals: 18 +}; + +export const eth = { + address: 'ETH' +}; + +export const makeContract$ = { + contractObject: { + instance: { + transfer: { + estimateGas: () => Promise.resolve(new BigNumber(123)) + } + } + }, + transfer$: jest.fn(() => ({ subscribe: jest.fn() })) +}; + +export const post$ = { + subscribe: jest.fn(callback => { + setTimeout(callback({ estimating: true }), 100); // eslint-disable-line standard/no-callback-literal + setTimeout(callback({ requested: 1 }), 200); // eslint-disable-line standard/no-callback-literal + }) +}; + +export const tx = { + amount: 0.01, // In Ether or in token + gasPrice: 4, // in Gwei + to: '0x123' +}; diff --git a/packages/fether-react/src/utils/withBalance.js b/packages/fether-react/src/utils/withBalance.js index c4442e731e3a63ba0c27d7ea9a2d3041117a5ec5..2292bf648ecaf3d72f89d6f5ea675070a9ed9fc8 100644 --- a/packages/fether-react/src/utils/withBalance.js +++ b/packages/fether-react/src/utils/withBalance.js @@ -4,7 +4,8 @@ // SPDX-License-Identifier: BSD-3-Clause import abi from '@parity/shared/lib/contracts/abi/eip20'; -import { empty } from 'rxjs'; +import branch from 'recompose/branch'; +import compose from 'recompose/compose'; import { defaultAccount$, isNullOrLoading, @@ -14,6 +15,27 @@ import { import { filter, map, switchMap } from 'rxjs/operators'; import { fromWei } from '@parity/api/lib/util/wei'; import light from 'light-hoc'; +import withProps from 'recompose/withProps'; + +export const withErc20Balance = light({ + erc20Balance: ({ token }) => + defaultAccount$().pipe( + filter(x => x), + switchMap(defaultAccount => + makeContract$(token.address, abi).balanceOf$(defaultAccount) + ), + map(value => (isNullOrLoading(value) ? null : value)), // Transform loading state to null + map(value => value && value.div(10 ** token.decimals)) + ) +}); + +export const withEthBalance = light({ + ethBalance: () => + myBalance$().pipe( + map(value => (isNullOrLoading(value) ? null : value)), // Transform loading state to null + map(value => value && fromWei(value)) + ) +}); /** * A HOC on light.js to get the current balance. The inner component needs to @@ -25,26 +47,13 @@ import light from 'light-hoc'; * * } */ -export default light({ - balance: ownProps => { - // Find our token object in the props - const { token } = ownProps; - - if (!token || !token.address) { - return empty(); - } - return token.address === 'ETH' - ? myBalance$().pipe( - map(value => (isNullOrLoading(value) ? null : value)), // Transform loading state to null - map(value => value && fromWei(value)) - ) - : defaultAccount$().pipe( - filter(x => x), - switchMap(defaultAccount => - makeContract$(token.address, abi).balanceOf$(defaultAccount) - ), - map(value => (isNullOrLoading(value) ? null : value)), // Transform loading state to null - map(value => value && value.div(10 ** token.decimals)) - ); - } -}); +export default compose( + branch( + ({ token }) => token && token.address && token.address !== 'ETH', + withErc20Balance, + withEthBalance + ), + withProps(props => ({ + balance: props.erc20Balance || props.ethBalance + })) +); diff --git a/packages/fether-ui/src/Form/Field/Field.js b/packages/fether-ui/src/Form/Field/Field.js index ae6350cd52ba8fdf5e34e146ad3491dd9f9b9d63..ccb1e3fe396fe5e79d361f17b1765b65df696f90 100644 --- a/packages/fether-ui/src/Form/Field/Field.js +++ b/packages/fether-ui/src/Form/Field/Field.js @@ -16,13 +16,16 @@ export const Field = ({ ...otherProps }) => (
- + ( - +export const Slider = ({ centerText, leftText, rightText, ...otherProps }) => ( +