Skip to content

Commit 369f042

Browse files
authored
Add event for failed payment relay (#3244)
We add an event when a payment could not be relayed and indicates that we may need to add liquidity towards the next node. It is really hard to figure it out in the context of a single payment though, so this event does not by itself mean that liquidity should be allocated. The listeners should collect several events and regularly query the state of existing channels with our peers, and network graph data for remote nodes, to create good heuristics for allocating liquidity. Otherwise, it would be trivial for malicious senders to game routing nodes into allocating liquidity "for free" towards them, which could result in financial loss.
1 parent ff7a24c commit 369f042

File tree

8 files changed

+45
-7
lines changed

8 files changed

+45
-7
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelEvents.scala

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2121
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction, TxId}
2222
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2323
import fr.acinq.eclair.channel.Helpers.Closing.ClosingType
24-
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, HtlcFailureMessage, LiquidityAds, UpdateAddHtlc, UpdateFulfillHtlc}
25-
import fr.acinq.eclair.{BlockHeight, Features, MilliSatoshi, RealShortChannelId, ShortChannelId}
24+
import fr.acinq.eclair.wire.protocol._
25+
import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshi, RealShortChannelId, ShortChannelId}
2626

2727
/**
2828
* Created by PM on 17/08/2016.
@@ -106,9 +106,14 @@ case class LocalCommitConfirmed(channel: ActorRef, remoteNodeId: PublicKey, chan
106106

107107
case class ChannelClosed(channel: ActorRef, channelId: ByteVector32, closingType: ClosingType, closingTxId: TxId, commitments: Commitments) extends ChannelEvent
108108

109-
case class OutgoingHtlcAdded(add: UpdateAddHtlc, remoteNodeId: PublicKey, fee: MilliSatoshi)
109+
/** An outgoing HTLC was sent to our channel peer: we're waiting for it to be settled. */
110+
case class OutgoingHtlcAdded(add: UpdateAddHtlc, remoteNodeId: PublicKey, fee: MilliSatoshi) extends ChannelEvent
110111

111-
sealed trait OutgoingHtlcSettled
112+
/** An outgoing HTLC could not be sent through the given channel. */
113+
case class OutgoingHtlcNotAdded(channelId: ByteVector32, remoteNodeId: PublicKey, paymentHash: ByteVector32, amount: MilliSatoshi, expiry: CltvExpiry, reason: ChannelException) extends ChannelEvent
114+
115+
/** An outgoing HTLC was settled by our channel peer. */
116+
sealed trait OutgoingHtlcSettled extends ChannelEvent
112117

