diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index ee4724cec..d6f3f1724 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -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" } @@ -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") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index 1f1685040..d6d9fa482 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -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 { @@ -80,7 +77,7 @@ interface OutgoingPaymentsDb { suspend fun addOutgoingLightningParts(parentId: UUID, parts: List) /** Mark an outgoing payment part as failed. */ - suspend fun completeOutgoingLightningPart(partId: UUID, failure: Either, 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()) @@ -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 } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt index ae9f574bf..77bab3048 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt @@ -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()) // @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) { - constructor(reason: FinalFailure, failures: List>, completedAt: Long = currentTimestampMillis()) : this(reason, failures.map { convertFailure(it, completedAt) }) + constructor(reason: FinalFailure, failures: List>, 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 { + 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, 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): 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) + } + } } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt index a1ac2f4d5..8c0c3d042 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt @@ -4,9 +4,9 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.Try import fr.acinq.lightning.* -import fr.acinq.lightning.channel.* -import fr.acinq.lightning.channel.states.Channel import fr.acinq.lightning.channel.ChannelAction +import fr.acinq.lightning.channel.ChannelException +import fr.acinq.lightning.channel.states.Channel import fr.acinq.lightning.channel.states.ChannelState import fr.acinq.lightning.crypto.sphinx.FailurePacket import fr.acinq.lightning.crypto.sphinx.SharedSecrets @@ -15,10 +15,14 @@ import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.db.OutgoingPaymentsDb import fr.acinq.lightning.io.PayInvoice import fr.acinq.lightning.io.WrappedChannelCommand -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.logging.mdc import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.NodeHop -import fr.acinq.lightning.utils.* +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sum import fr.acinq.lightning.wire.* class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: WalletParams, val db: OutgoingPaymentsDb) { @@ -105,7 +109,7 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle val logger = MDCLogger(logger, staticMdc = mapOf("channelId" to channelId, "childPaymentId" to add.paymentId) + payment.request.mdc()) logger.debug { "could not send HTLC: ${event.error.message}" } - db.completeOutgoingLightningPart(add.paymentId, Either.Left(event.error)) + db.completeOutgoingLightningPart(add.paymentId, OutgoingPaymentFailure.convertFailure(Either.Left(event.error))) val (updated, result) = when (payment) { is PaymentAttempt.PaymentInProgress -> { @@ -147,7 +151,7 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle } logger.debug { "HTLC failed: ${failure.message}" } - db.completeOutgoingLightningPart(event.paymentId, Either.Right(failure)) + db.completeOutgoingLightningPart(event.paymentId, OutgoingPaymentFailure.convertFailure(Either.Right(failure))) val (updated, result) = when (payment) { is PaymentAttempt.PaymentInProgress -> { @@ -219,7 +223,8 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle else -> { val logger = MDCLogger(logger, staticMdc = mapOf("childPaymentId" to partId) + payment.mdc()) logger.debug { "could not send HTLC (wallet restart): ${failure.fold({ it.message }, { it.message })}" } - db.completeOutgoingLightningPart(partId, failure) + val status = LightningOutgoingPayment.Part.Status.Failed(OutgoingPaymentFailure.convertFailure(failure)) + db.completeOutgoingLightningPart(partId, status.failure) val hasMorePendingParts = payment.parts.any { it.status == LightningOutgoingPayment.Part.Status.Pending && it.id != partId } return if (!hasMorePendingParts) { logger.warning { "payment failed: ${FinalFailure.WalletRestarted}" } @@ -235,7 +240,7 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle request = request, failure = OutgoingPaymentFailure( reason = FinalFailure.WalletRestarted, - failures = payment.parts.map { it.status }.filterIsInstance() + OutgoingPaymentFailure.convertFailure(failure) + failures = payment.parts.map { it.status }.filterIsInstance() + status ) ) } else { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/RouteCalculation.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/RouteCalculation.kt index 5523af0d5..ac4e94654 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/RouteCalculation.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/RouteCalculation.kt @@ -4,9 +4,9 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.channel.states.ChannelState -import fr.acinq.lightning.channel.states.Normal -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.channel.states.* +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.msat @@ -16,18 +16,25 @@ class RouteCalculation(loggerFactory: LoggerFactory) { data class Route(val amount: MilliSatoshi, val channel: Normal) + data class ChannelBalance(val c: Normal) { + val balance: MilliSatoshi = c.commitments.availableBalanceForSend() + val capacity: Satoshi = c.commitments.latest.fundingAmount + } + fun findRoutes(paymentId: UUID, amount: MilliSatoshi, channels: Map): Either> { val logger = MDCLogger(logger, staticMdc = mapOf("paymentId" to paymentId, "amount" to amount)) - data class ChannelBalance(val c: Normal) { - val balance: MilliSatoshi = c.commitments.availableBalanceForSend() - val capacity: Satoshi = c.commitments.latest.fundingAmount - } - val sortedChannels = channels.values.filterIsInstance().map { ChannelBalance(it) }.sortedBy { it.balance }.reversed() if (sortedChannels.isEmpty()) { - logger.warning { "no available channels" } - return Either.Left(FinalFailure.NoAvailableChannels) + val failure = when { + channels.values.any { it is Syncing || it is Offline } -> FinalFailure.ChannelNotConnected + channels.values.any { it is WaitForOpenChannel || it is WaitForAcceptChannel || it is WaitForFundingCreated || it is WaitForFundingSigned || it is WaitForFundingConfirmed || it is WaitForChannelReady } -> FinalFailure.ChannelOpening + channels.values.any { it is ShuttingDown || it is Negotiating || it is Closing || it is WaitForRemotePublishFutureCommitment } -> FinalFailure.ChannelClosing + // This may happen if adding an HTLC failed because we hit channel limits (e.g. max-accepted-htlcs) and we're retrying with this channel filtered out. + else -> FinalFailure.NoAvailableChannels + } + logger.warning { "no available channels: $failure" } + return Either.Left(failure) } val filteredChannels = sortedChannels.filter { it.balance >= it.c.channelUpdate.htlcMinimumMsat } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt index f833b27d7..b6760d2ed 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt @@ -3,13 +3,9 @@ package fr.acinq.lightning.db import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.TxId -import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.channel.ChannelException import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.OutgoingPaymentFailure import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.toByteVector32 -import fr.acinq.lightning.wire.FailureMessage class InMemoryPaymentsDb : PaymentsDb { private val incoming = mutableMapOf() @@ -106,10 +102,10 @@ class InMemoryPaymentsDb : PaymentsDb { parts.forEach { outgoingParts[it.id] = Pair(parentId, it) } } - override suspend fun completeOutgoingLightningPart(partId: UUID, failure: Either, completedAt: Long) { + override suspend fun completeOutgoingLightningPart(partId: UUID, failure: LightningOutgoingPayment.Part.Status.Failure, completedAt: Long) { require(outgoingParts.contains(partId)) { "outgoing payment part with id=$partId doesn't exist" } val (parentId, part) = outgoingParts[partId]!! - outgoingParts[partId] = Pair(parentId, part.copy(status = OutgoingPaymentFailure.convertFailure(failure, completedAt))) + outgoingParts[partId] = Pair(parentId, part.copy(status = LightningOutgoingPayment.Part.Status.Failed(failure, completedAt))) } override suspend fun completeOutgoingLightningPart(partId: UUID, preimage: ByteVector32, completedAt: Long) { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt index acbcd04e8..95f54a521 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt @@ -1,18 +1,18 @@ package fr.acinq.lightning.db -import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.Block +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey -import fr.acinq.lightning.channel.TooManyAcceptedHtlcs import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.* -import fr.acinq.lightning.wire.TemporaryNodeFailure import kotlin.test.* class PaymentsDbTestsCommon : LightningTestSuite() { @@ -209,16 +209,16 @@ class PaymentsDbTestsCommon : LightningTestSuite() { // One of the parts fails. val onePartFailed = initialPayment.copy( parts = listOf( - initialParts[0].copy(status = LightningOutgoingPayment.Part.Status.Failed(TemporaryNodeFailure.code, TemporaryNodeFailure.message, 110)), + initialParts[0].copy(status = LightningOutgoingPayment.Part.Status.Failed(LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure, 110)), initialParts[1] ) ) - db.completeOutgoingLightningPart(initialPayment.parts[0].id, Either.Right(TemporaryNodeFailure), 110) + db.completeOutgoingLightningPart(initialPayment.parts[0].id, LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure, 110) assertEquals(onePartFailed, db.getLightningOutgoingPayment(initialPayment.id)) initialPayment.parts.forEach { assertEquals(onePartFailed, db.getLightningOutgoingPaymentFromPartId(it.id)) } // We should never update non-existing parts. - assertFails { db.completeOutgoingLightningPart(UUID.randomUUID(), Either.Right(TemporaryNodeFailure)) } + assertFails { db.completeOutgoingLightningPart(UUID.randomUUID(), LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure) } assertFails { db.completeOutgoingLightningPart(UUID.randomUUID(), randomBytes32()) } // Other payment parts are added. @@ -343,21 +343,20 @@ class PaymentsDbTestsCommon : LightningTestSuite() { db.addOutgoingPayment(initialPayment) assertEquals(initialPayment, db.getLightningOutgoingPayment(initialPayment.id)) - val channelId = randomBytes32() val partsFailed = initialPayment.copy( parts = listOf( - initialParts[0].copy(status = LightningOutgoingPayment.Part.Status.Failed(TemporaryNodeFailure.code, TemporaryNodeFailure.message, 110)), - initialParts[1].copy(status = LightningOutgoingPayment.Part.Status.Failed(null, TooManyAcceptedHtlcs(channelId, 10).details(), 111)), + initialParts[0].copy(status = LightningOutgoingPayment.Part.Status.Failed(LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure, 110)), + initialParts[1].copy(status = LightningOutgoingPayment.Part.Status.Failed(LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments, 111)), ) ) - db.completeOutgoingLightningPart(initialPayment.parts[0].id, Either.Right(TemporaryNodeFailure), 110) - db.completeOutgoingLightningPart(initialPayment.parts[1].id, Either.Left(TooManyAcceptedHtlcs(channelId, 10)), 111) + db.completeOutgoingLightningPart(initialPayment.parts[0].id, LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure, 110) + db.completeOutgoingLightningPart(initialPayment.parts[1].id, LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments, 111) assertEquals(partsFailed, db.getLightningOutgoingPayment(initialPayment.id)) initialPayment.parts.forEach { assertEquals(partsFailed, db.getLightningOutgoingPaymentFromPartId(it.id)) } - val paymentFailed = partsFailed.copy(status = LightningOutgoingPayment.Status.Completed.Failed(FinalFailure.NoRouteToRecipient, 120)) - db.completeOutgoingPaymentOffchain(initialPayment.id, FinalFailure.NoRouteToRecipient, 120) - assertFails { db.completeOutgoingPaymentOffchain(UUID.randomUUID(), FinalFailure.NoRouteToRecipient, 120) } + val paymentFailed = partsFailed.copy(status = LightningOutgoingPayment.Status.Completed.Failed(FinalFailure.RetryExhausted, 120)) + db.completeOutgoingPaymentOffchain(initialPayment.id, FinalFailure.RetryExhausted, 120) + assertFails { db.completeOutgoingPaymentOffchain(UUID.randomUUID(), FinalFailure.RetryExhausted, 120) } assertEquals(paymentFailed, db.getLightningOutgoingPayment(initialPayment.id)) initialPayment.parts.forEach { assertEquals(paymentFailed, db.getLightningOutgoingPaymentFromPartId(it.id)) } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailureTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailureTestsCommon.kt index 7894da6ea..70be8d358 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailureTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailureTestsCommon.kt @@ -3,6 +3,7 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.channel.TooManyAcceptedHtlcs +import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.IncorrectOrUnknownPaymentDetails @@ -11,8 +12,8 @@ import fr.acinq.lightning.wire.TemporaryNodeFailure import fr.acinq.lightning.wire.UnknownNextPeer import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue +import kotlin.test.assertIs +import kotlin.test.assertIsNot class OutgoingPaymentFailureTestsCommon : LightningTestSuite() { @@ -26,9 +27,9 @@ class OutgoingPaymentFailureTestsCommon : LightningTestSuite() { Either.Left(TooManyAcceptedHtlcs(ByteVector32.Zeroes, 42)) ) ) - assertTrue(OutgoingPaymentFailure.isRouteError(failure.failures[0])) - assertTrue(OutgoingPaymentFailure.isRouteError(failure.failures[1])) - assertFalse(OutgoingPaymentFailure.isRouteError(failure.failures[2])) + assertIs(failure.failures[0].failure) + assertIs(failure.failures[1].failure) + assertIsNot(failure.failures[2].failure) } @Test @@ -41,9 +42,21 @@ class OutgoingPaymentFailureTestsCommon : LightningTestSuite() { Either.Right(IncorrectOrUnknownPaymentDetails(100_000.msat, 150)) ) ) - assertFalse(OutgoingPaymentFailure.isRejectedByRecipient(failure.failures[0])) - assertFalse(OutgoingPaymentFailure.isRejectedByRecipient(failure.failures[1])) - assertTrue(OutgoingPaymentFailure.isRejectedByRecipient(failure.failures[2])) + assertIsNot(failure.failures[0].failure) + assertIsNot(failure.failures[1].failure) + assertIs(failure.failures[2].failure) + } + + @Test + fun `explain failures`() { + val failure = OutgoingPaymentFailure( + FinalFailure.NoAvailableChannels, + listOf( + Either.Left(TooManyAcceptedHtlcs(ByteVector32.Zeroes, 42)), + Either.Right(PaymentTimeout), + ) + ) + assertEquals(Either.Left(LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue), failure.explain()) } @Test @@ -56,9 +69,9 @@ class OutgoingPaymentFailureTestsCommon : LightningTestSuite() { Either.Left(TooManyAcceptedHtlcs(ByteVector32.Zeroes, 42)) ) ) - val expected = "1: general temporary failure of the processing node\n" + - "2: processing node does not know the next peer in the route\n" + - "3: 0000000000000000000000000000000000000000000000000000000000000000: too many accepted htlcs: maximum=42\n" + val expected = "1: TemporaryRemoteFailure\n" + + "2: RecipientIsOffline\n" + + "3: TooManyPendingPayments\n" assertEquals(failure.details(), expected) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index 922a56e4c..15cf97a5a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -10,8 +10,6 @@ import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* -import fr.acinq.lightning.channel.ChannelAction -import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.Offline import fr.acinq.lightning.crypto.sphinx.FailurePacket @@ -77,7 +75,7 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { val outgoingPaymentHandler = OutgoingPaymentHandler(alice.staticParams.nodeParams, defaultWalletParams, InMemoryPaymentsDb()) val payment = PayInvoice(UUID.randomUUID(), 100_000.msat, LightningOutgoingPayment.Details.Normal(invoice)) val result = outgoingPaymentHandler.sendPayment(payment, mapOf(alice.channelId to Offline(alice.state)), alice.currentBlockHeight) - assertFailureEquals(result as OutgoingPaymentHandler.Failure, OutgoingPaymentHandler.Failure(payment, FinalFailure.NoAvailableChannels.toPaymentFailure())) + assertFailureEquals(result as OutgoingPaymentHandler.Failure, OutgoingPaymentHandler.Failure(payment, FinalFailure.ChannelNotConnected.toPaymentFailure())) assertNull(outgoingPaymentHandler.getPendingPayment(payment.paymentId)) val dbPayment = outgoingPaymentHandler.db.getLightningOutgoingPayment(payment.paymentId) @@ -85,7 +83,7 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(100_000.msat, dbPayment.recipientAmount) assertEquals(invoice.nodeId, dbPayment.recipient) assertTrue(dbPayment.status is LightningOutgoingPayment.Status.Completed.Failed) - assertEquals(FinalFailure.NoAvailableChannels, (dbPayment.status as LightningOutgoingPayment.Status.Completed.Failed).reason) + assertEquals(FinalFailure.ChannelNotConnected, (dbPayment.status as LightningOutgoingPayment.Status.Completed.Failed).reason) assertTrue(dbPayment.parts.isEmpty()) } @@ -629,22 +627,23 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { ) val outgoingPaymentHandler = OutgoingPaymentHandler(TestConstants.Alice.nodeParams, walletParams, InMemoryPaymentsDb()) val invoice = makeInvoice(amount = null, supportsTrampoline = true) - val payment = PayInvoice(UUID.randomUUID(), 550_000.msat, LightningOutgoingPayment.Details.Normal(invoice)) + val payment = PayInvoice(UUID.randomUUID(), 650_000.msat, LightningOutgoingPayment.Details.Normal(invoice)) val progress1 = outgoingPaymentHandler.sendPayment(payment, channels, TestConstants.defaultBlockHeight) as OutgoingPaymentHandler.Progress val adds1 = filterAddHtlcCommands(progress1) - assertEquals(3, adds1.size) - assertEquals(560_000.msat, adds1.map { it.second.amount }.sum()) + assertEquals(4, adds1.size) + assertEquals(660_000.msat, adds1.map { it.second.amount }.sum()) val attempt = outgoingPaymentHandler.getPendingPayment(payment.paymentId)!! assertNull(outgoingPaymentHandler.processAddSettled(adds1[0].first, createRemoteFailure(adds1[0].second, attempt, TrampolineFeeInsufficient), channels, TestConstants.defaultBlockHeight)) assertNull(outgoingPaymentHandler.processAddSettled(adds1[1].first, createRemoteFailure(adds1[1].second, attempt, TrampolineFeeInsufficient), channels, TestConstants.defaultBlockHeight)) - val fail = outgoingPaymentHandler.processAddSettled(adds1[2].first, createRemoteFailure(adds1[2].second, attempt, TrampolineFeeInsufficient), channels, TestConstants.defaultBlockHeight) as OutgoingPaymentHandler.Failure + assertNull(outgoingPaymentHandler.processAddSettled(adds1[2].first, createRemoteFailure(adds1[2].second, attempt, TrampolineFeeInsufficient), channels, TestConstants.defaultBlockHeight)) + val fail = outgoingPaymentHandler.processAddSettled(adds1[3].first, createRemoteFailure(adds1[3].second, attempt, TrampolineFeeInsufficient), channels, TestConstants.defaultBlockHeight) as OutgoingPaymentHandler.Failure val expected = OutgoingPaymentHandler.Failure(payment, OutgoingPaymentFailure(FinalFailure.InsufficientBalance, listOf(Either.Right(TrampolineFeeInsufficient)))) assertFailureEquals(expected, fail) assertNull(outgoingPaymentHandler.getPendingPayment(payment.paymentId)) - assertDbPaymentFailed(outgoingPaymentHandler.db, payment.paymentId, 3) + assertDbPaymentFailed(outgoingPaymentHandler.db, payment.paymentId, 4) } @Test @@ -721,6 +720,7 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { { channelId: ByteVector32 -> TooManyAcceptedHtlcs(channelId, 15) }, { channelId: ByteVector32 -> InsufficientFunds(channelId, 5_000.msat, 1.sat, 20.sat, 1.sat) }, { channelId: ByteVector32 -> HtlcValueTooHighInFlight(channelId, 150_000U, 155_000.msat) }, + { channelId: ByteVector32 -> ForbiddenDuringSplice(channelId, "update-add-htlc") } ) localFailures.forEach { localFailure -> val (channelId, add) = filterAddHtlcCommands(progress).first() @@ -732,10 +732,8 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { val (channelId, add) = filterAddHtlcCommands(progress).first() val fail = outgoingPaymentHandler.processAddFailed(channelId, ChannelAction.ProcessCmdRes.AddFailed(add, TooManyAcceptedHtlcs(channelId, 15), null), channels) as OutgoingPaymentHandler.Failure assertEquals(FinalFailure.InsufficientBalance, fail.failure.reason) - assertEquals(4, fail.failure.failures.filter { it.isLocalFailure() }.size) - assertNull(outgoingPaymentHandler.getPendingPayment(payment.paymentId)) - assertDbPaymentFailed(outgoingPaymentHandler.db, payment.paymentId, 4) + assertDbPaymentFailed(outgoingPaymentHandler.db, payment.paymentId, 5) } @Test @@ -935,6 +933,7 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { Pair(ShortChannelId(3), 0.msat), Pair(ShortChannelId(4), 10_000.msat), Pair(ShortChannelId(5), 200_000.msat), + Pair(ShortChannelId(6), 100_000.msat), ) return channelDetails.associate { val channelId = randomBytes32() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/RouteCalculationTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/RouteCalculationTestsCommon.kt index 6b2183e53..e04098ea0 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/RouteCalculationTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/RouteCalculationTestsCommon.kt @@ -64,7 +64,7 @@ class RouteCalculationTestsCommon : LightningTestSuite() { channelId2 to Syncing(makeChannel(channelId2, 20_000.msat, 5.msat), channelReestablishSent = true), channelId3 to Offline(makeChannel(channelId3, 10_000.msat, 10.msat)), ) - assertEquals(Either.Left(FinalFailure.NoAvailableChannels), routeCalculation.findRoutes(paymentId, 5_000.msat, channels)) + assertEquals(Either.Left(FinalFailure.ChannelNotConnected), routeCalculation.findRoutes(paymentId, 5_000.msat, channels)) } @Test