From e17335931b6220c085a12b3e4292bb4d7cfc54d9 Mon Sep 17 00:00:00 2001 From: Fabrice Drouin Date: Mon, 6 Nov 2017 10:56:07 -0800 Subject: [PATCH] Add an optional 'minimum htlc expiry' tag (#202) --- .../eclair/payment/PaymentLifecycle.scala | 8 +-- .../acinq/eclair/payment/PaymentRequest.scala | 54 +++++++++++++++---- .../fr/acinq/eclair/channel/FuzzySpec.scala | 2 +- .../eclair/payment/HtlcGenerationSpec.scala | 4 +- .../eclair/payment/PaymentRequestSpec.scala | 18 ++++++- 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala index 4a83009bb..c1ae415a4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala @@ -13,7 +13,7 @@ import scodec.Attempt // @formatter:off case class ReceivePayment(amountMsat: MilliSatoshi, description: String) -case class SendPayment(amountMsat: Long, paymentHash: BinaryData, targetNodeId: PublicKey, maxAttempts: Int = 5) +case class SendPayment(amountMsat: Long, paymentHash: BinaryData, targetNodeId: PublicKey, minFinalCltvExpiry: Long = PaymentLifecycle.defaultMinFinalCltvExpiry, maxAttempts: Int = 5) sealed trait PaymentResult case class PaymentSucceeded(route: Seq[Hop], paymentPreimage: BinaryData) extends PaymentResult @@ -54,7 +54,7 @@ class PaymentLifecycle(sourceNodeId: PublicKey, router: ActorRef, register: Acto case Event(RouteResponse(hops, ignoreNodes, ignoreChannels), WaitingForRoute(s, c, failures)) => log.info(s"route found: attempt=${failures.size + 1}/${c.maxAttempts} route=${hops.map(_.nextNodeId).mkString("->")} channels=${hops.map(_.lastUpdate.shortChannelId.toHexString).mkString("->")}") val firstHop = hops.head - val finalExpiry = Globals.blockCount.get().toInt + defaultHtlcExpiry + val finalExpiry = Globals.blockCount.get().toInt + c.minFinalCltvExpiry.toInt val (cmd, sharedSecrets) = buildCommand(c.amountMsat, finalExpiry, c.paymentHash, hops) // TODO: HACK!!!! see Router.scala (we actually store the first node id in the sig) if (firstHop.lastUpdate.signature.size == 32) { @@ -198,8 +198,8 @@ object PaymentLifecycle { (msat + feeMsat, expiry + expiryDelta, PerHopPayload(hop.lastUpdate.shortChannelId, msat, expiry) +: payloads) } - // TODO: set correct initial expiry - val defaultHtlcExpiry = 10 + // this is defined in BOLT 11 + val defaultMinFinalCltvExpiry = 9 def buildCommand(finalAmountMsat: Long, finalExpiry: Int, paymentHash: BinaryData, hops: Seq[Hop]): (CMD_ADD_HTLC, Seq[(BinaryData, PublicKey)]) = { val (firstAmountMsat, firstExpiry, payloads) = buildPayloads(finalAmountMsat, finalExpiry, hops.drop(1)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala index 3e38ca60f..35cda9d7d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala @@ -60,6 +60,14 @@ case class PaymentRequest(prefix: String, amount: Option[MilliSatoshi], timestam def routingInfo(): Seq[RoutingInfoTag] = tags.collect { case t: RoutingInfoTag => t } + def expiry: Option[Long] = tags.collectFirst { + case PaymentRequest.ExpiryTag(seconds) => seconds + } + + def minFinalCltvExpiry: Option[Long] = tags.collectFirst { + case PaymentRequest.MinFinalCltvExpiryTag(expiry) => expiry + } + /** * * @return a representation of this payment request, without its signature, as a bit stream. This is what will be signed. @@ -242,20 +250,24 @@ object PaymentRequest { /** * Expiry Date * - * @param seconds expriry data for this payment request + * @param seconds expiry data for this payment request */ case class ExpiryTag(seconds: Long) extends Tag { override def toInt5s = { val ints = writeUnsignedLong(seconds) - val size = writeUnsignedLong(ints.size) - // make sure that size is encoded on 2 int5 values - val size1 = size.length match { - case 0 => Seq(0.toByte, 0. toByte) - case 1 => 0.toByte +: size - case 2 => size - case n => throw new IllegalArgumentException("tag data length field must be encoded on 2 5-bits integers") - } - Bech32.map('x') +: (size1 ++ ints) + Bech32.map('x') +: (writeSize(ints.size) ++ ints) } + } + + /** + * Min final CLTV expiry + * + * + * @param blocks min final cltv expiry, in blocks + */ + case class MinFinalCltvExpiryTag(blocks: Long) extends Tag { + override def toInt5s = { + val ints = writeUnsignedLong(blocks) + Bech32.map('c') +: (writeSize(ints.size) ++ ints) } } @@ -322,6 +334,9 @@ object PaymentRequest { case x if x == Bech32.map('x') => val expiry = readUnsignedLong(len, input.drop(3).take(len)) ExpiryTag(expiry) + case c if c == Bech32.map('c') => + val expiry = readUnsignedLong(len, input.drop(3).take(len)) + MinFinalCltvExpiryTag(expiry) } } } @@ -406,9 +421,26 @@ object PaymentRequest { else writeUnsignedLong(value / 32, (value % 32).toByte +: acc) } + /** + * convert a tag data size to a sequence of Int5s. It * must * fit on a sequence + * of 2 Int5 values + * @param size data size + * @return size as a sequence of exactly 2 Int5 values + */ + def writeSize(size: Long) : Seq[Int5] = { + val output = writeUnsignedLong(size) + // make sure that size is encoded on 2 int5 values + output.length match { + case 0 => Seq(0.toByte, 0.toByte) + case 1 => 0.toByte +: output + case 2 => output + case n => throw new IllegalArgumentException("tag data length field must be encoded on 2 5-bits integers") + } + } + /** * reads an unsigned long value from a sequence of Int5s - * @param length length of the seauence + * @param length length of the sequence * @param ints sequence of Int5s * @return an unsigned long value */ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/FuzzySpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/FuzzySpec.scala index 8fced9d05..7db42307a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/FuzzySpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/FuzzySpec.scala @@ -75,7 +75,7 @@ class FuzzySpec extends TestkitBaseClass with StateTestsHelperMethods with Loggi def buildCmdAdd(paymentHash: BinaryData, dest: PublicKey) = { // allow overpaying (no more than 2 times the required amount) val amount = requiredAmount + Random.nextInt(requiredAmount) - val expiry = Globals.blockCount.get().toInt + PaymentLifecycle.defaultHtlcExpiry + val expiry = Globals.blockCount.get().toInt + PaymentLifecycle.defaultMinFinalCltvExpiry PaymentLifecycle.buildCommand(amount, expiry, paymentHash, Hop(null, dest, null) :: Nil)._1 } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/HtlcGenerationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/HtlcGenerationSpec.scala index ab1038a84..55b80f9ae 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/HtlcGenerationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/HtlcGenerationSpec.scala @@ -149,11 +149,11 @@ object HtlcGenerationSpec { val finalAmountMsat = 42000000L val currentBlockCount = 420000 - val finalExpiry = currentBlockCount + defaultHtlcExpiry + val finalExpiry = currentBlockCount + defaultMinFinalCltvExpiry val paymentPreimage = BinaryData("42" * 32) val paymentHash = Crypto.sha256(paymentPreimage) - val expiry_de = currentBlockCount + defaultHtlcExpiry + val expiry_de = currentBlockCount + defaultMinFinalCltvExpiry val amount_de = finalAmountMsat val fee_d = nodeFee(channelUpdate_de.feeBaseMsat, channelUpdate_de.feeProportionalMillionths, amount_de) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala index 7ede5c51b..5fe79fb6b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala @@ -157,13 +157,27 @@ class PaymentRequestSpec extends FunSuite { assert(PaymentRequest.write(pr.sign(priv)) == ref) } + test("On mainnet, with fallback (p2wsh) address bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3 and a minimum htlc cltv expiry of 12") { + val ref = "lnbc20m1pvjluezcqpvpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfp4qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q90qkf3gd7fcqs0ewr7t3xf72ptmc4n38evg0xhy4p64nlg7hgrmq6g997tkrvezs8afs0x0y8v4vs8thwsk6knkvdfvfa7wmhhpcsxcqw0ny48" + val pr = PaymentRequest.read(ref) + assert(pr.prefix == "lnbc") + assert(pr.amount == Some(MilliSatoshi(2000000000L))) + assert(pr.paymentHash == BinaryData("0001020304050607080900010203040506070809000102030405060708090102")) + assert(pr.timestamp == 1496314658L) + assert(pr.nodeId == PublicKey(BinaryData("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"))) + assert(pr.description == Right(Crypto.sha256("One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon".getBytes))) + assert(pr.fallbackAddress === Some("bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3")) + assert(pr.minFinalCltvExpiry === Some(12)) + assert(pr.tags.size == 4) + assert(PaymentRequest.write(pr.sign(priv)) == ref) + } + test("expiry is a variable-length unsigned value") { val pr = PaymentRequest(Block.RegtestGenesisBlock.hash, Some(MilliSatoshi(100000L)), BinaryData("0001020304050607080900010203040506070809000102030405060708090102"), priv, "test", fallbackAddress = None, expirySeconds = Some(21600), timestamp = System.currentTimeMillis() / 1000L) val serialized = PaymentRequest write pr val pr1 = PaymentRequest read serialized - val expiry = pr1.tags.collectFirst { case expiry: PaymentRequest.ExpiryTag => expiry.seconds }.get - assert(expiry == 21600) + assert(pr.expiry === Some(21600)) } }