Skip to content

Commit 21917f5

Browse files
authored
Add support for your_last_funding_locked and my_current_funding_locked tlvs in channel_reestablish (#3007)
When the `my_current_funding_locked_txid` TLV attribute confirms the latest funding tx we prune previous funding transaction similarly to receiving `splice_locked` from our peer for that txid. When we receive `your_last_funding_locked_txid` that does not match our latest confirmed funding tx, then we know our peer did not receive our last `splice_locked` and retransmit it. Doing the same for `channel_ready` will be handled in a follow-up PR. For public channels, nodes also retransmit `splice_locked` after `channel_reestablish` if they have not received `announcement_signatures` for the latest confirmed funding tx. This is needed to prompt our peer to also retransmit their own `splice_locked` and `announcement_signatures`. For public channels, nodes respond to `splice_locked` with their own `splice_locked` if they have not already sent it since the last `channel_reestablish` - this to prevents exchanging an endless loop of `splice_locked` messages. These changes ensure nodes have exchanged `splice_locked` (and `announcement_signatures` for public channels) after a disconnect and will be relevant for simple taproot channels to exchange nonces. If the `your_last_funding_locked` tlv is not set then nodes always send `splice_locked` on reconnect to preserve previous behavior for retransmitting `splice_locked`. Note: Previous behavior was susceptible to a race condition if one node sent a channel update after `channel_reestablish`, but before receiving `splice_locked` from a peer that had confirmed the latest funding tx while offline. cf. lightning/bolts#1223 When reconnecting in the Normal state, if a legacy channel does not have its latest remote funding status set to `Locked`, we set and store it to migrate older channels. After reconnecting in other states, the remote funding status will be set to `Locked` and stored when receiving `channel_ready` or deferred if the node is still waiting for the funding tx to be confirmed locally.
1 parent f6b051c commit 21917f5

File tree

11 files changed

+401
-60
lines changed

11 files changed

+401
-60
lines changed

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,9 @@ case class Commitments(params: ChannelParams,
873873
// We always use the last commitment that was created, to make sure we never go back in time.
874874
val latest = FullCommitment(params, changes, active.head.fundingTxIndex, active.head.firstRemoteCommitIndex, active.head.remoteFundingPubKey, active.head.localFundingStatus, active.head.remoteFundingStatus, active.head.localCommit, active.head.remoteCommit, active.head.nextRemoteCommit_opt)
875875

876+
val lastLocalLocked_opt: Option[Commitment] = active.filter(_.localFundingStatus.isInstanceOf[LocalFundingStatus.Locked]).sortBy(_.fundingTxIndex).lastOption
877+
val lastRemoteLocked_opt: Option[Commitment] = active.filter(c => c.remoteFundingStatus == RemoteFundingStatus.Locked).sortBy(_.fundingTxIndex).lastOption
878+
876879
def add(commitment: Commitment): Commitments = copy(active = commitment +: active)
877880

878881
// @formatter:off
@@ -1270,8 +1273,6 @@ case class Commitments(params: ChannelParams,
12701273
// This ensures that we only have to send splice_locked for the latest commitment instead of sending it for every commitment.
12711274
// A side-effect is that previous commitments that are implicitly locked don't necessarily have their status correctly set.
12721275
// That's why we look at locked commitments separately and then select the one with the oldest fundingTxIndex.
1273-
val lastLocalLocked_opt = active.find(_.localFundingStatus.isInstanceOf[LocalFundingStatus.Locked])
1274-
val lastRemoteLocked_opt = active.find(_.remoteFundingStatus == RemoteFundingStatus.Locked)
12751276
val lastLocked_opt = (lastLocalLocked_opt, lastRemoteLocked_opt) match {
12761277
// We select the locked commitment with the smaller value for fundingTxIndex, but both have to be defined.
12771278
// If both have the same fundingTxIndex, they must actually be the same commitment, because:
@@ -1280,13 +1281,13 @@ case class Commitments(params: ChannelParams,
12801281
// - we don't allow creating a splice on top of an unconfirmed transaction that has RBF attempts (because it
12811282
// would become invalid if another of the RBF attempts end up being confirmed)
12821283
case (Some(lastLocalLocked), Some(lastRemoteLocked)) => Some(Seq(lastLocalLocked, lastRemoteLocked).minBy(_.fundingTxIndex))
1283-
// Special case for the initial funding tx, we only require a local lock because channel_ready doesn't explicitly reference a funding tx.
1284+
// Special case for the initial funding tx, we only require a local lock because our peer may have never sent channel_ready.
12841285
case (Some(lastLocalLocked), None) if lastLocalLocked.fundingTxIndex == 0 => Some(lastLocalLocked)
12851286
case _ => None
12861287
}
12871288
lastLocked_opt match {
12881289
case Some(lastLocked) =>
1289-
// all commitments older than this one are inactive
1290+
// All commitments older than this one, and RBF alternatives, become inactive.
12901291
val inactive1 = active.filter(c => c.fundingTxId != lastLocked.fundingTxId && c.fundingTxIndex <= lastLocked.fundingTxIndex)
12911292
inactive1.foreach(c => log.info("deactivating commitment fundingTxIndex={} fundingTxId={}", c.fundingTxIndex, c.fundingTxId))
12921293
copy(

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

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
222222
var announcementSigsStash = Map.empty[RealShortChannelId, AnnouncementSignatures]
223223
// we record the announcement_signatures messages we already sent to avoid unnecessary retransmission
224224
var announcementSigsSent = Set.empty[RealShortChannelId]
225+
// we keep track of the splice_locked we sent after channel_reestablish and it's funding tx index to avoid sending it again
226+
private var spliceLockedSent = Map.empty[TxId, Long]
225227

226228
private def trimAnnouncementSigsStashIfNeeded(): Unit = {
227229
if (announcementSigsStash.size >= 10) {
@@ -233,6 +235,17 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
233235
}
234236
}
235237

238+
private def trimSpliceLockedSentIfNeeded(): Unit = {
239+
if (spliceLockedSent.size >= 10) {
240+
// We shouldn't store an unbounded number of splice_locked: on long-lived connections where we do a lot of splice
241+
// transactions, we only need to keep track of the most recent ones.
242+
val oldestFundingTxId = spliceLockedSent.toSeq
243+
.sortBy { case (_, fundingTxIndex) => fundingTxIndex }
244+
.map { case (fundingTxId, _) => fundingTxId }.head
245+
spliceLockedSent -= oldestFundingTxId
246+
}
247+
}
248+
236249
val txPublisher = txPublisherFactory.spawnTxPublisher(context, remoteNodeId)
237250

238251
// this will be used to detect htlc timeouts
@@ -775,10 +788,15 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
775788

776789
case Event(c: CurrentFeerates.BitcoinCore, d: DATA_NORMAL) => handleCurrentFeerate(c, d)
777790

778-
case Event(_: ChannelReady, _: DATA_NORMAL) =>
779-
// This happens on reconnection, because channel_ready is sent again if the channel hasn't been used yet,
780-
// otherwise we cannot be sure that it was correctly received before disconnecting.
781-
stay()
791+
case Event(_: ChannelReady, d: DATA_NORMAL) =>
792+
// After a reconnection, if the channel hasn't been used yet, our peer cannot be sure we received their channel_ready
793+
// so they will resend it. Their remote funding status must also be set to Locked if it wasn't already.
794+
// NB: Their remote funding status will be stored when the commitment is next updated, or channel_ready will
795+
// be sent again if a reconnection occurs first.
796+
stay() using d.copy(commitments = d.commitments.copy(active = d.commitments.active.map {
797+
case c if c.fundingTxIndex == 0 => c.copy(remoteFundingStatus = RemoteFundingStatus.Locked)
798+
case c => c
799+
}))
782800

783801
// Channels are publicly announced if both parties want it: we ignore this message if we don't want to announce the channel.
784802
case Event(remoteAnnSigs: AnnouncementSignatures, d: DATA_NORMAL) if d.commitments.announceChannel =>
@@ -1341,11 +1359,13 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
13411359
case Event(w: WatchPublishedTriggered, d: DATA_NORMAL) =>
13421360
val fundingStatus = LocalFundingStatus.ZeroconfPublishedFundingTx(w.tx, d.commitments.localFundingSigs(w.tx.txid), d.commitments.liquidityPurchase(w.tx.txid))
13431361
d.commitments.updateLocalFundingStatus(w.tx.txid, fundingStatus, d.lastAnnouncedFundingTxId_opt) match {
1344-
case Right((commitments1, _)) =>
1362+
case Right((commitments1, commitment)) =>
13451363
// This is a zero-conf channel, the min-depth isn't critical: we use the default.
13461364
watchFundingConfirmed(w.tx.txid, Some(nodeParams.channelConf.minDepth), delay_opt = None)
13471365
maybeEmitEventsPostSplice(d.aliases, d.commitments, commitments1, d.lastAnnouncement_opt)
13481366
maybeUpdateMaxHtlcAmount(d.channelUpdate.htlcMaximumMsat, commitments1)
1367+
spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex)
1368+
trimSpliceLockedSentIfNeeded()
13491369
stay() using d.copy(commitments = commitments1) storing() sending SpliceLocked(d.channelId, w.tx.txid)
13501370
case Left(_) => stay()
13511371
}
@@ -1356,7 +1376,11 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
13561376
// We check if this commitment was already locked before receiving the event (which happens when using 0-conf
13571377
// or for the initial funding transaction). If it was previously not locked, we must send splice_locked now.
13581378
val previouslyNotLocked = d.commitments.all.exists(c => c.fundingTxId == commitment.fundingTxId && c.localFundingStatus.isInstanceOf[LocalFundingStatus.NotLocked])
1359-
val spliceLocked_opt = if (previouslyNotLocked) Some(SpliceLocked(d.channelId, w.tx.txid)) else None
1379+
val spliceLocked_opt = if (previouslyNotLocked) {
1380+
spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex)
1381+
trimSpliceLockedSentIfNeeded()
1382+
Some(SpliceLocked(d.channelId, w.tx.txid))
1383+
} else None
13601384
// If the channel is public and we've received the remote splice_locked, we send our announcement_signatures
13611385
// in order to generate the channel_announcement.
13621386
val remoteLocked = commitment.fundingTxIndex == 0 || d.commitments.all.exists(c => c.fundingTxId == commitment.fundingTxId && c.remoteFundingStatus == RemoteFundingStatus.Locked)
@@ -1379,19 +1403,34 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
13791403
case Event(msg: SpliceLocked, d: DATA_NORMAL) =>
13801404
d.commitments.updateRemoteFundingStatus(msg.fundingTxId, d.lastAnnouncedFundingTxId_opt) match {
13811405
case Right((commitments1, commitment)) =>
1406+
// If we have both already sent splice_locked for this commitment, then we are receiving splice_locked
1407+
// again after a reconnection and must retransmit our splice_locked and new announcement_signatures. Nodes
1408+
// retransmit splice_locked after a reconnection when they have received splice_locked but NOT matching signatures
1409+
// before the last disconnect. If a matching splice_locked has already been sent since reconnecting, then do not
1410+
// retransmit splice_locked to avoid a loop.
1411+
// NB: It is important both nodes retransmit splice_locked after reconnecting to ensure new Taproot nonces
1412+
// are exchanged for channel announcements.
1413+
val isLatestLocked = d.commitments.lastLocalLocked_opt.exists(_.fundingTxId == msg.fundingTxId) && d.commitments.lastRemoteLocked_opt.exists(_.fundingTxId == msg.fundingTxId)
1414+
val spliceLocked_opt = if (d.commitments.announceChannel && isLatestLocked && !spliceLockedSent.contains(commitment.fundingTxId)) {
1415+
spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex)
1416+
trimSpliceLockedSentIfNeeded()
1417+
Some(SpliceLocked(d.channelId, commitment.fundingTxId))
1418+
} else {
1419+
None
1420+
}
13821421
// If the commitment is confirmed, we were waiting to receive the remote splice_locked before sending our announcement_signatures.
1383-
val localAnnSigs_opt = if (d.commitments.announceChannel) commitment.signAnnouncement(nodeParams, commitments1.params) else None
1384-
localAnnSigs_opt match {
1385-
case Some(localAnnSigs) =>
1386-
// The commitment was locked on our side and we were waiting to receive the remote splice_locked before sending our announcement_signatures.
1422+
val localAnnSigs_opt = commitment.signAnnouncement(nodeParams, commitments1.params) match {
1423+
case Some(localAnnSigs) if !announcementSigsSent.contains(localAnnSigs.shortChannelId) =>
13871424
announcementSigsSent += localAnnSigs.shortChannelId
13881425
// If we've already received the remote announcement_signatures, we're now ready to process them.
13891426
announcementSigsStash.get(localAnnSigs.shortChannelId).foreach(self ! _)
1390-
case None => // The channel is private or the commitment isn't locked on our side.
1427+
Some(localAnnSigs)
1428+
case Some(_) => None // We've already sent these announcement_signatures since the last reconnect.
1429+
case None => None // The channel is private or the commitment isn't locked on our side.
13911430
}
13921431
maybeEmitEventsPostSplice(d.aliases, d.commitments, commitments1, d.lastAnnouncement_opt)
13931432
maybeUpdateMaxHtlcAmount(d.channelUpdate.htlcMaximumMsat, commitments1)
1394-
stay() using d.copy(commitments = commitments1) storing() sending localAnnSigs_opt.toSeq
1433+
stay() using d.copy(commitments = commitments1) storing() sending spliceLocked_opt.toSeq ++ localAnnSigs_opt.toSeq
13951434
case Left(_) => stay()
13961435
}
13971436

@@ -2235,13 +2274,17 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
22352274
}
22362275
case _ => Set.empty
22372276
}
2277+
val lastFundingLockedTlvs: Set[ChannelReestablishTlv] =
2278+
d.commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId)).toSet ++
2279+
d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet
2280+
22382281
val channelReestablish = ChannelReestablish(
22392282
channelId = d.channelId,
22402283
nextLocalCommitmentNumber = d.commitments.localCommitIndex + 1,
22412284
nextRemoteRevocationNumber = d.commitments.remoteCommitIndex,
22422285
yourLastPerCommitmentSecret = PrivateKey(yourLastPerCommitmentSecret),
22432286
myCurrentPerCommitmentPoint = myCurrentPerCommitmentPoint,
2244-
tlvStream = TlvStream(rbfTlv)
2287+
tlvStream = TlvStream(rbfTlv ++ lastFundingLockedTlvs)
22452288
)
22462289
// we update local/remote connection-local global/local features, we don't persist it right now
22472290
val d1 = Helpers.updateFeatures(d, localInit, remoteInit)
@@ -2333,6 +2376,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
23332376
// re-send channel_ready if necessary
23342377
if (d.commitments.latest.fundingTxIndex == 0 && channelReestablish.nextLocalCommitmentNumber == 1 && d.commitments.localCommitIndex == 0) {
23352378
// If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node MUST retransmit channel_ready, otherwise it MUST NOT
2379+
// TODO: when the remote node enables option_splice we can use your_last_funding_locked to detect they did not receive our channel_ready.
23362380
log.debug("re-sending channelReady")
23372381
val channelKeyPath = keyManager.keyPath(d.commitments.params.localParams, d.commitments.params.channelConfig)
23382382
val nextPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, 1)
@@ -2379,25 +2423,39 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
23792423
case None => d.spliceStatus
23802424
}
23812425

