Skip to content

Commit e20b736

Browse files
authored
Add hints when features check fail (#2777)
This makes it much easier to diagnose incompatibilities.
1 parent 772e2b2 commit e20b736

File tree

3 files changed

+37
-14
lines changed

3 files changed

+37
-14
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/Features.scala

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ trait ChannelTypeFeature extends PermanentChannelFeature
7777

7878
case class UnknownFeature(bitIndex: Int)
7979

80+
// @formatter:off
81+
sealed trait FeatureCompatibilityResult {
82+
def areCompatible: Boolean = this == FeatureCompatibilityResult.Compatible
83+
def errorHints: Set[String] = this match {
84+
case FeatureCompatibilityResult.Compatible => Set.empty
85+
case r: FeatureCompatibilityResult.NotCompatible => r.hints
86+
}
87+
}
88+
object FeatureCompatibilityResult {
89+
case object Compatible extends FeatureCompatibilityResult
90+
case class NotCompatible(hints: Set[String]) extends FeatureCompatibilityResult
91+
}
92+
// @formatter:on
93+
8094
case class Features[T <: Feature](activated: Map[T, FeatureSupport], unknown: Set[UnknownFeature] = Set.empty) {
8195

8296
def isEmpty: Boolean = activated.isEmpty && unknown.isEmpty
@@ -87,17 +101,20 @@ case class Features[T <: Feature](activated: Map[T, FeatureSupport], unknown: Se
87101
}
88102

89103
/** NB: this method is not reflexive, see [[Features.areCompatible]] if you want symmetric validation. */
90-
def areSupported(remoteFeatures: Features[T]): Boolean = {
104+
def testSupported(remoteFeatures: Features[T]): FeatureCompatibilityResult = {
91105
// we allow unknown odd features (it's ok to be odd)
92-
val unknownFeaturesOk = remoteFeatures.unknown.forall(_.bitIndex % 2 == 1)
106+
val incompatibleUnknownFeatures = remoteFeatures.unknown.filter(_.bitIndex % 2 == 0)
93107
// we verify that we activated every mandatory feature they require
94-
val knownFeaturesOk = remoteFeatures.activated.forall {
95-
case (_, Optional) => true
96-
case (feature, Mandatory) => hasFeature(feature)
97-
}
98-
unknownFeaturesOk && knownFeaturesOk
108+
val incompatibleKnownFeatures = remoteFeatures.activated.filter {
109+
case (_, Optional) => false
110+
case (feature, Mandatory) => !hasFeature(feature)
111+
}.keySet
112+
val incompatibleFeatures = incompatibleUnknownFeatures.map(u => s"unknown_${u.bitIndex}") ++ incompatibleKnownFeatures.map(_.rfcName)
113+
if (incompatibleFeatures.isEmpty) FeatureCompatibilityResult.Compatible else FeatureCompatibilityResult.NotCompatible(incompatibleFeatures)
99114
}
100115

116+
def areSupported(remoteFeatures: Features[T]): Boolean = testSupported(remoteFeatures).areCompatible
117+
101118
def initFeatures(): Features[InitFeature] = Features(activated.collect { case (f: InitFeature, s) => (f, s) }, unknown)
102119

103120
def nodeAnnouncementFeatures(): Features[NodeFeature] = Features(activated.collect { case (f: NodeFeature, s) => (f, s) }, unknown)
@@ -354,8 +371,13 @@ object Features {
354371
FeatureException(s"$feature is set but is missing a dependency (${dependencies.filter(d => !features.unscoped().hasFeature(d)).mkString(" and ")})")
355372
}
356373

374+
def testCompatible[T <: Feature](ours: Features[T], theirs: Features[T]): FeatureCompatibilityResult = (ours.testSupported(theirs), theirs.testSupported(ours)) match {
375+
case (FeatureCompatibilityResult.Compatible, FeatureCompatibilityResult.Compatible) => FeatureCompatibilityResult.Compatible
376+
case (r1, r2) => FeatureCompatibilityResult.NotCompatible(r1.errorHints ++ r2.errorHints)
377+
}
378+
357379
/** Returns true if both feature sets are compatible. */
358-
def areCompatible[T <: Feature](ours: Features[T], theirs: Features[T]): Boolean = ours.areSupported(theirs) && theirs.areSupported(ours)
380+
def areCompatible[T <: Feature](ours: Features[T], theirs: Features[T]): Boolean = testCompatible(ours, theirs).areCompatible
359381

360382
/** returns true if both have at least optional support */
361383
def canUseFeature[T <: Feature](localFeatures: Features[T], remoteFeatures: Features[T], feature: T): Boolean = {

eclair-core/src/main/scala/fr/acinq/eclair/io/PeerConnection.scala

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
2828
import fr.acinq.eclair.router.Router._
2929
import fr.acinq.eclair.wire.protocol
3030
import fr.acinq.eclair.wire.protocol._
31-
import fr.acinq.eclair.{FSMDiagnosticActorLogging, Features, InitFeature, Logs, TimestampMilli, TimestampSecond}
31+
import fr.acinq.eclair.{FSMDiagnosticActorLogging, FeatureCompatibilityResult, Features, InitFeature, Logs, TimestampMilli, TimestampSecond}
3232
import scodec.Attempt
3333
import scodec.bits.ByteVector
3434

@@ -132,6 +132,7 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A
132132
remoteInit.remoteAddress_opt.foreach(address => log.info("peer reports that our IP address is {} (public={})", address.toString, NodeAddress.isPublicIPAddress(address)))
133133

134134
val featureGraphErr_opt = Features.validateFeatureGraph(remoteInit.features)
135+
val featuresCompatibilityResult = Features.testCompatible(d.localInit.features, remoteInit.features)
135136
if (remoteInit.networks.nonEmpty && remoteInit.networks.intersect(d.localInit.networks).isEmpty) {
136137
log.warning(s"incompatible networks (${remoteInit.networks}), disconnecting")
137138
d.pendingAuth.origin_opt.foreach(_ ! ConnectionResult.InitializationFailed("incompatible networks"))
@@ -143,9 +144,9 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A
143144
d.pendingAuth.origin_opt.foreach(_ ! ConnectionResult.InitializationFailed(featureGraphErr.message))
144145
d.transport ! PoisonPill
145146
stay()
146-
} else if (!Features.areCompatible(d.localInit.features, remoteInit.features)) {
147-
log.warning("incompatible features, disconnecting")
148-
d.pendingAuth.origin_opt.foreach(_ ! ConnectionResult.InitializationFailed("incompatible features"))
147+
} else if (!featuresCompatibilityResult.areCompatible) {
148+
log.warning(s"incompatible features (${featuresCompatibilityResult.errorHints.mkString(",")}), disconnecting")
149+
d.pendingAuth.origin_opt.foreach(_ ! ConnectionResult.InitializationFailed(s"incompatible features (${featuresCompatibilityResult.errorHints.mkString(",")})"))
149150
d.transport ! PoisonPill
150151
stay()
151152
} else {

eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi
153153
transport.send(peerConnection, LightningMessageCodecs.initCodec.decode(hex"0000 00050100000000".bits).require.value)
154154
transport.expectMsgType[TransportHandler.ReadAck]
155155
probe.expectTerminated(transport.ref)
156-
origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features"))
156+
origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features (unknown_32,payment_secret,var_onion_optin)"))
157157
peer.expectMsg(ConnectionDown(peerConnection))
158158
}
159159

@@ -170,7 +170,7 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi
170170
transport.send(peerConnection, LightningMessageCodecs.initCodec.decode(hex"00050100000000 0000".bits).require.value)
171171
transport.expectMsgType[TransportHandler.ReadAck]
172172
probe.expectTerminated(transport.ref)
173-
origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features"))
173+
origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features (unknown_32,payment_secret,var_onion_optin)"))
174174
peer.expectMsg(ConnectionDown(peerConnection))
175175
}
176176

0 commit comments

Comments
 (0)