Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,25 @@

## Major changes

<insert 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

Expand Down
24 changes: 9 additions & 15 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
6 changes: 6 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -484,6 +489,7 @@ object Features {
Quiescence,
AttributionData,
OnionMessages,
OnionMessagesChannelsOnly,
ZeroFeeCommitments,
ProvideStorage,
ChannelType,
Expand Down
9 changes: 4 additions & 5 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_'")
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is Features.OnionMessagesChannelsOnly not checked here ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you do with it? The RelauPolicy is either RelayAll or RelayChannelsOnly, there's no other value available. There is an enabled field on the entire policy that is used to disable it.


val purgeInvoicesInterval = if (config.getBoolean("purge-expired-invoices.enabled")) {
Some(FiniteDuration(config.getDuration("purge-expired-invoices.interval").toMinutes, TimeUnit.MINUTES))
Expand Down
2 changes: 1 addition & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do we check that we indeed have channels with that peer in the `Features.OnionMessagesChannelsOnly case?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is done inside MessageRelay itself (see waitForPreviousPeerForPolicyCheck and waitForNextPeerForPolicyCheck). To be honest, the whole message relay stack is a mess (it involves way too many tiny actors speaking to each other) and would benefit from a larger refactoring, at which point it probably would make more sense to verify whether we have a channel directly inside the Peer actor (by checking activeChannels for example). But I didn't want to embark on this refactoring right now 😅 , I just wanted to make sure that we supported that feature bit since it will be used by lnd and ldk soon.

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")
Expand Down
33 changes: 17 additions & 16 deletions eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) =>

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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]] = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Loading
Loading