From 37eb1420dc64d68ff394d81b83b9098bf4efa2d2 Mon Sep 17 00:00:00 2001 From: rorp Date: Fri, 2 Jun 2023 02:38:58 -0700 Subject: [PATCH] Add `closedchannels` RPC (#2642) This RPC allows to access the historic channel data without relying on third party services like LN explorers. Note that when the `remoteNodeId` filter is not provided, this query may be expensive on nodes with a lot of closed channels. --- contrib/eclair-cli.bash-completion | 2 +- docs/release-notes/eclair-vnext.md | 1 + eclair-core/eclair-cli | 1 + .../main/scala/fr/acinq/eclair/Eclair.scala | 10 ++++++++ .../scala/fr/acinq/eclair/db/ChannelsDb.scala | 5 +++- .../fr/acinq/eclair/db/DualDatabases.scala | 8 ++++++- .../fr/acinq/eclair/db/pg/PgChannelsDb.scala | 16 ++++++++++++- .../eclair/db/sqlite/SqliteChannelsDb.scala | 24 ++++++++++++++++++- .../fr/acinq/eclair/db/ChannelsDbSpec.scala | 7 +++++- .../acinq/eclair/api/handlers/Channel.scala | 12 ++++++++-- 10 files changed, 78 insertions(+), 8 deletions(-) diff --git a/contrib/eclair-cli.bash-completion b/contrib/eclair-cli.bash-completion index 5c7c53935..d4eac77e0 100644 --- a/contrib/eclair-cli.bash-completion +++ b/contrib/eclair-cli.bash-completion @@ -21,7 +21,7 @@ _eclair-cli() *) # works fine, but is too slow at the moment. # allopts=$($eclaircli help 2>&1 | awk '$1 ~ /^"/ { sub(/,/, ""); print $1}' | sed 's/[":]//g') - allopts="getinfo connect open cpfpbumpfees close forceclose updaterelayfee peers channels channel allnodes allchannels allupdates findroute findroutetonode findroutebetweennodes parseinvoice payinvoice sendtonode getsentinfo createinvoice getinvoice listinvoices listpendinginvoices listreceivedpayments getreceivedinfo audit networkfees channelstats" + allopts="getinfo connect open cpfpbumpfees close forceclose updaterelayfee peers channels channel closedchannels allnodes allchannels allupdates findroute findroutetonode findroutebetweennodes parseinvoice payinvoice sendtonode getsentinfo createinvoice getinvoice listinvoices listpendinginvoices listreceivedpayments getreceivedinfo audit networkfees channelstats" if ! [[ " $allopts " =~ " $prev " ]]; then # prevent double arguments if [[ -z "$cur" || "$cur" =~ ^[a-z] ]]; then diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 4473833cc..f05d413f1 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -48,6 +48,7 @@ Node operators that use Postgres as database backend and make SQL queries on cha - `channel-opened` websocket event was updated to contain the final `channel_id` and be published when a channel is ready to process payments (#2567) - `getsentinfo` can now be used with `--offer` to list payments sent to a specific offer. - `listreceivedpayments` lists payments received by your node (#2607) +- `closedchannels` lists closed channels. It accepts `--count` and `--skip` parameters to limit the number of retrieved items as well (#2642) - `cpfpbumpfees` can be used to unblock chains of unconfirmed transactions by creating a child transaction that pays a high fee (#1783) ### Miscellaneous improvements and bug fixes diff --git a/eclair-core/eclair-cli b/eclair-core/eclair-cli index 3bfe4c6d3..e3f9cdeab 100755 --- a/eclair-core/eclair-cli +++ b/eclair-core/eclair-cli @@ -45,6 +45,7 @@ and COMMAND is one of the available commands: - forceclose - channel - channels + - closedchannels - allchannels - allupdates - channelstats 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 7ca368849..5849c686a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -104,6 +104,8 @@ trait Eclair { def channelInfo(channel: ApiTypes.ChannelIdentifier)(implicit timeout: Timeout): Future[CommandResponse[CMD_GET_CHANNEL_INFO]] + def closedChannels(nodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]] + def peers()(implicit timeout: Timeout): Future[Iterable[PeerInfo]] def node(nodeId: PublicKey)(implicit timeout: Timeout): Future[Option[Router.PublicNode]] @@ -288,6 +290,14 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { sendToChannel[CMD_GET_CHANNEL_INFO, CommandResponse[CMD_GET_CHANNEL_INFO]](channel, CMD_GET_CHANNEL_INFO(ActorRef.noSender)) } + override def closedChannels(nodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]] = { + Future { + appKit.nodeParams.db.channels.listClosedChannels(nodeId_opt, paginated_opt).map { data => + RES_GET_CHANNEL_INFO(nodeId = data.remoteNodeId, channelId = data.channelId, state = CLOSED, data = data) + } + } + } + override def allChannels()(implicit timeout: Timeout): Future[Iterable[ChannelDesc]] = { (appKit.router ? Router.GetChannels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelDesc(c.shortChannelId, c.nodeId1, c.nodeId2))) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala index a771f927c..c312c9786 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala @@ -17,7 +17,8 @@ package fr.acinq.eclair.db import fr.acinq.bitcoin.scalacompat.ByteVector32 -import fr.acinq.eclair.CltvExpiry +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.{CltvExpiry, Paginated, TimestampSecond} import fr.acinq.eclair.channel.PersistentChannelData import fr.acinq.eclair.db.DbEventHandler.ChannelEvent @@ -33,6 +34,8 @@ trait ChannelsDb { def listLocalChannels(): Seq[PersistentChannelData] + def listClosedChannels(remoteNodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated]): Seq[PersistentChannelData] + def addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry): Unit def listHtlcInfos(channelId: ByteVector32, commitmentNumber: Long): Seq[(ByteVector32, CltvExpiry)] diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala index 43d9182aa..baa6e62fc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -1,6 +1,7 @@ package fr.acinq.eclair.db import com.google.common.util.concurrent.ThreadFactoryBuilder +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi} import fr.acinq.eclair.channel._ import fr.acinq.eclair.db.Databases.{FileBackup, PostgresDatabases, SqliteDatabases} @@ -10,7 +11,7 @@ import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} -import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, Paginated, RealShortChannelId, ShortChannelId, TimestampMilli} +import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, Paginated, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond} import grizzled.slf4j.Logging import java.io.File @@ -229,6 +230,11 @@ case class DualChannelsDb(primary: ChannelsDb, secondary: ChannelsDb) extends Ch primary.listLocalChannels() } + override def listClosedChannels(remoteNodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated]): Seq[PersistentChannelData] = { + runAsync(secondary.listClosedChannels(remoteNodeId_opt, paginated_opt)) + primary.listClosedChannels(remoteNodeId_opt, paginated_opt) + } + override def addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry): Unit = { runAsync(secondary.addHtlcInfo(channelId, commitmentNumber, paymentHash, cltvExpiry)) primary.addHtlcInfo(channelId, commitmentNumber, paymentHash, cltvExpiry) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala index a29b51d10..a2f852bef 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.db.pg import com.zaxxer.hikari.util.IsolationLevel import fr.acinq.bitcoin.scalacompat.ByteVector32 -import fr.acinq.eclair.CltvExpiry +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.channel.PersistentChannelData import fr.acinq.eclair.db.ChannelsDb import fr.acinq.eclair.db.DbEventHandler.ChannelEvent @@ -26,6 +26,7 @@ import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.db.pg.PgUtils.PgLock import fr.acinq.eclair.wire.internal.channel.ChannelCodecs.channelDataCodec +import fr.acinq.eclair.{CltvExpiry, Paginated} import grizzled.slf4j.Logging import scodec.bits.BitVector @@ -246,6 +247,19 @@ class PgChannelsDb(implicit ds: DataSource, lock: PgLock) extends ChannelsDb wit } } + override def listClosedChannels(remoteNodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated]): Seq[PersistentChannelData] = withMetrics("channels/list-closed-channels", DbBackends.Postgres) { + val sql = remoteNodeId_opt match { + case None => "SELECT data FROM local.channels WHERE is_closed=TRUE ORDER BY closed_timestamp DESC" + case Some(remoteNodeId) => s"SELECT data FROM local.channels WHERE is_closed=TRUE AND remote_node_id = '${remoteNodeId.toHex}' ORDER BY closed_timestamp DESC" + } + withLock { pg => + using(pg.prepareStatement(limited(sql, paginated_opt))) { statement => + statement.executeQuery() + .mapCodec(channelDataCodec).toSeq + } + } + } + override def addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry): Unit = withMetrics("channels/add-htlc-info", DbBackends.Postgres) { withLock { pg => using(pg.prepareStatement("INSERT INTO local.htlc_infos VALUES (?, ?, ?, ?)")) { statement => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala index 4648bf73d..ea0797728 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala @@ -17,13 +17,14 @@ package fr.acinq.eclair.db.sqlite import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.channel.PersistentChannelData import fr.acinq.eclair.db.ChannelsDb import fr.acinq.eclair.db.DbEventHandler.ChannelEvent import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.wire.internal.channel.ChannelCodecs.channelDataCodec -import fr.acinq.eclair.{CltvExpiry, TimestampMilli} +import fr.acinq.eclair.{CltvExpiry, Paginated, TimestampMilli, TimestampSecond} import grizzled.slf4j.Logging import scodec.bits.BitVector @@ -170,6 +171,27 @@ class SqliteChannelsDb(val sqlite: Connection) extends ChannelsDb with Logging { } } + + override def listClosedChannels(remoteNodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated]): Seq[PersistentChannelData] = withMetrics("channels/list-closed-channels", DbBackends.Sqlite) { + val sql = "SELECT data FROM local_channels WHERE is_closed=1 ORDER BY closed_timestamp DESC" + remoteNodeId_opt match { + case None => + using(sqlite.prepareStatement(limited(sql, paginated_opt))) { statement => + statement.executeQuery().mapCodec(channelDataCodec).toSeq + } + case Some(nodeId) => + using(sqlite.prepareStatement(sql)) { statement => + val filtered = statement.executeQuery() + .mapCodec(channelDataCodec).filter(_.remoteNodeId == nodeId) + val limited = paginated_opt match { + case None => filtered + case Some(p) => filtered.slice(p.skip, p.skip + p.count) + } + limited.toSeq + } + } + } + override def addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry): Unit = withMetrics("channels/add-htlc-info", DbBackends.Sqlite) { using(sqlite.prepareStatement("INSERT INTO htlc_infos VALUES (?, ?, ?, ?)")) { statement => statement.setBytes(1, channelId.toArray) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala index 4bd985808..25cd2ef44 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala @@ -18,6 +18,7 @@ package fr.acinq.eclair.db import com.softwaremill.quicklens._ import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.TestDatabases.{TestPgDatabases, TestSqliteDatabases, migrationCheck} import fr.acinq.eclair.channel.RealScidStatus @@ -30,7 +31,7 @@ import fr.acinq.eclair.db.sqlite.SqliteChannelsDb import fr.acinq.eclair.db.sqlite.SqliteUtils.ExtendedResultSet._ import fr.acinq.eclair.wire.internal.channel.ChannelCodecs.channelDataCodec import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.{CltvExpiry, RealShortChannelId, TestDatabases, randomBytes32, randomKey} +import fr.acinq.eclair.{CltvExpiry, RealShortChannelId, TestDatabases, TimestampSecond, randomBytes32, randomKey} import org.scalatest.funsuite.AnyFunSuite import scodec.bits.ByteVector @@ -90,9 +91,13 @@ class ChannelsDbSpec extends AnyFunSuite { assert(db.listHtlcInfos(channel1.channelId, commitNumber).toList.toSet == Set((paymentHash1, cltvExpiry1), (paymentHash2, cltvExpiry2))) assert(db.listHtlcInfos(channel1.channelId, 43).toList == Nil) + assert(db.listClosedChannels(None, None).isEmpty) db.removeChannel(channel1.channelId) assert(db.getChannel(channel1.channelId).isEmpty) assert(db.listLocalChannels() == List(channel2b)) + assert(db.listClosedChannels(None, None) == List(channel1)) + assert(db.listClosedChannels(Some(channel1.remoteNodeId), None) == List(channel1)) + assert(db.listClosedChannels(Some(PrivateKey(randomBytes32()).publicKey), None) == Nil) assert(db.listHtlcInfos(channel1.channelId, commitNumber).toList == Nil) db.removeChannel(channel2b.channelId) assert(db.getChannel(channel2b.channelId).isEmpty) 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 095795899..01571052f 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 @@ -19,7 +19,7 @@ package fr.acinq.eclair.api.handlers import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route} import akka.util.Timeout import fr.acinq.bitcoin.scalacompat.{Satoshi, Script} -import fr.acinq.eclair.MilliSatoshi +import fr.acinq.eclair.{MilliSatoshi, Paginated} import fr.acinq.eclair.api.Service import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.api.serde.FormParamExtractors._ @@ -127,6 +127,14 @@ trait Channel { } } + val closedChannels: Route = postRequest("closedchannels") { implicit t => + withPaginated { paginated_opt => + formFields(nodeIdFormParam.?) { toRemoteNodeId_opt => + complete(eclairApi.closedChannels(toRemoteNodeId_opt, paginated_opt.orElse(Some(Paginated(count = 10, skip = 0))))) + } + } + } + val allChannels: Route = postRequest("allchannels") { implicit t => complete(eclairApi.allChannels()) } @@ -147,6 +155,6 @@ trait Channel { complete(eclairApi.channelBalances()) } - val channelRoutes: Route = open ~ rbfOpen ~ spliceIn ~ spliceOut ~ close ~ forceClose ~ channel ~ channels ~ allChannels ~ allUpdates ~ channelStats ~ channelBalances + val channelRoutes: Route = open ~ rbfOpen ~ spliceIn ~ spliceOut ~ close ~ forceClose ~ channel ~ channels ~ closedChannels ~ allChannels ~ allUpdates ~ channelStats ~ channelBalances }