From 42dfa9f535b3762aaa427d0e250998662fc20617 Mon Sep 17 00:00:00 2001 From: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> Date: Tue, 6 Jun 2023 10:44:53 +0200 Subject: [PATCH] Handle invoice with amounts larger than 1btc (#2684) For amounts that are multiples of 1btc, we shouldn't use a multiplier and should directly encode this amount. --- .../acinq/eclair/payment/Bolt11Invoice.scala | 34 ++++++++------- .../eclair/payment/Bolt11InvoiceSpec.scala | 42 +++++++++++++------ 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala index b5e58272e..027f217f9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala @@ -477,22 +477,23 @@ object Bolt11Invoice { /** * @return the unit allowing for the shortest representation possible */ - def unit(amount: MilliSatoshi): Char = amount.toLong * 10 match { // 1 milli-satoshis == 10 pico-bitcoin - case pico if pico % 1000 > 0 => 'p' - case pico if pico % 1000000 > 0 => 'n' - case pico if pico % 1000000000 > 0 => 'u' - case _ => 'm' + def unit(amount: MilliSatoshi): Option[Char] = amount.toLong * 10 match { // 1 milli-satoshis == 10 pico-bitcoin + case pico if pico % 1_000 > 0 => Some('p') + case pico if pico % 1_000_000 > 0 => Some('n') + case pico if pico % 1_000_000_000 > 0 => Some('u') + case pico if pico % 1_000_000_000_000L > 0 => Some('m') + case _ => None } def decode(input: String): Try[Option[MilliSatoshi]] = (input match { case "" => Success(None) case a if a.last == 'p' && a.dropRight(1).last != '0' => Failure(new IllegalArgumentException("invalid sub-millisatoshi precision")) - case a if a.last == 'p' => Success(Some(MilliSatoshi(a.dropRight(1).toLong / 10L))) // 1 pico-bitcoin == 0.1 milli-satoshis - case a if a.last == 'n' => Success(Some(MilliSatoshi(a.dropRight(1).toLong * 100L))) - case a if a.last == 'u' => Success(Some(MilliSatoshi(a.dropRight(1).toLong * 100000L))) - case a if a.last == 'm' => Success(Some(MilliSatoshi(a.dropRight(1).toLong * 100000000L))) - case a => Success(Some(MilliSatoshi(a.toLong * 100000000000L))) + case a if a.last == 'p' => Success(Some(MilliSatoshi(a.dropRight(1).toLong / 10))) // 1 pico-bitcoin == 0.1 milli-satoshis + case a if a.last == 'n' => Success(Some(MilliSatoshi(a.dropRight(1).toLong * 100))) + case a if a.last == 'u' => Success(Some(MilliSatoshi(a.dropRight(1).toLong * 100_000))) + case a if a.last == 'm' => Success(Some(MilliSatoshi(a.dropRight(1).toLong * 100_000_000))) + case a => Success(Some(MilliSatoshi(a.toLong * 100_000_000_000L))) }).map { case None => None case Some(MilliSatoshi(0)) => None @@ -500,12 +501,15 @@ object Bolt11Invoice { } def encode(amount: Option[MilliSatoshi]): String = { - (amount: @unchecked) match { + amount match { case None => "" - case Some(amt) if unit(amt) == 'p' => s"${amt.toLong * 10L}p" // 1 pico-bitcoin == 0.1 milli-satoshis - case Some(amt) if unit(amt) == 'n' => s"${amt.toLong / 100L}n" - case Some(amt) if unit(amt) == 'u' => s"${amt.toLong / 100000L}u" - case Some(amt) if unit(amt) == 'm' => s"${amt.toLong / 100000000L}m" + case Some(amt) => unit(amt) match { + case Some('p') => s"${amt.toLong * 10}p" // 1 pico-bitcoin == 0.1 milli-satoshis + case Some('n') => s"${amt.toLong / 100}n" + case Some('u') => s"${amt.toLong / 100_000}u" + case Some('m') => s"${amt.toLong / 100_000_000}m" + case _ => s"${amt.toLong / 100_000_000_000L}" + } } } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala index 0cde4be61..bcd9777ca 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala @@ -83,17 +83,20 @@ class Bolt11InvoiceSpec extends AnyFunSuite { } test("check minimal unit is used") { - assert('p' == Amount.unit(1 msat)) - assert('p' == Amount.unit(99 msat)) - assert('n' == Amount.unit(100 msat)) - assert('p' == Amount.unit(101 msat)) - assert('n' == Amount.unit((1 sat).toMilliSatoshi)) - assert('u' == Amount.unit((100 sat).toMilliSatoshi)) - assert('n' == Amount.unit((101 sat).toMilliSatoshi)) - assert('u' == Amount.unit((1155400 sat).toMilliSatoshi)) - assert('m' == Amount.unit((1 millibtc).toMilliSatoshi)) - assert('m' == Amount.unit((10 millibtc).toMilliSatoshi)) - assert('m' == Amount.unit((1 btc).toMilliSatoshi)) + assert(Amount.unit(1 msat).contains('p')) + assert(Amount.unit(99 msat).contains('p')) + assert(Amount.unit(100 msat).contains('n')) + assert(Amount.unit(101 msat).contains('p')) + assert(Amount.unit((1 sat).toMilliSatoshi).contains('n')) + assert(Amount.unit((100 sat).toMilliSatoshi).contains('u')) + assert(Amount.unit((101 sat).toMilliSatoshi).contains('n')) + assert(Amount.unit((1155400 sat).toMilliSatoshi).contains('u')) + assert(Amount.unit((1 millibtc).toMilliSatoshi).contains('m')) + assert(Amount.unit((10 millibtc).toMilliSatoshi).contains('m')) + assert(Amount.unit((1 btc).toMilliSatoshi).isEmpty) + assert(Amount.unit((1.1 btc).toMilliSatoshi).contains('m')) + assert(Amount.unit((2 btc).toMilliSatoshi).isEmpty) + assert(Amount.unit((10 btc).toMilliSatoshi).isEmpty) } test("decode empty amount") { @@ -470,13 +473,26 @@ class Bolt11InvoiceSpec extends AnyFunSuite { assert(Bolt11Invoice.fromString(input.toUpperCase()).get.toString == input) } - test("Pay 1 BTC without multiplier") { + test("Pay 1 BTC with multiplier") { val ref = "lnbc1000m1pdkmqhusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5n2ees808r98m0rh4472yyth0c5fptzcxmexcjznrzmq8xald0cgqdqsf4ujqarfwqsxymmccqp2pv37ezvhth477nu0yhhjlcry372eef57qmldhreqnr0kx82jkupp3n7nw42u3kdyyjskdr8jhjy2vugr3skdmy8ersft36969xplkxsp2v7c58" val Success(invoice) = Bolt11Invoice.fromString(ref) - assert(invoice.amount_opt.contains(100000000000L msat)) + assert(invoice.amount_opt.contains(100_000_000_000L msat)) assert(features2bits(invoice.features) == BitVector.empty) } + test("Pay 1 BTC without multiplier") { + val testCases = Seq( + 100_000_000_000L.msat -> "lnbcrt11pj8wdh7sp5p2052f28az75s3eauqjskcwrzrjujf7rfqspsvk6hgppywytrdzspp5670t00mwakdy0l5w3lnw4rhdgnv4ctep974am6jp0zma627fhdfsdqqxqyjw5qcqp29qyysgqh2ce2cmptj33l35a9pt2l603aa34jpj8p35s302l0lhuujmtmkghrmkadv456h3rpsxjpschnpt5ugzltqsjtauvnfy799aufapav6gp202th5", + 100_000_000_000_000L.msat -> "lnbcrt10001pj8wd3rsp5cv2vayxnm7d4783r0477rstzpkl7n4ftmalgu9v8akzf0nhqrs3qpp5vednenalh0v6gzxpzrdxf9cepv4274vc0tax5389cjq0zv9qvs9sdqqxqyjw5qcqp29qyysgqk5f8um72jlnw9unjltdgxw9e2fvec0cxq05tcwuen2jpu42q4p9pt2djk2ysu62nkpg49km59wrexm0wt3msevz53fr2tfnqxf5sdnqpu8th97", + ) + testCases.foreach { case (amount, ref) => + val Success(invoice) = Bolt11Invoice.fromString(ref) + assert(invoice.amount_opt.contains(amount)) + val encoded = invoice.toString + assert(encoded == ref) + } + } + test("supported invoice features") { val nodeParams = TestConstants.Alice.nodeParams.copy(features = Features(knownFeatures.map(f => f -> Optional).toMap)) case class Result(allowMultiPart: Boolean, requirePaymentSecret: Boolean, areSupported: Boolean) // "supported" is based on the "it's okay to be odd" rule