import React from 'react' import PropTypes from 'prop-types' import { Box, Flex } from 'rebass' import { animated, Keyframes, Transition } from 'react-spring' import { FormattedMessage, injectIntl } from 'react-intl' import { decodePayReq, getMinFee, getMaxFee, getFeeRange, isOnchain, isLn } from 'lib/utils/crypto' import { convert } from 'lib/utils/btc' import { Bar, CryptoAmountInput, Dropdown, FiatAmountInput, Form, Message, Label, LightningInvoiceInput, Panel, Text } from 'components/UI' import PayButtons from './PayButtons' import PayHeader from './PayHeader' import { PaySummaryLightning, PaySummaryOnChain } from '.' import messages from './messages' /** * Animation to handle showing/hiding the payReq field. */ const ShowHidePayReq = Keyframes.Spring({ small: { height: 48 }, big: async (next, cancel, ownProps) => { ownProps.context.focusPayReqInput() await next({ height: 130, immediate: true }) } }) /** * Animation to handle showing/hiding the form buttons. */ const ShowHideButtons = Keyframes.Spring({ show: { opacity: 1 }, hide: { opacity: 0 } }) /** * Animation to handle showing/hiding the amount fields. */ const ShowHideAmount = Keyframes.Spring({ show: async (next, cancel, ownProps) => { await next({ display: 'block' }) ownProps.context.focusAmountInput() await next({ opacity: 1, height: 'auto' }) }, hide: { opacity: 0, height: 0, display: 'none' }, remove: { opacity: 0, height: 0, display: 'none', immediate: true } }) /** * Payment form (onchain & offchain) */ class Pay extends React.Component { static propTypes = { /** The currently active chain (bitcoin, litecoin etc) */ chain: PropTypes.string.isRequired, /** The currently active chain (mainnet, testnet) */ network: PropTypes.string.isRequired, /** Human readable chain name */ cryptoName: PropTypes.string.isRequired, /** Current channel balance (in satoshis). */ channelBalance: PropTypes.number.isRequired, /** Current ticker data as provided by blockchain.info */ currentTicker: PropTypes.object.isRequired, /** Currently selected cryptocurrency (key). */ cryptoCurrency: PropTypes.string.isRequired, /** Ticker symbol of the currently selected cryptocurrency. */ cryptoCurrencyTicker: PropTypes.string.isRequired, /** List of supported cryptocurrencies. */ cryptoCurrencies: PropTypes.arrayOf( PropTypes.shape({ key: PropTypes.string.isRequired, name: PropTypes.string.isRequired }) ).isRequired, /** List of supported fiat currencies. */ fiatCurrencies: PropTypes.array.isRequired, /** Currently selected fiat currency (key). */ fiatCurrency: PropTypes.string.isRequired, /** Payment address or invoice to populate the payReq field with when the form first loads. */ initialPayReq: PropTypes.string, /** Amount value to populate the amountCrypto field with when the form first loads. */ initialAmountCrypto: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** Amount value to populate the amountFiat field with when the form first loads. */ initialAmountFiat: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** Boolean indicating wether the form is being processed. If true, form buttons are disabled. */ isProcessing: PropTypes.bool, /** Boolean indicating wether fee information is currently being fetched. */ isQueryingFees: PropTypes.bool, /** Boolean indicating wether routing information is currently being fetched. */ isQueryingRoutes: PropTypes.bool, /** List of known nodes */ nodes: PropTypes.array, /** Current fee information as provided by bitcoinfees.earn.com */ onchainFees: PropTypes.shape({ fastestFee: PropTypes.number, halfHourFee: PropTypes.number, hourFee: PropTypes.number }), /** Routing information */ routes: PropTypes.array, /** Current wallet balance (in satoshis). */ walletBalance: PropTypes.number.isRequired, /** Method to process offChain invoice payments. Called when the form is submitted. */ payInvoice: PropTypes.func.isRequired, /** Set the current cryptocurrency. */ setCryptoCurrency: PropTypes.func.isRequired, /** Set the current fiat currency */ setFiatCurrency: PropTypes.func.isRequired, /** Method to process onChain transactions. Called when the form is submitted. */ sendCoins: PropTypes.func.isRequired, /** Method to fetch fee information for onchain transactions. */ queryFees: PropTypes.func.isRequired, /** Method to collect route information for lightning invoices. */ queryRoutes: PropTypes.func.isRequired } static defaultProps = { initialPayReq: null, initialAmountCrypto: null, initialAmountFiat: null, isProcessing: false, isQueryingFees: false, isQueryingRoutes: false, nodes: [], onchainFees: {}, routes: [] } state = { currentStep: 'address', previousStep: null, isLn: null, isOnchain: null } amountInput = React.createRef() payReqInput = React.createRef() componentDidUpdate(prevProps, prevState) { const { initialPayReq, queryRoutes } = this.props const { currentStep, invoice, isLn, isOnchain } = this.state // If initialPayReq has been set, reset the form and submit as new if (initialPayReq && initialPayReq !== prevProps.initialPayReq) { this.formApi.reset() this.formApi.setValue('payReq', initialPayReq) this.handlePayReqChange() } // If we have gone back to the address step, unmark all fields from being touched. if (currentStep !== prevState.currentStep) { if (currentStep === 'address') { Object.keys(this.formApi.getState().touched).forEach(field => { this.formApi.setTouched(field, false) }) } } // If we now have a valid onchain address, trigger the form submit. if (isOnchain && isOnchain !== prevState.isOnchain) { this.formApi.submitForm() } // If we now have a valid offchain address, trigger the form submit. if (isLn && isLn !== prevState.isLn) { this.formApi.submitForm() // And if now have a valid lightning invoice, call queryRoutes. if (invoice) { const { satoshis, payeeNodeKey } = invoice queryRoutes(payeeNodeKey, satoshis) } } } /** * Form submit handler. * @param {Object} values submitted form values. */ onSubmit = values => { const { currentStep, isOnchain } = this.state const { cryptoCurrency, onchainFees, payInvoice, routes, sendCoins } = this.props const feeLimit = getMaxFee(routes) if (currentStep === 'summary') { return isOnchain ? sendCoins({ value: values.amountCrypto, addr: values.payReq, currency: cryptoCurrency, satPerByte: onchainFees.fastestFee }) : payInvoice(values.payReq, feeLimit) } else { this.nextStep() } } /** * Store the formApi on the component context to make it available at this.formApi. */ setFormApi = formApi => { this.formApi = formApi } /** * Focus the payReq input. */ focusPayReqInput = () => { if (this.payReqInput.current) { this.payReqInput.current.focus() } } /** * Focus the amount input. */ focusAmountInput = () => { if (this.amountInput.current) { this.amountInput.current.focus() } } /** * Liost of enabled form steps. */ steps = () => { const { isLn, isOnchain } = this.state let steps = ['address'] if (isLn) { steps = ['address', 'summary'] } else if (isOnchain) { steps = ['address', 'amount', 'summary'] } return steps } /** * Go back to previous form step. */ previousStep = () => { const { currentStep } = this.state const nextStep = Math.max(this.steps().indexOf(currentStep) - 1, 0) if (currentStep !== nextStep) { this.setState({ currentStep: this.steps()[nextStep], previousStep: currentStep }) } } /** * Progress to next form step. */ nextStep = () => { const { currentStep } = this.state const nextStep = Math.min(this.steps().indexOf(currentStep) + 1, this.steps().length - 1) if (currentStep !== nextStep) { this.setState({ currentStep: this.steps()[nextStep], previousStep: currentStep }) } } /** * Set isLn/isOnchain state based on payReq value. */ handlePayReqChange = () => { const { chain, network } = this.props const payReq = this.formApi.getValue('payReq') const state = { isLn: null, isOnchain: null, invoice: null } // See if the user has entered a valid lightning payment request. if (isLn(payReq, chain, network)) { let invoice try { invoice = decodePayReq(payReq) state.invoice = invoice } catch (e) { return } state.isLn = true } // Otherwise, see if we have a valid onchain address. else if (isOnchain(payReq, chain, network)) { state.isOnchain = true } // Update the state with our findings. this.setState(state) } /** * Handle the case when the form is mountedwith an initialPayReq. * This is the earliest possibleplace we can do this because the form is not initialised in ComponentDidMount. */ handleChange = formState => { const { initialPayReq } = this.props const { currentStep, previousStep } = this.state // If this is the first time the address page is showing and we have an initialPayReq, process the request // as if the user had entered it themselves. if (currentStep === 'address' && !previousStep && initialPayReq && formState.values.payReq) { this.handlePayReqChange() } } /** * set the amountFiat field whenever the crypto amount changes. */ handleAmountCryptoChange = e => { const { cryptoCurrency, currentTicker, fiatCurrency } = this.props const lastPrice = currentTicker[fiatCurrency] const value = convert(cryptoCurrency, 'fiat', e.target.value, lastPrice) this.formApi.setValue('amountFiat', value) } /** * set the amountCrypto field whenever the fiat amount changes. */ handleAmountFiatChange = e => { const { cryptoCurrency, currentTicker, fiatCurrency } = this.props const lastPrice = currentTicker[fiatCurrency] const value = convert('fiat', cryptoCurrency, e.target.value, lastPrice) this.formApi.setValue('amountCrypto', value) } /** * Handle changes from the crypto currency dropdown. */ handleCryptoCurrencyChange = value => { const { setCryptoCurrency } = this.props setCryptoCurrency(value) } /** * Handle changes from the fiat currency dropdown. */ handleFiatCurrencyChange = value => { const { setFiatCurrency } = this.props setFiatCurrency(value) } renderHelpText = () => { const { initialPayReq } = this.props const { currentStep, previousStep } = this.state // Do not render the help text if the form has just loadad with an initial payment request. if (initialPayReq && !previousStep) { return null } return ( {show => show && (styles => ( )) } ) } renderAddressField = () => { const { currentStep, isLn } = this.state const { chain, initialPayReq, network } = this.props return ( {styles => ( )} ) } renderAmountFields = () => { const { currentStep, isOnchain } = this.state const { cryptoCurrency, cryptoCurrencies, currentTicker, fiatCurrency, fiatCurrencies, initialAmountCrypto, initialAmountFiat } = this.props // Do not render unless we are working with an onchain address. if (!isOnchain) { return null } return ( {styles => ( = )} ) } renderSummary = () => { const { currentStep, isOnchain } = this.state const { cryptoCurrency, cryptoCurrencyTicker, cryptoCurrencies, currentTicker, fiatCurrency, isQueryingFees, isQueryingRoutes, nodes, onchainFees, queryFees, routes, setCryptoCurrency } = this.props const formState = this.formApi.getState() let minFee, maxFee if (routes.length) { minFee = getMinFee(routes) maxFee = getMaxFee(routes) } const render = () => { // convert entered amount to satoshis if (isOnchain) { const amountInSatoshis = convert(cryptoCurrency, 'sats', formState.values.amountCrypto) return ( ) } else if (isLn) { return ( ) } } return ( {show => show && (styles => {render()})} ) } /** * Form renderer. */ render() { const { currentStep, invoice, isLn, isOnchain } = this.state const { chain, network, channelBalance, cryptoCurrency, cryptoCurrencyTicker, cryptoCurrencies, currentTicker, cryptoName, fiatCurrencies, fiatCurrency, initialPayReq, initialAmountCrypto, initialAmountFiat, intl, isProcessing, isQueryingFees, isQueryingRoutes, onchainFees, payInvoice, sendCoins, setCryptoCurrency, setFiatCurrency, queryFees, queryRoutes, routes, walletBalance, ...rest } = this.props return (
{({ formState }) => { // Deterine which buttons should be visible. const showBack = currentStep !== 'address' const showSubmit = currentStep !== 'address' || (isOnchain || isLn) // Determine wether we have a route to the sender. let hasRoute = true if (isLn && currentStep === 'summary') { const { min, max } = getFeeRange(routes || []) if (min === null || max === null) { hasRoute = false } } // Determine wether we have enough funds available. let hasEnoughFunds = true if (isLn && invoice) { hasEnoughFunds = invoice.satoshis <= channelBalance } else if (isOnchain) { const valueInSats = convert(cryptoCurrency, 'sats', formState.values.amountCrypto) hasEnoughFunds = valueInSats <= walletBalance } // Determine what the text should be for the next button. let nextButtonText = intl.formatMessage({ ...messages.next }) if (currentStep === 'summary') { const value = isLn && invoice ? convert('sats', cryptoCurrency, invoice.satoshis) : formState.values.amountCrypto nextButtonText = `${intl.formatMessage({ ...messages.send })} ${value} ${cryptoCurrencyTicker}` } return ( {this.renderHelpText()} {this.renderAddressField()} {this.renderAmountFields()} {this.renderSummary()} {styles => ( {currentStep === 'summary' && !isQueryingRoutes && !hasRoute && ( )} {currentStep === 'summary' && !hasEnoughFunds && ( )} {walletBalance !== null && ( : {convert('sats', cryptoCurrency, walletBalance)} {` `} {cryptoCurrencyTicker} (onchain), {convert('sats', cryptoCurrency, channelBalance)} {` `} {cryptoCurrencyTicker} (in channels) )} )} ) }}
) } } export default injectIntl(Pay)