You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

758 lines
24 KiB

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 (
<Transition
native
items={currentStep === 'address'}
from={{ opacity: 0, height: 0 }}
enter={{ opacity: 1, height: 'auto' }}
leave={{ opacity: 0, height: 0 }}
initial={{ opacity: 1, height: 'auto' }}
>
{show =>
show &&
(styles => (
<animated.div style={styles}>
<Box mb={4}>
<Text textAlign="justify">
<FormattedMessage {...messages.description} />
</Text>
</Box>
</animated.div>
))
}
</Transition>
)
}
renderAddressField = () => {
const { currentStep, isLn } = this.state
const { chain, initialPayReq, network } = this.props
return (
<Box className={currentStep !== 'summary' ? 'element-show' : 'element-hide'}>
<Box pb={2}>
<Label htmlFor="payReq" readOnly={currentStep !== 'address'}>
{currentStep === 'address' ? (
<FormattedMessage {...messages.request_label_combined} />
) : isLn ? (
<FormattedMessage {...messages.request_label_offchain} />
) : (
<FormattedMessage {...messages.request_label_onchain} />
)}
</Label>
</Box>
<ShowHidePayReq state={currentStep === 'address' ? 'big' : 'small'} context={this}>
{styles => (
<React.Fragment>
<LightningInvoiceInput
field="payReq"
name="payReq"
style={styles}
initialValue={initialPayReq}
required
chain={chain}
network={network}
validateOnBlur
validateOnChange
onChange={this.handlePayReqChange}
width={1}
readOnly={currentStep !== 'address'}
forwardedRef={this.payReqInput}
css={{
resize: 'vertical',
'min-height': '48px'
}}
/>
</React.Fragment>
)}
</ShowHidePayReq>
</Box>
)
}
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 (
<ShowHideAmount
state={currentStep === 'amount' ? 'show' : currentStep === 'address' ? 'hide' : 'remove'}
context={this}
>
{styles => (
<Box style={styles}>
<Bar my={3} />
<Label htmlFor="amountCrypto" pb={2}>
<FormattedMessage {...messages.amount} />
</Label>
<Flex justifyContent="space-between" alignItems="flex-start" mb={3}>
<Flex width={6 / 13}>
<Box width={150}>
<CryptoAmountInput
field="amountCrypto"
name="amountCrypto"
initialValue={initialAmountCrypto}
currency={cryptoCurrency}
required
width={150}
validateOnChange
validateOnBlur
onChange={this.handleAmountCryptoChange}
forwardedRef={this.amountInput}
disabled={currentStep === 'address'}
/>
</Box>
<Dropdown
activeKey={cryptoCurrency}
items={cryptoCurrencies}
onChange={this.handleCryptoCurrencyChange}
mt={2}
ml={2}
/>
</Flex>
<Text textAlign="center" mt={3} width={1 / 11}>
=
</Text>
<Flex width={6 / 13}>
<Box width={150} ml="auto">
<FiatAmountInput
field="amountFiat"
name="amountFiat"
initialValue={initialAmountFiat}
currency={fiatCurrency}
currentTicker={currentTicker}
width={150}
onChange={this.handleAmountFiatChange}
disabled={currentStep === 'address'}
/>
</Box>
<Dropdown
activeKey={fiatCurrency}
items={fiatCurrencies}
onChange={this.handleFiatCurrencyChange}
mt={2}
ml={2}
/>
</Flex>
</Flex>
</Box>
)}
</ShowHideAmount>
)
}
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 (
<PaySummaryOnChain
mt={-3}
amount={amountInSatoshis}
address={formState.values.payReq}
cryptoCurrency={cryptoCurrency}
cryptoCurrencyTicker={cryptoCurrencyTicker}
cryptoCurrencies={cryptoCurrencies}
currentTicker={currentTicker}
setCryptoCurrency={setCryptoCurrency}
fiatCurrency={fiatCurrency}
isQueryingFees={isQueryingFees}
onchainFees={onchainFees}
queryFees={queryFees}
/>
)
} else if (isLn) {
return (
<PaySummaryLightning
mt={-3}
currentTicker={currentTicker}
cryptoCurrency={cryptoCurrency}
cryptoCurrencyTicker={cryptoCurrencyTicker}
cryptoCurrencies={cryptoCurrencies}
fiatCurrency={fiatCurrency}
isQueryingRoutes={isQueryingRoutes}
minFee={minFee}
maxFee={maxFee}
nodes={nodes}
payReq={formState.values.payReq}
setCryptoCurrency={setCryptoCurrency}
/>
)
}
}
return (
<Transition
native
items={currentStep === 'summary'}
from={{ opacity: 0, height: 0 }}
enter={{ opacity: 1, height: 'auto' }}
leave={{ opacity: 0, height: 0 }}
initial={{ opacity: 1, height: 'auto' }}
>
{show => show && (styles => <animated.div style={styles}>{render()}</animated.div>)}
</Transition>
)
}
/**
* 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 (
<Form
width={1}
css={{ height: '100%' }}
{...rest}
getApi={this.setFormApi}
onChange={this.handleChange}
onSubmit={this.onSubmit}
>
{({ 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 (
<Panel>
<Panel.Header>
<PayHeader
title={`${intl.formatMessage({
...messages.send
})} ${cryptoName} (${cryptoCurrencyTicker})`}
type={isLn ? 'offchain' : isOnchain ? 'onchain' : null}
/>
<Bar pt={2} />
</Panel.Header>
<Panel.Body py={3}>
<Box width={1} css={{ position: 'relative' }}>
{this.renderHelpText()}
<Box width={1} css={{ position: 'absolute' }}>
{this.renderAddressField()}
{this.renderAmountFields()}
</Box>
<Box width={1} css={{ position: 'absolute' }}>
{this.renderSummary()}
</Box>
</Box>
</Panel.Body>
<Panel.Footer>
<ShowHideButtons state={showBack || showSubmit ? 'show' : 'show'}>
{styles => (
<Box style={styles}>
{currentStep === 'summary' &&
!isQueryingRoutes &&
!hasRoute && (
<Message variant="error" justifyContent="center" mb={2}>
<FormattedMessage {...messages.error_no_route} />
</Message>
)}
{currentStep === 'summary' &&
!hasEnoughFunds && (
<Message variant="error" justifyContent="center" mb={2}>
<FormattedMessage {...messages.error_not_enough_funds} />
</Message>
)}
<PayButtons
disabled={
formState.pristine ||
formState.invalid ||
isProcessing ||
(currentStep === 'summary' && (!hasRoute || !hasEnoughFunds))
}
nextButtonText={nextButtonText}
processing={isProcessing}
showBack={showBack}
showSubmit={showSubmit}
previousStep={this.previousStep}
/>
{walletBalance !== null && (
<React.Fragment>
<Text textAlign="center" mt={3} fontWeight="normal">
<FormattedMessage {...messages.current_balance} />:
</Text>
<Text textAlign="center" fontSize="xs">
{convert('sats', cryptoCurrency, walletBalance)}
{` `}
{cryptoCurrencyTicker} (onchain),
</Text>
<Text textAlign="center" fontSize="xs">
{convert('sats', cryptoCurrency, channelBalance)}
{` `}
{cryptoCurrencyTicker} (in channels)
</Text>
</React.Fragment>
)}
</Box>
)}
</ShowHideButtons>
</Panel.Footer>
</Panel>
)
}}
</Form>
)
}
}
export default injectIntl(Pay)