From dd0ca58bdbbb22ef0c82bbdf2a6123b39f1b14c4 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 6 Jun 2024 19:21:10 +0200 Subject: [PATCH] Add `recommended_feerates` optional message We send to our peers an optional message that tells them the feerates we'd like to use for funding channels. This lets them know which values are acceptable to us, in case we reject their funding requests. This is using an odd type and will be automatically ignored by existing nodes who don't support that feature. --- .../main/scala/fr/acinq/eclair/NodeParams.scala | 11 ++++++++++- .../src/main/scala/fr/acinq/eclair/io/Peer.scala | 14 +++++++++++++- .../wire/protocol/LightningMessageCodecs.scala | 7 +++++++ .../wire/protocol/LightningMessageTypes.scala | 6 ++++++ .../test/scala/fr/acinq/eclair/io/PeerSpec.scala | 15 ++++++++++++++- .../protocol/LightningMessageCodecsSpec.scala | 13 +++++++++++++ 6 files changed, 63 insertions(+), 3 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 8272387e11..d5d10b9fe2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -21,9 +21,9 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, Crypto, Satoshi, SatoshiLong} import fr.acinq.eclair.Setup.Seeds import fr.acinq.eclair.blockchain.fee._ -import fr.acinq.eclair.channel.ChannelFlags import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.{BalanceThreshold, ChannelConf, UnhandledExceptionStrategy} +import fr.acinq.eclair.channel.{ChannelFlags, ChannelTypes} import fr.acinq.eclair.crypto.Noise.KeyPair import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager, OnChainKeyManager} import fr.acinq.eclair.db._ @@ -109,6 +109,15 @@ case class NodeParams(nodeKeyManager: NodeKeyManager, /** Returns the features that should be used in our init message with the given peer. */ def initFeaturesFor(nodeId: PublicKey): Features[InitFeature] = overrideInitFeatures.getOrElse(nodeId, features).initFeatures() + + /** Returns the feerates we'd like our peer to use when funding channels. */ + def recommendedFeerates(remoteNodeId: PublicKey, currentFeerates: FeeratesPerKw, localFeatures: Features[InitFeature], remoteFeatures: Features[InitFeature]): RecommendedFeerates = { + val fundingFeerate = onChainFeeConf.getFundingFeerate(currentFeerates) + // We use the most likely commitment format, even though there is no guarantee that this is the one that will be used. + val commitmentFormat = ChannelTypes.defaultFromFeatures(localFeatures, remoteFeatures, announceChannel = false).commitmentFormat + val commitmentFeerate = onChainFeeConf.getCommitmentFeerate(currentFeerates, remoteNodeId, commitmentFormat, channelConf.minFundingPrivateSatoshis) + RecommendedFeerates(chainHash, fundingFeerate, commitmentFeerate) + } } case class PaymentFinalExpiryConf(min: CltvExpiryDelta, max: CltvExpiryDelta) { 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 e98d687a49..29cf7f2d06 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 @@ -29,7 +29,7 @@ import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.blockchain.{OnChainChannelFunder, OnchainPubkeyCache} +import fr.acinq.eclair.blockchain.{CurrentFeerates, OnChainChannelFunder, OnchainPubkeyCache} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.io.MessageRelay.Status @@ -63,6 +63,8 @@ class Peer(val nodeParams: NodeParams, import Peer._ + context.system.eventStream.subscribe(self, classOf[CurrentFeerates]) + startWith(INSTANTIATING, Nothing) when(INSTANTIATING) { @@ -344,6 +346,13 @@ class Peer(val nodeParams: NodeParams, } stay() + case Event(current: CurrentFeerates, d) => + d match { + case d: ConnectedData => d.peerConnection ! nodeParams.recommendedFeerates(remoteNodeId, current.feeratesPerKw, d.localFeatures, d.remoteFeatures) + case _ => () + } + stay() + case Event(_: Peer.OutgoingMessage, _) => stay() // we got disconnected or reconnected and this message was for the previous connection case Event(RelayOnionMessage(messageId, _, replyTo_opt), _) => @@ -388,6 +397,9 @@ class Peer(val nodeParams: NodeParams, // let's bring existing/requested channels online channels.values.toSet[ActorRef].foreach(_ ! INPUT_RECONNECTED(connectionReady.peerConnection, connectionReady.localInit, connectionReady.remoteInit)) // we deduplicate with toSet because there might be two entries per channel (tmp id and final id) + // We tell our peer what our current feerates are. + connectionReady.peerConnection ! nodeParams.recommendedFeerates(remoteNodeId, nodeParams.currentFeerates, connectionReady.localInit.features, connectionReady.remoteInit.features) + goto(CONNECTED) using ConnectedData(connectionReady.address, connectionReady.peerConnection, connectionReady.localInit, connectionReady.remoteInit, channels) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index f7790558a0..d61b36cdf6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -429,6 +429,11 @@ object LightningMessageCodecs { // + val recommendedFeeratesCodec: Codec[RecommendedFeerates] = ( + ("chainHash" | blockHash) :: + ("fundingFeerate" | feeratePerKw) :: + ("commitmentFeerate" | feeratePerKw)).as[RecommendedFeerates] + val unknownMessageCodec: Codec[UnknownMessage] = ( ("tag" | uint16) :: ("message" | bytes) @@ -479,6 +484,8 @@ object LightningMessageCodecs { .typecase(513, onionMessageCodec) // NB: blank lines to minimize merge conflicts + // + .typecase(35025, recommendedFeeratesCodec) // .typecase(37000, spliceInitCodec) .typecase(37002, spliceAckCodec) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 8c97570e4b..cb5e2a81c5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -601,4 +601,10 @@ case class OnionMessage(blindingKey: PublicKey, onionRoutingPacket: OnionRouting // +/** + * This message informs our peers of the feerates we recommend using. + * We may reject funding attempts that use values that are too far from our recommended feerates. + */ +case class RecommendedFeerates(chainHash: BlockHash, fundingFeerate: FeeratePerKw, commitmentFeerate: FeeratePerKw) extends SetupMessage with HasChainHash + case class UnknownMessage(tag: Int, data: ByteVector) extends LightningMessage \ No newline at end of file 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 d51388e45e..50a37dd3a0 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 @@ -25,8 +25,8 @@ import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features._ import fr.acinq.eclair.TestConstants._ import fr.acinq.eclair._ -import fr.acinq.eclair.blockchain.DummyOnChainWallet import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} +import fr.acinq.eclair.blockchain.{CurrentFeerates, DummyOnChainWallet} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.states.ChannelStateTestsTags import fr.acinq.eclair.io.Peer._ @@ -112,6 +112,7 @@ class PeerSpec extends FixtureSpec { switchboard.send(peer, Peer.Init(channels)) val localInit = protocol.Init(peer.underlyingActor.nodeParams.features.initFeatures()) switchboard.send(peer, PeerConnection.ConnectionReady(peerConnection.ref, remoteNodeId, fakeIPAddress, outgoing = true, localInit, remoteInit)) + peerConnection.expectMsgType[RecommendedFeerates] val probe = TestProbe() probe.send(peer, Peer.GetPeerInfo(Some(probe.ref.toTyped))) val peerInfo = probe.expectMsgType[Peer.PeerInfo] @@ -282,6 +283,7 @@ class PeerSpec extends FixtureSpec { } peerConnection2.send(peer, PeerConnection.ConnectionReady(peerConnection2.ref, remoteNodeId, fakeIPAddress, outgoing = false, localInit, remoteInit)) + peerConnection2.expectMsgType[RecommendedFeerates] // peer should kill previous connection peerConnection1.expectMsg(PeerConnection.Kill(PeerConnection.KillReason.ConnectionReplaced)) channel.expectMsg(INPUT_DISCONNECTED) @@ -291,6 +293,7 @@ class PeerSpec extends FixtureSpec { } peerConnection3.send(peer, PeerConnection.ConnectionReady(peerConnection3.ref, remoteNodeId, fakeIPAddress, outgoing = false, localInit, remoteInit)) + peerConnection3.expectMsgType[RecommendedFeerates] // peer should kill previous connection peerConnection2.expectMsg(PeerConnection.Kill(PeerConnection.KillReason.ConnectionReplaced)) channel.expectMsg(INPUT_DISCONNECTED) @@ -325,6 +328,16 @@ class PeerSpec extends FixtureSpec { monitor.expectMsg(FSM.Transition(reconnectionTask, ReconnectionTask.CONNECTING, ReconnectionTask.IDLE)) } + test("send recommended feerates when feerate changes") { f => + import f._ + + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + + // We regularly update our internal feerates. + peer ! CurrentFeerates(FeeratesPerKw(FeeratePerKw(253 sat), FeeratePerKw(1000 sat), FeeratePerKw(2500 sat), FeeratePerKw(5000 sat), FeeratePerKw(10_000 sat))) + peerConnection.expectMsg(RecommendedFeerates(Block.RegtestGenesisBlock.hash, FeeratePerKw(2500 sat), FeeratePerKw(5000 sat))) + } + test("don't spawn a channel with duplicate temporary channel id") { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala index b9a687b2c7..0d3caa27a1 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -488,6 +488,19 @@ class LightningMessageCodecsSpec extends AnyFunSuite { } } + test("encode/decode recommended_feerates") { + val testCases = Seq( + RecommendedFeerates(Block.TestnetGenesisBlock.hash, FeeratePerKw(2500 sat), FeeratePerKw(2500 sat)) -> hex"88d1 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 000009c4 000009c4", + RecommendedFeerates(Block.TestnetGenesisBlock.hash, FeeratePerKw(5000 sat), FeeratePerKw(253 sat)) -> hex"88d1 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 00001388 000000fd", + ) + for ((expected, encoded) <- testCases) { + val decoded = lightningMessageCodec.decode(encoded.bits).require.value + assert(decoded == expected) + val reEncoded = lightningMessageCodec.encode(decoded).require.bytes + assert(reEncoded == encoded) + } + } + test("unknown messages") { // Non-standard tag number so this message can only be handled by a codec with a fallback val unknown = UnknownMessage(tag = 47282, data = ByteVector32.Zeroes.bytes)