2382-
// re-send splice_locked (must come *after* potentially retransmitting tx_signatures)
2383-
// NB: there is a key difference between channel_ready and splice_confirmed:
2384-
// - channel_ready: a non-zero commitment index implies that both sides have seen the channel_ready
2385-
// - splice_confirmed: the commitment index can be updated as long as it is compatible with all splices, so
2386-
// we must keep sending our most recent splice_locked at each reconnection
2387-
val spliceLocked = d.commitments.active
2388-
.filter(c => c.fundingTxIndex > 0) // only consider splice txs
2389-
.collectFirst { case c if c.localFundingStatus.isInstanceOf[LocalFundingStatus.Locked] =>
2390-
log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId)
2391-
SpliceLocked(d.channelId, c.fundingTxId)
2392-
}
2393-
sendQueue = sendQueue ++ spliceLocked
2426+
// Prune previous funding transactions and RBF attempts if we already sent splice_locked for the last funding
2427+
// transaction that is also locked by our counterparty; we either missed their splice_locked or it confirmed
2428+
// while disconnected.
2429+
val commitments1: Commitments = channelReestablish.myCurrentFundingLocked_opt
2430+
.flatMap(remoteFundingTxLocked => d.commitments.updateRemoteFundingStatus(remoteFundingTxLocked, d.lastAnnouncedFundingTxId_opt).toOption.map(_._1))
2431+
.getOrElse(d.commitments)
2432+
// We then clean up unsigned updates that haven't been received before the disconnection.
2433+
.discardUnsignedUpdates()
2434+
2435+
val spliceLocked_opt = commitments1.lastLocalLocked_opt match {
2436+
case None => None
2437+
// We only send splice_locked for splice transactions.
2438+
case Some(c) if c.fundingTxIndex == 0 => None
2439+
case Some(c) =>
2440+
// If our peer has not received our splice_locked, we retransmit it.
2441+
val notReceivedByRemote = !channelReestablish.yourLastFundingLocked_opt.contains(c.fundingTxId)
2442+
// If this is a public channel and we haven't announced the splice, we retransmit our splice_locked and
2443+
// will exchange announcement_signatures afterwards.
2444+
val notAnnouncedYet = commitments1.announceChannel && d.lastAnnouncement_opt.forall(ann => !c.shortChannelId_opt.contains(ann.shortChannelId))
2445+
if (notReceivedByRemote || notAnnouncedYet) {
2446+
log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId)
2447+
spliceLockedSent += (c.fundingTxId -> c.fundingTxIndex)
2448+
trimSpliceLockedSentIfNeeded()
2449+
Some(SpliceLocked(d.channelId, c.fundingTxId))
2450+
} else {
2451+
None
2452+
}
2453+
}
2454+
sendQueue = sendQueue ++ spliceLocked_opt.toSeq
23942455