113118
case class OutgoingHtlcFailed(fail: HtlcFailureMessage) extends OutgoingHtlcSettled
114119

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3243,6 +3243,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
32433243
log.warning(s"${cause.getMessage} while processing cmd=${c.getClass.getSimpleName} in state=$stateName")
32443244
val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo
32453245
replyTo ! RES_ADD_FAILED(c, cause, channelUpdate)
3246+
context.system.eventStream.publish(OutgoingHtlcNotAdded(stateData.channelId, remoteNodeId, c.paymentHash, c.amount, c.cltvExpiry, cause))
32463247
context.system.eventStream.publish(ChannelErrorOccurred(self, stateData.channelId, remoteNodeId, LocalError(cause), isFatal = false))
32473248
stay()
32483249
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentEvents.scala

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ case class PaymentReceived(paymentHash: ByteVector32, parts: Seq[PaymentEvent.In
145145
val settledAt: TimestampMilli = parts.map(_.receivedAt).max // we use max here because we fulfill the payment only once we received all the parts
146146
}
147147

148+
/**
149+
* This event is emitted when we couldn't relay a payment and the reason is *likely* a liquidity issue.
150+
* This can help figure out where liquidity is needed to earn more routing fees by funding channels.
151+
*
152+
* Note that this event is *not* emitted on *every* payment relay failure. This event does *not* guarantee that the
153+
* failure was a liquidity issue, and malicious senders can force this event to be triggered for payments that they
154+
* would not have fulfilled. Listeners must add their own heuristics and gather additional data in order to efficiently
155+
* allocate liquidity and optimize their routing fees.
156+
*
157+
* @param fees fees we would have earned if we had successfully relayed that payment (can be gamed by malicious senders).
158+
*/
159+
case class PaymentNotRelayed(paymentHash: ByteVector32, remoteNodeId: PublicKey, fees: MilliSatoshi)
160+
148161
case class PaymentMetadataReceived(paymentHash: ByteVector32, paymentMetadata: ByteVector)
149162

150163
case class PaymentSettlingOnChain(id: UUID, channelId: ByteVector32, amount: MilliSatoshi, paymentHash: ByteVector32, timestamp: TimestampMilli = TimestampMilli.now())

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import fr.acinq.eclair.io.Peer.ProposeOnTheFlyFundingResponse
3030
import fr.acinq.eclair.io.{Peer, PeerReadyNotifier}
3131
import fr.acinq.eclair.payment.Monitoring.{Metrics, Tags}
3232
import fr.acinq.eclair.payment.relay.Relayer.{OutgoingChannel, OutgoingChannelParams}
33-
import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentEvent}
33+
import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentEvent, PaymentNotRelayed}
3434
import fr.acinq.eclair.reputation.Reputation
3535
import fr.acinq.eclair.reputation.ReputationRecorder.GetConfidence
3636
import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure
@@ -210,6 +210,7 @@ class ChannelRelay private(nodeParams: NodeParams,
210210
case RelayFailure(cmdFail) =>
211211
Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel)
212212
context.log.info("rejecting htlc reason={}", cmdFail.reason)
213+
channels.headOption.map(_._2.nextNodeId).foreach(nextNodeId => context.system.eventStream ! EventStream.Publish(PaymentNotRelayed(r.add.paymentHash, nextNodeId, fees = upstream.amountIn - r.amountToForward)))
213214
safeSendAndStop(r.add.channelId, cmdFail)
214215
case RelayNeedsFunding(nextNodeId, cmdFail) =>
215216
// Note that in the channel relay case, we don't have any outgoing onion shared secrets.
@@ -230,6 +231,7 @@ class ChannelRelay private(nodeParams: NodeParams,
230231
context.log.warn(s"couldn't resolve downstream channel $channelId, failing htlc #${upstream.add.id}")
231232
val cmdFail = makeCmdFailHtlc(upstream.add.id, UnknownNextPeer())
232233
Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel)
234+
channels.headOption.map(_._2.nextNodeId).foreach(nextNodeId => context.system.eventStream ! EventStream.Publish(PaymentNotRelayed(r.add.paymentHash, nextNodeId, fees = upstream.amountIn - r.amountToForward)))
233235
safeSendAndStop(upstream.add.channelId, cmdFail)
234236

