Browse Source

Merge pull request #886 from mrfelton/feat/pay-integration

feat(ui): implement new pay forms
renovate/lint-staged-8.x
JimmyMow 6 years ago
committed by GitHub
parent
commit
502301d0e1
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 44
      app/components/Form/Form.js
  2. 216
      app/components/Pay/Pay.js
  3. 31
      app/components/Pay/PaySummaryLightning.js
  4. 8
      app/components/Pay/PaySummaryOnChain.js
  5. 9
      app/components/Pay/messages.js
  6. 3
      app/components/UI/Input.js
  7. 1
      app/components/UI/LightningInvoiceInput.js
  8. 4
      app/components/UI/Modal.js
  9. 4
      app/components/UI/Range.js
  10. 3
      app/components/UI/TextArea.js
  11. 8
      app/components/Value/Value.js
  12. 41
      app/containers/Pay.js
  13. 7
      app/lib/lnd/methods/index.js
  14. 5
      app/lib/lnd/methods/networkController.js
  15. 36
      app/lib/lnd/methods/paymentsController.js
  16. 4
      app/lib/lnd/methods/walletController.js
  17. 8
      app/lib/utils/api.js
  18. 6
      app/lib/utils/crypto.js
  19. 4
      app/main.dev.js
  20. 2
      app/reducers/index.js
  21. 5
      app/reducers/ipc.js
  22. 13
      app/reducers/network.js
  23. 131
      app/reducers/pay.js
  24. 4
      app/reducers/payment.js
  25. 7
      app/reducers/transaction.js
  26. 1
      internals/webpack/webpack.config.renderer.dev.js
  27. 7
      internals/webpack/webpack.config.renderer.prod.js
  28. 2
      stories/components/form.stories.js
  29. 7
      stories/pages/pay.stories.js
  30. 2
      test/unit/components/Form.spec.js
  31. 87
      test/unit/components/Form/Pay.spec.js
  32. 1
      test/unit/components/Pay/PaySummaryOnchain.spec.js
  33. 10
      test/unit/components/Pay/__snapshots__/PaySummaryLightning.spec.js.snap
  34. 2
      test/unit/components/Pay/__snapshots__/PaySummaryOnchain.spec.js.snap
  35. 1
      test/unit/components/UI/__snapshots__/LightningInvoiceInput.spec.js.snap
  36. 3
      test/unit/components/UI/__snapshots__/Modal.spec.js.snap
  37. 2
      test/unit/components/UI/__snapshots__/Range.spec.js.snap

44
app/components/Form/Form.js

