Don't try reconnecting automatically to mobile wallets (#3287)
What changed, and why it matters
This change stops Eclair lightning nodes from automatically trying to reconnect to mobile wallets. Mobile wallets change IP addresses frequently and are usually offline, so server-initiated reconnections are wasteful and can leak information about which channels belong to mobile users. The mobile wallet will reconnect on its own when it opens the app or gets a push notification. This is a hardening and privacy improvement, not a fix for an active exploit.
No urgent action required. Operators and downstream users should ensure they are on a release containing this commit if they run Eclair nodes that peer with mobile wallets, to benefit from reduced resource waste and improved privacy.
Security signals we found
Prevents resource exhaustion from useless reconnection loops to offline mobile peers
Reduces network-level metadata leakage about which peers are mobile wallets
Adds feature-based gating using WakeUpNotificationClient
No input validation or cryptographic changes
Evidence from the diff
The commit adds a per-peer autoReconnect flag inside Peer.DisconnectedData. It is set to false when the peer advertises the WakeUpNotificationClient feature (used by mobile wallets) or when there are no channels. The ReconnectionTask now requires nodeParams.autoReconnect && nextPeerData.autoReconnect && nextPeerData.channels.nonEmpty before scheduling reconnections. Tests verify that mobile-wallet peers stay in IDLE and do not trigger reconnection attempts.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scalaeclair-core/src/main/scala/fr/acinq/eclair/io/ReconnectionTask.scalaPeer.DisconnectedData case classReconnectionTask IDLE transition logicInspect captured patch +64 / −22
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
index ccf59b1..a9ae568 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
@@ -88,10 +88,12 @@ class Peer(val nodeParams: NodeParams,
channel ! INPUT_RESTORED(state)
FinalChannelId(state.channelId) -> channel
}.toMap
+ // We only connect to nodes with whom we have a channel, which aren't mobile wallets.
+ val autoReconnect = init.storedChannels.exists(c => !c.channelParams.remoteParams.initFeatures.hasFeature(Features.WakeUpNotificationClient))
context.system.eventStream.publish(PeerCreated(self, remoteNodeId))
// When we restart, we will attempt to reconnect right away, but then we'll wait.
// We don't fetch our peer's features from the DB: if the connection succeeds, we will get them from their init message, which saves a DB call.
- goto(DISCONNECTED) using DisconnectedData(channels, activeChannels = Set.empty, peerStorage = PeerStorage.Uninitialized, remoteFeatures_opt = None)
+ goto(DISCONNECTED) using DisconnectedData(channels, activeChannels = Set.empty, peerStorage = PeerStorage.Uninitialized, remoteFeatures_opt = None, autoReconnect = autoReconnect)
}
when(DISCONNECTED) {
@@ -553,7 +555,9 @@ class Peer(val nodeParams: NodeParams,
} else {
d.channels.values.toSet[ActorRef].foreach(_ ! INPUT_DISCONNECTED) // we deduplicate with toSet because there might be two entries per channel (tmp id and final id)
val lastRemoteFeatures = LastRemoteFeatures(d.remoteFeatures, d.remoteFeaturesWritten)
- goto(DISCONNECTED) using DisconnectedData(d.channels.collect { case (k: FinalChannelId, v) => (k, v) }, d.activeChannels, d.peerStorage, Some(lastRemoteFeatures))
+ // We only reconnect if our peer is not a mobile wallet, and we now have a channel with them.
+ val autoReconnect = d.channels.nonEmpty && !d.remoteFeatures.hasFeature(Features.WakeUpNotificationClient)
+ goto(DISCONNECTED) using DisconnectedData(d.channels.collect { case (k: FinalChannelId, v) => (k, v) }, d.activeChannels, d.peerStorage, Some(lastRemoteFeatures), autoReconnect)
}
case Event(ChannelTerminated(actor), d: ConnectedData) =>
@@ -1103,7 +1107,7 @@ object Peer {
override def activeChannels: Set[ByteVector32] = Set.empty
override def peerStorage: PeerStorage = PeerStorage.Uninitialized
}
- case class DisconnectedData(channels: Map[FinalChannelId, ActorRef], activeChannels: Set[ByteVector32], peerStorage: PeerStorage, remoteFeatures_opt: Option[LastRemoteFeatures]) extends Data
+ case class DisconnectedData(channels: Map[FinalChannelId, ActorRef], activeChannels: Set[ByteVector32], peerStorage: PeerStorage, remoteFeatures_opt: Option[LastRemoteFeatures], autoReconnect: Boolean) extends Data
case class ConnectedData(address: NodeAddress, peerConnection: ActorRef, localInit: protocol.Init, remoteInit: protocol.Init, channels: Map[ChannelId, ActorRef], activeChannels: Set[ByteVector32], currentFeerates: RecommendedFeerates, previousFeerates_opt: Option[RecommendedFeerates], peerStorage: PeerStorage, remoteFeaturesWritten: Boolean) extends Data {
val connectionInfo: ConnectionInfo = ConnectionInfo(address, peerConnection, localInit, remoteInit)
def localFeatures: Features[InitFeature] = localInit.features
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/ReconnectionTask.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/ReconnectionTask.scala
index c74177b..0d14b7f 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/ReconnectionTask.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/ReconnectionTask.scala
@@ -80,7 +80,11 @@ class ReconnectionTask(nodeParams: NodeParams, remoteNodeId: PublicKey) extends
when(IDLE) {
case Event(Peer.Transition(previousPeerData, nextPeerData: Peer.DisconnectedData), d: IdleData) =>
- if (nodeParams.autoReconnect && nextPeerData.channels.nonEmpty) { // we only reconnect if nodeParams explicitly instructs us to or there are existing channels
+ // We only reconnect automatically if:
+ // - auto-reconnection is enabled in our node params (which is the default behavior)
+ // - and there are existing channels
+ // - and our peer is not a mobile wallet (which is likely to be offline and will initiate the connection themselves)
+ if (nodeParams.autoReconnect && nextPeerData.autoReconnect && nextPeerData.channels.nonEmpty) {
val (initialDelay, firstNextReconnectionDelay) = (previousPeerData, d.previousData) match {
case (Peer.Nothing, _) =>
// When restarting, we add some randomization before the first reconnection attempt to avoid herd effect
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
index 164767d..fc74b5f 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
@@ -50,6 +50,10 @@ class PeerSpec extends FixtureSpec {
import PeerSpec._
import akka.actor.typed.scaladsl.adapter._
+ private val autoReconnect = "auto_reconnect"
+ private val fastReconnectDelay = "fast_reconnect_delay"
+ private val withNodeAnn = "with_node_announcements"
+
override implicit val patienceConfig: PatienceConfig = PatienceConfig(timeout = 30 seconds, interval = 1 second)
case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, system: ActorSystem, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, switchboard: TestProbe, register: TestProbe, mockLimiter: ActorRef) {
@@ -75,9 +79,11 @@ class PeerSpec extends FixtureSpec {
.modify(_.channelConf.maxHtlcValueInFlightMsat).setToIf(testData.tags.contains("max-htlc-value-in-flight-percent"))(100_000_000 msat)
.modify(_.channelConf.maxHtlcValueInFlightPercent).setToIf(testData.tags.contains("max-htlc-value-in-flight-percent"))(25)
.modify(_.channelConf.channelFundingTimeout).setToIf(testData.tags.contains("channel_funding_timeout"))(100 millis)
- .modify(_.autoReconnect).setToIf(testData.tags.contains("auto_reconnect"))(true)
+ .modify(_.autoReconnect).setToIf(testData.tags.contains(autoReconnect))(true)
+ .modify(_.maxReconnectInterval).setToIf(testData.tags.contains(fastReconnectDelay))(10 millis)
+ .modify(_.initialRandomReconnectDelay).setToIf(testData.tags.contains(fastReconnectDelay))(1 millis)
- if (testData.tags.contains("with_node_announcement")) {
+ if (testData.tags.contains(withNodeAnn)) {
val bobAnnouncement = NodeAnnouncement(randomBytes64(), Features.empty, 1 unixsec, Bob.nodeParams.nodeId, Color(100.toByte, 200.toByte, 300.toByte), "node-alias", fakeIPAddress :: Nil)
aliceParams.db.network.addNode(bobAnnouncement)
}
@@ -196,7 +202,7 @@ class PeerSpec extends FixtureSpec {
probe.expectMsgType[PeerConnection.ConnectionResult.ConnectionFailed]
}
- test("successfully reconnect to peer at startup when there are existing channels", Tag("auto_reconnect")) { f =>
+ test("successfully reconnect to peer at startup when there are existing channels", Tag(autoReconnect)) { f =>
import f._
spawnClientSpawner(f)
@@ -219,6 +225,23 @@ class PeerSpec extends FixtureSpec {
mockServer.close()
}
+ test("don't reconnect to mobile wallets", Tag(autoReconnect), Tag(fastReconnectDelay)) { f =>
+ import f._
+
+ val monitor = TestProbe()
+ val reconnectionTask = peer.underlyingActor.context.child("reconnection-task").get
+ monitor.send(reconnectionTask, FSM.SubscribeTransitionCallBack(monitor.ref))
+ monitor.expectMsg(FSM.CurrentState(reconnectionTask, ReconnectionTask.IDLE))
+
+ val probe = TestProbe()
+ val localInit = protocol.Init(peer.underlyingActor.nodeParams.features.initFeatures())
+ val remoteInit = protocol.Init(peer.underlyingActor.nodeParams.features.initFeatures().add(Features.WakeUpNotificationClient, FeatureSupport.Optional))
+ val mobileWalletChannel = ChannelCodecsSpec.normal.copy(commitments = ChannelCodecsSpec.normal.commitments.updateInitFeatures(localInit, remoteInit))
+ probe.send(peer, Peer.Init(Set(mobileWalletChannel), Map.empty))
+ // the reconnection task will stay in the idle state
+ monitor.expectNoMessage(100 millis)
+ }
+
test("reject connection attempts in state CONNECTED") { f =>
import f._
@@ -306,7 +329,7 @@ class PeerSpec extends FixtureSpec {
}
}
- test("send state transitions to child reconnection actor", Tag("auto_reconnect"), Tag("with_node_announcement")) { f =>
+ test("send state transitions to child reconnection actor", Tag(autoReconnect), Tag(withNodeAnn)) { f =>
import f._
// monitor state changes of child reconnection task
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/ReconnectionTaskSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/ReconnectionTaskSpec.scala
index dcdf1d8..6d1adce 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/io/ReconnectionTaskSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/ReconnectionTaskSpec.scala
@@ -23,7 +23,7 @@ import fr.acinq.eclair._
import fr.acinq.eclair.io.Peer.{ChannelId, PeerStorage}
import fr.acinq.eclair.io.ReconnectionTask.WaitingData
import fr.acinq.eclair.tor.Socks5ProxyParams
-import fr.acinq.eclair.wire.protocol.{Color, NodeAddress, NodeAnnouncement, NodeInfo, RecommendedFeerates}
+import fr.acinq.eclair.wire.protocol.{Color, NodeAddress, NodeAnnouncement, RecommendedFeerates}
import org.mockito.IdiomaticMockito.StubbingOps
import org.mockito.MockitoSugar.mock
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
@@ -33,12 +33,15 @@ import scala.concurrent.duration._
class ReconnectionTaskSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with ParallelTestExecution {
+ private val autoReconnect = "auto_reconnect"
+ private val withNodeAnn = "with_node_announcements"
+
private val fakeIPAddress = NodeAddress.fromParts("1.2.3.4", 42000).get
private val channels = Map(Peer.FinalChannelId(randomBytes32()) -> system.deadLetters)
private val recommendedFeerates = RecommendedFeerates(Block.RegtestGenesisBlock.hash, TestConstants.feeratePerKw, TestConstants.anchorOutputsFeeratePerKw)
private val PeerNothingData = Peer.Nothing
- private val PeerDisconnectedData = Peer.DisconnectedData(channels, activeChannels = Set.empty, PeerStorage.Empty, remoteFeatures_opt = None)
+ private val PeerDisconnectedData = Peer.DisconnectedData(channels, activeChannels = Set.empty, PeerStorage.Empty, remoteFeatures_opt = None, autoReconnect = true)
private val PeerConnectedData = Peer.ConnectedData(fakeIPAddress, system.deadLetters, null, null, channels.map { case (k: ChannelId, v) => (k, v) }, activeChannels = Set.empty, recommendedFeerates, None, PeerStorage.Empty, remoteFeaturesWritten = true)
case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, reconnectionTask: TestFSMRef[ReconnectionTask.State, ReconnectionTask.Data, ReconnectionTask], monitor: TestProbe)
@@ -50,9 +53,9 @@ class ReconnectionTaskSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
import com.softwaremill.quicklens._
val aliceParams = TestConstants.Alice.nodeParams
- .modify(_.autoReconnect).setToIf(test.tags.contains("auto_reconnect"))(true)
+ .modify(_.autoReconnect).setToIf(test.tags.contains(autoReconnect))(true)
- if (test.tags.contains("with_node_announcements")) {
+ if (test.tags.contains(withNodeAnn)) {
val bobAnnouncement = NodeAnnouncement(randomBytes64(), Features.empty, 1 unixsec, remoteNodeId, Color(100.toByte, 200.toByte, 300.toByte), "node-alias", fakeIPAddress :: Nil)
aliceParams.db.network.addNode(bobAnnouncement)
}
@@ -70,23 +73,31 @@ class ReconnectionTaskSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
withFixture(test.toNoArgTest(FixtureParam(aliceParams, remoteNodeId, reconnectionTask, monitor)))
}
- test("stay idle at startup if auto-reconnect is disabled", Tag("with_node_announcements")) { f =>
+ test("stay idle at startup if auto-reconnect is disabled for all peers", Tag(withNodeAnn)) { f =>
import f._
val peer = TestProbe()
peer.send(reconnectionTask, Peer.Transition(PeerNothingData, PeerDisconnectedData))
- monitor.expectNoMessage()
+ monitor.expectNoMessage(100 millis)
+ }
+
+ test("stay idle at startup if auto-reconnect is disabled for this peer", Tag(autoReconnect), Tag(withNodeAnn)) { f =>
+ import f._
+
+ val peer = TestProbe()
+ peer.send(reconnectionTask, Peer.Transition(PeerNothingData, Peer.DisconnectedData(Map.empty, activeChannels = Set.empty, PeerStorage.Empty, None, autoReconnect = false)))
+ monitor.expectNoMessage(100 millis)
}
- test("stay idle at startup if there are no channels", Tag("auto_reconnect"), Tag("with_node_announcements")) { f =>
+ test("stay idle at startup if there are no channels", Tag(autoReconnect), Tag(withNodeAnn)) { f =>
import f._
val peer = TestProbe()
- peer.send(reconnectionTask, Peer.Transition(PeerNothingData, Peer.DisconnectedData(Map.empty, activeChannels = Set.empty, PeerStorage.Empty, None)))
- monitor.expectNoMessage()
+ peer.send(reconnectionTask, Peer.Transition(PeerNothingData, Peer.DisconnectedData(Map.empty, activeChannels = Set.empty, PeerStorage.Empty, None, autoReconnect = true)))
+ monitor.expectNoMessage(100 millis)
}
- test("only try to connect once at startup if auto-reconnect is enabled but there are no known address", Tag("auto_reconnect")) { f =>
+ test("only try to connect once at startup if auto-reconnect is enabled but there are no known address", Tag(autoReconnect)) { f =>
import f._
val peer = TestProbe()
@@ -95,7 +106,7 @@ class ReconnectionTaskSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
val TransitionWithData(ReconnectionTask.WAITING, ReconnectionTask.IDLE, _, _) = monitor.expectMsgType[TransitionWithData]
}
- test("initiate reconnection at startup if auto-reconnect is enabled", Tag("auto_reconnect"), Tag("with_node_announcements")) { f =>
+ test("initiate reconnection at startup if auto-reconnect is enabled", Tag(autoReconnect), Tag(withNodeAnn)) { f =>
import f._
val peer = TestProbe()
@@ -107,7 +118,7 @@ class ReconnectionTaskSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(expectedNextReconnectionDelayInterval contains connectingData.nextReconnectionDelay.toSeconds) // we only reconnect once
}
- test("reconnect with increasing delays", Tag("auto_reconnect")) { f =>
+ test("reconnect with increasing delays", Tag(autoReconnect)) { f =>
import f._
val probe = TestProbe()
@@ -157,7 +168,7 @@ class ReconnectionTaskSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(waitingData3.nextReconnectionDelay == (waitingData0.nextReconnectionDelay * 8))
}
- test("all kind of connection failures should be caught by the reconnection task", Tag("auto_reconnect")) { f =>
+ test("all kind of connection failures should be caught by the reconnection task", Tag(autoReconnect)) { f =>
import f._
val peer = TestProbe()
@@ -185,7 +196,7 @@ class ReconnectionTaskSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
}
}
- test("concurrent incoming/outgoing reconnection", Tag("auto_reconnect")) { f =>
+ test("concurrent incoming/outgoing reconnection", Tag(autoReconnect)) { f =>
import f._
val peer = TestProbe()
Why this scored 31/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.