Skip to content

Commit c43d447

Browse files
committed
Add support for trampoline failures
Add support for the trampoline failure messages added to the BOLTs. We also add support for encrypting failures e2e using the trampoline shared secrets on top of the outer onion shared secrets.
1 parent 7a9369d commit c43d447

File tree

9 files changed

+306
-95
lines changed

9 files changed

+306
-95
lines changed

src/commonMain/kotlin/fr/acinq/lightning/crypto/sphinx/Sphinx.kt

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import fr.acinq.bitcoin.utils.Either
1010
import fr.acinq.bitcoin.utils.Try
1111
import fr.acinq.bitcoin.utils.runTrying
1212
import fr.acinq.lightning.crypto.ChaCha20
13-
import fr.acinq.lightning.utils.*
13+
import fr.acinq.lightning.utils.toByteVector
14+
import fr.acinq.lightning.utils.toByteVector32
15+
import fr.acinq.lightning.utils.xor
1416
import fr.acinq.lightning.wire.*
1517
import fr.acinq.secp256k1.Hex
1618

@@ -343,27 +345,22 @@ object FailurePacket {
343345
* it was sent by the corresponding node.
344346
* Note that malicious nodes in the route may have altered the packet, triggering a decryption failure.
345347
*
346-
* @param packet failure packet.
348+
* @param packet failure packet.
347349
* @param sharedSecrets nodes shared secrets.
348-
* @return Success(secret, failure message) if the origin of the packet could be identified and the packet
349-
* decrypted, Failure otherwise.
350+
* @return the decrypted failure message and the failing node if the packet can be decrypted.
350351
*/
351-
fun decrypt(packet: ByteArray, sharedSecrets: SharedSecrets): Try<DecryptedFailurePacket> {
352-
fun loop(packet: ByteArray, secrets: List<Pair<ByteVector32, PublicKey>>): Try<DecryptedFailurePacket> {
353-
return if (secrets.isEmpty()) {
354-
val ex = IllegalArgumentException("couldn't parse error packet=$packet with sharedSecrets=$secrets")
355-
Try.Failure(ex)
356-
} else {
357-
val (secret, pubkey) = secrets.first()
358-
val packet1 = wrap(packet, secret)
359-
val um = Sphinx.generateKey("um", secret)
360-
when (val error = decode(packet1, um)) {
361-
is Try.Failure -> loop(packet1, secrets.tail())
362-
is Try.Success -> Try.Success(DecryptedFailurePacket(pubkey, error.result))
363-
}
352+
fun decrypt(packet: ByteArray, sharedSecrets: List<Pair<ByteVector32, PublicKey>>): Try<DecryptedFailurePacket> {
353+
return if (sharedSecrets.isEmpty()) {
354+
val ex = IllegalArgumentException("couldn't parse error packet=$packet with sharedSecrets=$sharedSecrets")
355+
Try.Failure(ex)
356+
} else {
357+
val (secret, pubkey) = sharedSecrets.first()
358+
val packet1 = wrap(packet, secret)
359+
val um = Sphinx.generateKey("um", secret)
360+
when (val error = decode(packet1, um)) {
361+
is Try.Failure -> decrypt(packet1, sharedSecrets.tail())
362+
is Try.Success -> Try.Success(DecryptedFailurePacket(pubkey, error.result))
364363
}
365364
}
366-
367-
return loop(packet, sharedSecrets.perHopSecrets)
368365
}
369366
}

src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ sealed class FinalFailure {
2626
data object NoAvailableChannels : FinalFailure() { override fun toString(): String = "payment could not be sent through existing channels, check individual failures for more details" }
2727
data object InsufficientBalance : FinalFailure() { override fun toString(): String = "not enough funds in wallet to afford payment" }
2828
data object RecipientUnreachable : FinalFailure() { override fun toString(): String = "the recipient was offline or did not have enough liquidity to receive the payment" }
29+
data object RecipientRejectedPayment : FinalFailure() { override fun toString(): String = "the recipient rejected the payment" }
2930
data object RetryExhausted: FinalFailure() { override fun toString(): String = "payment attempts exhausted without success" }
3031
data object WalletRestarted: FinalFailure() { override fun toString(): String = "wallet restarted while a payment was ongoing" }
3132
data object UnknownError : FinalFailure() { override fun toString(): String = "an unknown error occurred" }
@@ -78,20 +79,21 @@ data class OutgoingPaymentFailure(val reason: FinalFailure, val failures: List<L
7879
is Either.Right -> when (failure.value) {
7980
is AmountBelowMinimum -> LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall
8081
is FeeInsufficient -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees
81-
TrampolineExpiryTooSoon -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees
82-
TrampolineFeeInsufficient -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees
82+
is TrampolineFeeOrExpiryInsufficient -> LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees
8383
is FinalIncorrectCltvExpiry -> LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment
8484
is FinalIncorrectHtlcAmount -> LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment
8585
is IncorrectOrUnknownPaymentDetails -> LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment
8686
PaymentTimeout -> LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue
8787
UnknownNextPeer -> LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline
88+
UnknownNextTrampoline -> LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline
8889
is ExpiryTooSoon -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
8990
ExpiryTooFar -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
9091
is ChannelDisabled -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
9192
is TemporaryChannelFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
9293
TemporaryNodeFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
9394
PermanentChannelFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
9495
PermanentNodeFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
96+
TemporaryTrampolineFailure -> LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure
9597
is InvalidOnionBlinding -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
9698
is InvalidOnionHmac -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)
9799
is InvalidOnionKey -> LightningOutgoingPayment.Part.Status.Failure.Uninterpretable(failure.value.message)

src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import fr.acinq.lightning.*
77
import fr.acinq.lightning.channel.*
88
import fr.acinq.lightning.channel.states.*
99
import fr.acinq.lightning.crypto.sphinx.FailurePacket
10-
import fr.acinq.lightning.crypto.sphinx.PacketAndSecrets
11-
import fr.acinq.lightning.crypto.sphinx.SharedSecrets
1210
import fr.acinq.lightning.db.HopDesc
1311
import fr.acinq.lightning.db.LightningOutgoingPayment
1412
import fr.acinq.lightning.db.OutgoingPaymentsDb
@@ -20,10 +18,7 @@ import fr.acinq.lightning.logging.mdc
2018
import fr.acinq.lightning.router.NodeHop
2119
import fr.acinq.lightning.utils.UUID
2220
import fr.acinq.lightning.utils.msat
23-
import fr.acinq.lightning.wire.FailureMessage
24-
import fr.acinq.lightning.wire.TrampolineExpiryTooSoon
25-
import fr.acinq.lightning.wire.TrampolineFeeInsufficient
26-
import fr.acinq.lightning.wire.UnknownNextPeer
21+
import fr.acinq.lightning.wire.*
2722

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

@@ -53,14 +48,14 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
5348
* @param request payment request containing the total amount to send.
5449
* @param attemptNumber number of failed previous payment attempts.
5550
* @param pending pending outgoing payment.
56-
* @param sharedSecrets payment onion shared secrets, used to decrypt failures.
51+
* @param outgoing payment packet containing the shared secrets used to decrypt failures.
5752
* @param failures previous payment failures.
5853
*/
5954
data class PaymentAttempt(
6055
val request: PayInvoice,
6156
val attemptNumber: Int,
6257
val pending: LightningOutgoingPayment.Part,
63-
val sharedSecrets: SharedSecrets,
58+
val outgoing: OutgoingPacket,
6459
val failures: List<Either<ChannelException, FailureMessage>>
6560
) {
6661
val fees: MilliSatoshi = pending.amount - request.amount
@@ -73,9 +68,18 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
7368

7469
private suspend fun sendPaymentInternal(request: PayInvoice, failures: List<Either<ChannelException, FailureMessage>>, channels: Map<ByteVector32, ChannelState>, currentBlockHeight: Int, logger: MDCLogger): Either<Failure, Progress> {
7570
val attemptNumber = failures.size
76-
val trampolineFees = (request.trampolineFeesOverride ?: walletParams.trampolineFees)[attemptNumber]
77-
logger.info { "trying payment with fee_base=${trampolineFees.feeBase}, fee_proportional=${trampolineFees.feeProportional}" }
78-
val trampolineAmount = request.amount + trampolineFees.calculateFees(request.amount)
71+
val trampolineFees = (request.trampolineFeesOverride ?: walletParams.trampolineFees)
72+
val nextFees = when (val f = failures.lastOrNull()?.right) {
73+
is TrampolineFeeOrExpiryInsufficient -> {
74+
// The trampoline node is asking us to retry the payment with more fees or a larger expiry delta.
75+
val requestedFee = Lightning.nodeFee(f.feeBase, f.feeProportionalMillionths.toLong(), request.amount)
76+
val nextFees = trampolineFees.drop(attemptNumber).firstOrNull { it.calculateFees(request.amount) >= requestedFee } ?: trampolineFees[attemptNumber]
77+
nextFees.copy(cltvExpiryDelta = maxOf(nextFees.cltvExpiryDelta, f.expiryDelta))
78+
}
79+
else -> trampolineFees[attemptNumber]
80+
}
81+
logger.info { "trying payment with fee_base=${nextFees.feeBase}, fee_proportional=${nextFees.feeProportional}" }
82+
val trampolineAmount = request.amount + nextFees.calculateFees(request.amount)
7983
return when (val result = selectChannel(trampolineAmount, channels)) {
8084
is Either.Left -> {
8185
logger.warning { "payment failed: ${result.value}" }
@@ -87,14 +91,14 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
8791
Either.Left(Failure(request, OutgoingPaymentFailure(result.value, failures)))
8892
}
8993
is Either.Right -> {
90-
val hop = NodeHop(walletParams.trampolineNode.id, request.recipient, trampolineFees.cltvExpiryDelta, trampolineFees.calculateFees(request.amount))
91-
val (childPayment, sharedSecrets, cmd) = createOutgoingPayment(request, result.value, hop, currentBlockHeight)
94+
val hop = NodeHop(walletParams.trampolineNode.id, request.recipient, nextFees.cltvExpiryDelta, nextFees.calculateFees(request.amount))
95+
val (childPayment, packet, cmd) = createOutgoingPayment(request, result.value, hop, currentBlockHeight)
9296
if (attemptNumber == 0) {
9397
db.addOutgoingPayment(LightningOutgoingPayment(request.paymentId, request.amount, request.recipient, request.paymentDetails, listOf(childPayment), LightningOutgoingPayment.Status.Pending))
9498
} else {
9599
db.addOutgoingLightningParts(request.paymentId, listOf(childPayment))
96100
}
97-
val payment = PaymentAttempt(request, attemptNumber, childPayment, sharedSecrets, failures)
101+
val payment = PaymentAttempt(request, attemptNumber, childPayment, packet, failures)
98102
pending[request.paymentId] = payment
99103
Either.Right(Progress(request, payment.fees, listOf(cmd)))
100104
}
@@ -154,8 +158,10 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
154158
return null
155159
}
156160

161+
// We try decrypting with the payment onion hops first, and then iterate over the trampoline hops if necessary.
162+
val sharedSecrets = payment.outgoing.outerSharedSecrets.perHopSecrets + payment.outgoing.innerSharedSecrets.perHopSecrets
157163
val failure = when (event.result) {
158-
is ChannelAction.HtlcResult.Fail.RemoteFail -> when (val decrypted = FailurePacket.decrypt(event.result.fail.reason.toByteArray(), payment.sharedSecrets)) {
164+
is ChannelAction.HtlcResult.Fail.RemoteFail -> when (val decrypted = FailurePacket.decrypt(event.result.fail.reason.toByteArray(), sharedSecrets)) {
159165
is Try.Failure -> {
160166
logger.warning { "could not decrypt failure packet: ${decrypted.error.message}" }
161167
Either.Left(CannotDecryptFailure(channelId, decrypted.error.message ?: "unknown"))
@@ -185,8 +191,9 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
185191
val trampolineFees = payment.request.trampolineFeesOverride ?: walletParams.trampolineFees
186192
val finalError = when {
187193
trampolineFees.size <= payment.attemptNumber + 1 -> FinalFailure.RetryExhausted
188-
failure == Either.Right(UnknownNextPeer) -> FinalFailure.RecipientUnreachable
189-
failure != Either.Right(TrampolineExpiryTooSoon) && failure != Either.Right(TrampolineFeeInsufficient) -> FinalFailure.UnknownError // non-retriable error
194+
failure == Either.Right(UnknownNextPeer) || failure == Either.Right(UnknownNextTrampoline) -> FinalFailure.RecipientUnreachable
195+
failure.right is IncorrectOrUnknownPaymentDetails || failure.right is FinalIncorrectCltvExpiry || failure.right is FinalIncorrectHtlcAmount -> FinalFailure.RecipientRejectedPayment
196+
failure != Either.Right(TemporaryTrampolineFailure) && failure.right !is TrampolineFeeOrExpiryInsufficient && failure != Either.Right(PaymentTimeout) -> FinalFailure.UnknownError // non-retriable error
190197
else -> null
191198
}
192199
return if (finalError != null) {
@@ -290,7 +297,7 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
290297
}
291298
}
292299

293-
private fun createOutgoingPayment(request: PayInvoice, channel: Normal, hop: NodeHop, currentBlockHeight: Int): Triple<LightningOutgoingPayment.Part, SharedSecrets, WrappedChannelCommand> {
300+
private fun createOutgoingPayment(request: PayInvoice, channel: Normal, hop: NodeHop, currentBlockHeight: Int): Triple<LightningOutgoingPayment.Part, OutgoingPacket, WrappedChannelCommand> {
294301
val logger = MDCLogger(logger, staticMdc = request.mdc())
295302
val childId = UUID.randomUUID()
296303
childToPaymentId[childId] = request.paymentId
@@ -303,10 +310,10 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
303310
)
304311
logger.info { "sending $amount to channel ${channel.shortChannelId}" }
305312
val add = ChannelCommand.Htlc.Add(amount, request.paymentHash, expiry, onion.packet, paymentId = childId, commit = true)
306-
return Triple(outgoingPayment, onion.sharedSecrets, WrappedChannelCommand(channel.channelId, add))
313+
return Triple(outgoingPayment, onion, WrappedChannelCommand(channel.channelId, add))
307314
}
308315

309-
private fun createPaymentOnion(request: PayInvoice, hop: NodeHop, currentBlockHeight: Int): Triple<MilliSatoshi, CltvExpiry, PacketAndSecrets> {
316+
private fun createPaymentOnion(request: PayInvoice, hop: NodeHop, currentBlockHeight: Int): Triple<MilliSatoshi, CltvExpiry, OutgoingPacket> {
310317
return when (val paymentRequest = request.paymentDetails.paymentRequest) {
311318
is Bolt11Invoice -> {
312319
val minFinalExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA

0 commit comments

Comments
 (0)