@ -1,34 +1,38 @@
import React from 'react'
import PropTypes from 'prop-types'
import X from 'components/Icon/X'
import Pay from './Pay'
import { Modal } from 'components/UI'
import Pay from 'containers/Pay'
import Request from './Request'
import styles from './Form.scss'
const FORM_TYPES = {
PAY_FORM: Pay,
REQUEST_FORM: Request
}
const Form = ({ formType, formProps, closeForm }) => {
if (!formType) {
return null
}
const FormComponent = FORM_TYPES[formType]
return (
<div className={styles.container}>
<div className={styles.closeContainer}>
<span onClick={closeForm}>
<X />
</span>
</div>
<FormComponent {...formProps} />
</div>
)
switch (formType) {
case 'PAY_FORM':
return (
<div className={styles.container}>
<Modal onClose={closeForm}>
<Pay width={9 / 16} mx="auto" />
</Modal>
</div>
)
case 'REQUEST_FORM':
return (
<div className={styles.container}>
<div className={styles.closeContainer}>
<span onClick={closeForm}>
<X />
</span>
</div>
<Request {...formProps} />
</div>
)
}
}
Form.propTypes = {

216
app/components/Pay/Pay.js

@ -30,7 +30,7 @@ const ShowHidePayReq = Keyframes.Spring({
small: { height: 48 },
big: async (next, cancel, ownProps) => {
ownProps.context.focusPayReqInput()
await next({ height: 130 })
await next({ height: 130, immediate: true })
}
})
@ -124,17 +124,20 @@ class Pay extends React.Component {
}
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
}
@ -142,21 +145,18 @@ class Pay extends React.Component {
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()
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, focus the address input and unmark all fields from being touched.
*/
componentDidUpdate(prevProps, prevState) {
const { currentStep } = this.state
// 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 => {
@ -164,6 +164,21 @@ class Pay extends React.Component {
})
}
}
// 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)
}
}
}
/**
@ -172,15 +187,17 @@ class Pay extends React.Component {
*/
onSubmit = values => {
const { currentStep, isOnchain } = this.state
const { cryptoCurrency, payInvoice, sendCoins } = this.props
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
currency: cryptoCurrency,
satPerByte: onchainFees.fastestFee
})
: payInvoice(values.payReq)
: payInvoice(values.payReq, feeLimit)
} else {
this.nextStep()
}
@ -193,15 +210,6 @@ class Pay extends React.Component {
this.formApi = formApi
}
/**
* set the amountFiat field.
*/
setAmountFiat = () => {
if (this.amountInput.current) {
this.amountInput.current.focus()
}
}
/**
* Focus the payReq input.
*/
@ -241,7 +249,7 @@ class Pay extends React.Component {
const { currentStep } = this.state
const nextStep = Math.max(this.steps().indexOf(currentStep) - 1, 0)
if (currentStep !== nextStep) {
this.setState({ currentStep: this.steps()[nextStep] })
this.setState({ currentStep: this.steps()[nextStep], previousStep: currentStep })
}
}
@ -252,15 +260,15 @@ class Pay extends React.Component {
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] })
this.setState({ currentStep: this.steps()[nextStep], previousStep: currentStep })
}
}
/**
* Set isLn/isOnchain state based on payReq value.
*/
handlePayReqOnChange = () => {
const { chain, network, queryRoutes } = this.props
handlePayReqChange = () => {
const { chain, network } = this.props
const payReq = this.formApi.getValue('payReq')
const state = {
isLn: null,
@ -278,8 +286,6 @@ class Pay extends React.Component {
return
}
state.isLn = true
const { satoshis, payeeNodeKey } = invoice
queryRoutes(payeeNodeKey, satoshis)
}
// Otherwise, see if we have a valid onchain address.
@ -289,10 +295,19 @@ class Pay extends React.Component {
// 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()
/**
* 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()
}
}
@ -333,7 +348,14 @@ class Pay extends React.Component {
}
renderHelpText = () => {
const { currentStep } = this.state
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
@ -347,7 +369,7 @@ class Pay extends React.Component {
show &&
(styles => (
<animated.div style={styles}>
<Box mb={5}>
<Box mb={4}>
<Text textAlign="justify">
<FormattedMessage {...messages.description} />
</Text>
@ -380,15 +402,16 @@ class Pay extends React.Component {
{styles => (
<React.Fragment>
<LightningInvoiceInput
field="payReq"
name="payReq"
style={styles}
initialValue={initialPayReq}
required
chain={chain}
network={network}
field="payReq"
validateOnBlur
validateOnChange
onChange={this.handlePayReqOnChange}
onChange={this.handlePayReqChange}
width={1}
readOnly={currentStep !== 'address'}
forwardedRef={this.payReqInput}
@ -405,7 +428,7 @@ class Pay extends React.Component {
}
renderAmountFields = () => {
const { currentStep } = this.state
const { currentStep, isOnchain } = this.state
const {
cryptoCurrency,
cryptoCurrencies,
@ -415,6 +438,12 @@ class Pay extends React.Component {
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'}
@ -431,10 +460,11 @@ class Pay extends React.Component {
<Flex width={6 / 13}>
<Box width={145}>
<CryptoAmountInput
field="amountCrypto"
name="amountCrypto"
initialValue={initialAmountCrypto}
currency={cryptoCurrency}
required
field="amountCrypto"
width={145}
validateOnChange
validateOnBlur
@ -457,10 +487,11 @@ class Pay extends React.Component {
<Flex width={6 / 13}>
<Box width={145} ml="auto">
<FiatAmountInput
field="amountFiat"
name="amountFiat"
initialValue={initialAmountFiat}
currency={fiatCurrency}
currentTicker={currentTicker}
field="amountFiat"
width={145}
onChange={this.handleAmountFiatChange}
disabled={currentStep === 'address'}
@ -483,7 +514,7 @@ class Pay extends React.Component {
}
renderSummary = () => {
const { isOnchain } = this.state
const { currentStep, isOnchain } = this.state
const {
cryptoCurrency,
cryptoCurrencyTicker,
@ -498,6 +529,7 @@ class Pay extends React.Component {
routes,
setCryptoCurrency
} = this.props
const formState = this.formApi.getState()
let minFee, maxFee
if (routes.length) {
@ -505,41 +537,56 @@ class Pay extends React.Component {
maxFee = getMaxFee(routes)
}
// convert entered amount to satoshis
if (isOnchain) {
const amountInSatoshis = convert(cryptoCurrency, 'sats', formState.values.amountCrypto)
return (
<PaySummaryOnChain
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
currentTicker={currentTicker}
cryptoCurrency={cryptoCurrency}
cryptoCurrencyTicker={cryptoCurrencyTicker}
cryptoCurrencies={cryptoCurrencies}
fiatCurrency={fiatCurrency}
isQueryingRoutes={isQueryingRoutes}
minFee={minFee}
maxFee={maxFee}
nodes={nodes}
payReq={formState.values.payReq}
setCryptoCurrency={setCryptoCurrency}
/>
)
const render = () => {
// convert entered amount to satoshis
if (isOnchain) {
const amountInSatoshis = convert(cryptoCurrency, 'sats', formState.values.amountCrypto)
return (
<PaySummaryOnChain
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
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>
)
}
/**
@ -582,6 +629,7 @@ class Pay extends React.Component {
css={{ height: '100%' }}
{...rest}
getApi={this.setFormApi}
onChange={this.handleChange}
onSubmit={this.onSubmit}
>
{({ formState }) => {
@ -632,10 +680,16 @@ class Pay extends React.Component {
</Flex>
<Box as="section" css={{ flex: 1 }} mb={3}>
{this.renderHelpText()}
{this.renderAddressField()}
{isOnchain && this.renderAmountFields()}
{currentStep === 'summary' && this.renderSummary()}
<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>
</Box>
<Box as="footer" mt="auto">

31
app/components/Pay/PaySummaryLightning.js

@ -78,6 +78,23 @@ class PaySummaryLightning extends React.PureComponent {
const fiatAmount = satoshisToFiat(satoshis, currentTicker[fiatCurrency].last)
const nodeAlias = getNodeAlias(payeeNodeKey, nodes)
// Select an appropriate fee message...
// Default to unknown.
let feeMessage = messages.fee_unknown
// If thex max fee is 0 or 1 then show a message like "less than 1".
if (maxFee === 0 || maxFee === 1) {
feeMessage = messages.fee_less_than_1
}
// Otherwise, if we have both a min and max fee that are different, present the fee range.
else if (minFee !== null && maxFee !== null && minFee !== maxFee) {
feeMessage = messages.fee_range
}
// Finally, if we at least have a max fee then present it as upto that amount.
else if (maxFee) {
feeMessage = messages.fee_upto
}
return (
<React.Fragment>
<Box pb={2}>
@ -108,7 +125,7 @@ class PaySummaryLightning extends React.PureComponent {
</Box>
<Box width={5 / 11}>
<Text textAlign="right" className="hint--bottom-left" data-hint={payeeNodeKey}>
{<Truncate text={nodeAlias || payeeNodeKey} />}
{<Truncate text={nodeAlias || payeeNodeKey} maxlen={nodeAlias ? 30 : 15} />}
</Text>
</Box>
</Flex>
@ -127,10 +144,8 @@ class PaySummaryLightning extends React.PureComponent {
</Text>
<Spinner color="lightningOrange" />
</Flex>
) : minFee === null || maxFee === null ? (
<FormattedMessage {...messages.unknown} />
) : (
<FormattedMessage {...messages.fee_range} values={{ minFee, maxFee }} />
feeMessage && <FormattedMessage {...feeMessage} values={{ minFee, maxFee }} />
)
}
/>
@ -141,13 +156,7 @@ class PaySummaryLightning extends React.PureComponent {
left={<FormattedMessage {...messages.total} />}
right={
<React.Fragment>
<Value value={satoshis} currency={cryptoCurrency} /> {cryptoCurrencyTicker}
{!isQueryingRoutes &&
maxFee && (
<Text fontSize="s">
(+ <FormattedMessage {...messages.upto} /> {maxFee} msats)
</Text>
)}
<Value value={satoshis + maxFee} currency={cryptoCurrency} /> {cryptoCurrencyTicker}
</React.Fragment>
}
/>

8
app/components/Pay/PaySummaryOnChain.js

@ -51,7 +51,7 @@ class PaySummaryOnChain extends React.Component {
onchainFees: {}
}
componenDidMount() {
componentDidMount() {
const { queryFees } = this.props
queryFees()
}
@ -122,11 +122,11 @@ class PaySummaryOnChain extends React.Component {
<Spinner color="lightningOrange" />
</Flex>
) : !fee ? (
<FormattedMessage {...messages.unknown} />
<FormattedMessage {...messages.fee_unknown} />
) : (
<React.Fragment>
<Text>
{fee} satoshis <FormattedMessage {...messages.per_byte} />
{fee} satoshis <FormattedMessage {...messages.fee_per_byte} />
</Text>
<Text fontSize="s">
(<FormattedMessage {...messages.next_block_confirmation} />)
@ -143,7 +143,7 @@ class PaySummaryOnChain extends React.Component {
right={
<React.Fragment>
<Value value={amount} currency={cryptoCurrency} /> {cryptoCurrencyTicker}
{!isQueryingFees && fee && <Text fontSize="s">(+ {fee} satoshis per byte</Text>}
{!isQueryingFees && fee && <Text fontSize="s">(+ {fee} satoshis per byte)</Text>}
</React.Fragment>
}
/>

9
app/components/Pay/messages.js

@ -15,11 +15,12 @@ export default defineMessages({
back: 'Back',
send: 'Send',
fee: 'Fee',
fee_range: 'between {minFee} and {maxFee} msat',
unknown: 'unknown',
fee_less_than_1: 'less than 1 satoshi',
fee_range: 'between {minFee} and {maxFee} satoshis',
fee_upto: 'up to {maxFee} satoshi',
fee_unknown: 'unknown',
fee_per_byte: 'per byte',
amount: 'Amount',
per_byte: 'per byte',
upto: 'up to',
total: 'Total',
memo: 'Memo',
description:

3
app/components/UI/Input.js

@ -49,7 +49,6 @@ class Input extends React.Component {
onChange,
onBlur,
onFocus,
initialValue,
forwardedRef,
theme,
fieldApi,
@ -90,7 +89,7 @@ class Input extends React.Component {
)}
{...rest}
ref={this.inputRef}
value={!value && value !== 0 ? '' : initialValue || value}
value={!value && value !== 0 ? '' : value}
onChange={e => {
setValue(e.target.value)
if (onChange) {

1
app/components/UI/LightningInvoiceInput.js

@ -56,6 +56,7 @@ class LightningInvoiceInput extends React.Component {
placeholder={intl.formatMessage({ ...messages.payreq_placeholder })}
rows={5}
{...this.props}
spellCheck="false"
validate={this.validate}
/>
)

4
app/components/UI/Modal.js

@ -36,12 +36,12 @@ class Modal extends React.Component {
<Flex flexDirection="column" width={1} p={3} bg="darkestBackground" css={{ height: '100%' }}>
<Flex justifyContent="flex-end" as="header" color="primaryText">
<Box
css={{ cursor: 'pointer' }}
css={{ cursor: 'pointer', opacity: hover ? 0.6 : 1 }}
ml="auto"
onClick={onClose}
onMouseEnter={this.hoverOn}
onMouseLeave={this.hoverOff}
color={hover ? 'lightningOrange' : null}
p={2}
>
<X width="2em" height="2em" />
</Box>

4
app/components/UI/Range.js

@ -28,7 +28,7 @@ const Input = styled.input`
const Range = asField(({ fieldState, fieldApi, ...props }) => {
const { value } = fieldState
const { setValue, setTouched } = fieldApi
const { onChange, onBlur, initialValue, forwardedRef, ...rest } = props
const { onChange, onBlur, forwardedRef, ...rest } = props
return (
<Input
min={0}
@ -37,7 +37,7 @@ const Range = asField(({ fieldState, fieldApi, ...props }) => {
{...rest}
type="range"
ref={forwardedRef}
value={value || initialValue || '0'}
value={value || 0}
onChange={e => {
setValue(e.target.value)
if (onChange) {

3
app/components/UI/TextArea.js

@ -50,7 +50,6 @@ class TextArea extends React.PureComponent {
onChange,
onBlur,
onFocus,
initialValue,
forwardedRef,
theme,
fieldApi,
@ -92,7 +91,7 @@ class TextArea extends React.PureComponent {
)}
{...rest}
ref={this.inputRef}
value={!value && value !== 0 ? '' : initialValue || value}
value={!value && value !== 0 ? '' : value}
onChange={e => {
setValue(e.target.value)
if (onChange) {

8
app/components/Value/Value.js

@ -10,7 +10,13 @@ const Value = ({ value, currency, currentTicker, fiatTicker }) => {
if (currency === 'fiat') {
price = currentTicker[fiatTicker].last
}
return <i>{Number(convert('sats', currency, value, price))}</i>
return (
<i>
{Number(convert('sats', currency, value, price))
.toFixed(8)
.replace(/\.?0+$/, '')}
</i>
)
}
Value.propTypes = {

41
app/containers/Pay.js

@ -0,0 +1,41 @@
import { connect } from 'react-redux'
import { Pay } from 'components/Pay'
import { tickerSelectors, setCurrency, setFiatTicker } from 'reducers/ticker'
import { queryFees, queryRoutes } from 'reducers/pay'
import { infoSelectors } from 'reducers/info'
import { sendCoins } from 'reducers/transaction'
import { payInvoice } from 'reducers/payment'
const mapStateToProps = state => ({
chain: state.info.chain,
network: infoSelectors.testnetSelector(state) ? 'testnet' : 'mainnet',
cryptoName: tickerSelectors.cryptoName(state),
channelBalance: state.balance.channelBalance,
currentTicker: tickerSelectors.currentTicker(state),
cryptoCurrency: state.ticker.currency,
cryptoCurrencyTicker: tickerSelectors.currencyName(state),
cryptoCurrencies: state.ticker.currencyFilters,
fiatCurrencies: state.ticker.fiatTickers,
fiatCurrency: state.ticker.fiatTicker,
initialPayReq: state.pay.payReq,
isQueryingFees: state.pay.isQueryingFees,
isQueryingRoutes: state.pay.isQueryingRoutes,
nodes: state.network.nodes,
onchainFees: state.pay.onchainFees,
routes: state.pay.routes,
walletBalance: state.balance.walletBalance
})
const mapDispatchToProps = {
payInvoice,
setCryptoCurrency: setCurrency,
setFiatCurrency: setFiatTicker,
sendCoins,
queryFees,
queryRoutes
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Pay)

7
app/lib/lnd/methods/index.js

@ -40,8 +40,11 @@ export default function(lnd, log, event, msg, data) {
// Data looks like { pubkey: String, amount: Number }
networkController
.queryRoutes(lnd, data)
.then(routes => event.sender.send('receiveQueryRoutes', routes))
.catch(error => log.error('queryRoutes:', error))
.then(routes => event.sender.send('queryRoutesSuccess', routes))
.catch(error => {
log.error('queryRoutes:', error)
event.sender.send('queryRoutesFailure', { error: error.toString() })
})
break
case 'getInvoiceAndQueryRoutes':
// Data looks like { pubkey: String, amount: Number }

5
app/lib/lnd/methods/networkController.js

@ -58,13 +58,12 @@ export function describeGraph(lnd) {
* @param {[type]} amount [description]
* @return {[type]} [description]
*/
export function queryRoutes(lnd, { pubkey, amount }) {
export function queryRoutes(lnd, { pubkey, amount, numRoutes = 15 }) {
return new Promise((resolve, reject) => {
lnd.queryRoutes({ pub_key: pubkey, amt: amount }, (err, data) => {
lnd.queryRoutes({ pub_key: pubkey, amt: amount, num_routes: numRoutes }, (err, data) => {
if (err) {
return reject(err)
}
resolve(data)
})
})

36
app/lib/lnd/methods/paymentsController.js

@ -4,16 +4,19 @@
* @param {[type]} paymentRequest [description]
* @return {[type]} [description]
*/
export function sendPaymentSync(lnd, { paymentRequest }) {
export function sendPaymentSync(lnd, { paymentRequest, feeLimit }) {
return new Promise((resolve, reject) => {
lnd.sendPaymentSync({ payment_request: paymentRequest }, (error, data) => {
if (error) {
return reject(error)
} else if (!data || !data.payment_route) {
return reject(data.payment_error)
lnd.sendPaymentSync(
{ payment_request: paymentRequest, fee_limit: { fixed: feeLimit } },
(error, data) => {
if (error) {
return reject(error)
} else if (!data || !data.payment_route) {
return reject(data.payment_error)
}
resolve(data)
}
resolve(data)
})
)
})
}
@ -23,15 +26,18 @@ export function sendPaymentSync(lnd, { paymentRequest }) {
* @param {[type]} paymentRequest [description]
* @return {[type]} [description]
*/
export function sendPayment(lnd, { paymentRequest }) {
export function sendPayment(lnd, { paymentRequest, feeLimit }) {
return new Promise((resolve, reject) => {
lnd.sendPayment({ payment_request: paymentRequest }, (err, data) => {
if (err) {
return reject(err)
}
lnd.sendPayment(
{ payment_request: paymentRequest, fee_limit: { fixed: feeLimit } },
(err, data) => {
if (err) {
return reject(err)
}
resolve(data)
})
resolve(data)
}
)
})
}

4
app/lib/lnd/methods/walletController.js

@ -74,9 +74,9 @@ export function getTransactions(lnd) {
* @param {[type]} amount [description]
* @return {[type]} [description]
*/
export function sendCoins(lnd, { addr, amount }) {
export function sendCoins(lnd, { addr, amount, target_conf, sat_per_byte }) {
return new Promise((resolve, reject) => {
lnd.sendCoins({ addr, amount }, (err, data) => {
lnd.sendCoins({ addr, amount, target_conf, sat_per_byte }, (err, data) => {
if (err) {
return reject(err)
}

8
app/lib/utils/api.js

@ -35,3 +35,11 @@ export function requestSuggestedNodes() {
url: BASE_URL
}).then(response => response.data)
}
export function requestFees() {
const BASE_URL = 'https://bitcoinfees.earn.com/api/v1/fees/recommended'
return axios({
method: 'get',
url: BASE_URL
}).then(response => response.data)
}

6
app/lib/utils/crypto.js

@ -150,13 +150,13 @@ export const getNodeAlias = (pubkey, nodes = []) => {
/**
* Given a list of routest, find the minimum fee.
* @param {QueryRoutesResponse} routes
* @return {Number} minimum fee.
* @return {Number} minimum fee rounded up to the nearest satoshi.
*/
export const getMinFee = (routes = []) => {
if (!routes || !routes.length) {
return null
}
return routes.reduce((min, b) => Math.min(min, b.total_fees_msat), routes[0].total_fees_msat)
return routes.reduce((min, b) => Math.min(min, b.total_fees), routes[0].total_fees)
}
/**
@ -168,7 +168,7 @@ export const getMaxFee = routes => {
if (!routes || !routes.length) {
return null
}
return routes.reduce((max, b) => Math.max(max, b.total_fees_msat), routes[0].total_fees_msat)
return routes.reduce((max, b) => Math.max(max, b.total_fees), routes[0].total_fees)
}
/**

4
app/main.dev.js

@ -166,8 +166,8 @@ app.on('ready', async () => {
app.on('open-url', (event, url) => {
mainLog.debug('open-url')
event.preventDefault()
const payreq = url.split(':')[1]
zap.sendMessage('lightningPaymentUri', { payreq })
const payReq = url.split(':')[1]
zap.sendMessage('lightningPaymentUri', { payReq })
zap.mainWindow.show()
})

2
app/reducers/index.js

@ -13,6 +13,7 @@ import peers from './peers'
import channels from './channels'
import contactsform from './contactsform'
import form from './form'
import pay from './pay'
import payform from './payform'
import requestform from './requestform'
import invoice from './invoice'
@ -42,6 +43,7 @@ const rootReducer = combineReducers({
channels,
contactsform,
form,
pay,
payform,
requestform,
invoice,

5
app/reducers/ipc.js

@ -27,7 +27,7 @@ import {
channelGraphData,
channelGraphStatus
} from './channels'
import { lightningPaymentUri } from './payform'
import { lightningPaymentUri, queryRoutesSuccess, queryRoutesFailure } from './pay'
import { receivePayments, paymentSuccessful, paymentFailed } from './payment'
import {
receiveInvoices,
@ -84,6 +84,9 @@ const ipc = createIpc({
lightningPaymentUri,
queryRoutesSuccess,
queryRoutesFailure,
paymentSuccessful,
paymentFailed,

13
app/reducers/network.js

@ -1,6 +1,7 @@
import { createSelector } from 'reselect'
import { ipcRenderer } from 'electron'
import { bech32 } from 'lib/utils'
import { setError } from './error'
// ------------------------------------
// Constants
@ -10,6 +11,7 @@ export const RECEIVE_DESCRIBE_NETWORK = 'RECEIVE_DESCRIBE_NETWORK'
export const GET_QUERY_ROUTES = 'GET_QUERY_ROUTES'
export const RECEIVE_QUERY_ROUTES = 'RECEIVE_QUERY_ROUTES'
export const RECEIVE_QUERY_ROUTES_FAILED = 'RECEIVE_QUERY_ROUTES_FAILED'
export const SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE'
@ -142,6 +144,11 @@ export const queryRoutes = (pubkey, amount) => dispatch => {
ipcRenderer.send('lnd', { msg: 'queryRoutes', data: { pubkey, amount } })
}
export const queryRoutesFailed = (event, { error }) => dispatch => {
dispatch({ type: RECEIVE_QUERY_ROUTES_FAILED })
dispatch(setError(error))
}
export const receiveQueryRoutes = (event, { routes }) => dispatch =>
dispatch({ type: RECEIVE_QUERY_ROUTES, routes })
@ -176,6 +183,11 @@ const ACTION_HANDLERS = {
networkLoading: false,
selectedNode: { pubkey: state.selectedNode.pubkey, routes, currentRoute: routes[0] }
}),
[RECEIVE_QUERY_ROUTES_FAILED]: state => ({
...state,
networkLoading: false,
selectedNode: {}
}),
[SET_CURRENT_ROUTE]: (state, { route }) => ({ ...state, currentRoute: route }),
@ -294,6 +306,7 @@ const initialState = {
nodes: [],
edges: [],
selectedChannel: {},
selectedNode: {},
currentTab: 1,

131
app/reducers/pay.js

@ -0,0 +1,131 @@
import { ipcRenderer } from 'electron'
import get from 'lodash.get'
import { requestFees } from 'lib/utils/api'
import { setFormType } from './form'
// ------------------------------------
// Constants
// ------------------------------------
export const QUERY_FEES = 'QUERY_FEES'
export const QUERY_FEES_SUCCESS = 'QUERY_FEES_SUCCESS'
export const QUERY_FEES_FAILURE = 'QUERY_FEES_FAILURE'
export const QUERY_ROUTES = 'QUERY_ROUTES'
export const QUERY_ROUTES_SUCCESS = 'QUERY_ROUTES_SUCCESS'
export const QUERY_ROUTES_FAILURE = 'QUERY_ROUTES_FAILURE'
export const SET_PAY_REQ = 'SET_PAY_REQ'
// ------------------------------------
// Actions
// ------------------------------------
export const queryFees = () => async dispatch => {
dispatch({ type: QUERY_FEES })
try {
const onchainFees = await requestFees()
dispatch({ type: QUERY_FEES_SUCCESS, onchainFees })
} catch (e) {
const error = get(e, 'response.statusText', e)
dispatch({ type: QUERY_FEES_FAILURE, error })
}
}
export const queryRoutes = (pubKey, amount) => dispatch => {
dispatch({ type: QUERY_ROUTES, pubKey })
ipcRenderer.send('lnd', { msg: 'queryRoutes', data: { pubkey: pubKey, amount } })
}
export const queryRoutesSuccess = (event, { routes }) => dispatch =>
dispatch({ type: QUERY_ROUTES_SUCCESS, routes })
export const queryRoutesFailure = () => dispatch => {
dispatch({ type: QUERY_ROUTES_FAILURE })
}
export function setPayReq(payReq) {
return {
type: SET_PAY_REQ,
payReq
}
}
export const lightningPaymentUri = (event, { payReq }) => dispatch => {
dispatch(setPayReq(payReq))
dispatch(setFormType('PAY_FORM'))
dispatch(setPayReq(null))
}
// ------------------------------------
// Action Handlers
// ------------------------------------
const ACTION_HANDLERS = {
[QUERY_FEES]: state => ({
...state,
isQueryingFees: true,
onchainFees: {},
queryFeesError: null
}),
[QUERY_FEES_SUCCESS]: (state, { onchainFees }) => ({
...state,
isQueryingFees: false,
onchainFees,
queryFeesError: null
}),
[QUERY_FEES_FAILURE]: (state, { error }) => ({
...state,
isQueryingFees: false,
onchainFees: {},
queryFeesError: error
}),
[QUERY_ROUTES]: (state, { pubKey }) => ({
...state,
isQueryingRoutes: true,
pubKey,
queryRoutesError: null,
routes: []
}),
[QUERY_ROUTES_SUCCESS]: (state, { routes }) => ({
...state,
isQueryingRoutes: false,
queryRoutesError: null,
routes
}),
[QUERY_ROUTES_FAILURE]: (state, { error }) => ({
...state,
isQueryingRoutes: false,
pubKey: null,
queryRoutesError: error,
routes: []
}),
[SET_PAY_REQ]: (state, { payReq }) => ({
...state,
payReq
})
}
// ------------------------------------
// Initial State
// ------------------------------------
const initialState = {
isQueryingRoutes: false,
isQueryingFees: false,
onchainFees: {
fastestFee: null,
halfHourFee: null,
hourFee: null
},
payReq: null,
pubKey: null,
queryFeesError: null,
queryRoutesError: null,
routes: []
}
// ------------------------------------
// Reducer
// ------------------------------------
export default function activityReducer(state = initialState, action) {
const handler = ACTION_HANDLERS[action.type]
return handler ? handler(state, action) : state
}

4
app/reducers/payment.js

@ -119,9 +119,9 @@ export const paymentFailed = (event, { error }) => dispatch => {
dispatch(setError(error))
}
export const payInvoice = paymentRequest => (dispatch, getState) => {
export const payInvoice = (paymentRequest, feeLimit) => (dispatch, getState) => {
dispatch(sendPayment())
ipcRenderer.send('lnd', { msg: 'sendPayment', data: { paymentRequest } })
ipcRenderer.send('lnd', { msg: 'sendPayment', data: { paymentRequest, feeLimit } })
// Set an interval to call tick which will continuously tick down the ticker until the payment goes through or it hits
// 0 and throws an error. We also call setPaymentInterval so we are storing the interval. This allows us to clear the

7
app/reducers/transaction.js

@ -86,13 +86,16 @@ export const receiveTransactions = (event, { transactions }) => (dispatch, getSt
dispatch(fetchBalance())
}
export const sendCoins = ({ value, addr, currency }) => dispatch => {
export const sendCoins = ({ value, addr, currency, targetConf, satPerByte }) => dispatch => {
// backend needs amount in satoshis no matter what currency we are using
const amount = btc.convert(currency, 'sats', value)
// submit the transaction to LND
dispatch(sendTransaction())
ipcRenderer.send('lnd', { msg: 'sendCoins', data: { amount, addr } })
ipcRenderer.send('lnd', {
msg: 'sendCoins',
data: { amount, addr, currency, target_conf: targetConf, sat_per_byte: satPerByte }
})
// Close the form modal once the payment was sent to LND
// we will do the loading/success UX on the main page

1
internals/webpack/webpack.config.renderer.dev.js

@ -155,6 +155,7 @@ export default merge.smart(baseConfig, {
'http://localhost:*',
'ws://localhost:*',
'https://blockchain.info',
'https://bitcoinfees.earn.com',
'https://zap.jackmallers.com'
],
'script-src': ["'self'", 'http://localhost:*', "'unsafe-eval'"],

7
internals/webpack/webpack.config.renderer.prod.js

@ -98,7 +98,12 @@ export default merge.smart(baseConfig, {
new CspHtmlWebpackPlugin({
'default-src': "'self'",
'object-src': "'none'",
'connect-src': ["'self'", 'https://blockchain.info', 'https://zap.jackmallers.com'],
'connect-src': [
"'self'",
'https://blockchain.info',
'https://bitcoinfees.earn.com',
'https://zap.jackmallers.com'
],
'script-src': ["'self'"],
'font-src': ["'self'", 'data:', 'https://s3.amazonaws.com', 'https://fonts.gstatic.com'],
'style-src': ["'self'", 'blob:', 'https://s3.amazonaws.com', "'unsafe-inline'"]

2
stories/components/form.stories.js

@ -221,7 +221,7 @@ storiesOf('Components.Form', module)
<Label htmlFor="slider1">Example Range</Label>
</Box>
<Box>
<Range field="slider1" onChange={action('change')} />
<Range field="slider1" initialValue={25} onChange={action('change')} />
</Box>
</Box>

7
stories/pages/pay.stories.js

@ -60,18 +60,21 @@ const store = new Store({
})
const mockPayInvoice = async () => {
action('mockPayInvoice')
store.set({ isProcessing: true })
await delay(2000)
store.set({ isProcessing: false })
}
const mockSendCoins = async () => {
action('mockSendCoins')
store.set({ isProcessing: true })
await delay(2000)
store.set({ isProcessing: false })
}
const mockQueryFees = async () => {
action('mockQueryFees')
store.set({ isQueryingFees: true })
await delay(2000)
store.set({
@ -85,6 +88,7 @@ const mockQueryFees = async () => {
}
const mockQueryRoutes = async pubKey => {
action('mockQueryRoutes', pubKey)
store.set({ isQueryingRoutes: true })
await delay(2000)
const nodes = store.get('nodes')
@ -162,9 +166,10 @@ storiesOf('Containers.Pay', module)
<Modal onClose={action('clicked')}>
<State store={store}>
<Pay
width={1 / 2}
width={9 / 16}
mx="auto"
// State
// initialPayReq="lntb100n1pdaetlfpp5rkj5acj5usdlqekv3548nx5zc58tsqghm8qy6pdkrn3h37ep5aqsdqqcqzysxqyz5vq7vsxfsnak9yd0rf0zxpg9tukykxjqwef72apfwq2meg7wlz8zg0nxh3fmmc0ayv8ac5xhnlwlxajatqwnh3qwdx6uruyqn47enq9w6qplzqccc"
isProcessing={store.get('isProcessing')}
chain={store.get('chain')}
channelBalance={store.get('channelBalance')}

2
test/unit/components/Form.spec.js

@ -3,7 +3,7 @@ import { configure, shallow } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import Form from 'components/Form'
import Pay from 'components/Form/Pay'
import Pay from 'containers/Pay'
import Request from 'components/Form/Request'
configure({ adapter: new Adapter() })

87
test/unit/components/Form/Pay.spec.js

@ -1,87 +0,0 @@
import React from 'react'
import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import 'jest-styled-components'
import { ThemeProvider } from 'styled-components'
import Pay from 'components/Form/Pay'
import { dark } from 'themes'
import { mountWithIntl } from '../../__helpers__/intl-enzyme-test-helper'
configure({ adapter: new Adapter() })
const defaultProps = {
payform: {
amount: 0,
payInput: '',
invoice: {},
showErrors: {}
},
currency: '',
crypto: '',
nodes: [],
ticker: {
currency: 'btc',
fiatTicker: 'USD'
},
isOnchain: false,
isLn: true,
currentAmount: 0,
usdAmount: 0,
inputCaption: '',
showPayLoadingScreen: true,
payFormIsValid: {},
currencyFilters: [],
currencyName: '',
setPayAmount: () => {},
setPayInput: () => {},
fetchInvoice: () => {},
setCurrency: () => {},
onPayAmountBlur: () => {},
onPayInputBlur: () => {},
onPaySubmit: () => {}
}
describe('Form', () => {
describe('should show the form without an input', () => {
const el = mountWithIntl(
<ThemeProvider theme={dark}>
<Pay {...defaultProps} />
</ThemeProvider>
)
it('should contain Pay', () => {
expect(el.find('input#paymentRequest').props.value).toBe(undefined)
})
})
describe('should show lightning with a lightning input', () => {
const props = { ...defaultProps, isLn: true }
const el = mountWithIntl(
<ThemeProvider theme={dark}>
<Pay {...props} />
</ThemeProvider>
)
it('should contain Pay', () => {
expect(el.find('input#paymentRequest').props.value).toBe(undefined)
})
})
describe('should show on-chain with an on-chain input', () => {
const props = { ...defaultProps, isOnchain: true }
const el = mountWithIntl(
<ThemeProvider theme={dark}>
<Pay {...props} />
</ThemeProvider>
)
it('should contain Pay', () => {
expect(el.find('input#paymentRequest').props.value).toBe(undefined)
})
})
})

1
test/unit/components/Pay/PaySummaryOnchain.spec.js

@ -34,6 +34,7 @@ const props = {
}
],
fiatCurrency: 'USD',
queryFees: jest.fn(),
setCryptoCurrency: jest.fn()
}

10
test/unit/components/Pay/__snapshots__/PaySummaryLightning.spec.js.snap

@ -81,6 +81,7 @@ exports[`component.Form.PaySummaryLightning should render correctly 1`] = `
textAlign="right"
>
<Truncate
maxlen={15}
text="03c856d2dbec7454c48f311031f06bb99e3ca1ab15a9b9b35de14e139aa663b463"
/>
</Text>
@ -99,8 +100,13 @@ exports[`component.Form.PaySummaryLightning should render correctly 1`] = `
right={
<FormattedMessage
defaultMessage="unknown"
id="components.Pay.unknown"
values={Object {}}
id="components.Pay.fee_unknown"
values={
Object {
"maxFee": null,
"minFee": null,
}
}
/>
}
/>

2
test/unit/components/Pay/__snapshots__/PaySummaryOnchain.spec.js.snap

@ -99,7 +99,7 @@ exports[`component.Form.PaySummaryOnchain should render correctly 1`] = `
right={
<FormattedMessage
defaultMessage="unknown"
id="components.Pay.unknown"
id="components.Pay.fee_unknown"
values={Object {}}
/>
}

1
test/unit/components/UI/__snapshots__/LightningInvoiceInput.spec.js.snap

@ -52,6 +52,7 @@ exports[`component.UI.LightningInvoiceInput should render correctly 1`] = `
placeholder="Paste a Lightning Payment Request or Bitcoin Address here"
required={false}
rows={5}
spellCheck="false"
value=""
width={1}
/>

3
test/unit/components/UI/__snapshots__/Modal.spec.js.snap

@ -3,7 +3,9 @@
exports[`component.UI.Modal should render correctly 1`] = `
.c2 {
margin-left: auto;
padding: 8px;
cursor: pointer;
opacity: 1;
}
.c3 {
@ -49,7 +51,6 @@ exports[`component.UI.Modal should render correctly 1`] = `
>
<div
className="c2"
color={null}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>

2
test/unit/components/UI/__snapshots__/Range.spec.js.snap

@ -42,7 +42,7 @@ exports[`component.UI.Range should render correctly 1`] = `
onChange={[Function]}
step={1}
type="range"
value="0"
value={0}
/>
</form>
`;

Loading…
Cancel
Save