diff --git a/contrib/eclair-cli.bash-completion b/contrib/eclair-cli.bash-completion index 5c7c53935b..d4eac77e07 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 4473833cc6..f05d413f16 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 3bfe4c6d3a..e3f9cdeab3 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 7ca368849a..5849c686a3 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 a771f927cb..c312c9786d 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 43d9182aa0..baa6e62fcb 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 a29b51d108..a2f852befc 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 4648bf73df..ea07977280 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 4bd985808d..25cd2ef44c 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 0957958994..01571052fe 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 }