Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Move route blinding construction to router
- Loading branch information
Showing
7 changed files
with
178 additions
and
121 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
75 changes: 75 additions & 0 deletions
75
eclair-core/src/main/scala/fr/acinq/eclair/router/BlindedRouteCreation.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
/* | ||
* Copyright 2022 ACINQ SAS | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package fr.acinq.eclair.router | ||
|
||
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey | ||
import fr.acinq.eclair.crypto.Sphinx | ||
import fr.acinq.eclair.router.Router.ChannelHop | ||
import fr.acinq.eclair.wire.protocol.OfferTypes.PaymentInfo | ||
import fr.acinq.eclair.wire.protocol.{RouteBlindingEncryptedDataCodecs, RouteBlindingEncryptedDataTlv, TlvStream} | ||
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, randomKey} | ||
import scodec.bits.ByteVector | ||
|
||
object BlindedRouteCreation { | ||
|
||
/** Compute aggregated fees and expiry for a given route. */ | ||
def aggregatePaymentInfo(amount: MilliSatoshi, hops: Seq[ChannelHop]): PaymentInfo = { | ||
val zeroPaymentInfo = PaymentInfo(0 msat, 0, CltvExpiryDelta(0), 0 msat, amount, Features.empty) | ||
hops.foldRight(zeroPaymentInfo) { | ||
case (channel, payInfo) => | ||
val newFeeBase = MilliSatoshi((channel.params.relayFees.feeBase.toLong * 1_000_000 + payInfo.feeBase.toLong * (1_000_000 + channel.params.relayFees.feeProportionalMillionths) + 1_000_000 - 1) / 1_000_000) | ||
val newFeeProp = ((payInfo.feeProportionalMillionths + channel.params.relayFees.feeProportionalMillionths) * 1_000_000 + payInfo.feeProportionalMillionths * channel.params.relayFees.feeProportionalMillionths + 1_000_000 - 1) / 1_000_000 | ||
// Most nodes on the network set `htlc_maximum_msat` to the channel capacity. We cannot expect the route to be | ||
// able to relay that amount, so we remove 10% as a safety margin. | ||
val channelMaxHtlc = channel.params.htlcMaximum_opt.map(_ * 0.9).getOrElse(amount) | ||
PaymentInfo(newFeeBase, newFeeProp, payInfo.cltvExpiryDelta + channel.cltvExpiryDelta, payInfo.minHtlc.max(channel.params.htlcMinimum), payInfo.maxHtlc.min(channelMaxHtlc), payInfo.allowedFeatures) | ||
} | ||
} | ||
|
||
/** Create a blinded route from a non-empty list of channel hops. */ | ||
def createBlindedRouteFromHops(hops: Seq[Router.ChannelHop], pathId: ByteVector, minAmount: MilliSatoshi, routeFinalExpiry: CltvExpiry): Sphinx.RouteBlinding.BlindedRouteDetails = { | ||
require(hops.nonEmpty, "route must contain at least one hop") | ||
// We use the same constraints for all nodes so they can't use it to guess their position. | ||
val routeExpiry = hops.foldLeft(routeFinalExpiry) { case (expiry, hop) => expiry + hop.cltvExpiryDelta } | ||
val routeMinAmount = hops.foldLeft(minAmount) { case (amount, hop) => amount.max(hop.params.htlcMinimum) } | ||
val finalPayload = RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream( | ||
RouteBlindingEncryptedDataTlv.PaymentConstraints(routeExpiry, routeMinAmount), | ||
RouteBlindingEncryptedDataTlv.PathId(pathId), | ||
)).require.bytes | ||
val payloads = hops.foldRight(Seq(finalPayload)) { | ||
case (channel, payloads) => | ||
val payload = RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream( | ||
RouteBlindingEncryptedDataTlv.OutgoingChannelId(channel.shortChannelId), | ||
RouteBlindingEncryptedDataTlv.PaymentRelay(channel.cltvExpiryDelta, channel.params.relayFees.feeProportionalMillionths, channel.params.relayFees.feeBase), | ||
RouteBlindingEncryptedDataTlv.PaymentConstraints(routeExpiry, routeMinAmount), | ||
)).require.bytes | ||
payload +: payloads | ||
} | ||
val nodeIds = hops.map(_.nodeId) :+ hops.last.nextNodeId | ||
Sphinx.RouteBlinding.create(randomKey(), nodeIds, payloads) | ||
} | ||
|
||
/** Create a blinded route where the recipient is also the introduction point (which reveals the recipient's identity). */ | ||
def createBlindedRouteWithoutHops(nodeId: PublicKey, pathId: ByteVector, minAmount: MilliSatoshi, routeExpiry: CltvExpiry): Sphinx.RouteBlinding.BlindedRouteDetails = { | ||
val finalPayload = RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream( | ||
RouteBlindingEncryptedDataTlv.PaymentConstraints(routeExpiry, minAmount), | ||
RouteBlindingEncryptedDataTlv.PathId(pathId), | ||
)).require.bytes | ||
Sphinx.RouteBlinding.create(randomKey(), Seq(nodeId), Seq(finalPayload)) | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
eclair-core/src/test/scala/fr/acinq/eclair/router/BlindedRouteCreationSpec.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
/* | ||
* Copyright 2022 ACINQ SAS | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package fr.acinq.eclair.router | ||
|
||
import fr.acinq.eclair.router.RouteCalculationSpec.makeUpdateShort | ||
import fr.acinq.eclair.router.Router.{ChannelHop, ChannelRelayParams} | ||
import fr.acinq.eclair.wire.protocol.{BlindedRouteData, RouteBlindingEncryptedDataCodecs, RouteBlindingEncryptedDataTlv} | ||
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, ShortChannelId, randomBytes32, randomKey} | ||
import org.scalatest.funsuite.AnyFunSuite | ||
import org.scalatest.{ParallelTestExecution, Tag} | ||
|
||
class BlindedRouteCreationSpec extends AnyFunSuite with ParallelTestExecution { | ||
|
||
import BlindedRouteCreation._ | ||
|
||
test("create blinded route without hops") { | ||
val a = randomKey() | ||
val pathId = randomBytes32() | ||
val route = createBlindedRouteWithoutHops(a.publicKey, pathId, 1 msat, CltvExpiry(500)) | ||
assert(route.route.introductionNodeId == a.publicKey) | ||
assert(route.route.encryptedPayloads.length == 1) | ||
assert(route.route.blindingKey == route.lastBlinding) | ||
val Right(decoded) = RouteBlindingEncryptedDataCodecs.decode(a, route.route.blindingKey, route.route.encryptedPayloads.head) | ||
assert(BlindedRouteData.validPaymentRecipientData(decoded.tlvs).isRight) | ||
assert(decoded.tlvs.get[RouteBlindingEncryptedDataTlv.PathId].get.data == pathId.bytes) | ||
} | ||
|
||
test("create blinded route from channel hops") { | ||
val (a, b, c) = (randomKey(), randomKey(), randomKey()) | ||
val pathId = randomBytes32() | ||
val (scid1, scid2) = (ShortChannelId(1), ShortChannelId(2)) | ||
val hops = Seq( | ||
ChannelHop(scid1, a.publicKey, b.publicKey, ChannelRelayParams.FromAnnouncement(makeUpdateShort(scid1, a.publicKey, b.publicKey, 10 msat, 300, cltvDelta = CltvExpiryDelta(200)))), | ||
ChannelHop(scid2, b.publicKey, c.publicKey, ChannelRelayParams.FromAnnouncement(makeUpdateShort(scid2, b.publicKey, c.publicKey, 20 msat, 150, cltvDelta = CltvExpiryDelta(600)))), | ||
) | ||
val route = createBlindedRouteFromHops(hops, pathId, 1 msat, CltvExpiry(500)) | ||
assert(route.route.introductionNodeId == a.publicKey) | ||
assert(route.route.encryptedPayloads.length == 3) | ||
val Right(decoded1) = RouteBlindingEncryptedDataCodecs.decode(a, route.route.blindingKey, route.route.encryptedPayloads(0)) | ||
assert(BlindedRouteData.validatePaymentRelayData(decoded1.tlvs).isRight) | ||
assert(decoded1.tlvs.get[RouteBlindingEncryptedDataTlv.OutgoingChannelId].get.shortChannelId == scid1) | ||
assert(decoded1.tlvs.get[RouteBlindingEncryptedDataTlv.PaymentRelay].get.feeBase == 10.msat) | ||
assert(decoded1.tlvs.get[RouteBlindingEncryptedDataTlv.PaymentRelay].get.feeProportionalMillionths == 300) | ||
assert(decoded1.tlvs.get[RouteBlindingEncryptedDataTlv.PaymentRelay].get.cltvExpiryDelta == CltvExpiryDelta(200)) | ||
val Right(decoded2) = RouteBlindingEncryptedDataCodecs.decode(b, decoded1.nextBlinding, route.route.encryptedPayloads(1)) | ||
assert(BlindedRouteData.validatePaymentRelayData(decoded2.tlvs).isRight) | ||
assert(decoded2.tlvs.get[RouteBlindingEncryptedDataTlv.OutgoingChannelId].get.shortChannelId == scid2) | ||
assert(decoded2.tlvs.get[RouteBlindingEncryptedDataTlv.PaymentRelay].get.feeBase == 20.msat) | ||
assert(decoded2.tlvs.get[RouteBlindingEncryptedDataTlv.PaymentRelay].get.feeProportionalMillionths == 150) | ||
assert(decoded2.tlvs.get[RouteBlindingEncryptedDataTlv.PaymentRelay].get.cltvExpiryDelta == CltvExpiryDelta(600)) | ||
val Right(decoded3) = RouteBlindingEncryptedDataCodecs.decode(c, decoded2.nextBlinding, route.route.encryptedPayloads(2)) | ||
assert(BlindedRouteData.validPaymentRecipientData(decoded3.tlvs).isRight) | ||
assert(decoded3.tlvs.get[RouteBlindingEncryptedDataTlv.PathId].get.data == pathId.bytes) | ||
} | ||
|
||
test("create blinded route payment info", Tag("fuzzy")) { | ||
val rand = new scala.util.Random() | ||
val nodeId = randomKey().publicKey | ||
for (_ <- 0 to 100) { | ||
val routeLength = rand.nextInt(10) + 1 | ||
val hops = (1 to routeLength).map(i => { | ||
val scid = ShortChannelId(i) | ||
val feeBase = rand.nextInt(10_000).msat | ||
val feeProp = rand.nextInt(5000) | ||
val cltvExpiryDelta = CltvExpiryDelta(rand.nextInt(500)) | ||
val params = ChannelRelayParams.FromAnnouncement(makeUpdateShort(scid, nodeId, nodeId, feeBase, feeProp, cltvDelta = cltvExpiryDelta)) | ||
ChannelHop(scid, nodeId, nodeId, params) | ||
}) | ||
for (_ <- 0 to 100) { | ||
val amount = rand.nextLong(10_000_000_000L).msat | ||
val payInfo = aggregatePaymentInfo(amount, hops) | ||
assert(payInfo.cltvExpiryDelta == CltvExpiryDelta(hops.map(_.cltvExpiryDelta.toInt).sum)) | ||
// We verify that the aggregated fee slightly exceeds the actual fee (because of proportional fees rounding). | ||
val aggregatedFee = payInfo.fee(amount) | ||
val actualFee = Router.Route(amount, hops).fee(includeLocalChannelCost = true) | ||
assert(aggregatedFee >= actualFee, s"amount=$amount, hops=${hops.map(_.params.relayFees)}, aggregatedFee=$aggregatedFee, actualFee=$actualFee") | ||
assert(aggregatedFee - actualFee < 1000.msat.max(amount * 1e-5), s"amount=$amount, hops=${hops.map(_.params.relayFees)}, aggregatedFee=$aggregatedFee, actualFee=$actualFee") | ||
} | ||
} | ||
} | ||
|
||
} |
Oops, something went wrong.