Skip to content

Commit

Permalink
fix(ui): ensure failed payments marked as failed
Browse files Browse the repository at this point in the history
Generate a unique payment id for sending payments to enable more
accurate tracking of payments across retry attempts.

Fix LN-Zap#3142
  • Loading branch information
mrfelton committed Nov 8, 2019
1 parent 96fdfb3 commit a06e29b
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 78 deletions.
83 changes: 42 additions & 41 deletions renderer/reducers/payment.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getIntl } from '@zap/i18n'
import { decodePayReq, getNodeAlias } from '@zap/utils/crypto'
import { convert } from '@zap/utils/btc'
import delay from '@zap/utils/delay'
import genId from '@zap/utils/genId'
import { grpc } from 'workers'
import createReducer from './utils/createReducer'
import { fetchBalance } from './balance'
Expand Down Expand Up @@ -92,16 +93,14 @@ const decoratePayment = (payment, nodes = []) => {
}

/**
* getLastSendingEntry - Find the latest temporary paymentsSending entry for the payment.
* getPaymentSendingById - Fetch a sending payment by id.
*
* @param {object} state Redux state
* @param {string} paymentRequest Payment request
* @returns {object} sendingPayments entry
* @param {Array} paymentsSending List of sending payments
* @param {string} paymentId Internal payment id
* @returns {object} paymentsSending entry
*/
const getLastSendingEntry = (state, paymentRequest) =>
[...state.payment.paymentsSending]
.sort((a, b) => b.creation_date - a.creation_date)
.find(p => p.paymentRequest === paymentRequest)
const getPaymentSendingById = (paymentsSending, paymentId) =>
paymentsSending.find(p => p.paymentId === paymentId)

