Skip to content

Commit b5764b0

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 5c6fd67 commit b5764b0

File tree

9 files changed

+296
-95
lines changed

9 files changed

+296
-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: 25 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

@@ -51,14 +46,14 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
5146
* @param request payment request containing the total amount to send.
5247
* @param attemptNumber number of failed previous payment attempts.
5348
* @param pending pending outgoing payment.
54-
* @param sharedSecrets payment onion shared secrets, used to decrypt failures.
49+
* @param outgoing payment packet containing the shared secrets used to decrypt failures.
5550
* @param failures previous payment failures.
5651
*/
5752
data class PaymentAttempt(
5853
val request: PayInvoice,
5954
val attemptNumber: Int,
6055
val pending: LightningOutgoingPayment.Part,
61-
val sharedSecrets: SharedSecrets,
56+
val outgoing: OutgoingPacket,
6257
val failures: List<Either<ChannelException, FailureMessage>>
6358
) {
6459
val fees: MilliSatoshi = pending.amount - request.amount
@@ -99,8 +94,8 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
9994
}
10095
is Either.Right -> {
10196
val hop = NodeHop(walletParams.trampolineNode.id, request.recipient, trampolineFees.cltvExpiryDelta, trampolineFees.calculateFees(request.amount))
102-
val (childPayment, sharedSecrets, cmd) = createOutgoingPayment(request, result.value, hop, currentBlockHeight)
103-
val payment = PaymentAttempt(request, 0, childPayment, sharedSecrets, listOf())
97+
val (childPayment, packet, cmd) = createOutgoingPayment(request, result.value, hop, currentBlockHeight)
98+
val payment = PaymentAttempt(request, 0, childPayment, packet, listOf())
10499
db.addOutgoingPayment(LightningOutgoingPayment(request.paymentId, request.amount, request.recipient, request.paymentDetails, listOf(childPayment), LightningOutgoingPayment.Status.Pending))
105100
pending[request.paymentId] = payment
106101
Progress(request, payment.fees, listOf(cmd))
@@ -137,8 +132,10 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
137132
return null
138133
}
139134

