Skip to content

Commit 16a309e

Browse files
authored
Add DATA_CLOSED class when active channel is closed (#3170)
We introduce a `DATA_CLOSED` class with minimal information about a past channel that has been fully closed. This will let us deprecate legacy channels without having backwards-compatibility issues with very old closed channels inside our DB. When channels have never been properly opened or used, we don't bother storing them in our DB, as it would open the door to DoS attacks. We create a dedicated table to store `DATA_CLOSED`. We migrate the existing DB and remove the foreign key constraint on `htlc_infos`.
1 parent 2b39bf6 commit 16a309e

26 files changed

+762
-204
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
We remove the code used to deserialize channel data from versions of eclair prior to v0.13.
1212
Node operators running a version of `eclair` older than v0.13 must first upgrade to v0.13 to migrate their channel data, and then upgrade to the latest version.
1313

14+
### Move closed channels to dedicated database table
15+
16+
We previously kept closed channels in the same database table as active channels, with a flag indicating that it was closed.
17+
This creates performance issues for nodes with a large history of channels, and creates backwards-compatibility issues when changing the channel data format.
18+
19+
We now store closed channels in a dedicated table, where we only keep relevant information regarding the channel.
20+
When restarting your node, the channels table will automatically be cleaned up and closed channels will move to the new table.
21+
This may take some time depending on your channels history, but will only happen once.
22+
1423
### Update minimal version of Bitcoin Core
1524

1625
With this release, eclair requires using Bitcoin Core 29.1.
@@ -22,7 +31,7 @@ Newer versions of Bitcoin Core may be used, but have not been extensively tested
2231

2332
### API changes
2433

25-
<insert changes>
34+
- the `closedchannels` API now returns human-readable channel data
2635

2736
### Miscellaneous improvements and bug fixes
2837

eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ import fr.acinq.eclair.payment.send.PaymentInitiator._
4747
import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier}
4848
import fr.acinq.eclair.router.Router
4949
import fr.acinq.eclair.router.Router._
50-
import fr.acinq.eclair.transactions.Transactions.CommitmentFormat
5150
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
5251
import fr.acinq.eclair.wire.protocol._
5352
import grizzled.slf4j.Logging
@@ -117,7 +116,7 @@ trait Eclair {
117116

118117
def channelInfo(channel: ApiTypes.ChannelIdentifier)(implicit timeout: Timeout): Future[CommandResponse[CMD_GET_CHANNEL_INFO]]
119118

120-
def closedChannels(nodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]]
119+
def closedChannels(nodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[Iterable[DATA_CLOSED]]
121120

122121
def peers()(implicit timeout: Timeout): Future[Iterable[PeerInfo]]
123122

@@ -348,11 +347,9 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan
348347
sendToChannelTyped(channel = channel, cmdBuilder = CMD_GET_CHANNEL_INFO(_))
349348
}
350349

351-
override def closedChannels(nodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]] = {
350+
override def closedChannels(nodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[Iterable[DATA_CLOSED]] = {
352351
Future {
353-
appKit.nodeParams.db.channels.listClosedChannels(nodeId_opt, paginated_opt).map { data =>
354-
RES_GET_CHANNEL_INFO(nodeId = data.remoteNodeId, channelId = data.channelId, channel = ActorRef.noSender, state = CLOSED, data = data)
355-
}
352+
appKit.nodeParams.db.channels.listClosedChannels(nodeId_opt, paginated_opt)
356353
}
357354
}
358355

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import akka.actor.{ActorRef, PossiblyHarmful, typed}
2020
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2121
import fr.acinq.bitcoin.scalacompat.{ByteVector32, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, Transaction, TxId, TxOut}
2222
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw}
23+
import fr.acinq.eclair.channel.Helpers.Closing
2324
import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._
2425
import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession}
2526
import fr.acinq.eclair.io.Peer
@@ -560,6 +561,8 @@ sealed trait ChannelDataWithCommitments extends PersistentChannelData {
560561
def commitments: Commitments
561562
}
562563

