From 9358e5e1f5d5fbbf19edcb8891693822c0d4f7bf Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 29 Mar 2022 11:10:42 +0200 Subject: [PATCH] Add `channelbalances` API call (#2196) The `channelbalances` API call retrieves information about the balances of all local channels, not just those with usable outgoing balances that are enabled for sending. This change also adds the `isEnabled` attribute to the json results for both the new `channelbalances` and old `usablebalances` API calls. --- docs/release-notes/eclair-vnext.md | 2 +- eclair-core/eclair-cli | 1 + .../main/scala/fr/acinq/eclair/Eclair.scala | 16 ++++---- .../acinq/eclair/payment/relay/Relayer.scala | 7 ++-- .../fr/acinq/eclair/EclairImplSpec.scala | 20 +++++++--- .../integration/PaymentIntegrationSpec.scala | 4 +- .../payment/relay/ChannelRelayerSpec.scala | 4 +- .../acinq/eclair/api/handlers/Channel.scala | 6 ++- .../src/test/resources/api/channelbalances | 1 + .../src/test/resources/api/usablebalances | 2 +- .../fr/acinq/eclair/api/ApiServiceSpec.scala | 39 ++++++++++++++----- 11 files changed, 71 insertions(+), 31 deletions(-) create mode 100644 eclair-node/src/test/resources/api/channelbalances diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 97a737e03..9cef7f9d6 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -8,7 +8,7 @@ ### API changes - +- `channelbalances` Retrieves information about the balances of all local channels. (#2196) ### Miscellaneous improvements and bug fixes diff --git a/eclair-core/eclair-cli b/eclair-core/eclair-cli index 4b6e32016..553df5a97 100755 --- a/eclair-core/eclair-cli +++ b/eclair-core/eclair-cli @@ -44,6 +44,7 @@ and COMMAND is one of the available commands: - allchannels - allupdates - channelstats + - channelbalances === Fees === - networkfees diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 4ca1fb1ce..958bb234b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -19,9 +19,6 @@ package fr.acinq.eclair import akka.actor.ActorRef import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.adapter.ClassicSchedulerOps -import akka.actor.{ActorRef, typed} -import akka.actor.typed.scaladsl.AskPattern.{Askable, schedulerFromActorSystem} -import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, ClassicSchedulerOps} import akka.pattern._ import akka.util.Timeout import com.softwaremill.quicklens.ModifyPimp @@ -43,7 +40,7 @@ import fr.acinq.eclair.io._ import fr.acinq.eclair.message.{OnionMessages, Postman} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceivePayment -import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannels, RelayFees, UsableBalance} +import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees} import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.PreimageReceived import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.router.Router @@ -147,7 +144,9 @@ trait Eclair { def getInfo()(implicit timeout: Timeout): Future[GetInfoResponse] - def usableBalances()(implicit timeout: Timeout): Future[Iterable[UsableBalance]] + def usableBalances()(implicit timeout: Timeout): Future[Iterable[ChannelBalance]] + + def channelBalances()(implicit timeout: Timeout): Future[Iterable[ChannelBalance]] def onChainBalance(): Future[OnChainBalance] @@ -485,8 +484,11 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { instanceId = appKit.nodeParams.instanceId.toString) ) - override def usableBalances()(implicit timeout: Timeout): Future[Iterable[UsableBalance]] = - (appKit.relayer ? GetOutgoingChannels()).mapTo[OutgoingChannels].map(_.channels.map(_.toUsableBalance)) + override def usableBalances()(implicit timeout: Timeout): Future[Iterable[ChannelBalance]] = + (appKit.relayer ? GetOutgoingChannels()).mapTo[OutgoingChannels].map(_.channels.map(_.toChannelBalance)) + + override def channelBalances()(implicit timeout: Timeout): Future[Iterable[ChannelBalance]] = + (appKit.relayer ? GetOutgoingChannels(enabledOnly = false)).mapTo[OutgoingChannels].map(_.channels.map(_.toChannelBalance)) override def globalBalance()(implicit timeout: Timeout): Future[GlobalBalance] = { for { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala index b95982e74..d69dd2945 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala @@ -132,7 +132,7 @@ object Relayer extends Logging { } case class RelayForward(add: UpdateAddHtlc) - case class UsableBalance(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, canSend: MilliSatoshi, canReceive: MilliSatoshi, isPublic: Boolean) + case class ChannelBalance(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, canSend: MilliSatoshi, canReceive: MilliSatoshi, isPublic: Boolean, isEnabled: Boolean) /** * Get the list of local outgoing channels. @@ -141,12 +141,13 @@ object Relayer extends Logging { */ case class GetOutgoingChannels(enabledOnly: Boolean = true) case class OutgoingChannel(nextNodeId: PublicKey, channelUpdate: ChannelUpdate, prevChannelUpdate: Option[ChannelUpdate], commitments: AbstractCommitments) { - def toUsableBalance: UsableBalance = UsableBalance( + def toChannelBalance: ChannelBalance = ChannelBalance( remoteNodeId = nextNodeId, shortChannelId = channelUpdate.shortChannelId, canSend = commitments.availableBalanceForSend, canReceive = commitments.availableBalanceForReceive, - isPublic = commitments.announceChannel) + isPublic = commitments.announceChannel, + isEnabled = channelUpdate.channelFlags.isEnabled) } case class OutgoingChannels(channels: Seq[OutgoingChannel]) 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 bdd2efe22..2b6155a86 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -30,13 +30,12 @@ import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.channel._ import fr.acinq.eclair.db._ import fr.acinq.eclair.io.Peer.OpenChannel -import fr.acinq.eclair.payment.Bolt11Invoice import fr.acinq.eclair.payment.Bolt11Invoice.ExtraHop import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceivePayment import fr.acinq.eclair.payment.receive.PaymentHandler -import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, RelayFees} import fr.acinq.eclair.payment.send.PaymentInitiator._ -import fr.acinq.eclair.payment.{PaymentFailed, Invoice} +import fr.acinq.eclair.payment.{Bolt11Invoice, Invoice, PaymentFailed} import fr.acinq.eclair.router.RouteCalculationSpec.makeUpdateShort import fr.acinq.eclair.router.Router.{PredefinedNodeRoute, PublicChannel} import fr.acinq.eclair.router.{Announcements, Router} @@ -56,7 +55,7 @@ import scala.concurrent.duration._ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with IdiomaticMockito with ParallelTestExecution { implicit val timeout: Timeout = Timeout(30 seconds) - case class FixtureParam(register: TestProbe, router: TestProbe, paymentInitiator: TestProbe, switchboard: TestProbe, paymentHandler: TestProbe, sender: TestProbe, kit: Kit) + case class FixtureParam(register: TestProbe, relayer: TestProbe, router: TestProbe, paymentInitiator: TestProbe, switchboard: TestProbe, paymentHandler: TestProbe, sender: TestProbe, kit: Kit) override def withFixture(test: OneArgTest): Outcome = { val watcher = TestProbe() @@ -86,7 +85,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I postman.ref.toTyped, new DummyOnChainWallet() ) - withFixture(test.toNoArgTest(FixtureParam(register, router, paymentInitiator, switchboard, paymentHandler, TestProbe(), kit))) + withFixture(test.toNoArgTest(FixtureParam(register, relayer, router, paymentInitiator, switchboard, paymentHandler, TestProbe(), kit))) } test("convert fee rate properly") { f => @@ -611,4 +610,15 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I peersDb.addOrUpdateRelayFees(b, RelayFees(999 msat, 1234)).wasCalled(once) } + test("channelBalances asks for all channels, usableBalances only for enabled ones") { f => + import f._ + + val eclair = new EclairImpl(kit) + + eclair.channelBalances().pipeTo(sender.ref) + relayer.expectMsg(GetOutgoingChannels(enabledOnly=false)) + eclair.usableBalances().pipeTo(sender.ref) + relayer.expectMsg(GetOutgoingChannels()) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala index e9c4e53f4..851c4f8a1 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala @@ -699,8 +699,8 @@ class PaymentIntegrationSpec extends IntegrationSpec { val channels1 = sender.expectMsgType[Relayer.OutgoingChannels] val channels2 = sender.expectMsgType[Relayer.OutgoingChannels] - logger.info(channels1.channels.map(_.toUsableBalance)) - logger.info(channels2.channels.map(_.toUsableBalance)) + logger.info(channels1.channels.map(_.toChannelBalance)) + logger.info(channels2.channels.map(_.toChannelBalance)) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala index 6875f18a7..3ecf49602 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala @@ -463,9 +463,9 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a val channels1 = getOutgoingChannels(true) assert(channels1.size === 2) assert(channels1.head.channelUpdate === channelUpdate_ab) - assert(channels1.head.toUsableBalance === Relayer.UsableBalance(a, channelUpdate_ab.shortChannelId, 0 msat, 300000 msat, isPublic = false)) + assert(channels1.head.toChannelBalance === Relayer.ChannelBalance(a, channelUpdate_ab.shortChannelId, 0 msat, 300000 msat, isPublic = false, isEnabled = true)) assert(channels1.last.channelUpdate === channelUpdate_bc) - assert(channels1.last.toUsableBalance === Relayer.UsableBalance(c, channelUpdate_bc.shortChannelId, 400000 msat, 0 msat, isPublic = false)) + assert(channels1.last.toChannelBalance === Relayer.ChannelBalance(c, channelUpdate_bc.shortChannelId, 400000 msat, 0 msat, isPublic = false, isEnabled = true)) channelRelayer ! WrappedAvailableBalanceChanged(AvailableBalanceChanged(null, channelId_bc, channelUpdate_bc.shortChannelId, makeCommitments(channelId_bc, 200000 msat, 500000 msat))) val channels2 = getOutgoingChannels(true) 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 340e0845d..6a8691e94 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 @@ -106,6 +106,10 @@ trait Channel { } } - val channelRoutes: Route = open ~ close ~ forceClose ~ channel ~ channels ~ allChannels ~ allUpdates ~ channelStats + val channelBalances: Route = postRequest("channelbalances") { implicit t => + complete(eclairApi.channelBalances()) + } + + val channelRoutes: Route = open ~ close ~ forceClose ~ channel ~ channels ~ allChannels ~ allUpdates ~ channelStats ~ channelBalances } diff --git a/eclair-node/src/test/resources/api/channelbalances b/eclair-node/src/test/resources/api/channelbalances new file mode 100644 index 000000000..962b4c89a --- /dev/null +++ b/eclair-node/src/test/resources/api/channelbalances @@ -0,0 +1 @@ +[{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x1","canSend":100000000,"canReceive":20000000,"isPublic":true,"isEnabled":true},{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x2","canSend":0,"canReceive":30000000,"isPublic":false,"isEnabled":false}] \ No newline at end of file diff --git a/eclair-node/src/test/resources/api/usablebalances b/eclair-node/src/test/resources/api/usablebalances index c1ef6b492..5613aaf15 100644 --- a/eclair-node/src/test/resources/api/usablebalances +++ b/eclair-node/src/test/resources/api/usablebalances @@ -1 +1 @@ -[{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x1","canSend":100000000,"canReceive":20000000,"isPublic":true},{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x2","canSend":400000000,"canReceive":30000000,"isPublic":false}] \ No newline at end of file +[{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x1","canSend":100000000,"canReceive":20000000,"isPublic":true,"isEnabled":true},{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x2","canSend":400000000,"canReceive":30000000,"isPublic":false,"isEnabled":true}] \ No newline at end of file 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 a7e700292..ff44e2f09 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 @@ -42,12 +42,13 @@ import fr.acinq.eclair.io.Peer.PeerInfo import fr.acinq.eclair.io.{NodeURI, Peer} import fr.acinq.eclair.message.OnionMessages import fr.acinq.eclair.payment._ -import fr.acinq.eclair.payment.relay.Relayer.UsableBalance +import fr.acinq.eclair.payment.relay.Relayer.ChannelBalance import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.PreimageReceived import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToRouteResponse import fr.acinq.eclair.router.Router import fr.acinq.eclair.router.Router.PredefinedNodeRoute import fr.acinq.eclair.wire.protocol._ +import org.json4s.{Formats, Serialization} import org.mockito.scalatest.IdiomaticMockito import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -62,12 +63,12 @@ import scala.util.Try class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticMockito with Matchers { - implicit val formats = JsonSupport.formats - implicit val serialization = JsonSupport.serialization - implicit val routeTestTimeout = RouteTestTimeout(3 seconds) + implicit val formats: Formats = JsonSupport.formats + implicit val serialization: Serialization = JsonSupport.serialization + implicit val routeTestTimeout: RouteTestTimeout = RouteTestTimeout(3 seconds) - val aliceNodeId = PublicKey(hex"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0") - val bobNodeId = PublicKey(hex"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585") + val aliceNodeId: PublicKey = PublicKey(hex"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0") + val bobNodeId: PublicKey = PublicKey(hex"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585") object PluginApi extends RouteProvider { override def route(directives: EclairDirectives): Route = { @@ -200,11 +201,11 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM } } - test("'usablebalances' asks relayer for current usable balances") { + test("'usablebalances' returns expected balance json only for enabled channels") { val eclair = mock[Eclair] eclair.usableBalances()(any[Timeout]) returns Future.successful(List( - UsableBalance(aliceNodeId, ShortChannelId(1), 100000000 msat, 20000000 msat, isPublic = true), - UsableBalance(aliceNodeId, ShortChannelId(2), 400000000 msat, 30000000 msat, isPublic = false) + ChannelBalance(aliceNodeId, ShortChannelId(1), 100000000 msat, 20000000 msat, isPublic = true, isEnabled = true), + ChannelBalance(aliceNodeId, ShortChannelId(2), 400000000 msat, 30000000 msat, isPublic = false, isEnabled = true) )) val mockService = mockApi(eclair) @@ -220,6 +221,26 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM } } + test("'channelbalances' returns expected balance json for all channels") { + val eclair = mock[Eclair] + eclair.channelBalances()(any[Timeout]) returns Future.successful(List( + ChannelBalance(aliceNodeId, ShortChannelId(1), 100000000 msat, 20000000 msat, isPublic = true, isEnabled = true), + ChannelBalance(aliceNodeId, ShortChannelId(2), 0 msat, 30000000 msat, isPublic = false, isEnabled = false) + )) + + val mockService = mockApi(eclair) + Post("/channelbalances") ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + Route.seal(mockService.channelBalances) ~> + check { + assert(handled) + assert(status == OK) + val response = entityAs[String] + eclair.channelBalances()(any[Timeout]).wasCalled(once) + matchTestJson("channelbalances", response) + } + } + test("'getinfo' response should include this node ID") { val eclair = mock[Eclair] val mockService = new MockService(eclair)