Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

### API changes

<insert changes>
- `channelbalances` Retrieves information about the balances of all local channels. (#2196)

### Miscellaneous improvements and bug fixes

Expand Down
1 change: 1 addition & 0 deletions eclair-core/eclair-cli
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ and COMMAND is one of the available commands:
- allchannels
- allupdates
- channelstats
- channelbalances
Comment thread
t-bast marked this conversation as resolved.

=== Fees ===
- networkfees
Expand Down
16 changes: 9 additions & 7 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually there is a subtlety here: isEnabled only applies to our side of the channel, there is a separate channel_update for the other direction (our peer -> us).

Ideally we would expose two bools: sendEnabled and receiveEnabled (our current isEnabled is actually sendEnabled) to have the most accurate data. However the relayer only keeps our own channe_ update, not our peer's channel_update (because we only want to know if we can send, we don't care about receiving).

I really don't think it's worth fixing, unless someone has a very compelling use-case for it, but I thought it was interesting to raise the point.

}
case class OutgoingChannels(channels: Seq[OutgoingChannel])

Expand Down
20 changes: 15 additions & 5 deletions eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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()
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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())
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

}
1 change: 1 addition & 0 deletions eclair-node/src/test/resources/api/channelbalances
Original file line number Diff line number Diff line change
@@ -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}]
2 changes: 1 addition & 1 deletion eclair-node/src/test/resources/api/usablebalances
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x1","canSend":100000000,"canReceive":20000000,"isPublic":true},{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x2","canSend":400000000,"canReceive":30000000,"isPublic":false}]
[{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x1","canSend":100000000,"canReceive":20000000,"isPublic":true,"isEnabled":true},{"remoteNodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","shortChannelId":"0x0x2","canSend":400000000,"canReceive":30000000,"isPublic":false,"isEnabled":true}]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down