Skip to content

Commit 100e174

Browse files
Add attribution data to UpdateFulfillHtlc (#3100)
Attribution data is added to both failed and fulfilled HTLCs lightning/bolts#1044
1 parent e7b9b89 commit 100e174

34 files changed

+324
-229
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,17 @@ When using anchor outputs, allows propagating our local commitment transaction t
1313

1414
This removes the need for increasing the commitment feerate based on mempool conditions, which ensures that channels won't be force-closed anymore when nodes disagree on the current feerate.
1515

16-
### Attributable failures
16+
### Attribution data
1717

18-
Eclair now supports attributable failures which allow nodes to prove they are not the source of the failure and provide timing data.
18+
Eclair now supports attributable failures which allow nodes to prove they are not the source of the failure.
1919
Previously a failing node could choose not to report the failure and we would penalize all nodes of the route.
2020
If all nodes of the route support attributable failures, we only need to penalize two nodes (there is still some uncertainty as to which of the two nodes is the failing one).
2121
See https://github.com/lightning/bolts/pull/1044 for more details.
2222

23+
Attribution data also provides hold times from payment relayers, both for fulfilled and failed HTLCs.
24+
2325
Support is disabled by default as the spec is not yet final.
24-
It can be enabled by setting `eclair.features.option_attributable_failure = optional` at the risk of being incompatible with the final spec.
26+
It can be enabled by setting `eclair.features.option_attribution_data = optional` at the risk of being incompatible with the final spec.
2527

2628
### API changes
2729

eclair-core/src/main/resources/reference.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ eclair {
7474
option_shutdown_anysegwit = optional
7575
option_dual_fund = optional
7676
option_quiesce = optional
77-
option_attributable_failure = disabled
77+
option_attribution_data = disabled
7878
option_onion_messages = optional
7979
// This feature should only be enabled when acting as an LSP for mobile wallets.
8080
// When activating this feature, the peer-storage section should be customized to match desired SLAs.

eclair-core/src/main/scala/fr/acinq/eclair/Features.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,8 @@ object Features {
270270
val mandatory = 34
271271
}
272272

273-
case object AttributableFailures extends Feature with InitFeature with NodeFeature with Bolt11Feature {
274-
val rfcName = "option_attributable_failure"
273+
case object AttributionData extends Feature with InitFeature with NodeFeature with Bolt11Feature {
274+
val rfcName = "option_attribution_data"
275275
val mandatory = 36
276276
}
277277
case object OnionMessages extends Feature with InitFeature with NodeFeature {
@@ -377,7 +377,7 @@ object Features {
377377
ShutdownAnySegwit,
378378
DualFunding,
379379
Quiescence,
380-
AttributableFailures,
380+
AttributionData,
381381
OnionMessages,
382382
ProvideStorage,
383383
ChannelType,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ final case class CMD_ADD_HTLC(replyTo: ActorRef,
214214
commit: Boolean = false) extends HasReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent
215215

216216
sealed trait HtlcSettlementCommand extends HasOptionalReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent { def id: Long }
217-
final case class CMD_FULFILL_HTLC(id: Long, r: ByteVector32, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
217+
final case class CMD_FULFILL_HTLC(id: Long, r: ByteVector32, downstreamAttribution_opt: Option[ByteVector], htlcReceivedAt_opt: Option[TimestampMilli], commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
218218
final case class CMD_FAIL_HTLC(id: Long, reason: FailureReason, htlcReceivedAt_opt: Option[TimestampMilli], delay_opt: Option[FiniteDuration] = None, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
219219
final case class CMD_FAIL_MALFORMED_HTLC(id: Long, onionHash: ByteVector32, failureCode: Int, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
220220
final case class CMD_UPDATE_FEE(feeratePerKw: FeeratePerKw, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -900,14 +900,14 @@ case class Commitments(params: ChannelParams,
900900
.getOrElse(Right(copy(changes = changes1)))
901901
}
902902

903-
def sendFulfill(cmd: CMD_FULFILL_HTLC): Either[ChannelException, (Commitments, UpdateFulfillHtlc)] =
903+
def sendFulfill(cmd: CMD_FULFILL_HTLC, nodeSecret: PrivateKey, useAttributionData: Boolean): Either[ChannelException, (Commitments, UpdateFulfillHtlc)] =
904904
getIncomingHtlcCrossSigned(cmd.id) match {
905905
case Some(htlc) if CommitmentChanges.alreadyProposed(changes.localChanges.proposed, htlc.id) =>
906906
// we have already sent a fail/fulfill for this htlc
907907
Left(UnknownHtlcId(channelId, cmd.id))
908908
case Some(htlc) if htlc.paymentHash == Crypto.sha256(cmd.r) =>
909909
payment.Monitoring.Metrics.recordIncomingPaymentDistribution(params.remoteNodeId, htlc.amountMsat)
910-
val fulfill = UpdateFulfillHtlc(channelId, cmd.id, cmd.r)
910+
val fulfill = OutgoingPaymentPacket.buildHtlcFulfill(nodeSecret, useAttributionData, cmd, htlc)
911911
Right((copy(changes = changes.addLocalProposal(fulfill)), fulfill))
912912
case Some(_) => Left(InvalidHtlcPreimage(channelId, cmd.id))
913913
case None => Left(UnknownHtlcId(channelId, cmd.id))

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
481481
}
482482

483483
case Event(c: CMD_FULFILL_HTLC, d: DATA_NORMAL) =>
484-
d.commitments.sendFulfill(c) match {
484+
d.commitments.sendFulfill(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
485485
case Right((commitments1, fulfill)) =>
486486
if (c.commit) self ! CMD_SIGN()
487487
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.aliases, commitments1, d.lastAnnouncement_opt))
@@ -506,7 +506,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
506506
log.debug("delaying CMD_FAIL_HTLC with id={} for {}", c.id, delay)
507507
context.system.scheduler.scheduleOnce(delay, self, c.copy(delay_opt = None))
508508
stay()
509-
case None => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures)) match {
509+
case None => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
510510
case Right((commitments1, fail)) =>
511511
if (c.commit) self ! CMD_SIGN()
512512
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.aliases, commitments1, d.lastAnnouncement_opt))
@@ -1482,7 +1482,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
14821482

14831483
when(SHUTDOWN)(handleExceptions {
14841484
case Event(c: CMD_FULFILL_HTLC, d: DATA_SHUTDOWN) =>
1485-
d.commitments.sendFulfill(c) match {
1485+
d.commitments.sendFulfill(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
14861486
case Right((commitments1, fulfill)) =>
14871487
if (c.commit) self ! CMD_SIGN()
14881488
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fulfill
@@ -1501,7 +1501,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
15011501
}
15021502

15031503
case Event(c: CMD_FAIL_HTLC, d: DATA_SHUTDOWN) =>
1504-
d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures)) match {
1504+
d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
15051505
case Right((commitments1, fail)) =>
15061506
if (c.commit) self ! CMD_SIGN()
15071507
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fail
@@ -1859,8 +1859,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
18591859
when(CLOSING)(handleExceptions {
18601860
case Event(c: HtlcSettlementCommand, d: DATA_CLOSING) =>
18611861
(c match {
1862-
case c: CMD_FULFILL_HTLC => d.commitments.sendFulfill(c)
1863-
case c: CMD_FAIL_HTLC => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures))
1862+
case c: CMD_FULFILL_HTLC => d.commitments.sendFulfill(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData))
1863+
case c: CMD_FAIL_HTLC => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData))
18641864
case c: CMD_FAIL_MALFORMED_HTLC => d.commitments.sendFailMalformed(c)
18651865
}) match {
18661866
case Right((commitments1, _)) =>

eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala

Lines changed: 89 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -348,86 +348,102 @@ object Sphinx extends Logging {
348348
HtlcFailure(attribution1_opt.map(n => HoldTime(n._1, ss.remoteNodeId) +: downstreamHoldTimes).getOrElse(Nil), failure)
349349
}
350350
}
351+
}
352+
353+
/**
354+
* Attribution data is added to the failure packet and prevents a node from evading responsibility for its failures.
355+
* Nodes that relay attribution data can prove that they are not the erring node and in case the erring node tries
356+
* to hide, there will only be at most two nodes that can be the erring node (the last one to send attribution data
357+
* and the one after it). It also adds timing data for each node on the path.
358+
* Attribution data can also be added to fulfilled HTLCs to provide timing data and allow choosing fast nodes for
359+
* future payments.
360+
* https://github.com/lightning/bolts/pull/1044
361+
*/
362+
object Attribution {
363+
val maxNumHops = 20
364+
val holdTimeLength = 4
365+
val hmacLength = 4 // HMACs are truncated to 4 bytes to save space
366+
val totalLength = maxNumHops * holdTimeLength + maxNumHops * (maxNumHops + 1) / 2 * hmacLength // = 920
367+
368+
private def cipher(bytes: ByteVector, sharedSecret: ByteVector32): ByteVector = {
369+
val key = generateKey("ammagext", sharedSecret)
370+
val stream = generateStream(key, totalLength)
371+
bytes xor stream
372+
}
351373

352374
/**
353-
* Attribution data is added to the failure packet and prevents a node from evading responsibility for its failures.
354-
* Nodes that relay attribution data can prove that they are not the erring node and in case the erring node tries
355-
* to hide, there will only be at most two nodes that can be the erring node (the last one to send attribution data
356-
* and the one after it).
357-
* It also adds timing data for each node on the path.
358-
* https://github.com/lightning/bolts/pull/1044
375+
* Get the HMACs from the attribution data.
376+
* The layout of the attribution data is as follows (using maxNumHops = 3 for conciseness):
377+
* holdTime(0) ++ holdTime(1) ++ holdTime(2) ++
378+
* hmacs(0)(0) ++ hmacs(0)(1) ++ hmacs(0)(2) ++
379+
* hmacs(1)(0) ++ hmacs(1)(1) ++
380+
* hmacs(2)(0)
381+
*
382+
* Where `hmac(i)(j)` is the hmac added by node `i` (counted from the node that built the attribution data),
383+
* assuming it is `maxNumHops - 1 - i - j` hops away from the erring node.
359384
*/
360-
object Attribution {
361-
val maxNumHops = 20
362-
val holdTimeLength = 4
363-
val hmacLength = 4 // HMACs are truncated to 4 bytes to save space
364-
val totalLength = maxNumHops * holdTimeLength + maxNumHops * (maxNumHops + 1) / 2 * hmacLength // = 920
365-
366-
private def cipher(bytes: ByteVector, sharedSecret: ByteVector32): ByteVector = {
367-
val key = generateKey("ammagext", sharedSecret)
368-
val stream = generateStream(key, totalLength)
369-
bytes xor stream
370-
}
385+
private def getHmacs(bytes: ByteVector): Seq[Seq[ByteVector]] =
386+
(0 until maxNumHops).map(i => (0 until (maxNumHops - i)).map(j => {
387+
val start = maxNumHops * holdTimeLength + (maxNumHops * i - (i * (i - 1)) / 2 + j) * hmacLength
388+
bytes.slice(start, start + hmacLength)
389+
}))
371390

372-
/**
373-
* Get the HMACs from the attribution data.
374-
* The layout of the attribution data is as follows (using maxNumHops = 3 for conciseness):
375-
* holdTime(0) ++ holdTime(1) ++ holdTime(2) ++
376-
* hmacs(0)(0) ++ hmacs(0)(1) ++ hmacs(0)(2) ++
377-
* hmacs(1)(0) ++ hmacs(1)(1) ++
378-
* hmacs(2)(0)
379-
*
380-
* Where `hmac(i)(j)` is the hmac added by node `i` (counted from the node that built the attribution data),
381-
* assuming it is `maxNumHops - 1 - i - j` hops away from the erring node.
382-
*/
383-
private def getHmacs(bytes: ByteVector): Seq[Seq[ByteVector]] =
384-
(0 until maxNumHops).map(i => (0 until (maxNumHops - i)).map(j => {
385-
val start = maxNumHops * holdTimeLength + (maxNumHops * i - (i * (i - 1)) / 2 + j) * hmacLength
386-
bytes.slice(start, start + hmacLength)
387-
}))
388-
389-
/**
390-
* Computes the HMACs for the node that is `minNumHop` hops away from us. Hence we only compute `maxNumHops - minNumHop` HMACs.
391-
* HMACs are truncated to 4 bytes to save space. An attacker has only one try to guess the HMAC so 4 bytes should be enough.
392-
*/
393-
private def computeHmacs(mac: Mac32, failurePacket: ByteVector, holdTimes: ByteVector, hmacs: Seq[Seq[ByteVector]], minNumHop: Int): Seq[ByteVector] = {
394-
(minNumHop until maxNumHops).map(i => {
395-
val y = maxNumHops - i
396-
mac.mac(failurePacket ++
397-
holdTimes.take(y * holdTimeLength) ++
398-
ByteVector.concat((0 until y - 1).map(j => hmacs(j)(i)))).bytes.take(hmacLength)
399-
})
400-
}
391+
/**
392+
* Computes the HMACs for the node that is `minNumHop` hops away from us. Hence we only compute `maxNumHops - minNumHop` HMACs.
393+
* HMACs are truncated to 4 bytes to save space. An attacker has only one try to guess the HMAC so 4 bytes should be enough.
394+
*/
395+
private def computeHmacs(mac: Mac32, failurePacket: ByteVector, holdTimes: ByteVector, hmacs: Seq[Seq[ByteVector]], minNumHop: Int): Seq[ByteVector] = {
396+
(minNumHop until maxNumHops).map(i => {
397+
val y = maxNumHops - i
398+
mac.mac(failurePacket ++
399+
holdTimes.take(y * holdTimeLength) ++
400+
ByteVector.concat((0 until y - 1).map(j => hmacs(j)(i)))).bytes.take(hmacLength)
401+
})
402+
}
403+
404+
/**
405+
* Create attribution data to send with the failure packet or with a fulfilled HTLC
406+
*
407+
* @param failurePacket_opt the failure packet before being wrapped or `None` for fulfilled HTLCs
408+
*/
409+
def create(previousAttribution_opt: Option[ByteVector], failurePacket_opt: Option[ByteVector], holdTime: FiniteDuration, sharedSecret: ByteVector32): ByteVector = {
410+
val previousAttribution = previousAttribution_opt.getOrElse(ByteVector.low(totalLength))
411+
val previousHmacs = getHmacs(previousAttribution).dropRight(1).map(_.drop(1))
412+
val mac = Hmac256(generateKey("um", sharedSecret))
413+
val holdTimes = uint32.encode(holdTime.toMillis).require.bytes ++ previousAttribution.take((maxNumHops - 1) * holdTimeLength)
414+
val hmacs = computeHmacs(mac, failurePacket_opt.getOrElse(ByteVector.empty), holdTimes, previousHmacs, 0) +: previousHmacs
415+
cipher(holdTimes ++ ByteVector.concat(hmacs.map(ByteVector.concat(_))), sharedSecret)
416+
}
401417

402-
/**
403-
* Create attribution data to send with the failure packet
404-
*
405-
* @param failurePacket the failure packet before being wrapped
406-
*/
407-
def create(previousAttribution_opt: Option[ByteVector], failurePacket: ByteVector, holdTime: FiniteDuration, sharedSecret: ByteVector32): ByteVector = {
408-
val previousAttribution = previousAttribution_opt.getOrElse(ByteVector.low(totalLength))
409-
val previousHmacs = getHmacs(previousAttribution).dropRight(1).map(_.drop(1))
410-
val mac = Hmac256(generateKey("um", sharedSecret))
411-
val holdTimes = uint32.encode(holdTime.toMillis).require.bytes ++ previousAttribution.take((maxNumHops - 1) * holdTimeLength)
412-
val hmacs = computeHmacs(mac, failurePacket, holdTimes, previousHmacs, 0) +: previousHmacs
413-
cipher(holdTimes ++ ByteVector.concat(hmacs.map(ByteVector.concat(_))), sharedSecret)
418+
/**
419+
* Unwrap one hop of attribution data
420+
* @return a pair with the hold time for this hop and the attribution data for the next hop, or None if the attribution data was invalid
421+
*/
422+
def unwrap(encrypted: ByteVector, failurePacket: ByteVector, sharedSecret: ByteVector32, minNumHop: Int): Option[(FiniteDuration, ByteVector)] = {
423+
val bytes = cipher(encrypted, sharedSecret)
424+
val holdTime = uint32.decode(bytes.take(holdTimeLength).bits).require.value.milliseconds
425+
val hmacs = getHmacs(bytes)
426+
val mac = Hmac256(generateKey("um", sharedSecret))
427+
if (computeHmacs(mac, failurePacket, bytes.take(maxNumHops * holdTimeLength), hmacs.drop(1), minNumHop) == hmacs.head.drop(minNumHop)) {
428+
val unwrapped = bytes.slice(holdTimeLength, maxNumHops * holdTimeLength) ++ ByteVector.low(holdTimeLength) ++ ByteVector.concat((hmacs.drop(1) :+ Seq()).map(s => ByteVector.low(hmacLength) ++ ByteVector.concat(s)))
429+
Some(holdTime, unwrapped)
430+
} else {
431+
None
414432
}
433+
}
415434

416-
/**
417-
* Unwrap one hop of attribution data
418-
* @return a pair with the hold time for this hop and the attribution data for the next hop, or None if the attribution data was invalid
419-
*/
420-
def unwrap(encrypted: ByteVector, failurePacket: ByteVector, sharedSecret: ByteVector32, minNumHop: Int): Option[(FiniteDuration, ByteVector)] = {
421-
val bytes = cipher(encrypted, sharedSecret)
422-
val holdTime = uint32.decode(bytes.take(holdTimeLength).bits).require.value.milliseconds
423-
val hmacs = getHmacs(bytes)
424-
val mac = Hmac256(generateKey("um", sharedSecret))
425-
if (computeHmacs(mac, failurePacket, bytes.take(maxNumHops * holdTimeLength), hmacs.drop(1), minNumHop) == hmacs.head.drop(minNumHop)) {
426-
val unwrapped = bytes.slice(holdTimeLength, maxNumHops * holdTimeLength) ++ ByteVector.low(holdTimeLength) ++ ByteVector.concat((hmacs.drop(1) :+ Seq()).map(s => ByteVector.low(hmacLength) ++ ByteVector.concat(s)))
427-
Some(holdTime, unwrapped)
428-
} else {
429-
None
430-
}
435+
/**
436+
* Decrypt the hold times from the attribution data of a fulfilled HTLC
437+
*/
438+
def fulfillHoldTimes(attribution: ByteVector, sharedSecrets: Seq[SharedSecret], hopIndex: Int = 0): List[HoldTime] = {
439+
sharedSecrets match {
440+
case Nil => Nil
441+
case ss :: tail =>
442+
unwrap(attribution, ByteVector.empty, ss.secret, hopIndex) match {
443+
case Some((holdTime, nextAttribution)) =>
444+
HoldTime(holdTime, ss.remoteNodeId) :: fulfillHoldTimes(nextAttribution, tail, hopIndex + 1)
445+
case None => Nil
446+
}
431447
}
432448
}
433449
}

0 commit comments

Comments
 (0)