Skip to content

Commit

Permalink
More detailed error when HTLC could not be sent (#634)
Browse files Browse the repository at this point in the history
We use more detailed errors when we fail to send an HTLC, since this
may happen because the channel is offline, syncing, opening or closing.

It may also happen that we have a channel in the `Normal` state that
has enough funds, but we cannot send the HTLC because it hits a channel
limit (too many HTLCs pending, a splice is ongoing, etc). When that
happens we will retry ignoring the channel, which leads to the default
`NoAvailableChannels` error: in that case, the "real" error can be found
in the `failures` list of the `OutgoingPaymentFailure` instance.

Fixes #616
  • Loading branch information
t-bast authored May 14, 2024
1 parent 088410f commit 6fd2a4e
Show file tree
Hide file tree
Showing 10 changed files with 200 additions and 106 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.wire.InteractiveTxMessage
import fr.acinq.lightning.wire.UpdateAddHtlc

open class ChannelException(open val channelId: ByteVector32, override val message: String) : RuntimeException(message) {
sealed class ChannelException(open val channelId: ByteVector32, override val message: String) : RuntimeException(message) {
fun details(): String = "$channelId: $message"
}

Expand Down Expand Up @@ -63,7 +63,6 @@ data class InvalidHtlcSignature (override val channelId: Byte
data class InvalidCloseSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid close signature: txId=$txId")
data class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid closing tx: some outputs are below dust: txId=$txId")
data class CommitSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "commit sig count mismatch: expected=$expected actual=$actual")
data class SwapInSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "swap-in sig count mismatch: expected=$expected actual=$actual")
data class HtlcSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "htlc sig count mismatch: expected=$expected actual: $actual")
data class ForcedLocalCommit (override val channelId: ByteVector32) : ChannelException(channelId, "forced local commit")
data class UnexpectedHtlcId (override val channelId: ByteVector32, val expected: Long, val actual: Long) : ChannelException(channelId, "unexpected htlc id: expected=$expected actual=$actual")
Expand Down
43 changes: 35 additions & 8 deletions src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
package fr.acinq.lightning.db

import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.ShortChannelId
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.payment.*
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.FailureMessage
import fr.acinq.lightning.wire.LiquidityAds

