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 b668ff62b7..e9728bd326 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -305,6 +305,12 @@ object Features { val mandatory = 54 } + /** This feature bit indicates that the node is a mobile wallet that can be woken up via push notifications. */ + case object WakeUpNotificationClient extends Feature with InitFeature { + val rfcName = "wake_up_notification_client" + val mandatory = 132 + } + // TODO: @t-bast: update feature bits once spec-ed (currently reserved here: https://github.com/lightningnetwork/lightning-rfc/issues/605) // We're not advertising these bits yet in our announcements, clients have to assume support. // This is why we haven't added them yet to `areSupported`. @@ -369,6 +375,7 @@ object Features { PaymentMetadata, ZeroConf, KeySend, + WakeUpNotificationClient, TrampolinePaymentPrototype, AsyncPaymentPrototype, SplicePrototype, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala index 98f1708d3f..a0d3da40bb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala @@ -66,6 +66,7 @@ object NodeRelay { private case class WrappedPaymentFailed(paymentFailed: PaymentFailed) extends Command private case class WrappedPeerReadyResult(result: PeerReadyNotifier.Result) extends Command private case class WrappedResolvedPaths(resolved: Seq[ResolvedPath]) extends Command + private case class WrappedPeerInfo(isPeer: Boolean, remoteFeatures_opt: Option[Features[InitFeature]]) extends Command private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command // @formatter:on @@ -137,24 +138,6 @@ object NodeRelay { } } - /** This function identifies whether the next node is a wallet node directly connected to us, and returns its node_id. */ - private def nextWalletNodeId(nodeParams: NodeParams, recipient: Recipient): Option[PublicKey] = { - recipient match { - // This recipient is only used when we're the payment initiator. - case _: SpontaneousRecipient => None - // When relaying to a trampoline node, the next node may be a wallet node directly connected to us, but we don't - // want to have false positives. Feature branches should check an internal DB/cache to confirm. - case r: ClearRecipient if r.nextTrampolineOnion_opt.nonEmpty => None - // If we're relaying to a non-trampoline recipient, it's never a wallet node. - case _: ClearRecipient => None - // When using blinded paths, we may be the introduction node for a wallet node directly connected to us. - case r: BlindedRecipient => r.blindedHops.head.resolved.route match { - case BlindedPathsResolver.PartialBlindedRoute(walletNodeId: EncodedNodeId.WithPublicKey.Wallet, _, _) => Some(walletNodeId.publicKey) - case _ => None - } - } - } - /** Compute route params that honor our fee and cltv requirements. */ private def computeRouteParams(nodeParams: NodeParams, amountIn: MilliSatoshi, expiryIn: CltvExpiry, amountOut: MilliSatoshi, expiryOut: CltvExpiry): RouteParams = { nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams @@ -264,14 +247,14 @@ class NodeRelay private(nodeParams: NodeParams, val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks val recipient = ClearRecipient(payloadOut.outgoingNodeId, Features.empty, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt) context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId) - ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt) + attemptWakeUpIfRecipientIsWallet(upstream, recipient, nextPayload, nextPacket_opt) case payloadOut: IntermediatePayload.NodeRelay.ToNonTrampoline => val paymentSecret = payloadOut.paymentSecret val features = Features(payloadOut.invoiceFeatures).invoiceFeatures() val extraEdges = payloadOut.invoiceRoutingInfo.flatMap(Bolt11Invoice.toExtraEdges(_, payloadOut.outgoingNodeId)) val recipient = ClearRecipient(payloadOut.outgoingNodeId, features, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, extraEdges, payloadOut.paymentMetadata) context.log.debug("forwarding payment to non-trampoline recipient {}", recipient.nodeId) - ensureRecipientReady(upstream, recipient, nextPayload, None) + attemptWakeUpIfRecipientIsWallet(upstream, recipient, nextPayload, None) case payloadOut: IntermediatePayload.NodeRelay.ToBlindedPaths => // Blinded paths in Bolt 12 invoices may encode the introduction node with an scid and a direction: we need to // resolve that to a nodeId in order to reach that introduction node and use the blinded path. @@ -287,21 +270,40 @@ class NodeRelay private(nodeParams: NodeParams, // We don't have access to the invoice: we use the only node_id that somewhat makes sense for the recipient. val blindedNodeId = resolved.head.route.blindedNodeIds.last val recipient = BlindedRecipient.fromPaths(blindedNodeId, Features(payloadOut.invoiceFeatures).invoiceFeatures(), payloadOut.amountToForward, payloadOut.outgoingCltv, resolved, Set.empty) - context.log.debug("forwarding payment to blinded recipient {}", recipient.nodeId) - ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt) + resolved.head.route match { + case BlindedPathsResolver.PartialBlindedRoute(walletNodeId: EncodedNodeId.WithPublicKey.Wallet, _, _) if nodeParams.peerWakeUpConfig.enabled => + context.log.debug("forwarding payment to blinded peer {}", walletNodeId.publicKey) + attemptWakeUp(upstream, walletNodeId.publicKey, recipient, nextPayload, nextPacket_opt) + case _ => + context.log.debug("forwarding payment to blinded recipient {}", recipient.nodeId) + relay(upstream, recipient, None, None, nextPayload, nextPacket_opt) + } } } } } - /** - * The next node may be a mobile wallet directly connected to us: in that case, we'll need to wake them up before - * relaying the payment. - */ - private def ensureRecipientReady(upstream: Upstream.Hot.Trampoline, recipient: Recipient, nextPayload: IntermediatePayload.NodeRelay, nextPacket_opt: Option[OnionRoutingPacket]): Behavior[Command] = { - nextWalletNodeId(nodeParams, recipient) match { - case Some(walletNodeId) if nodeParams.peerWakeUpConfig.enabled => waitForPeerReady(upstream, walletNodeId, recipient, nextPayload, nextPacket_opt) - case walletNodeId_opt => relay(upstream, recipient, walletNodeId_opt, None, nextPayload, nextPacket_opt) + /** The next node may be a mobile wallet directly connected to us: in that case, we'll need to wake them up before relaying the payment. */ + private def attemptWakeUpIfRecipientIsWallet(upstream: Upstream.Hot.Trampoline, recipient: Recipient, nextPayload: IntermediatePayload.NodeRelay, nextPacket_opt: Option[OnionRoutingPacket]): Behavior[Command] = { + if (nodeParams.peerWakeUpConfig.enabled) { + val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.GetPeerInfo]](_ => WrappedPeerInfo(isPeer = false, remoteFeatures_opt = None)) + val peerInfoAdapter = context.messageAdapter[Peer.PeerInfoResponse] { + case _: Peer.PeerNotFound => WrappedPeerInfo(isPeer = false, remoteFeatures_opt = None) + case info: Peer.PeerInfo => WrappedPeerInfo(isPeer = true, info.features) + } + register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, recipient.nodeId, Peer.GetPeerInfo(Some(peerInfoAdapter))) + Behaviors.receiveMessagePartial { + rejectExtraHtlcPartialFunction orElse { + case info: WrappedPeerInfo => + if (info.isPeer && info.remoteFeatures_opt.exists(_.hasFeature(Features.WakeUpNotificationClient))) { + attemptWakeUp(upstream, recipient.nodeId, recipient, nextPayload, nextPacket_opt) + } else { + relay(upstream, recipient, None, None, nextPayload, nextPacket_opt) + } + } + } + } else { + relay(upstream, recipient, None, None, nextPayload, nextPacket_opt) } } @@ -309,7 +311,7 @@ class NodeRelay private(nodeParams: NodeParams, * The next node is the payment recipient. They are directly connected to us and may be offline. We try to wake them * up and will relay the payment once they're connected and channels are reestablished. */ - private def waitForPeerReady(upstream: Upstream.Hot.Trampoline, walletNodeId: PublicKey, recipient: Recipient, nextPayload: IntermediatePayload.NodeRelay, nextPacket_opt: Option[OnionRoutingPacket]): Behavior[Command] = { + private def attemptWakeUp(upstream: Upstream.Hot.Trampoline, walletNodeId: PublicKey, recipient: Recipient, nextPayload: IntermediatePayload.NodeRelay, nextPacket_opt: Option[OnionRoutingPacket]): Behavior[Command] = { context.log.info("trying to wake up next peer (nodeId={})", walletNodeId) val notifier = context.spawnAnonymous(PeerReadyNotifier(walletNodeId, timeout_opt = Some(Left(nodeParams.peerWakeUpConfig.timeout)))) notifier ! PeerReadyNotifier.NotifyWhenPeerReady(context.messageAdapter(WrappedPeerReadyResult)) @@ -349,7 +351,7 @@ class NodeRelay private(nodeParams: NodeParams, } val payFSM = outgoingPaymentFactory.spawnOutgoingPayFSM(context, paymentCfg, useMultiPart) payFSM ! payment - sending(upstream, recipient, recipientFeatures_opt, payloadOut, TimestampMilli.now(), fulfilledUpstream = false) + sending(upstream, recipient, walletNodeId_opt, recipientFeatures_opt, payloadOut, TimestampMilli.now(), fulfilledUpstream = false) } /** @@ -361,6 +363,7 @@ class NodeRelay private(nodeParams: NodeParams, */ private def sending(upstream: Upstream.Hot.Trampoline, recipient: Recipient, + walletNodeId_opt: Option[PublicKey], recipientFeatures_opt: Option[Features[InitFeature]], nextPayload: IntermediatePayload.NodeRelay, startedAt: TimestampMilli, @@ -373,7 +376,7 @@ class NodeRelay private(nodeParams: NodeParams, // We want to fulfill upstream as soon as we receive the preimage (even if not all HTLCs have fulfilled downstream). context.log.debug("got preimage from downstream") fulfillPayment(upstream, paymentPreimage) - sending(upstream, recipient, recipientFeatures_opt, nextPayload, startedAt, fulfilledUpstream = true) + sending(upstream, recipient, walletNodeId_opt, recipientFeatures_opt, nextPayload, startedAt, fulfilledUpstream = true) } else { // we don't want to fulfill multiple times Behaviors.same @@ -388,7 +391,7 @@ class NodeRelay private(nodeParams: NodeParams, recordRelayDuration(startedAt, isSuccess = true) stopping() case WrappedPaymentFailed(PaymentFailed(_, _, failures, _)) => - nextWalletNodeId(nodeParams, recipient) match { + walletNodeId_opt match { case Some(walletNodeId) if shouldAttemptOnTheFlyFunding(nodeParams, recipientFeatures_opt, failures)(context) => context.log.info("trampoline payment failed, attempting on-the-fly funding") attemptOnTheFlyFunding(upstream, walletNodeId, recipient, nextPayload, failures, startedAt) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/NodeRelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/NodeRelayerSpec.scala index ea51e3e175..30c821c0ef 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/NodeRelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/NodeRelayerSpec.scala @@ -16,7 +16,6 @@ package fr.acinq.eclair.payment.relay -import akka.actor.Status import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} import akka.actor.typed.ActorRef import akka.actor.typed.eventstream.EventStream @@ -115,6 +114,7 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl .modify(_.peerWakeUpConfig.enabled).setToIf(test.tags.contains(wakeUpEnabled))(true) .modify(_.peerWakeUpConfig.timeout).setToIf(test.tags.contains(wakeUpTimeout))(100 millis) .modify(_.features.activated).usingIf(test.tags.contains(onTheFlyFunding))(_ + (Features.OnTheFlyFunding -> FeatureSupport.Optional)) + .modify(_.features.activated).usingIf(test.tags.contains(wakeUpEnabled))(_ + (Features.WakeUpNotificationClient -> FeatureSupport.Optional)) val router = TestProbe[Any]("router") val register = TestProbe[Any]("register") val eventListener = TestProbe[PaymentEvent]("event-listener") @@ -630,10 +630,7 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl register.expectNoMessage(100 millis) } - // The two tests below are disabled by default, since there is no default mechanism to flag the next trampoline node - // as being a wallet node. Feature branches that support wallet software should restore those tests and flag the - // outgoing node_id as being a wallet node. - ignore("relay incoming multi-part payment with on-the-fly funding", Tag(wakeUpEnabled), Tag(onTheFlyFunding)) { f => + test("relay incoming multi-part payment with on-the-fly funding", Tag(wakeUpEnabled), Tag(onTheFlyFunding)) { f => import f._ val (peerReadyManager, switchboard) = createWakeUpActors() @@ -642,6 +639,11 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl val (nodeRelayer, parent) = f.createNodeRelay(incomingMultiPart.head) incomingMultiPart.foreach(p => nodeRelayer ! NodeRelay.Relay(p, randomKey().publicKey)) + // We first check if the outgoing node is our peer and supports wake-up notifications. + val peerFeaturesRequest = register.expectMessageType[Register.ForwardNodeId[Peer.GetPeerInfo]] + assert(peerFeaturesRequest.nodeId == outgoingNodeId) + peerFeaturesRequest.message.replyTo.foreach(_ ! Peer.PeerInfo(TestProbe[Any]().ref.toClassic, outgoingNodeId, Peer.DISCONNECTED, Some(nodeParams.features.initFeatures()), None, Set.empty)) + // The remote node is a wallet node: we wake them up before relaying the payment. val wakeUp = peerReadyManager.expectMessageType[PeerReadyManager.Register] assert(wakeUp.remoteNodeId == outgoingNodeId) @@ -676,7 +678,7 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl parent.expectMessageType[NodeRelayer.RelayComplete] } - ignore("relay incoming multi-part payment with on-the-fly funding (non-liquidity failure)", Tag(wakeUpEnabled), Tag(onTheFlyFunding)) { f => + test("relay incoming multi-part payment with on-the-fly funding (non-liquidity failure)", Tag(wakeUpEnabled), Tag(onTheFlyFunding)) { f => import f._ val (peerReadyManager, switchboard) = createWakeUpActors() @@ -685,6 +687,11 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl val (nodeRelayer, parent) = f.createNodeRelay(incomingMultiPart.head) incomingMultiPart.foreach(p => nodeRelayer ! NodeRelay.Relay(p, randomKey().publicKey)) + // We first check if the outgoing node is our peer and supports wake-up notifications. + val peerFeaturesRequest = register.expectMessageType[Register.ForwardNodeId[Peer.GetPeerInfo]] + assert(peerFeaturesRequest.nodeId == outgoingNodeId) + peerFeaturesRequest.message.replyTo.foreach(_ ! Peer.PeerInfo(TestProbe[Any]().ref.toClassic, outgoingNodeId, Peer.DISCONNECTED, Some(nodeParams.features.initFeatures()), None, Set.empty)) + // The remote node is a wallet node: we wake them up before relaying the payment. peerReadyManager.expectMessageType[PeerReadyManager.Register].replyTo ! PeerReadyManager.Registered(outgoingNodeId, otherAttempts = 0) val peerInfo = switchboard.expectMessageType[Switchboard.GetPeerInfo]