Skip to content

Commit

Permalink
Adapt the simple_close protocol to simple taproot channels
Browse files Browse the repository at this point in the history
`partial_signature_with_nonce` TLVs are added to `closing_complete` and `closing_sig` with the same format as in `commitment_signed`

The closing workflow is similar to the standard "simple close" workflow:
- Alice and Bob exchange `shutdown`, which includes a "closing nonce" (no changes here compared to the "simple taproot channels" spec).
- Alice selects possible closing transaction (closer_output_only, closee_output_only, closer_and_closee_output) and for each of them creates
a partial_signature_with_nonce using a new random local nonce and Bob's closing nonce (which she received in Bob's `shutdown` message).
- Alice send a `closing_complete` message to Bob that include these partial_signature_with_nonce.
- Bob receive Alice's `closing_complete` message, selects one of Alice's partial_signature_with_nonce, creates partial_signature_with_nonce using.
his closing nonce and the nonce attached to the partial_signature_with_nonce and sends it to Alice in a `closing_sig` message.
- Alice receives Bob's `closing_sig` and creates a partial signature for her closing tx using her closing nonce and the nonce attached Bob's partial_signature_with_nonce.
- Alice combines this signature with Bob's and can broadcat her closing tx.
  • Loading branch information
sstone committed Oct 21, 2024
1 parent 33d4dd9 commit 1d85d22
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 51 deletions.
194 changes: 150 additions & 44 deletions eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
if (d.commitments.hasNoPendingHtlcsOrFeeUpdate) {
// there are no pending signed changes, let's directly negotiate a closing transaction
if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) {
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, sendList)
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, sendList, closingNonce, remoteShutdown.shutdownNonce_opt)
} else if (d.commitments.params.localParams.paysClosingFees) {
// we pay the closing fees, so we initiate the negotiation by sending the first closing_signed
val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.closingFeerates, closingNonce, remoteShutdown.shutdownNonce_opt)
Expand Down Expand Up @@ -1592,7 +1592,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
context.system.eventStream.publish(ChannelSignatureReceived(self, commitments1))
if (commitments1.hasNoPendingHtlcsOrFeeUpdate) {
if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) {
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, revocation :: Nil)
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, revocation :: Nil, closingNonce, remoteShutdown.shutdownNonce_opt)
} else if (d.commitments.params.localParams.paysClosingFees) {
// we pay the closing fees, so we initiate the negotiation by sending the first closing_signed
val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates, closingNonce, remoteShutdown.shutdownNonce_opt)
Expand Down Expand Up @@ -1636,7 +1636,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
if (commitments1.hasNoPendingHtlcsOrFeeUpdate) {
log.debug("switching to NEGOTIATING spec:\n{}", commitments1.latest.specs2String)
if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) {
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, Nil)
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, Nil, closingNonce, remoteShutdown.shutdownNonce_opt)
} else if (d.commitments.params.localParams.paysClosingFees) {
// we pay the closing fees, so we initiate the negotiation by sending the first closing_signed
val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates, closingNonce, remoteShutdown.shutdownNonce_opt)
Expand Down Expand Up @@ -1871,7 +1871,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case status: ClosingNegotiation.SigningTransactions =>
val localScript = status.localShutdown.scriptPubKey
val remoteScript = status.remoteShutdown.scriptPubKey
MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, localScript, remoteScript, closingComplete) match {
MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, localScript, remoteScript, closingComplete, closingNonce) match {
case Left(f) =>
// This may happen if scripts were updated concurrently, so we simply ignore failures.
log.warning("invalid closing_complete: {}", f.getMessage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package fr.acinq.eclair.channel.fsm

import akka.actor.FSM
import fr.acinq.bitcoin.crypto.musig2.{IndividualNonce, SecretNonce}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Script}
import fr.acinq.eclair.Features
import fr.acinq.eclair.channel.Helpers.Closing.MutualClose
Expand Down Expand Up @@ -132,11 +133,11 @@ trait CommonHandlers {
finalScriptPubKey
}

def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerates: Option[ClosingFeerates], toSend: List[LightningMessage]) = {
def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerates: Option[ClosingFeerates], toSend: List[LightningMessage], localClosingNonce_opt: Option[(SecretNonce, IndividualNonce)] = None, remoteClosingNonce_opt: Option[IndividualNonce] = None) = {
val localScript = localShutdown.scriptPubKey
val remoteScript = remoteShutdown.scriptPubKey
val closingFeerate = closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates))
MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, commitments.latest, localScript, remoteScript, closingFeerate) match {
MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, commitments.latest, localScript, remoteScript, closingFeerate, localClosingNonce_opt, remoteClosingNonce_opt) match {
case Left(f) =>
log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage)
val status = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, None, None, None)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,11 @@ super.sign(privateKey, txOwner, commitmentFormat)
}
}

