From 177c8014029660b278448bf3fb5a62dced914c5b Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 22 Aug 2023 17:30:11 +0200 Subject: [PATCH] Allow splicing on non dual-funded channels We currently allow splicing on top of a channel that was created without using dual funding, but it results in an incorrect channel reserve being used post-splice. --- .../fr/acinq/eclair/channel/Commitments.scala | 8 ++--- .../fr/acinq/eclair/channel/fsm/Channel.scala | 2 +- .../channel/fund/InteractiveTxBuilder.scala | 2 +- .../ChannelStateTestsHelperMethods.scala | 2 +- .../states/e/NormalSplicesStateSpec.scala | 29 +++++++++++++++++++ 5 files changed, 36 insertions(+), 7 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 fb34dd3095..260e820776 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 @@ -103,14 +103,14 @@ case class ChannelParams(channelId: ByteVector32, def minDepthDualFunding(defaultMinDepth: Int, sharedTx: SharedTransaction): Option[Long] = minDepthDualFunding(defaultMinDepth, sharedTx.sharedOutput.amount, Some(sharedTx.remoteInputs.nonEmpty)) /** Channel reserve that applies to our funds. */ - def localChannelReserveForCapacity(capacity: Satoshi): Satoshi = if (channelFeatures.hasFeature(Features.DualFunding)) { + def localChannelReserveForCapacity(capacity: Satoshi, isSplice: Boolean): Satoshi = if (channelFeatures.hasFeature(Features.DualFunding) || isSplice) { (capacity / 100).max(remoteParams.dustLimit) } else { remoteParams.requestedChannelReserve_opt.get // this is guarded by a require() in Params } /** Channel reserve that applies to our peer's funds. */ - def remoteChannelReserveForCapacity(capacity: Satoshi): Satoshi = if (channelFeatures.hasFeature(Features.DualFunding)) { + def remoteChannelReserveForCapacity(capacity: Satoshi, isSplice: Boolean): Satoshi = if (channelFeatures.hasFeature(Features.DualFunding) || isSplice) { (capacity / 100).max(localParams.dustLimit) } else { localParams.requestedChannelReserve_opt.get // this is guarded by a require() in Params @@ -264,10 +264,10 @@ case class Commitment(fundingTxIndex: Long, val capacity: Satoshi = commitInput.txOut.amount /** Channel reserve that applies to our funds. */ - def localChannelReserve(params: ChannelParams): Satoshi = params.localChannelReserveForCapacity(capacity) + def localChannelReserve(params: ChannelParams): Satoshi = params.localChannelReserveForCapacity(capacity, fundingTxIndex > 0) /** Channel reserve that applies to our peer's funds. */ - def remoteChannelReserve(params: ChannelParams): Satoshi = params.remoteChannelReserveForCapacity(capacity) + def remoteChannelReserve(params: ChannelParams): Satoshi = params.remoteChannelReserveForCapacity(capacity, fundingTxIndex > 0) // NB: when computing availableBalanceForSend and availableBalanceForReceive, the initiator keeps an extra buffer on // top of its usual channel reserve to avoid getting channels stuck in case the on-chain feerate increases (see 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 c2760e5470..df6db45ce1 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 @@ -570,7 +570,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with stay() using d1 storing() sending signingSession1.localSigs calling endQuiescence(d1) } } - case _ if d.commitments.params.channelFeatures.hasFeature(Features.DualFunding) && d.commitments.latest.localFundingStatus.signedTx_opt.isEmpty && commit.batchSize == 1 => + case _ if d.commitments.latest.localFundingStatus.isInstanceOf[LocalFundingStatus.DualFundedUnconfirmedFundingTx] && d.commitments.latest.localFundingStatus.signedTx_opt.isEmpty && commit.batchSize == 1 => // The latest funding transaction is unconfirmed and we're missing our peer's tx_signatures: any commit_sig // that we receive before that should be ignored, it's either a retransmission of a commit_sig we've already // received or a bug that will eventually lead to a force-close anyway. 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 572811a86d..80ecc936d2 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 @@ -649,7 +649,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } val sharedInput_opt = fundingParams.sharedInput_opt.map(_ => { - val remoteReserve = channelParams.remoteChannelReserveForCapacity(fundingParams.fundingAmount) + val remoteReserve = channelParams.remoteChannelReserveForCapacity(fundingParams.fundingAmount, isSplice = true) // We ignore the reserve requirement if we are splicing funds into the channel, which increases the size of the reserve. if (sharedOutput.remoteAmount < remoteReserve && remoteOutputs.nonEmpty && localInputs.isEmpty) { log.warn("invalid interactive tx: peer takes too much funds out and falls below the channel reserve ({} < {})", sharedOutput.remoteAmount, remoteReserve) 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 d2b16961a2..e164ecdb18 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 @@ -154,7 +154,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.onChainFeeConf.spendAnchorWithoutHtlcs).setToIf(tags.contains(ChannelStateTestsTags.DontSpendAnchorWithoutHtlcs))(false) val wallet = wallet_opt match { case Some(wallet) => wallet - case None => if (tags.contains(ChannelStateTestsTags.DualFunding)) new SingleKeyOnChainWallet() else new DummyOnChainWallet() + case None => if (tags.contains(ChannelStateTestsTags.DualFunding) || tags.contains(ChannelStateTestsTags.Splicing)) new SingleKeyOnChainWallet() else new DummyOnChainWallet() } val alice: TestFSMRef[ChannelState, ChannelData, Channel] = { implicit val system: ActorSystem = systemA 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..e1b20e174f 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 @@ -158,6 +158,35 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toRemote == 700_000_000.msat) } + test("recv CMD_SPLICE (splice-in, non dual-funded channel)") { () => + val f = init(tags = Set(ChannelStateTestsTags.DualFunding, ChannelStateTestsTags.Splicing)) + import f._ + + reachNormal(f, tags = Set(ChannelStateTestsTags.Splicing)) // we open a non dual-funded channel + alice2bob.ignoreMsg { case _: ChannelUpdate => true } + bob2alice.ignoreMsg { case _: ChannelUpdate => true } + awaitCond(alice.stateName == NORMAL && bob.stateName == NORMAL) + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(!initialState.commitments.params.channelFeatures.hasFeature(Features.DualFunding)) + assert(initialState.commitments.latest.capacity == 1_000_000.sat) + assert(initialState.commitments.latest.localCommit.spec.toLocal == 800_000_000.msat) + assert(initialState.commitments.latest.localCommit.spec.toRemote == 200_000_000.msat) + // The channel reserve is set by each participant when not using dual-funding. + assert(initialState.commitments.latest.localChannelReserve == 20_000.sat) + assert(initialState.commitments.latest.remoteChannelReserve == 10_000.sat) + + // We can splice on top of a non dual-funded channel. + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) + val postSpliceState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(!postSpliceState.commitments.params.channelFeatures.hasFeature(Features.DualFunding)) + assert(postSpliceState.commitments.latest.capacity == 1_500_000.sat) + assert(postSpliceState.commitments.latest.localCommit.spec.toLocal == 1_300_000_000.msat) + assert(postSpliceState.commitments.latest.localCommit.spec.toRemote == 200_000_000.msat) + // The channel reserve is now implicitly set to 1% of the channel capacity on both sides. + assert(postSpliceState.commitments.latest.localChannelReserve == 15_000.sat) + assert(postSpliceState.commitments.latest.remoteChannelReserve == 15_000.sat) + } + test("recv CMD_SPLICE (splice-out)") { f => import f._