135+
// We try decrypting with the payment onion hops first, and then iterate over the trampoline hops if necessary.
136+
val sharedSecrets = payment.outgoing.outerSharedSecrets.perHopSecrets + payment.outgoing.innerSharedSecrets.perHopSecrets
140137
val failure = when (event.result) {
141-
is ChannelAction.HtlcResult.Fail.RemoteFail -> when (val decrypted = FailurePacket.decrypt(event.result.fail.reason.toByteArray(), payment.sharedSecrets)) {
138+
is ChannelAction.HtlcResult.Fail.RemoteFail -> when (val decrypted = FailurePacket.decrypt(event.result.fail.reason.toByteArray(), sharedSecrets)) {
142139
is Try.Failure -> {
143140
logger.warning { "could not decrypt failure packet: ${decrypted.error.message}" }
144141
Either.Left(CannotDecryptFailure(channelId, decrypted.error.message ?: "unknown"))
@@ -168,17 +165,25 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
168165
val trampolineFees = payment.request.trampolineFeesOverride ?: walletParams.trampolineFees
169166
val finalError = when {
170167
trampolineFees.size <= payment.attemptNumber + 1 -> FinalFailure.RetryExhausted
171-
failure == Either.Right(UnknownNextPeer) -> FinalFailure.RecipientUnreachable
172-
failure != Either.Right(TrampolineExpiryTooSoon) && failure != Either.Right(TrampolineFeeInsufficient) -> FinalFailure.UnknownError // non-retriable error
168+
failure == Either.Right(UnknownNextPeer) || failure == Either.Right(UnknownNextTrampoline) -> FinalFailure.RecipientUnreachable
169+
failure.right is IncorrectOrUnknownPaymentDetails || failure.right is FinalIncorrectCltvExpiry || failure.right is FinalIncorrectHtlcAmount -> FinalFailure.RecipientRejectedPayment
170+
failure != Either.Right(TemporaryTrampolineFailure) && failure.right !is TrampolineFeeOrExpiryInsufficient && failure != Either.Right(PaymentTimeout) -> FinalFailure.UnknownError // non-retriable error
173171
else -> null
174172
}
175173
return if (finalError != null) {
176174
db.completeOutgoingPaymentOffchain(payment.request.paymentId, finalError)
177175
removeFromState(payment.request.paymentId)
178176
Failure(payment.request, OutgoingPaymentFailure(finalError, payment.failures + failure))
179177
} else {
180-
// The trampoline node is asking us to retry the payment with more fees or a larger expiry delta.
181-
val nextFees = trampolineFees[payment.attemptNumber + 1]
178+
val nextFees = when (val f = failure.right) {
179+
is TrampolineFeeOrExpiryInsufficient -> {
180+
// The trampoline node is asking us to retry the payment with more fees or a larger expiry delta.
181+
val requestedFee = Lightning.nodeFee(f.feeBase, f.feeProportionalMillionths.toLong(), payment.request.amount)
182+
val nextFees = trampolineFees.drop(payment.attemptNumber + 1).firstOrNull { it.calculateFees(payment.request.amount) >= requestedFee } ?: trampolineFees[payment.attemptNumber + 1]
183+
nextFees.copy(cltvExpiryDelta = maxOf(nextFees.cltvExpiryDelta, f.expiryDelta))
184+
}
185+
else -> trampolineFees[payment.attemptNumber + 1]
186+
}
182187
logger.info { "retrying payment with higher fees (base=${nextFees.feeBase}, proportional=${nextFees.feeProportional})..." }
183188
val trampolineAmount = payment.request.amount + nextFees.calculateFees(payment.request.amount)
184189
when (val result = selectChannel(trampolineAmount, channels)) {
@@ -190,13 +195,13 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
190195
}
191196
is Either.Right -> {
192197
val hop = NodeHop(walletParams.trampolineNode.id, payment.request.recipient, nextFees.cltvExpiryDelta, nextFees.calculateFees(payment.request.amount))
193-
val (childPayment, sharedSecrets, cmd) = createOutgoingPayment(payment.request, result.value, hop, currentBlockHeight)
198+
val (childPayment, packet, cmd) = createOutgoingPayment(payment.request, result.value, hop, currentBlockHeight)
194199
db.addOutgoingLightningParts(payment.request.paymentId, listOf(childPayment))
195200
val payment1 = PaymentAttempt(
196201
request = payment.request,
197202
attemptNumber = payment.attemptNumber + 1,
198203
pending = childPayment,
199-
sharedSecrets = sharedSecrets,
204+
outgoing = packet,
200205
failures = payment.failures + failure
201206
)
202207
pending[payment1.request.paymentId] = payment1
@@ -319,7 +324,7 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
319324
}
320325
}
321326

322-
private fun createOutgoingPayment(request: PayInvoice, channel: Normal, hop: NodeHop, currentBlockHeight: Int): Triple<LightningOutgoingPayment.Part, SharedSecrets, WrappedChannelCommand> {
327+
private fun createOutgoingPayment(request: PayInvoice, channel: Normal, hop: NodeHop, currentBlockHeight: Int): Triple<LightningOutgoingPayment.Part, OutgoingPacket, WrappedChannelCommand> {
323328
val logger = MDCLogger(logger, staticMdc = request.mdc())
324329
val childId = UUID.randomUUID()
325330
childToParentId[childId] = request.paymentId
@@ -332,10 +337,10 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
332337
)
333338
logger.info { "sending $amount to channel ${channel.shortChannelId}" }
334339
val add = ChannelCommand.Htlc.Add(amount, request.paymentHash, expiry, onion.packet, paymentId = childId, commit = true)
335-
return Triple(outgoingPayment, onion.sharedSecrets, WrappedChannelCommand(channel.channelId, add))
340+
return Triple(outgoingPayment, onion, WrappedChannelCommand(channel.channelId, add))
336341
}
337342

338-
private fun createPaymentOnion(request: PayInvoice, hop: NodeHop, currentBlockHeight: Int): Triple<MilliSatoshi, CltvExpiry, PacketAndSecrets> {
343+
private fun createPaymentOnion(request: PayInvoice, hop: NodeHop, currentBlockHeight: Int): Triple<MilliSatoshi, CltvExpiry, OutgoingPacket> {
339344
return when (val paymentRequest = request.paymentDetails.paymentRequest) {
340345
is Bolt11Invoice -> {
341346
val minFinalExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA

0 commit comments

Comments
 (0)