Skip to content

Commit bddacda

Browse files
Publish hold times to the event stream (#3123)
Publish hold times from settled HTLCs. Refactor `TrampolinePaymentLifecycle` to allow decrypting the attribution data. Check hold times in integration tests.
1 parent 6fb7ac1 commit bddacda

4 files changed

Lines changed: 168 additions & 58 deletions

File tree

eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,16 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
103103
Metrics.PaymentAttempt.withTag(Tags.MultiPart, value = false).record(d.failures.size + 1)
104104
val p = PartialPayment(id, d.request.amount, d.cmd.amount - d.request.amount, htlc.channelId, Some(d.route.fullRoute))
105105
val remainingAttribution_opt = fulfill match {
106-
case HtlcResult.RemoteFulfill(fulfill) =>
107-
fulfill.attribution_opt.flatMap(Sphinx.Attribution.fulfillHoldTimes(_, d.sharedSecrets).remaining_opt)
106+
case HtlcResult.RemoteFulfill(updateFulfill) =>
107+
updateFulfill.attribution_opt match {
108+
case Some(attribution) =>
109+
val Sphinx.Attribution.UnwrappedAttribution(holdTimes, remaining_opt) = Sphinx.Attribution.fulfillHoldTimes(attribution, d.sharedSecrets)
110+
if (holdTimes.nonEmpty) {
111+
context.system.eventStream.publish(Router.ReportedHoldTimes(holdTimes))
112+
}
113+
remaining_opt
114+
case None => None
115+
}
108116
case _: HtlcResult.OnChainFulfill => None
109117
}
110118
myStop(d.request, Right(cfg.createPaymentSent(d.recipient, fulfill.paymentPreimage, p :: Nil, remainingAttribution_opt)))
@@ -170,6 +178,9 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
170178
private def handleRemoteFail(d: WaitingForComplete, fail: UpdateFailHtlc) = {
171179
import d._
172180
val htlcFailure = Sphinx.FailurePacket.decrypt(fail.reason, fail.attribution_opt, sharedSecrets)
181+
if (htlcFailure.holdTimes.nonEmpty) {
182+
context.system.eventStream.publish(Router.ReportedHoldTimes(htlcFailure.holdTimes))
183+
}
173184
((htlcFailure.failure match {
174185
case success@Right(e) =>
175186
Metrics.PaymentError.withTag(Tags.Failure, Tags.FailureType(RemoteFailure(request.amount, Nil, e))).increment()

eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala

Lines changed: 116 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package fr.acinq.eclair.payment.send
1818

19+
import akka.actor.typed.eventstream.EventStream
1920
import akka.actor.typed.scaladsl.adapter._
2021
import akka.actor.typed.scaladsl.{ActorContext, Behaviors}
2122
import akka.actor.typed.{ActorRef, Behavior}
@@ -27,6 +28,8 @@ import fr.acinq.eclair.io.Peer
2728
import fr.acinq.eclair.payment.OutgoingPaymentPacket.{NodePayload, buildOnion}
2829
import fr.acinq.eclair.payment.PaymentSent.PartialPayment
2930
import fr.acinq.eclair.payment._
31+
import fr.acinq.eclair.payment.send.TrampolinePayment.{buildOutgoingPayment, computeFees}
32+
import fr.acinq.eclair.router.Router
3033
import fr.acinq.eclair.router.Router.RouteParams
3134
import fr.acinq.eclair.wire.protocol.{PaymentOnion, PaymentOnionCodecs}
3235
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, Logs, MilliSatoshi, NodeParams, randomBytes32}
@@ -54,9 +57,9 @@ object TrampolinePaymentLifecycle {
5457
require(invoice.amount_opt.nonEmpty, "amount-less invoices are not supported in trampoline tests")
5558
}
5659
private case class TrampolinePeerNotFound(trampolineNodeId: PublicKey) extends Command
60+
private case class CouldntAddHtlc(failure: Throwable) extends Command
61+
private case class HtlcSettled(result: HtlcResult, part: PartialPayment, holdTimes: Seq[Sphinx.HoldTime]) extends Command
5762
private case class WrappedPeerChannels(channels: Seq[Peer.ChannelInfo]) extends Command
58-
private case class WrappedAddHtlcResponse(response: CommandResponse[CMD_ADD_HTLC]) extends Command
59-
private case class WrappedHtlcSettled(result: RES_ADD_SETTLED[Origin.Hot, HtlcResult]) extends Command
6063
// @formatter:on
6164

6265
def apply(nodeParams: NodeParams, register: ActorRef[Register.ForwardNodeId[Peer.GetPeerChannels]]): Behavior[Command] =
@@ -75,6 +78,79 @@ object TrampolinePaymentLifecycle {
7578
}
7679
}
7780

81+
object PartHandler {
82+
sealed trait Command
83+
84+
private case class WrappedAddHtlcResponse(response: CommandResponse[CMD_ADD_HTLC]) extends Command
85+
86+
private case class WrappedHtlcSettled(result: RES_ADD_SETTLED[Origin.Hot, HtlcResult]) extends Command
87+
88+
def apply(parent: ActorRef[TrampolinePaymentLifecycle.Command],
89+
cmd: TrampolinePaymentLifecycle.SendPayment,
90+
amount: MilliSatoshi,
91+
channelInfo: Peer.ChannelInfo,
92+
expiry: CltvExpiry,
93+
trampolinePaymentSecret: ByteVector32,
94+
attemptNumber: Int): Behavior[Command] =
95+
Behaviors.setup { context =>
96+
new PartHandler(context, parent, cmd).start(amount, channelInfo, expiry, trampolinePaymentSecret, attemptNumber)
97+
}
98+
}
99+
100+
class PartHandler(context: ActorContext[PartHandler.Command], parent: ActorRef[Command], cmd: TrampolinePaymentLifecycle.SendPayment) {
101+
102+
import PartHandler._
103+
104+
private val paymentHash = cmd.invoice.paymentHash
105+
106+
private val addHtlcAdapter = context.messageAdapter[CommandResponse[CMD_ADD_HTLC]](WrappedAddHtlcResponse)
107+
private val htlcSettledAdapter = context.messageAdapter[RES_ADD_SETTLED[Origin.Hot, HtlcResult]](WrappedHtlcSettled)
108+
109+
def start(amount: MilliSatoshi, channelInfo: Peer.ChannelInfo, expiry: CltvExpiry, trampolinePaymentSecret: ByteVector32, attemptNumber: Int): Behavior[PartHandler.Command] = {
110+
val origin = Origin.Hot(htlcSettledAdapter.toClassic, Upstream.Local(cmd.paymentId))
111+
val outgoing = buildOutgoingPayment(cmd.trampolineNodeId, cmd.invoice, amount, expiry, Some(trampolinePaymentSecret), attemptNumber)
112+
val add = CMD_ADD_HTLC(addHtlcAdapter.toClassic, outgoing.trampolineAmount, paymentHash, outgoing.trampolineExpiry, outgoing.onion.packet, None, 1.0, None, origin, commit = true)
113+
channelInfo.channel ! add
114+
val channelId = channelInfo.data.asInstanceOf[DATA_NORMAL].channelId
115+
val part = PartialPayment(cmd.paymentId, amount, computeFees(amount, attemptNumber), channelId, None)
116+
waitForSettlement(part, outgoing.onion.sharedSecrets, outgoing.trampolineOnion.sharedSecrets)
117+
}
118+
119+
def waitForSettlement(part: PartialPayment, outerOnionSecrets: Seq[Sphinx.SharedSecret], trampolineOnionSecrets: Seq[Sphinx.SharedSecret]): Behavior[PartHandler.Command] = {
120+
Behaviors.receiveMessagePartial {
121+
case WrappedAddHtlcResponse(response) => response match {
122+
case _: CommandSuccess[_] =>
123+
// HTLC was correctly sent out.
124+
Behaviors.same
125+
case failure: CommandFailure[_, Throwable] =>
126+
parent ! CouldntAddHtlc(failure.t)
127+
Behaviors.stopped
128+
}
129+
case WrappedHtlcSettled(result) => result.result match {
130+
case fulfill: HtlcResult.Fulfill =>
131+
val holdTimes = fulfill match {
132+
case HtlcResult.RemoteFulfill(updateFulfill) =>
133+
updateFulfill.attribution_opt match {
134+
case Some(attribution) =>
135+
Sphinx.Attribution.fulfillHoldTimes(attribution, outerOnionSecrets).holdTimes
136+
case None => Nil
137+
}
138+
case _: HtlcResult.OnChainFulfill => Nil
139+
}
140+
parent ! HtlcSettled(fulfill, part, holdTimes)
141+
Behaviors.stopped
142+
case fail: HtlcResult.Fail =>
143+
val holdTimes = fail match {
144+
case HtlcResult.RemoteFail(updateFail) =>
145+
Sphinx.FailurePacket.decrypt(updateFail.reason, updateFail.attribution_opt, outerOnionSecrets).holdTimes
146+
case _ => Nil
147+
}
148+
parent ! HtlcSettled(fail, part, holdTimes)
149+
Behaviors.stopped
150+
}
151+
}
152+
}
153+
}
78154
}
79155

80156
class TrampolinePaymentLifecycle private(nodeParams: NodeParams,
@@ -90,8 +166,6 @@ class TrampolinePaymentLifecycle private(nodeParams: NodeParams,
90166

91167
private val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.GetPeerChannels]](_ => TrampolinePeerNotFound(cmd.trampolineNodeId))
92168
private val peerChannelsResponseAdapter = context.messageAdapter[Peer.PeerChannels](c => WrappedPeerChannels(c.channels))
93-
private val addHtlcAdapter = context.messageAdapter[CommandResponse[CMD_ADD_HTLC]](WrappedAddHtlcResponse)
94-
private val htlcSettledAdapter = context.messageAdapter[RES_ADD_SETTLED[Origin.Hot, HtlcResult]](WrappedHtlcSettled)
95169

96170
def start(): Behavior[Command] = listChannels(attemptNumber = 0)
97171

@@ -117,7 +191,6 @@ class TrampolinePaymentLifecycle private(nodeParams: NodeParams,
117191
case _ => None
118192
}
119193
})
120-
val origin = Origin.Hot(htlcSettledAdapter.toClassic, Upstream.Local(cmd.paymentId))
121194
val expiry = CltvExpiry(nodeParams.currentBlockHeight) + CltvExpiryDelta(36)
122195
if (filtered.isEmpty) {
123196
context.log.warn("no usable channel with trampoline node {}", cmd.trampolineNodeId)
@@ -131,54 +204,49 @@ class TrampolinePaymentLifecycle private(nodeParams: NodeParams,
131204
// We generate a random secret to avoid leaking the invoice secret to the trampoline node.
132205
val trampolinePaymentSecret = randomBytes32()
133206
context.log.info("sending trampoline payment parts: {}->{}, {}->{}", channel1.data.channelId, amount1, channel2.data.channelId, amount2)
134-
val parts = Seq((amount1, channel1), (amount2, channel2)).map { case (amount, channelInfo) =>
135-
val outgoing = buildOutgoingPayment(cmd.trampolineNodeId, cmd.invoice, amount, expiry, Some(trampolinePaymentSecret), attemptNumber)
136-
val add = CMD_ADD_HTLC(addHtlcAdapter.toClassic, outgoing.trampolineAmount, paymentHash, outgoing.trampolineExpiry, outgoing.onion.packet, None, 1.0, None, origin, commit = true)
137-
channelInfo.channel ! add
138-
val channelId = channelInfo.data.asInstanceOf[DATA_NORMAL].channelId
139-
PartialPayment(cmd.paymentId, amount, computeFees(amount, attemptNumber), channelId, None)
207+
Seq((amount1, channel1), (amount2, channel2)).foreach { case (amount, channelInfo) =>
208+
context.spawnAnonymous(PartHandler(context.self, cmd, amount, channelInfo, expiry, trampolinePaymentSecret, attemptNumber))
140209
}
141-
waitForSettlement(remaining = 2, attemptNumber, parts)
210+
waitForSettlement(remaining = 2, attemptNumber, Nil)
142211
}
143212
}
144213

145-
private def waitForSettlement(remaining: Int, attemptNumber: Int, parts: Seq[PartialPayment]): Behavior[Command] = {
214+
private def waitForSettlement(remaining: Int, attemptNumber: Int, fulfilledParts: Seq[PartialPayment]): Behavior[Command] = {
146215
Behaviors.receiveMessagePartial {
147-
case WrappedAddHtlcResponse(response) => response match {
148-
case _: CommandSuccess[_] =>
149-
// HTLC was correctly sent out.
150-
Behaviors.same
151-
case failure: CommandFailure[_, Throwable] =>
152-
context.log.warn("HTLC could not be sent: {}", failure.t.getMessage)
153-
if (remaining > 1) {
154-
context.log.info("waiting for remaining HTLCs to complete")
155-
waitForSettlement(remaining - 1, attemptNumber, parts)
156-
} else {
157-
context.log.warn("trampoline payment failed")
158-
cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, failure.t) :: Nil)
159-
Behaviors.stopped
160-
}
161-
}
162-
case WrappedHtlcSettled(result) => result.result match {
163-
case fulfill: HtlcResult.Fulfill =>
164-
context.log.info("HTLC was fulfilled")
165-
if (remaining > 1) {
166-
context.log.info("waiting for remaining HTLCs to be fulfilled")
167-
waitForSettlement(remaining - 1, attemptNumber, parts)
168-
} else {
169-
context.log.info("trampoline payment succeeded")
170-
cmd.replyTo ! PaymentSent(cmd.paymentId, paymentHash, fulfill.paymentPreimage, totalAmount, cmd.invoice.nodeId, parts, None)
171-
Behaviors.stopped
172-
}
173-
case fail: HtlcResult.Fail =>
174-
context.log.warn("received HTLC failure: {}", fail)
175-
if (remaining > 1) {
176-
context.log.info("waiting for remaining HTLCs to be failed")
177-
waitForSettlement(remaining - 1, attemptNumber, parts)
178-
} else {
179-
retryOrStop(attemptNumber + 1)
180-
}
181-
}
216+
case CouldntAddHtlc(failure) =>
217+
context.log.warn("HTLC could not be sent: {}", failure.getMessage)
218+
if (remaining > 1) {
219+
context.log.info("waiting for remaining HTLCs to complete")
220+
waitForSettlement(remaining - 1, attemptNumber, fulfilledParts)
221+
} else {
222+
context.log.warn("trampoline payment failed")
223+
cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, failure) :: Nil)
224+
Behaviors.stopped
225+
}
226+
case HtlcSettled(result: HtlcResult, part, holdTimes) =>
227+
if (holdTimes.nonEmpty) {
228+
context.system.eventStream ! EventStream.Publish(Router.ReportedHoldTimes(holdTimes))
229+
}
230+
result match {
231+
case fulfill: HtlcResult.Fulfill =>
232+
context.log.info("HTLC was fulfilled")
233+
if (remaining > 1) {
234+
context.log.info("waiting for remaining HTLCs to be fulfilled")
235+
waitForSettlement(remaining - 1, attemptNumber, part +: fulfilledParts)
236+
} else {
237+
context.log.info("trampoline payment succeeded")
238+
cmd.replyTo ! PaymentSent(cmd.paymentId, paymentHash, fulfill.paymentPreimage, totalAmount, cmd.invoice.nodeId, part +: fulfilledParts, None)
239+
Behaviors.stopped
240+
}
241+
case fail: HtlcResult.Fail =>
242+
context.log.warn("received HTLC failure: {}", fail)
243+
if (remaining > 1) {
244+
context.log.info("waiting for remaining HTLCs to be failed")
245+
waitForSettlement(remaining - 1, attemptNumber, fulfilledParts)
246+
} else {
247+
retryOrStop(attemptNumber + 1)
248+
}
249+
}
182250
}
183251
}
184252

eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import fr.acinq.eclair._
2828
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher
2929
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
3030
import fr.acinq.eclair.channel._
31-
import fr.acinq.eclair.crypto.TransportHandler
31+
import fr.acinq.eclair.crypto.{Sphinx, TransportHandler}
3232
import fr.acinq.eclair.db.NetworkDb
3333
import fr.acinq.eclair.io.Peer.PeerRoutingMessage
3434
import fr.acinq.eclair.payment.Invoice.ExtraEdge
@@ -830,4 +830,6 @@ object Router {
830830

831831
/** We have tried to relay this amount from this channel and it failed. */
832832
case class ChannelCouldNotRelay(amount: MilliSatoshi, hop: ChannelHop)
833+
834+
case class ReportedHoldTimes(holdTimes: Seq[Sphinx.HoldTime])
833835
}

0 commit comments

Comments
 (0)