// ------------------------------------
// Actions
Expand Down Expand Up @@ -171,12 +170,12 @@ export const receivePayments = payments => dispatch => {
/**
* decPaymentRetry - Decrement payment request retry count.
*
* @param {object} paymentRequest Lightning payment request.
* @param {object} paymentId Internal payment id
* @returns {Function} Thunk
*/
const decPaymentRetry = paymentRequest => ({
const decPaymentRetry = paymentId => ({
type: DECREASE_PAYMENT_RETRIES,
paymentRequest,
paymentId,
})

/**
Expand All @@ -187,25 +186,30 @@ const decPaymentRetry = paymentRequest => ({
* @param {string} options.payReq Payment request
* @param {number} options.amt Payment amount (in sats)
* @param {number} options.feeLimit The max fee to apply
* @param {boolean} options.isRetry Boolean indicating whether this is a retry attempt
* @param {boolean} options.originalPaymentId Id of the original payment if this is a payment retry
* @param {number} options.retries Number of remaining retries
* @returns {Function} Thunk
*/
export const payInvoice = ({
payReq,
amt,
feeLimit,
isRetry = false,
retries = 0,
originalPaymentId,
}) => async dispatch => {
// if it's a retry - only decrease number of retries left
if (isRetry) {
dispatch(decPaymentRetry(payReq))
let paymentId = originalPaymentId

// If we already have an id then this is a retry. Decrease the retry count.
if (originalPaymentId) {
dispatch(decPaymentRetry(originalPaymentId))
}
// Otherwise, add to sendingPayments in the state.

// Otherwise, add to paymentsSending in the state.
else {
paymentId = genId()
dispatch(
sendPayment({
paymentId,
paymentRequest: payReq,
feeLimit,
value: amt,
Expand All @@ -218,18 +222,15 @@ export const payInvoice = ({
// Submit the payment to LND.
try {
const data = await grpc.services.Lightning.sendPayment({
paymentId,
payment_request: payReq,
amt,
fee_limit: { fixed: feeLimit },
})
dispatch(paymentSuccessful(data))
} catch (e) {
dispatch(
paymentFailed({
error: e.message,
payment_request: e.payload.payment_request,
})
)
const { details: data, message: error } = e
dispatch(paymentFailed(error, data))
}
}

Expand All @@ -253,9 +254,10 @@ export const updatePayment = paymentRequest => async dispatch => {
* @param {{payment_request}} payment_request Payment request
* @returns {Function} Thunk
*/
export const paymentSuccessful = ({ payment_request }) => async (dispatch, getState) => {
export const paymentSuccessful = ({ paymentId }) => async (dispatch, getState) => {
const state = getState()
const paymentSending = getLastSendingEntry(state, payment_request)
const paymentSending = getPaymentSendingById(state.payment.paymentsSending, paymentId)

// If we found a related entry in paymentsSending, gracefully remove it and handle as success case.
if (paymentSending) {
const { creation_date, paymentRequest } = paymentSending
Expand All @@ -264,7 +266,7 @@ export const paymentSuccessful = ({ payment_request }) => async (dispatch, getSt
await delay(2000 - (Date.now() - creation_date * 1000))

// Mark the payment as successful.
dispatch({ type: PAYMENT_SUCCESSFUL, paymentRequest })
dispatch({ type: PAYMENT_SUCCESSFUL, paymentId })

// Wait for another second.
await delay(1500)
Expand All @@ -282,14 +284,15 @@ export const paymentSuccessful = ({ payment_request }) => async (dispatch, getSt
/**
* paymentFailed - Error handler for payInvoice.
*
* @param {object} details Details
* @param {string} details.payment_request Payment request
* @param {string} details.error Error message
* @param {object} error Error message
* @param {object} details Failed payment details
*
* @returns {Function} Thunk
*/
export const paymentFailed = ({ payment_request, error }) => async (dispatch, getState) => {
export const paymentFailed = (error, { paymentId }) => async (dispatch, getState) => {
const state = getState()
const paymentSending = getLastSendingEntry(state, payment_request)
const paymentSending = getPaymentSendingById(state.payment.paymentsSending, paymentId)

// errors that trigger retry mechanism
const RETRIABLE_ERRORS = [
'payment attempt not completed before timeout', // ErrPaymentAttemptTimeout
Expand All @@ -305,7 +308,7 @@ export const paymentFailed = ({ payment_request, error }) => async (dispatch, ge
const data = {
...paymentSending,
payReq: paymentRequest,
isRetry: true,
originalPaymentId: paymentId,
}
const retryIndex = maxRetries - remainingRetries + 1
// add increasing delay
Expand All @@ -316,7 +319,7 @@ export const paymentFailed = ({ payment_request, error }) => async (dispatch, ge
await delay(2000 - (Date.now() - creation_date * 1000))

// Mark the payment as failed.
dispatch({ type: PAYMENT_FAILED, paymentRequest, error: errorToUserFriendly(error) })
dispatch({ type: PAYMENT_FAILED, paymentId, error: errorToUserFriendly(error) })
}
}
}
Expand All @@ -340,28 +343,26 @@ const ACTION_HANDLERS = {
state.paymentsSending.push(payment)
},

[DECREASE_PAYMENT_RETRIES]: (state, { paymentRequest }) => {
[DECREASE_PAYMENT_RETRIES]: (state, { paymentId }) => {
const { paymentsSending } = state
const item = paymentsSending.find(
i => i.paymentRequest === paymentRequest && i.status === PAYMENT_STATUS_SENDING
)
const item = getPaymentSendingById(paymentsSending, paymentId)
if (item) {
item.remainingRetries -= 1
if (item.feeLimit) {
item.feeLimit = Math.ceil(item.feeLimit * config.invoices.feeIncrementExponent)
}
}
},
[PAYMENT_SUCCESSFUL]: (state, { paymentRequest }) => {
[PAYMENT_SUCCESSFUL]: (state, { paymentId }) => {
const { paymentsSending } = state
const item = paymentsSending.find(i => i.paymentRequest === paymentRequest)
const item = getPaymentSendingById(paymentsSending, paymentId)
if (item) {
item.status = PAYMENT_STATUS_SUCCESSFUL
}
},
[PAYMENT_FAILED]: (state, { paymentRequest, error }) => {
[PAYMENT_FAILED]: (state, { paymentId, error }) => {
const { paymentsSending } = state
const item = paymentsSending.find(i => i.paymentRequest === paymentRequest)
const item = getPaymentSendingById(paymentsSending, paymentId)
if (item) {
item.status = PAYMENT_STATUS_FAILED
item.error = error
Expand Down
69 changes: 32 additions & 37 deletions services/grpc/lightning.methods.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,56 +260,51 @@ async function closeChannel(payload = {}) {
* sendPayment - Call lnd grpc sendPayment method.
*
* @param {object} payload Payload
* @returns {Promise} SendResponse
* @returns {Promise} Original payload augmented with lnd sendPayment response data.
*/
async function sendPayment(payload = {}) {
// Our response will always include the original payload.
const res = { ...payload }

return new Promise((resolve, reject) => {
try {
const call = this.service.sendPayment(payload)

call.on('data', data => {
const isSuccess = !data.payment_error
if (isSuccess) {
grpcLog.debug('PAYMENT SUCCESS', data)

// Convert payment_hash to hex string.
let paymentHash = data.payment_hash
if (paymentHash) {
paymentHash = paymentHash.toString('hex')
}

// In some cases lnd does not return the payment_hash. If this happens, retrieve it from the invoice.
else {
const invoice = bolt11DecodePayReq(payload.payment_request)
const paymentHashTag = invoice.tags
? invoice.tags.find(t => t.tagName === 'payment_hash')
: null
paymentHash = paymentHashTag ? paymentHashTag.data : null
}

// Convert the preimage to a hex string.
const paymentPreimage = data.payment_preimage
? data.payment_preimage.toString('hex')
// Convert payment_hash to hex string.
data.payment_hash = data.payment_hash ? data.payment_hash.toString('hex') : null

// In some cases lnd does not return the payment_hash. If this happens, retrieve it from the invoice.
if (!data.payment_hash) {
const invoice = bolt11DecodePayReq(payload.payment_request)
const paymentHashTag = invoice.tags
? invoice.tags.find(t => t.tagName === 'payment_hash')
: null
data.payment_hash = paymentHashTag ? paymentHashTag.data : null
}

// Convert the preimage to a hex string.
data.payment_preimage = data.payment_preimage ? data.payment_preimage.toString('hex') : null

// Add lnd return data, as well as payment preimage and hash as hex strings to the response.
Object.assign(res, data)

// Notify the client of a successful payment.
const res = {
...payload,
data,
payment_preimage: paymentPreimage,
payment_hash: paymentHash,
}
// Payment success is determined by the absense of a payment error.
const isSuccess = !res.payment_error
if (isSuccess) {
grpcLog.debug('PAYMENT SUCCESS', res)
this.emit('sendPayment.data', res)
resolve(res)
} else {
// Notify the client if there was a problem sending the payment
grpcLog.error('PAYMENT ERROR', data)
const error = new Error(data.payment_error)
error.payload = payload
}

// In case of an error, notify the client if there was a problem sending the payment.
else {
grpcLog.error('PAYMENT ERROR', res)
const error = new Error(res.payment_error)
error.details = res
this.emit('sendPayment.error', error)
reject(error)
}

call.end()
})

Expand All @@ -326,7 +321,7 @@ async function sendPayment(payload = {}) {
call.write(payload)
} catch (e) {
const error = new Error(e.message)
error.payload = payload
error.details = res
throw error
}
})
Expand Down

0 comments on commit a06e29b

Please sign in to comment.