Skip to content

Commit

Permalink
Don't send splice_locked before tx_signatures (#528)
Browse files Browse the repository at this point in the history
When reconnecting in the middle of signing a splice, we must ensure that
splice_locked is sent *after* tx_signatures. Otherwise when using 0-conf
we may retransmit splice_locked before tx_signatures, which our peer will
ignore because they don't have a corresponding fully signed commitment.
  • Loading branch information
t-bast authored Sep 13, 2023
1 parent fdba64a commit 6c6446c
Show file tree
Hide file tree
Showing 2 changed files with 76 additions and 14 deletions.
29 changes: 15 additions & 14 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -155,26 +155,13 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent:
// normal case, our data is up-to-date
val actions = ArrayList<ChannelAction>()

// re-send channel_ready or splice_locked
// re-send channel_ready if necessary
if (state.commitments.latest.fundingTxIndex == 0L && cmd.message.nextLocalCommitmentNumber == 1L && state.commitments.localCommitIndex == 0L) {
// 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
logger.debug { "re-sending channel_ready" }
val nextPerCommitmentPoint = channelKeys().commitmentPoint(1)
val channelReady = ChannelReady(state.commitments.channelId, nextPerCommitmentPoint)
actions.add(ChannelAction.Message.Send(channelReady))
} else {
// NB: there is a key difference between channel_ready and splice_locked:
// - channel_ready: a non-zero commitment index implies that both sides have seen the channel_ready
// - splice_locked: the commitment index can be updated as long as it is compatible with all splices, so
// we must keep sending our most recent splice_locked at each reconnection
state.commitments.active
.filter { it.fundingTxIndex > 0L } // only consider splice txs
.firstOrNull { staticParams.useZeroConf || it.localFundingStatus is LocalFundingStatus.ConfirmedFundingTx }
?.let {
logger.debug { "re-sending splice_locked for fundingTxId=${it.fundingTxId}" }
val spliceLocked = SpliceLocked(channelId, it.fundingTxId.reversed())
actions.add(ChannelAction.Message.Send(spliceLocked))
}
}

// resume splice signing session if any
Expand Down Expand Up @@ -216,6 +203,20 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent:
state.spliceStatus
}

// Re-send splice_locked (must come *after* potentially retransmitting tx_signatures).
// NB: there is a key difference between channel_ready and splice_locked:
// - channel_ready: a non-zero commitment index implies that both sides have seen the channel_ready
// - splice_locked: the commitment index can be updated as long as it is compatible with all splices, so
// we must keep sending our most recent splice_locked at each reconnection
state.commitments.active
.filter { it.fundingTxIndex > 0L } // only consider splice txs
.firstOrNull { staticParams.useZeroConf || it.localFundingStatus is LocalFundingStatus.ConfirmedFundingTx }
?.let {
logger.debug { "re-sending splice_locked for fundingTxId=${it.fundingTxId}" }
val spliceLocked = SpliceLocked(channelId, it.fundingTxId.reversed())
actions.add(ChannelAction.Message.Send(spliceLocked))
}

try {
val (commitments1, sendQueue1) = handleSync(cmd.message, state)
actions.addAll(sendQueue1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,67 @@ class SpliceTestsCommon : LightningTestSuite() {
actionsBob6.has<ChannelAction.Storage.StoreState>()
}

@Test
fun `disconnect -- tx_signatures sent by bob -- zero-conf`() {
val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true)
val (alice1, commitSigAlice1, bob1, _) = spliceOutWithoutSigs(alice, bob, 20_000.sat)
val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(commitSigAlice1))
assertIs<LNChannel<Normal>>(bob2)
val spliceTxId = actionsBob2.hasOutgoingMessage<TxSignatures>().txId
actionsBob2.hasOutgoingMessage<SpliceLocked>()
assertEquals(bob2.state.spliceStatus, SpliceStatus.None)

val (alice2, bob3, channelReestablishAlice) = disconnect(alice1, bob2)
assertEquals(channelReestablishAlice.nextFundingTxId, spliceTxId)
val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(channelReestablishAlice))
assertEquals(actionsBob4.size, 4)
val channelReestablishBob = actionsBob4.findOutgoingMessage<ChannelReestablish>()
val commitSigBob2 = actionsBob4.findOutgoingMessage<CommitSig>()
val txSigsBob = actionsBob4.findOutgoingMessage<TxSignatures>()
// splice_locked must always be sent *after* tx_signatures
assertIs<SpliceLocked>(actionsBob4.filterIsInstance<ChannelAction.Message.Send>().last().message)
val spliceLockedBob = actionsBob4.findOutgoingMessage<SpliceLocked>()
assertEquals(channelReestablishBob.nextFundingTxId, spliceTxId)
val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(channelReestablishBob))
assertEquals(actionsAlice3.size, 1)
val commitSigAlice2 = actionsAlice3.findOutgoingMessage<CommitSig>()

val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(commitSigBob2))
assertTrue(actionsAlice4.isEmpty())
val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(txSigsBob))
assertIs<LNChannel<Normal>>(alice5)
assertEquals(alice5.state.commitments.active.size, 2)
assertEquals(actionsAlice5.size, 6)
assertEquals(actionsAlice5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId)
actionsAlice5.hasWatchConfirmed(spliceTxId)
actionsAlice5.has<ChannelAction.Storage.StoreState>()
actionsAlice5.has<ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceOut>()
val txSigsAlice = actionsAlice5.findOutgoingMessage<TxSignatures>()
assertIs<SpliceLocked>(actionsAlice5.filterIsInstance<ChannelAction.Message.Send>().last().message)
val spliceLockedAlice = actionsAlice5.findOutgoingMessage<SpliceLocked>()
val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(spliceLockedBob))
assertIs<LNChannel<Normal>>(alice6)
assertEquals(alice6.state.commitments.active.size, 1)
assertEquals(actionsAlice6.size, 2)
actionsAlice6.find<ChannelAction.Storage.SetLocked>().also { assertEquals(it.txId, spliceTxId) }
actionsAlice6.has<ChannelAction.Storage.StoreState>()

val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(commitSigAlice2))
assertTrue(actionsBob5.isEmpty())
val (bob6, actionsBob6) = bob5.process(ChannelCommand.MessageReceived(txSigsAlice))
assertIs<LNChannel<Normal>>(bob6)
assertEquals(bob6.state.commitments.active.size, 2)
assertEquals(actionsBob6.size, 2)
assertEquals(actionsBob6.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId)
actionsBob6.has<ChannelAction.Storage.StoreState>()
val (bob7, actionsBob7) = bob6.process(ChannelCommand.MessageReceived(spliceLockedAlice))
assertIs<LNChannel<Normal>>(bob7)
assertEquals(bob7.state.commitments.active.size, 1)
assertEquals(actionsBob7.size, 2)
actionsBob7.find<ChannelAction.Storage.SetLocked>().also { assertEquals(it.txId, spliceTxId) }
actionsBob7.has<ChannelAction.Storage.StoreState>()
}

@Test
fun `disconnect -- tx_signatures received by alice`() {
val (alice, bob) = reachNormalWithConfirmedFundingTx()
Expand Down

0 comments on commit 6c6446c

Please sign in to comment.