Skip to content

Commit

Permalink
Add support for trampoline failures
Browse files Browse the repository at this point in the history
Add support for the trampoline failure messages added to the BOLTs.
We also add supports for encrypting failure e2e using the trampoline
shared secrets on top of the outer onion shared secrets.

This is a work-in-progress: the basic mechanism works, but it needs
some clean-up / refactoring.
  • Loading branch information
t-bast committed Aug 9, 2024
1 parent a5e3e16 commit 2a1e84d
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,21 @@ data class OutgoingPaymentFailure(val reason: FinalFailure, val failures: List<L
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 TrampolineFeeOrExpiryInsufficient -> 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
UnknownNextTrampoline -> 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
TemporaryTrampolineFailure -> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ import fr.acinq.lightning.logging.mdc
import fr.acinq.lightning.router.NodeHop
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.wire.FailureMessage
import fr.acinq.lightning.wire.TrampolineExpiryTooSoon
import fr.acinq.lightning.wire.TrampolineFeeInsufficient
import fr.acinq.lightning.wire.UnknownNextPeer
import fr.acinq.lightning.wire.*

class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: WalletParams, val db: OutgoingPaymentsDb) {

Expand Down Expand Up @@ -168,8 +165,9 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
val trampolineFees = payment.request.trampolineFeesOverride ?: walletParams.trampolineFees
val finalError = when {
trampolineFees.size <= payment.attemptNumber + 1 -> FinalFailure.RetryExhausted
failure == Either.Right(UnknownNextPeer) -> FinalFailure.RecipientUnreachable
failure != Either.Right(TrampolineExpiryTooSoon) && failure != Either.Right(TrampolineFeeInsufficient) -> FinalFailure.UnknownError // non-retriable error
failure == Either.Right(UnknownNextPeer) || failure == Either.Right(UnknownNextTrampoline) -> FinalFailure.RecipientUnreachable
// TODO: take actual fees returned into account (rework the trampoline fees mechanism).
failure != Either.Right(TemporaryTrampolineFailure) && failure.right !is TrampolineFeeOrExpiryInsufficient -> FinalFailure.UnknownError // non-retriable error
else -> null
}
return if (finalError != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package fr.acinq.lightning.payment

import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.flatMap
import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.Feature
import fr.acinq.lightning.Lightning
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.crypto.sphinx.FailurePacket
import fr.acinq.lightning.crypto.sphinx.PacketAndSecrets
import fr.acinq.lightning.crypto.sphinx.SharedSecrets
import fr.acinq.lightning.crypto.sphinx.Sphinx
import fr.acinq.lightning.router.NodeHop
import fr.acinq.lightning.wire.*
Expand Down Expand Up @@ -53,7 +55,9 @@ object OutgoingPaymentPacket {
val trampolinePaymentSecret = Lightning.randomBytes32()
val payload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineAmount, trampolineExpiry, trampolinePaymentSecret, trampolineOnion.packet)
val paymentOnion = buildOnion(listOf(hop.nodeId), listOf(payload), invoice.paymentHash, OnionRoutingPacket.PaymentPacketLength)
return Triple(trampolineAmount, trampolineExpiry, paymentOnion)
// We merge the shared secrets from each onion to allow decrypting failure onions.
val sharedSecrets = SharedSecrets(paymentOnion.sharedSecrets.perHopSecrets + trampolineOnion.sharedSecrets.perHopSecrets)
return Triple(trampolineAmount, trampolineExpiry, paymentOnion.copy(sharedSecrets = sharedSecrets))
}

/**
Expand Down Expand Up @@ -162,16 +166,16 @@ object OutgoingPaymentPacket {
}

fun buildHtlcFailure(nodeSecret: PrivateKey, paymentHash: ByteVector32, onion: OnionRoutingPacket, reason: ChannelCommand.Htlc.Settlement.Fail.Reason): Either<FailureMessage, ByteVector> {
// we need to decrypt the payment onion to obtain the shared secret to build the error packet
return when (val result = Sphinx.peel(nodeSecret, paymentHash, onion)) {
is Either.Right -> {
val encryptedReason = when (reason) {
is ChannelCommand.Htlc.Settlement.Fail.Reason.Bytes -> FailurePacket.wrap(reason.bytes.toByteArray(), result.value.sharedSecret)
is ChannelCommand.Htlc.Settlement.Fail.Reason.Failure -> FailurePacket.create(result.value.sharedSecret, reason.message)
}
Either.Right(ByteVector(encryptedReason))
return extractSharedSecrets(nodeSecret, paymentHash, onion).map { sharedSecrets ->
val encryptedReason = when (reason) {
is ChannelCommand.Htlc.Settlement.Fail.Reason.Bytes -> FailurePacket.wrap(reason.bytes.toByteArray(), sharedSecrets.first())
is ChannelCommand.Htlc.Settlement.Fail.Reason.Failure -> FailurePacket.create(sharedSecrets.first(), reason.message)
}
if (sharedSecrets.size == 2) {
ByteVector(FailurePacket.wrap(encryptedReason, sharedSecrets.last()))
} else {
ByteVector(encryptedReason)
}
is Either.Left -> Either.Left(result.value)
}
}

Expand All @@ -183,4 +187,15 @@ object OutgoingPaymentPacket {
}
}

private fun extractSharedSecrets(nodeSecret: PrivateKey, paymentHash: ByteVector32, onion: OnionRoutingPacket): Either<FailureMessage, List<ByteVector32>> {
// We decrypt the payment onion to obtain the shared secret.
return Sphinx.peel(nodeSecret, paymentHash, onion).flatMap { outer ->
// If it contains a trampoline onion, we decrypt it as well to obtain the shared secret.
when (val trampolineOnion = PaymentOnion.PerHopPayload.read(outer.payload.toByteArray()).map { it.get<OnionPaymentPayloadTlv.TrampolineOnion>() }.right) {
null -> Either.Right(listOf(outer.sharedSecret))
else -> Sphinx.peel(nodeSecret, paymentHash, trampolineOnion.packet).map { listOf(it.sharedSecret, outer.sharedSecret) }
}
}
}

}
36 changes: 24 additions & 12 deletions src/commonMain/kotlin/fr/acinq/lightning/wire/FailureMessage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.io.ByteArrayInput
import fr.acinq.bitcoin.io.ByteArrayOutput
import fr.acinq.bitcoin.io.Output
import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.CltvExpiryDelta
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.utils.toByteVector32

Expand Down Expand Up @@ -41,10 +42,8 @@ sealed class FailureMessage {
UnknownNextPeer.code -> UnknownNextPeer
AmountBelowMinimum.code -> AmountBelowMinimum(MilliSatoshi(LightningCodecs.u64(stream)), readChannelUpdate(stream))
FeeInsufficient.code -> FeeInsufficient(MilliSatoshi(LightningCodecs.u64(stream)), readChannelUpdate(stream))
TrampolineFeeInsufficient.code -> TrampolineFeeInsufficient
IncorrectCltvExpiry.code -> IncorrectCltvExpiry(CltvExpiry(LightningCodecs.u32(stream).toLong()), readChannelUpdate(stream))
ExpiryTooSoon.code -> ExpiryTooSoon(readChannelUpdate(stream))
TrampolineExpiryTooSoon.code -> TrampolineExpiryTooSoon
IncorrectOrUnknownPaymentDetails.code -> {
val amount = if (stream.availableBytes > 0) MilliSatoshi(LightningCodecs.u64(stream)) else MilliSatoshi(0)
val blockHeight = if (stream.availableBytes > 0) LightningCodecs.u32(stream).toLong() else 0L
Expand All @@ -56,6 +55,9 @@ sealed class FailureMessage {
ExpiryTooFar.code -> ExpiryTooFar
InvalidOnionPayload.code -> InvalidOnionPayload(LightningCodecs.bigSize(stream), LightningCodecs.u16(stream))
PaymentTimeout.code -> PaymentTimeout
TemporaryTrampolineFailure.code -> TemporaryTrampolineFailure
TrampolineFeeOrExpiryInsufficient.code -> TrampolineFeeOrExpiryInsufficient(MilliSatoshi(LightningCodecs.u32(stream).toLong()), LightningCodecs.u32(stream), CltvExpiryDelta(LightningCodecs.u16(stream)))
UnknownNextTrampoline.code -> UnknownNextTrampoline
else -> UnknownFailureMessage(code)
}
}
Expand Down Expand Up @@ -90,13 +92,11 @@ sealed class FailureMessage {
LightningCodecs.writeU64(input.amount.toLong(), out)
writeChannelUpdate(input.update, out)
}
TrampolineFeeInsufficient -> {}
is IncorrectCltvExpiry -> {
LightningCodecs.writeU32(input.expiry.toLong().toInt(), out)
writeChannelUpdate(input.update, out)
}
is ExpiryTooSoon -> writeChannelUpdate(input.update, out)
TrampolineExpiryTooSoon -> {}
is IncorrectOrUnknownPaymentDetails -> {
LightningCodecs.writeU64(input.amount.toLong(), out)
LightningCodecs.writeU32(input.height.toInt(), out)
Expand All @@ -114,6 +114,13 @@ sealed class FailureMessage {
LightningCodecs.writeU16(input.offset, out)
}
PaymentTimeout -> {}
TemporaryTrampolineFailure -> {}
is TrampolineFeeOrExpiryInsufficient -> {
LightningCodecs.writeU32(input.feeBase.toLong().toInt(), out)
LightningCodecs.writeU32(input.feeProportionalMillionths, out)
LightningCodecs.writeU16(input.expiryDelta.toInt(), out)
}
UnknownNextTrampoline -> {}
is UnknownFailureMessage -> {}
}
}
Expand Down Expand Up @@ -195,10 +202,6 @@ data class FeeInsufficient(val amount: MilliSatoshi, override val update: Channe
override val message get() = "payment fee was below the minimum required by the channel"
companion object { const val code = UPDATE or 12 }
}
object TrampolineFeeInsufficient : FailureMessage(), Node {
override val code get() = NODE or 51
override val message get() = "payment fee was below the minimum required by the trampoline node"
}
data class IncorrectCltvExpiry(val expiry: CltvExpiry, override val update: ChannelUpdate) : FailureMessage(), Update {
override val code get() = IncorrectCltvExpiry.code
override val message get() = "payment expiry doesn't match the value in the onion"
Expand All @@ -209,10 +212,6 @@ data class ExpiryTooSoon(override val update: ChannelUpdate) : FailureMessage(),
override val message get() = "payment expiry is too close to the current block height for safe handling by the relaying node"
companion object { const val code = UPDATE or 14 }
}
object TrampolineExpiryTooSoon : FailureMessage(), Node {
override val code get() = NODE or 52
override val message get() = "payment expiry is too close to the current block height for safe handling by the relaying node"
}
data class IncorrectOrUnknownPaymentDetails(val amount: MilliSatoshi, val height: Long) : FailureMessage(), Perm {
override val code get() = IncorrectOrUnknownPaymentDetails.code
override val message get() = "incorrect payment details or unknown payment hash"
Expand Down Expand Up @@ -246,6 +245,19 @@ data object PaymentTimeout : FailureMessage() {
override val code get() = 23
override val message get() = "the complete payment amount was not received within a reasonable time"
}
data object TemporaryTrampolineFailure : FailureMessage(), Node {
override val code get() = NODE or 25
override val message get() = "the trampoline node was unable to relay the payment because of downstream temporary failures"
}
data class TrampolineFeeOrExpiryInsufficient(val feeBase: MilliSatoshi, val feeProportionalMillionths: Int, val expiryDelta: CltvExpiryDelta) : FailureMessage(), Node {
override val code get() = TrampolineFeeOrExpiryInsufficient.code
override val message get() = "trampoline fees or expiry are insufficient to relay the payment"
companion object { const val code = NODE or 26 }
}
data object UnknownNextTrampoline : FailureMessage(), Perm {
override val code get() = PERM or 27
override val message get() = "the trampoline node was unable to find the next trampoline node"
}
/**
* We allow remote nodes to send us unknown failure codes (e.g. deprecated failure codes).
* By reading the PERM and NODE bits of the failure code we can still extract useful information for payment retry even
Expand Down
Loading

0 comments on commit 2a1e84d

Please sign in to comment.