From df5fbef62d77ad62d00bedae9fa307b60bee8d87 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 19 Sep 2023 16:44:20 +0200 Subject: [PATCH 1/9] Add `option_simple_close` feature bit --- eclair-core/src/main/resources/reference.conf | 1 + eclair-core/src/main/scala/fr/acinq/eclair/Features.scala | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 77dd6f058c..771a2711e0 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -81,6 +81,7 @@ eclair { // node that you trust using override-init-features (see below). option_zeroconf = disabled keysend = disabled + option_simple_close=optional trampoline_payment_prototype = disabled async_payment_prototype = disabled on_the_fly_funding = disabled diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala index c9886b031e..b95843e81c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -300,6 +300,11 @@ object Features { val mandatory = 54 } + case object SimpleClose extends Feature with InitFeature with NodeFeature { + val rfcName = "option_simple_close" + val mandatory = 60 + } + // TODO: @t-bast: update feature bits once spec-ed (currently reserved here: https://github.com/lightningnetwork/lightning-rfc/issues/605) // We're not advertising these bits yet in our announcements, clients have to assume support. // This is why we haven't added them yet to `areSupported`. @@ -363,6 +368,7 @@ object Features { PaymentMetadata, ZeroConf, KeySend, + SimpleClose, TrampolinePaymentPrototype, AsyncPaymentPrototype, SplicePrototype, @@ -380,6 +386,7 @@ object Features { RouteBlinding -> (VariableLengthOnion :: Nil), TrampolinePaymentPrototype -> (PaymentSecret :: Nil), KeySend -> (VariableLengthOnion :: Nil), + SimpleClose -> (ShutdownAnySegwit :: Nil), AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil), OnTheFlyFunding -> (SplicePrototype :: Nil), FundingFeeCredit -> (OnTheFlyFunding :: Nil) From a685fe2401dddd8f3ec1fb90e9ad7b7b736d8fdd Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 19 Sep 2023 17:55:43 +0200 Subject: [PATCH 2/9] Add codecs for `option_simple_close` This feature adds two new messages: - `closing_complete` sent after exchanging `shutdown` - `closing_sig` sent in response to `closing_complete` --- .../eclair/wire/protocol/ChannelTlv.scala | 22 +++++++++++++++- .../protocol/LightningMessageCodecs.scala | 12 +++++++++ .../wire/protocol/LightningMessageTypes.scala | 12 +++++++++ .../protocol/LightningMessageCodecsSpec.scala | 25 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala index 7d0fa016f2..ff57406ca0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.wire.protocol -import fr.acinq.bitcoin.scalacompat.{Satoshi, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector64, Satoshi, TxId} import fr.acinq.eclair.channel.{ChannelType, ChannelTypes} import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream, tmillisatoshi} @@ -270,3 +270,23 @@ object ClosingSignedTlv { ) } + +sealed trait ClosingTlv extends Tlv + +object ClosingTlv { + /** Signature for a closing transaction containing only the closer's output. */ + case class CloserNoClosee(sig: ByteVector64) extends ClosingTlv + + /** Signature for a closing transaction containing only the closee's output. */ + case class NoCloserClosee(sig: ByteVector64) extends ClosingTlv + + /** Signature for a closing transaction containing the closer and closee's outputs. */ + case class CloserAndClosee(sig: ByteVector64) 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])) + ) + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index 9e6128d0a6..05a2e0802a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -227,6 +227,16 @@ object LightningMessageCodecs { ("signature" | bytes64) :: ("tlvStream" | ClosingSignedTlv.closingSignedTlvCodec)).as[ClosingSigned] + val closingCompleteCodec: Codec[ClosingComplete] = ( + ("channelId" | bytes32) :: + ("fees" | satoshi) :: + ("lockTime" | uint32) :: + ("tlvStream" | ClosingTlv.closingTlvCodec)).as[ClosingComplete] + + val closingSigCodec: Codec[ClosingSig] = ( + ("channelId" | bytes32) :: + ("tlvStream" | ClosingTlv.closingTlvCodec)).as[ClosingSig] + val updateAddHtlcCodec: Codec[UpdateAddHtlc] = ( ("channelId" | bytes32) :: ("id" | uint64overflow) :: @@ -487,6 +497,8 @@ object LightningMessageCodecs { .typecase(36, channelReadyCodec) .typecase(38, shutdownCodec) .typecase(39, closingSignedCodec) + .typecase(40, closingCompleteCodec) + .typecase(41, closingSigCodec) .typecase(64, openDualFundedChannelCodec) .typecase(65, acceptDualFundedChannelCodec) .typecase(66, txAddInputCodec) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 40cc0633c1..a584441b0f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -362,6 +362,18 @@ case class ClosingSigned(channelId: ByteVector32, val feeRange_opt = tlvStream.get[ClosingSignedTlv.FeeRange] } +case class ClosingComplete(channelId: ByteVector32, fees: Satoshi, lockTime: Long, 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) +} + +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) +} + case class UpdateAddHtlc(channelId: ByteVector32, id: Long, amountMsat: MilliSatoshi, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala index 5f7dbc939d..23acceb03f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -519,6 +519,31 @@ class LightningMessageCodecsSpec extends AnyFunSuite { } } + test("encode/decode closing messages") { + val channelId = ByteVector32(hex"58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86") + val sig1 = ByteVector64(hex"01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101") + val sig2 = ByteVector64(hex"02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202") + val sig3 = ByteVector64(hex"03030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303") + val testCases = Seq( + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000" -> ClosingComplete(channelId, 1105 sat, 0), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 000c96a8 024001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingComplete(channelId, 1105 sat, 825_000, TlvStream(ClosingTlv.NoCloserClosee(sig1))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000 034001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingComplete(channelId, 1105 sat, 0, TlvStream(ClosingTlv.CloserAndClosee(sig1))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 034002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202" -> ClosingComplete(channelId, 1105 sat, 0, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.CloserAndClosee(sig2))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 024002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202 034003030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303" -> ClosingComplete(channelId, 1105 sat, 0, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.NoCloserClosee(sig2), ClosingTlv.CloserAndClosee(sig3))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86" -> ClosingSig(channelId), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 024001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingSig(channelId, TlvStream(ClosingTlv.NoCloserClosee(sig1))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 034001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingSig(channelId, TlvStream(ClosingTlv.CloserAndClosee(sig1))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 034002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202" -> ClosingSig(channelId, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.CloserAndClosee(sig2))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 024002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202 034003030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303" -> ClosingSig(channelId, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.NoCloserClosee(sig2), ClosingTlv.CloserAndClosee(sig3))), + ) + for ((encoded, expected) <- testCases) { + val decoded = lightningMessageCodec.decode(encoded.bits).require.value + assert(decoded == expected) + val reEncoded = lightningMessageCodec.encode(expected).require.bytes + assert(reEncoded == encoded) + } + } + test("encode/decode all channel messages") { val unknownTlv = GenericTlv(UInt64(5), ByteVector.fromValidHex("deadbeef")) val msgs = List( From 8d8794415d28f4a5383771834d362383fc93dfb0 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 20 Sep 2023 16:21:00 +0200 Subject: [PATCH 3/9] Add support for OP_RETURN closing scripts The spec allows the closer to use an OP_RETURN output if their amount is too low when using `option_simple_close`. --- .../fr/acinq/eclair/channel/Commitments.scala | 6 +++-- .../fr/acinq/eclair/channel/Helpers.scala | 21 ++++++----------- .../fr/acinq/eclair/channel/fsm/Channel.scala | 2 +- .../channel/fund/InteractiveTxBuilder.scala | 2 +- .../eclair/transactions/Transactions.scala | 23 ++++++++++++++++++- .../fr/acinq/eclair/channel/HelpersSpec.scala | 1 + 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 2cbe214432..a491ee7d69 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -112,10 +112,11 @@ case class ChannelParams(channelId: ByteVector32, // README: if we set our bitcoin node to generate taproot addresses and our peer does not support option_shutdown_anysegwit, we will not be able to mutual-close // channels as the isValidFinalScriptPubkey() check would fail. val allowAnySegwit = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.ShutdownAnySegwit) + val allowOpReturn = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.SimpleClose) val mustUseUpfrontShutdownScript = channelFeatures.hasFeature(Features.UpfrontShutdownScript) // we only enforce using the pre-generated shutdown script if option_upfront_shutdown_script is set if (mustUseUpfrontShutdownScript && localParams.upfrontShutdownScript_opt.exists(_ != localScriptPubKey)) Left(InvalidFinalScript(channelId)) - else if (!Closing.MutualClose.isValidFinalScriptPubkey(localScriptPubKey, allowAnySegwit)) Left(InvalidFinalScript(channelId)) + else if (!Closing.MutualClose.isValidFinalScriptPubkey(localScriptPubKey, allowAnySegwit, allowOpReturn)) Left(InvalidFinalScript(channelId)) else Right(localScriptPubKey) } @@ -126,10 +127,11 @@ case class ChannelParams(channelId: ByteVector32, def validateRemoteShutdownScript(remoteScriptPubKey: ByteVector): Either[ChannelException, ByteVector] = { // to check whether shutdown_any_segwit is active we check features in local and remote parameters, which are negotiated each time we connect to our peer. val allowAnySegwit = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.ShutdownAnySegwit) + val allowOpReturn = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.SimpleClose) val mustUseUpfrontShutdownScript = channelFeatures.hasFeature(Features.UpfrontShutdownScript) // we only enforce using the pre-generated shutdown script if option_upfront_shutdown_script is set if (mustUseUpfrontShutdownScript && remoteParams.upfrontShutdownScript_opt.exists(_ != remoteScriptPubKey)) Left(InvalidFinalScript(channelId)) - else if (!Closing.MutualClose.isValidFinalScriptPubkey(remoteScriptPubKey, allowAnySegwit)) Left(InvalidFinalScript(channelId)) + else if (!Closing.MutualClose.isValidFinalScriptPubkey(remoteScriptPubKey, allowAnySegwit, allowOpReturn)) Left(InvalidFinalScript(channelId)) else Right(remoteScriptPubKey) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 78db6d35d0..0bff53b511 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -67,14 +67,15 @@ object Helpers { private def extractShutdownScript(channelId: ByteVector32, localFeatures: Features[InitFeature], remoteFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector]): Either[ChannelException, Option[ByteVector]] = { val canUseUpfrontShutdownScript = Features.canUseFeature(localFeatures, remoteFeatures, Features.UpfrontShutdownScript) val canUseAnySegwit = Features.canUseFeature(localFeatures, remoteFeatures, Features.ShutdownAnySegwit) - extractShutdownScript(channelId, canUseUpfrontShutdownScript, canUseAnySegwit, upfrontShutdownScript_opt) + val canUseOpReturn = Features.canUseFeature(localFeatures, remoteFeatures, Features.SimpleClose) + extractShutdownScript(channelId, canUseUpfrontShutdownScript, canUseAnySegwit, canUseOpReturn, upfrontShutdownScript_opt) } - private def extractShutdownScript(channelId: ByteVector32, hasOptionUpfrontShutdownScript: Boolean, allowAnySegwit: Boolean, upfrontShutdownScript_opt: Option[ByteVector]): Either[ChannelException, Option[ByteVector]] = { + private def extractShutdownScript(channelId: ByteVector32, hasOptionUpfrontShutdownScript: Boolean, allowAnySegwit: Boolean, allowOpReturn: Boolean, upfrontShutdownScript_opt: Option[ByteVector]): Either[ChannelException, Option[ByteVector]] = { (hasOptionUpfrontShutdownScript, upfrontShutdownScript_opt) match { case (true, None) => Left(MissingUpfrontShutdownScript(channelId)) case (true, Some(script)) if script.isEmpty => Right(None) // but the provided script can be empty - case (true, Some(script)) if !Closing.MutualClose.isValidFinalScriptPubkey(script, allowAnySegwit) => Left(InvalidFinalScript(channelId)) + case (true, Some(script)) if !Closing.MutualClose.isValidFinalScriptPubkey(script, allowAnySegwit, allowOpReturn) => Left(InvalidFinalScript(channelId)) case (true, Some(script)) => Right(Some(script)) case (false, Some(_)) => Right(None) // they provided a script but the feature is not active, we just ignore it case _ => Right(None) @@ -640,13 +641,14 @@ object Helpers { object MutualClose { - def isValidFinalScriptPubkey(scriptPubKey: ByteVector, allowAnySegwit: Boolean): Boolean = { + def isValidFinalScriptPubkey(scriptPubKey: ByteVector, allowAnySegwit: Boolean, allowOpReturn: Boolean): Boolean = { Try(Script.parse(scriptPubKey)) match { case Success(OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubkeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil) if pubkeyHash.size == 20 => true case Success(OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil) if scriptHash.size == 20 => true case Success(OP_0 :: OP_PUSHDATA(pubkeyHash, _) :: Nil) if pubkeyHash.size == 20 => true case Success(OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil) if scriptHash.size == 32 => true case Success((OP_1 | OP_2 | OP_3 | OP_4 | OP_5 | OP_6 | OP_7 | OP_8 | OP_9 | OP_10 | OP_11 | OP_12 | OP_13 | OP_14 | OP_15 | OP_16) :: OP_PUSHDATA(program, _) :: Nil) if allowAnySegwit && 2 <= program.length && program.length <= 40 => true + case Success(OP_RETURN :: _) if allowOpReturn => true case _ => false } } @@ -713,16 +715,7 @@ object Helpers { * The various dust limits are detailed in https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#dust-limits */ def checkClosingDustAmounts(closingTx: ClosingTx): Boolean = { - closingTx.tx.txOut.forall(txOut => { - Try(Script.parse(txOut.publicKeyScript)) match { - case Success(OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubkeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil) if pubkeyHash.size == 20 => txOut.amount >= 546.sat - case Success(OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil) if scriptHash.size == 20 => txOut.amount >= 540.sat - case Success(OP_0 :: OP_PUSHDATA(pubkeyHash, _) :: Nil) if pubkeyHash.size == 20 => txOut.amount >= 294.sat - case Success(OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil) if scriptHash.size == 32 => txOut.amount >= 330.sat - case Success((OP_1 | OP_2 | OP_3 | OP_4 | OP_5 | OP_6 | OP_7 | OP_8 | OP_9 | OP_10 | OP_11 | OP_12 | OP_13 | OP_14 | OP_15 | OP_16) :: OP_PUSHDATA(program, _) :: Nil) if 2 <= program.length && program.length <= 40 => txOut.amount >= 354.sat - case _ => txOut.amount >= 546.sat - } - }) + closingTx.tx.txOut.forall(txOut => txOut.amount >= Transactions.dustLimit(txOut.publicKeyScript)) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 06a9d83ea9..aab6cf3c0f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -2969,7 +2969,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with if (fundingContribution < 0.sat && parentCommitment.localCommit.spec.toLocal + fundingContribution < parentCommitment.localChannelReserve(d.commitments.params).max(commitTxFees)) { log.warning(s"cannot do splice: insufficient funds (commitTxFees=$commitTxFees reserve=${parentCommitment.localChannelReserve(d.commitments.params)})") Left(InvalidSpliceRequest(d.channelId)) - } else if (cmd.spliceOut_opt.map(_.scriptPubKey).exists(!MutualClose.isValidFinalScriptPubkey(_, allowAnySegwit = true))) { + } else if (cmd.spliceOut_opt.map(_.scriptPubKey).exists(!MutualClose.isValidFinalScriptPubkey(_, allowAnySegwit = true, allowOpReturn = false))) { log.warning("cannot do splice: invalid splice-out script") Left(InvalidSpliceRequest(d.channelId)) } else { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index 836ceb9d44..29f41c4a7b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -578,7 +578,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon Left(OutputBelowDust(fundingParams.channelId, addOutput.serialId, addOutput.amount, fundingParams.dustLimit)) } else if (addOutput.pubkeyScript == fundingPubkeyScript && addOutput.amount != fundingParams.fundingAmount) { Left(InvalidSharedOutputAmount(fundingParams.channelId, addOutput.serialId, addOutput.amount, fundingParams.fundingAmount)) - } else if (!MutualClose.isValidFinalScriptPubkey(addOutput.pubkeyScript, allowAnySegwit = true)) { + } else if (!MutualClose.isValidFinalScriptPubkey(addOutput.pubkeyScript, allowAnySegwit = true, allowOpReturn = false)) { Left(InvalidSpliceOutputScript(fundingParams.channelId, addOutput.serialId, addOutput.pubkeyScript)) } else if (addOutput.pubkeyScript == fundingPubkeyScript) { Right(Output.Shared(addOutput.serialId, addOutput.pubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 286a93dafe..55993903a7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -30,7 +30,7 @@ import fr.acinq.eclair.wire.protocol.UpdateAddHtlc import scodec.bits.ByteVector import java.nio.ByteOrder -import scala.util.Try +import scala.util.{Success, Try} /** * Created by PM on 15/12/2016. @@ -231,6 +231,27 @@ object Transactions { */ def fee2rate(fee: Satoshi, weight: Int): FeeratePerKw = FeeratePerKw((fee * 1000L) / weight) + /** As defined in https://github.com/lightning/bolts/blob/master/03-transactions.md#dust-limits */ + def dustLimit(scriptPubKey: ByteVector): Satoshi = { + Try(Script.parse(scriptPubKey)) match { + case Success(OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubkeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil) if pubkeyHash.size == 20 => 546.sat + case Success(OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil) if scriptHash.size == 20 => 540.sat + case Success(OP_0 :: OP_PUSHDATA(pubkeyHash, _) :: Nil) if pubkeyHash.size == 20 => 294.sat + case Success(OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil) if scriptHash.size == 32 => 330.sat + case Success((OP_1 | OP_2 | OP_3 | OP_4 | OP_5 | OP_6 | OP_7 | OP_8 | OP_9 | OP_10 | OP_11 | OP_12 | OP_13 | OP_14 | OP_15 | OP_16) :: OP_PUSHDATA(program, _) :: Nil) if 2 <= program.length && program.length <= 40 => 354.sat + case Success(OP_RETURN :: _) => 0.sat // OP_RETURN is never dust + case _ => 546.sat + } + } + + /** When an output is using OP_RETURN, we usually want to make sure its amount is 0, otherwise bitcoind won't accept it. */ + def isOpReturn(scriptPubKey: ByteVector): Boolean = { + Try(Script.parse(scriptPubKey)) match { + case Success(OP_RETURN :: _) => true + case _ => false + } + } + /** Offered HTLCs below this amount will be trimmed. */ def offeredHtlcTrimThreshold(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Satoshi = dustLimit + weight2fee(spec.htlcTxFeerate(commitmentFormat), commitmentFormat.htlcTimeoutWeight) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala index 52b3d7bf4d..a1b1655c07 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala @@ -225,6 +225,7 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat TxOut(294 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: Nil), TxOut(330 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000000000000000000000000000") :: Nil), TxOut(354 sat, OP_3 :: OP_PUSHDATA(hex"0000000000") :: Nil), + TxOut(0 sat, OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil), ) def toClosingTx(txOut: Seq[TxOut]): ClosingTx = { From b625aba12b61d290de128e1c51d9705d8a6141b4 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 4 Oct 2023 11:45:24 +0200 Subject: [PATCH 4/9] Implement the `option_simple_close` protocol We introduce a new `NEGOTIATING_SIMPLE` state where we exchange the `closing_complete` and `closing_sig` messages, and allow RBF-ing previous transactions and updating our closing script. We stay in that state until one of the transactions confirms, or a force close is detected. This is important to ensure we're able to correctly reconnect and negotiate RBF candidates. We keep this separate from the previous NEGOTIATING state to make it easier to remove support for the older mutual close protocols once we're confident the network has been upgraded. --- docs/release-notes/eclair-vnext.md | 12 +- .../acinq/eclair/balance/CheckBalance.scala | 1 + .../fr/acinq/eclair/channel/ChannelData.scala | 10 + .../eclair/channel/ChannelExceptions.scala | 2 + .../fr/acinq/eclair/channel/Helpers.scala | 90 ++++++ .../fr/acinq/eclair/channel/fsm/Channel.scala | 206 ++++++++++--- .../eclair/channel/fsm/CommonHandlers.scala | 1 + .../eclair/channel/fsm/ErrorHandlers.scala | 12 + .../eclair/io/OpenChannelInterceptor.scala | 1 + .../acinq/eclair/io/PeerReadyNotifier.scala | 1 + .../acinq/eclair/json/JsonSerializers.scala | 1 + .../eclair/transactions/Transactions.scala | 71 +++++ .../channel/version4/ChannelCodecs4.scala | 13 + .../scala/fr/acinq/eclair/TestDatabases.scala | 1 + .../ChannelStateTestsHelperMethods.scala | 59 ++-- .../channel/states/f/ShutdownStateSpec.scala | 23 +- .../states/g/NegotiatingStateSpec.scala | 280 ++++++++++++++++-- .../channel/states/h/ClosingStateSpec.scala | 28 +- .../integration/ChannelIntegrationSpec.scala | 2 +- .../relay/AsyncPaymentTriggererSpec.scala | 4 +- .../transactions/TransactionsSpec.scala | 83 +++++- 21 files changed, 814 insertions(+), 87 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 5562da1032..867837acc5 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -4,7 +4,17 @@ ## Major changes - +### Simplified mutual close + +This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1096). +This protocol allows both channel participants to decide exactly how much fees they're willing to pay to close the channel. +Each participant obtains a channel closing transaction where they are paying the fees. + +Once closing transactions are broadcast, they can be RBF-ed by calling the `close` RPC again with a higher feerate: + +```sh +./eclair-cli close --channelId= --preferredFeerateSatByte= +``` ### API changes diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala b/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala index 03ce950797..c4bb4db38f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala @@ -213,6 +213,7 @@ object CheckBalance { case (r, d: DATA_NORMAL) => r.modify(_.normal).using(updateMainAndHtlcBalance(d.commitments, knownPreimages)) case (r, d: DATA_SHUTDOWN) => r.modify(_.shutdown).using(updateMainAndHtlcBalance(d.commitments, knownPreimages)) case (r, d: DATA_NEGOTIATING) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit)) + case (r, d: DATA_NEGOTIATING_SIMPLE) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit)) case (r, d: DATA_CLOSING) => Closing.isClosingTypeAlreadyKnown(d) match { case None if d.mutualClosePublished.nonEmpty && d.localCommitPublished.isEmpty && d.remoteCommitPublished.isEmpty && d.nextRemoteCommitPublished.isEmpty && d.revokedCommitPublished.isEmpty => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 3b0221542d..df1c0c696e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -72,6 +72,7 @@ case object WAIT_FOR_DUAL_FUNDING_READY extends ChannelState case object NORMAL extends ChannelState case object SHUTDOWN extends ChannelState case object NEGOTIATING extends ChannelState +case object NEGOTIATING_SIMPLE extends ChannelState case object CLOSING extends ChannelState case object CLOSED extends ChannelState case object OFFLINE extends ChannelState @@ -653,6 +654,15 @@ final case class DATA_NEGOTIATING(commitments: Commitments, require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation") require(!commitments.params.localParams.paysClosingFees || closingTxProposed.forall(_.nonEmpty), "initiator must have at least one closing signature for every negotiation attempt because it initiates the closing") } +final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments, + localShutdown: Shutdown, remoteShutdown: Shutdown, + // Closing transactions we created, where we pay the fees (unsigned). + proposedClosingTxs: List[ClosingTxs], + // Closing transactions we published: this contains our local transactions for + // which they sent a signature, and their closing transactions that we signed. + publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments { + def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid)) +} final case class DATA_CLOSING(commitments: Commitments, waitingSince: BlockHeight, // how long since we initiated the closing finalScriptPubKey: ByteVector, // where to send all on-chain funds diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index 90ff6f3ccf..916869f391 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -116,6 +116,8 @@ case class FeerateTooDifferent (override val channelId: Byte case class InvalidAnnouncementSignatures (override val channelId: ByteVector32, annSigs: AnnouncementSignatures) extends ChannelException(channelId, s"invalid announcement signatures: $annSigs") case class InvalidCommitmentSignature (override val channelId: ByteVector32, fundingTxId: TxId, fundingTxIndex: Long, unsignedCommitTx: Transaction) extends ChannelException(channelId, s"invalid commitment signature: fundingTxId=$fundingTxId fundingTxIndex=$fundingTxIndex commitTxId=${unsignedCommitTx.txid} commitTx=$unsignedCommitTx") case class InvalidHtlcSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid htlc signature: txId=$txId") +case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed") +case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output") case class InvalidCloseSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid close signature: txId=$txId") case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: txId=$txId") case class CommitSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"commit sig count mismatch: expected=$expected actual=$actual") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 0bff53b511..6a4b8ec0d6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -59,6 +59,7 @@ object Helpers { case d: DATA_NORMAL => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_SHUTDOWN => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_NEGOTIATING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) + case d: DATA_NEGOTIATING_SIMPLE => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_CLOSING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) } @@ -709,6 +710,95 @@ object Helpers { } } + /** We are the closer: we sign closing transactions for which we pay the fees. */ + def makeSimpleClosingTx(currentBlockHeight: BlockHeight, keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw): Either[ChannelException, (ClosingTxs, ClosingComplete)] = { + require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid localScriptPubkey") + require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid remoteScriptPubkey") + // We must convert the feerate to a fee: we must build dummy transactions to compute their weight. + val closingFee = { + val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey) + dummyClosingTxs.preferred_opt match { + case Some(dummyTx) => + val dummySignedTx = Transactions.addSigs(dummyTx, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig) + SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.tx.weight())) + case None => return Left(CannotGenerateClosingTx(commitment.channelId)) + } + } + // Now that we know the fee we're ready to pay, we can create our closing transactions. + val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey) + // The actual fee we're paying will be bigger than the one we previously computed if we omit our output. + val actualFee = closingTxs.preferred_opt match { + case Some(closingTx) if closingTx.fee > 0.sat => closingTx.fee + case _ => return Left(CannotGenerateClosingTx(commitment.channelId)) + } + val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex) + val closingComplete = ClosingComplete(commitment.channelId, actualFee, currentBlockHeight.toLong, TlvStream(Set( + closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))), + closingTxs.localOnly_opt.map(tx => ClosingTlv.CloserNoClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))), + closingTxs.remoteOnly_opt.map(tx => ClosingTlv.NoCloserClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))), + ).flatten[ClosingTlv])) + Right(closingTxs, closingComplete) + } + + /** + * We are the closee: we choose one of the closer's transactions and sign it back. + * Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the + * closing_complete doesn't match the latest state of the closing negotiation (someone changed their script). + */ + def signSimpleClosingTx(keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete): Either[ChannelException, (ClosingTx, ClosingSig)] = { + val closingFee = SimpleClosingTxFee.PaidByThem(closingComplete.fees) + val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, closingComplete.lockTime, localScriptPubkey, remoteScriptPubkey) + // If our output isn't dust, they must provide a signature for a transaction that includes it. + // Note that we're the closee, so we look for signatures including the closee output. + (closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match { + case (Some(_), Some(_)) if closingComplete.closerAndCloseeSig_opt.isEmpty && closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case (Some(_), None) if closingComplete.closerAndCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case (None, Some(_)) if closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case _ => () + } + // We choose the closing signature that matches our preferred closing transaction. + val closingTxsWithSigs = Seq( + closingComplete.closerAndCloseeSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserAndClosee(localSig)))), + closingComplete.noCloserCloseeSig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.NoCloserClosee(localSig)))), + closingComplete.closerNoCloseeSig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserNoClosee(localSig)))), + ).flatten + closingTxsWithSigs.headOption match { + case Some((closingTx, remoteSig, sigToTlv)) => + val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex) + val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat) + val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig) + Transactions.checkSpendable(signedClosingTx) match { + case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + case Success(_) => Right(signedClosingTx, ClosingSig(commitment.channelId, TlvStream(sigToTlv(localSig)))) + } + case None => Left(MissingCloseSignature(commitment.channelId)) + } + } + + /** + * We are the closer: they sent us their signature so we should now have a fully signed closing transaction. + * Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the + * closing_sig doesn't match the latest state of the closing negotiation (someone changed their script). + */ + def receiveSimpleClosingSig(keyManager: ChannelKeyManager, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig): Either[ChannelException, ClosingTx] = { + val closingTxsWithSig = Seq( + closingSig.closerAndCloseeSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig))), + closingSig.closerNoCloseeSig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig))), + closingSig.noCloserCloseeSig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig))), + ).flatten + closingTxsWithSig.headOption match { + case Some((closingTx, remoteSig)) => + val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex) + val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat) + val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig) + Transactions.checkSpendable(signedClosingTx) match { + case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + case Success(_) => Right(signedClosingTx) + } + case None => Left(MissingCloseSignature(commitment.channelId)) + } + } + /** * Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk * that the closing transaction will not be relayed to miners' mempool and will not confirm. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index aab6cf3c0f..977063c6f3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -734,10 +734,20 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } // are there pending signed changes on either side? we need to have received their last revocation! if (d.commitments.hasNoPendingHtlcsOrFeeUpdate) { - // there are no pending signed changes, let's go directly to NEGOTIATING - if (d.commitments.params.localParams.paysClosingFees) { + // 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)) { + val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() sending sendList + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending sendList :+ closingComplete + } + } 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) = Closing.MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.closingFeerates) goto(NEGOTIATING) using DATA_NEGOTIATING(d.commitments, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending sendList :+ closingSigned } else { // we are not the channel initiator, will wait for their closing_signed @@ -1513,9 +1523,19 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.debug("received a new sig:\n{}", commitments1.latest.specs2String) context.system.eventStream.publish(ChannelSignatureReceived(self, commitments1)) if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { - if (d.commitments.params.localParams.paysClosingFees) { + if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) { + val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() sending revocation + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending revocation :: closingComplete :: Nil + } + } 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) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending revocation :: closingSigned :: Nil } else { // we are not the channel initiator, will wait for their closing_signed @@ -1555,9 +1575,19 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { log.debug("switching to NEGOTIATING spec:\n{}", commitments1.latest.specs2String) - if (d.commitments.params.localParams.paysClosingFees) { + if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) { + val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending closingComplete + } + } 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) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending closingSigned } else { // we are not the channel initiator, will wait for their closing_signed @@ -1572,6 +1602,15 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Left(cause) => handleLocalError(cause, d, Some(revocation)) } + case Event(shutdown: Shutdown, d: DATA_SHUTDOWN) => + if (shutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) { + log.debug("our peer updated their shutdown script (previous={}, current={})", d.remoteShutdown.scriptPubKey, shutdown.scriptPubKey) + stay() using d.copy(remoteShutdown = shutdown) storing() + } else { + // This is a retransmission of their previous shutdown, we can ignore it. + stay() + } + case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d) case Event(ProcessCurrentBlockHeight(c), d: DATA_SHUTDOWN) => handleNewBlock(c, d) @@ -1579,17 +1618,18 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(c: CurrentFeerates.BitcoinCore, d: DATA_SHUTDOWN) => handleCurrentFeerate(c, d) case Event(c: CMD_CLOSE, d: DATA_SHUTDOWN) => - c.feerates match { - case Some(feerates) if c.feerates != d.closingFeerates => - if (c.scriptPubKey.nonEmpty && !c.scriptPubKey.contains(d.localShutdown.scriptPubKey)) { - log.warning("cannot update closing script when closing is already in progress") - handleCommandError(ClosingAlreadyInProgress(d.channelId), c) - } else { - log.info("updating our closing feerates: {}", feerates) - handleCommandSuccess(c, d.copy(closingFeerates = c.feerates)) storing() - } - case _ => - handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + val useSimpleClose = Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose) + val localShutdown_opt = c.scriptPubKey match { + case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey && useSimpleClose => Some(Shutdown(d.channelId, scriptPubKey)) + case _ => None + } + if (c.scriptPubKey.exists(_ != d.localShutdown.scriptPubKey) && !useSimpleClose) { + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + } else if (localShutdown_opt.nonEmpty || c.feerates.nonEmpty) { + val d1 = d.copy(localShutdown = localShutdown_opt.getOrElse(d.localShutdown), closingFeerates = c.feerates.orElse(d.closingFeerates)) + handleCommandSuccess(c, d1) storing() sending localShutdown_opt.toSeq + } else { + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) } case Event(e: Error, d: DATA_SHUTDOWN) => handleRemoteError(e, d) @@ -1597,17 +1637,17 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with }) when(NEGOTIATING)(handleExceptions { - // Upon reconnection, nodes must re-transmit their shutdown message, so we may receive it now. case Event(remoteShutdown: Shutdown, d: DATA_NEGOTIATING) => if (remoteShutdown != d.remoteShutdown) { - // This is a spec violation: it will likely lead to a disagreement when exchanging closing_signed and a force-close. - log.warning("received unexpected shutdown={} (previous={})", remoteShutdown, d.remoteShutdown) + // This may lead to a signature mismatch if our peer changed their script without using option_simple_close. + stay() using d.copy(remoteShutdown = remoteShutdown) storing() + } else { + stay() } - stay() case Event(c: ClosingSigned, d: DATA_NEGOTIATING) => val (remoteClosingFee, remoteSig) = (c.feeSatoshis, c.signature) - Closing.MutualClose.checkClosingSignature(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, remoteClosingFee, remoteSig) match { + MutualClose.checkClosingSignature(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, remoteClosingFee, remoteSig) match { case Right((signedClosingTx, closingSignedRemoteFees)) => val lastLocalClosingSigned_opt = d.closingTxProposed.last.lastOption if (lastLocalClosingSigned_opt.exists(_.localClosingSigned.feeSatoshis == remoteClosingFee)) { @@ -1630,7 +1670,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Some(ClosingSignedTlv.FeeRange(minFee, maxFee)) if !d.commitments.params.localParams.paysClosingFees => // if we are not paying the closing fees and they proposed a fee range, we pick a value in that range and they should accept it without further negotiation // we don't care much about the closing fee since they're paying it (not us) and we can use CPFP if we want to speed up confirmation - val localClosingFees = Closing.MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) + val localClosingFees = MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) if (maxFee < localClosingFees.min) { log.warning("their highest closing fee is below our minimum fee: {} < {}", maxFee, localClosingFees.min) stay() sending Warning(d.channelId, s"closing fee range must not be below ${localClosingFees.min}") @@ -1645,7 +1685,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.info("accepting their closing fee={}", remoteClosingFee) handleMutualClose(signedClosingTx, Left(d.copy(bestUnpublishedClosingTx_opt = Some(signedClosingTx)))) sending closingSignedRemoteFees } else { - val (closingTx, closingSigned) = Closing.MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, ClosingFees(closingFee, minFee, maxFee)) + val (closingTx, closingSigned) = MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, ClosingFees(closingFee, minFee, maxFee)) log.info("proposing closing fee={} in their fee range (min={} max={})", closingSigned.feeSatoshis, minFee, maxFee) val closingTxProposed1 = (d.closingTxProposed: @unchecked) match { case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned)) @@ -1657,9 +1697,9 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with val lastLocalClosingFee_opt = lastLocalClosingSigned_opt.map(_.localClosingSigned.feeSatoshis) val (closingTx, closingSigned) = { // if we are not the channel initiator and we were waiting for them to send their first closing_signed, we don't have a lastLocalClosingFee, so we compute a firstClosingFee - val localClosingFees = Closing.MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) - val nextPreferredFee = Closing.MutualClose.nextClosingFee(lastLocalClosingFee_opt.getOrElse(localClosingFees.preferred), remoteClosingFee) - Closing.MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, localClosingFees.copy(preferred = nextPreferredFee)) + val localClosingFees = MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) + val nextPreferredFee = MutualClose.nextClosingFee(lastLocalClosingFee_opt.getOrElse(localClosingFees.preferred), remoteClosingFee) + MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, localClosingFees.copy(preferred = nextPreferredFee)) } val closingTxProposed1 = (d.closingTxProposed: @unchecked) match { case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned)) @@ -1688,7 +1728,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with handleCommandError(ClosingAlreadyInProgress(d.channelId), c) } else { log.info("updating our closing feerates: {}", feerates) - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, Some(feerates)) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, Some(feerates)) val closingTxProposed1 = d.closingTxProposed match { case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned)) case previousNegotiations => previousNegotiations :+ List(ClosingTxProposed(closingTx, closingSigned)) @@ -1699,10 +1739,96 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with handleCommandError(ClosingAlreadyInProgress(d.channelId), c) } + case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.closingTxProposed.flatten.exists(_.unsignedTx.tx.txid == tx.txid) => + // they can publish a closing tx with any sig we sent them, even if we are not done negotiating + handleMutualClose(getMutualClosePublished(tx, d.closingTxProposed), Left(d)) + + case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.bestUnpublishedClosingTx_opt.exists(_.tx.txid == tx.txid) => + log.warning(s"looks like a mutual close tx has been published from the outside of the channel: closingTxId=${tx.txid}") + // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that + handleMutualClose(d.bestUnpublishedClosingTx_opt.get, Left(d)) + case Event(e: Error, d: DATA_NEGOTIATING) => handleRemoteError(e, d) }) + when(NEGOTIATING_SIMPLE)(handleExceptions { + case Event(remoteShutdown: Shutdown, d: DATA_NEGOTIATING_SIMPLE) => + // Our peer wants to create a new version of their closing transaction. + // We don't need to update our version of the closing transaction: we simply wait for their closing_complete. + stay() using d.copy(remoteShutdown = remoteShutdown) storing() + + case Event(closingComplete: ClosingComplete, d: DATA_NEGOTIATING_SIMPLE) => + MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, closingComplete) match { + case Left(f) => + // This may happen if scripts were updated concurrently, so we simply ignore failures. + // Bolt 2: + // - If the signature field is not valid for the corresponding closing transaction: + // - MUST ignore `closing_complete`. + log.warning("invalid closing_complete: {}", f.getMessage) + stay() + case Right((signedClosingTx, closingSig)) => + log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx) + val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig + } + + case Event(closingSig: ClosingSig, d: DATA_NEGOTIATING_SIMPLE) => + MutualClose.receiveSimpleClosingSig(keyManager, d.commitments.latest, d.proposedClosingTxs.last, closingSig) match { + case Left(f) => + // This may happen if scripts were updated concurrently, so we simply ignore failures. + // Bolt 2: + // - If the signature field is not valid for the corresponding closing transaction: + // - MUST ignore `closing_sig`. + log.warning("invalid closing_sig: {}", f.getMessage) + stay() + case Right(signedClosingTx) => + log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx) + val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = true) + } + + case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty => + if (!d.publishedClosingTxs.exists(_.tx.txid == tx.txid)) { + // They published one of our closing transactions without sending us their signature. + // We need to publish it ourselves to record the fees and watch for confirmation. + val closingTx = d.findClosingTx(tx).get.copy(tx = tx) + stay() using d.copy(publishedClosingTxs = d.publishedClosingTxs :+ closingTx) storing() calling doPublish(closingTx, localPaysClosingFees = true) + } else { + // This is one of the transactions we published. + stay() + } + + case Event(WatchTxConfirmedTriggered(_, _, tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty => + val closingType = MutualClose(d.findClosingTx(tx).get) + log.info("channel closed (type={})", EventType.Closed(closingType).label) + context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, d.commitments)) + goto(CLOSED) using d storing() + + case Event(c: CMD_CLOSE, d: DATA_NEGOTIATING_SIMPLE) => + val localShutdown_opt = c.scriptPubKey match { + case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey => Some(Shutdown(d.channelId, scriptPubKey)) + case _ => None + } + if (localShutdown_opt.nonEmpty || c.feerates.nonEmpty) { + val localScript = localShutdown_opt.map(_.scriptPubKey).getOrElse(d.localShutdown.scriptPubKey) + val feerate = c.feerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, d.remoteShutdown.scriptPubKey, feerate) match { + case Left(f) => handleCommandError(f, c) + case Right((closingTxs, closingComplete)) => + log.info("new closing transaction created with script={} fees={}", localScript, closingComplete.fees) + log.debug("signing local mutual close transactions: {}", closingTxs) + val d1 = d.copy(localShutdown = localShutdown_opt.getOrElse(d.localShutdown), proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) + stay() using d1 storing() sending localShutdown_opt.toSeq :+ closingComplete + } + } else { + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + } + + case Event(e: Error, d: DATA_NEGOTIATING_SIMPLE) => handleRemoteError(e, d) + + }) + when(CLOSING)(handleExceptions { case Event(c: HtlcSettlementCommand, d: DATA_CLOSING) => (c match { @@ -2352,6 +2478,15 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with goto(NEGOTIATING) using d.copy(closingTxProposed = closingTxProposed1) sending d.localShutdown } + case Event(_: ChannelReestablish, d: DATA_NEGOTIATING_SIMPLE) => + // We retransmit our shutdown: we may have updated our script and they may not have received it. + // We also sign a new round of closing transactions since network fees may have changed while we were offline. + val closingFeerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates) + Closing.MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, closingFeerate) match { + case Left(_) => goto(NEGOTIATING_SIMPLE) using d sending d.localShutdown + case Right((closingTxs, closingComplete)) => goto(NEGOTIATING_SIMPLE) using d.copy(proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) sending Seq(d.localShutdown, closingComplete) + } + // This handler is a workaround for an issue in lnd: starting with versions 0.10 / 0.11, they sometimes fail to send // a channel_reestablish when reconnecting a channel that recently got confirmed, and instead send a channel_ready // first and then go silent. This is due to a race condition on their side, so we trigger a reconnection, hoping that @@ -2507,6 +2642,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case d: DATA_NORMAL => d.copy(commitments = commitments1) case d: DATA_SHUTDOWN => d.copy(commitments = commitments1) case d: DATA_NEGOTIATING => d.copy(commitments = commitments1) + case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = commitments1) case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = commitments1) case d: DATA_CLOSING => d.copy(commitments = commitments1) } @@ -2534,6 +2670,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case d: DATA_NORMAL => d.copy(commitments = commitments1) case d: DATA_SHUTDOWN => d.copy(commitments = commitments1) case d: DATA_NEGOTIATING => d.copy(commitments = commitments1) + case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = commitments1) case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = commitments1) case d: DATA_CLOSING => d // there is a dedicated handler in CLOSING state } @@ -2541,15 +2678,6 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Left(_) => stay() } - case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.closingTxProposed.flatten.exists(_.unsignedTx.tx.txid == tx.txid) => - // they can publish a closing tx with any sig we sent them, even if we are not done negotiating - handleMutualClose(getMutualClosePublished(tx, d.closingTxProposed), Left(d)) - - case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.bestUnpublishedClosingTx_opt.exists(_.tx.txid == tx.txid) => - log.warning(s"looks like a mutual close tx has been published from the outside of the channel: closingTxId=${tx.txid}") - // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that - handleMutualClose(d.bestUnpublishedClosingTx_opt.get, Left(d)) - case Event(WatchFundingSpentTriggered(tx), d: ChannelDataWithCommitments) => if (d.commitments.all.map(_.fundingTxId).contains(tx.txid)) { // if the spending tx is itself a funding tx, this is a splice and there is nothing to do @@ -2623,7 +2751,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case (SYNCING, NORMAL, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("syncing->normal", d2, sendToPeer = d2.channelAnnouncement.isEmpty)) case (NORMAL, OFFLINE, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("normal->offline", d2, sendToPeer = false)) case (OFFLINE, OFFLINE, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("offline->offline", d2, sendToPeer = false)) - case (NORMAL | SYNCING | OFFLINE, SHUTDOWN | NEGOTIATING | CLOSING | CLOSED | ERR_INFORMATION_LEAK | WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT, d: DATA_NORMAL, _) => Some(EmitLocalChannelDown(d)) + case (NORMAL | SYNCING | OFFLINE, SHUTDOWN | NEGOTIATING | NEGOTIATING_SIMPLE | CLOSING | CLOSED | ERR_INFORMATION_LEAK | WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT, d: DATA_NORMAL, _) => Some(EmitLocalChannelDown(d)) case _ => None } emitEvent_opt.foreach { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index a5efa07cb6..a8458f4ec5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -106,6 +106,7 @@ trait CommonHandlers { case d: DATA_NORMAL if d.localShutdown.isDefined => d.localShutdown.get.scriptPubKey case d: DATA_SHUTDOWN => d.localShutdown.scriptPubKey case d: DATA_NEGOTIATING => d.localShutdown.scriptPubKey + case d: DATA_NEGOTIATING_SIMPLE => d.localShutdown.scriptPubKey case d: DATA_CLOSING => d.finalScriptPubKey case d => d.commitments.params.localParams.upfrontShutdownScript_opt match { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala index cb6ebe1005..a2ffe53180 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala @@ -87,6 +87,10 @@ trait ErrorHandlers extends CommonHandlers { log.info(s"we have a valid closing tx, publishing it instead of our commitment: closingTxId=${bestUnpublishedClosingTx.tx.txid}") // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that handleMutualClose(bestUnpublishedClosingTx, Left(negotiating)) + case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty => + // We have published at least one mutual close transaction, it's better to use it instead of our local commit. + val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localShutdown.scriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) + goto(CLOSING) using closing storing() case dd: ChannelDataWithCommitments => // We publish our commitment even if we have nothing at stake: it's a nice thing to do because it lets our peer // get their funds back without delays. @@ -133,6 +137,10 @@ trait ErrorHandlers extends CommonHandlers { case negotiating@DATA_NEGOTIATING(_, _, _, _, Some(bestUnpublishedClosingTx)) => // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that handleMutualClose(bestUnpublishedClosingTx, Left(negotiating)) + case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty => + // We have published at least one mutual close transaction, it's better to use it instead of our local commit. + val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localShutdown.scriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) + goto(CLOSING) using closing storing() // NB: we publish the commitment even if we have nothing at stake (in a dataloss situation our peer will send us an error just for that) case hasCommitments: ChannelDataWithCommitments => if (e.toAscii == "internal error") { @@ -211,6 +219,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(localCommitPublished = Some(localCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, negotiating.closingTxProposed.flatten.map(_.unsignedTx), localCommitPublished = Some(localCommitPublished)) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, localCommitPublished = Some(localCommitPublished)) case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, localCommitPublished = Some(localCommitPublished)) } goto(CLOSING) using nextData storing() calling doPublish(localCommitPublished, commitment) @@ -257,6 +266,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(remoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), remoteCommitPublished = Some(remoteCommitPublished)) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, remoteCommitPublished = Some(remoteCommitPublished)) case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, remoteCommitPublished = Some(remoteCommitPublished)) } goto(CLOSING) using nextData storing() calling doPublish(remoteCommitPublished, commitments) @@ -275,6 +285,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(nextRemoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), nextRemoteCommitPublished = Some(remoteCommitPublished)) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, remoteCommitPublished = Some(remoteCommitPublished)) // NB: if there is a next commitment, we can't be in DATA_WAIT_FOR_FUNDING_CONFIRMED so we don't have the case where fundingTx is defined case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, nextRemoteCommitPublished = Some(remoteCommitPublished)) } @@ -314,6 +325,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(revokedCommitPublished = closing.revokedCommitPublished :+ revokedCommitPublished) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), revokedCommitPublished = revokedCommitPublished :: Nil) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, revokedCommitPublished = revokedCommitPublished :: Nil) // NB: if there is a revoked commitment, we can't be in DATA_WAIT_FOR_FUNDING_CONFIRMED so we don't have the case where fundingTx is defined case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, revokedCommitPublished = revokedCommitPublished :: Nil) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala index 8714ac9b5a..51321cac8a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala @@ -274,6 +274,7 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], case _: DATA_NORMAL => false case _: DATA_SHUTDOWN => true case _: DATA_NEGOTIATING => true + case _: DATA_NEGOTIATING_SIMPLE => true case _: DATA_CLOSING => true case _: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => true } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala index 932e414cdf..ecb92805d3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala @@ -198,6 +198,7 @@ object PeerReadyNotifier { case channel.NORMAL => true case channel.SHUTDOWN => true case channel.NEGOTIATING => true + case channel.NEGOTIATING_SIMPLE => true case channel.CLOSING => true case channel.CLOSED => true case channel.WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => true diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala index 2f390f13b9..f8caa38bba 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala @@ -643,6 +643,7 @@ object CustomTypeHints { classOf[DATA_NORMAL], classOf[DATA_SHUTDOWN], classOf[DATA_NEGOTIATING], + classOf[DATA_NEGOTIATING_SIMPLE], classOf[DATA_CLOSING], classOf[DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT] ), typeHintFieldName = "type") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 55993903a7..c96bb9d5e9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -835,6 +835,77 @@ object Transactions { ClosingTx(commitTxInput, tx, toLocalOutput) } + // @formatter:off + /** We always create multiple versions of each closing transaction, where fees are either paid by us or by our peer. */ + sealed trait SimpleClosingTxFee + object SimpleClosingTxFee { + case class PaidByUs(fee: Satoshi) extends SimpleClosingTxFee + case class PaidByThem(fee: Satoshi) extends SimpleClosingTxFee + } + // @formatter:on + + /** Each closing attempt can result in multiple potential closing transactions, depending on which outputs are included. */ + case class ClosingTxs(localAndRemote_opt: Option[ClosingTx], localOnly_opt: Option[ClosingTx], remoteOnly_opt: Option[ClosingTx]) { + /** Preferred closing transaction for this closing attempt. */ + val preferred_opt: Option[ClosingTx] = localAndRemote_opt.orElse(localOnly_opt).orElse(remoteOnly_opt) + val all: Seq[ClosingTx] = Seq(localAndRemote_opt, localOnly_opt, remoteOnly_opt).flatten + + override def toString: String = s"localAndRemote=${localAndRemote_opt.map(_.tx.toString()).getOrElse("n/a")}, localOnly=${localOnly_opt.map(_.tx.toString()).getOrElse("n/a")}, remoteOnly=${remoteOnly_opt.map(_.tx.toString()).getOrElse("n/a")}" + } + + def makeSimpleClosingTxs(input: InputInfo, spec: CommitmentSpec, fee: SimpleClosingTxFee, lockTime: Long, localScriptPubKey: ByteVector, remoteScriptPubKey: ByteVector): ClosingTxs = { + require(spec.htlcs.isEmpty, "there shouldn't be any pending htlcs") + + val txNoOutput = Transaction(2, Seq(TxIn(input.outPoint, ByteVector.empty, sequence = 0xFFFFFFFDL)), Nil, lockTime) + + // We compute the remaining balance for each side after paying the closing fees. + // This lets us decide whether outputs can be included in the closing transaction or not. + val (toLocalAmount, toRemoteAmount) = fee match { + case SimpleClosingTxFee.PaidByUs(fee) => (spec.toLocal.truncateToSatoshi - fee, spec.toRemote.truncateToSatoshi) + case SimpleClosingTxFee.PaidByThem(fee) => (spec.toLocal.truncateToSatoshi, spec.toRemote.truncateToSatoshi - fee) + } + + // An OP_RETURN script may be provided, but only when burning all of the peer's balance to fees. + val toLocalOutput_opt = if (toLocalAmount >= dustLimit(localScriptPubKey)) { + val amount = if (isOpReturn(localScriptPubKey)) 0.sat else toLocalAmount + Some(TxOut(amount, localScriptPubKey)) + } else { + None + } + val toRemoteOutput_opt = if (toRemoteAmount >= dustLimit(remoteScriptPubKey)) { + val amount = if (isOpReturn(remoteScriptPubKey)) 0.sat else toRemoteAmount + Some(TxOut(amount, remoteScriptPubKey)) + } else { + None + } + + // We may create multiple closing transactions based on which outputs may be included. + (toLocalOutput_opt, toRemoteOutput_opt) match { + case (Some(toLocalOutput), Some(toRemoteOutput)) => + val txLocalAndRemote = LexicographicalOrdering.sort(txNoOutput.copy(txOut = Seq(toLocalOutput, toRemoteOutput))) + val toLocalOutputInfo = findPubKeyScriptIndex(txLocalAndRemote, localScriptPubKey).map(index => OutputInfo(index, toLocalOutput.amount, localScriptPubKey)).toOption + ClosingTxs( + localAndRemote_opt = Some(ClosingTx(input, txLocalAndRemote, toLocalOutputInfo)), + // We also provide a version of the transaction without the remote output, which they may want to omit if not economical to spend. + localOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toLocalOutput)), Some(OutputInfo(0, toLocalOutput.amount, localScriptPubKey)))), + remoteOnly_opt = None + ) + case (Some(toLocalOutput), None) => + ClosingTxs( + localAndRemote_opt = None, + localOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toLocalOutput)), Some(OutputInfo(0, toLocalOutput.amount, localScriptPubKey)))), + remoteOnly_opt = None + ) + case (None, Some(toRemoteOutput)) => + ClosingTxs( + localAndRemote_opt = None, + localOnly_opt = None, + remoteOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toRemoteOutput)), None)) + ) + case (None, None) => ClosingTxs(None, None, None) + } + } + def findPubKeyScriptIndex(tx: Transaction, pubkeyScript: ByteVector): Either[TxGenerationSkipped, Int] = { val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == pubkeyScript) if (outputIndex >= 0) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala index afcfbc4cbd..8dbd2bb56f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala @@ -754,6 +754,18 @@ private[channel] object ChannelCodecs4 { ("closingTxProposed" | listOfN(uint16, listOfN(uint16, lengthDelimited(closingTxProposedCodec)))) :: ("bestUnpublishedClosingTx_opt" | optional(bool8, closingTxCodec))).as[DATA_NEGOTIATING] + private val closingTxsCodec: Codec[ClosingTxs] = ( + ("localAndRemote_opt" | optional(bool8, closingTxCodec)) :: + ("localOnly_opt" | optional(bool8, closingTxCodec)) :: + ("remoteOnly_opt" | optional(bool8, closingTxCodec))).as[ClosingTxs] + + val DATA_NEGOTIATING_SIMPLE_14_Codec: Codec[DATA_NEGOTIATING_SIMPLE] = ( + ("commitments" | commitmentsCodec) :: + ("localShutdown" | lengthDelimited(shutdownCodec)) :: + ("remoteShutdown" | lengthDelimited(shutdownCodec)) :: + ("proposedClosingTxs" | listOfN(uint16, closingTxsCodec)) :: + ("publishedClosingTxs" | listOfN(uint16, closingTxCodec))).as[DATA_NEGOTIATING_SIMPLE] + val DATA_CLOSING_07_Codec: Codec[DATA_CLOSING] = ( ("commitments" | commitmentsCodecWithoutFirstRemoteCommitIndex) :: ("waitingSince" | blockHeight) :: @@ -789,6 +801,7 @@ private[channel] object ChannelCodecs4 { // Order matters! val channelDataCodec: Codec[PersistentChannelData] = discriminated[PersistentChannelData].by(uint16) + .typecase(0x14, Codecs.DATA_NEGOTIATING_SIMPLE_14_Codec) .typecase(0x13, Codecs.DATA_WAIT_FOR_DUAL_FUNDING_SIGNED_13_Codec) .typecase(0x12, Codecs.DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT_12_Codec) .typecase(0x11, Codecs.DATA_CLOSING_11_Codec) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index 304afa9b48..f102cd3868 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -84,6 +84,7 @@ object TestDatabases { } case d: DATA_CLOSING => d.copy(commitments = freeze2(d.commitments)) case d: DATA_NEGOTIATING => d.copy(commitments = freeze2(d.commitments)) + case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = freeze2(d.commitments)) case d: DATA_SHUTDOWN => d.copy(commitments = freeze2(d.commitments)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index 278f45290b..562d9ec311 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -31,11 +31,12 @@ import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet, OnchainPub import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher -import fr.acinq.eclair.channel.publish.TxPublisher.PublishReplaceableTx +import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory import fr.acinq.eclair.payment.send.SpontaneousRecipient import fr.acinq.eclair.payment.{Invoice, OutgoingPaymentPacket} import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, Route} +import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol._ @@ -91,8 +92,10 @@ object ChannelStateTestsTags { val RejectRbfAttempts = "reject_rbf_attempts" /** If set, the non-initiator will require a 1-block delay between RBF attempts. */ val DelayRbfAttempts = "delay_rbf_attempts" - /** If set, channels will adapt their max HTLC amount to the available balance */ - val AdaptMaxHtlcAmount = "adapt-max-htlc-amount" + /** If set, channels will adapt their max HTLC amount to the available balance. */ + val AdaptMaxHtlcAmount = "adapt_max_htlc_amount" + /** If set, closing will use option_simple_close. */ + val SimpleClose = "option_simple_close" } trait ChannelStateTestsBase extends Assertions with Eventually { @@ -188,6 +191,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroConf))(_.updated(Features.ZeroConf, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) .initFeatures() val bobInitFeatures = Bob.nodeParams.features .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableWumbo))(_.removed(Features.Wumbo)) @@ -200,6 +204,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroConf))(_.updated(Features.ZeroConf, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) .initFeatures() val channelType = ChannelTypes.defaultFromFeatures(aliceInitFeatures, bobInitFeatures, announceChannel = channelFlags.announceChannel) @@ -508,23 +513,41 @@ trait ChannelStateTestsBase extends Assertions with Eventually { s2r.forward(r) r2s.expectMsgType[Shutdown] r2s.forward(s) - // agreeing on a closing fee - var sCloseFee, rCloseFee = 0.sat - do { - sCloseFee = s2r.expectMsgType[ClosingSigned].feeSatoshis + if (s.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) { + s2r.expectMsgType[ClosingComplete] s2r.forward(r) - rCloseFee = r2s.expectMsgType[ClosingSigned].feeSatoshis + r2s.expectMsgType[ClosingComplete] + r2s.forward(s) + r2s.expectMsgType[ClosingSig] r2s.forward(s) - } while (sCloseFee != rCloseFee) - s2blockchain.expectMsgType[TxPublisher.PublishTx] - s2blockchain.expectMsgType[WatchTxConfirmed] - r2blockchain.expectMsgType[TxPublisher.PublishTx] - r2blockchain.expectMsgType[WatchTxConfirmed] - eventually { - assert(s.stateName == CLOSING) - assert(r.stateName == CLOSING) + val sTx = r2blockchain.expectMsgType[PublishFinalTx].tx + r2blockchain.expectWatchTxConfirmed(sTx.txid) + s2r.expectMsgType[ClosingSig] + s2r.forward(r) + val rTx = s2blockchain.expectMsgType[PublishFinalTx].tx + s2blockchain.expectWatchTxConfirmed(rTx.txid) + assert(s2blockchain.expectMsgType[PublishFinalTx].tx.txid == sTx.txid) + s2blockchain.expectWatchTxConfirmed(sTx.txid) + assert(r2blockchain.expectMsgType[PublishFinalTx].tx.txid == rTx.txid) + r2blockchain.expectWatchTxConfirmed(rTx.txid) + } else { + // agreeing on a closing fee + var sCloseFee, rCloseFee = 0.sat + do { + sCloseFee = s2r.expectMsgType[ClosingSigned].feeSatoshis + s2r.forward(r) + rCloseFee = r2s.expectMsgType[ClosingSigned].feeSatoshis + r2s.forward(s) + } while (sCloseFee != rCloseFee) + s2blockchain.expectMsgType[TxPublisher.PublishTx] + s2blockchain.expectMsgType[WatchTxConfirmed] + r2blockchain.expectMsgType[TxPublisher.PublishTx] + r2blockchain.expectMsgType[WatchTxConfirmed] + eventually { + assert(s.stateName == CLOSING) + assert(r.stateName == CLOSING) + } } - // both nodes are now in CLOSING state with a mutual close tx pending for confirmation } def localClose(s: TestFSMRef[ChannelState, ChannelData, Channel], s2blockchain: TestProbe): LocalCommitPublished = { @@ -566,7 +589,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { // we watch the confirmation of the "final" transactions that send funds to our wallets (main delayed output and 2nd stage htlc transactions) assert(s2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) localCommitPublished.claimMainDelayedOutputTx.foreach(claimMain => { - val watchConfirmed = s2blockchain.expectMsgType[WatchTxConfirmed] + val watchConfirmed = s2blockchain.expectMsgType[WatchTxConfirmed] assert(watchConfirmed.txId == claimMain.tx.txid) assert(watchConfirmed.delay_opt.map(_.parentTxId).contains(publishedLocalCommitTx.txid)) }) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala index 8e49654cc3..19e0eecdbf 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala @@ -20,7 +20,7 @@ import akka.testkit.TestProbe import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Script, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates} @@ -33,7 +33,7 @@ import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.payment.send.SpontaneousRecipient import fr.acinq.eclair.transactions.Transactions.ClaimLocalAnchorOutputTx import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingSigned, CommitSig, Error, FailureMessageCodecs, FailureReason, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc} -import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} import scodec.bits.ByteVector @@ -911,6 +911,25 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice.stateData.asInstanceOf[DATA_SHUTDOWN].closingFeerates.contains(closingFeerates2)) } + test("recv CMD_CLOSE with updated script") { f => + import f._ + val sender = TestProbe() + val script = Script.write(Script.pay2wpkh(randomKey().publicKey)) + alice ! CMD_CLOSE(sender.ref, Some(script), None) + sender.expectMsgType[RES_FAILURE[CMD_CLOSE, ClosingAlreadyInProgress]] + } + + test("recv CMD_CLOSE with updated script (option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + val sender = TestProbe() + val script = Script.write(Script.pay2wpkh(randomKey().publicKey)) + alice ! CMD_CLOSE(sender.ref, Some(script), None) + sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]] + assert(alice2bob.expectMsgType[Shutdown].scriptPubKey == script) + alice2bob.forward(bob) + awaitCond(bob.stateData.asInstanceOf[DATA_SHUTDOWN].remoteShutdown.scriptPubKey == script) + } + test("recv CMD_FORCECLOSE") { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index c011d66514..226767cd4b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.channel.states.g import akka.testkit.TestProbe -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, Script, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel.Helpers.Closing @@ -26,11 +26,12 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.testutils.PimpTestProbe._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat import fr.acinq.eclair.wire.protocol.ClosingSignedTlv.FeeRange -import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingSigned, Error, Shutdown, TlvStream, Warning} -import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Shutdown, TlvStream, Warning} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -63,11 +64,15 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike alice2bob.forward(bob, aliceShutdown) val bobShutdown = bob2alice.expectMsgType[Shutdown] bob2alice.forward(alice, bobShutdown) - awaitCond(alice.stateName == NEGOTIATING) - assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) - - awaitCond(bob.stateName == NEGOTIATING) - assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + if (alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) { + awaitCond(alice.stateName == NEGOTIATING_SIMPLE) + awaitCond(bob.stateName == NEGOTIATING_SIMPLE) + } else { + awaitCond(alice.stateName == NEGOTIATING) + assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) + awaitCond(bob.stateName == NEGOTIATING) + assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + } } def bobClose(f: FixtureParam, feerates: Option[ClosingFeerates] = None): Unit = { @@ -79,11 +84,15 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2alice.forward(alice, bobShutdown) val aliceShutdown = alice2bob.expectMsgType[Shutdown] alice2bob.forward(bob, aliceShutdown) - awaitCond(alice.stateName == NEGOTIATING) - assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) - - awaitCond(bob.stateName == NEGOTIATING) - assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + if (bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) { + awaitCond(alice.stateName == NEGOTIATING_SIMPLE) + awaitCond(bob.stateName == NEGOTIATING_SIMPLE) + } else { + awaitCond(alice.stateName == NEGOTIATING) + assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) + awaitCond(bob.stateName == NEGOTIATING) + assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + } } def buildFeerates(feerate: FeeratePerKw, minFeerate: FeeratePerKw = FeeratePerKw(250 sat)): FeeratesPerKw = @@ -473,6 +482,131 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2blockchain.expectMsgType[WatchTxConfirmed] } + test("recv ClosingComplete (both outputs)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + assert(aliceClosingComplete.fees > 0.sat) + assert(aliceClosingComplete.closerAndCloseeSig_opt.nonEmpty) + assert(aliceClosingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(aliceClosingComplete.noCloserCloseeSig_opt.isEmpty) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + assert(bobClosingComplete.fees > 0.sat) + assert(bobClosingComplete.closerAndCloseeSig_opt.nonEmpty) + assert(bobClosingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(bobClosingComplete.noCloserCloseeSig_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) + val closingComplete = alice2bob.expectMsgType[ClosingComplete] + assert(closingComplete.closerAndCloseeSig_opt.isEmpty) + assert(closingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(closingComplete.noCloserCloseeSig_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) + crossSign(alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlc.id, r, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + + aliceClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + assert(aliceClosingComplete.closerAndCloseeSig_opt.isEmpty) + assert(aliceClosingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(aliceClosingComplete.noCloserCloseeSig_opt.isEmpty) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + assert(bobClosingComplete.closerAndCloseeSig_opt.isEmpty) + assert(bobClosingComplete.closerNoCloseeSig_opt.isEmpty) + assert(bobClosingComplete.noCloserCloseeSig_opt.nonEmpty) + + 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) + bob2blockchain.expectWatchTxConfirmed(bobTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(bobTx.tx.txid) + assert(alice.stateName == NEGOTIATING_SIMPLE) + assert(bob.stateName == NEGOTIATING_SIMPLE) + } + + test("recv ClosingComplete (missing closee output)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete.copy(tlvStream = TlvStream(ClosingTlv.CloserNoClosee(aliceClosingComplete.closerNoCloseeSig_opt.get)))) + // Bob expects to receive a signature for a closing transaction containing his output, so he ignores Alice's + // closing_complete instead of sending back his closing_sig. + bob2alice.expectNoMessage(100 millis) + bob2alice.forward(alice, bobClosingComplete) + val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob, aliceClosingSig.copy(tlvStream = TlvStream(ClosingTlv.NoCloserClosee(aliceClosingSig.closerAndCloseeSig_opt.get)))) + bob2alice.expectNoMessage(100 millis) + bob2blockchain.expectNoMessage(100 millis) + } + + test("recv ClosingComplete (with concurrent shutdown)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] + bob2alice.expectMsgType[ClosingComplete] // ignored + // Bob updates his closing script before receiving Alice's closing_complete. + val bobScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + bob ! CMD_CLOSE(TestProbe().ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) + val bobShutdown = bob2alice.expectMsgType[Shutdown] + assert(bobShutdown.scriptPubKey == bobScript) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete1) + bob2alice.expectNoMessage(100 millis) // Bob ignores Alice's obsolete closing_complete. + // When Alice receives Bob's shutdown, she doesn't change her own closing txs. + bob2alice.forward(alice, bobShutdown) + alice2bob.expectNoMessage(100 millis) + // When she receives Bob's new closing_complete, she signs it: Bob now has closing transactions with his last closing script. + bob2alice.forward(alice, bobClosingComplete) + val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob, aliceClosingSig) + alice2blockchain.expectMsgType[PublishFinalTx] + bob2blockchain.expectMsgType[PublishFinalTx] + } + test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f => import f._ aliceClose(f) @@ -533,6 +667,94 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(bob.stateName == CLOSING) } + test("recv WatchFundingSpentTriggered (signed closing tx)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + bobClose(f) + // Alice and Bob publish a first closing tx. + val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete1) + val bobClosingComplete1 = bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice, bobClosingComplete1) + val aliceClosingSig1 = alice2bob.expectMsgType[ClosingSig] + val bobTx1 = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(bobTx1.txid) + val bobClosingSig1 = bob2alice.expectMsgType[ClosingSig] + val aliceTx1 = bob2blockchain.expectMsgType[PublishFinalTx].tx + bob2blockchain.expectWatchTxConfirmed(aliceTx1.txid) + alice2bob.forward(bob, aliceClosingSig1) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx1.txid) + bob2blockchain.expectWatchTxConfirmed(bobTx1.txid) + bob2alice.forward(alice, bobClosingSig1) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx1.txid) + alice2blockchain.expectWatchTxConfirmed(aliceTx1.txid) + + // Alice updates her closing script. + alice ! CMD_CLOSE(TestProbe().ref, Some(Script.write(Script.pay2wpkh(randomKey().publicKey))), None) + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + val aliceTx2 = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(aliceTx2.txid) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx2.txid) + bob2blockchain.expectWatchTxConfirmed(aliceTx2.txid) + + // They first receive a watch event for the older transaction, then the new one. + alice ! WatchFundingSpentTriggered(aliceTx1) + alice ! WatchFundingSpentTriggered(bobTx1) + alice ! WatchFundingSpentTriggered(aliceTx2) + alice2blockchain.expectNoMessage(100 millis) + assert(alice.stateName == NEGOTIATING_SIMPLE) + bob ! WatchFundingSpentTriggered(aliceTx1) + bob ! WatchFundingSpentTriggered(bobTx1) + bob ! WatchFundingSpentTriggered(aliceTx2) + bob2blockchain.expectNoMessage(100 millis) + assert(bob.stateName == NEGOTIATING_SIMPLE) + } + + test("recv WatchFundingSpentTriggered (unsigned closing tx)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + bobClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice, bobClosingComplete) + alice2bob.expectMsgType[ClosingSig] + val bobTx = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(bobTx.txid) + bob2alice.expectMsgType[ClosingSig] + val aliceTx = bob2blockchain.expectMsgType[PublishFinalTx].tx + bob2blockchain.expectWatchTxConfirmed(aliceTx.txid) + + alice ! WatchFundingSpentTriggered(aliceTx) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx.txid) + alice2blockchain.expectWatchTxConfirmed(aliceTx.txid) + alice2blockchain.expectNoMessage(100 millis) + + bob ! WatchFundingSpentTriggered(bobTx) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx.txid) + bob2blockchain.expectWatchTxConfirmed(bobTx.txid) + bob2blockchain.expectNoMessage(100 millis) + } + + test("recv WatchFundingSpentTriggered (unrecognized commit)") { f => + import f._ + bobClose(f) + alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0)) + alice2blockchain.expectNoMessage(100 millis) + assert(alice.stateName == NEGOTIATING) + } + + test("recv WatchFundingSpentTriggered (unrecognized commit, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + bobClose(f) + alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0)) + alice2blockchain.expectNoMessage(100 millis) + assert(alice.stateName == NEGOTIATING_SIMPLE) + } + test("recv CMD_CLOSE") { f => import f._ bobClose(f) @@ -573,14 +795,6 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike awaitCond(bob.stateName == CLOSING) } - test("recv WatchFundingSpentTriggered (unrecognized commit)") { f => - import f._ - bobClose(f) - alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0)) - alice2blockchain.expectNoMessage(100 millis) - assert(alice.stateName == NEGOTIATING) - } - test("recv Error") { f => import f._ bobClose(f) @@ -593,4 +807,28 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == tx.txid) } + test("recv Error (option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val closingComplete = alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob, closingComplete) + bob2alice.expectMsgType[ClosingComplete] + val closingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, closingSig) + val closingTx = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(closingTx.txid) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.txid) + bob2blockchain.expectWatchTxConfirmed(closingTx.txid) + + alice ! Error(ByteVector32.Zeroes, "oops") + awaitCond(alice.stateName == CLOSING) + assert(alice.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.nonEmpty) + alice2blockchain.expectNoMessage(100 millis) // we have a mutual close transaction, so we don't publish the commit tx + + bob ! Error(ByteVector32.Zeroes, "oops") + awaitCond(bob.stateName == CLOSING) + assert(bob.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.nonEmpty) + bob2blockchain.expectNoMessage(100 millis) // we have a mutual close transaction, so we don't publish the commit tx + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index ba527991bf..cbb62eb78f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -335,6 +335,18 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } + test("recv WatchTxConfirmedTriggered (mutual close, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) + val mutualCloseTx = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].publishedClosingTxs.last + + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mutualCloseTx.tx) + awaitCond(alice.stateName == CLOSED) + + bob ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mutualCloseTx.tx) + awaitCond(bob.stateName == CLOSED) + } + test("recv WatchFundingSpentTriggered (local commit)") { f => import f._ // an error occurs and alice publishes her commit tx @@ -859,6 +871,18 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(!listener.expectMsgType[LocalChannelUpdate].channelUpdate.channelFlags.isEnabled) } + test("recv WatchFundingSpentTriggered (remote commit, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) + // Bob publishes his last current commit tx, the one it had when entering NEGOTIATING state. + val bobCommitTx = bobCommitTxs.last.commitTx.tx + val closingState = remoteClose(bobCommitTx, alice, alice2blockchain) + assert(closingState.claimHtlcTxs.isEmpty) + val txPublished = txListener.expectMsgType[TransactionPublished] + assert(txPublished.tx == bobCommitTx) + assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the remote commit + } + test("recv CMD_BUMP_FORCE_CLOSE_FEE (remote commit)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ @@ -904,10 +928,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } - test("recv WatchTxConfirmedTriggered (remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey)) { f => + test("recv WatchTxConfirmedTriggered (remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey), Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) - assert(alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures == ChannelFeatures(Features.StaticRemoteKey)) + assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].commitments.params.channelFeatures == ChannelFeatures(Features.StaticRemoteKey)) // bob publishes his last current commit tx, the one it had when entering NEGOTIATING state val bobCommitTx = bobCommitTxs.last.commitTx.tx assert(bobCommitTx.txOut.size == 2) // two main outputs diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala index 5a959adeb4..6c8ecc90b4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala @@ -580,7 +580,7 @@ class StandardChannelIntegrationSpec extends ChannelIntegrationSpec { fundee.register ! Register.Forward(sender.ref.toTyped[Any], channelId, CMD_CLOSE(sender.ref, None, None)) sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]] // we then wait for C and F to negotiate the closing fee - awaitCond(stateListener.expectMsgType[ChannelStateChanged](max = 60 seconds).currentState == CLOSING, max = 60 seconds) + awaitCond(stateListener.expectMsgType[ChannelStateChanged](max = 60 seconds).currentState == NEGOTIATING_SIMPLE, max = 60 seconds) // and close the channel val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) awaitCond({ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala index 55d0335b3e..665db36d0b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala @@ -10,7 +10,7 @@ import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.blockchain.CurrentBlockHeight -import fr.acinq.eclair.channel.NEGOTIATING +import fr.acinq.eclair.channel.{NEGOTIATING, NEGOTIATING_SIMPLE} import fr.acinq.eclair.io.Switchboard.GetPeerInfo import fr.acinq.eclair.io.{Peer, PeerConnected, PeerReadyManager, Switchboard} import fr.acinq.eclair.payment.relay.AsyncPaymentTriggerer._ @@ -166,7 +166,7 @@ class AsyncPaymentTriggererSpec extends ScalaTestWithActorTestKit(ConfigFactory. system.eventStream ! EventStream.Publish(PeerConnected(peer.ref.toClassic, remoteNodeId, null)) val request2 = switchboard.expectMessageType[Switchboard.GetPeerInfo] request2.replyTo ! Peer.PeerInfo(peer.ref.toClassic, remoteNodeId, Peer.CONNECTED, None, None, Set(TestProbe().ref.toClassic)) - peer.expectMessageType[Peer.GetPeerChannels].replyTo ! Peer.PeerChannels(remoteNodeId, Seq(Peer.ChannelInfo(null, NEGOTIATING, null))) + peer.expectMessageType[Peer.GetPeerChannels].replyTo ! Peer.PeerChannels(remoteNodeId, Seq(Peer.ChannelInfo(null, NEGOTIATING_SIMPLE, null))) probe.expectNoMessage(100 millis) probe2.expectMessage(AsyncPaymentTriggered) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index 951c62146b..840ca7517f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.SigHash._ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, ripemd160, sha256} import fr.acinq.bitcoin.scalacompat.Script.{pay2wpkh, pay2wsh, write} -import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OutPoint, Protocol, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut, millibtc2satoshi} +import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OP_PUSHDATA, OP_RETURN, OutPoint, Protocol, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut, millibtc2satoshi} import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw} @@ -828,6 +828,56 @@ class TransactionsSpec extends AnyFunSuite with Logging { val toRemoteIndex = (toLocal.index + 1) % 2 assert(closingTx.tx.txOut(toRemoteIndex.toInt).amount == 250_000.sat) } + { + // Different amounts, both outputs untrimmed, local is closer (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 250_000_000 msat) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(5_000 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.localAndRemote_opt.nonEmpty) + assert(closingTxs.localOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.isEmpty) + val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get + assert(localAndRemote.publicKeyScript == localPubKeyScript) + assert(localAndRemote.amount == 145_000.sat) + val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(localOnly.publicKeyScript == localPubKeyScript) + assert(localOnly.amount == 145_000.sat) + } + { + // Remote is using OP_RETURN (option_simple_close): we set their output amount to 0 sat. + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_500_000 msat) + val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(5_000 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.localAndRemote_opt.nonEmpty) + assert(closingTxs.localOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.isEmpty) + val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get + assert(localAndRemote.publicKeyScript == localPubKeyScript) + assert(localAndRemote.amount == 145_000.sat) + val remoteOutput = closingTxs.localAndRemote_opt.get.tx.txOut((localAndRemote.index.toInt + 1) % 2) + assert(remoteOutput.amount == 0.sat) + assert(remoteOutput.publicKeyScript == remotePubKeyScript) + val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(localOnly.publicKeyScript == localPubKeyScript) + assert(localOnly.amount == 145_000.sat) + } + { + // Remote is using OP_RETURN (option_simple_close) and paying the fees: we set their output amount to 0 sat. + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 10_000_000 msat) + val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(5_000 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.localAndRemote_opt.nonEmpty) + assert(closingTxs.localOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.isEmpty) + val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get + assert(localAndRemote.publicKeyScript == localPubKeyScript) + assert(localAndRemote.amount == 150_000.sat) + val remoteOutput = closingTxs.localAndRemote_opt.get.tx.txOut((localAndRemote.index.toInt + 1) % 2) + assert(remoteOutput.amount == 0.sat) + assert(remoteOutput.publicKeyScript == remotePubKeyScript) + val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(localOnly.publicKeyScript == localPubKeyScript) + assert(localOnly.amount == 150_000.sat) + } { // Same amounts, both outputs untrimmed, local is fundee: val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 150_000_000 msat) @@ -851,6 +901,29 @@ class TransactionsSpec extends AnyFunSuite with Logging { assert(toLocal.amount == 150_000.sat) assert(toLocal.index == 0) } + { + // Their output is trimmed (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_000_000 msat) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(800 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.all.size == 1) + assert(closingTxs.localOnly_opt.nonEmpty) + val toLocal = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(toLocal.publicKeyScript == localPubKeyScript) + assert(toLocal.amount == 150_000.sat) + assert(toLocal.index == 0) + } + { + // Their OP_RETURN output is trimmed (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_000_000 msat) + val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(1_001 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.all.size == 1) + assert(closingTxs.localOnly_opt.nonEmpty) + val toLocal = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(toLocal.publicKeyScript == localPubKeyScript) + assert(toLocal.amount == 150_000.sat) + assert(toLocal.index == 0) + } { // Our output is trimmed: val spec = CommitmentSpec(Set.empty, feeratePerKw, 50_000 msat, 150_000_000 msat) @@ -858,6 +931,14 @@ class TransactionsSpec extends AnyFunSuite with Logging { assert(closingTx.tx.txOut.length == 1) assert(closingTx.toLocalOutput.isEmpty) } + { + // Our output is trimmed (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 1_000_000 msat, 150_000_000 msat) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(800 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.all.size == 1) + assert(closingTxs.remoteOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.flatMap(_.toLocalOutput).isEmpty) + } { // Both outputs are trimmed: val spec = CommitmentSpec(Set.empty, feeratePerKw, 50_000 msat, 10_000 msat) From df6ad3e8183dafeeee412b55f99d2ec8da921cd4 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 21 Oct 2024 10:37:41 +0200 Subject: [PATCH 5/9] Require strict exchange of `shutdown` Whenever one side sends `shutdown`, we restart a signing round from scratch. To be compatible with future taproot channels, we require the receiver to also send `shutdown` before moving on to exchanging `closing_complete` and `closing_sig`. This will give nodes a message to exchange fresh musig2 nonces before producing signatures. On reconnection, we also restart a signing session from scratch and discard pending partial signatures. --- .../fr/acinq/eclair/channel/ChannelData.scala | 37 +++- .../eclair/channel/ChannelExceptions.scala | 1 + .../fr/acinq/eclair/channel/Helpers.scala | 2 - .../fr/acinq/eclair/channel/fsm/Channel.scala | 201 +++++++++++------- .../eclair/channel/fsm/CommonHandlers.scala | 25 ++- .../eclair/channel/fsm/ErrorHandlers.scala | 4 +- .../channel/version4/ChannelCodecs4.scala | 19 +- .../scala/fr/acinq/eclair/TestDatabases.scala | 16 +- .../states/g/NegotiatingStateSpec.scala | 156 ++++++++++++-- .../channel/version4/ChannelCodecs4Spec.scala | 26 ++- 10 files changed, 374 insertions(+), 113 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index df1c0c696e..6b519059e7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningS import fr.acinq.eclair.io.Peer import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureReason, FundingCreated, FundingSigned, Init, LiquidityAds, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxInitRbf, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc} +import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingComplete, ClosingSig, ClosingSigned, CommitSig, FailureReason, FundingCreated, FundingSigned, Init, LiquidityAds, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxInitRbf, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc} import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, TimestampMilli, UInt64} import scodec.bits.ByteVector @@ -536,6 +536,38 @@ object SpliceStatus { case object SpliceAborted extends SpliceStatus } +case class ClosingCompleteSent(closingComplete: ClosingComplete, closingFeerate: FeeratePerKw) + +sealed trait OnRemoteShutdown +object OnRemoteShutdown { + /** When receiving the remote shutdown, we sign a new version of our closing transaction. */ + case class SignTransaction(closingFeerate: FeeratePerKw) extends OnRemoteShutdown + /** When receiving the remote shutdown, we don't sign a new version of our closing transaction, but our peer may sign theirs. */ + case object WaitForSigs extends OnRemoteShutdown +} + +sealed trait ClosingNegotiation { + def localShutdown: Shutdown + // When we disconnect, we discard pending signatures. + def disconnect(): ClosingNegotiation.WaitingForRemoteShutdown = this match { + case status: ClosingNegotiation.WaitingForRemoteShutdown => status + case status: ClosingNegotiation.SigningTransactions => status.closingCompleteSent_opt.map(_.closingFeerate) match { + // If we were waiting for their signature, we will send closing_complete again after exchanging shutdown. + case Some(closingFeerate) if status.closingSigReceived_opt.isEmpty => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) + case _ => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, OnRemoteShutdown.WaitForSigs) + } + case status: ClosingNegotiation.WaitingForConfirmation => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, OnRemoteShutdown.WaitForSigs) + } +} +object ClosingNegotiation { + /** We've sent a new shutdown message: we wait for their shutdown message before taking any action. */ + case class WaitingForRemoteShutdown(localShutdown: Shutdown, onRemoteShutdown: OnRemoteShutdown) extends ClosingNegotiation + /** We've exchanged shutdown messages: at least one side will send closing_complete to renew their closing transaction. */ + case class SigningTransactions(localShutdown: Shutdown, remoteShutdown: Shutdown, closingCompleteSent_opt: Option[ClosingCompleteSent], closingSigSent_opt: Option[ClosingSig], closingSigReceived_opt: Option[ClosingSig]) extends ClosingNegotiation + /** We've signed a new closing transaction and are waiting for confirmation or to initiate RBF. */ + case class WaitingForConfirmation(localShutdown: Shutdown, remoteShutdown: Shutdown) extends ClosingNegotiation +} + sealed trait ChannelData extends PossiblyHarmful { def channelId: ByteVector32 } @@ -655,12 +687,13 @@ final case class DATA_NEGOTIATING(commitments: Commitments, require(!commitments.params.localParams.paysClosingFees || closingTxProposed.forall(_.nonEmpty), "initiator must have at least one closing signature for every negotiation attempt because it initiates the closing") } final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments, - localShutdown: Shutdown, remoteShutdown: Shutdown, + status: ClosingNegotiation, // Closing transactions we created, where we pay the fees (unsigned). proposedClosingTxs: List[ClosingTxs], // Closing transactions we published: this contains our local transactions for // which they sent a signature, and their closing transactions that we signed. publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments { + val localScriptPubKey: ByteVector = status.localShutdown.scriptPubKey def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid)) } final case class DATA_CLOSING(commitments: Commitments, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index 916869f391..ad2ef0366b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -119,6 +119,7 @@ case class InvalidHtlcSignature (override val channelId: Byte case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed") case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output") case class InvalidCloseSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid close signature: txId=$txId") +case class UnexpectedClosingComplete (override val channelId: ByteVector32, fees: Satoshi, lockTime: Long) extends ChannelException(channelId, s"unexpected closing_complete with fees=$fees and lockTime=$lockTime: we already sent closing_sig, you must send shutdown first") case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: txId=$txId") case class CommitSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"commit sig count mismatch: expected=$expected actual=$actual") case class HtlcSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"htlc sig count mismatch: expected=$expected actual=$actual") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 6a4b8ec0d6..12b3b2cd90 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -712,8 +712,6 @@ object Helpers { /** We are the closer: we sign closing transactions for which we pay the fees. */ def makeSimpleClosingTx(currentBlockHeight: BlockHeight, keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw): Either[ChannelException, (ClosingTxs, ClosingComplete)] = { - require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid localScriptPubkey") - require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid remoteScriptPubkey") // We must convert the feerate to a fee: we must build dummy transactions to compute their weight. val closingFee = { val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 977063c6f3..a2ae4242f6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -736,15 +736,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)) { - val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) - MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, closingFeerate) match { - case Left(f) => - log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) - goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() sending sendList - case Right((closingTxs, closingComplete)) => - log.debug("signing local mutual close transactions: {}", closingTxs) - goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending sendList :+ closingComplete - } + startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, sendList) } 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) @@ -1524,15 +1516,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)) { - val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) - MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, closingFeerate) match { - case Left(f) => - log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) - goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() sending revocation - case Right((closingTxs, closingComplete)) => - log.debug("signing local mutual close transactions: {}", closingTxs) - goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending revocation :: closingComplete :: Nil - } + startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, revocation :: Nil) } 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) @@ -1576,15 +1560,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)) { - val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) - MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, closingFeerate) match { - case Left(f) => - log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) - goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() - case Right((closingTxs, closingComplete)) => - log.debug("signing local mutual close transactions: {}", closingTxs) - goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending closingComplete - } + startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, Nil) } 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) @@ -1605,11 +1581,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(shutdown: Shutdown, d: DATA_SHUTDOWN) => if (shutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) { log.debug("our peer updated their shutdown script (previous={}, current={})", d.remoteShutdown.scriptPubKey, shutdown.scriptPubKey) - stay() using d.copy(remoteShutdown = shutdown) storing() - } else { - // This is a retransmission of their previous shutdown, we can ignore it. - stay() } + stay() using d.copy(remoteShutdown = shutdown) storing() case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d) @@ -1754,38 +1727,111 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with when(NEGOTIATING_SIMPLE)(handleExceptions { case Event(remoteShutdown: Shutdown, d: DATA_NEGOTIATING_SIMPLE) => - // Our peer wants to create a new version of their closing transaction. - // We don't need to update our version of the closing transaction: we simply wait for their closing_complete. - stay() using d.copy(remoteShutdown = remoteShutdown) storing() + d.status match { + case status: ClosingNegotiation.WaitingForRemoteShutdown => + // We have already sent our shutdown. Now that we've received theirs, we're ready to sign closing transactions. + // If we don't have a closing feerate, we don't need to create a new version of our closing transaction (which + // can happen after a reconnection for example). + status.onRemoteShutdown match { + case OnRemoteShutdown.SignTransaction(closingFeerate) => + val localScript = status.localShutdown.scriptPubKey + val remoteScript = remoteShutdown.scriptPubKey + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, remoteScript, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, None, None, None) + stay() using d.copy(status = status1) + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, Some(ClosingCompleteSent(closingComplete, closingFeerate)), None, None) + stay() using d.copy(status = status1, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) storing() sending closingComplete + } + case OnRemoteShutdown.WaitForSigs => + val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, None, None, None) + stay() using d.copy(status = status1) + } + case status: ClosingNegotiation.SigningTransactions => + // We were in the middle of signing transactions: we restart a signing round from scratch. + // If we were waiting for their signature, we will send closing_complete again after exchanging shutdown. + val localShutdown = status.localShutdown + val onRemoteShutdown = status.closingCompleteSent_opt.map(_.closingFeerate) match { + case Some(closingFeerate) if status.closingSigReceived_opt.isEmpty => OnRemoteShutdown.SignTransaction(closingFeerate) + case _ => OnRemoteShutdown.WaitForSigs + } + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, onRemoteShutdown) + self ! remoteShutdown + stay() using d.copy(status = status1) sending localShutdown + case status: ClosingNegotiation.WaitingForConfirmation => + // Our peer wants to create a new version of their closing transaction. We don't need to update our version of + // the closing transaction: we re-send our previous shutdown and wait for their closing_complete. + val localShutdown = status.localShutdown + val status1 = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, None, None, None) + stay() using d.copy(status = status1) sending localShutdown + } case Event(closingComplete: ClosingComplete, d: DATA_NEGOTIATING_SIMPLE) => - MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, closingComplete) match { - case Left(f) => - // This may happen if scripts were updated concurrently, so we simply ignore failures. - // Bolt 2: - // - If the signature field is not valid for the corresponding closing transaction: - // - MUST ignore `closing_complete`. - log.warning("invalid closing_complete: {}", f.getMessage) + d.status match { + case _: ClosingNegotiation.WaitingForRemoteShutdown => + log.info("ignoring remote closing_complete, we've sent shutdown to initiate a new signing round") stay() - case Right((signedClosingTx, closingSig)) => - log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx) - val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) - stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig + case _: ClosingNegotiation.WaitingForConfirmation => + log.info("ignoring closing_complete, we've already sent closing_sig: peer must send shutdown again before closing_complete") + stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) + case status: ClosingNegotiation.SigningTransactions if status.closingSigSent_opt.nonEmpty => + log.info("ignoring closing_complete, we've already sent closing_sig: peer must send shutdown again before closing_complete") + stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) + case status: ClosingNegotiation.SigningTransactions => + val localScript = status.localShutdown.scriptPubKey + val remoteScript = status.remoteShutdown.scriptPubKey + MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, localScript, remoteScript, closingComplete) match { + case Left(f) => + // This may happen if scripts were updated concurrently, so we simply ignore failures. + log.warning("invalid closing_complete: {}", f.getMessage) + stay() + case Right((signedClosingTx, closingSig)) => + log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx) + val status1 = status.closingCompleteSent_opt match { + // We've sent closing_complete: we may be waiting for their closing_sig. + case Some(_) => status.closingSigReceived_opt match { + case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown) + case None => status.copy(closingSigSent_opt = Some(closingSig)) + } + // We haven't sent closing_complete: we're not waiting for their closing_sig'. + case None => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown) + } + val d1 = d.copy(status = status1, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig + } } case Event(closingSig: ClosingSig, d: DATA_NEGOTIATING_SIMPLE) => - MutualClose.receiveSimpleClosingSig(keyManager, d.commitments.latest, d.proposedClosingTxs.last, closingSig) match { - case Left(f) => - // This may happen if scripts were updated concurrently, so we simply ignore failures. - // Bolt 2: - // - If the signature field is not valid for the corresponding closing transaction: - // - MUST ignore `closing_sig`. - log.warning("invalid closing_sig: {}", f.getMessage) + d.status match { + case _: ClosingNegotiation.WaitingForRemoteShutdown => + log.info("ignoring remote closing_sig, we've sent shutdown to initiate a new signing round") + stay() + case _: ClosingNegotiation.WaitingForConfirmation => + log.info("ignoring closing_sig, we've already fully signed closing transactions") stay() - case Right(signedClosingTx) => - log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx) - val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) - stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = true) + case status: ClosingNegotiation.SigningTransactions if status.closingSigReceived_opt.nonEmpty => + log.info("ignoring closing_sig, we've already received it") + stay() + case status: ClosingNegotiation.SigningTransactions => + MutualClose.receiveSimpleClosingSig(keyManager, d.commitments.latest, d.proposedClosingTxs.last, closingSig) match { + case Left(f) => + // This may happen if scripts were updated concurrently, so we simply ignore failures. + log.warning("invalid closing_sig: {}", f.getMessage) + stay() + case Right(signedClosingTx) => + log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx) + val status1 = status.closingSigSent_opt match { + // We have already signed their transaction: both local and remote closing transactions have been updated. + case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown) + // We haven't sent closing_sig yet: they may send us closing_complete to update their closing transaction. + case None => status.copy(closingSigReceived_opt = Some(closingSig)) + } + val d1 = d.copy(status = status1, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = true) + } } case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty => @@ -1806,27 +1852,26 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with goto(CLOSED) using d storing() case Event(c: CMD_CLOSE, d: DATA_NEGOTIATING_SIMPLE) => - val localShutdown_opt = c.scriptPubKey match { - case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey => Some(Shutdown(d.channelId, scriptPubKey)) - case _ => None - } - if (localShutdown_opt.nonEmpty || c.feerates.nonEmpty) { - val localScript = localShutdown_opt.map(_.scriptPubKey).getOrElse(d.localShutdown.scriptPubKey) - val feerate = c.feerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) - MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, d.remoteShutdown.scriptPubKey, feerate) match { - case Left(f) => handleCommandError(f, c) - case Right((closingTxs, closingComplete)) => - log.info("new closing transaction created with script={} fees={}", localScript, closingComplete.fees) - log.debug("signing local mutual close transactions: {}", closingTxs) - val d1 = d.copy(localShutdown = localShutdown_opt.getOrElse(d.localShutdown), proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) - stay() using d1 storing() sending localShutdown_opt.toSeq :+ closingComplete - } - } else { - handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + val localShutdown = Shutdown(d.channelId, c.scriptPubKey.getOrElse(d.status.localShutdown.scriptPubKey)) + val closingFeerate = c.feerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + d.status match { + case _: ClosingNegotiation.WaitingForRemoteShutdown => + log.info("we're already waiting for our peer to send their shutdown message, no need to send ours again") + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + case _: ClosingNegotiation.SigningTransactions => + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) + stay() using d.copy(status = status1) storing() sending localShutdown + case _: ClosingNegotiation.WaitingForConfirmation => + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) + stay() using d.copy(status = status1) storing() sending localShutdown } case Event(e: Error, d: DATA_NEGOTIATING_SIMPLE) => handleRemoteError(e, d) + case Event(INPUT_DISCONNECTED, d: DATA_NEGOTIATING_SIMPLE) => + val status1 = d.status.disconnect() + goto(OFFLINE) using d.copy(status = status1) + }) when(CLOSING)(handleExceptions { @@ -2480,12 +2525,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(_: ChannelReestablish, d: DATA_NEGOTIATING_SIMPLE) => // We retransmit our shutdown: we may have updated our script and they may not have received it. - // We also sign a new round of closing transactions since network fees may have changed while we were offline. - val closingFeerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates) - Closing.MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, closingFeerate) match { - case Left(_) => goto(NEGOTIATING_SIMPLE) using d sending d.localShutdown - case Right((closingTxs, closingComplete)) => goto(NEGOTIATING_SIMPLE) using d.copy(proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) sending Seq(d.localShutdown, closingComplete) + val localShutdown = d.status.localShutdown + val status1 = d.status match { + case status: ClosingNegotiation.WaitingForRemoteShutdown => status.copy(localShutdown = localShutdown) + case _ => ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.WaitForSigs) } + goto(NEGOTIATING_SIMPLE) using d.copy(status = status1) sending localShutdown // This handler is a workaround for an issue in lnd: starting with versions 0.10 / 0.11, they sometimes fail to send // a channel_reestablish when reconnecting a channel that recently got confirmed, and instead send a channel_ready diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index a8458f4ec5..d22e0799ea 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -16,13 +16,14 @@ package fr.acinq.eclair.channel.fsm -import akka.actor.{ActorRef, FSM, Status} +import akka.actor.FSM import fr.acinq.bitcoin.scalacompat.{ByteVector32, Script} import fr.acinq.eclair.Features +import fr.acinq.eclair.channel.Helpers.Closing.MutualClose import fr.acinq.eclair.channel._ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer -import fr.acinq.eclair.wire.protocol.{HtlcSettlementMessage, LightningMessage, UpdateMessage} +import fr.acinq.eclair.wire.protocol.{HtlcSettlementMessage, LightningMessage, Shutdown, UpdateMessage} import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt @@ -106,7 +107,7 @@ trait CommonHandlers { case d: DATA_NORMAL if d.localShutdown.isDefined => d.localShutdown.get.scriptPubKey case d: DATA_SHUTDOWN => d.localShutdown.scriptPubKey case d: DATA_NEGOTIATING => d.localShutdown.scriptPubKey - case d: DATA_NEGOTIATING_SIMPLE => d.localShutdown.scriptPubKey + case d: DATA_NEGOTIATING_SIMPLE => d.localScriptPubKey case d: DATA_CLOSING => d.finalScriptPubKey case d => d.commitments.params.localParams.upfrontShutdownScript_opt match { @@ -131,4 +132,22 @@ trait CommonHandlers { finalScriptPubKey } + def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerates: Option[ClosingFeerates], toSend: List[LightningMessage]) = { + 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 { + 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) + val d = DATA_NEGOTIATING_SIMPLE(commitments, status, Nil, Nil) + goto(NEGOTIATING_SIMPLE) using d storing() sending toSend + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + val status = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, Some(ClosingCompleteSent(closingComplete, closingFeerate)), None, None) + val d = DATA_NEGOTIATING_SIMPLE(commitments, status, closingTxs :: Nil, Nil) + goto(NEGOTIATING_SIMPLE) using d storing() sending toSend :+ closingComplete + } + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala index a2ffe53180..d1bffd374e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala @@ -89,7 +89,7 @@ trait ErrorHandlers extends CommonHandlers { handleMutualClose(bestUnpublishedClosingTx, Left(negotiating)) case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty => // We have published at least one mutual close transaction, it's better to use it instead of our local commit. - val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localShutdown.scriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) + val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) goto(CLOSING) using closing storing() case dd: ChannelDataWithCommitments => // We publish our commitment even if we have nothing at stake: it's a nice thing to do because it lets our peer @@ -139,7 +139,7 @@ trait ErrorHandlers extends CommonHandlers { handleMutualClose(bestUnpublishedClosingTx, Left(negotiating)) case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty => // We have published at least one mutual close transaction, it's better to use it instead of our local commit. - val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localShutdown.scriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) + val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) goto(CLOSING) using closing storing() // NB: we publish the commitment even if we have nothing at stake (in a dataloss situation our peer will send us an error just for that) case hasCommitments: ChannelDataWithCommitments => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala index 8dbd2bb56f..083faf417e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala @@ -681,7 +681,7 @@ private[channel] object ChannelCodecs4 { ("remotePushAmount" | millisatoshi) :: ("status" | interactiveTxWaitingForSigsCodec) :: ("remoteChannelData_opt" | optional(bool8, varsizebinarydata))).as[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED] - + val DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED_02_Codec: Codec[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED] = ( ("commitments" | commitmentsCodecWithoutFirstRemoteCommitIndex) :: ("localPushAmount" | millisatoshi) :: @@ -759,10 +759,23 @@ private[channel] object ChannelCodecs4 { ("localOnly_opt" | optional(bool8, closingTxCodec)) :: ("remoteOnly_opt" | optional(bool8, closingTxCodec))).as[ClosingTxs] + private val onRemoteShutdownCodec: Codec[OnRemoteShutdown] = discriminated[OnRemoteShutdown].by(uint8) + .typecase(0x00, provide(OnRemoteShutdown.WaitForSigs)) + .typecase(0x01, feeratePerKw.as[OnRemoteShutdown.SignTransaction]) + + private val waitingForRemoteShutdownCodec: Codec[ClosingNegotiation.WaitingForRemoteShutdown] = ( + ("localShutdown" | lengthDelimited(shutdownCodec)) :: + ("onRemoteShutdown" | onRemoteShutdownCodec) + ).as[ClosingNegotiation.WaitingForRemoteShutdown] + + val closingNegotiationCodec: Codec[ClosingNegotiation] = discriminated[ClosingNegotiation].by(uint8) + .\(0x01) { case status: ClosingNegotiation.WaitingForRemoteShutdown => status }(waitingForRemoteShutdownCodec) + .\(0x02) { case status: ClosingNegotiation.SigningTransactions => status.disconnect() }(waitingForRemoteShutdownCodec) + .\(0x03) { case status: ClosingNegotiation.WaitingForConfirmation => status.disconnect() }(waitingForRemoteShutdownCodec) + val DATA_NEGOTIATING_SIMPLE_14_Codec: Codec[DATA_NEGOTIATING_SIMPLE] = ( ("commitments" | commitmentsCodec) :: - ("localShutdown" | lengthDelimited(shutdownCodec)) :: - ("remoteShutdown" | lengthDelimited(shutdownCodec)) :: + ("status" | closingNegotiationCodec) :: ("proposedClosingTxs" | listOfN(uint16, closingTxsCodec)) :: ("publishedClosingTxs" | listOfN(uint16, closingTxCodec))).as[DATA_NEGOTIATING_SIMPLE] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index f102cd3868..fb91428dca 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -79,18 +79,25 @@ object TestDatabases { case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = freeze2(d.commitments)) case d: DATA_NORMAL => d.copy(commitments = freeze2(d.commitments)) .modify(_.spliceStatus).using { - case s: SpliceStatus.SpliceWaitingForSigs => s - case _ => SpliceStatus.NoSplice - } + case s: SpliceStatus.SpliceWaitingForSigs => s + case _ => SpliceStatus.NoSplice + } case d: DATA_CLOSING => d.copy(commitments = freeze2(d.commitments)) case d: DATA_NEGOTIATING => d.copy(commitments = freeze2(d.commitments)) case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = freeze2(d.commitments)) case d: DATA_SHUTDOWN => d.copy(commitments = freeze2(d.commitments)) } + // When negotiating closing transactions with the option_simple_close feature, we discard pending signatures on + // disconnection and will restart a signing round on reconnection. + def freeze4(input: PersistentChannelData): PersistentChannelData = input match { + case d: DATA_NEGOTIATING_SIMPLE => freeze3(d.copy(status = d.status.disconnect())) + case d => freeze3(d) + } + super.addOrUpdateChannel(data) val check = super.getChannel(data.channelId) - val frozen = freeze3(data) + val frozen = freeze4(data) require(check.contains(frozen), s"serialization/deserialization check failed, $check != $frozen") } } @@ -133,6 +140,7 @@ object TestDatabases { } object TestPgDatabases { + import _root_.io.zonky.test.db.postgres.embedded.EmbeddedPostgres /** single instance */ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index 226767cd4b..174bb245b5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -23,14 +23,14 @@ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} +import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx, SetChannelId} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.testutils.PimpTestProbe._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat import fr.acinq.eclair.wire.protocol.ClosingSignedTlv.FeeRange -import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Shutdown, TlvStream, Warning} +import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelReestablish, ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Init, Shutdown, TlvStream, Warning} import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -586,25 +586,43 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike test("recv ClosingComplete (with concurrent shutdown)", Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ aliceClose(f) - val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] - bob2alice.expectMsgType[ClosingComplete] // ignored - // Bob updates his closing script before receiving Alice's closing_complete. + val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] // ignored + bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice) + alice2blockchain.expectMsgType[PublishFinalTx] + alice2blockchain.expectMsgType[WatchTxConfirmed] + val aliceClosingSig1 = alice2bob.expectMsgType[ClosingSig] // ignored + + // Bob updates his closing script before receiving Alice's closing_complete and closing_sig. val bobScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) bob ! CMD_CLOSE(TestProbe().ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) - val bobShutdown = bob2alice.expectMsgType[Shutdown] - assert(bobShutdown.scriptPubKey == bobScript) - val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + assert(bob2alice.expectMsgType[Shutdown].scriptPubKey == bobScript) + bob2alice.forward(alice) + + // Bob receives Alice's previous closing_complete and closing_sig and ignores them. alice2bob.forward(bob, aliceClosingComplete1) - bob2alice.expectNoMessage(100 millis) // Bob ignores Alice's obsolete closing_complete. - // When Alice receives Bob's shutdown, she doesn't change her own closing txs. - bob2alice.forward(alice, bobShutdown) - alice2bob.expectNoMessage(100 millis) - // When she receives Bob's new closing_complete, she signs it: Bob now has closing transactions with his last closing script. - bob2alice.forward(alice, bobClosingComplete) - val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] - alice2bob.forward(bob, aliceClosingSig) - alice2blockchain.expectMsgType[PublishFinalTx] - bob2blockchain.expectMsgType[PublishFinalTx] + alice2bob.forward(bob, aliceClosingSig1) + bob2alice.expectNoMessage(100 millis) + + // Alice re-sends shutdown in response to Bob's shutdown, at which point they sign transactions from scratch. + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice) + val bobTxId = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId) } test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f => @@ -692,6 +710,8 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike alice ! CMD_CLOSE(TestProbe().ref, Some(Script.write(Script.pay2wpkh(randomKey().publicKey))), None) alice2bob.expectMsgType[Shutdown] alice2bob.forward(bob) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) alice2bob.expectMsgType[ClosingComplete] alice2bob.forward(bob) bob2alice.expectMsgType[ClosingSig] @@ -795,6 +815,106 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike awaitCond(bob.stateName == CLOSING) } + test("receive INPUT_RECONNECTED", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId1 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice) + val bobTxId1 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId1) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + bob2alice.expectMsgType[ClosingSig] // ignored + + // A disconnection happens before Alice received Bob's closing_sig. + // On reconnection, she retries signing her closing transaction. + alice ! INPUT_DISCONNECTED + bob ! INPUT_DISCONNECTED + awaitCond(alice.stateName == OFFLINE) + awaitCond(bob.stateName == OFFLINE) + val aliceInit = Init(TestConstants.Alice.nodeParams.features.initFeatures()) + val bobInit = Init(TestConstants.Bob.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) + alice2bob.expectMsgType[ChannelReestablish] + alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReestablish] + bob2alice.forward(alice) + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + bob2alice.expectNoMessage(100 millis) + } + + test("receive INPUT_RESTORED", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId1 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice) + val bobTxId1 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId1) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + bob2alice.expectMsgType[ClosingSig] // ignored + val aliceData = alice.underlyingActor.nodeParams.db.channels.getChannel(channelId(alice)).get + val bobData = bob.underlyingActor.nodeParams.db.channels.getChannel(channelId(bob)).get + + // Alice restarts before receiving Bob's closing_sig. + // On reconnection, she retries signing her closing transaction. + // simulate another node restart + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(aliceData) + alice2blockchain.expectMsgType[SetChannelId] + alice2blockchain.expectMsgType[WatchFundingSpent] + bob.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + bob ! INPUT_RESTORED(bobData) + bob2blockchain.expectMsgType[SetChannelId] + bob2blockchain.expectMsgType[WatchFundingSpent] + awaitCond(alice.stateName == OFFLINE && bob.stateName == OFFLINE) + val aliceInit = Init(TestConstants.Alice.nodeParams.features.initFeatures()) + val bobInit = Init(TestConstants.Bob.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) + alice2bob.expectMsgType[ChannelReestablish] + alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReestablish] + bob2alice.forward(alice) + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + bob2alice.expectNoMessage(100 millis) + } + test("recv Error") { f => import f._ bobClose(f) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala index ca67359422..28a772cf03 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala @@ -17,7 +17,7 @@ import fr.acinq.eclair.transactions.{CommitmentSpec, Scripts} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec.normal import fr.acinq.eclair.wire.internal.channel.version4.ChannelCodecs4.Codecs._ import fr.acinq.eclair.wire.internal.channel.version4.ChannelCodecs4.channelDataCodec -import fr.acinq.eclair.wire.protocol.{LiquidityAds, TxSignatures} +import fr.acinq.eclair.wire.protocol.{ClosingComplete, ClosingSig, LiquidityAds, Shutdown, TxSignatures} import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, UInt64, randomBytes32, randomKey} import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ @@ -230,4 +230,28 @@ class ChannelCodecs4Spec extends AnyFunSuite { assert(originCodec.decode(trampolineRelayedBin.bits).require.value == trampolineRelayed) } + test("encode/decode closing negotiation status") { + val channelId = randomBytes32() + val localShutdown = Shutdown(channelId, Script.write(Script.pay2wpkh(randomKey().publicKey))) + val remoteShutdown = Shutdown(channelId, Script.write(Script.pay2wpkh(randomKey().publicKey))) + val waitingForRemoteShutdown = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.WaitForSigs) + val closingFeerate = FeeratePerKw(5000 sat) + val waitingForRemoteShutdownWithFeerate = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) + val closingCompleteSent = ClosingCompleteSent(ClosingComplete(channelId, 1500 sat, 0), closingFeerate) + val closingSigReceived = ClosingSig(channelId) + val testCases = Map( + waitingForRemoteShutdown -> waitingForRemoteShutdown, + waitingForRemoteShutdownWithFeerate -> waitingForRemoteShutdownWithFeerate, + ClosingNegotiation.WaitingForConfirmation(localShutdown, remoteShutdown) -> waitingForRemoteShutdown, + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, None, None, None) -> waitingForRemoteShutdown, + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, Some(closingCompleteSent), None, None) -> waitingForRemoteShutdownWithFeerate, + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, Some(closingCompleteSent), None, Some(closingSigReceived)) -> waitingForRemoteShutdown, + ) + testCases.foreach { case (status, expected) => + val encoded = closingNegotiationCodec.encode(status).require + val decoded = closingNegotiationCodec.decode(encoded).require.value + assert(decoded == expected) + } + } + } From c45cf33cc3b539cbbdd1893a6f2c7c61c4f24e0c Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 9 Dec 2024 11:56:56 +0100 Subject: [PATCH 6/9] Require strict re-signing before `shutdown` update Whenever we exchange `shutdown`, we now require that new closing txs are signed before allowing another `shutdown` message to be sent to start a new signing round. This creates more risk of deadlock when one side fails to send their sigs, where we'll need to disconnect to start a new signing round. But that shouldn't happen if nodes are honest and not buggy, so it probably doesn't matter. If nodes are buggy or malicious, we will need to force-close anyway. --- .../fr/acinq/eclair/channel/ChannelData.scala | 32 ++----- .../eclair/channel/ChannelExceptions.scala | 1 + .../fr/acinq/eclair/channel/fsm/Channel.scala | 88 ++++++++----------- .../eclair/channel/fsm/CommonHandlers.scala | 4 +- .../channel/version4/ChannelCodecs4.scala | 10 +-- .../scala/fr/acinq/eclair/TestDatabases.scala | 2 +- .../states/g/NegotiatingStateSpec.scala | 74 ++++++++++------ .../channel/version4/ChannelCodecs4Spec.scala | 22 +++-- 8 files changed, 111 insertions(+), 122 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 6b519059e7..ffd4b13c68 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -536,36 +536,18 @@ object SpliceStatus { case object SpliceAborted extends SpliceStatus } -case class ClosingCompleteSent(closingComplete: ClosingComplete, closingFeerate: FeeratePerKw) - -sealed trait OnRemoteShutdown -object OnRemoteShutdown { - /** When receiving the remote shutdown, we sign a new version of our closing transaction. */ - case class SignTransaction(closingFeerate: FeeratePerKw) extends OnRemoteShutdown - /** When receiving the remote shutdown, we don't sign a new version of our closing transaction, but our peer may sign theirs. */ - case object WaitForSigs extends OnRemoteShutdown -} - sealed trait ClosingNegotiation { def localShutdown: Shutdown - // When we disconnect, we discard pending signatures. - def disconnect(): ClosingNegotiation.WaitingForRemoteShutdown = this match { - case status: ClosingNegotiation.WaitingForRemoteShutdown => status - case status: ClosingNegotiation.SigningTransactions => status.closingCompleteSent_opt.map(_.closingFeerate) match { - // If we were waiting for their signature, we will send closing_complete again after exchanging shutdown. - case Some(closingFeerate) if status.closingSigReceived_opt.isEmpty => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) - case _ => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, OnRemoteShutdown.WaitForSigs) - } - case status: ClosingNegotiation.WaitingForConfirmation => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, OnRemoteShutdown.WaitForSigs) - } + /** Closing feerate for our closing transaction. */ + def closingFeerate: FeeratePerKw } object ClosingNegotiation { /** We've sent a new shutdown message: we wait for their shutdown message before taking any action. */ - case class WaitingForRemoteShutdown(localShutdown: Shutdown, onRemoteShutdown: OnRemoteShutdown) extends ClosingNegotiation - /** We've exchanged shutdown messages: at least one side will send closing_complete to renew their closing transaction. */ - case class SigningTransactions(localShutdown: Shutdown, remoteShutdown: Shutdown, closingCompleteSent_opt: Option[ClosingCompleteSent], closingSigSent_opt: Option[ClosingSig], closingSigReceived_opt: Option[ClosingSig]) extends ClosingNegotiation - /** We've signed a new closing transaction and are waiting for confirmation or to initiate RBF. */ - case class WaitingForConfirmation(localShutdown: Shutdown, remoteShutdown: Shutdown) extends ClosingNegotiation + case class WaitingForRemoteShutdown(localShutdown: Shutdown, closingFeerate: FeeratePerKw) extends ClosingNegotiation + /** We've exchanged shutdown messages: we both send closing_complete to renew the closing transactions. */ + case class SigningTransactions(localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerate: FeeratePerKw, closingCompleteSent_opt: Option[ClosingComplete], closingSigSent_opt: Option[ClosingSig], closingSigReceived_opt: Option[ClosingSig]) extends ClosingNegotiation + /** We've signed new closing transactions and are waiting for confirmation or to initiate RBF. */ + case class WaitingForConfirmation(localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerate: FeeratePerKw) extends ClosingNegotiation } sealed trait ChannelData extends PossiblyHarmful { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index ad2ef0366b..12dddeb955 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -117,6 +117,7 @@ case class InvalidAnnouncementSignatures (override val channelId: Byte case class InvalidCommitmentSignature (override val channelId: ByteVector32, fundingTxId: TxId, fundingTxIndex: Long, unsignedCommitTx: Transaction) extends ChannelException(channelId, s"invalid commitment signature: fundingTxId=$fundingTxId fundingTxIndex=$fundingTxIndex commitTxId=${unsignedCommitTx.txid} commitTx=$unsignedCommitTx") case class InvalidHtlcSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid htlc signature: txId=$txId") case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed") +case class ShutdownWaitingForSigs (override val channelId: ByteVector32) extends ChannelException(channelId, "received unexpected shutdown while signing closing transactions") case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output") case class InvalidCloseSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid close signature: txId=$txId") case class UnexpectedClosingComplete (override val channelId: ByteVector32, fees: Satoshi, lockTime: Long) extends ChannelException(channelId, s"unexpected closing_complete with fees=$fees and lockTime=$lockTime: we already sent closing_sig, you must send shutdown first") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index a2ae4242f6..fe7ac214d0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1727,77 +1727,69 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with when(NEGOTIATING_SIMPLE)(handleExceptions { case Event(remoteShutdown: Shutdown, d: DATA_NEGOTIATING_SIMPLE) => + val localScript = d.status.localShutdown.scriptPubKey + val remoteScript = remoteShutdown.scriptPubKey d.status match { case status: ClosingNegotiation.WaitingForRemoteShutdown => // We have already sent our shutdown. Now that we've received theirs, we're ready to sign closing transactions. - // If we don't have a closing feerate, we don't need to create a new version of our closing transaction (which - // can happen after a reconnection for example). - status.onRemoteShutdown match { - case OnRemoteShutdown.SignTransaction(closingFeerate) => - val localScript = status.localShutdown.scriptPubKey - val remoteScript = remoteShutdown.scriptPubKey - MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, remoteScript, closingFeerate) match { - case Left(f) => - log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) - val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, None, None, None) - stay() using d.copy(status = status1) - case Right((closingTxs, closingComplete)) => - log.debug("signing local mutual close transactions: {}", closingTxs) - val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, Some(ClosingCompleteSent(closingComplete, closingFeerate)), None, None) - stay() using d.copy(status = status1, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) storing() sending closingComplete - } - case OnRemoteShutdown.WaitForSigs => - val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, None, None, None) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, remoteScript, status.closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, status.closingFeerate, None, None, None) stay() using d.copy(status = status1) + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, status.closingFeerate, Some(closingComplete), None, None) + stay() using d.copy(status = status1, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) storing() sending closingComplete } - case status: ClosingNegotiation.SigningTransactions => - // We were in the middle of signing transactions: we restart a signing round from scratch. - // If we were waiting for their signature, we will send closing_complete again after exchanging shutdown. - val localShutdown = status.localShutdown - val onRemoteShutdown = status.closingCompleteSent_opt.map(_.closingFeerate) match { - case Some(closingFeerate) if status.closingSigReceived_opt.isEmpty => OnRemoteShutdown.SignTransaction(closingFeerate) - case _ => OnRemoteShutdown.WaitForSigs - } - val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, onRemoteShutdown) - self ! remoteShutdown - stay() using d.copy(status = status1) sending localShutdown + case _: ClosingNegotiation.SigningTransactions => + // We were in the middle of signing transactions: sending shutdown is forbidden at that point. + context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) + stay() sending Warning(d.channelId, ShutdownWaitingForSigs(d.channelId).getMessage) case status: ClosingNegotiation.WaitingForConfirmation => // Our peer wants to create a new version of their closing transaction. We don't need to update our version of - // the closing transaction: we re-send our previous shutdown and wait for their closing_complete. + // the closing transaction: we use the same parameters as we did in the previous signing round. val localShutdown = status.localShutdown - val status1 = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, None, None, None) - stay() using d.copy(status = status1) sending localShutdown + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, remoteScript, status.closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + val status1 = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, status.closingFeerate, None, None, None) + stay() using d.copy(status = status1) sending localShutdown + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + val status1 = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, status.closingFeerate, Some(closingComplete), None, None) + stay() using d.copy(status = status1, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) storing() sending Seq(localShutdown, closingComplete) + } } case Event(closingComplete: ClosingComplete, d: DATA_NEGOTIATING_SIMPLE) => d.status match { case _: ClosingNegotiation.WaitingForRemoteShutdown => log.info("ignoring remote closing_complete, we've sent shutdown to initiate a new signing round") - stay() + stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) case _: ClosingNegotiation.WaitingForConfirmation => log.info("ignoring closing_complete, we've already sent closing_sig: peer must send shutdown again before closing_complete") stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) case status: ClosingNegotiation.SigningTransactions if status.closingSigSent_opt.nonEmpty => - log.info("ignoring closing_complete, we've already sent closing_sig: peer must send shutdown again before closing_complete") + log.info("ignoring closing_complete, we've already sent closing_sig: peer must send closing_sig, then shutdown again before closing_complete") stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) case status: ClosingNegotiation.SigningTransactions => val localScript = status.localShutdown.scriptPubKey val remoteScript = status.remoteShutdown.scriptPubKey MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, localScript, remoteScript, closingComplete) match { case Left(f) => - // This may happen if scripts were updated concurrently, so we simply ignore failures. log.warning("invalid closing_complete: {}", f.getMessage) - stay() + stay() sending Warning(d.channelId, f.getMessage) case Right((signedClosingTx, closingSig)) => log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx) val status1 = status.closingCompleteSent_opt match { // We've sent closing_complete: we may be waiting for their closing_sig. case Some(_) => status.closingSigReceived_opt match { - case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown) + case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown, status.closingFeerate) case None => status.copy(closingSigSent_opt = Some(closingSig)) } - // We haven't sent closing_complete: we're not waiting for their closing_sig'. - case None => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown) + // We haven't sent closing_complete: we're not waiting for their closing_sig. + case None => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown, status.closingFeerate) } val d1 = d.copy(status = status1, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig @@ -1818,14 +1810,13 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case status: ClosingNegotiation.SigningTransactions => MutualClose.receiveSimpleClosingSig(keyManager, d.commitments.latest, d.proposedClosingTxs.last, closingSig) match { case Left(f) => - // This may happen if scripts were updated concurrently, so we simply ignore failures. log.warning("invalid closing_sig: {}", f.getMessage) - stay() + stay() sending Warning(d.channelId, f.getMessage) case Right(signedClosingTx) => log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx) val status1 = status.closingSigSent_opt match { // We have already signed their transaction: both local and remote closing transactions have been updated. - case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown) + case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown, status.closingFeerate) // We haven't sent closing_sig yet: they may send us closing_complete to update their closing transaction. case None => status.copy(closingSigReceived_opt = Some(closingSig)) } @@ -1859,17 +1850,17 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.info("we're already waiting for our peer to send their shutdown message, no need to send ours again") handleCommandError(ClosingAlreadyInProgress(d.channelId), c) case _: ClosingNegotiation.SigningTransactions => - val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) - stay() using d.copy(status = status1) storing() sending localShutdown + log.info("we're in the middle of signing closing transactions, we should finish this round before starting a new signing session") + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) case _: ClosingNegotiation.WaitingForConfirmation => - val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, closingFeerate) stay() using d.copy(status = status1) storing() sending localShutdown } case Event(e: Error, d: DATA_NEGOTIATING_SIMPLE) => handleRemoteError(e, d) case Event(INPUT_DISCONNECTED, d: DATA_NEGOTIATING_SIMPLE) => - val status1 = d.status.disconnect() + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(d.status.localShutdown, d.status.closingFeerate) goto(OFFLINE) using d.copy(status = status1) }) @@ -2526,10 +2517,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(_: ChannelReestablish, d: DATA_NEGOTIATING_SIMPLE) => // We retransmit our shutdown: we may have updated our script and they may not have received it. val localShutdown = d.status.localShutdown - val status1 = d.status match { - case status: ClosingNegotiation.WaitingForRemoteShutdown => status.copy(localShutdown = localShutdown) - case _ => ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.WaitForSigs) - } + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, d.status.closingFeerate) goto(NEGOTIATING_SIMPLE) using d.copy(status = status1) sending localShutdown // This handler is a workaround for an issue in lnd: starting with versions 0.10 / 0.11, they sometimes fail to send diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index d22e0799ea..ab883425dd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -139,12 +139,12 @@ trait CommonHandlers { MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, commitments.latest, localScript, remoteScript, closingFeerate) 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) + val status = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, None, None, None) val d = DATA_NEGOTIATING_SIMPLE(commitments, status, Nil, Nil) goto(NEGOTIATING_SIMPLE) using d storing() sending toSend case Right((closingTxs, closingComplete)) => log.debug("signing local mutual close transactions: {}", closingTxs) - val status = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, Some(ClosingCompleteSent(closingComplete, closingFeerate)), None, None) + val status = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, Some(closingComplete), None, None) val d = DATA_NEGOTIATING_SIMPLE(commitments, status, closingTxs :: Nil, Nil) goto(NEGOTIATING_SIMPLE) using d storing() sending toSend :+ closingComplete } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala index 083faf417e..53ee59a77b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala @@ -758,20 +758,16 @@ private[channel] object ChannelCodecs4 { ("localAndRemote_opt" | optional(bool8, closingTxCodec)) :: ("localOnly_opt" | optional(bool8, closingTxCodec)) :: ("remoteOnly_opt" | optional(bool8, closingTxCodec))).as[ClosingTxs] - - private val onRemoteShutdownCodec: Codec[OnRemoteShutdown] = discriminated[OnRemoteShutdown].by(uint8) - .typecase(0x00, provide(OnRemoteShutdown.WaitForSigs)) - .typecase(0x01, feeratePerKw.as[OnRemoteShutdown.SignTransaction]) private val waitingForRemoteShutdownCodec: Codec[ClosingNegotiation.WaitingForRemoteShutdown] = ( ("localShutdown" | lengthDelimited(shutdownCodec)) :: - ("onRemoteShutdown" | onRemoteShutdownCodec) + ("closingFeerate" | feeratePerKw) ).as[ClosingNegotiation.WaitingForRemoteShutdown] val closingNegotiationCodec: Codec[ClosingNegotiation] = discriminated[ClosingNegotiation].by(uint8) .\(0x01) { case status: ClosingNegotiation.WaitingForRemoteShutdown => status }(waitingForRemoteShutdownCodec) - .\(0x02) { case status: ClosingNegotiation.SigningTransactions => status.disconnect() }(waitingForRemoteShutdownCodec) - .\(0x03) { case status: ClosingNegotiation.WaitingForConfirmation => status.disconnect() }(waitingForRemoteShutdownCodec) + .\(0x02) { case status: ClosingNegotiation.SigningTransactions => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, status.closingFeerate) }(waitingForRemoteShutdownCodec) + .\(0x03) { case status: ClosingNegotiation.WaitingForConfirmation => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, status.closingFeerate) }(waitingForRemoteShutdownCodec) val DATA_NEGOTIATING_SIMPLE_14_Codec: Codec[DATA_NEGOTIATING_SIMPLE] = ( ("commitments" | commitmentsCodec) :: diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index fb91428dca..3aacd874fe 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -91,7 +91,7 @@ object TestDatabases { // When negotiating closing transactions with the option_simple_close feature, we discard pending signatures on // disconnection and will restart a signing round on reconnection. def freeze4(input: PersistentChannelData): PersistentChannelData = input match { - case d: DATA_NEGOTIATING_SIMPLE => freeze3(d.copy(status = d.status.disconnect())) + case d: DATA_NEGOTIATING_SIMPLE => freeze3(d.copy(status = ClosingNegotiation.WaitingForRemoteShutdown(d.status.localShutdown, d.status.closingFeerate))) case d => freeze3(d) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index 174bb245b5..099382197f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx, SetChannelId} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.io.Peer import fr.acinq.eclair.testutils.PimpTestProbe._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat @@ -575,10 +576,12 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike alice2bob.forward(bob, aliceClosingComplete.copy(tlvStream = TlvStream(ClosingTlv.CloserNoClosee(aliceClosingComplete.closerNoCloseeSig_opt.get)))) // Bob expects to receive a signature for a closing transaction containing his output, so he ignores Alice's // closing_complete instead of sending back his closing_sig. + bob2alice.expectMsgType[Warning] bob2alice.expectNoMessage(100 millis) bob2alice.forward(alice, bobClosingComplete) val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] alice2bob.forward(bob, aliceClosingSig.copy(tlvStream = TlvStream(ClosingTlv.NoCloserClosee(aliceClosingSig.closerAndCloseeSig_opt.get)))) + bob2alice.expectMsgType[Warning] bob2alice.expectNoMessage(100 millis) bob2blockchain.expectNoMessage(100 millis) } @@ -586,43 +589,61 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike test("recv ClosingComplete (with concurrent shutdown)", Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ aliceClose(f) - val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] // ignored + val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] bob2alice.expectMsgType[ClosingComplete] bob2alice.forward(alice) - alice2blockchain.expectMsgType[PublishFinalTx] - alice2blockchain.expectMsgType[WatchTxConfirmed] - val aliceClosingSig1 = alice2bob.expectMsgType[ClosingSig] // ignored + val bobTxId1 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId1) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) - // Bob updates his closing script before receiving Alice's closing_complete and closing_sig. + // Bob cannot send shutdown while signing is in progress. val bobScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) - bob ! CMD_CLOSE(TestProbe().ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) - assert(bob2alice.expectMsgType[Shutdown].scriptPubKey == bobScript) - bob2alice.forward(alice) + val probe = TestProbe() + bob ! CMD_CLOSE(probe.ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) + probe.expectMsgType[RES_FAILURE[CMD_CLOSE, ClosingAlreadyInProgress]] - // Bob receives Alice's previous closing_complete and closing_sig and ignores them. + // After sending closing_sig to Alice, Bob can update his closing script. alice2bob.forward(bob, aliceClosingComplete1) - alice2bob.forward(bob, aliceClosingSig1) - bob2alice.expectNoMessage(100 millis) + val bobClosingSig1 = bob2alice.expectMsgType[ClosingSig] + val aliceTxId1 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob ! CMD_CLOSE(probe.ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) + val bobShutdown2 = bob2alice.expectMsgType[Shutdown] + assert(bobShutdown2.scriptPubKey == bobScript) + + // If Bob sends shutdown without sending closing_sig first, Alice will ignore it. + bob2alice.forward(alice, bobShutdown2) + alice2bob.expectMsgType[Warning] + alice2bob.expectNoMessage(100 millis) + + // After sending closing_sig, Bob can send his second shutdown. + bob2alice.forward(alice, bobClosingSig1) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId1) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob2alice.forward(alice, bobShutdown2) // Alice re-sends shutdown in response to Bob's shutdown, at which point they sign transactions from scratch. alice2bob.expectMsgType[Shutdown] alice2bob.forward(bob) bob2alice.expectMsgType[ClosingComplete] bob2alice.forward(alice) - val bobTxId = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId) + val bobTxId2 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId2) alice2bob.expectMsgType[ClosingComplete] alice2bob.forward(bob) - val aliceTxId = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId) + val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) alice2bob.expectMsgType[ClosingSig] alice2bob.forward(bob) - assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId) - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId2) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId2) bob2alice.expectMsgType[ClosingSig] bob2alice.forward(alice) - assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) } test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f => @@ -714,8 +735,9 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2alice.forward(alice) alice2bob.expectMsgType[ClosingComplete] alice2bob.forward(bob) - bob2alice.expectMsgType[ClosingSig] - bob2alice.forward(alice) + bob2alice.expectMsgType[ClosingComplete] // ignored + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, bobClosingSig) val aliceTx2 = alice2blockchain.expectMsgType[PublishFinalTx].tx alice2blockchain.expectWatchTxConfirmed(aliceTx2.txid) assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx2.txid) @@ -852,10 +874,11 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2alice.forward(alice) alice2bob.expectMsgType[ClosingComplete] alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] // ignored val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) - bob2alice.expectMsgType[ClosingSig] - bob2alice.forward(alice) + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, bobClosingSig) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) bob2alice.expectNoMessage(100 millis) @@ -906,10 +929,11 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2alice.forward(alice) alice2bob.expectMsgType[ClosingComplete] alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] // ignored val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) - bob2alice.expectMsgType[ClosingSig] - bob2alice.forward(alice) + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, bobClosingSig) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) bob2alice.expectNoMessage(100 millis) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala index 28a772cf03..6e9007fe02 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala @@ -234,23 +234,21 @@ class ChannelCodecs4Spec extends AnyFunSuite { val channelId = randomBytes32() val localShutdown = Shutdown(channelId, Script.write(Script.pay2wpkh(randomKey().publicKey))) val remoteShutdown = Shutdown(channelId, Script.write(Script.pay2wpkh(randomKey().publicKey))) - val waitingForRemoteShutdown = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.WaitForSigs) val closingFeerate = FeeratePerKw(5000 sat) - val waitingForRemoteShutdownWithFeerate = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, OnRemoteShutdown.SignTransaction(closingFeerate)) - val closingCompleteSent = ClosingCompleteSent(ClosingComplete(channelId, 1500 sat, 0), closingFeerate) + val waitingForRemoteShutdown = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, closingFeerate) + val closingComplete = ClosingComplete(channelId, 1500 sat, 0) val closingSigReceived = ClosingSig(channelId) - val testCases = Map( - waitingForRemoteShutdown -> waitingForRemoteShutdown, - waitingForRemoteShutdownWithFeerate -> waitingForRemoteShutdownWithFeerate, - ClosingNegotiation.WaitingForConfirmation(localShutdown, remoteShutdown) -> waitingForRemoteShutdown, - ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, None, None, None) -> waitingForRemoteShutdown, - ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, Some(closingCompleteSent), None, None) -> waitingForRemoteShutdownWithFeerate, - ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, Some(closingCompleteSent), None, Some(closingSigReceived)) -> waitingForRemoteShutdown, + val testCases = Seq( + waitingForRemoteShutdown, + ClosingNegotiation.WaitingForConfirmation(localShutdown, remoteShutdown, closingFeerate), + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, None, None, None), + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, Some(closingComplete), None, None), + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, Some(closingComplete), None, Some(closingSigReceived)), ) - testCases.foreach { case (status, expected) => + testCases.foreach { status => val encoded = closingNegotiationCodec.encode(status).require val decoded = closingNegotiationCodec.decode(encoded).require.value - assert(decoded == expected) + assert(decoded == waitingForRemoteShutdown) } } From 56a1c94ee41c194626acdfb4fdb003fc58d80fa5 Mon Sep 17 00:00:00 2001 From: sstone Date: Tue, 19 Mar 2024 11:36:24 +0100 Subject: [PATCH 7/9] Refactor tx signing (no functional changes) --- .../fr/acinq/eclair/channel/Commitments.scala | 4 +- .../keymanager/LocalChannelKeyManager.scala | 6 +- .../eclair/transactions/Transactions.scala | 27 +++--- .../eclair/transactions/TestVectorsSpec.scala | 12 +-- .../transactions/TransactionsSpec.scala | 86 +++++++++---------- .../internal/channel/ChannelCodecsSpec.scala | 2 +- 6 files changed, 72 insertions(+), 65 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index a491ee7d69..d4f2d478a4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -227,7 +227,7 @@ object LocalCommit { fundingTxIndex: Long, remoteFundingPubKey: PublicKey, commitInput: InputInfo, commit: CommitSig, localCommitIndex: Long, spec: CommitmentSpec, localPerCommitmentPoint: PublicKey): Either[ChannelException, LocalCommit] = { val (localCommitTx, htlcTxs) = Commitment.makeLocalTxs(keyManager, params.channelConfig, params.channelFeatures, localCommitIndex, params.localParams, params.remoteParams, fundingTxIndex, remoteFundingPubKey, commitInput, localPerCommitmentPoint, spec) - if (!checkSig(localCommitTx, commit.signature, remoteFundingPubKey, TxOwner.Remote, params.commitmentFormat)) { + if (!localCommitTx.checkSig(commit.signature, remoteFundingPubKey, TxOwner.Remote, params.commitmentFormat)) { return Left(InvalidCommitmentSignature(params.channelId, fundingTxId, fundingTxIndex, localCommitTx.tx)) } val sortedHtlcTxs = htlcTxs.sortBy(_.input.outPoint.index) @@ -237,7 +237,7 @@ object LocalCommit { val remoteHtlcPubkey = Generators.derivePubKey(params.remoteParams.htlcBasepoint, localPerCommitmentPoint) val htlcTxsAndRemoteSigs = sortedHtlcTxs.zip(commit.htlcSignatures).toList.map { case (htlcTx: HtlcTx, remoteSig) => - if (!checkSig(htlcTx, remoteSig, remoteHtlcPubkey, TxOwner.Remote, params.commitmentFormat)) { + if (!htlcTx.checkSig(remoteSig, remoteHtlcPubkey, TxOwner.Remote, params.commitmentFormat)) { return Left(InvalidHtlcSignature(params.channelId, htlcTx.tx.txid)) } HtlcTxAndRemoteSig(htlcTx, remoteSig) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala index 75ab2736ac..41c2693c75 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala @@ -113,7 +113,7 @@ class LocalChannelKeyManager(seed: ByteVector, chainHash: BlockHash) extends Cha Metrics.SignTxCount.withTags(tags).increment() KamonExt.time(Metrics.SignTxDuration.withTags(tags)) { val privateKey = privateKeys.get(publicKey.path) - Transactions.sign(tx, privateKey.privateKey, txOwner, commitmentFormat) + tx.sign(privateKey.privateKey, txOwner, commitmentFormat) } } @@ -134,7 +134,7 @@ class LocalChannelKeyManager(seed: ByteVector, chainHash: BlockHash) extends Cha KamonExt.time(Metrics.SignTxDuration.withTags(tags)) { val privateKey = privateKeys.get(publicKey.path) val currentKey = Generators.derivePrivKey(privateKey.privateKey, remotePoint) - Transactions.sign(tx, currentKey, txOwner, commitmentFormat) + tx.sign(currentKey, txOwner, commitmentFormat) } } @@ -154,7 +154,7 @@ class LocalChannelKeyManager(seed: ByteVector, chainHash: BlockHash) extends Cha KamonExt.time(Metrics.SignTxDuration.withTags(tags)) { val privateKey = privateKeys.get(publicKey.path) val currentKey = Generators.revocationPrivKey(privateKey.privateKey, remoteSecret) - Transactions.sign(tx, currentKey, txOwner, commitmentFormat) + tx.sign(currentKey, txOwner, commitmentFormat) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index c96bb9d5e9..53e0c5fb8d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -118,7 +118,23 @@ object Transactions { } /** Sighash flags to use when signing the transaction. */ def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = SIGHASH_ALL + + def sign(key: PrivateKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): ByteVector64 = Transactions.sign(this, key, sighash(txOwner, commitmentFormat)) + + def sign(key: PrivateKey, sighashType: Int): ByteVector64 = { + // NB: the tx may have multiple inputs, we will only sign the one provided in txinfo.input. Bear in mind that the + // signature will be invalidated if other inputs are added *afterwards* and sighashType was SIGHASH_ALL. + val inputIndex = tx.txIn.zipWithIndex.find(_._1.outPoint == input.outPoint).get._2 + Transactions.sign(tx, input.redeemScript, input.txOut.amount, key, sighashType, inputIndex) + } + + def checkSig(sig: ByteVector64, pubKey: PublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): Boolean = { + val sighash = this.sighash(txOwner, commitmentFormat) + val data = Transaction.hashForSigning(tx, inputIndex = 0, input.redeemScript, sighash, input.txOut.amount, SIGVERSION_WITNESS_V0) + Crypto.verifySignature(data, sig, pubKey) + } } + sealed trait ReplaceableTransactionWithInputInfo extends TransactionWithInputInfo { /** Block before which the transaction must be confirmed. */ def confirmationTarget: ConfirmationTarget @@ -944,15 +960,13 @@ object Transactions { sig64 } - def sign(txinfo: TransactionWithInputInfo, key: PrivateKey, sighashType: Int): ByteVector64 = { + private def sign(txinfo: TransactionWithInputInfo, key: PrivateKey, sighashType: Int): ByteVector64 = { // NB: the tx may have multiple inputs, we will only sign the one provided in txinfo.input. Bear in mind that the // signature will be invalidated if other inputs are added *afterwards* and sighashType was SIGHASH_ALL. val inputIndex = txinfo.tx.txIn.zipWithIndex.find(_._1.outPoint == txinfo.input.outPoint).get._2 sign(txinfo.tx, txinfo.input.redeemScript, txinfo.input.txOut.amount, key, sighashType, inputIndex) } - def sign(txinfo: TransactionWithInputInfo, key: PrivateKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): ByteVector64 = sign(txinfo, key, txinfo.sighash(txOwner, commitmentFormat)) - def addSigs(commitTx: CommitTx, localFundingPubkey: PublicKey, remoteFundingPubkey: PublicKey, localSig: ByteVector64, remoteSig: ByteVector64): CommitTx = { val witness = Scripts.witness2of2(localSig, remoteSig, localFundingPubkey, remoteFundingPubkey) commitTx.copy(tx = commitTx.tx.updateWitness(0, witness)) @@ -1027,11 +1041,4 @@ object Transactions { // NB: we don't verify the other inputs as they should only be wallet inputs used to RBF the transaction Try(Transaction.correctlySpends(txinfo.tx, Map(txinfo.input.outPoint -> txinfo.input.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) } - - def checkSig(txinfo: TransactionWithInputInfo, sig: ByteVector64, pubKey: PublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): Boolean = { - val sighash = txinfo.sighash(txOwner, commitmentFormat) - val data = Transaction.hashForSigning(txinfo.tx, inputIndex = 0, txinfo.input.redeemScript, sighash, txinfo.input.txOut.amount, SIGVERSION_WITNESS_V0) - Crypto.verifySignature(data, sig, pubKey) - } - } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala index d3f4fcdbde..b41e426040 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala @@ -209,9 +209,9 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { remotePaymentBasePoint = Remote.payment_basepoint, localIsChannelOpener = true, outputs = outputs) - val local_sig = Transactions.sign(tx, Local.funding_privkey, TxOwner.Local, commitmentFormat) + val local_sig = tx.sign(Local.funding_privkey, TxOwner.Local, commitmentFormat) logger.info(s"# local_signature = ${Scripts.der(local_sig).dropRight(1).toHex}") - val remote_sig = Transactions.sign(tx, Remote.funding_privkey, TxOwner.Remote, commitmentFormat) + val remote_sig = tx.sign(Remote.funding_privkey, TxOwner.Remote, commitmentFormat) logger.info(s"remote_signature: ${Scripts.der(remote_sig).dropRight(1).toHex}") Transactions.addSigs(tx, Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig) } @@ -248,8 +248,8 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { val signedTxs = htlcTxs.collect { case tx: HtlcSuccessTx => - val localSig = Transactions.sign(tx, Local.htlc_privkey, TxOwner.Local, commitmentFormat) - val remoteSig = Transactions.sign(tx, Remote.htlc_privkey, TxOwner.Remote, commitmentFormat) + val localSig = tx.sign(Local.htlc_privkey, TxOwner.Local, commitmentFormat) + val remoteSig = tx.sign(Remote.htlc_privkey, TxOwner.Remote, commitmentFormat) val htlcIndex = htlcScripts.indexOf(Script.parse(tx.input.redeemScript)) val preimage = paymentPreimages.find(p => Crypto.sha256(p) == tx.paymentHash).get val tx1 = Transactions.addSigs(tx, localSig, remoteSig, preimage, commitmentFormat) @@ -260,8 +260,8 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { logger.info(s"htlc_success_tx (htlc #$htlcIndex): ${tx1.tx}") tx1 case tx: HtlcTimeoutTx => - val localSig = Transactions.sign(tx, Local.htlc_privkey, TxOwner.Local, commitmentFormat) - val remoteSig = Transactions.sign(tx, Remote.htlc_privkey, TxOwner.Remote, commitmentFormat) + val localSig = tx.sign(Local.htlc_privkey, TxOwner.Local, commitmentFormat) + val remoteSig = tx.sign(Remote.htlc_privkey, TxOwner.Remote, commitmentFormat) val htlcIndex = htlcScripts.indexOf(Script.parse(tx.input.redeemScript)) val tx1 = Transactions.addSigs(tx, localSig, remoteSig, commitmentFormat) Transaction.correctlySpends(tx1.tx, Seq(commitTx.tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index 840ca7517f..03db3aa0a4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -290,8 +290,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val commitTxNumber = 0x404142434445L val commitTx = { val txInfo = makeCommitTx(commitInput, commitTxNumber, localPaymentPriv.publicKey, remotePaymentPriv.publicKey, localIsChannelOpener = true, outputs) - val localSig = Transactions.sign(txInfo, localPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) - val remoteSig = Transactions.sign(txInfo, remotePaymentPriv, TxOwner.Remote, DefaultCommitmentFormat) + val localSig = txInfo.sign(localPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) + val remoteSig = txInfo.sign(remotePaymentPriv, TxOwner.Remote, DefaultCommitmentFormat) Transactions.addSigs(txInfo, localFundingPriv.publicKey, remoteFundingPriv.publicKey, localSig, remoteSig) } @@ -317,8 +317,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // either party spends local->remote htlc output with htlc timeout tx for (htlcTimeoutTx <- htlcTimeoutTxs) { - val localSig = sign(htlcTimeoutTx, localHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) - val remoteSig = sign(htlcTimeoutTx, remoteHtlcPriv, TxOwner.Remote, DefaultCommitmentFormat) + val localSig = htlcTimeoutTx.sign(localHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) + val remoteSig = htlcTimeoutTx.sign(remoteHtlcPriv, TxOwner.Remote, DefaultCommitmentFormat) val signed = addSigs(htlcTimeoutTx, localSig, remoteSig, DefaultCommitmentFormat) assert(checkSpendable(signed).isSuccess) } @@ -326,7 +326,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // local spends delayed output of htlc1 timeout tx val Right(htlcDelayed) = makeHtlcDelayedTx(htlcTimeoutTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val localSig = sign(htlcDelayed, localDelayedPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) + val localSig = htlcDelayed.sign(localDelayedPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) val signedTx = addSigs(htlcDelayed, localSig) assert(checkSpendable(signedTx).isSuccess) // local can't claim delayed output of htlc3 timeout tx because it is below the dust limit @@ -337,7 +337,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { // remote spends local->remote htlc1/htlc3 output directly in case of success for ((htlc, paymentPreimage) <- (htlc1, paymentPreimage1) :: (htlc3, paymentPreimage3) :: Nil) { val Right(claimHtlcSuccessTx) = makeClaimHtlcSuccessTx(commitTx.tx, outputs, localDustLimit, remoteHtlcPriv.publicKey, localHtlcPriv.publicKey, localRevocationPriv.publicKey, finalPubKeyScript, htlc, feeratePerKw, DefaultCommitmentFormat) - val localSig = sign(claimHtlcSuccessTx, remoteHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) + val localSig = claimHtlcSuccessTx.sign(remoteHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) val signed = addSigs(claimHtlcSuccessTx, localSig, paymentPreimage) assert(checkSpendable(signed).isSuccess) } @@ -345,18 +345,18 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // local spends remote->local htlc2/htlc4 output with htlc success tx using payment preimage for ((htlcSuccessTx, paymentPreimage) <- (htlcSuccessTxs(1), paymentPreimage2) :: (htlcSuccessTxs(0), paymentPreimage4) :: Nil) { - val localSig = sign(htlcSuccessTx, localHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) - val remoteSig = sign(htlcSuccessTx, remoteHtlcPriv, TxOwner.Remote, DefaultCommitmentFormat) + val localSig = htlcSuccessTx.sign(localHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) + val remoteSig = htlcSuccessTx.sign(remoteHtlcPriv, TxOwner.Remote, DefaultCommitmentFormat) val signedTx = addSigs(htlcSuccessTx, localSig, remoteSig, paymentPreimage, DefaultCommitmentFormat) assert(checkSpendable(signedTx).isSuccess) // check remote sig - assert(checkSig(htlcSuccessTx, remoteSig, remoteHtlcPriv.publicKey, TxOwner.Remote, DefaultCommitmentFormat)) + assert(htlcSuccessTx.checkSig(remoteSig, remoteHtlcPriv.publicKey, TxOwner.Remote, DefaultCommitmentFormat)) } } { // local spends delayed output of htlc2 success tx val Right(htlcDelayed) = makeHtlcDelayedTx(htlcSuccessTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val localSig = sign(htlcDelayed, localDelayedPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) + val localSig = htlcDelayed.sign(localDelayedPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) val signedTx = addSigs(htlcDelayed, localSig) assert(checkSpendable(signedTx).isSuccess) // local can't claim delayed output of htlc4 success tx because it is below the dust limit @@ -366,35 +366,35 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // local spends main delayed output val Right(claimMainOutputTx) = makeClaimLocalDelayedOutputTx(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val localSig = sign(claimMainOutputTx, localDelayedPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) + val localSig = claimMainOutputTx.sign(localDelayedPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) val signedTx = addSigs(claimMainOutputTx, localSig) assert(checkSpendable(signedTx).isSuccess) } { // remote spends main output val Right(claimP2WPKHOutputTx) = makeClaimP2WPKHOutputTx(commitTx.tx, localDustLimit, remotePaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val localSig = sign(claimP2WPKHOutputTx, remotePaymentPriv, TxOwner.Local, DefaultCommitmentFormat) + val localSig = claimP2WPKHOutputTx.sign(remotePaymentPriv, TxOwner.Local, DefaultCommitmentFormat) val signedTx = addSigs(claimP2WPKHOutputTx, remotePaymentPriv.publicKey, localSig) assert(checkSpendable(signedTx).isSuccess) } { // remote spends remote->local htlc output directly in case of timeout val Right(claimHtlcTimeoutTx) = makeClaimHtlcTimeoutTx(commitTx.tx, outputs, localDustLimit, remoteHtlcPriv.publicKey, localHtlcPriv.publicKey, localRevocationPriv.publicKey, finalPubKeyScript, htlc2, feeratePerKw, DefaultCommitmentFormat) - val localSig = sign(claimHtlcTimeoutTx, remoteHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) + val localSig = claimHtlcTimeoutTx.sign(remoteHtlcPriv, TxOwner.Local, DefaultCommitmentFormat) val signed = addSigs(claimHtlcTimeoutTx, localSig) assert(checkSpendable(signed).isSuccess) } { // remote spends local main delayed output with revocation key val Right(mainPenaltyTx) = makeMainPenaltyTx(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, finalPubKeyScript, toLocalDelay, localDelayedPaymentPriv.publicKey, feeratePerKw) - val sig = sign(mainPenaltyTx, localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) + val sig = mainPenaltyTx.sign(localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) val signed = addSigs(mainPenaltyTx, sig) assert(checkSpendable(signed).isSuccess) } { // remote spends htlc1's htlc-timeout tx with revocation key val Seq(Right(claimHtlcDelayedPenaltyTx)) = makeClaimHtlcDelayedOutputPenaltyTxs(htlcTimeoutTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val sig = sign(claimHtlcDelayedPenaltyTx, localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) + val sig = claimHtlcDelayedPenaltyTx.sign(localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) val signed = addSigs(claimHtlcDelayedPenaltyTx, sig) assert(checkSpendable(signed).isSuccess) // remote can't claim revoked output of htlc3's htlc-timeout tx because it is below the dust limit @@ -409,14 +409,14 @@ class TransactionsSpec extends AnyFunSuite with Logging { case _ => false }.map(_._2) val Right(htlcPenaltyTx) = makeHtlcPenaltyTx(commitTx.tx, htlcOutputIndex, script, localDustLimit, finalPubKeyScript, feeratePerKw) - val sig = sign(htlcPenaltyTx, localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) + val sig = htlcPenaltyTx.sign(localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) val signed = addSigs(htlcPenaltyTx, sig, localRevocationPriv.publicKey) assert(checkSpendable(signed).isSuccess) } { // remote spends htlc2's htlc-success tx with revocation key val Seq(Right(claimHtlcDelayedPenaltyTx)) = makeClaimHtlcDelayedOutputPenaltyTxs(htlcSuccessTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val sig = sign(claimHtlcDelayedPenaltyTx, localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) + val sig = claimHtlcDelayedPenaltyTx.sign(localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) val signed = addSigs(claimHtlcDelayedPenaltyTx, sig) assert(checkSpendable(signed).isSuccess) // remote can't claim revoked output of htlc4's htlc-success tx because it is below the dust limit @@ -431,7 +431,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { case _ => false }.map(_._2) val Right(htlcPenaltyTx) = makeHtlcPenaltyTx(commitTx.tx, htlcOutputIndex, script, localDustLimit, finalPubKeyScript, feeratePerKw) - val sig = sign(htlcPenaltyTx, localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) + val sig = htlcPenaltyTx.sign(localRevocationPriv, TxOwner.Local, DefaultCommitmentFormat) val signed = addSigs(htlcPenaltyTx, sig, localRevocationPriv.publicKey) assert(checkSpendable(signed).isSuccess) } @@ -527,8 +527,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val commitTxNumber = 0x404142434445L val outputs = makeCommitTxOutputs(localPaysCommitTxFees = true, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, remotePaymentPriv.publicKey, localHtlcPriv.publicKey, remoteHtlcPriv.publicKey, localFundingPriv.publicKey, remoteFundingPriv.publicKey, spec, UnsafeLegacyAnchorOutputsCommitmentFormat) val txInfo = makeCommitTx(commitInput, commitTxNumber, localPaymentPriv.publicKey, remotePaymentPriv.publicKey, localIsChannelOpener = true, outputs) - val localSig = Transactions.sign(txInfo, localPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) - val remoteSig = Transactions.sign(txInfo, remotePaymentPriv, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = txInfo.sign(localPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val remoteSig = txInfo.sign(remotePaymentPriv, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat) val commitTx = Transactions.addSigs(txInfo, localFundingPriv.publicKey, remoteFundingPriv.publicKey, localSig, remoteSig) val htlcTxs = makeHtlcTxs(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, spec.htlcTxFeerate(UnsafeLegacyAnchorOutputsCommitmentFormat), outputs, UnsafeLegacyAnchorOutputsCommitmentFormat) @@ -563,7 +563,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // local spends main delayed output val Right(claimMainOutputTx) = makeClaimLocalDelayedOutputTx(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val localSig = sign(claimMainOutputTx, localDelayedPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = claimMainOutputTx.sign(localDelayedPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimMainOutputTx, localSig) assert(checkSpendable(signedTx).isSuccess) } @@ -575,7 +575,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // remote spends main delayed output val Right(claimRemoteDelayedOutputTx) = makeClaimRemoteDelayedOutputTx(commitTx.tx, localDustLimit, remotePaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val localSig = sign(claimRemoteDelayedOutputTx, remotePaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = claimRemoteDelayedOutputTx.sign(remotePaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimRemoteDelayedOutputTx, localSig) assert(checkSpendable(signedTx).isSuccess) } @@ -583,7 +583,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { // local spends local anchor val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, localFundingPriv.publicKey, ConfirmationTarget.Absolute(BlockHeight(0))) assert(checkSpendable(claimAnchorOutputTx).isFailure) - val localSig = sign(claimAnchorOutputTx, localFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = claimAnchorOutputTx.sign(localFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimAnchorOutputTx, localSig) assert(checkSpendable(signedTx).isSuccess) } @@ -591,28 +591,28 @@ class TransactionsSpec extends AnyFunSuite with Logging { // remote spends remote anchor val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, remoteFundingPriv.publicKey, ConfirmationTarget.Absolute(BlockHeight(0))) assert(checkSpendable(claimAnchorOutputTx).isFailure) - val localSig = sign(claimAnchorOutputTx, remoteFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = claimAnchorOutputTx.sign(remoteFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimAnchorOutputTx, localSig) assert(checkSpendable(signedTx).isSuccess) } { // remote spends local main delayed output with revocation key val Right(mainPenaltyTx) = makeMainPenaltyTx(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, finalPubKeyScript, toLocalDelay, localDelayedPaymentPriv.publicKey, feeratePerKw) - val sig = sign(mainPenaltyTx, localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val sig = mainPenaltyTx.sign(localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signed = addSigs(mainPenaltyTx, sig) assert(checkSpendable(signed).isSuccess) } { // local spends received htlc with HTLC-timeout tx for (htlcTimeoutTx <- htlcTimeoutTxs) { - val localSig = sign(htlcTimeoutTx, localHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) - val remoteSig = sign(htlcTimeoutTx, remoteHtlcPriv, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = htlcTimeoutTx.sign(localHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val remoteSig = htlcTimeoutTx.sign(remoteHtlcPriv, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(htlcTimeoutTx, localSig, remoteSig, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(checkSpendable(signedTx).isSuccess) // local detects when remote doesn't use the right sighash flags val invalidSighash = Seq(SIGHASH_ALL, SIGHASH_ALL | SIGHASH_ANYONECANPAY, SIGHASH_SINGLE, SIGHASH_NONE) for (sighash <- invalidSighash) { - val invalidRemoteSig = sign(htlcTimeoutTx, remoteHtlcPriv, sighash) + val invalidRemoteSig = htlcTimeoutTx.sign(remoteHtlcPriv, sighash) val invalidTx = addSigs(htlcTimeoutTx, localSig, invalidRemoteSig, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(checkSpendable(invalidTx).isFailure) } @@ -621,7 +621,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // local spends delayed output of htlc1 timeout tx val Right(htlcDelayed) = makeHtlcDelayedTx(htlcTimeoutTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val localSig = sign(htlcDelayed, localDelayedPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = htlcDelayed.sign(localDelayedPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(htlcDelayed, localSig) assert(checkSpendable(signedTx).isSuccess) // local can't claim delayed output of htlc3 timeout tx because it is below the dust limit @@ -631,19 +631,19 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // local spends offered htlc with HTLC-success tx for ((htlcSuccessTx, paymentPreimage) <- (htlcSuccessTxs(0), paymentPreimage4) :: (htlcSuccessTxs(1), paymentPreimage2) :: (htlcSuccessTxs(2), paymentPreimage2) :: Nil) { - val localSig = sign(htlcSuccessTx, localHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) - val remoteSig = sign(htlcSuccessTx, remoteHtlcPriv, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = htlcSuccessTx.sign(localHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val remoteSig = htlcSuccessTx.sign(remoteHtlcPriv, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(htlcSuccessTx, localSig, remoteSig, paymentPreimage, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(checkSpendable(signedTx).isSuccess) // check remote sig - assert(checkSig(htlcSuccessTx, remoteSig, remoteHtlcPriv.publicKey, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(htlcSuccessTx.checkSig(remoteSig, remoteHtlcPriv.publicKey, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat)) // local detects when remote doesn't use the right sighash flags val invalidSighash = Seq(SIGHASH_ALL, SIGHASH_ALL | SIGHASH_ANYONECANPAY, SIGHASH_SINGLE, SIGHASH_NONE) for (sighash <- invalidSighash) { - val invalidRemoteSig = sign(htlcSuccessTx, remoteHtlcPriv, sighash) + val invalidRemoteSig = htlcSuccessTx.sign(remoteHtlcPriv, sighash) val invalidTx = addSigs(htlcSuccessTx, localSig, invalidRemoteSig, paymentPreimage, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(checkSpendable(invalidTx).isFailure) - assert(!checkSig(invalidTx, invalidRemoteSig, remoteHtlcPriv.publicKey, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(!invalidTx.checkSig(invalidRemoteSig, remoteHtlcPriv.publicKey, TxOwner.Remote, UnsafeLegacyAnchorOutputsCommitmentFormat)) } } } @@ -652,7 +652,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { val Right(htlcDelayedA) = makeHtlcDelayedTx(htlcSuccessTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) val Right(htlcDelayedB) = makeHtlcDelayedTx(htlcSuccessTxs(2).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) for (htlcDelayed <- Seq(htlcDelayedA, htlcDelayedB)) { - val localSig = sign(htlcDelayed, localDelayedPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = htlcDelayed.sign(localDelayedPaymentPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(htlcDelayed, localSig) assert(checkSpendable(signedTx).isSuccess) } @@ -664,7 +664,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { // remote spends local->remote htlc outputs directly in case of success for ((htlc, paymentPreimage) <- (htlc1, paymentPreimage1) :: (htlc3, paymentPreimage3) :: Nil) { val Right(claimHtlcSuccessTx) = makeClaimHtlcSuccessTx(commitTx.tx, commitTxOutputs, localDustLimit, remoteHtlcPriv.publicKey, localHtlcPriv.publicKey, localRevocationPriv.publicKey, finalPubKeyScript, htlc, feeratePerKw, UnsafeLegacyAnchorOutputsCommitmentFormat) - val localSig = sign(claimHtlcSuccessTx, remoteHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = claimHtlcSuccessTx.sign(remoteHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signed = addSigs(claimHtlcSuccessTx, localSig, paymentPreimage) assert(checkSpendable(signed).isSuccess) } @@ -672,7 +672,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // remote spends htlc1's htlc-timeout tx with revocation key val Seq(Right(claimHtlcDelayedPenaltyTx)) = makeClaimHtlcDelayedOutputPenaltyTxs(htlcTimeoutTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) - val sig = sign(claimHtlcDelayedPenaltyTx, localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val sig = claimHtlcDelayedPenaltyTx.sign(localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signed = addSigs(claimHtlcDelayedPenaltyTx, sig) assert(checkSpendable(signed).isSuccess) // remote can't claim revoked output of htlc3's htlc-timeout tx because it is below the dust limit @@ -683,7 +683,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { // remote spends remote->local htlc output directly in case of timeout for (htlc <- Seq(htlc2a, htlc2b)) { val Right(claimHtlcTimeoutTx) = makeClaimHtlcTimeoutTx(commitTx.tx, commitTxOutputs, localDustLimit, remoteHtlcPriv.publicKey, localHtlcPriv.publicKey, localRevocationPriv.publicKey, finalPubKeyScript, htlc, feeratePerKw, UnsafeLegacyAnchorOutputsCommitmentFormat) - val localSig = sign(claimHtlcTimeoutTx, remoteHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val localSig = claimHtlcTimeoutTx.sign(remoteHtlcPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signed = addSigs(claimHtlcTimeoutTx, localSig) assert(checkSpendable(signed).isSuccess) } @@ -693,7 +693,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { val Seq(Right(claimHtlcDelayedPenaltyTxA)) = makeClaimHtlcDelayedOutputPenaltyTxs(htlcSuccessTxs(1).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) val Seq(Right(claimHtlcDelayedPenaltyTxB)) = makeClaimHtlcDelayedOutputPenaltyTxs(htlcSuccessTxs(2).tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw) for (claimHtlcSuccessPenaltyTx <- Seq(claimHtlcDelayedPenaltyTxA, claimHtlcDelayedPenaltyTxB)) { - val sig = sign(claimHtlcSuccessPenaltyTx, localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val sig = claimHtlcSuccessPenaltyTx.sign(localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signed = addSigs(claimHtlcSuccessPenaltyTx, sig) assert(checkSpendable(signed).isSuccess) } @@ -723,7 +723,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { case _ => false }.map(_._2) val Right(htlcPenaltyTx) = makeHtlcPenaltyTx(commitTx.tx, htlcOutputIndex, script, localDustLimit, finalPubKeyScript, feeratePerKw) - val sig = sign(htlcPenaltyTx, localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val sig = htlcPenaltyTx.sign(localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signed = addSigs(htlcPenaltyTx, sig, localRevocationPriv.publicKey) assert(checkSpendable(signed).isSuccess) } @@ -736,7 +736,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { case _ => false }.map(_._2) val Right(htlcPenaltyTx) = makeHtlcPenaltyTx(commitTx.tx, htlcOutputIndex, script, localDustLimit, finalPubKeyScript, feeratePerKw) - val sig = sign(htlcPenaltyTx, localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) + val sig = htlcPenaltyTx.sign(localRevocationPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signed = addSigs(htlcPenaltyTx, sig, localRevocationPriv.publicKey) assert(checkSpendable(signed).isSuccess) } @@ -782,8 +782,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val (commitTx, outputs, htlcTxs) = { val outputs = makeCommitTxOutputs(localPaysCommitTxFees = true, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, remotePaymentPriv.publicKey, localHtlcPriv.publicKey, remoteHtlcPriv.publicKey, localFundingPriv.publicKey, remoteFundingPriv.publicKey, spec, DefaultCommitmentFormat) val txInfo = makeCommitTx(commitInput, commitTxNumber, localPaymentPriv.publicKey, remotePaymentPriv.publicKey, localIsChannelOpener = true, outputs) - val localSig = Transactions.sign(txInfo, localPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) - val remoteSig = Transactions.sign(txInfo, remotePaymentPriv, TxOwner.Remote, DefaultCommitmentFormat) + val localSig = txInfo.sign(localPaymentPriv, TxOwner.Local, DefaultCommitmentFormat) + val remoteSig = txInfo.sign(remotePaymentPriv, TxOwner.Remote, DefaultCommitmentFormat) val commitTx = Transactions.addSigs(txInfo, localFundingPriv.publicKey, remoteFundingPriv.publicKey, localSig, remoteSig) val htlcTxs = makeHtlcTxs(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, feeratePerKw, outputs, DefaultCommitmentFormat) (commitTx, outputs, htlcTxs) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/ChannelCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/ChannelCodecsSpec.scala index eb9424e830..1c4620db64 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/ChannelCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/ChannelCodecsSpec.scala @@ -242,7 +242,7 @@ class ChannelCodecsSpec extends AnyFunSuite { assert(newnormal.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx.txIn.forall(_.witness.stack.isEmpty)) assert(newnormal.commitments.latest.localCommit.htlcTxsAndRemoteSigs.forall(_.htlcTx.tx.txIn.forall(_.witness.stack.isEmpty))) // make sure that we have extracted the remote sig of the local tx - Transactions.checkSig(newnormal.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx, newnormal.commitments.latest.localCommit.commitTxAndRemoteSig.remoteSig, newnormal.commitments.remoteNodeId, TxOwner.Remote, newnormal.commitments.params.commitmentFormat) + newnormal.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.checkSig(newnormal.commitments.latest.localCommit.commitTxAndRemoteSig.remoteSig, newnormal.commitments.remoteNodeId, TxOwner.Remote, newnormal.commitments.params.commitmentFormat) } } From 84f6c3becaf4ad0e8955edbbc27e3c92965f07a0 Mon Sep 17 00:00:00 2001 From: sstone Date: Tue, 6 Aug 2024 16:44:59 +0200 Subject: [PATCH 8/9] Upgrade input info class to allow spending from taproot transactions Our InputInfo class contains a tx output and the matching redeem script, which is enough to spend segwit v0 transactions. For taproot transactions, instead of a redeem script, we need a script tree instead, and the appropriate internal pubkey. --- .../fr/acinq/eclair/channel/Commitments.scala | 2 +- .../channel/publish/ReplaceableTxFunder.scala | 6 ++- .../keymanager/LocalChannelKeyManager.scala | 1 - .../eclair/transactions/Transactions.scala | 49 ++++++++++++------- .../channel/version0/ChannelCodecs0.scala | 8 ++- .../channel/version0/ChannelTypes0.scala | 6 +-- .../channel/version1/ChannelCodecs1.scala | 8 ++- .../channel/version2/ChannelCodecs2.scala | 8 ++- .../channel/version3/ChannelCodecs3.scala | 8 ++- .../channel/version4/ChannelCodecs4.scala | 22 ++++++++- .../eclair/wire/protocol/CommonCodecs.scala | 4 +- .../publish/ReplaceableTxFunderSpec.scala | 2 +- .../eclair/transactions/TestVectorsSpec.scala | 8 +-- .../channel/version4/ChannelCodecs4Spec.scala | 2 +- 14 files changed, 93 insertions(+), 41 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index d4f2d478a4..36e5b0dfac 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -1142,7 +1142,7 @@ case class Commitments(params: ChannelParams, val localFundingKey = keyManager.fundingPublicKey(params.localParams.fundingKeyPath, commitment.fundingTxIndex).publicKey val remoteFundingKey = commitment.remoteFundingPubKey val fundingScript = Script.write(Scripts.multiSig2of2(localFundingKey, remoteFundingKey)) - commitment.commitInput.redeemScript == fundingScript + commitment.commitInput.redeemScriptOrScriptTree == Left(fundingScript) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index d0be968257..e9d80acf2b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -358,12 +358,16 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, import fr.acinq.bitcoin.scalacompat.KotlinUtils._ // We create a PSBT with the non-wallet input already signed: + val witnessScript = locallySignedTx.txInfo.input.redeemScriptOrScriptTree match { + case Left(redeemScript) => fr.acinq.bitcoin.Script.parse(redeemScript) + case _ => null + } val psbt = new Psbt(locallySignedTx.txInfo.tx) .updateWitnessInput( locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.input.txOut, null, - fr.acinq.bitcoin.Script.parse(locallySignedTx.txInfo.input.redeemScript), + witnessScript, fr.acinq.bitcoin.SigHash.SIGHASH_ALL, java.util.Map.of(), null, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala index 41c2693c75..86975a1ae8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala @@ -23,7 +23,6 @@ import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, ByteVector32, ByteVector6 import fr.acinq.eclair.crypto.Generators import fr.acinq.eclair.crypto.Monitoring.{Metrics, Tags} import fr.acinq.eclair.router.Announcements -import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, TransactionWithInputInfo, TxOwner} import fr.acinq.eclair.{KamonExt, randomLong} import grizzled.slf4j.Logging diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 53e0c5fb8d..ceb5bab1fa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -16,10 +16,10 @@ package fr.acinq.eclair.transactions -import fr.acinq.bitcoin.ScriptFlags +import fr.acinq.bitcoin.{ScriptFlags, ScriptTree} import fr.acinq.bitcoin.SigHash._ import fr.acinq.bitcoin.SigVersion._ -import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, ripemd160} +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, XonlyPublicKey, ripemd160} import fr.acinq.bitcoin.scalacompat.Script._ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.eclair._ @@ -94,9 +94,22 @@ object Transactions { // @formatter:off case class OutputInfo(index: Long, amount: Satoshi, publicKeyScript: ByteVector) - case class InputInfo(outPoint: OutPoint, txOut: TxOut, redeemScript: ByteVector) + + /** + * to spend the output of a taproot transactions, we need to know the script tree and internal key used to build this output + */ + case class ScriptTreeAndInternalKey(scriptTree: ScriptTree, internalKey: XonlyPublicKey) { + val publicKeyScript: ByteVector = Script.write(Script.pay2tr(internalKey, Some(scriptTree))) + } + + case class InputInfo(outPoint: OutPoint, txOut: TxOut, redeemScriptOrScriptTree: Either[ByteVector, ScriptTreeAndInternalKey]) { + val redeemScriptOrEmptyScript: ByteVector = redeemScriptOrScriptTree.swap.getOrElse(ByteVector.empty) // TODO: use the actual script tree for taproot transactions, once we implement them + } + object InputInfo { - def apply(outPoint: OutPoint, txOut: TxOut, redeemScript: Seq[ScriptElt]) = new InputInfo(outPoint, txOut, Script.write(redeemScript)) + def apply(outPoint: OutPoint, txOut: TxOut, redeemScript: ByteVector) = new InputInfo(outPoint, txOut, Left(redeemScript)) + def apply(outPoint: OutPoint, txOut: TxOut, redeemScript: Seq[ScriptElt]) = new InputInfo(outPoint, txOut, Left(Script.write(redeemScript))) + def apply(outPoint: OutPoint, txOut: TxOut, scriptTree: ScriptTreeAndInternalKey) = new InputInfo(outPoint, txOut, Right(scriptTree)) } /** Owner of a given transaction (local/remote). */ @@ -125,12 +138,12 @@ object Transactions { // NB: the tx may have multiple inputs, we will only sign the one provided in txinfo.input. Bear in mind that the // signature will be invalidated if other inputs are added *afterwards* and sighashType was SIGHASH_ALL. val inputIndex = tx.txIn.zipWithIndex.find(_._1.outPoint == input.outPoint).get._2 - Transactions.sign(tx, input.redeemScript, input.txOut.amount, key, sighashType, inputIndex) + Transactions.sign(tx, input.redeemScriptOrEmptyScript, input.txOut.amount, key, sighashType, inputIndex) } def checkSig(sig: ByteVector64, pubKey: PublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): Boolean = { val sighash = this.sighash(txOwner, commitmentFormat) - val data = Transaction.hashForSigning(tx, inputIndex = 0, input.redeemScript, sighash, input.txOut.amount, SIGVERSION_WITNESS_V0) + val data = Transaction.hashForSigning(tx, inputIndex = 0, input.redeemScriptOrEmptyScript, sighash, input.txOut.amount, SIGVERSION_WITNESS_V0) Crypto.verifySignature(data, sig, pubKey) } } @@ -964,7 +977,7 @@ object Transactions { // NB: the tx may have multiple inputs, we will only sign the one provided in txinfo.input. Bear in mind that the // signature will be invalidated if other inputs are added *afterwards* and sighashType was SIGHASH_ALL. val inputIndex = txinfo.tx.txIn.zipWithIndex.find(_._1.outPoint == txinfo.input.outPoint).get._2 - sign(txinfo.tx, txinfo.input.redeemScript, txinfo.input.txOut.amount, key, sighashType, inputIndex) + sign(txinfo.tx, txinfo.input.redeemScriptOrEmptyScript, txinfo.input.txOut.amount, key, sighashType, inputIndex) } def addSigs(commitTx: CommitTx, localFundingPubkey: PublicKey, remoteFundingPubkey: PublicKey, localSig: ByteVector64, remoteSig: ByteVector64): CommitTx = { @@ -973,32 +986,32 @@ object Transactions { } def addSigs(mainPenaltyTx: MainPenaltyTx, revocationSig: ByteVector64): MainPenaltyTx = { - val witness = Scripts.witnessToLocalDelayedWithRevocationSig(revocationSig, mainPenaltyTx.input.redeemScript) + val witness = Scripts.witnessToLocalDelayedWithRevocationSig(revocationSig, mainPenaltyTx.input.redeemScriptOrEmptyScript) mainPenaltyTx.copy(tx = mainPenaltyTx.tx.updateWitness(0, witness)) } def addSigs(htlcPenaltyTx: HtlcPenaltyTx, revocationSig: ByteVector64, revocationPubkey: PublicKey): HtlcPenaltyTx = { - val witness = Scripts.witnessHtlcWithRevocationSig(revocationSig, revocationPubkey, htlcPenaltyTx.input.redeemScript) + val witness = Scripts.witnessHtlcWithRevocationSig(revocationSig, revocationPubkey, htlcPenaltyTx.input.redeemScriptOrEmptyScript) htlcPenaltyTx.copy(tx = htlcPenaltyTx.tx.updateWitness(0, witness)) } def addSigs(htlcSuccessTx: HtlcSuccessTx, localSig: ByteVector64, remoteSig: ByteVector64, paymentPreimage: ByteVector32, commitmentFormat: CommitmentFormat): HtlcSuccessTx = { - val witness = witnessHtlcSuccess(localSig, remoteSig, paymentPreimage, htlcSuccessTx.input.redeemScript, commitmentFormat) + val witness = witnessHtlcSuccess(localSig, remoteSig, paymentPreimage, htlcSuccessTx.input.redeemScriptOrEmptyScript, commitmentFormat) htlcSuccessTx.copy(tx = htlcSuccessTx.tx.updateWitness(0, witness)) } def addSigs(htlcTimeoutTx: HtlcTimeoutTx, localSig: ByteVector64, remoteSig: ByteVector64, commitmentFormat: CommitmentFormat): HtlcTimeoutTx = { - val witness = witnessHtlcTimeout(localSig, remoteSig, htlcTimeoutTx.input.redeemScript, commitmentFormat) + val witness = witnessHtlcTimeout(localSig, remoteSig, htlcTimeoutTx.input.redeemScriptOrEmptyScript, commitmentFormat) htlcTimeoutTx.copy(tx = htlcTimeoutTx.tx.updateWitness(0, witness)) } def addSigs(claimHtlcSuccessTx: ClaimHtlcSuccessTx, localSig: ByteVector64, paymentPreimage: ByteVector32): ClaimHtlcSuccessTx = { - val witness = witnessClaimHtlcSuccessFromCommitTx(localSig, paymentPreimage, claimHtlcSuccessTx.input.redeemScript) + val witness = witnessClaimHtlcSuccessFromCommitTx(localSig, paymentPreimage, claimHtlcSuccessTx.input.redeemScriptOrEmptyScript) claimHtlcSuccessTx.copy(tx = claimHtlcSuccessTx.tx.updateWitness(0, witness)) } def addSigs(claimHtlcTimeoutTx: ClaimHtlcTimeoutTx, localSig: ByteVector64): ClaimHtlcTimeoutTx = { - val witness = witnessClaimHtlcTimeoutFromCommitTx(localSig, claimHtlcTimeoutTx.input.redeemScript) + val witness = witnessClaimHtlcTimeoutFromCommitTx(localSig, claimHtlcTimeoutTx.input.redeemScriptOrEmptyScript) claimHtlcTimeoutTx.copy(tx = claimHtlcTimeoutTx.tx.updateWitness(0, witness)) } @@ -1008,27 +1021,27 @@ object Transactions { } def addSigs(claimRemoteDelayedOutputTx: ClaimRemoteDelayedOutputTx, localSig: ByteVector64): ClaimRemoteDelayedOutputTx = { - val witness = witnessClaimToRemoteDelayedFromCommitTx(localSig, claimRemoteDelayedOutputTx.input.redeemScript) + val witness = witnessClaimToRemoteDelayedFromCommitTx(localSig, claimRemoteDelayedOutputTx.input.redeemScriptOrEmptyScript) claimRemoteDelayedOutputTx.copy(tx = claimRemoteDelayedOutputTx.tx.updateWitness(0, witness)) } def addSigs(claimDelayedOutputTx: ClaimLocalDelayedOutputTx, localSig: ByteVector64): ClaimLocalDelayedOutputTx = { - val witness = witnessToLocalDelayedAfterDelay(localSig, claimDelayedOutputTx.input.redeemScript) + val witness = witnessToLocalDelayedAfterDelay(localSig, claimDelayedOutputTx.input.redeemScriptOrEmptyScript) claimDelayedOutputTx.copy(tx = claimDelayedOutputTx.tx.updateWitness(0, witness)) } def addSigs(htlcDelayedTx: HtlcDelayedTx, localSig: ByteVector64): HtlcDelayedTx = { - val witness = witnessToLocalDelayedAfterDelay(localSig, htlcDelayedTx.input.redeemScript) + val witness = witnessToLocalDelayedAfterDelay(localSig, htlcDelayedTx.input.redeemScriptOrEmptyScript) htlcDelayedTx.copy(tx = htlcDelayedTx.tx.updateWitness(0, witness)) } def addSigs(claimAnchorOutputTx: ClaimLocalAnchorOutputTx, localSig: ByteVector64): ClaimLocalAnchorOutputTx = { - val witness = witnessAnchor(localSig, claimAnchorOutputTx.input.redeemScript) + val witness = witnessAnchor(localSig, claimAnchorOutputTx.input.redeemScriptOrEmptyScript) claimAnchorOutputTx.copy(tx = claimAnchorOutputTx.tx.updateWitness(0, witness)) } def addSigs(claimHtlcDelayedPenalty: ClaimHtlcDelayedOutputPenaltyTx, revocationSig: ByteVector64): ClaimHtlcDelayedOutputPenaltyTx = { - val witness = Scripts.witnessToLocalDelayedWithRevocationSig(revocationSig, claimHtlcDelayedPenalty.input.redeemScript) + val witness = Scripts.witnessToLocalDelayedWithRevocationSig(revocationSig, claimHtlcDelayedPenalty.input.redeemScriptOrEmptyScript) claimHtlcDelayedPenalty.copy(tx = claimHtlcDelayedPenalty.tx.updateWitness(0, witness)) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala index e1ae2a8e2e..046ae615a3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala @@ -125,10 +125,14 @@ private[channel] object ChannelCodecs0 { closingTx => closingTx.tx ) - val inputInfoCodec: Codec[InputInfo] = ( + private case class InputInfoLegacy(outPoint: OutPoint, txOut: TxOut, redeemScript: ByteVector) + + private val inputInfoLegacyCodec: Codec[InputInfoLegacy] = ( ("outPoint" | outPointCodec) :: ("txOut" | txOutCodec) :: - ("redeemScript" | varsizebinarydata)).as[InputInfo].decodeOnly + ("redeemScript" | varsizebinarydata)).as[InputInfoLegacy] + + val inputInfoCodec: Codec[InputInfo] = inputInfoLegacyCodec.xmap[InputInfo](legacy => InputInfo(legacy.outPoint, legacy.txOut, Left(legacy.redeemScript)), _ => ???).decodeOnly private val defaultConfirmationTarget: Codec[ConfirmationTarget.Absolute] = provide(ConfirmationTarget.Absolute(BlockHeight(0))) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala index e5fb015785..390c0b5335 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala @@ -48,7 +48,7 @@ private[channel] object ChannelTypes0 { // modified: we don't use the InputInfo in closing business logic, so we don't need to fill everything (this part // assumes that we only have standard channels, no anchor output channels - which was the case before version2). val input = childTx.txIn.head.outPoint - InputInfo(input, parentTx.txOut(input.index.toInt), Nil) + InputInfo(input, parentTx.txOut(input.index.toInt), ByteVector.fromValidHex("deadbeef")) } case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: Option[Transaction], htlcSuccessTxs: List[Transaction], htlcTimeoutTxs: List[Transaction], claimHtlcDelayedTxs: List[Transaction], irrevocablySpent: Map[OutPoint, TxId]) { @@ -97,7 +97,7 @@ private[channel] object ChannelTypes0 { val htlcPenaltyTxsNew = htlcPenaltyTxs.map(tx => HtlcPenaltyTx(getPartialInputInfo(commitTx, tx), tx)) val claimHtlcDelayedPenaltyTxsNew = claimHtlcDelayedPenaltyTxs.map(tx => { // We don't have all the `InputInfo` data, but it's ok: we only use the tx that is fully signed. - ClaimHtlcDelayedOutputPenaltyTx(InputInfo(tx.txIn.head.outPoint, TxOut(Satoshi(0), Nil), Nil), tx) + ClaimHtlcDelayedOutputPenaltyTx(InputInfo(tx.txIn.head.outPoint, TxOut(Satoshi(0), Nil), ByteVector.fromValidHex("deadbeef")), tx) // FIXME: use proper value when we upgrade InputInfo to use `Either` }) channel.RevokedCommitPublished(commitTx, claimMainOutputTxNew, mainPenaltyTxNew, htlcPenaltyTxsNew, claimHtlcDelayedPenaltyTxsNew, irrevocablySpentNew) } @@ -108,7 +108,7 @@ private[channel] object ChannelTypes0 { * the raw transaction. It provides more information for auditing but is not used for business logic, so we can safely * put dummy values in the migration. */ - def migrateClosingTx(tx: Transaction): ClosingTx = ClosingTx(InputInfo(tx.txIn.head.outPoint, TxOut(Satoshi(0), Nil), Nil), tx, None) + def migrateClosingTx(tx: Transaction): ClosingTx = ClosingTx(InputInfo(tx.txIn.head.outPoint, TxOut(Satoshi(0), Nil), ByteVector.fromValidHex("deadbeef")), tx, None) case class HtlcTxAndSigs(txinfo: HtlcTx, localSig: ByteVector64, remoteSig: ByteVector64) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala index 1f75e8242b..8e2f22056e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala @@ -97,10 +97,14 @@ private[channel] object ChannelCodecs1 { closingTx => closingTx.tx ) - val inputInfoCodec: Codec[InputInfo] = ( + private case class InputInfoLegacy(outPoint: OutPoint, txOut: TxOut, redeemScript: ByteVector) + + private val inputInfoLegacyCodec: Codec[InputInfoLegacy] = ( ("outPoint" | outPointCodec) :: ("txOut" | txOutCodec) :: - ("redeemScript" | lengthDelimited(bytes))).as[InputInfo] + ("redeemScript" | lengthDelimited(bytes))).as[InputInfoLegacy] + + val inputInfoCodec: Codec[InputInfo] = inputInfoLegacyCodec.xmap[InputInfo](legacy => InputInfo(legacy.outPoint, legacy.txOut, Left(legacy.redeemScript)), _ => ???).decodeOnly private val defaultConfirmationTarget: Codec[ConfirmationTarget.Absolute] = provide(ConfirmationTarget.Absolute(BlockHeight(0))) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala index 8d49b376f9..c85b07feff 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala @@ -101,10 +101,14 @@ private[channel] object ChannelCodecs2 { val txCodec: Codec[Transaction] = lengthDelimited(bytes.xmap(d => Transaction.read(d.toArray), d => Transaction.write(d))) - val inputInfoCodec: Codec[InputInfo] = ( + private case class InputInfoLegacy(outPoint: OutPoint, txOut: TxOut, redeemScript: ByteVector) + + private val inputInfoLegacyCodec: Codec[InputInfoLegacy] = ( ("outPoint" | outPointCodec) :: ("txOut" | txOutCodec) :: - ("redeemScript" | lengthDelimited(bytes))).as[InputInfo] + ("redeemScript" | lengthDelimited(bytes))).as[InputInfoLegacy] + + val inputInfoCodec: Codec[InputInfo] = inputInfoLegacyCodec.xmap[InputInfo](legacy => InputInfo(legacy.outPoint, legacy.txOut, Left(legacy.redeemScript)), _ => ???).decodeOnly val outputInfoCodec: Codec[OutputInfo] = ( ("index" | uint32) :: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala index a3a98f7d0e..36521f3db7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala @@ -113,10 +113,14 @@ private[channel] object ChannelCodecs3 { val txCodec: Codec[Transaction] = lengthDelimited(bytes.xmap(d => Transaction.read(d.toArray), d => Transaction.write(d))) - val inputInfoCodec: Codec[InputInfo] = ( + private case class InputInfoLegacy(outPoint: OutPoint, txOut: TxOut, redeemScript: ByteVector) + + private val inputInfoLegacyCodec: Codec[InputInfoLegacy] = ( ("outPoint" | outPointCodec) :: ("txOut" | txOutCodec) :: - ("redeemScript" | lengthDelimited(bytes))).as[InputInfo] + ("redeemScript" | lengthDelimited(bytes))).as[InputInfoLegacy] + + val inputInfoCodec: Codec[InputInfo] = inputInfoLegacyCodec.xmap[InputInfo](legacy => InputInfo(legacy.outPoint, legacy.txOut, Left(legacy.redeemScript)), _ => ???).decodeOnly val outputInfoCodec: Codec[OutputInfo] = ( ("index" | uint32) :: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala index 53ee59a77b..44e406c647 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala @@ -1,5 +1,7 @@ package fr.acinq.eclair.wire.internal.channel.version4 +import fr.acinq.bitcoin.ScriptTree +import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath import fr.acinq.bitcoin.scalacompat.{OutPoint, ScriptWitness, Transaction, TxOut} @@ -109,10 +111,26 @@ private[channel] object ChannelCodecs4 { val txCodec: Codec[Transaction] = lengthDelimited(bytes.xmap(d => Transaction.read(d.toArray), d => Transaction.write(d))) - val inputInfoCodec: Codec[InputInfo] = ( + val scriptTreeCodec: Codec[ScriptTree] = lengthDelimited(bytes.xmap(d => ScriptTree.read(new ByteArrayInput(d.toArray)), d => ByteVector.view(d.write()))) + + val scriptTreeAndInternalKey: Codec[ScriptTreeAndInternalKey] = (scriptTreeCodec :: xonlyPublicKey).as[ScriptTreeAndInternalKey] + + private case class InputInfoEx(outPoint: OutPoint, txOut: TxOut, redeemScript: ByteVector, redeemScriptOrScriptTree: Either[ByteVector, ScriptTreeAndInternalKey], dummy: Boolean) + + // To support the change from redeemScript to "either redeem script or script tree" while remaining backwards-compatible with the previous version 4 codec, we use + // the redeem script itself as a left/write indicator: empty -> right, not empty -> left + private val inputInfoExCodec: Codec[InputInfoEx] = ( ("outPoint" | outPointCodec) :: ("txOut" | txOutCodec) :: - ("redeemScript" | lengthDelimited(bytes))).as[InputInfo] + (("redeemScript" | lengthDelimited(bytes)) >>:~ { redeemScript => + ("redeemScriptOrScriptTree" | either(provide(redeemScript.isEmpty), provide(redeemScript), scriptTreeAndInternalKey)) :: ("dummy" | provide(false)) + }) + ).as[InputInfoEx] + + val inputInfoCodec: Codec[InputInfo] = inputInfoExCodec.xmap( + iex => InputInfo(iex.outPoint, iex.txOut, iex.redeemScriptOrScriptTree), + i => InputInfoEx(i.outPoint, i.txOut, i.redeemScriptOrScriptTree.swap.toOption.getOrElse(ByteVector.empty), i.redeemScriptOrScriptTree, false) + ) val outputInfoCodec: Codec[OutputInfo] = ( ("index" | uint32) :: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala index 48f6e45711..53cfee271e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.wire.protocol -import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, XonlyPublicKey} import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Satoshi, Transaction, TxHash, TxId} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.{ChannelFlags, RealScidStatus, ShortIds} @@ -167,6 +167,8 @@ object CommonCodecs { (wire: BitVector) => bytes(33).decode(wire).map(_.map(b => PublicKey(b))) ) + val xonlyPublicKey: Codec[XonlyPublicKey] = publicKey.xmap(p => p.xOnly, x => x.publicKey) + val rgb: Codec[Color] = bytes(3).xmap(buf => Color(buf(0), buf(1), buf(2)), t => ByteVector(t.r, t.g, t.b)) val txCodec: Codec[Transaction] = bytes.xmap(d => Transaction.read(d.toArray), d => Transaction.write(d)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala index ff7591e67a..1577b1bcea 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala @@ -42,7 +42,7 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { val commitInput = Funding.makeFundingInputInfo(randomTxId(), 1, 500 sat, PlaceHolderPubKey, PlaceHolderPubKey) val commitTx = Transaction( 2, - Seq(TxIn(commitInput.outPoint, commitInput.redeemScript, 0, Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, PlaceHolderPubKey, PlaceHolderPubKey))), + Seq(TxIn(commitInput.outPoint, commitInput.redeemScriptOrEmptyScript, 0, Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, PlaceHolderPubKey, PlaceHolderPubKey))), Seq(TxOut(330 sat, Script.pay2wsh(anchorScript))), 0 ) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala index b41e426040..ddedba2fad 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala @@ -140,8 +140,8 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { logger.info(s"remotekey: ${Remote.payment_privkey.publicKey}") logger.info(s"local_delayedkey: ${Local.delayed_payment_privkey.publicKey}") logger.info(s"local_revocation_key: ${Local.revocation_pubkey}") - logger.info(s"# funding wscript = ${commitmentInput.redeemScript}") - assert(commitmentInput.redeemScript == hex"5221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb21030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae") + logger.info(s"# funding wscript = ${commitmentInput.redeemScriptOrScriptTree}") + assert(commitmentInput.redeemScriptOrScriptTree == Left(hex"5221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb21030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae")) val paymentPreimages = Seq( ByteVector32(hex"0000000000000000000000000000000000000000000000000000000000000000"), @@ -250,7 +250,7 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { case tx: HtlcSuccessTx => val localSig = tx.sign(Local.htlc_privkey, TxOwner.Local, commitmentFormat) val remoteSig = tx.sign(Remote.htlc_privkey, TxOwner.Remote, commitmentFormat) - val htlcIndex = htlcScripts.indexOf(Script.parse(tx.input.redeemScript)) + val htlcIndex = htlcScripts.indexOf(Script.parse(tx.input.redeemScriptOrEmptyScript)) val preimage = paymentPreimages.find(p => Crypto.sha256(p) == tx.paymentHash).get val tx1 = Transactions.addSigs(tx, localSig, remoteSig, preimage, commitmentFormat) Transaction.correctlySpends(tx1.tx, Seq(commitTx.tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -262,7 +262,7 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { case tx: HtlcTimeoutTx => val localSig = tx.sign(Local.htlc_privkey, TxOwner.Local, commitmentFormat) val remoteSig = tx.sign(Remote.htlc_privkey, TxOwner.Remote, commitmentFormat) - val htlcIndex = htlcScripts.indexOf(Script.parse(tx.input.redeemScript)) + val htlcIndex = htlcScripts.indexOf(Script.parse(tx.input.redeemScriptOrEmptyScript)) val tx1 = Transactions.addSigs(tx, localSig, remoteSig, commitmentFormat) Transaction.correctlySpends(tx1.tx, Seq(commitTx.tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) logger.info(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #$htlcIndex)") diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala index 6e9007fe02..cc15f5289f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala @@ -124,7 +124,7 @@ class ChannelCodecs4Spec extends AnyFunSuite { test("encode/decode rbf status") { val channelId = randomBytes32() - val fundingInput = InputInfo(OutPoint(randomTxId(), 3), TxOut(175_000 sat, Script.pay2wpkh(randomKey().publicKey)), Nil) + val fundingInput = InputInfo(OutPoint(randomTxId(), 3), TxOut(175_000 sat, Script.pay2wpkh(randomKey().publicKey)), Script.pay2wpkh(randomKey().publicKey)) val fundingTx = SharedTransaction( sharedInput_opt = None, sharedOutput = InteractiveTxBuilder.Output.Shared(UInt64(8), ByteVector.empty, 100_000_600 msat, 74_000_400 msat, 0 msat), From 154235d4d4c7b264606f44f9645f6cb6e43d091d Mon Sep 17 00:00:00 2001 From: Fabrice Drouin Date: Wed, 18 Sep 2024 10:44:52 +0200 Subject: [PATCH 9/9] Fixup! Update legacy InputInfo codec Co-authored-by: Pierre-Marie Padiou --- .../eclair/wire/internal/channel/version0/ChannelCodecs0.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala index 046ae615a3..4e08376c92 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala @@ -132,7 +132,7 @@ private[channel] object ChannelCodecs0 { ("txOut" | txOutCodec) :: ("redeemScript" | varsizebinarydata)).as[InputInfoLegacy] - val inputInfoCodec: Codec[InputInfo] = inputInfoLegacyCodec.xmap[InputInfo](legacy => InputInfo(legacy.outPoint, legacy.txOut, Left(legacy.redeemScript)), _ => ???).decodeOnly + val inputInfoCodec: Codec[InputInfo] = inputInfoLegacyCodec.map(legacy => InputInfo(legacy.outPoint, legacy.txOut, Left(legacy.redeemScript))).decodeOnly private val defaultConfirmationTarget: Codec[ConfirmationTarget.Absolute] = provide(ConfirmationTarget.Absolute(BlockHeight(0)))