Skip to content

Commit fd47ba5

Browse files
committed
Accept onion failure without a channel_update
lightning/bolts#1163 makes the channel update in onion failures optional. One reason for this change is that it can be a privacy issue: by applying a `channel_update` received from an payment attempt, you may reveal that you are the sender. Another reason is that some nodes have been omitting that field for years (which was arguably a bug), and it's better to be able to correctly handle such failures.
1 parent c493493 commit fd47ba5

File tree

10 files changed

+126
-101
lines changed

10 files changed

+126
-101
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
640640
case PostRevocationAction.RejectHtlc(add) =>
641641
log.debug("rejecting incoming htlc {}", add)
642642
// NB: we don't set commit = true, we will sign all updates at once afterwards.
643-
self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(d.channelUpdate)), commit = true)
643+
self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(Some(d.channelUpdate))), commit = true)
644644
case PostRevocationAction.RelayFailure(result) =>
645645
log.debug("forwarding {} to relayer", result)
646646
relayer ! result

eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentEvents.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ object PaymentFailure {
211211
case RemoteFailure(_, _, Sphinx.DecryptedFailurePacket(nodeId, _: Node)) =>
212212
ignore + nodeId
213213
case RemoteFailure(_, hops, Sphinx.DecryptedFailurePacket(nodeId, failureMessage: Update)) =>
214-
if (Announcements.checkSig(failureMessage.update, nodeId)) {
214+
if (failureMessage.update.forall(update => Announcements.checkSig(update, nodeId))) {
215215
val shouldIgnore = failureMessage match {
216216
case _: TemporaryChannelFailure => true
217217
case _: ChannelDisabled => true
@@ -224,7 +224,7 @@ object PaymentFailure {
224224
ignore
225225
}
226226
} else {
227-
// This node is fishy, it gave us a bad signature, so let's filter it out.
227+
// This node is fishy, it gave us a bad channel update signature, so let's filter it out.
228228
ignore + nodeId
229229
}
230230
case RemoteFailure(_, hops, Sphinx.DecryptedFailurePacket(nodeId, _)) =>
@@ -257,7 +257,10 @@ object PaymentFailure {
257257
// We're only interested in the last channel update received per channel.
258258
val updates = failures.foldLeft(Map.empty[ShortChannelId, ChannelUpdate]) {
259259
case (current, failure) => failure match {
260-
case RemoteFailure(_, _, Sphinx.DecryptedFailurePacket(_, f: Update)) => current.updated(f.update.shortChannelId, f.update)
260+
case RemoteFailure(_, _, Sphinx.DecryptedFailurePacket(_, f: Update)) => f.update match {
261+
case Some(update) => current.updated(update.shortChannelId, update)
262+
case None => current
263+
}
261264
case _ => current
262265
}
263266
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,15 @@ object ChannelRelay {
7171
*/
7272
def translateLocalError(error: Throwable, channelUpdate_opt: Option[ChannelUpdate]): FailureMessage = {
7373
(error, channelUpdate_opt) match {
74-
case (_: ExpiryTooSmall, Some(channelUpdate)) => ExpiryTooSoon(channelUpdate)
74+
case (_: ExpiryTooSmall, Some(channelUpdate)) => ExpiryTooSoon(Some(channelUpdate))
7575
case (_: ExpiryTooBig, _) => ExpiryTooFar()
76-
case (_: InsufficientFunds, Some(channelUpdate)) => TemporaryChannelFailure(channelUpdate)
77-
case (_: TooManyAcceptedHtlcs, Some(channelUpdate)) => TemporaryChannelFailure(channelUpdate)
78-
case (_: HtlcValueTooHighInFlight, Some(channelUpdate)) => TemporaryChannelFailure(channelUpdate)
79-
case (_: LocalDustHtlcExposureTooHigh, Some(channelUpdate)) => TemporaryChannelFailure(channelUpdate)
80-
case (_: RemoteDustHtlcExposureTooHigh, Some(channelUpdate)) => TemporaryChannelFailure(channelUpdate)
81-
case (_: FeerateTooDifferent, Some(channelUpdate)) => TemporaryChannelFailure(channelUpdate)
82-
case (_: ChannelUnavailable, Some(channelUpdate)) if !channelUpdate.channelFlags.isEnabled => ChannelDisabled(channelUpdate.messageFlags, channelUpdate.channelFlags, channelUpdate)
76+
case (_: InsufficientFunds, Some(channelUpdate)) => TemporaryChannelFailure(Some(channelUpdate))
77+
case (_: TooManyAcceptedHtlcs, Some(channelUpdate)) => TemporaryChannelFailure(Some(channelUpdate))
78+
case (_: HtlcValueTooHighInFlight, Some(channelUpdate)) => TemporaryChannelFailure(Some(channelUpdate))
79+
case (_: LocalDustHtlcExposureTooHigh, Some(channelUpdate)) => TemporaryChannelFailure(Some(channelUpdate))
80+
case (_: RemoteDustHtlcExposureTooHigh, Some(channelUpdate)) => TemporaryChannelFailure(Some(channelUpdate))
81+
case (_: FeerateTooDifferent, Some(channelUpdate)) => TemporaryChannelFailure(Some(channelUpdate))
82+
case (_: ChannelUnavailable, Some(channelUpdate)) if !channelUpdate.channelFlags.isEnabled => ChannelDisabled(channelUpdate.messageFlags, channelUpdate.channelFlags, Some(channelUpdate))
8383
case (_: ChannelUnavailable, None) => PermanentChannelFailure()
8484
case _ => TemporaryNodeFailure()
8585
}
@@ -91,7 +91,7 @@ object ChannelRelay {
9191
case f: HtlcResult.RemoteFailMalformed => CMD_FAIL_HTLC(originHtlcId, Right(createBadOnionFailure(f.fail.onionHash, f.fail.failureCode)), commit = true)
9292
case _: HtlcResult.OnChainFail => CMD_FAIL_HTLC(originHtlcId, Right(PermanentChannelFailure()), commit = true)
9393
case HtlcResult.ChannelFailureBeforeSigned => CMD_FAIL_HTLC(originHtlcId, Right(PermanentChannelFailure()), commit = true)
94-
case f: HtlcResult.DisconnectedBeforeSigned => CMD_FAIL_HTLC(originHtlcId, Right(TemporaryChannelFailure(f.channelUpdate)), commit = true)
94+
case f: HtlcResult.DisconnectedBeforeSigned => CMD_FAIL_HTLC(originHtlcId, Right(TemporaryChannelFailure(Some(f.channelUpdate))), commit = true)
9595
}
9696
}
9797

@@ -294,16 +294,16 @@ class ChannelRelay private(nodeParams: NodeParams,
294294
case None =>
295295
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(UnknownNextPeer()), commit = true))
296296
case Some(c) if !c.channelUpdate.channelFlags.isEnabled =>
297-
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(ChannelDisabled(c.channelUpdate.messageFlags, c.channelUpdate.channelFlags, c.channelUpdate)), commit = true))
297+
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(ChannelDisabled(c.channelUpdate.messageFlags, c.channelUpdate.channelFlags, Some(c.channelUpdate))), commit = true))
298298
case Some(c) if r.amountToForward < c.channelUpdate.htlcMinimumMsat =>
299-
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(AmountBelowMinimum(r.amountToForward, c.channelUpdate)), commit = true))
299+
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(AmountBelowMinimum(r.amountToForward, Some(c.channelUpdate))), commit = true))
300300
case Some(c) if r.expiryDelta < c.channelUpdate.cltvExpiryDelta =>
301-
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(IncorrectCltvExpiry(r.outgoingCltv, c.channelUpdate)), commit = true))
301+
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(IncorrectCltvExpiry(r.outgoingCltv, Some(c.channelUpdate))), commit = true))
302302
case Some(c) if r.relayFeeMsat < nodeFee(c.channelUpdate.relayFees, r.amountToForward) &&
303303
// fees also do not satisfy the previous channel update for `enforcementDelay` seconds after current update
304304
(TimestampSecond.now() - c.channelUpdate.timestamp > nodeParams.relayParams.enforcementDelay ||
305305
outgoingChannel_opt.flatMap(_.prevChannelUpdate).forall(c => r.relayFeeMsat < nodeFee(c.relayFees, r.amountToForward))) =>
306-
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(FeeInsufficient(r.add.amountMsat, c.channelUpdate)), commit = true))
306+
RelayFailure(CMD_FAIL_HTLC(r.add.id, Right(FeeInsufficient(r.add.amountMsat, Some(c.channelUpdate))), commit = true))
307307
case Some(c: OutgoingChannel) =>
308308
val origin = Origin.ChannelRelayedHot(addResponseAdapter.toClassic, r.add, r.amountToForward)
309309
val nextBlindingKey_opt = r.payload match {

eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,18 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
181181
router ! Router.RouteCouldRelay(stoppedRoute)
182182
}
183183
failureMessage match {
184-
case TemporaryChannelFailure(update, _) =>
184+
case TemporaryChannelFailure(update_opt, _) =>
185185
route.hops.find(_.nodeId == nodeId) match {
186-
case Some(failingHop) if HopRelayParams.areSame(failingHop.params, HopRelayParams.FromAnnouncement(update), ignoreHtlcSize = true) =>
187-
router ! Router.ChannelCouldNotRelay(stoppedRoute.amount, failingHop)
188-
case _ => // otherwise the relay parameters may have changed, so it's not necessarily a liquidity issue
186+
case Some(failingHop) =>
187+
val isLiquidityIssue = update_opt match {
188+
// If the relay parameters have changed, it's not necessarily a liquidity issue.
189+
case Some(update) => HopRelayParams.areSame(failingHop.params, HopRelayParams.FromAnnouncement(update), ignoreHtlcSize = true)
190+
case None => true
191+
}
192+
if (isLiquidityIssue) {
193+
router ! Router.ChannelCouldNotRelay(stoppedRoute.amount, failingHop)
194+
}
195+
case _ => ()
189196
}
190197
case _ => // other errors should not be used for liquidity issues
191198
}
@@ -227,7 +234,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
227234
case Success(e@Sphinx.DecryptedFailurePacket(nodeId, failureMessage: Update)) =>
228235
log.info(s"received 'Update' type error message from nodeId=$nodeId, retrying payment (failure=$failureMessage)")
229236
val failure = RemoteFailure(request.amount, route.fullRoute, e)
230-
if (Announcements.checkSig(failureMessage.update, nodeId)) {
237+
if (failureMessage.update.forall(update => Announcements.checkSig(update, nodeId))) {
231238
val recipient1 = handleUpdate(nodeId, failureMessage, d)
232239
val ignore1 = PaymentFailure.updateIgnored(failure, ignore)
233240
// let's try again, router will have updated its state
@@ -240,7 +247,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
240247
goto(WAITING_FOR_ROUTE) using WaitingForRoute(request.copy(recipient = recipient1), failures :+ failure, ignore1)
241248
}
242249
} else {
243-
// this node is fishy, it gave us a bad sig!! let's filter it out
250+
// this node is fishy, it gave us a bad channel update signature: let's filter it out.
244251
log.warning(s"got bad signature from node=$nodeId update=${failureMessage.update}")
245252
request match {
246253
case _: SendPaymentToRoute =>
@@ -289,38 +296,49 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
289296
val extraEdges1 = data.route.hops.find(_.nodeId == nodeId) match {
290297
case Some(hop) => hop.params match {
291298
case ann: HopRelayParams.FromAnnouncement =>
292-
if (ann.channelUpdate.shortChannelId != failure.update.shortChannelId) {
293-
// it is possible that nodes in the route prefer using a different channel (to the same N+1 node) than the one we requested, that's fine
294-
log.info("received an update for a different channel than the one we asked: requested={} actual={} update={}", ann.channelUpdate.shortChannelId, failure.update.shortChannelId, failure.update)
295-
} else if (Announcements.areSame(ann.channelUpdate, failure.update)) {
296-
// node returned the exact same update we used, this can happen e.g. if the channel is imbalanced
297-
// in that case, let's temporarily exclude the channel from future routes, giving it time to recover
298-
log.info("received exact same update from nodeId={}, excluding the channel from futures routes", nodeId)
299-
router ! ExcludeChannel(ChannelDesc(ann.channelUpdate.shortChannelId, nodeId, hop.nextNodeId), Some(nodeParams.routerConf.channelExcludeDuration))
300-
} else if (PaymentFailure.hasAlreadyFailedOnce(nodeId, data.failures)) {
301-
// this node had already given us a new channel update and is still unhappy, it is probably messing with us, let's exclude it
302-
log.warning("it is the second time nodeId={} answers with a new update, excluding it: old={} new={}", nodeId, ann.channelUpdate, failure.update)
303-
router ! ExcludeChannel(ChannelDesc(ann.channelUpdate.shortChannelId, nodeId, hop.nextNodeId), Some(nodeParams.routerConf.channelExcludeDuration))
304-
} else {
305-
log.info("got a new update for shortChannelId={}: old={} new={}", ann.channelUpdate.shortChannelId, ann.channelUpdate, failure.update)
299+
failure.update match {
300+
case Some(update) if ann.channelUpdate.shortChannelId != update.shortChannelId =>
301+
// it is possible that nodes in the route prefer using a different channel (to the same N+1 node) than the one we requested, that's fine
302+
log.info("received an update for a different channel than the one we asked: requested={} actual={} update={}", ann.channelUpdate.shortChannelId, update.shortChannelId, update)
303+
case Some(update) if Announcements.areSame(ann.channelUpdate, update) =>
304+
// node returned the exact same update we used, this can happen e.g. if the channel is imbalanced
305+
// in that case, let's temporarily exclude the channel from future routes, giving it time to recover
306+
log.info("received exact same update from nodeId={}, excluding the channel from futures routes", nodeId)
307+
router ! ExcludeChannel(ChannelDesc(ann.channelUpdate.shortChannelId, nodeId, hop.nextNodeId), Some(nodeParams.routerConf.channelExcludeDuration))
308+
case Some(_) if PaymentFailure.hasAlreadyFailedOnce(nodeId, data.failures) =>
309+
// this node had already given us a new channel update and is still unhappy, it is probably messing with us, let's exclude it
310+
log.warning("it is the second time nodeId={} answers with a new update, excluding it: old={} new={}", nodeId, ann.channelUpdate, failure.update)
311+
router ! ExcludeChannel(ChannelDesc(ann.channelUpdate.shortChannelId, nodeId, hop.nextNodeId), Some(nodeParams.routerConf.channelExcludeDuration))
312+
case Some(update) =>
313+
log.info("got a new update for shortChannelId={}: old={} new={}", ann.channelUpdate.shortChannelId, ann.channelUpdate, update)
314+
case None =>
315+
// this isn't a relay parameter issue, so it's probably a liquidity issue
316+
log.info("update not provided for shortChannelId={}", ann.channelUpdate.shortChannelId)
317+
router ! ExcludeChannel(ChannelDesc(ann.channelUpdate.shortChannelId, nodeId, hop.nextNodeId), Some(nodeParams.routerConf.channelExcludeDuration))
306318
}
307319
data.recipient.extraEdges
308-
case _: HopRelayParams.FromHint =>
309-
log.info("received an update for a routing hint (shortChannelId={} nodeId={} enabled={} update={})", failure.update.shortChannelId, nodeId, failure.update.channelFlags.isEnabled, failure.update)
310-
if (failure.update.channelFlags.isEnabled) {
311-
data.recipient.extraEdges.map {
312-
case edge: ExtraEdge if edge.sourceNodeId == nodeId && edge.targetNodeId == hop.nextNodeId => edge.update(failure.update)
313-
case edge: ExtraEdge => edge
314-
}
315-
} else {
316-
// if the channel is disabled, we temporarily exclude it: this is necessary because the routing hint doesn't
317-
// contain channel flags to indicate that it's disabled
318-
// we want the exclusion to be router-wide so that sister payments in the case of MPP are aware the channel is faulty
319-
data.recipient.extraEdges
320-
.find(edge => edge.sourceNodeId == nodeId && edge.targetNodeId == hop.nextNodeId)
321-
.foreach(edge => router ! ExcludeChannel(ChannelDesc(edge.shortChannelId, edge.sourceNodeId, edge.targetNodeId), Some(nodeParams.routerConf.channelExcludeDuration)))
322-
// we remove this edge for our next payment attempt
323-
data.recipient.extraEdges.filterNot(edge => edge.sourceNodeId == nodeId && edge.targetNodeId == hop.nextNodeId)
320+
case hint: HopRelayParams.FromHint =>
321+
failure.update match {
322+
case Some(update) =>
323+
log.info("received an update for a routing hint (shortChannelId={} nodeId={} enabled={} update={})", update.shortChannelId, nodeId, update.channelFlags.isEnabled, failure.update)
324+
if (update.channelFlags.isEnabled) {
325+
data.recipient.extraEdges.map {
326+
case edge: ExtraEdge if edge.sourceNodeId == nodeId && edge.targetNodeId == hop.nextNodeId => edge.update(update)
327+
case edge: ExtraEdge => edge
328+
}
329+
} else {
330+
// if the channel is disabled, we temporarily exclude it: this is necessary because the routing hint doesn't
331+
// contain channel flags to indicate that it's disabled
332+
// we want the exclusion to be router-wide so that sister payments in the case of MPP are aware the channel is faulty
333+
data.recipient.extraEdges
334+
.find(edge => edge.sourceNodeId == nodeId && edge.targetNodeId == hop.nextNodeId)
335+
.foreach(edge => router ! ExcludeChannel(ChannelDesc(edge.shortChannelId, edge.sourceNodeId, edge.targetNodeId), Some(nodeParams.routerConf.channelExcludeDuration)))
336+
// we remove this edge for our next payment attempt
337+
data.recipient.extraEdges.filterNot(edge => edge.sourceNodeId == nodeId && edge.targetNodeId == hop.nextNodeId)
338+
}
339+
case None =>
340+
// this is most likely a liquidity issue, we remove this edge for our next payment attempt
341+
data.recipient.extraEdges.filterNot(edge => edge.sourceNodeId == nodeId && edge.targetNodeId == hop.nextNodeId)
324342
}
325343
}
326344
case None =>

0 commit comments

Comments
 (0)