case class ClosingTx(input: InputInfo, tx: Transaction, toLocalOutput: Option[OutputInfo]) extends TransactionWithInputInfo { override def desc: String = "closing" }
case class ClosingTx(input: InputInfo, tx: Transaction, toLocalOutput: Option[OutputInfo]) extends TransactionWithInputInfo {
// these nonces are generated on the fly at during a "simple" closing session and can be forgotten once the session ends
@volatile var localNonce_opt: Option[(SecretNonce, IndividualNonce)] = None
override def desc: String = "closing"
}

sealed trait TxGenerationSkipped
case object OutputNotFound extends TxGenerationSkipped { override def toString = "output not found (probably trimmed)" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,10 +321,22 @@ object ClosingTlv {
/** Signature for a closing transaction containing the closer and closee's outputs. */
case class CloserAndClosee(sig: ByteVector64) extends ClosingTlv

/** Signature for a closing transaction containing only the closer's output. */
case class CloserNoCloseePartialSignature(partialSigWithNonce: PartialSignatureWithNonce) extends ClosingTlv

/** Signature for a closing transaction containing only the closee's output. */
case class NoCloserCloseePartialSignature(partialSigWithNonce: PartialSignatureWithNonce) extends ClosingTlv

/** Signature for a closing transaction containing the closer and closee's outputs. */
case class CloserAndCloseePartialSignature(partialSigWithNonce: PartialSignatureWithNonce) extends ClosingTlv

val closingTlvCodec: Codec[TlvStream[ClosingTlv]] = tlvStream(discriminated[ClosingTlv].by(varint)
.typecase(UInt64(1), tlvField(bytes64.as[CloserNoClosee]))
.typecase(UInt64(2), tlvField(bytes64.as[NoCloserClosee]))
.typecase(UInt64(3), tlvField(bytes64.as[CloserAndClosee]))
.typecase(UInt64(4), tlvField(partialSignatureWithNonce.as[CloserNoCloseePartialSignature]))
.typecase(UInt64(5), tlvField(partialSignatureWithNonce.as[NoCloserCloseePartialSignature]))
.typecase(UInt64(6), tlvField(partialSignatureWithNonce.as[CloserAndCloseePartialSignature]))
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -409,12 +409,18 @@ case class ClosingComplete(channelId: ByteVector32, fees: Satoshi, lockTime: Lon
val closerNoCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserNoClosee].map(_.sig)
val noCloserCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.NoCloserClosee].map(_.sig)
val closerAndCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserAndClosee].map(_.sig)
val closerNoCloseePartialSig_opt: Option[PartialSignatureWithNonce] = tlvStream.get[ClosingTlv.CloserNoCloseePartialSignature].map(_.partialSigWithNonce)
val noCloserCloseePartialSig_opt: Option[PartialSignatureWithNonce] = tlvStream.get[ClosingTlv.NoCloserCloseePartialSignature].map(_.partialSigWithNonce)
val closerAndCloseePartialSig_opt: Option[PartialSignatureWithNonce] = tlvStream.get[ClosingTlv.CloserAndCloseePartialSignature].map(_.partialSigWithNonce)
}

case class ClosingSig(channelId: ByteVector32, tlvStream: TlvStream[ClosingTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId {
val closerNoCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserNoClosee].map(_.sig)
val noCloserCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.NoCloserClosee].map(_.sig)
val closerAndCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserAndClosee].map(_.sig)
val closerNoCloseePartialSig_opt: Option[PartialSignatureWithNonce] = tlvStream.get[ClosingTlv.CloserNoCloseePartialSignature].map(_.partialSigWithNonce)
val noCloserCloseePartialSig_opt: Option[PartialSignatureWithNonce] = tlvStream.get[ClosingTlv.NoCloserCloseePartialSignature].map(_.partialSigWithNonce)
val closerAndCloseePartialSig_opt: Option[PartialSignatureWithNonce] = tlvStream.get[ClosingTlv.CloserAndCloseePartialSignature].map(_.partialSigWithNonce)
}

