diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 3fa778748d..2a06d7793e 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -4,7 +4,25 @@ ## Major changes - +### Advertize when onion messages require channels + +We add support for the `option_onion_messages_only_channels` feature that was recently added to the BOLTs +(see https://github.com/lightning/bolts/pull/1343 for more details), which lets us tell the network that +we will only relay onion messages from peers with whom we already have channels. + +This was supported previously by setting your relay policy in `eclair.conf` to: + +```conf +eclair.onion-messages.relay-policy = "channels-only" +``` + +This `relay-policy` field has been removed from the configuration. If you wish to only relay onion messages +from peers with whom you have channels, you should set the corresponding features in your `eclair.conf`: + +```conf +eclair.features.option_onion_messages = disabled +eclair.features.option_onion_messages_only_channels = optional +``` ### Configuration changes diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 098fc99bd9..2b2fd38ae8 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -75,7 +75,11 @@ eclair { option_dual_fund = optional option_quiesce = optional option_attribution_data = optional + // If you want to relay onion messages only from peers with whom you have channels, you should disable + // option_onion_messages and enable option_onion_messages_only_channels instead. + // If you don't want to relay onion messages at all, you should disable both features. option_onion_messages = optional + option_onion_messages_only_channels = disabled zero_fee_commitments = disabled // This feature should only be enabled when acting as an LSP for mobile wallets. // When activating this feature, the peer-storage section should be customized to match desired SLAs. @@ -626,25 +630,15 @@ eclair { } onion-messages { - # Valid values are - # - channels-only: Only relay messages from peers with which we have a channel to peers with which we have a channel. - # - relay-all: Relay everything and create new connections if necessary - relay-policy = "channels-only" - # If you want to never relay onion messages (but still be able to send and receive them), you need to set - # features.option_onion_messages = disabled - - # Transient connections opened to relay messages will be closed after this delay of inactivity + // Transient connections opened to relay messages will be closed after this delay of inactivity. kill-transient-connection-after = 30 seconds - + // Maximum number of onion messages accepted per second (for each peer). max-per-peer-per-second = 10 - - # Minimum number of hops before our node to hide it in the reply paths that we build + // Minimum number of hops before our node to hide it in the reply paths that we build. min-intermediate-hops = 6 - - # Consider a message to be lost if we haven't received a reply after that amount of time + // Consider a message to be lost if we haven't received a reply after that amount of time. reply-timeout = 15 seconds - - # If we expect a reply but do not get one, retry until we reach this number of attempts + // If we expect a reply but do not get one, retry until we reach this number of attempts. max-attempts = 3 } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala index de93ef08a8..ef7e9d3ec9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -404,6 +404,11 @@ object Features { val mandatory = 62 } + case object OnionMessagesChannelsOnly extends Feature with InitFeature with NodeFeature { + val rfcName = "option_onion_messages_only_channels" + val mandatory = 66 + } + case object SimpleTaprootChannels extends Feature with InitFeature with NodeFeature with ChannelTypeFeature { val rfcName = "option_simple_taproot" val mandatory = 80 @@ -484,6 +489,7 @@ object Features { Quiescence, AttributionData, OnionMessages, + OnionMessagesChannelsOnly, ZeroFeeCommitments, ProvideStorage, ChannelType, 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 931317c776..74091fdde0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -27,7 +27,7 @@ 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._ -import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy} +import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly} import fr.acinq.eclair.io.{PeerConnection, PeerReadyNotifier} import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig import fr.acinq.eclair.payment.offer.OffersConfig @@ -347,6 +347,8 @@ object NodeParams extends Logging { // v0.12.0 "channel.mindepth-blocks" -> "channel.min-depth-blocks", "sync-whitelist" -> "router.sync.whitelist", + // v0.14.0 + "onion-messages.relay-policy" -> "features.option_onion_messages, features.option_onion_messages_only_channels", ) deprecatedKeyPaths.foreach { case (old, new_) => require(!config.hasPath(old), s"configuration key '$old' has been replaced by '$new_'") @@ -515,10 +517,7 @@ object NodeParams extends Logging { case "stop" => UnhandledExceptionStrategy.Stop } - val onionMessageRelayPolicy: RelayPolicy = config.getString("onion-messages.relay-policy") match { - case "channels-only" => RelayChannelsOnly - case "relay-all" => RelayAll - } + val onionMessageRelayPolicy = if (features.hasFeature(Features.OnionMessages)) RelayAll else RelayChannelsOnly val purgeInvoicesInterval = if (config.getBoolean("purge-expired-invoices.enabled")) { Some(FiniteDuration(config.getDuration("purge-expired-invoices.interval").toMinutes, TimeUnit.MINUTES)) 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 f7e57c97c0..4b2a75b969 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 @@ -613,7 +613,7 @@ class Peer(val nodeParams: NodeParams, OnionMessages.process(nodeParams.privateKey, msg) match { case OnionMessages.DropMessage(reason) => log.info("dropping message from {}: {}", remoteNodeId.value.toHex, reason.toString) - case OnionMessages.SendMessage(nextNode, message) if nodeParams.features.hasFeature(Features.OnionMessages) => + case OnionMessages.SendMessage(nextNode, message) if nodeParams.features.hasFeature(Features.OnionMessages) || nodeParams.features.hasFeature(Features.OnionMessagesChannelsOnly) => val messageId = randomBytes32() log.info("relaying onion message with messageId={}", messageId) val relay = context.spawn(Behaviors.supervise(MessageRelay(nodeParams, switchboard, register, router)).onFailure(typed.SupervisorStrategy.stop), s"relay-message-$messageId") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala index 13d98e2c8a..329a330707 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala @@ -46,7 +46,7 @@ object Graph { * @param cltv sum of each edge's cltv * @param successProbability estimate of the probability that the payment would succeed using this path * @param fees total fees of the path - * @param weight cost multiplied by a factor based on heuristics (see [[PaymentWeightRatios]]). + * @param weight cost multiplied by a factor based on heuristics */ case class PaymentPathWeight(amount: MilliSatoshi, length: Int, cltv: CltvExpiryDelta, successProbability: Double, fees: MilliSatoshi, virtualFees: MilliSatoshi, weight: Double) extends PathWeight { override def canUseEdge(edge: GraphEdge): Boolean = @@ -64,7 +64,7 @@ object Graph { * The cumulative weight of a set of edges (path in the graph). * * @param length number of edges in the path - * @param weight cost multiplied by a factor based on heuristics (see [[PaymentWeightRatios]]). + * @param weight cost multiplied by a factor based on heuristics (see [[PaymentPathWeight]]). */ case class MessagePathWeight(length: Int, weight: Double) extends PathWeight { override def canUseEdge(edge: GraphEdge): Boolean = true @@ -188,7 +188,7 @@ object Graph { * Yen's algorithm to find the k-shortest (loop-less) paths in a graph, uses dijkstra as search algo. Is guaranteed to * terminate finding at most @pathsToFind paths sorted by cost (the cheapest is in position 0). * - * @param graph the graph on which will be performed the search + * @param g the graph on which will be performed the search * @param sourceNode the starting node of the path we're looking for (payer) * @param targetNode the destination node of the path (recipient) * @param amount amount to send to the last node @@ -215,7 +215,9 @@ object Graph { includeLocalChannelCost: Boolean): Seq[WeightedPath[PaymentPathWeight]] = { // find the shortest path (k = 0) val targetWeight = PaymentPathWeight(amount) - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost) match { + // we don't require any specific feature for intermediate nodes + val validateNodeFeatures = (_: Features[NodeFeature]) => true + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, validateNodeFeatures, currentBlockHeight, wr, includeLocalChannelCost) match { case None => Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty) case Some(shortestPath) => @@ -253,7 +255,7 @@ object Graph { val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet val rootPathWeight = pathWeight(g.balances, sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost) // find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths - dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost) match { + dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, validateNodeFeatures, currentBlockHeight, wr, includeLocalChannelCost) match { case Some(spurPath) => val completePath = spurPath ++ rootPathEdges val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost)) @@ -289,7 +291,7 @@ object Graph { * @param extraEdges additional edges that can be used (e.g. private channels from invoices) * @param initialWeight weight that will be applied to the target node * @param boundaries a predicate function that can be used to impose limits on the outcome of the search - * @param nodeFeatures features required for nodes on the path + * @param validateNodeFeatures must return true if the node's features are compatible with the path we want * @param currentBlockHeight the height of the chain tip (latest block) * @param wr ratios used to 'weight' edges when searching for the shortest path * @param includeLocalChannelCost if the path is for relaying and we need to include the cost of the local channel @@ -302,7 +304,7 @@ object Graph { extraEdges: Set[GraphEdge], initialWeight: RichWeight, boundaries: RichWeight => Boolean, - nodeFeatures: Features[NodeFeature], + validateNodeFeatures: Features[NodeFeature] => Boolean, currentBlockHeight: BlockHeight, wr: WeightRatios[RichWeight], includeLocalChannelCost: Boolean): Option[Seq[GraphEdge]] = { @@ -344,7 +346,7 @@ object Graph { if (current.weight.canUseEdge(edge) && !ignoredEdges.contains(edge.desc) && !ignoredVertices.contains(neighbor) && - (neighbor == sourceNode || g.graph.getVertexFeatures(neighbor).areSupported(nodeFeatures))) { + (neighbor == sourceNode || validateNodeFeatures(g.graph.getVertexFeatures(neighbor)))) { // NB: this contains the amount (including fees) that will need to be sent to `neighbor`, but the amount that // will be relayed through that edge is the one in `currentWeight`. val neighborWeight = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost) @@ -387,8 +389,10 @@ object Graph { ignoredVertices: Set[PublicKey], boundaries: MessagePathWeight => Boolean, currentBlockHeight: BlockHeight, - wr: MessageWeightRatios): Option[Seq[GraphEdge]] = - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true) + wr: MessageWeightRatios): Option[Seq[GraphEdge]] = { + val validateNodeFeatures = (f: Features[NodeFeature]) => f.hasFeature(Features.OnionMessages) || f.hasFeature(Features.OnionMessagesChannelsOnly) + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, validateNodeFeatures, currentBlockHeight, wr, includeLocalChannelCost = true) + } /** * Find non-overlapping (no vertices shared) payment paths that support route blinding @@ -409,8 +413,9 @@ object Graph { val paths = new mutable.ArrayBuffer[WeightedPath[PaymentPathWeight]](pathsToFind) val verticesToIgnore = new mutable.HashSet[PublicKey]() verticesToIgnore.addAll(ignoredVertices) + val validateNodeFeatures = (f: Features[NodeFeature]) => f.hasFeature(Features.RouteBlinding) for (_ <- 1 to pathsToFind) { - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true) match { + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, validateNodeFeatures, currentBlockHeight, wr, includeLocalChannelCost = true) match { case Some(path) => val weight = pathWeight(g.balances, sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true) paths += WeightedPath(path, weight) @@ -474,10 +479,6 @@ object Graph { val CAPACITY_CHANNEL_LOW: MilliSatoshi = MilliBtc(1).toMilliSatoshi val CAPACITY_CHANNEL_HIGH: MilliSatoshi = Btc(1).toMilliSatoshi - // Low/High bound for CLTV channel value - val CLTV_LOW: Int = 9 - val CLTV_HIGH: Int = 2016 - /** * Normalize the given value between (0, 1). If the @param value is outside the min/max window we flatten it to something very close to the * extremes but always bigger than zero so it's guaranteed to never return zero @@ -657,7 +658,7 @@ object Graph { }) } - def addVertices(announcements: Iterable[NodeAnnouncement]): DirectedGraph = announcements.foldLeft(this)((acc, ann) => acc.addOrUpdateVertex(ann)) + private def addVertices(announcements: Iterable[NodeAnnouncement]): DirectedGraph = announcements.foldLeft(this)((acc, ann) => acc.addOrUpdateVertex(ann)) /** * Note this operation will traverse all edges in the graph (expensive) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/MessageIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/MessageIntegrationSpec.scala index 2b2a4551e4..164dc03e27 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/MessageIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/MessageIntegrationSpec.scala @@ -47,11 +47,11 @@ class MessageIntegrationSpec extends IntegrationSpec { implicit val timeout: Timeout = FiniteDuration(30, SECONDS) test("start eclair nodes") { - instantiateEclairNode("A", ConfigFactory.parseMap(Map("eclair.node-alias" -> "A", "eclair.server.port" -> 30700, "eclair.api.port" -> 30780, s"eclair.features.${Features.OnionMessages.rfcName}" -> "optional", "eclair.onion-messages.relay-policy" -> "relay-all", "eclair.onion-messages.reply-timeout" -> "1 minute").asJava).withFallback(commonConfig)) - instantiateEclairNode("B", ConfigFactory.parseMap(Map("eclair.node-alias" -> "B", "eclair.server.port" -> 30701, "eclair.api.port" -> 30781, s"eclair.features.${Features.OnionMessages.rfcName}" -> "optional", "eclair.onion-messages.relay-policy" -> "relay-all", "eclair.onion-messages.reply-timeout" -> "1 second").asJava).withFallback(commonConfig)) - instantiateEclairNode("C", ConfigFactory.parseMap(Map("eclair.node-alias" -> "C", "eclair.server.port" -> 30702, "eclair.api.port" -> 30782, s"eclair.features.${Features.OnionMessages.rfcName}" -> "optional", "eclair.onion-messages.relay-policy" -> "relay-all").asJava).withFallback(commonConfig)) + instantiateEclairNode("A", ConfigFactory.parseMap(Map("eclair.node-alias" -> "A", "eclair.server.port" -> 30700, "eclair.api.port" -> 30780, s"eclair.features.${Features.OnionMessages.rfcName}" -> "optional", "eclair.onion-messages.reply-timeout" -> "1 minute").asJava).withFallback(commonConfig)) + instantiateEclairNode("B", ConfigFactory.parseMap(Map("eclair.node-alias" -> "B", "eclair.server.port" -> 30701, "eclair.api.port" -> 30781, s"eclair.features.${Features.OnionMessages.rfcName}" -> "optional", "eclair.onion-messages.reply-timeout" -> "1 second").asJava).withFallback(commonConfig)) + instantiateEclairNode("C", ConfigFactory.parseMap(Map("eclair.node-alias" -> "C", "eclair.server.port" -> 30702, "eclair.api.port" -> 30782, s"eclair.features.${Features.OnionMessages.rfcName}" -> "optional").asJava).withFallback(commonConfig)) instantiateEclairNode("D", ConfigFactory.parseMap(Map("eclair.node-alias" -> "D", "eclair.server.port" -> 30703, "eclair.api.port" -> 30783).asJava).withFallback(commonConfig)) - instantiateEclairNode("E", ConfigFactory.parseMap(Map("eclair.node-alias" -> "E", "eclair.server.port" -> 30704, "eclair.api.port" -> 30784, s"eclair.features.${Features.OnionMessages.rfcName}" -> "optional", "eclair.onion-messages.relay-policy" -> "channels-only").asJava).withFallback(commonConfig)) + instantiateEclairNode("E", ConfigFactory.parseMap(Map("eclair.node-alias" -> "E", "eclair.server.port" -> 30704, "eclair.api.port" -> 30784, s"eclair.features.${Features.OnionMessages.rfcName}" -> "disabled", s"eclair.features.${Features.OnionMessagesChannelsOnly.rfcName}" -> "optional").asJava).withFallback(commonConfig)) instantiateEclairNode("F", ConfigFactory.parseMap(Map("eclair.node-alias" -> "F", "eclair.server.port" -> 30705, "eclair.api.port" -> 30785, s"eclair.features.${Features.OnionMessages.rfcName}" -> "disabled").asJava).withFallback(commonConfig)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala index dea2ee93be..46bbc04dc8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala @@ -65,7 +65,7 @@ class PaymentIntegrationSpec extends IntegrationSpec { test("start eclair nodes") { instantiateEclairNode("A", ConfigFactory.parseMap(Map("eclair.node-alias" -> "A", "eclair.channel.expiry-delta-blocks" -> 130, "eclair.server.port" -> 29730, "eclair.api.port" -> 28080, "eclair.channel.channel-flags.announce-channel" -> false).asJava).withFallback(withAnchorOutputsZeroFeeHtlcTxs).withFallback(commonConfig)) // A's channels are private - instantiateEclairNode("B", ConfigFactory.parseMap(Map("eclair.node-alias" -> "B", "eclair.channel.expiry-delta-blocks" -> 131, "eclair.server.port" -> 29731, "eclair.api.port" -> 28081, "eclair.trampoline-payments-enable" -> true, "eclair.onion-messages.relay-policy" -> "relay-all").asJava).withFallback(withAnchorOutputsZeroFeeHtlcTxs).withFallback(commonConfig)) + instantiateEclairNode("B", ConfigFactory.parseMap(Map("eclair.node-alias" -> "B", "eclair.channel.expiry-delta-blocks" -> 131, "eclair.server.port" -> 29731, "eclair.api.port" -> 28081, "eclair.trampoline-payments-enable" -> true).asJava).withFallback(withAnchorOutputsZeroFeeHtlcTxs).withFallback(commonConfig)) instantiateEclairNode("C", ConfigFactory.parseMap(Map("eclair.node-alias" -> "C", "eclair.channel.expiry-delta-blocks" -> 132, "eclair.server.port" -> 29732, "eclair.api.port" -> 28082, "eclair.trampoline-payments-enable" -> true).asJava).withFallback(withDualFunding).withFallback(commonConfig)) instantiateEclairNode("D", ConfigFactory.parseMap(Map("eclair.node-alias" -> "D", "eclair.channel.expiry-delta-blocks" -> 133, "eclair.server.port" -> 29733, "eclair.api.port" -> 28083, "eclair.trampoline-payments-enable" -> true).asJava).withFallback(withAnchorOutputsZeroFeeHtlcTxs).withFallback(commonConfig)) instantiateEclairNode("E", ConfigFactory.parseMap(Map("eclair.node-alias" -> "E", "eclair.channel.expiry-delta-blocks" -> 134, "eclair.server.port" -> 29734, "eclair.api.port" -> 28084).asJava).withFallback(withDualFunding).withFallback(commonConfig)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala index 5e15a56b39..ccb096bc39 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala @@ -410,8 +410,8 @@ class GraphSpec extends AnyFunSuite { makeEdge(5L, d, e, 8 msat, 8, capacity = 1000 sat, minHtlc = 800 msat, maxHtlc = Some(900 msat)), makeEdge(5L, e, d, 9 msat, 9, capacity = 1000 sat, minHtlc = 900 msat, maxHtlc = Some(1000 msat)), )).addOrUpdateVertex(makeNodeAnnouncement(priv_a, "A", Color(0, 0, 0), Nil, Features(Features.OnionMessages -> FeatureSupport.Optional))) - .addOrUpdateVertex(makeNodeAnnouncement(priv_b, "B", Color(0, 0, 0), Nil, Features(Features.OnionMessages -> FeatureSupport.Optional))) - .addOrUpdateVertex(makeNodeAnnouncement(priv_c, "C", Color(0, 0, 0), Nil, Features(Features.OnionMessages -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_b, "B", Color(0, 0, 0), Nil, Features(Features.OnionMessagesChannelsOnly -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_c, "C", Color(0, 0, 0), Nil, Features(Features.OnionMessagesChannelsOnly -> FeatureSupport.Optional))) .addOrUpdateVertex(makeNodeAnnouncement(priv_d, "D", Color(0, 0, 0), Nil, Features(Features.OnionMessages -> FeatureSupport.Optional))) .addOrUpdateVertex(makeNodeAnnouncement(priv_e, "E", Color(0, 0, 0), Nil, Features(Features.OnionMessages -> FeatureSupport.Optional)))