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 lightningPayReq from 'bolt11' import { getMinFee, getMaxFee, getFeeRange, isOnchain, isLn } from 'lib/utils/crypto' import { convert } from 'lib/utils/btc' import { Bar, CryptoAmountInput, Dropdown, FiatAmountInput, Form, FormFieldMessage, Label, LightningInvoiceInput, 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 }) } }) /** * 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 = { initialAmountCrypto: null, initialAmountFiat: null, isProcessing: false, isQueryingFees: false, isQueryingRoutes: false, nodes: [], routes: [] } state = { currentStep: 'address', isLn: null, isOnchain: null } amountInput = React.createRef() payReqInput = React.createRef() /** * If we have an address when the component mounts, run the payReq change handler to compure isLn / isOnchain. */ componentDidMount() { const { formApi } = this if (formApi.getValue('payReq')) { this.handlePayReqOnChange() } } /** * If we have gone back to the address step, focus the address input and unmark all fields from being touched. */ componentDidUpdate(prevProps, prevState) { const { currentStep } = this.state if (currentStep !== prevState.currentStep) { if (currentStep === 'address') { Object.keys(this.formApi.getState().touched).forEach(field => { this.formApi.setTouched(field, false) }) } } } /** * Form submit handler. * @param {Object} values submitted form values. */ onSubmit = values => { const { currentStep, isOnchain } = this.state const { cryptoCurrency, payInvoice, sendCoins } = this.props if (currentStep === 'summary') { return isOnchain ? sendCoins({ value: values.amountCrypto, addr: values.payReq, currency: cryptoCurrency }) : payInvoice(values.payReq) } else { this.nextStep() } } /** * Store the formApi on the component context to make it available at this.formApi. */ setFormApi = formApi => { this.formApi = formApi } /** * set the amountFiat field. */ setAmountFiat = () => { if (this.amountInput.current) { this.amountInput.current.focus() } } /** * 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] }) } } /** * 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] }) } } /** * Set isLn/isOnchain state based on payReq value. */ handlePayReqOnChange = () => { const { chain, network, queryRoutes } = 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 = lightningPayReq.decode(payReq) state.invoice = invoice } catch (e) { return } state.isLn = true const { satoshis, payeeNodeKey } = invoice queryRoutes(payeeNodeKey, satoshis) } // 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) // As soon as we have detected a valid address, submit the form. if (state.isLn || state.isOnchain) { this.formApi.submitForm() } } /** * set the amountFiat field whenever the crypto amount changes. */ handleAmountCryptoChange = e => { const { cryptoCurrency, currentTicker, fiatCurrency } = this.props const lastPrice = currentTicker[fiatCurrency].last 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].last 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 { currentStep } = this.state return ( {show => show && (styles => ( )) } ) } renderAddressField = () => { const { currentStep, isLn } = this.state const { chain, initialPayReq, network } = this.props return ( {styles => ( )} ) } renderAmountFields = () => { const { currentStep } = this.state const { cryptoCurrency, cryptoCurrencies, currentTicker, fiatCurrency, fiatCurrencies, initialAmountCrypto, initialAmountFiat } = this.props return ( {styles => ( = )} ) } renderSummary = () => { const { 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) } // convert entered amount to satoshis if (isOnchain) { const amountInSatoshis = convert(cryptoCurrency, 'sats', formState.values.amountCrypto) return ( ) } else if (isLn) { return ( ) } } /** * 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()} {isOnchain && this.renderAmountFields()} {currentStep === 'summary' && 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)