From c37a2ca38c5e2ee88164780f1662ae8e7ecf7c50 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 14 Apr 2021 15:18:12 +0200 Subject: [PATCH] Add API params to configure closing fee range Add new fields to the `close` API to let users configure their preferred fees for mutual close. --- .../fr/acinq/eclair/EclairImplSpec.scala | 24 ++++--- .../acinq/eclair/api/handlers/Channel.scala | 14 +++- .../fr/acinq/eclair/api/ApiServiceSpec.scala | 67 +++++++++++++++++-- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 7aaa6abd34..c18485ea48 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -295,20 +295,24 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x0"), CMD_FORCECLOSE(ActorRef.noSender)) ) - eclair.close(Left(ByteVector32.Zeroes) :: Nil, None) - register.expectMsg(Register.Forward(ActorRef.noSender, ByteVector32.Zeroes, CMD_CLOSE(ActorRef.noSender, None))) + eclair.close(Left(ByteVector32.Zeroes) :: Nil, None, None) + register.expectMsg(Register.Forward(ActorRef.noSender, ByteVector32.Zeroes, CMD_CLOSE(ActorRef.noSender, None, None))) - eclair.close(Right(ShortChannelId("568749x2597x0")) :: Nil, None) - register.expectMsg(Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x0"), CMD_CLOSE(ActorRef.noSender, None))) + val customClosingFees = ClosingFeerates(FeeratePerKw(500 sat), FeeratePerKw(200 sat), FeeratePerKw(1000 sat)) + eclair.close(Left(ByteVector32.Zeroes) :: Nil, None, Some(customClosingFees)) + register.expectMsg(Register.Forward(ActorRef.noSender, ByteVector32.Zeroes, CMD_CLOSE(ActorRef.noSender, None, Some(customClosingFees)))) - eclair.close(Right(ShortChannelId("568749x2597x0")) :: Nil, Some(ByteVector.empty)) - register.expectMsg(Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x0"), CMD_CLOSE(ActorRef.noSender, Some(ByteVector.empty)))) + eclair.close(Right(ShortChannelId("568749x2597x0")) :: Nil, None, None) + register.expectMsg(Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x0"), CMD_CLOSE(ActorRef.noSender, None, None))) - eclair.close(Right(ShortChannelId("568749x2597x0")) :: Left(ByteVector32.One) :: Right(ShortChannelId("568749x2597x1")) :: Nil, None) + eclair.close(Right(ShortChannelId("568749x2597x0")) :: Nil, Some(ByteVector.empty), Some(customClosingFees)) + register.expectMsg(Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x0"), CMD_CLOSE(ActorRef.noSender, Some(ByteVector.empty), Some(customClosingFees)))) + + eclair.close(Right(ShortChannelId("568749x2597x0")) :: Left(ByteVector32.One) :: Right(ShortChannelId("568749x2597x1")) :: Nil, None, None) register.expectMsgAllOf( - Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x0"), CMD_CLOSE(ActorRef.noSender, None)), - Register.Forward(ActorRef.noSender, ByteVector32.One, CMD_CLOSE(ActorRef.noSender, None)), - Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x1"), CMD_CLOSE(ActorRef.noSender, None)) + Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x0"), CMD_CLOSE(ActorRef.noSender, None, None)), + Register.Forward(ActorRef.noSender, ByteVector32.One, CMD_CLOSE(ActorRef.noSender, None, None)), + Register.ForwardShortId(ActorRef.noSender, ShortChannelId("568749x2597x1"), CMD_CLOSE(ActorRef.noSender, None, None)) ) } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala index cacaf4b9f0..0f138f742a 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala @@ -23,7 +23,8 @@ import fr.acinq.eclair.MilliSatoshi import fr.acinq.eclair.api.Service import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.api.serde.FormParamExtractors._ -import fr.acinq.eclair.blockchain.fee.FeeratePerByte +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} +import fr.acinq.eclair.channel.ClosingFeerates import scodec.bits.ByteVector trait Channel { @@ -53,8 +54,15 @@ trait Channel { val close: Route = postRequest("close") { implicit t => withChannelsIdentifier { channels => - formFields("scriptPubKey".as[ByteVector](binaryDataUnmarshaller).?) { scriptPubKey_opt => - complete(eclairApi.close(channels, scriptPubKey_opt)) + formFields("scriptPubKey".as[ByteVector](binaryDataUnmarshaller).?, "preferredFeerateSatByte".as[FeeratePerByte].?, "minFeerateSatByte".as[FeeratePerByte].?, "maxFeerateSatByte".as[FeeratePerByte].?) { + (scriptPubKey_opt, preferredFeerate_opt, minFeerate_opt, maxFeerate_opt) => + val closingFeerates = preferredFeerate_opt.map(preferredPerByte => { + val preferredFeerate = FeeratePerKw(preferredPerByte) + val minFeerate = minFeerate_opt.map(feerate => FeeratePerKw(feerate)).getOrElse(preferredFeerate / 2) + val maxFeerate = maxFeerate_opt.map(feerate => FeeratePerKw(feerate)).getOrElse(preferredFeerate * 2) + ClosingFeerates(preferredFeerate, minFeerate, maxFeerate) + }) + complete(eclairApi.close(channels, scriptPubKey_opt, closingFeerates)) } } } diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala index 106252f3c0..4a4f899a64 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -292,13 +292,13 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM val channelId = ByteVector32(hex"56d7d6eda04d80138270c49709f1eadb5ab4939e5061309ccdacdb98ce637d0e") val channelIdSerialized = channelId.toHex val response = Map[ChannelIdentifier, Either[Throwable, CommandResponse[CMD_CLOSE]]]( - Left(channelId) -> Right(RES_SUCCESS(CMD_CLOSE(ActorRef.noSender, None), channelId)), + Left(channelId) -> Right(RES_SUCCESS(CMD_CLOSE(ActorRef.noSender, None, None), channelId)), Left(channelId.reverse) -> Left(new RuntimeException("channel not found")), - Right(ShortChannelId(shortChannelIdSerialized)) -> Right(RES_SUCCESS(CMD_CLOSE(ActorRef.noSender, None), ByteVector32.fromValidHex(channelIdSerialized.reverse))) + Right(ShortChannelId(shortChannelIdSerialized)) -> Right(RES_SUCCESS(CMD_CLOSE(ActorRef.noSender, None, None), ByteVector32.fromValidHex(channelIdSerialized.reverse))) ) val eclair = mock[Eclair] - eclair.close(any, any)(any[Timeout]) returns Future.successful(response) + eclair.close(any, any, any)(any[Timeout]) returns Future.successful(response) val mockService = new MockService(eclair) Post("/close", FormData("shortChannelId" -> shortChannelIdSerialized).toEntity) ~> @@ -309,7 +309,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM assert(handled) assert(status == OK) val resp = entityAs[String] - eclair.close(Right(ShortChannelId(shortChannelIdSerialized)) :: Nil, None)(any[Timeout]).wasCalled(once) + eclair.close(Right(ShortChannelId(shortChannelIdSerialized)) :: Nil, None, None)(any[Timeout]).wasCalled(once) matchTestJson("close", resp) } @@ -321,7 +321,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM assert(handled) assert(status == OK) val resp = entityAs[String] - eclair.close(Left(channelId) :: Nil, None)(any[Timeout]).wasCalled(once) + eclair.close(Left(channelId) :: Nil, None, None)(any[Timeout]).wasCalled(once) matchTestJson("close", resp) } @@ -333,11 +333,66 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM assert(handled) assert(status == OK) val resp = entityAs[String] - eclair.close(Left(channelId) :: Right(ShortChannelId("42000x27x3")) :: Right(ShortChannelId("42000x561x1")) :: Nil, None)(any[Timeout]).wasCalled(once) + eclair.close(Left(channelId) :: Right(ShortChannelId("42000x27x3")) :: Right(ShortChannelId("42000x561x1")) :: Nil, None, None)(any[Timeout]).wasCalled(once) matchTestJson("close", resp) } } + test("'close' accepts custom closing feerates") { + val shortChannelId = "1701x42x3" + val response = Map[ChannelIdentifier, Either[Throwable, CommandResponse[CMD_CLOSE]]]( + Right(ShortChannelId(shortChannelId)) -> Right(RES_SUCCESS(CMD_CLOSE(ActorRef.noSender, None, None), randomBytes32)) + ) + + val eclair = mock[Eclair] + eclair.close(any, any, any)(any[Timeout]) returns Future.successful(response) + val mockService = new MockService(eclair) + + Post("/close", FormData("shortChannelId" -> shortChannelId, "preferredFeerateSatByte" -> "10", "minFeerateSatByte" -> "2", "maxFeerateSatByte" -> "50").toEntity) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.close) ~> + check { + assert(handled) + assert(status == OK) + val expectedFeerates = ClosingFeerates(FeeratePerKw(2500 sat), FeeratePerKw(500 sat), FeeratePerKw(12500 sat)) + eclair.close(Right(ShortChannelId(shortChannelId)) :: Nil, None, Some(expectedFeerates))(any[Timeout]).wasCalled(once) + } + + Post("/close", FormData("shortChannelId" -> shortChannelId, "preferredFeerateSatByte" -> "10").toEntity) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.close) ~> + check { + assert(handled) + assert(status == OK) + val expectedFeerates = ClosingFeerates(FeeratePerKw(2500 sat), FeeratePerKw(1250 sat), FeeratePerKw(5000 sat)) + eclair.close(Right(ShortChannelId(shortChannelId)) :: Nil, None, Some(expectedFeerates))(any[Timeout]).wasCalled(once) + } + + Post("/close", FormData("shortChannelId" -> shortChannelId, "preferredFeerateSatByte" -> "10", "minFeerateSatByte" -> "2").toEntity) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.close) ~> + check { + assert(handled) + assert(status == OK) + val expectedFeerates = ClosingFeerates(FeeratePerKw(2500 sat), FeeratePerKw(500 sat), FeeratePerKw(5000 sat)) + eclair.close(Right(ShortChannelId(shortChannelId)) :: Nil, None, Some(expectedFeerates))(any[Timeout]).wasCalled(once) + } + + Post("/close", FormData("shortChannelId" -> shortChannelId, "preferredFeerateSatByte" -> "10", "maxFeerateSatByte" -> "50").toEntity) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.close) ~> + check { + assert(handled) + assert(status == OK) + val expectedFeerates = ClosingFeerates(FeeratePerKw(2500 sat), FeeratePerKw(1250 sat), FeeratePerKw(12500 sat)) + eclair.close(Right(ShortChannelId(shortChannelId)) :: Nil, None, Some(expectedFeerates))(any[Timeout]).wasCalled(once) + } + } + test("'connect' method should accept an URI and a triple with nodeId/host/port") { val remoteNodeId = PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87") val remoteUri = NodeURI.parse("030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87@93.137.102.239:9735")