case class UpdateAddHtlc(channelId: ByteVector32,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,42 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(bob.stateName == NEGOTIATING_SIMPLE)
}

test("recv ClosingComplete (both outputs, simple taproot channels)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaprootStaging), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
import f._
aliceClose(f)
val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete]
assert(aliceClosingComplete.fees > 0.sat)
assert(aliceClosingComplete.closerAndCloseeSig_opt.nonEmpty || aliceClosingComplete.closerAndCloseePartialSig_opt.nonEmpty)
assert(aliceClosingComplete.closerNoCloseeSig_opt.nonEmpty || aliceClosingComplete.closerNoCloseePartialSig_opt.nonEmpty)
assert(aliceClosingComplete.noCloserCloseeSig_opt.isEmpty && aliceClosingComplete.noCloserCloseePartialSig_opt.isEmpty)
val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete]
assert(bobClosingComplete.fees > 0.sat)
assert(bobClosingComplete.closerAndCloseeSig_opt.nonEmpty || bobClosingComplete.closerAndCloseePartialSig_opt.nonEmpty)
assert(bobClosingComplete.closerNoCloseeSig_opt.nonEmpty || bobClosingComplete.closerNoCloseePartialSig_opt.nonEmpty)
assert(bobClosingComplete.noCloserCloseeSig_opt.isEmpty && bobClosingComplete.noCloserCloseePartialSig_opt.isEmpty)

alice2bob.forward(bob, aliceClosingComplete)
val bobClosingSig = bob2alice.expectMsgType[ClosingSig]
bob2alice.forward(alice, bobClosingSig)
val aliceTx = alice2blockchain.expectMsgType[PublishFinalTx]
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx.tx.txid)
assert(aliceTx.desc == "closing")
alice2blockchain.expectWatchTxConfirmed(aliceTx.tx.txid)
bob2blockchain.expectWatchTxConfirmed(aliceTx.tx.txid)
assert(alice.stateName == NEGOTIATING_SIMPLE)

bob2alice.forward(alice, bobClosingComplete)
val aliceClosingSig = alice2bob.expectMsgType[ClosingSig]
alice2bob.forward(bob, aliceClosingSig)
val bobTx = bob2blockchain.expectMsgType[PublishFinalTx]
assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx.tx.txid)
assert(aliceTx.tx.txid != bobTx.tx.txid)
assert(bobTx.desc == "closing")
bob2blockchain.expectWatchTxConfirmed(bobTx.tx.txid)
alice2blockchain.expectWatchTxConfirmed(bobTx.tx.txid)
assert(bob.stateName == NEGOTIATING_SIMPLE)
}

test("recv ClosingComplete (single output)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f =>
import f._
aliceClose(f)
Expand All @@ -539,6 +575,27 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(bob.stateName == NEGOTIATING_SIMPLE)
}

test("recv ClosingComplete (single output, simple taproot channels)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount), Tag(ChannelStateTestsTags.OptionSimpleTaprootStaging), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
import f._
aliceClose(f)
val closingComplete = alice2bob.expectMsgType[ClosingComplete]
assert(closingComplete.closerAndCloseeSig_opt.isEmpty && closingComplete.closerAndCloseePartialSig_opt.isEmpty)
assert(closingComplete.closerNoCloseeSig_opt.nonEmpty || closingComplete.closerNoCloseePartialSig_opt.nonEmpty)
assert(closingComplete.noCloserCloseeSig_opt.isEmpty && closingComplete.noCloserCloseePartialSig_opt.isEmpty)
// Bob has nothing at stake.
bob2alice.expectNoMessage(100 millis)

alice2bob.forward(bob, closingComplete)
bob2alice.expectMsgType[ClosingSig]
bob2alice.forward(alice)
val closingTx = alice2blockchain.expectMsgType[PublishFinalTx]
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.tx.txid)
alice2blockchain.expectWatchTxConfirmed(closingTx.tx.txid)
bob2blockchain.expectWatchTxConfirmed(closingTx.tx.txid)
assert(alice.stateName == NEGOTIATING_SIMPLE)
assert(bob.stateName == NEGOTIATING_SIMPLE)
}

test("recv ClosingComplete (single output, trimmed)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f =>
import f._
val (r, htlc) = addHtlc(250_000 msat, alice, bob, alice2bob, bob2alice)
Expand Down

0 comments on commit 1d85d22

Please sign in to comment.