From dceb6b9b068ef2b99dccbd3ee1a76a7b3351ac9f Mon Sep 17 00:00:00 2001 From: Pierre-Marie Padiou Date: Thu, 15 Mar 2018 18:53:14 +0100 Subject: [PATCH 1/3] Better handling of force-closing of channels (#483) As soon as we receive a valid closing signature, we will publish the resulting closing tx instead of our commitment tx if we need to immediately close the channel (before end of negotiation, e.g. in case of errors, or in case the counterparty goes OFFLINE). When the closing signature didn't correspond to one we sent ourselves, we weren't properly recognizing the publishing tx and went into `ERR_INFORMATION_LEAK` state. CMD_CLOSE is now split it to commands: * `CMD_CLOSE`: this command will succeed only if the current channel state is in NORMAL, or if the channel hasn't yet been created. * `CMD_FORCECLOSE`: the channel will publish its current local commitment (or its best signed closing tx if it has one). Using `CMD_FORCECLOSE` is more expensive and it incurs a delay before funds are spendable, but this can be useful in some situations, for example when the counterparty isn't responding anymore. Added a new `forceclose` method to the API and a force close button in the GUI. --- README.md | 1 + .../scala/fr/acinq/eclair/api/Service.scala | 9 +++- .../fr/acinq/eclair/channel/Channel.scala | 41 +++++++++++-------- .../eclair/channel/ChannelExceptions.scala | 1 + .../acinq/eclair/channel/ChannelTypes.scala | 11 +++-- .../b/WaitForFundingSignedStateSpec.scala | 13 ++++-- .../c/WaitForFundingConfirmedStateSpec.scala | 13 +++++- .../c/WaitForFundingLockedStateSpec.scala | 14 ++++++- .../channel/states/e/NormalStateSpec.scala | 6 +-- .../channel/states/h/ClosingStateSpec.scala | 34 ++++++++++++++- .../eclair/integration/IntegrationSpec.scala | 9 ++-- .../main/resources/gui/main/channelPane.fxml | 7 +++- .../src/main/resources/gui/main/main.css | 4 ++ .../fr/acinq/eclair/gui/GUIUpdater.scala | 26 ++++++++---- .../controllers/ChannelPaneController.scala | 1 + 15 files changed, 139 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index e0e2e026a..76793a79d 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ java -Declair.datadir=/tmp/node1 -jar eclair-node-gui--.jar checkpayment | paymentRequest | returns true if the payment has been received, false otherwise close | channelId | close a channel close | channelId, scriptPubKey | close a channel and send the funds to the given scriptPubKey + forceclose | channelId | force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)" help | | display available methods ## Docker diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala index 83d040837..d3151fa37 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -141,10 +141,14 @@ trait Service extends Logging { case _ => reject(UnknownParamsRejection(req.id, s"[nodeId, fundingSatoshis], [nodeId, fundingSatoshis, pushMsat], [nodeId, fundingSatoshis, pushMsat, feerateSatPerByte] or [nodeId, fundingSatoshis, pushMsat, feerateSatPerByte, flag]")) } case "close" => req.params match { - case JString(identifier) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String]) - case JString(identifier) :: JString(scriptPubKey) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(scriptPubKey))).mapTo[String]) + case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String]) + case JString(identifier) :: JString(scriptPubKey) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(scriptPubKey))).mapTo[String]) case _ => reject(UnknownParamsRejection(req.id, "[channelId] or [channelId, scriptPubKey]")) } + case "forceclose" => req.params match { + case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_FORCECLOSE).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, "[channelId]")) + } // local network methods case "peers" => completeRpcFuture(req.id, for { @@ -273,6 +277,7 @@ trait Service extends Logging { "send (paymentRequest, amountMsat): send a payment to a lightning node using a BOLT11 payment request and a custom amount", "close (channelId): close a channel", "close (channelId, scriptPubKey): close a channel and send the funds to the given scriptPubKey", + "forceclose (channelId): force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)", "checkpayment (paymentHash): returns true if the payment has been received, false otherwise", "checkpayment (paymentRequest): returns true if the payment has been received, false otherwise", "getinfo: returns info about the blockchain and this node", diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index cc6413302..75cb08a35 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -174,6 +174,8 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu blockchain ! WatchLost(self, data.commitments.commitInput.outPoint.txid, nodeParams.minDepthBlocks, BITCOIN_FUNDING_LOST) goto(OFFLINE) using data } + + case Event(CMD_CLOSE(_), _) => goto(CLOSED) replying "ok" }) when(WAIT_FOR_OPEN_CHANNEL)(handleExceptions { @@ -220,7 +222,7 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu goto(WAIT_FOR_FUNDING_CREATED) using DATA_WAIT_FOR_FUNDING_CREATED(open.temporaryChannelId, localParams, remoteParams, open.fundingSatoshis, open.pushMsat, open.feeratePerKw, open.firstPerCommitmentPoint, open.channelFlags, accept) sending accept } - case Event(CMD_CLOSE(_), _) => goto(CLOSED) + case Event(CMD_CLOSE(_), _) => goto(CLOSED) replying "ok" case Event(e: Error, d: DATA_WAIT_FOR_OPEN_CHANNEL) => handleRemoteError(e, d) @@ -261,7 +263,7 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu case Event(CMD_CLOSE(_), _) => replyToUser(Right("closed")) - goto(CLOSED) + goto(CLOSED) replying "ok" case Event(e: Error, d: DATA_WAIT_FOR_ACCEPT_CHANNEL) => replyToUser(Left(Right(e))) @@ -300,7 +302,7 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu case Event(CMD_CLOSE(_), _) => replyToUser(Right("closed")) - goto(CLOSED) + goto(CLOSED) replying "ok" case Event(e: Error, d: DATA_WAIT_FOR_FUNDING_INTERNAL) => replyToUser(Left(Right(e))) @@ -352,7 +354,7 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu goto(WAIT_FOR_FUNDING_CONFIRMED) using store(DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, None, Right(fundingSigned))) sending fundingSigned } - case Event(CMD_CLOSE(_), _) => goto(CLOSED) + case Event(CMD_CLOSE(_), _) => goto(CLOSED) replying "ok" case Event(e: Error, d: DATA_WAIT_FOR_FUNDING_CREATED) => handleRemoteError(e, d) @@ -403,11 +405,11 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu goto(WAIT_FOR_FUNDING_CONFIRMED) using nextState } - case Event(CMD_CLOSE(_), d: DATA_WAIT_FOR_FUNDING_SIGNED) => + case Event(CMD_CLOSE(_) | CMD_FORCECLOSE, d: DATA_WAIT_FOR_FUNDING_SIGNED) => // we rollback the funding tx, it will never be published wallet.rollback(d.fundingTx) replyToUser(Right("closed")) - goto(CLOSED) + goto(CLOSED) replying "ok" case Event(e: Error, d: DATA_WAIT_FOR_FUNDING_SIGNED) => // we rollback the funding tx, it will never be published @@ -456,8 +458,6 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx), d: DATA_WAIT_FOR_FUNDING_CONFIRMED) => handleInformationLeak(tx, d) - case Event(CMD_CLOSE(_), d: DATA_WAIT_FOR_FUNDING_CONFIRMED) => spendLocalCurrent(d) - case Event(e: Error, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) => handleRemoteError(e, d) }) @@ -480,8 +480,6 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx), d: DATA_WAIT_FOR_FUNDING_LOCKED) => handleInformationLeak(tx, d) - case Event(CMD_CLOSE(_), d: DATA_WAIT_FOR_FUNDING_LOCKED) => spendLocalCurrent(d) - case Event(e: Error, d: DATA_WAIT_FOR_FUNDING_LOCKED) => handleRemoteError(e, d) }) @@ -1076,6 +1074,9 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu if (d.mutualCloseProposed.map(_.txid).contains(tx.txid)) { // at any time they can publish a closing tx with any sig we sent them handleMutualClose(tx, Right(d)) + } else if (d.mutualClosePublished.map(_.txid).contains(tx.txid)) { + // we have published a closing tx which isn't one that we proposed, and used it instead of our last commitment when an error happened + handleMutualClose(tx, Right(d)) } else if (Some(tx.txid) == d.localCommitPublished.map(_.commitTx.txid)) { // this is because WatchSpent watches never expire and we are notified multiple times stay @@ -1223,8 +1224,6 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu goto(SYNCING) sending channelReestablish - case Event(c: CMD_CLOSE, d: HasCommitments) => handleLocalError(ForcedLocalCommit(d.channelId, "can't do a mutual close while disconnected"), d, Some(c)) replying "ok" - case Event(c@CurrentBlockCount(count), d: HasCommitments) if d.commitments.hasTimedoutOutgoingHtlcs(count) => // note: this can only happen if state is NORMAL or SHUTDOWN // -> in NEGOTIATING there are no more htlcs @@ -1333,8 +1332,6 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu goto(NEGOTIATING) using d.copy(closingTxProposed = closingTxProposed1) sending d.localShutdown } - case Event(c: CMD_CLOSE, d: HasCommitments) => handleLocalError(ForcedLocalCommit(d.channelId, "can't do a mutual close while syncing"), d, Some(c)) - case Event(c@CurrentBlockCount(count), d: HasCommitments) if d.commitments.hasTimedoutOutgoingHtlcs(count) => handleLocalError(HtlcTimedout(d.channelId), d, Some(c)) case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx), d: DATA_NEGOTIATING) if d.closingTxProposed.flatten.map(_.unsignedTx.txid).contains(tx.txid) => handleMutualClose(tx, Left(d)) @@ -1366,8 +1363,6 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu whenUnhandled { - case Event(c@INPUT_PUBLISH_LOCALCOMMIT, d: HasCommitments) => handleLocalError(ForcedLocalCommit(d.channelId, "manual unilateral close"), d, Some(c)) - case Event(INPUT_DISCONNECTED, _) => goto(OFFLINE) case Event(WatchEventLost(BITCOIN_FUNDING_LOST), _) => goto(ERR_FUNDING_LOST) @@ -1393,6 +1388,14 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu case _ => handleCommandError(AddHtlcFailed(d.channelId, c.paymentHash, error, origin(c), None), c) // we don't provide a channel_update: this will be a permanent channel failure } + case Event(c: CMD_CLOSE, d) => handleCommandError(CannotCloseInThisState(Helpers.getChannelId(d), stateName), c) + + case Event(c@CMD_FORCECLOSE, d) => + d match { + case data: HasCommitments => handleLocalError(ForcedLocalCommit(data.channelId, "forced local commit"), data, Some(c)) replying "ok" + case _ => handleCommandError(CannotCloseInThisState(Helpers.getChannelId(d), stateName), c) + } + // we only care about this event in NORMAL and SHUTDOWN state, and we never unregister to the event stream case Event(CurrentBlockCount(_), _) => stay @@ -1481,7 +1484,10 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu } def handleLocalError(cause: Throwable, d: HasCommitments, msg: Option[Any]) = { - log.error(s"${cause.getMessage} while processing msg=${msg.getOrElse("n/a").getClass.getSimpleName} in state=$stateName") + cause match { + case _: ForcedLocalCommit => log.warning(s"force-closing channel at user request") + case _ => log.error(s"${cause.getMessage} while processing msg=${msg.getOrElse("n/a").getClass.getSimpleName} in state=$stateName") + } cause match { case _: ChannelException => () case _ => log.error(cause, s"msg=${msg.getOrElse("n/a")} stateData=$stateData ") @@ -1490,6 +1496,7 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu d match { case negotiating@DATA_NEGOTIATING(_, _, _, _, Some(bestUnpublishedClosingTx)) => + log.info(s"we have a valid closing tx, publishing it instead of our commitment: closingTxId=${bestUnpublishedClosingTx.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 _ => 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 d8125d591..7c0a1aaea 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 @@ -22,6 +22,7 @@ case class ChannelReserveTooHigh (override val channelId: BinaryDa case class ChannelFundingError (override val channelId: BinaryData) extends ChannelException(channelId, "channel funding error") case class NoMoreHtlcsClosingInProgress (override val channelId: BinaryData) extends ChannelException(channelId, "cannot send new htlcs, closing in progress") case class ClosingAlreadyInProgress (override val channelId: BinaryData) extends ChannelException(channelId, "closing already in progress") +case class CannotCloseInThisState (override val channelId: BinaryData, state: State) extends ChannelException(channelId, s"cannot close in state=$state") case class CannotCloseWithUnsignedOutgoingHtlcs(override val channelId: BinaryData) extends ChannelException(channelId, "cannot close when there are unsigned outgoing htlcs") case class ChannelUnavailable (override val channelId: BinaryData) extends ChannelException(channelId, "channel is unavailable (offline or closing)") case class InvalidFinalScript (override val channelId: BinaryData) extends ChannelException(channelId, "invalid final script") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala index df3f659c4..6dba96393 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala @@ -33,7 +33,6 @@ case object WAIT_FOR_ACCEPT_CHANNEL extends State case object WAIT_FOR_FUNDING_INTERNAL extends State case object WAIT_FOR_FUNDING_CREATED extends State case object WAIT_FOR_FUNDING_SIGNED extends State -case object WAIT_FOR_FUNDING_PUBLISHED extends State case object WAIT_FOR_FUNDING_CONFIRMED extends State case object WAIT_FOR_FUNDING_LOCKED extends State case object NORMAL extends State @@ -63,7 +62,6 @@ case object ERR_INFORMATION_LEAK extends State case class INPUT_INIT_FUNDER(temporaryChannelId: BinaryData, fundingSatoshis: Long, pushMsat: Long, initialFeeratePerKw: Long, fundingTxFeeratePerKw: Long, localParams: LocalParams, remote: ActorRef, remoteInit: Init, channelFlags: Byte) case class INPUT_INIT_FUNDEE(temporaryChannelId: BinaryData, localParams: LocalParams, remote: ActorRef, remoteInit: Init) case object INPUT_CLOSE_COMPLETE_TIMEOUT // when requesting a mutual close, we wait for as much as this timeout, then unilateral close -case object INPUT_PUBLISH_LOCALCOMMIT // used in tests case object INPUT_DISCONNECTED case class INPUT_RECONNECTED(remote: ActorRef) case class INPUT_RESTORED(data: HasCommitments) @@ -97,11 +95,12 @@ final case class CMD_FULFILL_HTLC(id: Long, r: BinaryData, commit: Boolean = fal final case class CMD_FAIL_HTLC(id: Long, reason: Either[BinaryData, FailureMessage], commit: Boolean = false) extends Command final case class CMD_FAIL_MALFORMED_HTLC(id: Long, onionHash: BinaryData, failureCode: Int, commit: Boolean = false) extends Command final case class CMD_UPDATE_FEE(feeratePerKw: Long, commit: Boolean = false) extends Command -case object CMD_SIGN extends Command +final case object CMD_SIGN extends Command final case class CMD_CLOSE(scriptPubKey: Option[BinaryData]) extends Command -case object CMD_GETSTATE extends Command -case object CMD_GETSTATEDATA extends Command -case object CMD_GETINFO extends Command +final case object CMD_FORCECLOSE extends Command +final case object CMD_GETSTATE extends Command +final case object CMD_GETSTATEDATA extends Command +final case object CMD_GETINFO extends Command final case class RES_GETINFO(nodeId: BinaryData, channelId: BinaryData, state: State, data: Data) /* diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala index 8e9f8fed6..178308274 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala @@ -40,7 +40,7 @@ class WaitForFundingSignedStateSpec extends TestkitBaseClass with StateTestsHelp test((alice, alice2bob, bob2alice, alice2blockchain)) } - test("recv FundingSigned with valid signature") { case (alice, alice2bob, bob2alice, alice2blockchain) => + test("recv FundingSigned with valid signature") { case (alice, _, bob2alice, alice2blockchain) => within(30 seconds) { bob2alice.expectMsgType[FundingSigned] bob2alice.forward(alice) @@ -50,7 +50,7 @@ class WaitForFundingSignedStateSpec extends TestkitBaseClass with StateTestsHelp } } - test("recv FundingSigned with invalid signature") { case (alice, alice2bob, bob2alice, alice2blockchain) => + test("recv FundingSigned with invalid signature") { case (alice, alice2bob, _, _) => within(30 seconds) { // sending an invalid sig alice ! FundingSigned("00" * 32, BinaryData("00" * 64)) @@ -59,11 +59,18 @@ class WaitForFundingSignedStateSpec extends TestkitBaseClass with StateTestsHelp } } - test("recv CMD_CLOSE") { case (alice, alice2bob, bob2alice, _) => + test("recv CMD_CLOSE") { case (alice, _, _, _) => within(30 seconds) { alice ! CMD_CLOSE(None) awaitCond(alice.stateName == CLOSED) } } + test("recv CMD_FORCECLOSE") { case (alice, _, _, _) => + within(30 seconds) { + alice ! CMD_FORCECLOSE + awaitCond(alice.stateName == CLOSED) + } + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala index ff40c2e18..2c132ff1e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala @@ -1,5 +1,6 @@ package fr.acinq.eclair.channel.states.c +import akka.actor.Status.Failure import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.Transaction import fr.acinq.eclair.TestConstants.{Alice, Bob} @@ -104,10 +105,18 @@ class WaitForFundingConfirmedStateSpec extends TestkitBaseClass with StateTestsH } } - test("recv CMD_CLOSE") { case (alice, _, _, _, alice2blockchain) => + test("recv CMD_CLOSE") { case (alice, _, _, _, _) => + within(30 seconds) { + val sender = TestProbe() + sender.send(alice, CMD_CLOSE(None)) + sender.expectMsg(Failure(CannotCloseInThisState(channelId(alice), WAIT_FOR_FUNDING_CONFIRMED))) + } + } + + test("recv CMD_FORCECLOSE") { case (alice, _, _, _, alice2blockchain) => within(30 seconds) { val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CONFIRMED].commitments.localCommit.publishableTxs.commitTx.tx - alice ! CMD_CLOSE(None) + alice ! CMD_FORCECLOSE awaitCond(alice.stateName == CLOSING) alice2blockchain.expectMsg(PublishAsap(tx)) alice2blockchain.expectMsgType[PublishAsap] // claim-main-delayed diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala index c4b1a7e17..9edbe5894 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala @@ -1,5 +1,7 @@ package fr.acinq.eclair.channel.states.c +import akka.actor.Status +import akka.actor.Status.Failure import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.Transaction import fr.acinq.eclair.TestConstants.{Alice, Bob} @@ -94,10 +96,18 @@ class WaitForFundingLockedStateSpec extends TestkitBaseClass with StateTestsHelp } } - test("recv CMD_CLOSE") { case (alice, _, alice2bob, bob2alice, alice2blockchain, router) => + test("recv CMD_CLOSE") { case (alice, _, _, _, _, _) => + within(30 seconds) { + val sender = TestProbe() + sender.send(alice, CMD_CLOSE(None)) + sender.expectMsg(Failure(CannotCloseInThisState(channelId(alice), WAIT_FOR_FUNDING_LOCKED))) + } + } + + test("recv CMD_FORCECLOSE") { case (alice, _, _, _, alice2blockchain, _) => within(30 seconds) { val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_LOCKED].commitments.localCommit.publishableTxs.commitTx.tx - alice ! CMD_CLOSE(None) + alice ! CMD_FORCECLOSE awaitCond(alice.stateName == CLOSING) alice2blockchain.expectMsg(PublishAsap(tx)) alice2blockchain.expectMsgType[PublishAsap] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index e960ec655..4c0afd272 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -1863,11 +1863,9 @@ class NormalStateSpec extends TestkitBaseClass with StateTestsHelperMethods { within(30 seconds) { val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] val sender = TestProbe() - sender.send(alice, WatchEventConfirmed(BITCOIN_FUNDING_DEEPLYBURIED, 42, 10)) - assert(relayer.expectMsgType[LocalChannelUpdate].channelAnnouncement_opt === None) + sender.send(alice, WatchEventConfirmed(BITCOIN_FUNDING_DEEPLYBURIED, 400000, 42)) val annSigsA = alice2bob.expectMsgType[AnnouncementSignatures] - sender.send(bob, WatchEventConfirmed(BITCOIN_FUNDING_DEEPLYBURIED, 42, 10)) - assert(relayer.expectMsgType[LocalChannelUpdate].channelAnnouncement_opt === None) + sender.send(bob, WatchEventConfirmed(BITCOIN_FUNDING_DEEPLYBURIED, 400000, 42)) val annSigsB = bob2alice.expectMsgType[AnnouncementSignatures] import initialState.commitments.localParams import initialState.commitments.remoteParams 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 ff4722d41..157934fd9 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 @@ -3,8 +3,9 @@ package fr.acinq.eclair.channel.states.h import akka.actor.Status.Failure import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.{OutPoint, ScriptFlags, Transaction, TxIn} -import fr.acinq.eclair.TestkitBaseClass +import fr.acinq.eclair.{Globals, TestkitBaseClass} import fr.acinq.eclair.blockchain._ +import fr.acinq.eclair.blockchain.fee.FeeratesPerKw import fr.acinq.eclair.channel.states.StateTestsHelperMethods import fr.acinq.eclair.channel.{Data, State, _} import fr.acinq.eclair.payment.{CommandBuffer, ForwardAdd, ForwardFulfill, Local} @@ -101,7 +102,6 @@ class ClosingStateSpec extends TestkitBaseClass with StateTestsHelperMethods { } } - test("recv CMD_FULFILL_HTLC (unexisting htlc)") { case (alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, _, _) => within(30 seconds) { mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) @@ -115,6 +115,36 @@ class ClosingStateSpec extends TestkitBaseClass with StateTestsHelperMethods { } } + test("recv BITCOIN_FUNDING_SPENT (mutual close before converging)") { case (alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, _, _) => + within(30 seconds) { + val sender = TestProbe() + // alice initiates a closing + sender.send(alice, CMD_CLOSE(None)) + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) + // agreeing on a closing fee + val aliceCloseFee = alice2bob.expectMsgType[ClosingSigned].feeSatoshis + Globals.feeratesPerKw.set(FeeratesPerKw.single(100)) + alice2bob.forward(bob) + val bobCloseFee = bob2alice.expectMsgType[ClosingSigned].feeSatoshis + bob2alice.forward(alice) + // they don't converge yet, but alice has a publishable commit tx now + assert(aliceCloseFee != bobCloseFee) + val Some(mutualCloseTx) = alice.stateData.asInstanceOf[DATA_NEGOTIATING].bestUnpublishedClosingTx_opt + // let's make alice publish this closing tx + alice ! Error("00" * 32, "") + awaitCond(alice.stateName == CLOSING) + assert(mutualCloseTx === alice.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.last) + + // actual test starts here + alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, mutualCloseTx) + alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(mutualCloseTx), 0, 0) + awaitCond(alice.stateName == CLOSED) + } + } + test("recv BITCOIN_TX_CONFIRMED (mutual close)") { case (alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, _, _) => within(30 seconds) { mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala index 246baedd0..fbced1c65 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala @@ -404,7 +404,8 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit sender.expectMsgType[State] == OFFLINE }, max = 20 seconds, interval = 1 second) // we then have C unilateral close the channel (which will make F redeem the htlc onchain) - sender.send(nodes("C").register, Forward(htlc.channelId, INPUT_PUBLISH_LOCALCOMMIT)) + sender.send(nodes("C").register, Forward(htlc.channelId, CMD_FORCECLOSE)) + sender.expectMsg("ok") // we then wait for F to detect the unilateral close and go to CLOSING state awaitCond({ sender.send(nodes("F1").register, Forward(htlc.channelId, CMD_GETSTATE)) @@ -473,7 +474,8 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit sender.expectMsgType[State] == OFFLINE }, max = 20 seconds, interval = 1 second) // then we have F unilateral close the channel - sender.send(nodes("F2").register, Forward(htlc.channelId, INPUT_PUBLISH_LOCALCOMMIT)) + sender.send(nodes("F2").register, Forward(htlc.channelId, CMD_FORCECLOSE)) + sender.expectMsg("ok") // we then fulfill the htlc (it won't be sent to C, and will be used to pull funds on-chain) sender.send(nodes("F2").register, Forward(htlc.channelId, CMD_FULFILL_HTLC(htlc.id, preimage))) // we then generate one block so that the htlc success tx gets written to the blockchain @@ -579,7 +581,8 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit val res = sender.expectMsgType[JValue](10 seconds) val previouslyReceivedByC = res.filter(_ \ "address" == JString(finalAddressC)).flatMap(_ \ "txids" \\ classOf[JString]) // then we ask F to unilaterally close the channel - sender.send(nodes("F4").register, Forward(htlc.channelId, INPUT_PUBLISH_LOCALCOMMIT)) + sender.send(nodes("F4").register, Forward(htlc.channelId, CMD_FORCECLOSE)) + sender.expectMsg("ok") // we then generate enough blocks to make the htlc timeout sender.send(bitcoincli, BitcoinReq("generate", 11)) sender.expectMsgType[JValue](10 seconds) diff --git a/eclair-node-gui/src/main/resources/gui/main/channelPane.fxml b/eclair-node-gui/src/main/resources/gui/main/channelPane.fxml index 92dcc7c4d..809ca1f89 100644 --- a/eclair-node-gui/src/main/resources/gui/main/channelPane.fxml +++ b/eclair-node-gui/src/main/resources/gui/main/channelPane.fxml @@ -24,11 +24,14 @@ + -