564+
sealed trait ClosedData extends ChannelData
565+
563566
final case class DATA_WAIT_FOR_OPEN_CHANNEL(initFundee: INPUT_INIT_CHANNEL_NON_INITIATOR) extends TransientChannelData {
564567
val channelId: ByteVector32 = initFundee.temporaryChannelId
565568
}
@@ -696,6 +699,102 @@ final case class DATA_CLOSING(commitments: Commitments,
696699

697700
final case class DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT(commitments: Commitments, remoteChannelReestablish: ChannelReestablish) extends ChannelDataWithCommitments
698701

702+
/** We use this class when a channel shouldn't be stored in the DB (e.g. because it never confirmed). */
703+
case class IgnoreClosedData(previousData: ChannelData) extends ClosedData {
704+
val channelId: ByteVector32 = previousData.channelId
705+
}
706+
707+
/**
708+
* This class contains the data we will keep in our DB for every closed channel.
709+
* It shouldn't contain data we may wish to remove in the future, otherwise we'll have backwards-compatibility issues.
710+
* This is why for example the commitmentFormat is a string instead of using the [[CommitmentFormat]] trait, to allow
711+
* storing legacy cases that we don't support anymore for active channels.
712+
*
713+
* Note that we only store channels that have been fully opened and for which we had something at stake. Channels that
714+
* are cancelled before having a confirmed funding transactions are ignored, which protects against spam.
715+
*/
716+
final case class DATA_CLOSED(channelId: ByteVector32,
717+
remoteNodeId: PublicKey,
718+
fundingTxId: TxId,
719+
fundingOutputIndex: Long,
720+
fundingTxIndex: Long,
721+
fundingKeyPath: String,
722+
channelFeatures: String,
723+
isChannelOpener: Boolean,
724+
commitmentFormat: String,
725+
announced: Boolean,
726+
capacity: Satoshi,
727+
closingTxId: TxId,
728+
closingType: String,
729+
closingScript: ByteVector,
730+
localBalance: MilliSatoshi,
731+
remoteBalance: MilliSatoshi,
732+
closingAmount: Satoshi) extends ClosedData
733+
734+
object DATA_CLOSED {
735+
def apply(d: DATA_NEGOTIATING_SIMPLE, closingTx: ClosingTx): DATA_CLOSED = DATA_CLOSED(
736+
channelId = d.channelId,
737+
remoteNodeId = d.remoteNodeId,
738+
fundingTxId = d.commitments.latest.fundingTxId,
739+
fundingOutputIndex = d.commitments.latest.fundingInput.index,
740+
fundingTxIndex = d.commitments.latest.fundingTxIndex,
741+
fundingKeyPath = d.commitments.channelParams.localParams.fundingKeyPath.toString(),
742+
channelFeatures = d.commitments.channelParams.channelFeatures.toString,
743+
isChannelOpener = d.commitments.latest.channelParams.localParams.isChannelOpener,
744+
commitmentFormat = d.commitments.latest.commitmentFormat.toString,
745+
announced = d.commitments.latest.channelParams.announceChannel,
746+
capacity = d.commitments.latest.capacity,
747+
closingTxId = closingTx.tx.txid,
748+
closingType = Helpers.Closing.MutualClose(closingTx).toString,
749+
closingScript = d.localScriptPubKey,
750+
localBalance = d.commitments.latest.localCommit.spec.toLocal,
751+
remoteBalance = d.commitments.latest.localCommit.spec.toRemote,
752+
closingAmount = closingTx.toLocalOutput_opt.map(_.amount).getOrElse(0 sat)
753+
)
754+
755+
def apply(d: DATA_CLOSING, closingType: Helpers.Closing.ClosingType): DATA_CLOSED = DATA_CLOSED(
756+
channelId = d.channelId,
757+
remoteNodeId = d.remoteNodeId,
758+
fundingTxId = d.commitments.latest.fundingTxId,
759+
fundingOutputIndex = d.commitments.latest.fundingInput.index,
760+
fundingTxIndex = d.commitments.latest.fundingTxIndex,
761+
fundingKeyPath = d.commitments.channelParams.localParams.fundingKeyPath.toString(),
762+
channelFeatures = d.commitments.channelParams.channelFeatures.toString,
763+
isChannelOpener = d.commitments.latest.channelParams.localParams.isChannelOpener,
764+
commitmentFormat = d.commitments.latest.commitmentFormat.toString,
765+
announced = d.commitments.latest.channelParams.announceChannel,
766+
capacity = d.commitments.latest.capacity,
767+
closingTxId = closingType match {
768+
case Closing.MutualClose(closingTx) => closingTx.tx.txid
769+
case Closing.LocalClose(_, localCommitPublished) => localCommitPublished.commitTx.txid
770+
case Closing.CurrentRemoteClose(_, remoteCommitPublished) => remoteCommitPublished.commitTx.txid
771+
case Closing.NextRemoteClose(_, remoteCommitPublished) => remoteCommitPublished.commitTx.txid
772+
case Closing.RecoveryClose(remoteCommitPublished) => remoteCommitPublished.commitTx.txid
773+
case Closing.RevokedClose(revokedCommitPublished) => revokedCommitPublished.commitTx.txid
774+
},
775+
closingType = closingType.toString,
776+
closingScript = d.finalScriptPubKey,
777+
localBalance = closingType match {
778+
case _: Closing.CurrentRemoteClose => d.commitments.latest.remoteCommit.spec.toRemote
779+
case _: Closing.NextRemoteClose => d.commitments.latest.nextRemoteCommit_opt.getOrElse(d.commitments.latest.remoteCommit).spec.toRemote
780+
case _ => d.commitments.latest.localCommit.spec.toLocal
781+
},
782+
remoteBalance = closingType match {
783+
case _: Closing.CurrentRemoteClose => d.commitments.latest.remoteCommit.spec.toLocal
784+
case _: Closing.NextRemoteClose => d.commitments.latest.nextRemoteCommit_opt.getOrElse(d.commitments.latest.remoteCommit).spec.toLocal
785+
case _ => d.commitments.latest.localCommit.spec.toRemote
786+
},
787+
closingAmount = closingType match {
788+
case Closing.MutualClose(closingTx) => closingTx.toLocalOutput_opt.map(_.amount).getOrElse(0 sat)
789+
case Closing.LocalClose(_, localCommitPublished) => Closing.closingBalance(d.channelParams, d.commitments.latest.commitmentFormat, d.finalScriptPubKey, localCommitPublished)
790+
case Closing.CurrentRemoteClose(_, remoteCommitPublished) => Closing.closingBalance(d.channelParams, d.commitments.latest.commitmentFormat, d.finalScriptPubKey, remoteCommitPublished)
791+
case Closing.NextRemoteClose(_, remoteCommitPublished) => Closing.closingBalance(d.channelParams, d.commitments.latest.commitmentFormat, d.finalScriptPubKey, remoteCommitPublished)
792+
case Closing.RecoveryClose(remoteCommitPublished) => Closing.closingBalance(d.channelParams, d.commitments.latest.commitmentFormat, d.finalScriptPubKey, remoteCommitPublished)
793+
case Closing.RevokedClose(revokedCommitPublished) => Closing.closingBalance(d.channelParams, d.commitments.latest.commitmentFormat, d.finalScriptPubKey, revokedCommitPublished)
794+
}
795+
)
796+
}
797+
699798
/** Local params that apply for the channel's lifetime. */
700799
case class LocalChannelParams(nodeId: PublicKey,
701800
fundingKeyPath: DeterministicWallet.KeyPath,

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -608,13 +608,13 @@ object Helpers {
608608

609609
// @formatter:off
610610
sealed trait ClosingType
611-
case class MutualClose(tx: ClosingTx) extends ClosingType
612-
case class LocalClose(localCommit: LocalCommit, localCommitPublished: LocalCommitPublished) extends ClosingType
611+
case class MutualClose(tx: ClosingTx) extends ClosingType { override def toString: String = "mutual-close" }
612+
case class LocalClose(localCommit: LocalCommit, localCommitPublished: LocalCommitPublished) extends ClosingType { override def toString: String = "local-close" }
613613
sealed trait RemoteClose extends ClosingType { def remoteCommit: RemoteCommit; def remoteCommitPublished: RemoteCommitPublished }
614-
case class CurrentRemoteClose(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished) extends RemoteClose
615-
case class NextRemoteClose(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished) extends RemoteClose
616-
case class RecoveryClose(remoteCommitPublished: RemoteCommitPublished) extends ClosingType
617-
case class RevokedClose(revokedCommitPublished: RevokedCommitPublished) extends ClosingType
614+
case class CurrentRemoteClose(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished) extends RemoteClose { override def toString: String = "remote-close" }
615+
case class NextRemoteClose(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished) extends RemoteClose { override def toString: String = "next-remote-close" }
616+
case class RecoveryClose(remoteCommitPublished: RemoteCommitPublished) extends ClosingType { override def toString: String = "recovery-close" }
617+
case class RevokedClose(revokedCommitPublished: RevokedCommitPublished) extends ClosingType { override def toString: String = "revoked-close" }
618618
// @formatter:on
619619

620620
/**
@@ -1707,6 +1707,23 @@ object Helpers {
17071707
revokedCommitPublished.copy(irrevocablySpent = revokedCommitPublished.irrevocablySpent ++ relevantOutpoints.map(o => o -> tx).toMap)
17081708
}
17091709

1710+
/** Returns the amount we've successfully claimed from a force-closed channel. */
1711+
def closingBalance(channelParams: ChannelParams, commitmentFormat: CommitmentFormat, closingScript: ByteVector, commit: CommitPublished): Satoshi = {
1712+
val toLocal = commit.localOutput_opt match {
1713+
case Some(o) if o.index < commit.commitTx.txOut.size => commit.commitTx.txOut(o.index.toInt).amount
1714+
case _ => 0 sat
1715+
}
1716+
val toClosingScript = commit.irrevocablySpent.values.flatMap(_.txOut)
1717+
.filter(_.publicKeyScript == closingScript)
1718+
.map(_.amount)
1719+
.sum
1720+
commitmentFormat match {
1721+
case DefaultCommitmentFormat if channelParams.localParams.walletStaticPaymentBasepoint.nonEmpty => toLocal + toClosingScript
1722+
case DefaultCommitmentFormat => toClosingScript
1723+
case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => toClosingScript
1724+
}
1725+
}
1726+
17101727
}
17111728

17121729
}

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
385385
case closing: DATA_CLOSING if Closing.nothingAtStake(closing) =>
386386
log.info("we have nothing at stake, going straight to CLOSED")
387387
context.system.eventStream.publish(ChannelAborted(self, remoteNodeId, closing.channelId))
388-
goto(CLOSED) using closing
388+
goto(CLOSED) using IgnoreClosedData(closing)
389389
case closing: DATA_CLOSING =>
390390
val localPaysClosingFees = closing.commitments.localChannelParams.paysClosingFees
391391
val closingType_opt = Closing.isClosingTypeAlreadyKnown(closing)
@@ -2289,7 +2289,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
22892289
case Some(closingType) =>
22902290
log.info("channel closed (type={})", EventType.Closed(closingType).label)
22912291
context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, d.commitments))
2292-
goto(CLOSED) using d1 storing()
2292+
goto(CLOSED) using DATA_CLOSED(d1, closingType)
22932293
case None =>
22942294
stay() using d1 storing()
22952295
}
@@ -2366,9 +2366,12 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
23662366
when(CLOSED)(handleExceptions {
23672367
case Event(Symbol("shutdown"), _) =>
23682368
stateData match {
2369-
case d: PersistentChannelData =>
2370-
log.info(s"deleting database record for channelId=${d.channelId}")
2371-
nodeParams.db.channels.removeChannel(d.channelId)
2369+
case d: DATA_CLOSED =>
2370+
log.info(s"moving channelId=${d.channelId} to the closed channels DB")
2371+
nodeParams.db.channels.removeChannel(d.channelId, Some(d))
2372+
case _: PersistentChannelData | _: IgnoreClosedData =>
2373+
log.info("deleting database record for channelId={}", stateData.channelId)
2374+
nodeParams.db.channels.removeChannel(stateData.channelId, None)
23722375
case _: TransientChannelData => // nothing was stored in the DB
23732376
}
23742377
log.info("shutting down")
@@ -3029,10 +3032,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
30293032
}
30303033

30313034
case Event(WatchTxConfirmedTriggered(_, _, tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty =>
3032-
val closingType = MutualClose(d.findClosingTx(tx).get)
3035+
val closingTx = d.findClosingTx(tx).get
3036+
val closingType = MutualClose(closingTx)
30333037
log.info("channel closed (type={})", EventType.Closed(closingType).label)
30343038
context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, d.commitments))
3035-
goto(CLOSED) using d storing()
3039+
goto(CLOSED) using DATA_CLOSED(d, closingTx)
30363040

30373041
case Event(WatchFundingSpentTriggered(tx), d: ChannelDataWithCommitments) =>
30383042
if (d.commitments.all.map(_.fundingTxId).contains(tx.txid)) {
@@ -3070,6 +3074,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
30703074
case d: ChannelDataWithCommitments => Some(d.commitments)
30713075
case _: ChannelDataWithoutCommitments => None
30723076
case _: TransientChannelData => None
3077+
case _: ClosedData => None
30733078
}
30743079
context.system.eventStream.publish(ChannelStateChanged(self, nextStateData.channelId, peer, remoteNodeId, state, nextState, commitments_opt))
30753080
}

0 commit comments

Comments
 (0)