From a4e0d11cef6fe93bc547b0f079c296e05d2be652 Mon Sep 17 00:00:00 2001 From: Kenny Tran Date: Tue, 23 Oct 2018 09:19:21 -0700 Subject: [PATCH] Connect send page with exchange utils (#75) * Connect send page with exchange utils * Add exchangeRate and fix txId --- src/components/AddressInputPanel/index.js | 11 + src/components/CurrencyInputPanel/index.js | 2 +- src/ducks/index.js | 2 + src/ducks/send.js | 88 +++++ src/helpers/exchange-utils.js | 20 +- src/pages/Send/index.js | 396 ++++++++++++++++++++- 6 files changed, 496 insertions(+), 23 deletions(-) create mode 100644 src/ducks/send.js diff --git a/src/components/AddressInputPanel/index.js b/src/components/AddressInputPanel/index.js index 10b8700531..f87d524b5a 100644 --- a/src/components/AddressInputPanel/index.js +++ b/src/components/AddressInputPanel/index.js @@ -9,6 +9,13 @@ class AddressInputPanel extends Component { title: PropTypes.string, description: PropTypes.string, extraText: PropTypes.string, + onChange: PropTypes.func, + value: PropTypes.string, + }; + + static defaultProps = { + onChange() {}, + value: '', }; render() { @@ -16,6 +23,8 @@ class AddressInputPanel extends Component { title, description, extraText, + onChange, + value } = this.props; return ( @@ -31,6 +40,8 @@ class AddressInputPanel extends Component { type="text" className="address-input-panel__input" placeholder="0x1234..." + onChange={e => onChange(e.target.value)} + value={value} /> diff --git a/src/components/CurrencyInputPanel/index.js b/src/components/CurrencyInputPanel/index.js index f4948f2ec1..d8d0f08e78 100644 --- a/src/components/CurrencyInputPanel/index.js +++ b/src/components/CurrencyInputPanel/index.js @@ -37,7 +37,7 @@ class CurrencyInputPanel extends Component { onCurrencySelected: PropTypes.func, onValueChange: PropTypes.func, tokenAddresses: PropTypes.shape({ - address: PropTypes.array.isRequired, + addresses: PropTypes.array.isRequired, }).isRequired, exchangeAddresses: PropTypes.shape({ fromToken: PropTypes.object.isRequired, diff --git a/src/ducks/index.js b/src/ducks/index.js index de3c96a63e..da5f5fb5c1 100644 --- a/src/ducks/index.js +++ b/src/ducks/index.js @@ -4,6 +4,7 @@ import addresses from './addresses'; import exchangeContracts from './exchange-contract'; import tokenContracts from './token-contract'; import exchange from './exchange'; +import send from './send'; import swap from './swap'; import web3connect from './web3connect'; @@ -12,6 +13,7 @@ export default combineReducers({ exchangeContracts, tokenContracts, exchange, + send, swap, web3connect, ...drizzleReducers, diff --git a/src/ducks/send.js b/src/ducks/send.js new file mode 100644 index 0000000000..75e3894f86 --- /dev/null +++ b/src/ducks/send.js @@ -0,0 +1,88 @@ +const UPDATE_FIELD = 'app/send/updateField'; +const ADD_ERROR = 'app/send/addError'; +const REMOVE_ERROR = 'app/send/removeError'; +const RESET_SEND = 'app/send/resetSend'; + +const getInitialState = () => { + return { + input: '', + output: '', + inputCurrency: '', + outputCurrency: '', + recipient: '', + lastEditedField: '', + inputErrors: [], + outputErrors: [], + }; +}; + +export const isValidSend = (state) => { + const { send } = state; + + return send.outputCurrency !== '' && + send.inputCurrency !== '' && + send.input !== '' && + send.output !== '' && + send.recipient !== '' && + send.inputErrors.length === 0 && + send.outputErrors.length === 0; +}; + +export const updateField = ({ name, value }) => ({ + type: UPDATE_FIELD, + payload: { name, value }, +}); + +export const addError = ({ name, value }) => ({ + type: ADD_ERROR, + payload: { name, value }, +}); + +export const removeError = ({ name, value }) => ({ + type: REMOVE_ERROR, + payload: { name, value }, +}); + +export const resetSend = () => ({ + type: RESET_SEND, +}); + +function reduceAddError(state, payload) { + const { name, value } = payload; + let nextErrors = state[name]; + if (nextErrors.indexOf(value) === -1) { + nextErrors = [...nextErrors, value]; + } + + return { + ...state, + [name]: nextErrors, + }; +} + +function reduceRemoveError(state, payload) { + const { name, value } = payload; + + return { + ...state, + [name]: state[name].filter(error => error !== value), + }; +} + +export default function sendReducer(state = getInitialState(), { type, payload }) { + switch (type) { + case UPDATE_FIELD: + return { + ...state, + [payload.name]: payload.value, + }; + case ADD_ERROR: + return reduceAddError(state, payload); + case REMOVE_ERROR: + return reduceRemoveError(state, payload); + case RESET_SEND: + return getInitialState(); + default: + return state; + } +} diff --git a/src/helpers/exchange-utils.js b/src/helpers/exchange-utils.js index 8d3dd93c1e..030a59af7d 100644 --- a/src/helpers/exchange-utils.js +++ b/src/helpers/exchange-utils.js @@ -273,7 +273,7 @@ const ETH_TO_ERC20 = { value: maxInput.toFixed(0), }); }, - sendInput: async ({drizzleCtx, contractStore, input, output, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { + sendInput: async ({drizzleCtx, contractStore, input, output, outputDecimals, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { if (!validEthToErc20(inputCurrency, outputCurrency)) { return; } @@ -287,7 +287,6 @@ const ETH_TO_ERC20 = { const deadline = await getDeadline(drizzleCtx, 300); const ALLOWED_SLIPPAGE = BN(0.025); - const outputDecimals = await getDecimals({ address: outputCurrency, contractStore, drizzleCtx }); const minOutput = BN(output).multipliedBy(10 ** outputDecimals).multipliedBy(BN(1).minus(ALLOWED_SLIPPAGE)); return exchange.methods.ethToTokenTransferInput.cacheSend( @@ -300,7 +299,7 @@ const ETH_TO_ERC20 = { } ); }, - sendOutput: async ({drizzleCtx, contractStore, input, output, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { + sendOutput: async ({drizzleCtx, contractStore, input, output, outputDecimals, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { if (!validEthToErc20(inputCurrency, outputCurrency)) { return; } @@ -314,7 +313,6 @@ const ETH_TO_ERC20 = { const deadline = await getDeadline(drizzleCtx, 300); const ALLOWED_SLIPPAGE = BN(0.025); - const outputDecimals = await getDecimals({ address: outputCurrency, contractStore, drizzleCtx }); const outputAmount = BN(output).multipliedBy(BN(10 ** outputDecimals)); const maxInput = BN(input).multipliedBy(10 ** 18).multipliedBy(BN(1).plus(ALLOWED_SLIPPAGE)); return exchange.methods.ethToTokenTransferOutput.cacheSend( @@ -481,7 +479,7 @@ const ERC20_TO_ETH = { { from: account }, ); }, - sendInput: async ({drizzleCtx, contractStore, input, output, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { + sendInput: async ({drizzleCtx, contractStore, input, output, inputDecimals, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { if (!validErc20ToEth(inputCurrency, outputCurrency)) { return; } @@ -495,7 +493,6 @@ const ERC20_TO_ETH = { const deadline = await getDeadline(drizzleCtx, 300); const ALLOWED_SLIPPAGE = BN(0.025); - const inputDecimals = await getDecimals({ address: inputCurrency, contractStore, drizzleCtx }); const minOutput = BN(output).multipliedBy(10 ** 18).multipliedBy(BN(1).minus(ALLOWED_SLIPPAGE)); const inputAmount = BN(input).multipliedBy(10 ** inputDecimals); @@ -507,7 +504,7 @@ const ERC20_TO_ETH = { { from: account, value: '0x0' }, ); }, - sendOutput: async ({drizzleCtx, contractStore, input, output, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { + sendOutput: async ({drizzleCtx, contractStore, input, output, inputDecimals, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { if (!validErc20ToEth(inputCurrency, outputCurrency)) { return; } @@ -521,7 +518,6 @@ const ERC20_TO_ETH = { const deadline = await getDeadline(drizzleCtx, 300); const ALLOWED_SLIPPAGE = BN(0.025); - const inputDecimals = await getDecimals({ address: inputCurrency, contractStore, drizzleCtx }); const maxInput = BN(input).multipliedBy(10 ** inputDecimals).multipliedBy(BN(1).plus(ALLOWED_SLIPPAGE)); const outputAmount = BN(output).multipliedBy(10 ** 18); @@ -676,7 +672,7 @@ const ERC20_TO_ERC20 = { { from: account }, ); }, - sendInput: async ({drizzleCtx, contractStore, input, output, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { + sendInput: async ({drizzleCtx, contractStore, input, output, inputDecimals, outputDecimals, account, inputCurrency, outputCurrency, exchangeAddresses, recipient}) => { if (!validErc20ToErc20(inputCurrency, outputCurrency)) { return; } @@ -688,8 +684,6 @@ const ERC20_TO_ERC20 = { } const deadline = await getDeadline(drizzleCtx, 300); const ALLOWED_SLIPPAGE = BN(0.04); - const inputDecimals = await getDecimals({ address: inputCurrency, contractStore, drizzleCtx }); - const outputDecimals = await getDecimals({ address: outputCurrency, contractStore, drizzleCtx }); const inputAmount = BN(input).multipliedBy(BN(10 ** inputDecimals)); const outputAmount = BN(input).multipliedBy(BN(10 ** outputDecimals)); @@ -719,6 +713,8 @@ const ERC20_TO_ERC20 = { outputCurrency, exchangeAddresses, recipient, + inputDecimals, + outputDecimals, } = opts; const exchangeRateA = await ETH_TO_ERC20.calculateInput({ ...opts, inputCurrency: 'ETH' }); if (!exchangeRateA) { @@ -738,8 +734,6 @@ const ERC20_TO_ERC20 = { const deadline = await getDeadline(drizzleCtx, 300); const ALLOWED_SLIPPAGE = BN(0.04); - const inputDecimals = await getDecimals({ address: inputCurrency, contractStore, drizzleCtx }); - const outputDecimals = await getDecimals({ address: outputCurrency, contractStore, drizzleCtx }); const inputAmount = BN(input).multipliedBy(BN(10 ** inputDecimals)); const outputAmount = BN(output).multipliedBy(BN(10 ** outputDecimals)); const inputAmountB = BN(output).dividedBy(exchangeRateA).multipliedBy(BN(10 ** 18)); diff --git a/src/pages/Send/index.js b/src/pages/Send/index.js index caca34e77f..6e198c38c7 100644 --- a/src/pages/Send/index.js +++ b/src/pages/Send/index.js @@ -3,11 +3,32 @@ import { drizzleConnect } from 'drizzle-react'; import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import classnames from 'classnames'; + +import { isValidSend, updateField, addError, removeError, resetSend } from '../../ducks/send'; +import { selectors, sync } from '../../ducks/web3connect'; +import {BigNumber as BN} from "bignumber.js"; +import deepEqual from 'deep-equal'; + import Header from '../../components/Header'; import CurrencyInputPanel from '../../components/CurrencyInputPanel'; import AddressInputPanel from '../../components/AddressInputPanel'; import OversizedPanel from '../../components/OversizedPanel'; import ArrowDown from '../../assets/images/arrow-down-blue.svg'; +import Pending from '../../assets/images/pending.svg'; + +import { + calculateExchangeRateFromInput, + calculateExchangeRateFromOutput, + sendInput, + sendOutput, +} from '../../helpers/exchange-utils'; +import { + isExchangeUnapproved, + approveExchange, +} from '../../helpers/approval-utils'; +import { + getTxStatus +} from '../../helpers/contract-utils'; import "./send.scss"; @@ -18,9 +39,300 @@ class Send extends Component { pathname: PropTypes.string.isRequired, currentAddress: PropTypes.string, isConnected: PropTypes.bool.isRequired, + isValid: PropTypes.bool.isRequired, + updateField: PropTypes.func.isRequired, + input: PropTypes.string, + output: PropTypes.string, + inputCurrency: PropTypes.string, + outputCurrency: PropTypes.string, + recipient: PropTypes.string, + lastEditedField: PropTypes.string, + inputErrors: PropTypes.arrayOf(PropTypes.string), + outputErrors: PropTypes.arrayOf(PropTypes.string), }; + static contextTypes = { + drizzle: PropTypes.object, + }; + + state = { + exchangeRate: BN(0), + approvalTxId: null, + sendTxId: null, + }; + + shouldComponentUpdate(nextProps, nextState) { + return !deepEqual(nextProps, this.props) || + !deepEqual(nextState, this.state); + } + + componentDidUpdate() { + if (this.getSendStatus() === 'pending') { + this.resetSend(); + } + + this.getExchangeRate(this.props) + .then(exchangeRate => { + if (this.state.exchangeRate !== exchangeRate) { + this.setState({ exchangeRate }); + } + + if (!exchangeRate) { + return; + } + + if (this.props.lastEditedField === 'input') { + this.props.updateField('output', `${BN(this.props.input).multipliedBy(exchangeRate).toFixed(7)}`); + } else if (this.props.lastEditedField === 'output') { + this.props.updateField('input', `${BN(this.props.output).multipliedBy(BN(1).dividedBy(exchangeRate)).toFixed(7)}`); + } + }); + } + + componentWillUnmount() { + this.resetSend(); + } + + resetSend() { + this.props.resetSend(); + this.setState({approvalTxId: null, sendTxId: null}); + } + + getSendStatus() { + const { drizzle } = this.context; + + return getTxStatus({ + drizzleCtx: drizzle, + txId: this.state.sendTxId, + }); + } + + getTokenLabel(address) { + if (address === 'ETH') { + return 'ETH'; + } + + const { + initialized, + contracts, + } = this.props; + const { drizzle } = this.context; + const { web3 } = drizzle; + + if (!initialized || !web3 || !address) { + return ''; + } + + const symbolKey = drizzle.contracts[address].methods.symbol.cacheCall(); + const token = contracts[address]; + const symbol = token.symbol[symbolKey]; + + if (!symbol) { + return ''; + } + + return symbol.value; + } + + getBalance(currency) { + const { selectors, account } = this.props; + + if (!currency) { + return ''; + } + + if (currency === 'ETH') { + const { value, decimals } = selectors().getBalance(account); + return `Balance: ${value.dividedBy(10 ** decimals).toFixed(4)}`; + } + + const { value, decimals } = selectors().getTokenBalance(currency, account); + return `Balance: ${value.dividedBy(10 ** decimals).toFixed(4)}`; + } + + updateInput(amount) { + this.props.updateField('input', amount); + if (!amount) { + this.props.updateField('output', ''); + } + this.props.updateField('lastEditedField', 'input'); + } + + updateOutput(amount) { + this.props.updateField('output', amount); + if (!amount) { + this.props.updateField('input', ''); + } + this.props.updateField('lastEditedField', 'output'); + } + + async getExchangeRate(props) { + const { + input, + output, + inputCurrency, + outputCurrency, + exchangeAddresses, + lastEditedField, + contracts, + } = props; + + const { drizzle } = this.context; + + return lastEditedField === 'input' + ? await calculateExchangeRateFromInput({ + drizzleCtx: drizzle, + contractStore: contracts, + input, + output, + inputCurrency, + outputCurrency, + exchangeAddresses, + }) + : await calculateExchangeRateFromOutput({ + drizzleCtx: drizzle, + contractStore: contracts, + input, + output, + inputCurrency, + outputCurrency, + exchangeAddresses, + }) ; + } + + getIsUnapproved() { + const { + input, + inputCurrency, + account, + contracts, + exchangeAddresses + } = this.props; + const { drizzle } = this.context; + + return isExchangeUnapproved({ + value: input, + currency: inputCurrency, + drizzleCtx: drizzle, + contractStore: contracts, + account, + exchangeAddresses, + }); + } + + approveExchange = async () => { + const { + inputCurrency, + exchangeAddresses, + account, + contracts, + } = this.props; + const { drizzle } = this.context; + + if (this.getIsUnapproved()) { + const approvalTxId = await approveExchange({ + currency: inputCurrency, + drizzleCtx: drizzle, + contractStore: contracts, + account, + exchangeAddresses, + }); + + this.setState({ approvalTxId }) + } + } + + getApprovalStatus() { + const { drizzle } = this.context; + + return getTxStatus({ + drizzleCtx: drizzle, + txId: this.state.approvalTxId, + }); + } + + onSend = async () => { + const { + input, + output, + inputCurrency, + outputCurrency, + recipient, + exchangeAddresses, + lastEditedField, + account, + contracts, + selectors, + } = this.props; + + const { drizzle } = this.context; + const { decimals: inputDecimals } = inputCurrency === 'ETH' ? + selectors().getBalance(account) + : selectors().getTokenBalance(inputCurrency, account); + const { decimals: outputDecimals } = outputCurrency === 'ETH' ? + selectors().getBalance(account) + : selectors().getTokenBalance(outputCurrency, account); + let sendTxId; + + if (lastEditedField === 'input') { + sendTxId = await sendInput({ + drizzleCtx: drizzle, + contractStore: contracts, + input, + output, + inputCurrency, + outputCurrency, + recipient, + exchangeAddresses, + account, + inputDecimals, + outputDecimals, + }); + } + + if (lastEditedField === 'output') { + sendTxId = await sendOutput({ + drizzleCtx: drizzle, + contractStore: contracts, + input, + output, + inputCurrency, + outputCurrency, + recipient, + exchangeAddresses, + account, + inputDecimals, + outputDecimals, + }); + } + + this.setState({ sendTxId }); + }; + + handleSubButtonClick = () => { + if (this.getIsUnapproved() && this.getApprovalStatus() !== 'pending') { + this.approveExchange(); + } + } + + renderSubButtonText() { + if (this.getApprovalStatus() === 'pending') { + return [ + (), + (Pending) + ]; + } else { + return '🔒 Unlock' + } + } + render() { + const { lastEditedField, inputCurrency, outputCurrency, input, output, recipient, isValid, outputErrors, inputErrors } = this.props; + const { exchangeRate } = this.state; + const inputLabel = this.getTokenLabel(inputCurrency); + const outputLabel = this.getTokenLabel(outputCurrency); + const estimatedText = '(estimated)'; + // 0xc41c71CAeA8ccc9AE19c6d8a66c6870C6E9c3632 return (
@@ -31,7 +343,19 @@ class Send extends Component { > { + this.props.updateField('inputCurrency', d) + this.props.sync(); + }} + onValueChange={d => this.updateInput(d)} + selectedTokens={[inputCurrency, outputCurrency]} + addError={error => this.props.addError('inputErrors', error)} + removeError={error => this.props.removeError('inputErrors', error)} + errors={inputErrors} + value={input} + selectedTokenAddress={inputCurrency} + extraText={this.getBalance(inputCurrency)} />
@@ -40,30 +364,56 @@ class Send extends Component { { + this.props.updateField('outputCurrency', d) + this.props.sync(); + }} + onValueChange={d => this.updateOutput(d)} + selectedTokens={[inputCurrency, outputCurrency]} + addError={error => this.props.addError('outputErrors', error)} + removeError={error => this.props.removeError('outputErrors', error)} + errors={outputErrors} + value={output} + selectedTokenAddress={outputCurrency} + extraText={this.getBalance(outputCurrency)} />
- + this.props.updateField('recipient', address)} + />
Exchange Rate - 1 ETH = 1283.878 BAT + + {exchangeRate ? `1 ${inputLabel} = ${exchangeRate.toFixed(7)} ${outputLabel}` : ' - '} +
-
-
You are selling 0.01 ETH
-
You will receive between 12.80 and 12.83 BAT
-
+ { + inputLabel && input + ? ( +
+
You are selling {`${input} ${inputLabel}`}
+
You will receive between 12.80 and 12.83 BAT
+
+ ) + : null + }
@@ -77,10 +427,38 @@ export default withRouter( drizzleConnect( Send, (state, ownProps) => ({ + // React Router push: ownProps.history.push, pathname: ownProps.location.pathname, + + // From Drizzle + initialized: state.drizzleStatus.initialized, + balance: state.accountBalances[state.accounts[0]] || null, + account: state.accounts[0], + contracts: state.contracts, currentAddress: state.accounts[0], isConnected: !!(state.drizzleStatus.initialized && state.accounts[0]), + + // Redux Store + balances: state.web3connect.balances, + input: state.send.input, + output: state.send.output, + inputCurrency: state.send.inputCurrency, + outputCurrency: state.send.outputCurrency, + recipient: state.send.recipient, + lastEditedField: state.send.lastEditedField, + exchangeAddresses: state.addresses.exchangeAddresses, + isValid: isValidSend(state), + inputErrors: state.send.inputErrors, + outputErrors: state.send.outputErrors, }), + dispatch => ({ + updateField: (name, value) => dispatch(updateField({ name, value })), + addError: (name, value) => dispatch(addError({ name, value })), + removeError: (name, value) => dispatch(removeError({ name, value })), + resetSend: () => dispatch(resetSend()), + selectors: () => dispatch(selectors()), + sync: () => dispatch(sync()), + }) ), );