235237
case WrappedAddResponse(addFailed: RES_ADD_FAILED[_]) =>

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,9 @@ class NodeRelay private(nodeParams: NodeParams,
270270
rejectExtraHtlcPartialFunction orElse {
271271
case WrappedResolvedPaths(resolved) if resolved.isEmpty =>
272272
context.log.warn("rejecting trampoline payment to blinded paths: no usable blinded path")
273+
payloadOut.outgoingBlindedPaths.map(_.route.firstNodeId).collectFirst { case n: EncodedNodeId.WithPublicKey => n.publicKey }.foreach(nodeId => {
274+
context.system.eventStream ! EventStream.Publish(PaymentNotRelayed(paymentHash, nodeId, fees = upstream.amountIn - outgoingAmount(upstream, nextPayload)))
275+
})
273276
rejectPayment(upstream, Some(UnknownNextPeer()))
274277
stopping()
275278
case WrappedResolvedPaths(resolved) =>
@@ -411,6 +414,7 @@ class NodeRelay private(nodeParams: NodeParams,
411414
context.log.info("trampoline payment failed, attempting on-the-fly funding")
412415
attemptOnTheFlyFunding(upstream, walletNodeId, recipient, nextPayload, e.failures, startedAt)
413416
case _ =>
417+
context.system.eventStream ! EventStream.Publish(PaymentNotRelayed(paymentHash, recipient.nodeId, fees = upstream.amountIn - outgoingAmount(upstream, nextPayload)))
414418
rejectPayment(upstream, translateError(nodeParams, e.failures, upstream, nextPayload))
415419
recordRelayDuration(startedAt, isSuccess = false)
416420
stopping()

eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,15 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
210210

211211
test("recv CMD_ADD_HTLC (insufficient funds)") { f =>
212212
import f._
213+
val listener = TestProbe()
214+
systemA.eventStream.subscribe(listener.ref, classOf[OutgoingHtlcNotAdded])
213215
val sender = TestProbe()
214216
val initialState = alice.stateData.asInstanceOf[DATA_NORMAL]
215217
val add = CMD_ADD_HTLC(sender.ref, initialState.commitments.availableBalanceForSend + 1.msat, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, None, Reputation.Score.max(accountable = false), None, localOrigin(sender.ref))
216218
alice ! add
217219
val error = InsufficientFunds(channelId(alice), amount = add.amount, missing = 0 sat, reserve = 20000 sat, fees = 3900 sat)
218220
sender.expectMsg(RES_ADD_FAILED(add, error, Some(initialState.channelUpdate)))
221+
assert(listener.expectMsgType[OutgoingHtlcNotAdded].reason == error)
219222
alice2bob.expectNoMessage(100 millis)
220223
}
221224

@@ -333,6 +336,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
333336

334337
test("recv CMD_ADD_HTLC (over remote max inflight htlc value)", Tag(ChannelStateTestsTags.AliceLowMaxHtlcValueInFlight)) { f =>
335338
import f._
339+
val listener = TestProbe()
340+
systemB.eventStream.subscribe(listener.ref, classOf[OutgoingHtlcNotAdded])
336341
val sender = TestProbe()
337342
val initialState = bob.stateData.asInstanceOf[DATA_NORMAL]
338343
assert(initialState.commitments.latest.localCommitParams.maxHtlcValueInFlight == UInt64(initialState.commitments.latest.capacity.toMilliSatoshi.toLong))
@@ -341,6 +346,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
341346
bob ! add
342347
val error = HtlcValueTooHighInFlight(channelId(bob), maximum = UInt64(150_000_000), actual = 151_000_000 msat)
343348
sender.expectMsg(RES_ADD_FAILED(add, error, Some(initialState.channelUpdate)))
349+
assert(listener.expectMsgType[OutgoingHtlcNotAdded].reason == error)
344350
bob2alice.expectNoMessage(100 millis)
345351
}
346352

eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import fr.acinq.eclair.crypto.Sphinx
3434
import fr.acinq.eclair.io.{Peer, PeerReadyManager, Switchboard}
3535
import fr.acinq.eclair.payment.IncomingPaymentPacket.ChannelRelayPacket
3636
import fr.acinq.eclair.payment.relay.ChannelRelayer._
37-
import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentPacketSpec}
37+
import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentNotRelayed, PaymentPacketSpec}
3838
import fr.acinq.eclair.reputation.{Reputation, ReputationRecorder}
3939
import fr.acinq.eclair.router.Announcements
4040
import fr.acinq.eclair.wire.protocol.BlindedRouteData.PaymentRelayData
@@ -539,6 +539,9 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a
539539
test("fail to relay when there is a local error") { f =>
540540
import f._
541541

542+
val eventListener = TestProbe[PaymentNotRelayed]()
543+
system.eventStream ! EventStream.Subscribe(eventListener.ref)
544+
542545
val channelId1 = channelIds(realScid1)
543546
val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry, upgradeAccountability = false)
544547
val r = createValidIncomingPacket(payload)
@@ -564,6 +567,7 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a
564567
val fwd = expectFwdAdd(register, channelIds(realScid1), outgoingAmount, outgoingExpiry, outAccountable = false)
565568
fwd.message.replyTo ! RES_ADD_FAILED(fwd.message, testCase.exc, Some(testCase.update))
566569
expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(testCase.failure), None, commit = true))
570+
assert(eventListener.expectMessageType[PaymentNotRelayed].remoteNodeId == outgoingNodeId)
567571
}
568572
}
569573

eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/NodeRelayerSpec.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,9 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl
473473
test("fail to relay because outgoing balance isn't sufficient (low fees)") { f =>
474474
import f._
475475

476+
val listener = TestProbe[PaymentNotRelayed]()
477+
system.eventStream ! EventStream.Subscribe(listener.ref)
478+
476479
// Receive an upstream multi-part payment.
477480
val (nodeRelayer, _) = f.createNodeRelay(incomingMultiPart.head)
478481
incomingMultiPart.foreach(p => nodeRelayer ! NodeRelay.Relay(p, randomKey().publicKey, 0.01))
@@ -492,8 +495,8 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl
492495
assert(fwd.message == CMD_FAIL_HTLC(p.add.id, FailureReason.LocalFailure(TrampolineFeeInsufficient()), Some(FailureAttributionData(p.receivedAt, Some(incomingMultiPart.last.receivedAt))), commit = true))
493496
}
494497

498+
assert(listener.expectMessageType[PaymentNotRelayed].remoteNodeId == getPeerInfo.nodeId)
495499
register.expectNoMessage(100 millis)
496-
eventListener.expectNoMessage(100 millis)
497500
}
498501

499502
test("fail to relay because outgoing balance isn't sufficient (high fees)") { f =>

0 commit comments

Comments
 (0)