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 8506161de3..99503a686b 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 @@ -251,7 +251,7 @@ object InteractiveTxBuilder { case class Remote(serialId: UInt64, amount: Satoshi, pubkeyScript: ByteVector) extends Output with Incoming /** The shared output can be added by us or by our peer, depending on who initiated the protocol. */ - case class Shared(serialId: UInt64, pubkeyScript: ByteVector, localAmount: MilliSatoshi, remoteAmount: MilliSatoshi, htlcsAmount: MilliSatoshi = 0 msat) extends Output with Incoming with Outgoing { + case class Shared(serialId: UInt64, pubkeyScript: ByteVector, localAmount: MilliSatoshi, remoteAmount: MilliSatoshi, htlcsAmount: MilliSatoshi) extends Output with Incoming with Outgoing { // Note that the truncation is a no-op: the sum of balances in a channel must be a satoshi amount. override val amount: Satoshi = (localAmount + remoteAmount + htlcsAmount).truncateToSatoshi } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala index 9bcc9e7c35..3f8b7b538f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala @@ -2510,13 +2510,19 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("reference test vector") { val channelId = ByteVector32.Zeroes val parentTx = Transaction.read("02000000000101f86fd1d0db3ac5a72df968622f31e6b5e6566a09e29206d7c7a55df90e181de800000000171600141fb9623ffd0d422eacc450fd1e967efc477b83ccffffffff0580b2e60e00000000220020fd89acf65485df89797d9ba7ba7a33624ac4452f00db08107f34257d33e5b94680b2e60e0000000017a9146a235d064786b49e7043e4a042d4cc429f7eb6948780b2e60e00000000160014fbb4db9d85fba5e301f4399e3038928e44e37d3280b2e60e0000000017a9147ecd1b519326bc13b0ec716e469b58ed02b112a087f0006bee0000000017a914f856a70093da3a5b5c4302ade033d4c2171705d387024730440220696f6cee2929f1feb3fd6adf024ca0f9aa2f4920ed6d35fb9ec5b78c8408475302201641afae11242160101c6f9932aeb4fcd1f13a9c6df5d1386def000ea259a35001210381d7d5b1bc0d7600565d827242576d9cb793bfe0754334af82289ee8b65d137600000000") - val sharedOutput = Output.Shared(UInt64(44), hex"0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5", 200_000_000_000L msat, 200_000_000_000L msat) + val localAmountIn = 1_749_990_000_000L msat + val remoteAmountIn = 2_000_000_000_000L msat + val localAmountOut = localAmountIn + (200_000_000_000L msat) + val remoteAmountOut = remoteAmountIn + (200_000_000_000L msat) + val htlcsAmount = parentTx.txOut(4).amount - (localAmountIn + remoteAmountIn) + val sharedOutput = Output.Shared(UInt64(44), hex"0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5", localAmountOut, remoteAmountOut, htlcsAmount) + val sharedInput = Input.Shared(UInt64(22), OutPoint(parentTx, 4), 4294967293L, localAmountIn, remoteAmountIn) val initiatorTx = { - val initiatorInput = Input.Local(UInt64(20), parentTx, 0, 4294967293L) + val initiatorInput = Input.Local(UInt64(20), parentTx, 0, 4294967293L) // 2_500_000_000 sat val initiatorOutput = Output.Local.Change(UInt64(30), 49_999_845 sat, hex"00141ca1cca8855bad6bc1ea5436edd8cff10b7e448b") - val nonInitiatorInput = Input.Remote(UInt64(11), OutPoint(parentTx, 2), parentTx.txOut(2), 4294967293L) + val nonInitiatorInput = Input.Remote(UInt64(11), OutPoint(parentTx, 2), parentTx.txOut(2), 4294967293L) // 2_500_000_000 sat val nonInitiatorOutput = Output.Remote(UInt64(33), 49_999_900 sat, hex"001444cb0c39f93ecc372b5851725bd29d865d333b10") - SharedTransaction(None, sharedOutput, List(initiatorInput), List(nonInitiatorInput), List(initiatorOutput), List(nonInitiatorOutput), lockTime = 120) + SharedTransaction(Some(sharedInput), sharedOutput, List(initiatorInput), List(nonInitiatorInput), List(initiatorOutput), List(nonInitiatorOutput), lockTime = 120) } assert(initiatorTx.localFees == 155_000.msat) assert(initiatorTx.remoteFees == 100_000.msat) @@ -2527,23 +2533,23 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val initiatorOutput = Output.Remote(UInt64(30), 49_999_845 sat, hex"00141ca1cca8855bad6bc1ea5436edd8cff10b7e448b") val nonInitiatorInput = Input.Local(UInt64(11), parentTx, 2, 4294967293L) val nonInitiatorOutput = Output.Local.Change(UInt64(33), 49_999_900 sat, hex"001444cb0c39f93ecc372b5851725bd29d865d333b10") - SharedTransaction(None, sharedOutput, List(nonInitiatorInput), List(initiatorInput), List(nonInitiatorOutput), List(initiatorOutput), lockTime = 120) + SharedTransaction(Some(sharedInput), sharedOutput, List(nonInitiatorInput), List(initiatorInput), List(nonInitiatorOutput), List(initiatorOutput), lockTime = 120) } assert(nonInitiatorTx.localFees == 100_000.msat) assert(nonInitiatorTx.remoteFees == 155_000.msat) assert(nonInitiatorTx.fees == 255.sat) - val unsignedTx = Transaction.read("0200000002b932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430000000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000000016001444cb0c39f93ecc372b5851725bd29d865d333b100084d71700000000220020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec578000000") + val unsignedTx = Transaction.read("0200000003b932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430000000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430400000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000000016001444cb0c39f93ecc372b5851725bd29d865d333b10f084420601000000220020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec578000000") assert(initiatorTx.buildUnsignedTx().txid == unsignedTx.txid) assert(nonInitiatorTx.buildUnsignedTx().txid == unsignedTx.txid) val initiatorSigs = TxSignatures(channelId, unsignedTx, Seq(ScriptWitness(Seq(hex"68656c6c6f2074686572652c2074686973206973206120626974636f6e212121", hex"82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87"))), None) val nonInitiatorSigs = TxSignatures(channelId, unsignedTx, Seq(ScriptWitness(Seq(hex"304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d01", hex"034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"))), None) val initiatorSignedTx = FullySignedSharedTransaction(initiatorTx, initiatorSigs, nonInitiatorSigs, None) - assert(initiatorSignedTx.feerate == FeeratePerKw(262 sat)) + assert(initiatorSignedTx.feerate == FeeratePerKw(224 sat)) val nonInitiatorSignedTx = FullySignedSharedTransaction(nonInitiatorTx, nonInitiatorSigs, initiatorSigs, None) - assert(nonInitiatorSignedTx.feerate == FeeratePerKw(262 sat)) - val signedTx = Transaction.read("02000000000102b932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430000000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000000016001444cb0c39f93ecc372b5851725bd29d865d333b100084d71700000000220020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec50247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484022068656c6c6f2074686572652c2074686973206973206120626974636f6e2121212782012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff8778000000") + assert(nonInitiatorSignedTx.feerate == FeeratePerKw(224 sat)) + val signedTx = Transaction.read("02000000000103b932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430000000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430400000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000000016001444cb0c39f93ecc372b5851725bd29d865d333b10f084420601000000220020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec50247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484022068656c6c6f2074686572652c2074686973206973206120626974636f6e2121212782012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff870078000000") assert(initiatorSignedTx.signedTx == signedTx) assert(initiatorSignedTx.signedTx == nonInitiatorSignedTx.signedTx) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index 98c27c5d00..fd4f01c19f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -33,6 +33,7 @@ import fr.acinq.eclair.channel.states.ChannelStateTestsBase.{FakeTxPublisherFact import fr.acinq.eclair.channel.states.ChannelStateTestsTags.{AnchorOutputsZeroFeeHtlcTxs, NoMaxHtlcValueInFlight, ZeroConf} import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.testutils.PimpTestProbe.convert +import fr.acinq.eclair.transactions.DirectedHtlc.{incoming, outgoing} import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol._ import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -70,6 +71,12 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val sender = TestProbe() val cmd = CMD_SPLICE(sender.ref, spliceIn_opt, spliceOut_opt) alice ! cmd + if (alice.stateData.asInstanceOf[DATA_NORMAL].commitments.params.useQuiescence) { + alice2bob.expectMsgType[Stfu] + alice2bob.forward(bob) + bob2alice.expectMsgType[Stfu] + bob2alice.forward(alice) + } alice2bob.expectMsgType[SpliceInit] alice2bob.forward(bob) bob2alice.expectMsgType[SpliceAck] @@ -1494,4 +1501,89 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2blockchain.expectNoMessage(100 millis) } + test("recv CMD_SPLICE (splice-in + pending htlcs)", Tag(ChannelStateTestsTags.Quiescence)) { f => + import f._ + + addHtlc(5_000 msat, alice, bob, alice2bob, bob2alice) + addHtlc(5_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + addHtlc(5_000 msat, bob, alice, bob2alice, alice2bob) + addHtlc(5_000 msat, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(initialState.commitments.latest.capacity == 1_500_000.sat) + assert(initialState.commitments.latest.localCommit.spec.toLocal == 799_990_000.msat) + assert(initialState.commitments.latest.localCommit.spec.toRemote == 699_990_000.msat) + + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) + + val finalState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(finalState.commitments.latest.capacity == 2_000_000.sat) + assert(finalState.commitments.latest.localCommit.spec.toLocal == 1_299_990_000.msat) + assert(finalState.commitments.latest.localCommit.spec.toRemote == 699_990_000.msat) + + assert(finalState.commitments.latest.localCommit.spec.htlcs.collect(incoming).toSeq.map(_.amountMsat).sum == 10_000.msat) + assert(finalState.commitments.latest.localCommit.spec.htlcs.collect(outgoing).toSeq.map(_.amountMsat).sum == 10_000.msat) + } + + test("recv CMD_SPLICE (splice-out + pending htlcs)", Tag(ChannelStateTestsTags.Quiescence)) { f => + import f._ + + addHtlc(5_000 msat, alice, bob, alice2bob, bob2alice) + addHtlc(5_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + addHtlc(5_000 msat, bob, alice, bob2alice, alice2bob) + addHtlc(5_000 msat, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(initialState.commitments.latest.capacity == 1_500_000.sat) + assert(initialState.commitments.latest.localCommit.spec.toLocal == 799_990_000.msat) + assert(initialState.commitments.latest.localCommit.spec.toRemote == 699_990_000.msat) + + initiateSplice(f, spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + + val finalState = alice.stateData.asInstanceOf[DATA_NORMAL] + val fundingTx1 = finalState.commitments.latest.localFundingStatus.signedTx_opt.get + val feerate = alice.nodeParams.onChainFeeConf.getFundingFeerate(alice.nodeParams.currentFeerates) + val expectedMiningFee = Transactions.weight2fee(feerate, fundingTx1.weight()) + val actualMiningFee = 1_400_000.sat - finalState.commitments.latest.capacity + // fee computation is approximate + assert(actualMiningFee - expectedMiningFee < 100.sat || expectedMiningFee - actualMiningFee < 100.sat) + // initiator pays the fee + assert(finalState.commitments.latest.localCommit.spec.toLocal == 699_990_000.msat - actualMiningFee) + assert(finalState.commitments.latest.localCommit.spec.toRemote == 699_990_000.msat) + + assert(finalState.commitments.latest.localCommit.spec.htlcs.collect(incoming).toSeq.map(_.amountMsat).sum == 10_000.msat) + assert(finalState.commitments.latest.localCommit.spec.htlcs.collect(outgoing).toSeq.map(_.amountMsat).sum == 10_000.msat) + } + + test("recv CMD_SPLICE (splice-in + splice-out + pending htlcs)", Tag(ChannelStateTestsTags.Quiescence)) { f => + import f._ + + addHtlc(5_000 msat, alice, bob, alice2bob, bob2alice) + addHtlc(5_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + addHtlc(5_000 msat, bob, alice, bob2alice, alice2bob) + addHtlc(5_000 msat, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(initialState.commitments.latest.capacity == 1_500_000.sat) + assert(initialState.commitments.latest.localCommit.spec.toLocal == 799_990_000.msat) + assert(initialState.commitments.latest.localCommit.spec.toRemote == 699_990_000.msat) + + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + + // NB: since there is a splice-in, swap-out fees will be paid from bitcoind so final capacity is predictable + val finalState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(finalState.commitments.latest.capacity == 1_900_000.sat) + assert(finalState.commitments.latest.localCommit.spec.toLocal == 1_199_990_000.msat) + assert(finalState.commitments.latest.localCommit.spec.toRemote == 699_990_000.msat) + + assert(finalState.commitments.latest.localCommit.spec.htlcs.collect(incoming).toSeq.map(_.amountMsat).sum == 10_000.msat) + assert(finalState.commitments.latest.localCommit.spec.htlcs.collect(outgoing).toSeq.map(_.amountMsat).sum == 10_000.msat) + } + } 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 4e18d08786..152d7e45ac 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 @@ -123,7 +123,7 @@ class ChannelCodecs4Spec extends AnyFunSuite { val fundingInput = InputInfo(OutPoint(randomBytes32(), 3), TxOut(175_000 sat, Script.pay2wpkh(randomKey().publicKey)), Nil) val fundingTx = SharedTransaction( sharedInput_opt = None, - sharedOutput = InteractiveTxBuilder.Output.Shared(UInt64(8), ByteVector.empty, 100_000_600 msat, 74_000_400 msat), + sharedOutput = InteractiveTxBuilder.Output.Shared(UInt64(8), ByteVector.empty, 100_000_600 msat, 74_000_400 msat, 150_000_000 msat), localInputs = Nil, remoteInputs = Nil, localOutputs = Nil, remoteOutputs = Nil, lockTime = 0