diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-node/src/main/scala/fr/acinq/eclair/channel/Channel.scala index f0effe48e..5b36fda3e 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -5,10 +5,11 @@ import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin._ import fr.acinq.eclair._ import fr.acinq.eclair.blockchain._ +import fr.acinq.eclair.blockchain.peer.CurrentBlockCount import fr.acinq.eclair.channel.Helpers.{Closing, Funding} import fr.acinq.eclair.crypto.{Generators, ShaChain} import fr.acinq.eclair.payment.Binding -import fr.acinq.eclair.router.{Announcements, Router} +import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions._ import fr.acinq.eclair.wire._ @@ -309,7 +310,8 @@ class Channel(val them: ActorRef, val blockchain: ActorRef, router: ActorRef, re goto(CLOSING) using DATA_CLOSING(d.commitments, localCommitPublished = Some(localCommitPublished)) case Event(e: Error, d: DATA_WAIT_FOR_FUNDING_LOCKED_INTERNAL) => - log.error(s"peer sent $e, closing connection") // see bolt #2: A node MUST fail the connection if it receives an err message + log.error(s"peer sent $e, closing connection") + // see bolt #2: A node MUST fail the connection if it receives an err message val localCommitTx = d.commitments.localCommit.publishableTxs.commitTx.tx blockchain ! PublishAsap(localCommitTx) blockchain ! WatchConfirmed(self, localCommitTx.txid, d.params.minimumDepth, BITCOIN_LOCALCOMMIT_DONE) @@ -342,6 +344,8 @@ class Channel(val them: ActorRef, val blockchain: ActorRef, router: ActorRef, re context.system.scheduler.scheduleOnce(3 seconds, router, 'tick_broadcast) case _ => log.info(s"channel ${d.channelId} won't be announced") } + // this clock will be used to detect htlc timeouts + context.system.eventStream.subscribe(self, classOf[CurrentBlockCount]) goto(NORMAL) using d.copy(commitments = d.commitments.copy(remoteNextCommitInfo = Right(nextPerCommitmentPoint))) case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx: Transaction), d: DATA_NORMAL) if tx.txid == d.commitments.remoteCommit.txid => handleRemoteSpentCurrent(tx, d) @@ -511,6 +515,9 @@ class Channel(val them: ActorRef, val blockchain: ActorRef, router: ActorRef, re case Failure(cause) => handleLocalError(cause, d) } + case Event(CurrentBlockCount(count), d: DATA_NORMAL) if d.commitments.hasTimedoutHtlcs(count) => + handleLocalError(new RuntimeException(s"one or more htlcs timedout at blockheight=$count, closing the channel"), d) + case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx: Transaction), d: DATA_NORMAL) if tx.txid == d.commitments.remoteCommit.txid => handleRemoteSpentCurrent(tx, d) case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx: Transaction), d: DATA_NORMAL) => handleRemoteSpentOther(tx, d) @@ -604,6 +611,9 @@ class Channel(val them: ActorRef, val blockchain: ActorRef, router: ActorRef, re case Failure(cause) => handleLocalError(cause, d) } + case Event(CurrentBlockCount(count), d: DATA_SHUTDOWN) if d.commitments.hasTimedoutHtlcs(count) => + handleLocalError(new RuntimeException(s"one or more htlcs timedout at blockheight=$count, closing the channel"), d) + case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx: Transaction), d: DATA_SHUTDOWN) if tx.txid == d.commitments.remoteCommit.txid => handleRemoteSpentCurrent(tx, d) case Event(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx: Transaction), d: DATA_SHUTDOWN) => handleRemoteSpentOther(tx, d) @@ -725,8 +735,8 @@ class Channel(val them: ActorRef, val blockchain: ActorRef, router: ActorRef, re }, stateName, stateData) stay - // because channels send CMD to each others when relaying payments - case Event("ok", _) => stay + // we only care about this event in NORMAL and SHUTDOWN state, and we never unregister to the event stream + case Event(CurrentBlockCount(_), _) => stay } onTransition { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-node/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index f1c051932..d6ca2c8b2 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -39,6 +39,10 @@ case class Commitments(localParams: LocalParams, remoteParams: RemoteParams, def hasNoPendingHtlcs: Boolean = localCommit.spec.htlcs.isEmpty && remoteCommit.spec.htlcs.isEmpty + def hasTimedoutHtlcs(blockheight: Long): Boolean = + localCommit.spec.htlcs.exists(htlc => htlc.direction == OUT && blockheight >= htlc.add.expiry) || + remoteCommit.spec.htlcs.exists(htlc => htlc.direction == IN && blockheight >= htlc.add.expiry) + def addLocalProposal(proposal: UpdateMessage): Commitments = Commitments.addLocalProposal(this, proposal) def addRemoteProposal(proposal: UpdateMessage): Commitments = Commitments.addRemoteProposal(this, proposal) diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 597787ffb..674f6e03e 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -6,6 +6,7 @@ import fr.acinq.bitcoin.Crypto.Scalar import fr.acinq.bitcoin.{BinaryData, Crypto, Satoshi, Script, ScriptFlags, Transaction} import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain._ +import fr.acinq.eclair.blockchain.peer.CurrentBlockCount import fr.acinq.eclair.channel.states.StateTestsHelperMethods import fr.acinq.eclair.channel.{Data, State, _} import fr.acinq.eclair.payment.{Binding, Local, Relayed} @@ -791,6 +792,37 @@ class NormalStateSpec extends TestkitBaseClass with StateTestsHelperMethods { } } + test("recv CurrentBlockCount (no htlc timed out)") { case (alice, bob, alice2bob, bob2alice, _, _, _) => + within(30 seconds) { + val sender = TestProbe() + val (r, htlc) = addHtlc(50000000, alice, bob, alice2bob, bob2alice) + sign(alice, bob, alice2bob, bob2alice) + + // actual test begins + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + sender.send(alice, CurrentBlockCount(1400)) + awaitCond(alice.stateData == initialState) + } + } + + test("recv CurrentBlockCount (an htlc timed out)") { case (alice, bob, alice2bob, bob2alice, alice2blockchain, _, _) => + within(30 seconds) { + val sender = TestProbe() + val (r, htlc) = addHtlc(50000000, alice, bob, alice2bob, bob2alice) + sign(alice, bob, alice2bob, bob2alice) + + // actual test begins + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + val aliceCommitTx = initialState.commitments.localCommit.publishableTxs.commitTx.tx + sender.send(alice, CurrentBlockCount(1441)) + alice2blockchain.expectMsg(PublishAsap(aliceCommitTx)) + + val watch = alice2blockchain.expectMsgType[WatchConfirmed] + assert(watch.txId === aliceCommitTx.txid) + assert(watch.event === BITCOIN_LOCALCOMMIT_DONE) + } + } + test("recv BITCOIN_FUNDING_SPENT (their commit w/ htlc)") { case (alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, _) => within(30 seconds) { val sender = TestProbe() diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala index aeeededcf..f7787778a 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala @@ -4,9 +4,10 @@ import akka.actor.Props import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.Crypto.Scalar import fr.acinq.bitcoin.{BinaryData, Crypto, Satoshi, ScriptFlags, Transaction} -import fr.acinq.eclair.{TestkitBaseClass, TestBitcoinClient} +import fr.acinq.eclair.{TestBitcoinClient, TestkitBaseClass} import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain._ +import fr.acinq.eclair.blockchain.peer.CurrentBlockCount import fr.acinq.eclair.channel.states.StateTestsHelperMethods import fr.acinq.eclair.channel.{Data, State, _} import fr.acinq.eclair.wire.{CommitSig, Error, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFulfillHtlc} @@ -339,6 +340,29 @@ class ShutdownStateSpec extends TestkitBaseClass with StateTestsHelperMethods { } } + test("recv CurrentBlockCount (no htlc timed out)") { case (alice, bob, alice2bob, bob2alice, _, _) => + within(30 seconds) { + val sender = TestProbe() + val initialState = alice.stateData.asInstanceOf[DATA_SHUTDOWN] + sender.send(alice, CurrentBlockCount(1400)) + awaitCond(alice.stateData == initialState) + } + } + + test("recv CurrentBlockCount (an htlc timed out)") { case (alice, bob, alice2bob, bob2alice, alice2blockchain, _) => + within(30 seconds) { + val sender = TestProbe() + val initialState = alice.stateData.asInstanceOf[DATA_SHUTDOWN] + val aliceCommitTx = initialState.commitments.localCommit.publishableTxs.commitTx.tx + sender.send(alice, CurrentBlockCount(1441)) + alice2blockchain.expectMsg(PublishAsap(aliceCommitTx)) + + val watch = alice2blockchain.expectMsgType[WatchConfirmed] + assert(watch.txId === aliceCommitTx.txid) + assert(watch.event === BITCOIN_LOCALCOMMIT_DONE) + } + } + test("recv RevokeAndAck (unexpectedly)") { case (alice, bob, alice2bob, bob2alice, alice2blockchain, _) => within(30 seconds) { val tx = alice.stateData.asInstanceOf[DATA_SHUTDOWN].commitments.localCommit.publishableTxs.commitTx.tx