23952456
// we may need to retransmit updates and/or commit_sig and/or revocation
23962457
sendQueue = sendQueue ++ syncSuccess.retransmit
23972458

2398-
// then we clean up unsigned updates
2399-
val commitments1 = d.commitments.discardUnsignedUpdates()
2400-
24012459
commitments1.remoteNextCommitInfo match {
24022460
case Left(_) =>
24032461
// we expect them to (re-)send the revocation immediately
@@ -2877,6 +2935,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
28772935
sigStash = Nil
28782936
announcementSigsStash = Map.empty
28792937
announcementSigsSent = Set.empty
2938+
spliceLockedSent = Map.empty[TxId, Long]
28802939
}
28812940

28822941
/*

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
9595
| . |
9696
| . |
9797
WAIT_FOR_DUAL_FUNDING_LOCKED | | WAIT_FOR_DUAL_FUNDING_LOCKED
98-
| funding_locked funding_locked |
98+
| channel_ready channel_ready |
9999
|---------------- ---------------|
100100
| \/ |
101101
| /\ |

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,14 @@ trait CommonFundingHandlers extends CommonHandlers {
143143
val initialChannelUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate, commitments.params, relayFees, Helpers.maxHtlcAmount(nodeParams, commitments), enable = true)
144144
// We need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network.
145145
context.system.scheduler.scheduleWithFixedDelay(initialDelay = REFRESH_CHANNEL_UPDATE_INTERVAL, delay = REFRESH_CHANNEL_UPDATE_INTERVAL, receiver = self, message = BroadcastChannelUpdate(PeriodicRefresh))
146-
val commitments1 = commitments.modify(_.remoteNextCommitInfo).setTo(Right(channelReady.nextPerCommitmentPoint))
146+
val commitments1 = commitments.copy(
147+
// Set the remote status for all initial funding commitments to Locked. If there are RBF attempts, only one can be confirmed locally.
148+
active = commitments.active.map {
149+
case c if c.fundingTxIndex == 0 => c.copy(remoteFundingStatus = RemoteFundingStatus.Locked)
150+
case c => c
151+
},
152+
remoteNextCommitInfo = Right(channelReady.nextPerCommitmentPoint)
153+
)
147154
peer ! ChannelReadyForPayments(self, remoteNodeId, commitments.channelId, fundingTxIndex = 0)
148155
DATA_NORMAL(commitments1, aliases1, None, initialChannelUpdate, None, None, None, SpliceStatus.NoSplice)
149156
}

0 commit comments

Comments
 (0)