interface PaymentsDb : IncomingPaymentsDb, OutgoingPaymentsDb {
Expand Down Expand Up @@ -80,7 +77,7 @@ interface OutgoingPaymentsDb {
suspend fun addOutgoingLightningParts(parentId: UUID, parts: List<LightningOutgoingPayment.Part>)

/** Mark an outgoing payment part as failed. */
suspend fun completeOutgoingLightningPart(partId: UUID, failure: Either<ChannelException, FailureMessage>, completedAt: Long = currentTimestampMillis())
suspend fun completeOutgoingLightningPart(partId: UUID, failure: LightningOutgoingPayment.Part.Status.Failure, completedAt: Long = currentTimestampMillis())

/** Mark an outgoing payment part as succeeded. This should not update the parent payment, since some parts may still be pending. */
suspend fun completeOutgoingLightningPart(partId: UUID, preimage: ByteVector32, completedAt: Long = currentTimestampMillis())
Expand Down Expand Up @@ -337,13 +334,43 @@ data class LightningOutgoingPayment(
sealed class Status {
data object Pending : Status()
data class Succeeded(val preimage: ByteVector32, val completedAt: Long = currentTimestampMillis()) : Status()
data class Failed(val failure: Failure, val completedAt: Long = currentTimestampMillis()) : Status()

/**
* @param remoteFailureCode Bolt4 failure code when the failure came from a remote node (see [FailureMessage]).
* If null this was a local error (channel unavailable for low-level technical reasons).
* User-friendly payment part failure reason, whenever possible.
* Applications should define their own localized message for each of these failure cases.
*/
data class Failed(val remoteFailureCode: Int?, val details: String, val completedAt: Long = currentTimestampMillis()) : Status() {
fun isLocalFailure(): Boolean = remoteFailureCode == null
sealed class Failure {
// @formatter:off
/** The payment is too small: try sending a larger amount. */
data object PaymentAmountTooSmall : Failure()
/** The user has sufficient balance, but the payment is too big: try sending a smaller amount. */
data object PaymentAmountTooBig : Failure()
/** The user doesn't have sufficient balance: try sending a smaller amount. */
data object NotEnoughFunds : Failure()
/** The payment must be retried with more fees to reach the recipient. */
data object NotEnoughFees : Failure()
/** The payment expiry specified by the recipient is too far away in the future. */
data object PaymentExpiryTooBig : Failure()
/** There are too many pending payments: wait for them to settle and retry. */
data object TooManyPendingPayments : Failure()
/** Payments are temporarily paused while a channel is splicing: the payment can be retried after the splice. */
data object ChannelIsSplicing : Failure()
/** The channel is closing: another channel should be created to send the payment. */
data object ChannelIsClosing : Failure()
/** Remote failure from an intermediate node in the payment route. */
sealed class RouteFailure : Failure()
/** A remote node had a temporary failure: the payment may succeed if retried. */
data object TemporaryRemoteFailure : RouteFailure()
/** The payment amount could not be relayed to the recipient, most likely because they don't have enough inbound liquidity. */
data object RecipientLiquidityIssue : RouteFailure()
/** The payment recipient is offline and could not accept the payment. */
data object RecipientIsOffline : RouteFailure()
/** The payment recipient received the payment but rejected it. */
data object RecipientRejectedPayment : Failure()
/** This is an error that cannot be easily interpreted: we don't know what exactly went wrong and cannot correctly inform the user. */
data class Uninterpretable(val message: String) : Failure()
// @formatter:on
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,60 +1,109 @@
package fr.acinq.lightning.payment

import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.channel.*
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.wire.*

/** A fatal failure that stops payment attempts. */
/**
* A fatal failure that stops payment attempts.
* Applications should define their own localized message for each of these failures.
*/
sealed class FinalFailure {

/** Use this function when no payment attempts have been made (e.g. when a precondition failed). */
fun toPaymentFailure(): OutgoingPaymentFailure = OutgoingPaymentFailure(this, listOf<LightningOutgoingPayment.Part.Status.Failed>())

// @formatter:off
object AlreadyPaid : FinalFailure() { override fun toString(): String = "this invoice has already been paid" }
object InvalidPaymentAmount : FinalFailure() { override fun toString(): String = "payment amount must be positive" }
object FeaturesNotSupported : FinalFailure() { override fun toString(): String = "payment request features not supported" }
object InvalidPaymentId : FinalFailure() { override fun toString(): String = "payment ID must be unique" }
object NoAvailableChannels : FinalFailure() { override fun toString(): String = "no channels available to send payment" }
object InsufficientBalance : FinalFailure() { override fun toString(): String = "not enough funds in wallet to afford payment" }
object NoRouteToRecipient : FinalFailure() { override fun toString(): String = "unable to route payment to recipient" }
object RecipientUnreachable : FinalFailure() { override fun toString(): String = "the recipient was offline or did not have enough liquidity to receive the payment" }
object RetryExhausted: FinalFailure() { override fun toString(): String = "payment attempts exhausted without success" }
object WalletRestarted: FinalFailure() { override fun toString(): String = "wallet restarted while a payment was ongoing" }
object UnknownError : FinalFailure() { override fun toString(): String = "an unknown error occurred" }
data object AlreadyPaid : FinalFailure() { override fun toString(): String = "this invoice has already been paid" }
data object InvalidPaymentAmount : FinalFailure() { override fun toString(): String = "payment amount must be positive" }
data object FeaturesNotSupported : FinalFailure() { override fun toString(): String = "payment request features not supported" }
data object InvalidPaymentId : FinalFailure() { override fun toString(): String = "payment ID must be unique" }
data object ChannelNotConnected : FinalFailure() { override fun toString(): String = "channel is not connected yet, please retry when connected" }
data object ChannelOpening : FinalFailure() { override fun toString(): String = "channel creation is in progress, please retry when ready" }
data object ChannelClosing : FinalFailure() { override fun toString(): String = "channel closing is in progress, please retry when a new channel has been created" }
data object NoAvailableChannels : FinalFailure() { override fun toString(): String = "payment could not be sent through existing channels, check individual failures for more details" }
data object InsufficientBalance : FinalFailure() { override fun toString(): String = "not enough funds in wallet to afford payment" }
data object RecipientUnreachable : FinalFailure() { override fun toString(): String = "the recipient was offline or did not have enough liquidity to receive the payment" }
data object RetryExhausted: FinalFailure() { override fun toString(): String = "payment attempts exhausted without success" }
data object WalletRestarted: FinalFailure() { override fun toString(): String = "wallet restarted while a payment was ongoing" }
data object UnknownError : FinalFailure() { override fun toString(): String = "an unknown error occurred" }
// @formatter:on
}

data class OutgoingPaymentFailure(val reason: FinalFailure, val failures: List<LightningOutgoingPayment.Part.Status.Failed>) {
constructor(reason: FinalFailure, failures: List<Either<ChannelException, FailureMessage>>, completedAt: Long = currentTimestampMillis()) : this(reason, failures.map { convertFailure(it, completedAt) })
constructor(reason: FinalFailure, failures: List<Either<ChannelException, FailureMessage>>, completedAt: Long = currentTimestampMillis()) : this(
reason,
failures.map { LightningOutgoingPayment.Part.Status.Failed(convertFailure(it), completedAt) }
)

/** Extracts the most user-friendly reason for the payment failure. */
fun explain(): Either<LightningOutgoingPayment.Part.Status.Failure, FinalFailure> {
val partFailure = failures.map { it.failure }.lastOrNull { it !is LightningOutgoingPayment.Part.Status.Failure.Uninterpretable } ?: failures.lastOrNull()?.failure
return when (reason) {
FinalFailure.NoAvailableChannels, FinalFailure.UnknownError, FinalFailure.RetryExhausted -> partFailure?.let { Either.Left(it) } ?: Either.Right(reason)
else -> Either.Right(reason)
}
}

/**
* A detailed summary of the all internal errors.
* This is targeted at users with technical knowledge of the lightning protocol.
*/
fun details(): String = failures.foldIndexed("") { index, msg, problem -> msg + "${index + 1}: ${problem.details}\n" }
fun details(): String = failures.foldIndexed("") { index, msg, problem -> msg + "${index + 1}: ${problem.failure}\n" }

companion object {
fun convertFailure(failure: Either<ChannelException, FailureMessage>, completedAt: Long = currentTimestampMillis()): LightningOutgoingPayment.Part.Status.Failed = when (failure) {
is Either.Left -> LightningOutgoingPayment.Part.Status.Failed(null, failure.value.details(), completedAt)
is Either.Right -> LightningOutgoingPayment.Part.Status.Failed(failure.value.code, failure.value.message, completedAt)
}

fun isRouteError(failure: LightningOutgoingPayment.Part.Status.Failed) = when (failure.remoteFailureCode) {
UnknownNextPeer.code -> true
ChannelDisabled.code -> true
TemporaryChannelFailure.code -> true
PermanentChannelFailure.code -> true
TemporaryNodeFailure.code -> true
PermanentNodeFailure.code -> true
else -> false
}

fun isRejectedByRecipient(failure: LightningOutgoingPayment.Part.Status.Failed) = when (failure.remoteFailureCode) {
IncorrectOrUnknownPaymentDetails.code -> true
else -> false
fun convertFailure(failure: Either<ChannelException, FailureMessage>): LightningOutgoingPayment.Part.Status.Failure {
return when (failure) {
is Either.Left -> when (failure.value) {
is HtlcValueTooSmall -> LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall
is CannotAffordFees -> LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig
is RemoteCannotAffordFeesForNewHtlc -> LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig
is HtlcValueTooHighInFlight -> LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig
is InsufficientFunds -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFunds
is TooManyAcceptedHtlcs -> LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments
is TooManyOfferedHtlcs -> LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments
is ExpiryTooBig -> LightningOutgoingPayment.Part.Status.Failure.PaymentExpiryTooBig
is ForbiddenDuringSplice -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsSplicing
is ChannelUnavailable -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing
is ClosingAlreadyInProgress -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing
is ForcedLocalCommit -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing
is FundingTxSpent -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing
is HtlcOverriddenByLocalCommit -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing
is HtlcsTimedOutDownstream -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing
is NoMoreHtlcsClosingInProgress -> LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing
else -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
}
is Either.Right -> when (failure.value) {
is AmountBelowMinimum -> LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall
is FeeInsufficient -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees
TrampolineExpiryTooSoon -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees
TrampolineFeeInsufficient -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees
is FinalIncorrectCltvExpiry -> LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment
is FinalIncorrectHtlcAmount -> LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment
is IncorrectOrUnknownPaymentDetails -> LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment
PaymentTimeout -> LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue
UnknownNextPeer -> LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline
is ExpiryTooSoon -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
ExpiryTooFar -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
is ChannelDisabled -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
is TemporaryChannelFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
TemporaryNodeFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
PermanentChannelFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
PermanentNodeFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
is InvalidOnionBlinding -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
is InvalidOnionHmac -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
is InvalidOnionKey -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
is InvalidOnionPayload -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
is InvalidOnionVersion -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
InvalidRealm -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
is IncorrectCltvExpiry -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
RequiredChannelFeatureMissing -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
RequiredNodeFeatureMissing -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
is UnknownFailureMessage -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
}
}
}
}
}
Loading

0 comments on commit 6fd2a4e

Please sign in to comment.