From ad4e122969416dd1681d143926a7ac1004b6cb72 Mon Sep 17 00:00:00 2001 From: Andrea Date: Fri, 4 Oct 2019 16:40:05 +0200 Subject: [PATCH 01/26] Add test for triggering the remote publishing via DLP --- .../channel/states/e/OfflineStateSpec.scala | 201 +++++++++++++++++- 1 file changed, 196 insertions(+), 5 deletions(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala index 528e384104..b154d77679 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala @@ -20,21 +20,24 @@ import java.util.UUID import akka.actor.Status import akka.testkit.{TestActorRef, TestProbe} -import fr.acinq.bitcoin.Crypto.PrivateKey -import fr.acinq.bitcoin.{ByteVector32, ScriptFlags, Transaction} -import fr.acinq.eclair.TestConstants.Alice +import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.{ByteVector32, ByteVector64, OutPoint, ScriptFlags, Transaction, TxOut} +import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.fee.FeeratesPerKw import fr.acinq.eclair.blockchain._ import fr.acinq.eclair.channel.Channel.LocalError import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.states.StateTestsHelperMethods +import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.payment.CommandBuffer import fr.acinq.eclair.payment.CommandBuffer.CommandSend import fr.acinq.eclair.router.Announcements -import fr.acinq.eclair.transactions.Transactions.HtlcSuccessTx +import fr.acinq.eclair.transactions.{CommitmentSpec, Transactions} +import fr.acinq.eclair.transactions.Transactions.{CommitTx, HtlcSuccessTx} import fr.acinq.eclair.wire._ -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, LongToBtcAmount, TestConstants, TestkitBaseClass, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, LongToBtcAmount, TestConstants, TestkitBaseClass, UInt64, randomBytes32} import org.scalatest.{Outcome, Tag} +import scodec.bits._ import scala.concurrent.duration._ @@ -267,6 +270,194 @@ class OfflineStateSpec extends TestkitBaseClass with StateTestsHelperMethods { } + test("ask the last per-commitment-secret to remote and make it publish its commitment tx") { f => + import f._ + val sender = TestProbe() + + val oldAliceState = alice.stateData.asInstanceOf[DATA_NORMAL] + + // simulate a fulfilled payment to move forward the commitment index + addHtlc(250000000 msat, alice, bob, alice2bob, bob2alice) + sender.send(alice, CMD_SIGN) + sender.expectMsg("ok") + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[RevokeAndAck] + bob2alice.forward(alice) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + + addHtlc(210000000 msat, alice, bob, alice2bob, bob2alice) + sender.send(alice, CMD_SIGN) + sender.expectMsg("ok") + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[RevokeAndAck] + bob2alice.forward(alice) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + + addHtlc(210000000 msat, alice, bob, alice2bob, bob2alice) + sender.send(alice, CMD_SIGN) + sender.expectMsg("ok") + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[RevokeAndAck] + bob2alice.forward(alice) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + + // there have been 3 fully ack'ed and revoked commitments + val effectiveLastCommitmentIndex = 3 + assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.index == effectiveLastCommitmentIndex) + + val mockAliceIndex = 1 // alice will claim to be at this index when reestablishing the channel - IT MUST BE STRICTLY SMALLER THAN THE ACTUAL INDEX + val mockBobIndex = 123 // alice will claim that BOB is at this index when reestablishing the channel + + // the mock state contain "random" data that is not really associated with the channel + // most importantly this data is made in such a way that it will trigger a channel failure from the remote + val mockAliceState = DATA_NORMAL( + commitments = Commitments( + channelVersion = ChannelVersion.STANDARD, + localParams = oldAliceState.commitments.localParams, // during the actual recovery flow this can be reconstructed with seed + channelKeyPath + remoteParams = RemoteParams( + Bob.nodeParams.nodeId, + dustLimit = 0 sat, + maxHtlcValueInFlightMsat = UInt64(0), + channelReserve = 0 sat, + htlcMinimum = 0 msat, + toSelfDelay = CltvExpiryDelta(0), + maxAcceptedHtlcs = 0, + fundingPubKey = PublicKey(hex"02184615bf2294acc075701892d7bd8aff28d78f84330e8931102e537c8dfe92a3"), + revocationBasepoint = PublicKey(hex"020beeba2c3015509a16558c35b930bed0763465cf7a9a9bc4555fd384d8d383f6"), + paymentBasepoint = PublicKey(hex"02e63d3b87e5269d96f1935563ca7c197609a35a928528484da1464eee117335c5"), + delayedPaymentBasepoint = PublicKey(hex"033dea641e24e7ae550f7c3a94bd9f23d55b26a649c79cd4a3febdf912c6c08281"), + htlcBasepoint = PublicKey(hex"0274a89988063045d3589b162ac6eea5fa0343bf34220648e92a636b1c2468a434"), + globalFeatures = hex"00", + localFeatures = hex"00" + ), + channelFlags = 1.toByte, + localCommit = LocalCommit( + mockAliceIndex, + spec = CommitmentSpec( + htlcs = Set(), + feeratePerKw = 234, + toLocal = 0 msat, + toRemote = 0 msat + ), + publishableTxs = PublishableTxs( + CommitTx( + input = Transactions.InputInfo( + outPoint = OutPoint(ByteVector32.Zeroes, 0), + txOut = TxOut(0 sat, ByteVector.empty), + redeemScript = ByteVector.empty + ), + tx = Transaction.read("0200000000010163c75c555d712a81998ddbaf9ce1d55b153fc7cb71441ae1782143bb6b04b95d0000000000a325818002bc893c0000000000220020ae8d04088ff67f3a0a9106adb84beb7530097b262ff91f8a9a79b7851b50857f00127a0000000000160014be0f04e9ed31b6ece46ca8c17e1ed233c71da0e9040047304402203b280f9655f132f4baa441261b1b590bec3a6fcd6d7180c929fa287f95d200f80220100d826d56362c65d09b8687ca470a31c1e2bb3ad9a41321ceba355d60b77b79014730440220539e34ab02cced861f9c39f9d14ece41f1ed6aed12443a9a4a88eb2792356be6022023dc4f18730a6471bdf9b640dfb831744b81249ffc50bd5a756ae85d8c6749c20147522102184615bf2294acc075701892d7bd8aff28d78f84330e8931102e537c8dfe92a3210367d50e7eab4a0ab0c6b92aa2dcf6cc55a02c3db157866b27a723b8ec47e1338152ae74f15a20") + ), + htlcTxsAndSigs = List.empty + ) + ), + remoteCommit = RemoteCommit( + mockBobIndex, + spec = CommitmentSpec( + htlcs = Set(), + feeratePerKw = 432, + toLocal = 0 msat, + toRemote = 0 msat + ), + txid = ByteVector32.fromValidHex("b70c3314af259029e7d11191ca0fe6ee407352dfaba59144df7f7ce5cc1c7b51"), + remotePerCommitmentPoint = PublicKey(hex"0286f6253405605640f6c19ea85a51267795163183a17df077050bf680ed62c224") + ), + localChanges = LocalChanges( + proposed = List.empty, + signed = List.empty, + acked = List.empty + ), + remoteChanges = RemoteChanges( + proposed = List.empty, + signed = List.empty, + acked = List.empty + ), + localNextHtlcId = 0, + remoteNextHtlcId = 0, + originChannels = Map(), + remoteNextCommitInfo = Right(PublicKey(hex"0386f6253405605640f6c19ea85a51267795163183a17df077050bf680ed62c224")), + commitInput = Transactions.InputInfo( + outPoint = OutPoint(ByteVector32.Zeroes, 0), + txOut = TxOut(0 sat, ByteVector.empty), + redeemScript = ByteVector.empty + ), + remotePerCommitmentSecrets = ShaChain.init, + channelId = oldAliceState.commitments.channelId + ), + shortChannelId = oldAliceState.shortChannelId, + buried = oldAliceState.buried, + channelAnnouncement = None, + channelUpdate = ChannelUpdate( + signature = ByteVector64.Zeroes, + chainHash = Alice.nodeParams.chainHash, + shortChannelId = oldAliceState.shortChannelId, + timestamp = 1556526043L, + messageFlags = 0.toByte, + channelFlags = 0.toByte, + cltvExpiryDelta = CltvExpiryDelta(144), + htlcMinimumMsat = 0 msat, + feeBaseMsat = 0 msat, + feeProportionalMillionths = 0, + htlcMaximumMsat = None + ), + localShutdown = None, + remoteShutdown = None + ) + + // we simulate a disconnection + sender.send(alice, INPUT_DISCONNECTED) + sender.send(bob, INPUT_DISCONNECTED) + awaitCond(alice.stateName == OFFLINE) + awaitCond(bob.stateName == OFFLINE) + + // alice's state data contains dummy values + alice.setState(OFFLINE, mockAliceState) + + // then we reconnect them + sender.send(alice, INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit)) + sender.send(bob, INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit)) + + // peers exchange channel_reestablish messages + val bobCommitments = bob.stateData.asInstanceOf[HasCommitments].commitments + val bobCurrentPerCommitmentPoint = Bob.keyManager.commitmentPoint(Bob.keyManager.channelKeyPath(bobCommitments.localParams, bobCommitments.channelVersion), bobCommitments.localCommit.index) + val aliceCurrentPerCommitmentPoint = Alice.keyManager.commitmentPoint(Alice.keyManager.channelKeyPath(mockAliceState.commitments.localParams, mockAliceState.commitments.channelVersion), mockAliceIndex) + // that's what we expect from Bob, Alice's per-commitment-secret generated using the latest commitment index + val aliceLatestPerCommitmentSecret = Alice.keyManager.commitmentSecret(Alice.keyManager.channelKeyPath(mockAliceState.commitments.localParams, mockAliceState.commitments.channelVersion), effectiveLastCommitmentIndex - 1) + + // Alice sends the indexes and commitment points according to her (mistaken) view of the commitment, Bob will let her know she's behind + alice2bob.expectMsg(ChannelReestablish(oldAliceState.commitments.channelId, mockAliceIndex + 1, mockBobIndex, Some(PrivateKey(ByteVector32.Zeroes)), Some(aliceCurrentPerCommitmentPoint))) + bob2alice.expectMsg(ChannelReestablish(oldAliceState.commitments.channelId, effectiveLastCommitmentIndex + 1, effectiveLastCommitmentIndex, Some(aliceLatestPerCommitmentSecret), Some(bobCurrentPerCommitmentPoint))) + + // alice then realizes it has an old state... + bob2alice.forward(alice) + // ... and ask bob to publish its current commitment + val error = alice2bob.expectMsgType[Error] + assert(new String(error.data.toArray) === PleasePublishYourCommitment(channelId(alice)).getMessage) + + // alice now waits for bob to publish its commitment + awaitCond(alice.stateName == WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT) + + // bob is nice and publishes its commitment + val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx + sender.send(alice, WatchEventSpent(BITCOIN_FUNDING_SPENT, bobCommitTx)) + + // alice is able to claim its main output + val claimMainOutput = alice2blockchain.expectMsgType[PublishAsap].tx + Transaction.correctlySpends(claimMainOutput, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + test("discover that they have a more recent commit than the one we know") { f => import f._ val sender = TestProbe() From da04ccc4b22b49855d9a55ce5b5b2ff5f280b394 Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 7 Oct 2019 10:39:19 +0200 Subject: [PATCH 02/26] WIP --- .../scala/fr/acinq/eclair/NodeParams.scala | 4 +- .../bitcoind/rpc/ExtendedBitcoinClient.scala | 12 + .../scala/fr/acinq/eclair/db/Databases.scala | 7 +- .../fr/acinq/eclair/io/Switchboard.scala | 7 + .../scala/fr/acinq/eclair/TestConstants.scala | 5 +- .../fr/acinq/eclair/io/SwitchboardSpec.scala | 2 + .../scala/fr/acinq/eclair/RecoveryTool.scala | 268 ++++++++++++++++++ 7 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 eclair-node/src/main/scala/fr/acinq/eclair/RecoveryTool.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 78f499e024..a2372447ce 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -41,7 +41,8 @@ import scala.concurrent.duration.FiniteDuration /** * Created by PM on 26/02/2017. */ -case class NodeParams(keyManager: KeyManager, +case class NodeParams(config: Config, + keyManager: KeyManager, private val blockCount: AtomicLong, alias: String, color: Color, @@ -203,6 +204,7 @@ object NodeParams { } NodeParams( + config = config, keyManager = keyManager, blockCount = blockCount, alias = nodeAlias, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala index ff277febca..49e290ff9f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala @@ -48,6 +48,18 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) { case t: JsonRPCError if t.error.code == -5 => None } + def getBlock(blockHash: ByteVector32)(implicit ec: ExecutionContext): Future[Block] = + rpcClient.invoke("getblock", blockHash.toHex, 0).collect { + case JString(b) => Block.read(b) + } + + def getBlockHeight(blockHash: ByteVector32)(implicit ec: ExecutionContext): Future[Long] = + rpcClient.invoke("getblock", blockHash.toHex, 1) + .map(json => json \ "height") + .collect { + case JInt(height) => height.longValue() + } + def lookForSpendingTx(blockhash_opt: Option[String], txid: String, outputIndex: Int)(implicit ec: ExecutionContext): Future[Transaction] = for { blockhash <- blockhash_opt match { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index eb982b0f7e..f23a9ed6cc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -35,6 +35,8 @@ trait Databases { val pendingRelay: PendingRelayDb + val dbDir: Option[File] + def backup(file: File) : Unit } @@ -52,16 +54,17 @@ object Databases { val sqliteAudit = DriverManager.getConnection(s"jdbc:sqlite:${new File(dbdir, "audit.sqlite")}") SqliteUtils.obtainExclusiveLock(sqliteEclair) // there should only be one process writing to this file - databaseByConnections(sqliteAudit, sqliteNetwork, sqliteEclair) + databaseByConnections(sqliteAudit, sqliteNetwork, sqliteEclair, Some(dbdir)) } - def databaseByConnections(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection) = new Databases { + def databaseByConnections(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection, dbDir_opt: Option[File]) = new Databases { override val network = new SqliteNetworkDb(networkJdbc) override val audit = new SqliteAuditDb(auditJdbc) override val channels = new SqliteChannelsDb(eclairJdbc) override val peers = new SqlitePeersDb(eclairJdbc) override val payments = new SqlitePaymentsDb(eclairJdbc) override val pendingRelay = new SqlitePendingRelayDb(eclairJdbc) + override val dbDir: Option[File] = dbDir_opt override def backup(file: File): Unit = { SqliteUtils.using(eclairJdbc.createStatement()) { statement => { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala index 2ee641c62f..bd1e13ec14 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel.{HasCommitments, _} import fr.acinq.eclair.db.PendingRelayDb +import fr.acinq.eclair.io.Peer.Connect import fr.acinq.eclair.payment.Relayer.RelayPayload import fr.acinq.eclair.payment.{Relayed, Relayer} import fr.acinq.eclair.router.Rebroadcast @@ -112,6 +113,10 @@ class Switchboard(nodeParams: NodeParams, authenticator: ActorRef, watcher: Acto case 'peers => sender ! context.children + case c: ReconnectWithCommitments => + val peer = createOrGetPeer(c.uri.nodeId, previousKnownAddress = None, offlineChannels = Set(c.commitments)) + peer forward Connect(c.uri) + } /** @@ -227,6 +232,8 @@ object Switchboard extends Logging { toClean.size } + // Used during the recovery tool procedure to trigger the connection to a peer using the provided channel state data + case class ReconnectWithCommitments(uri: NodeURI, commitments: HasCommitments) } class HtlcReaper extends Actor with ActorLogging { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 757f37f368..bfdc68e2fc 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -19,6 +19,7 @@ package fr.acinq.eclair import java.sql.{Connection, DriverManager} import java.util.concurrent.atomic.AtomicLong +import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.Crypto.PrivateKey import fr.acinq.bitcoin.{Block, ByteVector32, Script} import fr.acinq.eclair.NodeParams.BITCOIND @@ -57,7 +58,7 @@ object TestConstants { def sqliteInMemory() = DriverManager.getConnection("jdbc:sqlite::memory:") - def inMemoryDb(connection: Connection = sqliteInMemory()): Databases = Databases.databaseByConnections(connection, connection, connection) + def inMemoryDb(connection: Connection = sqliteInMemory()): Databases = Databases.databaseByConnections(connection, connection, connection, None) object Alice { val seed = ByteVector32(ByteVector.fill(32)(1)) @@ -65,6 +66,7 @@ object TestConstants { // This is a function, and not a val! When called will return a new NodeParams def nodeParams = NodeParams( + ConfigFactory.empty(), keyManager = keyManager, blockCount = new AtomicLong(400000), alias = "alice", @@ -143,6 +145,7 @@ object TestConstants { val keyManager = new LocalKeyManager(seed, Block.RegtestGenesisBlock.hash) def nodeParams = NodeParams( + ConfigFactory.empty(), keyManager = keyManager, blockCount = new AtomicLong(400000), alias = "bob", diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala index ca78e81b99..442d12cd95 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala @@ -27,6 +27,7 @@ class SwitchboardSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit override val peers: PeersDb = Alice.nodeParams.db.peers override val payments: PaymentsDb = Alice.nodeParams.db.payments override val pendingRelay: PendingRelayDb = Alice.nodeParams.db.pendingRelay + override val dbDir: Option[File] = None override def backup(file: File): Unit = () } ) @@ -67,6 +68,7 @@ class SwitchboardSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit override val peers: PeersDb = Alice.nodeParams.db.peers override val payments: PaymentsDb = Alice.nodeParams.db.payments override val pendingRelay: PendingRelayDb = Alice.nodeParams.db.pendingRelay + override val dbDir: Option[File] = None override def backup(file: File): Unit = () } ) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/RecoveryTool.scala new file mode 100644 index 0000000000..4f8108aa54 --- /dev/null +++ b/eclair-node/src/main/scala/fr/acinq/eclair/RecoveryTool.scala @@ -0,0 +1,268 @@ +package fr.acinq.eclair + +import java.io.{File, FileWriter} + +import akka.util.Timeout +import com.softwaremill.sttp.okhttp.OkHttpFutureBackend +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.bitcoin.DeterministicWallet.KeyPath +import fr.acinq.bitcoin.{ByteVector32, ByteVector64, OutPoint, Satoshi, Script, Transaction, TxOut} +import fr.acinq.eclair.blockchain.bitcoind.BitcoinCoreWallet +import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, ExtendedBitcoinClient} +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.crypto.{KeyManager, LocalKeyManager, ShaChain} +import fr.acinq.eclair.io.{NodeURI, Peer} +import fr.acinq.eclair.transactions.{CommitmentSpec, Scripts, Transactions} +import fr.acinq.eclair.transactions.Transactions.{CommitTx, InputInfo} +import scodec.bits.ByteVector +import akka.pattern._ +import fr.acinq.eclair.api.JsonSupport +import grizzled.slf4j.Logging + +import concurrent.duration._ +import scala.compat.Platform +import scala.concurrent.{Await, Future} +import scala.util.{Failure, Random, Success, Try} +import scodec.bits._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.io.Source +import JsonSupport.formats +import JsonSupport.serialization +import fr.acinq.eclair.io.Switchboard.ReconnectWithCommitments +import fr.acinq.eclair.wire.ChannelUpdate + +object RecoveryTool extends Logging { + + private lazy val scanner = new java.util.Scanner(System.in).useDelimiter("\\n") + + def interactiveRecovery(appKit: Kit): Unit = { + print(s"\n ### Welcome to the eclair recovery tool ### \n") + val nodeUri = getInput[NodeURI]("Please insert the URI of the target node: ", NodeURI.parse) + val hasShortChannelId = getInput[Boolean]("Press 's' if you know the shortChannelId, 'c' for channelId",{ s => + s match { + case "s" => true + case "c" => false + } + }) + val channelIdentifier: Either[ShortChannelId, ByteVector32] = if(hasShortChannelId){ + Left(getInput("Please insert the shortChannelId: ", ShortChannelId.apply)) + } else { + Right(getInput("Please insert the channelId: ", ByteVector32.fromValidHex)) + } + + println(s"### Attempting channel recovery now - good luck! ###") + doRecovery(appKit, backup, nodeUri) + } + + def storeBackup(nodeParams: NodeParams, channelData: HasCommitments) = Future { + val backup = ChannelBackup( + fundingTxid = channelData.commitments.commitInput.outPoint.txid, + fundingOutputIndex = channelData.commitments.commitInput.outPoint.index, + isFunder = channelData.commitments.localParams.isFunder, + remoteNodeId = channelData.commitments.remoteParams.nodeId, + remoteFundingPubkey_opt = Some(channelData.commitments.remoteParams.fundingPubKey) + ) + + if (nodeParams.db.dbDir.isEmpty) { + logger.warn(s"No database folder defined, skipping static backup") + } + + nodeParams.db.dbDir.foreach { dbDir => + val backupDir = new File(dbDir, "channel-backups") + if (!backupDir.exists()) backupDir.mkdir() + + val channelBackup = new File(backupDir, "backup_" + backup.fundingTxid.toHex + ".json") + val writer = new FileWriter(channelBackup) + writer.write(serialization.writePretty(backup)) + writer.close() + logger.info(s"Created channel backup: ${channelBackup.getAbsolutePath}") + } + + } + + private def getInput[T](msg: String, parse: String => T): T = { + do { + print(msg) + Try(parse(scanner.next())) match { + case Success(someT) => return someT + case Failure(thr) => println(s"Error: ${thr.getMessage}") + } + } while (true) + + throw new IllegalArgumentException("Unable to get input") + } + + def doRecovery(appKit: Kit, backup: ChannelBackup, uri: NodeURI): Future[Unit] = { + + implicit val timeout = Timeout(10 minutes) + implicit val shttp = OkHttpFutureBackend() + + val bitcoinRpcClient = new BasicBitcoinJsonRPCClient( + user = appKit.nodeParams.config.getString("bitcoind.rpcuser"), + password = appKit.nodeParams.config.getString("bitcoind.rpcpassword"), + host = appKit.nodeParams.config.getString("bitcoind.host"), + port = appKit.nodeParams.config.getInt("bitcoind.rpcport") + ) + + val bitcoinClient = new ExtendedBitcoinClient(bitcoinRpcClient) + + val (fundingTx, blockHeight, finalAddress, isFundingSpendable) = Await.result(for { + Some(blockHash) <- bitcoinClient.getTxBlockHash(backup.fundingTxid.toHex) + block <- bitcoinClient.getBlock(ByteVector32.fromValidHex(blockHash)) + height <- bitcoinClient.getBlockHeight(ByteVector32.fromValidHex(blockHash)) + Some(funding) = block.tx.find(_.txid === backup.fundingTxid) + isSpendable <- bitcoinClient.isTransactionOutputSpendable(funding.txid.toHex, backup.fundingOutputIndex.toInt, includeMempool = true) + address <- new BitcoinCoreWallet(bitcoinRpcClient).getFinalAddress + } yield (funding, height, address, isSpendable), 60 seconds) + + if (!isFundingSpendable) { + logger.info(s"Sorry but the funding tx has been spent, the channel has been closed") + return Future.successful(()) + } + + val finalScriptPubkey = Script.write(addressToPublicKeyScript(finalAddress, appKit.nodeParams.chainHash)) + val channelId = fr.acinq.eclair.toLongId(fundingTx.hash, backup.fundingOutputIndex.toInt) + + val inputInfo = Transactions.InputInfo( + outPoint = OutPoint(fundingTx.hash, backup.fundingOutputIndex), + txOut = fundingTx.txOut(backup.fundingOutputIndex.toInt), + redeemScript = ByteVector.empty + ) + + val channelKeyPath = ??? + + logger.info(s"Recovery using: channelId=$channelId finalScriptPubKey=$finalAddress remotePeer=${uri.nodeId} funder=${backup.isFunder}") + val commitments = makeDummyCommitment(appKit.nodeParams.keyManager, channelKeyPath, uri.nodeId, appKit.nodeParams.nodeId, channelId, inputInfo, finalScriptPubkey, appKit.nodeParams.chainHash) + (appKit.switchboard ? ReconnectWithCommitments(uri, commitments)).mapTo[Unit] + } + + /** + * This creates the necessary data to simulate a channel in state NORMAL, it contains dummy "points" and "indexes", as well as a dummy channel_update. + */ + def makeDummyCommitment( + keyManager: KeyManager, + channelKeyPath: KeyPath, + remoteNodeId: PublicKey, + localNodeId: PublicKey, + channelId: ByteVector32, + commitInput: InputInfo, + finalScriptPubkey: ByteVector, + chainHash: ByteVector32 + ) = DATA_NORMAL( + commitments = Commitments( + channelVersion = ChannelVersion.STANDARD, + localParams = LocalParams( + nodeId = localNodeId, + fundingKeyPath = channelKeyPath, + dustLimit = 0 sat, + maxHtlcValueInFlightMsat = UInt64(0), + channelReserve = 0 sat, + toSelfDelay = CltvExpiryDelta(0), + htlcMinimum = 0 msat, + maxAcceptedHtlcs = 0, + isFunder = true, + defaultFinalScriptPubKey = finalScriptPubkey, + globalFeatures = hex"00", + localFeatures = hex"00" + ), + remoteParams = RemoteParams( + remoteNodeId, + dustLimit = 0 sat, + maxHtlcValueInFlightMsat = UInt64(0), + channelReserve = 0 sat, + htlcMinimum = 0 msat, + toSelfDelay = CltvExpiryDelta(0), + maxAcceptedHtlcs = 0, + fundingPubKey = keyManager.fundingPublicKey(randomKeyPath).publicKey, + revocationBasepoint = randomPoint(chainHash), + paymentBasepoint = randomPoint(chainHash), + delayedPaymentBasepoint = randomPoint(chainHash), + htlcBasepoint = randomPoint(chainHash), + globalFeatures = hex"00", + localFeatures = hex"00" + ), + channelFlags = 1.toByte, + localCommit = LocalCommit( + 0, + spec = CommitmentSpec( + htlcs = Set(), + feeratePerKw = 10, // TODO: review + toLocal = 0 msat, + toRemote = 0 msat + ), + publishableTxs = PublishableTxs( + CommitTx( + input = Transactions.InputInfo( + outPoint = OutPoint(ByteVector32.Zeroes, 0), + txOut = TxOut(Satoshi(0), ByteVector.empty), + redeemScript = ByteVector.empty + ), + tx = Transaction.read("0200000000010163c75c555d712a81998ddbaf9ce1d55b153fc7cb71441ae1782143bb6b04b95d0000000000a325818002bc893c0000000000220020ae8d04088ff67f3a0a9106adb84beb7530097b262ff91f8a9a79b7851b50857f00127a0000000000160014be0f04e9ed31b6ece46ca8c17e1ed233c71da0e9040047304402203b280f9655f132f4baa441261b1b590bec3a6fcd6d7180c929fa287f95d200f80220100d826d56362c65d09b8687ca470a31c1e2bb3ad9a41321ceba355d60b77b79014730440220539e34ab02cced861f9c39f9d14ece41f1ed6aed12443a9a4a88eb2792356be6022023dc4f18730a6471bdf9b640dfb831744b81249ffc50bd5a756ae85d8c6749c20147522102184615bf2294acc075701892d7bd8aff28d78f84330e8931102e537c8dfe92a3210367d50e7eab4a0ab0c6b92aa2dcf6cc55a02c3db157866b27a723b8ec47e1338152ae74f15a20") + ), + htlcTxsAndSigs = List.empty + ) + ), + remoteCommit = RemoteCommit( + 0, + spec = CommitmentSpec( + htlcs = Set(), + feeratePerKw = 10, // TODO: review + toLocal = 0 msat, + toRemote = 0 msat + ), + txid = ByteVector32.Zeroes, + remotePerCommitmentPoint = randomPoint(chainHash) + ), + localChanges = LocalChanges( + proposed = List.empty, + signed = List.empty, + acked = List.empty + ), + remoteChanges = RemoteChanges( + proposed = List.empty, + signed = List.empty, + acked = List.empty + ), + localNextHtlcId = 0, + remoteNextHtlcId = 0, + originChannels = Map(), + remoteNextCommitInfo = Right(randomPoint(chainHash)), + commitInput = commitInput, + remotePerCommitmentSecrets = ShaChain.init, + channelId = channelId + ), + shortChannelId = ShortChannelId("123x1x0"), + buried = true, + channelAnnouncement = None, + channelUpdate = ChannelUpdate( + signature = ByteVector64.Zeroes, + chainHash = chainHash, + shortChannelId = ShortChannelId("123x1x0"), + timestamp = Platform.currentTime.milliseconds.toSeconds, + messageFlags = 0.toByte, + channelFlags = 0.toByte, + cltvExpiryDelta = CltvExpiryDelta(144), + htlcMinimumMsat = 0 msat, + feeBaseMsat = 0 msat, + feeProportionalMillionths = 0, + htlcMaximumMsat = None + ), + localShutdown = None, + remoteShutdown = None + ) + + private def randomPoint(chainHash: ByteVector32) = { + val keyManager = new LocalKeyManager(seed = randomBytes(32), chainHash) + val keyPath = randomKeyPath() + keyManager.commitmentPoint(keyPath, Random.nextLong().abs) + } + + private def randomKeyPath() = KeyPath(Seq( + Random.nextLong().abs, + Random.nextLong().abs, + Random.nextLong().abs, + Random.nextLong().abs + )) + +} \ No newline at end of file From 5da55baa6f7fbb32013ccfc408a155351241e9fd Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 16 Oct 2019 11:59:47 +0200 Subject: [PATCH 03/26] Use zeroed channelId in DLP test --- .../channel/states/e/OfflineStateSpec.scala | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala index b154d77679..3f8e3a848e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala @@ -325,7 +325,20 @@ class OfflineStateSpec extends TestkitBaseClass with StateTestsHelperMethods { val mockAliceState = DATA_NORMAL( commitments = Commitments( channelVersion = ChannelVersion.STANDARD, - localParams = oldAliceState.commitments.localParams, // during the actual recovery flow this can be reconstructed with seed + channelKeyPath + localParams = LocalParams( + nodeId = oldAliceState.commitments.localParams.nodeId, + fundingKeyPath = oldAliceState.commitments.localParams.fundingKeyPath, + dustLimit = 0 sat, + maxHtlcValueInFlightMsat = UInt64(0), + channelReserve = 0 sat, + toSelfDelay = CltvExpiryDelta(0), + htlcMinimum = 0 msat, + maxAcceptedHtlcs = 0, + isFunder = true, + defaultFinalScriptPubKey = oldAliceState.commitments.localParams.defaultFinalScriptPubKey, + globalFeatures = hex"00", + localFeatures = hex"00" + ), remoteParams = RemoteParams( Bob.nodeParams.nodeId, dustLimit = 0 sat, @@ -394,7 +407,7 @@ class OfflineStateSpec extends TestkitBaseClass with StateTestsHelperMethods { redeemScript = ByteVector.empty ), remotePerCommitmentSecrets = ShaChain.init, - channelId = oldAliceState.commitments.channelId + channelId = ByteVector32.Zeroes ), shortChannelId = oldAliceState.shortChannelId, buried = oldAliceState.buried, @@ -437,7 +450,7 @@ class OfflineStateSpec extends TestkitBaseClass with StateTestsHelperMethods { val aliceLatestPerCommitmentSecret = Alice.keyManager.commitmentSecret(Alice.keyManager.channelKeyPath(mockAliceState.commitments.localParams, mockAliceState.commitments.channelVersion), effectiveLastCommitmentIndex - 1) // Alice sends the indexes and commitment points according to her (mistaken) view of the commitment, Bob will let her know she's behind - alice2bob.expectMsg(ChannelReestablish(oldAliceState.commitments.channelId, mockAliceIndex + 1, mockBobIndex, Some(PrivateKey(ByteVector32.Zeroes)), Some(aliceCurrentPerCommitmentPoint))) + alice2bob.expectMsg(ChannelReestablish(ByteVector32.Zeroes, mockAliceIndex + 1, mockBobIndex, Some(PrivateKey(ByteVector32.Zeroes)), Some(aliceCurrentPerCommitmentPoint))) bob2alice.expectMsg(ChannelReestablish(oldAliceState.commitments.channelId, effectiveLastCommitmentIndex + 1, effectiveLastCommitmentIndex, Some(aliceLatestPerCommitmentSecret), Some(bobCurrentPerCommitmentPoint))) // alice then realizes it has an old state... From 8e0879cefc9a6b6879afd9d7e9ed850cf1642852 Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 16 Oct 2019 12:07:30 +0200 Subject: [PATCH 04/26] Create recovery package --- .../eclair/{ => recovery}/RecoveryTool.scala | 103 ++++-------------- 1 file changed, 22 insertions(+), 81 deletions(-) rename eclair-node/src/main/scala/fr/acinq/eclair/{ => recovery}/RecoveryTool.scala (68%) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala similarity index 68% rename from eclair-node/src/main/scala/fr/acinq/eclair/RecoveryTool.scala rename to eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala index 4f8108aa54..d9caf24cdc 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/RecoveryTool.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala @@ -1,84 +1,40 @@ -package fr.acinq.eclair - -import java.io.{File, FileWriter} +package fr.acinq.eclair.recovery import akka.util.Timeout +import akka.pattern.ask import com.softwaremill.sttp.okhttp.OkHttpFutureBackend import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.DeterministicWallet.KeyPath import fr.acinq.bitcoin.{ByteVector32, ByteVector64, OutPoint, Satoshi, Script, Transaction, TxOut} import fr.acinq.eclair.blockchain.bitcoind.BitcoinCoreWallet -import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, ExtendedBitcoinClient} +import fr.acinq.eclair.blockchain.bitcoind.rpc.BasicBitcoinJsonRPCClient import fr.acinq.eclair.channel._ +import fr.acinq.eclair._ import fr.acinq.eclair.crypto.{KeyManager, LocalKeyManager, ShaChain} -import fr.acinq.eclair.io.{NodeURI, Peer} -import fr.acinq.eclair.transactions.{CommitmentSpec, Scripts, Transactions} +import fr.acinq.eclair.io.NodeURI +import fr.acinq.eclair.io.Switchboard.ReconnectWithCommitments import fr.acinq.eclair.transactions.Transactions.{CommitTx, InputInfo} -import scodec.bits.ByteVector -import akka.pattern._ -import fr.acinq.eclair.api.JsonSupport +import fr.acinq.eclair.transactions.{CommitmentSpec, Transactions} +import fr.acinq.eclair.wire.ChannelUpdate +import fr.acinq.eclair.{CltvExpiryDelta, Kit, ShortChannelId, UInt64, addressToPublicKeyScript, randomBytes} import grizzled.slf4j.Logging +import scodec.bits.ByteVector +import scodec.bits.HexStringSyntax -import concurrent.duration._ import scala.compat.Platform -import scala.concurrent.{Await, Future} +import scala.concurrent.{Await, ExecutionContext, Future} +import scala.concurrent.duration._ import scala.util.{Failure, Random, Success, Try} -import scodec.bits._ - -import scala.concurrent.ExecutionContext.Implicits.global -import scala.io.Source -import JsonSupport.formats -import JsonSupport.serialization -import fr.acinq.eclair.io.Switchboard.ReconnectWithCommitments -import fr.acinq.eclair.wire.ChannelUpdate object RecoveryTool extends Logging { private lazy val scanner = new java.util.Scanner(System.in).useDelimiter("\\n") def interactiveRecovery(appKit: Kit): Unit = { - print(s"\n ### Welcome to the eclair recovery tool ### \n") + println(s"\n ### Welcome to the eclair recovery tool ### \n") val nodeUri = getInput[NodeURI]("Please insert the URI of the target node: ", NodeURI.parse) - val hasShortChannelId = getInput[Boolean]("Press 's' if you know the shortChannelId, 'c' for channelId",{ s => - s match { - case "s" => true - case "c" => false - } - }) - val channelIdentifier: Either[ShortChannelId, ByteVector32] = if(hasShortChannelId){ - Left(getInput("Please insert the shortChannelId: ", ShortChannelId.apply)) - } else { - Right(getInput("Please insert the channelId: ", ByteVector32.fromValidHex)) - } - println(s"### Attempting channel recovery now - good luck! ###") - doRecovery(appKit, backup, nodeUri) - } - - def storeBackup(nodeParams: NodeParams, channelData: HasCommitments) = Future { - val backup = ChannelBackup( - fundingTxid = channelData.commitments.commitInput.outPoint.txid, - fundingOutputIndex = channelData.commitments.commitInput.outPoint.index, - isFunder = channelData.commitments.localParams.isFunder, - remoteNodeId = channelData.commitments.remoteParams.nodeId, - remoteFundingPubkey_opt = Some(channelData.commitments.remoteParams.fundingPubKey) - ) - - if (nodeParams.db.dbDir.isEmpty) { - logger.warn(s"No database folder defined, skipping static backup") - } - - nodeParams.db.dbDir.foreach { dbDir => - val backupDir = new File(dbDir, "channel-backups") - if (!backupDir.exists()) backupDir.mkdir() - - val channelBackup = new File(backupDir, "backup_" + backup.fundingTxid.toHex + ".json") - val writer = new FileWriter(channelBackup) - writer.write(serialization.writePretty(backup)) - writer.close() - logger.info(s"Created channel backup: ${channelBackup.getAbsolutePath}") - } - + doRecovery(appKit, nodeUri)(appKit.system.dispatcher) } private def getInput[T](msg: String, parse: String => T): T = { @@ -93,7 +49,7 @@ object RecoveryTool extends Logging { throw new IllegalArgumentException("Unable to get input") } - def doRecovery(appKit: Kit, backup: ChannelBackup, uri: NodeURI): Future[Unit] = { + def doRecovery(appKit: Kit, uri: NodeURI)(implicit ec: ExecutionContext): Future[Unit] = { implicit val timeout = Timeout(10 minutes) implicit val shttp = OkHttpFutureBackend() @@ -105,34 +61,19 @@ object RecoveryTool extends Logging { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - val bitcoinClient = new ExtendedBitcoinClient(bitcoinRpcClient) - - val (fundingTx, blockHeight, finalAddress, isFundingSpendable) = Await.result(for { - Some(blockHash) <- bitcoinClient.getTxBlockHash(backup.fundingTxid.toHex) - block <- bitcoinClient.getBlock(ByteVector32.fromValidHex(blockHash)) - height <- bitcoinClient.getBlockHeight(ByteVector32.fromValidHex(blockHash)) - Some(funding) = block.tx.find(_.txid === backup.fundingTxid) - isSpendable <- bitcoinClient.isTransactionOutputSpendable(funding.txid.toHex, backup.fundingOutputIndex.toInt, includeMempool = true) - address <- new BitcoinCoreWallet(bitcoinRpcClient).getFinalAddress - } yield (funding, height, address, isSpendable), 60 seconds) - - if (!isFundingSpendable) { - logger.info(s"Sorry but the funding tx has been spent, the channel has been closed") - return Future.successful(()) - } - + val finalAddress = Await.result(new BitcoinCoreWallet(bitcoinRpcClient).getFinalAddress, 30 seconds) val finalScriptPubkey = Script.write(addressToPublicKeyScript(finalAddress, appKit.nodeParams.chainHash)) - val channelId = fr.acinq.eclair.toLongId(fundingTx.hash, backup.fundingOutputIndex.toInt) + val channelId = ByteVector32.Zeroes //fr.acinq.eclair.toLongId(fundingTx.hash, backup.fundingOutputIndex.toInt) val inputInfo = Transactions.InputInfo( - outPoint = OutPoint(fundingTx.hash, backup.fundingOutputIndex), - txOut = fundingTx.txOut(backup.fundingOutputIndex.toInt), + outPoint = OutPoint(ByteVector32.Zeroes, 0), + txOut = TxOut(0 sat, ByteVector.empty), redeemScript = ByteVector.empty ) val channelKeyPath = ??? - logger.info(s"Recovery using: channelId=$channelId finalScriptPubKey=$finalAddress remotePeer=${uri.nodeId} funder=${backup.isFunder}") + logger.info(s"Recovery using: channelId=$channelId finalScriptPubKey=$finalAddress remotePeer=${uri.nodeId}") val commitments = makeDummyCommitment(appKit.nodeParams.keyManager, channelKeyPath, uri.nodeId, appKit.nodeParams.nodeId, channelId, inputInfo, finalScriptPubkey, appKit.nodeParams.chainHash) (appKit.switchboard ? ReconnectWithCommitments(uri, commitments)).mapTo[Unit] } @@ -265,4 +206,4 @@ object RecoveryTool extends Logging { Random.nextLong().abs )) -} \ No newline at end of file +} From ad8bf9ae4cfb3dbeb0f8de621cdfafc8e204473a Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 16 Oct 2019 14:36:05 +0200 Subject: [PATCH 05/26] WIP recovery --- .../eclair/recovery/RecoveryChannel.scala | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala new file mode 100644 index 0000000000..2c18e007ee --- /dev/null +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala @@ -0,0 +1,34 @@ +package fr.acinq.eclair.recovery + +import akka.actor.{ActorRef, FSM} +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.eclair.NodeParams +import fr.acinq.eclair.blockchain.EclairWallet +import fr.acinq.eclair.io.{NodeURI, Peer} +import fr.acinq.eclair.recovery.RecoveryChannel._ + +import scala.concurrent.ExecutionContext + +class RecoveryChannel(val nodeParams: NodeParams, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, origin_opt: Option[ActorRef] = None)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) extends FSM[State, Data] { + + startWith(WAIT_FOR_CONNECTION, Empty) + + when(WAIT_FOR_CONNECTION){ + case Event(Connect(nodeURI: NodeURI), Empty) => + switchboard ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) + stay + } + +} + +object RecoveryChannel { + + sealed trait State + case object WAIT_FOR_CONNECTION extends State + + sealed trait Data + case object Empty extends Data + + sealed trait Event + case class Connect(remote: NodeURI) +} \ No newline at end of file From 0d574a521af03f7a2b9d5c2ec8120cbf532ffab8 Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 30 Oct 2019 10:07:20 +0100 Subject: [PATCH 06/26] Remove unused classes --- .../fr/acinq/eclair/io/Switchboard.scala | 7 ---- .../eclair/recovery/RecoveryChannel.scala | 34 ------------------- 2 files changed, 41 deletions(-) delete mode 100644 eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala index 59ab93f94d..5e5f832ecd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala @@ -26,7 +26,6 @@ import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel.{HasCommitments, _} import fr.acinq.eclair.db.PendingRelayDb -import fr.acinq.eclair.io.Peer.Connect import fr.acinq.eclair.payment.Relayer.RelayPayload import fr.acinq.eclair.payment.{Origin, Relayer} import fr.acinq.eclair.router.Rebroadcast @@ -111,10 +110,6 @@ class Switchboard(nodeParams: NodeParams, authenticator: ActorRef, watcher: Acto case 'peers => sender ! context.children - case c: ReconnectWithCommitments => - val peer = createOrGetPeer(c.uri.nodeId, previousKnownAddress = None, offlineChannels = Set(c.commitments)) - peer forward Connect(c.uri) - } /** @@ -223,8 +218,6 @@ object Switchboard extends Logging { toClean.size } - // Used during the recovery tool procedure to trigger the connection to a peer using the provided channel state data - case class ReconnectWithCommitments(uri: NodeURI, commitments: HasCommitments) } class HtlcReaper extends Actor with ActorLogging { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala deleted file mode 100644 index 2c18e007ee..0000000000 --- a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryChannel.scala +++ /dev/null @@ -1,34 +0,0 @@ -package fr.acinq.eclair.recovery - -import akka.actor.{ActorRef, FSM} -import fr.acinq.bitcoin.Crypto.PublicKey -import fr.acinq.eclair.NodeParams -import fr.acinq.eclair.blockchain.EclairWallet -import fr.acinq.eclair.io.{NodeURI, Peer} -import fr.acinq.eclair.recovery.RecoveryChannel._ - -import scala.concurrent.ExecutionContext - -class RecoveryChannel(val nodeParams: NodeParams, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, origin_opt: Option[ActorRef] = None)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) extends FSM[State, Data] { - - startWith(WAIT_FOR_CONNECTION, Empty) - - when(WAIT_FOR_CONNECTION){ - case Event(Connect(nodeURI: NodeURI), Empty) => - switchboard ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) - stay - } - -} - -object RecoveryChannel { - - sealed trait State - case object WAIT_FOR_CONNECTION extends State - - sealed trait Data - case object Empty extends Data - - sealed trait Event - case class Connect(remote: NodeURI) -} \ No newline at end of file From 91db81457c8473ef6cdd4b6e9505f69e479feb0a Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 31 Oct 2019 12:03:36 +0100 Subject: [PATCH 07/26] WIP create recovery switchboard and peer --- .../main/scala/fr/acinq/eclair/Eclair.scala | 11 +- .../main/scala/fr/acinq/eclair/Setup.scala | 9 +- .../main/scala/fr/acinq/eclair/io/Peer.scala | 8 +- .../acinq/eclair/recovery/RecoveryFSM.scala | 97 +++++++++ .../fr/acinq/eclair/EclairImplSpec.scala | 2 + .../src/main/scala/fr/acinq/eclair/Boot.scala | 22 ++- .../acinq/eclair/recovery/RecoveryTool.scala | 185 +----------------- 7 files changed, 146 insertions(+), 188 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index c235c9fbec..da053e6478 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair import java.util.UUID -import akka.actor.ActorRef +import akka.actor.{ActorRef, Props} import akka.pattern._ import akka.util.Timeout import fr.acinq.bitcoin.Crypto.PublicKey @@ -32,6 +32,7 @@ import fr.acinq.eclair.io.{NodeURI, Peer} import fr.acinq.eclair.payment.PaymentInitiator.SendPaymentRequest import fr.acinq.eclair.payment.PaymentLifecycle.ReceivePayment import fr.acinq.eclair.payment._ +import fr.acinq.eclair.recovery.RecoveryFSM import fr.acinq.eclair.router.{ChannelDesc, RouteRequest, RouteResponse, Router} import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} import scodec.bits.ByteVector @@ -111,6 +112,8 @@ trait Eclair { def getInfoResponse()(implicit timeout: Timeout): Future[GetInfoResponse] def usableBalances()(implicit timeout: Timeout): Future[Iterable[UsableBalances]] + + def doRecovery(uri: NodeURI): Unit } class EclairImpl(appKit: Kit) extends Eclair { @@ -289,4 +292,10 @@ class EclairImpl(appKit: Kit) extends Eclair { ) override def usableBalances()(implicit timeout: Timeout): Future[Iterable[UsableBalances]] = (appKit.relayer ? GetUsableBalances).mapTo[Iterable[UsableBalances]] + + override def doRecovery(uri: NodeURI): Unit = { + + ??? +// val recoveryFSM = appKit.system.actorOf(Props(new RecoveryFSM(appKit.nodeParams, appKit.))) + } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index aac5a02c72..8e2930a8cc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -44,6 +44,7 @@ import fr.acinq.eclair.crypto.LocalKeyManager import fr.acinq.eclair.db.{BackupHandler, Databases} import fr.acinq.eclair.io.{Authenticator, Server, Switchboard} import fr.acinq.eclair.payment._ +import fr.acinq.eclair.recovery.RecoverySwitchBoard import fr.acinq.eclair.router._ import fr.acinq.eclair.tor.TorProtocolHandler.OnionServiceVersion import fr.acinq.eclair.tor.{Controller, TorProtocolHandler} @@ -284,7 +285,7 @@ class Setup(datadir: File, register = system.actorOf(SimpleSupervisor.props(Props(new Register), "register", SupervisorStrategy.Resume)) relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, register, paymentHandler), "relayer", SupervisorStrategy.Resume)) authenticator = system.actorOf(SimpleSupervisor.props(Authenticator.props(nodeParams), "authenticator", SupervisorStrategy.Resume)) - switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, authenticator, watcher, router, relayer, wallet), "switchboard", SupervisorStrategy.Resume)) + switchboard = getSwitchboard(authenticator, watcher, router, relayer, wallet) server = system.actorOf(SimpleSupervisor.props(Server.props(nodeParams, authenticator, serverBindingAddress, Some(tcpBound)), "server", SupervisorStrategy.Restart)) paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, router, register), "payment-initiator", SupervisorStrategy.Restart)) _ = for (i <- 0 until config.getInt("autoprobe-count")) yield system.actorOf(SimpleSupervisor.props(Autoprobe.props(nodeParams, router, paymentInitiator), s"payment-autoprobe-$i", SupervisorStrategy.Restart)) @@ -300,6 +301,7 @@ class Setup(datadir: File, switchboard = switchboard, paymentInitiator = paymentInitiator, server = server, + authenticator = authenticator, wallet = wallet) zmqBlockTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) @@ -313,6 +315,10 @@ class Setup(datadir: File, } + def getSwitchboard(authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet): ActorRef = { + system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, authenticator, watcher, router, relayer, wallet), "switchboard", SupervisorStrategy.Resume)) + } + private def await[T](awaitable: Awaitable[T], atMost: Duration, messageOnTimeout: => String): T = try { Await.result(awaitable, atMost) } catch { @@ -364,6 +370,7 @@ case class Kit(nodeParams: NodeParams, switchboard: ActorRef, paymentInitiator: ActorRef, server: ActorRef, + authenticator: ActorRef, wallet: EclairWallet) case object BitcoinZMQConnectionTimeoutException extends RuntimeException("could not connect to bitcoind using zeromq") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index 0aee107c95..531aa3b9e2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -214,6 +214,10 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: A } when(CONNECTED) { + case event => whenConnected(event) + } + + def whenConnected(event: Event): State = event match { case Event(StateTimeout, _: ConnectedData) => // the first ping is sent after the connection has been quiet for a while // we don't want to send pings right after connection, because peer will be syncing and may not be able to @@ -357,8 +361,8 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: A case Event(DelayedRebroadcast(rebroadcast), d: ConnectedData) => /** - * Send and count in a single iteration - */ + * Send and count in a single iteration + */ def sendAndCount(msgs: Map[_ <: RoutingMessage, Set[ActorRef]]): Int = msgs.foldLeft(0) { case (count, (_, origins)) if origins.contains(self) => // the announcement came from this peer, we don't send it back diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala new file mode 100644 index 0000000000..8b96e6e0ed --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -0,0 +1,97 @@ +package fr.acinq.eclair.recovery + +import java.net.InetSocketAddress + +import akka.actor.{Actor, ActorRef, Props} +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.eclair.NodeParams +import fr.acinq.eclair.blockchain.EclairWallet +import fr.acinq.eclair.channel.{Channel, HasCommitments} +import fr.acinq.eclair.crypto.TransportHandler +import fr.acinq.eclair.io.Peer.{ConnectedData, FinalChannelId} +import fr.acinq.eclair.io.Switchboard.peerActorName +import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected, Switchboard} +import fr.acinq.eclair.recovery.RecoveryFSM._ +import fr.acinq.eclair.wire._ +import fr.acinq.eclair.{wire, _} +import grizzled.slf4j.Logging + +class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef) extends Actor with Logging { + + context.system.eventStream.subscribe(self, classOf[PeerConnected]) + self ! RecoveryConnect(nodeURI) + + override def receive: Receive = waitingForConnection() + + def waitingForConnection(): Receive = { + case c@RecoveryConnect(nodeURI: NodeURI) => + logger.info(s"creating new recovery peer") + val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId, authenticator, blockchain, router, relayer, wallet, self))) + peer ! Peer.Init(previousKnownAddress = None, storedChannels = Set.empty) + peer ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) + context.become(waitingForConnection()) + case PeerConnected(_, nodeId) if nodeId == nodeURI.nodeId => + logger.info(s"Connected to remote $nodeId") + context.become(waitingForChannel()) + } + + def waitingForChannel(): Receive = { + case ChannelFound(channelId) => + logger.info(s"peer=${nodeURI.nodeId} knows channelId=$channelId") + context.become(waitingForChannel()) + } + +} + +object RecoveryFSM { + + sealed trait State + case object WAIT_FOR_CONNECTION extends State + case object WAIT_FOR_CHANNEL extends State + + sealed trait Data + case object Empty extends Data + + sealed trait Event + case class RecoveryConnect(remote: NodeURI) extends Event + case class ChannelFound(channelId: ByteVector32) extends Event +} + +class RecoverySwitchBoard(nodeParams: NodeParams, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet) extends Switchboard(nodeParams, authenticator, watcher, router, relayer, wallet) { + + override def createOrGetPeer(remoteNodeId: PublicKey, previousKnownAddress: Option[InetSocketAddress], offlineChannels: Set[HasCommitments]): ActorRef = { + getPeer(remoteNodeId) match { + case Some(peer) => peer + case None => + log.info(s"creating new recovery peer current=${context.children.size}") + val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet, self)), name = peerActorName(remoteNodeId)) + peer ! Peer.Init(previousKnownAddress, offlineChannels) + peer + } + } + +} + +class RecoveryPeer(override val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet, recoveryFSM: ActorRef) extends Peer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet) { + + override def whenConnected(event: Event): State = event match { + case Event(msg: HasChannelId, d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(msg) + recoveryFSM ! ChannelFound(msg.channelId) + d.channels.get(FinalChannelId(msg.channelId)) match { + case Some(channel) => channel forward msg + case None => d.transport ! wire.Error(msg.channelId, Peer.UNKNOWN_CHANNEL_MESSAGE) + } + stay + + case _ => super.whenConnected(event) + } + +} + +//class RecoveryChannel(override val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: PublicKey, blockchain: ActorRef, router: ActorRef, relayer: ActorRef, origin_opt: Option[ActorRef] = None) extends Channel(nodeParams, wallet, remoteNodeId, blockchain, router, relayer, origin_opt) { +// +// log.info("!! Using recovery channel !!") +// +//} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index f6e604ad23..4c52c55407 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -54,6 +54,7 @@ class EclairImplSpec extends TestKit(ActorSystem("test")) with fixture.FunSuiteL val switchboard = TestProbe() val paymentInitiator = TestProbe() val server = TestProbe() + val authenticator = TestProbe() val kit = Kit( TestConstants.Alice.nodeParams, system, @@ -65,6 +66,7 @@ class EclairImplSpec extends TestKit(ActorSystem("test")) with fixture.FunSuiteL switchboard.ref, paymentInitiator.ref, server.ref, + authenticator.ref, new TestWallet() ) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala index 638ecad1e3..7120e2156e 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala @@ -18,11 +18,13 @@ package fr.acinq.eclair import java.io.File -import akka.actor.ActorSystem +import akka.actor.{ActorRef, ActorSystem, Props, SupervisorStrategy} import akka.http.scaladsl.Http import akka.stream.{ActorMaterializer, BindFailedException} -import com.typesafe.config.Config +import com.typesafe.config.{Config, ConfigFactory} import fr.acinq.eclair.api.Service +import fr.acinq.eclair.blockchain.EclairWallet +import fr.acinq.eclair.recovery.{RecoverySwitchBoard, RecoveryTool} import grizzled.slf4j.Logging import kamon.Kamon @@ -41,7 +43,17 @@ object Boot extends App with Logging { plugins.foreach(plugin => logger.info(s"loaded plugin ${plugin.getClass.getSimpleName}")) implicit val system: ActorSystem = ActorSystem("eclair-node") implicit val ec: ExecutionContext = system.dispatcher - val setup = new Setup(datadir) + + val tempConfig = ConfigFactory.load() + val setup = if(tempConfig.hasPath("recovery-tool")){ + new Setup(new File("/tmp/eclair_recovery_datadir")) { + override def getSwitchboard(authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet): ActorRef = { + system.actorOf(SimpleSupervisor.props(Props(new RecoverySwitchBoard(nodeParams, authenticator, watcher, router, relayer, wallet)), "switchboard", SupervisorStrategy.Resume)) + } + } + } else { + new Setup(datadir) + } if (setup.config.getBoolean("enable-kamon")) { Kamon.init(setup.appConfig) @@ -52,6 +64,10 @@ object Boot extends App with Logging { case Success(kit) => startApiServiceIfEnabled(setup.config, kit) plugins.foreach(_.onKit(kit)) + if(setup.config.hasPath("recovery-tool")){ + RecoveryTool.interactiveRecovery(kit) + } + case Failure(t) => onError(t) } } catch { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala index d9caf24cdc..11ccd3ceb3 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala @@ -1,29 +1,10 @@ package fr.acinq.eclair.recovery -import akka.util.Timeout -import akka.pattern.ask -import com.softwaremill.sttp.okhttp.OkHttpFutureBackend -import fr.acinq.bitcoin.Crypto.PublicKey -import fr.acinq.bitcoin.DeterministicWallet.KeyPath -import fr.acinq.bitcoin.{ByteVector32, ByteVector64, OutPoint, Satoshi, Script, Transaction, TxOut} -import fr.acinq.eclair.blockchain.bitcoind.BitcoinCoreWallet -import fr.acinq.eclair.blockchain.bitcoind.rpc.BasicBitcoinJsonRPCClient -import fr.acinq.eclair.channel._ -import fr.acinq.eclair._ -import fr.acinq.eclair.crypto.{KeyManager, LocalKeyManager, ShaChain} -import fr.acinq.eclair.io.NodeURI -import fr.acinq.eclair.io.Switchboard.ReconnectWithCommitments -import fr.acinq.eclair.transactions.Transactions.{CommitTx, InputInfo} -import fr.acinq.eclair.transactions.{CommitmentSpec, Transactions} -import fr.acinq.eclair.wire.ChannelUpdate -import fr.acinq.eclair.{CltvExpiryDelta, Kit, ShortChannelId, UInt64, addressToPublicKeyScript, randomBytes} +import akka.actor.Props +import fr.acinq.eclair.io.{NodeURI, Peer} +import fr.acinq.eclair.Kit import grizzled.slf4j.Logging -import scodec.bits.ByteVector -import scodec.bits.HexStringSyntax -import scala.compat.Platform -import scala.concurrent.{Await, ExecutionContext, Future} -import scala.concurrent.duration._ import scala.util.{Failure, Random, Success, Try} object RecoveryTool extends Logging { @@ -34,7 +15,7 @@ object RecoveryTool extends Logging { println(s"\n ### Welcome to the eclair recovery tool ### \n") val nodeUri = getInput[NodeURI]("Please insert the URI of the target node: ", NodeURI.parse) println(s"### Attempting channel recovery now - good luck! ###") - doRecovery(appKit, nodeUri)(appKit.system.dispatcher) + appKit.system.actorOf(Props(new RecoveryFSM(nodeUri, appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer))) } private def getInput[T](msg: String, parse: String => T): T = { @@ -48,162 +29,4 @@ object RecoveryTool extends Logging { throw new IllegalArgumentException("Unable to get input") } - - def doRecovery(appKit: Kit, uri: NodeURI)(implicit ec: ExecutionContext): Future[Unit] = { - - implicit val timeout = Timeout(10 minutes) - implicit val shttp = OkHttpFutureBackend() - - val bitcoinRpcClient = new BasicBitcoinJsonRPCClient( - user = appKit.nodeParams.config.getString("bitcoind.rpcuser"), - password = appKit.nodeParams.config.getString("bitcoind.rpcpassword"), - host = appKit.nodeParams.config.getString("bitcoind.host"), - port = appKit.nodeParams.config.getInt("bitcoind.rpcport") - ) - - val finalAddress = Await.result(new BitcoinCoreWallet(bitcoinRpcClient).getFinalAddress, 30 seconds) - val finalScriptPubkey = Script.write(addressToPublicKeyScript(finalAddress, appKit.nodeParams.chainHash)) - val channelId = ByteVector32.Zeroes //fr.acinq.eclair.toLongId(fundingTx.hash, backup.fundingOutputIndex.toInt) - - val inputInfo = Transactions.InputInfo( - outPoint = OutPoint(ByteVector32.Zeroes, 0), - txOut = TxOut(0 sat, ByteVector.empty), - redeemScript = ByteVector.empty - ) - - val channelKeyPath = ??? - - logger.info(s"Recovery using: channelId=$channelId finalScriptPubKey=$finalAddress remotePeer=${uri.nodeId}") - val commitments = makeDummyCommitment(appKit.nodeParams.keyManager, channelKeyPath, uri.nodeId, appKit.nodeParams.nodeId, channelId, inputInfo, finalScriptPubkey, appKit.nodeParams.chainHash) - (appKit.switchboard ? ReconnectWithCommitments(uri, commitments)).mapTo[Unit] - } - - /** - * This creates the necessary data to simulate a channel in state NORMAL, it contains dummy "points" and "indexes", as well as a dummy channel_update. - */ - def makeDummyCommitment( - keyManager: KeyManager, - channelKeyPath: KeyPath, - remoteNodeId: PublicKey, - localNodeId: PublicKey, - channelId: ByteVector32, - commitInput: InputInfo, - finalScriptPubkey: ByteVector, - chainHash: ByteVector32 - ) = DATA_NORMAL( - commitments = Commitments( - channelVersion = ChannelVersion.STANDARD, - localParams = LocalParams( - nodeId = localNodeId, - fundingKeyPath = channelKeyPath, - dustLimit = 0 sat, - maxHtlcValueInFlightMsat = UInt64(0), - channelReserve = 0 sat, - toSelfDelay = CltvExpiryDelta(0), - htlcMinimum = 0 msat, - maxAcceptedHtlcs = 0, - isFunder = true, - defaultFinalScriptPubKey = finalScriptPubkey, - globalFeatures = hex"00", - localFeatures = hex"00" - ), - remoteParams = RemoteParams( - remoteNodeId, - dustLimit = 0 sat, - maxHtlcValueInFlightMsat = UInt64(0), - channelReserve = 0 sat, - htlcMinimum = 0 msat, - toSelfDelay = CltvExpiryDelta(0), - maxAcceptedHtlcs = 0, - fundingPubKey = keyManager.fundingPublicKey(randomKeyPath).publicKey, - revocationBasepoint = randomPoint(chainHash), - paymentBasepoint = randomPoint(chainHash), - delayedPaymentBasepoint = randomPoint(chainHash), - htlcBasepoint = randomPoint(chainHash), - globalFeatures = hex"00", - localFeatures = hex"00" - ), - channelFlags = 1.toByte, - localCommit = LocalCommit( - 0, - spec = CommitmentSpec( - htlcs = Set(), - feeratePerKw = 10, // TODO: review - toLocal = 0 msat, - toRemote = 0 msat - ), - publishableTxs = PublishableTxs( - CommitTx( - input = Transactions.InputInfo( - outPoint = OutPoint(ByteVector32.Zeroes, 0), - txOut = TxOut(Satoshi(0), ByteVector.empty), - redeemScript = ByteVector.empty - ), - tx = Transaction.read("0200000000010163c75c555d712a81998ddbaf9ce1d55b153fc7cb71441ae1782143bb6b04b95d0000000000a325818002bc893c0000000000220020ae8d04088ff67f3a0a9106adb84beb7530097b262ff91f8a9a79b7851b50857f00127a0000000000160014be0f04e9ed31b6ece46ca8c17e1ed233c71da0e9040047304402203b280f9655f132f4baa441261b1b590bec3a6fcd6d7180c929fa287f95d200f80220100d826d56362c65d09b8687ca470a31c1e2bb3ad9a41321ceba355d60b77b79014730440220539e34ab02cced861f9c39f9d14ece41f1ed6aed12443a9a4a88eb2792356be6022023dc4f18730a6471bdf9b640dfb831744b81249ffc50bd5a756ae85d8c6749c20147522102184615bf2294acc075701892d7bd8aff28d78f84330e8931102e537c8dfe92a3210367d50e7eab4a0ab0c6b92aa2dcf6cc55a02c3db157866b27a723b8ec47e1338152ae74f15a20") - ), - htlcTxsAndSigs = List.empty - ) - ), - remoteCommit = RemoteCommit( - 0, - spec = CommitmentSpec( - htlcs = Set(), - feeratePerKw = 10, // TODO: review - toLocal = 0 msat, - toRemote = 0 msat - ), - txid = ByteVector32.Zeroes, - remotePerCommitmentPoint = randomPoint(chainHash) - ), - localChanges = LocalChanges( - proposed = List.empty, - signed = List.empty, - acked = List.empty - ), - remoteChanges = RemoteChanges( - proposed = List.empty, - signed = List.empty, - acked = List.empty - ), - localNextHtlcId = 0, - remoteNextHtlcId = 0, - originChannels = Map(), - remoteNextCommitInfo = Right(randomPoint(chainHash)), - commitInput = commitInput, - remotePerCommitmentSecrets = ShaChain.init, - channelId = channelId - ), - shortChannelId = ShortChannelId("123x1x0"), - buried = true, - channelAnnouncement = None, - channelUpdate = ChannelUpdate( - signature = ByteVector64.Zeroes, - chainHash = chainHash, - shortChannelId = ShortChannelId("123x1x0"), - timestamp = Platform.currentTime.milliseconds.toSeconds, - messageFlags = 0.toByte, - channelFlags = 0.toByte, - cltvExpiryDelta = CltvExpiryDelta(144), - htlcMinimumMsat = 0 msat, - feeBaseMsat = 0 msat, - feeProportionalMillionths = 0, - htlcMaximumMsat = None - ), - localShutdown = None, - remoteShutdown = None - ) - - private def randomPoint(chainHash: ByteVector32) = { - val keyManager = new LocalKeyManager(seed = randomBytes(32), chainHash) - val keyPath = randomKeyPath() - keyManager.commitmentPoint(keyPath, Random.nextLong().abs) - } - - private def randomKeyPath() = KeyPath(Seq( - Random.nextLong().abs, - Random.nextLong().abs, - Random.nextLong().abs, - Random.nextLong().abs - )) - } From b0e4fc02a0ee55a8ae0c28fc948fc62656d8ad15 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 31 Oct 2019 15:07:16 +0100 Subject: [PATCH 08/26] Lookup funding transaction and ask remote to publish last commitment --- .../acinq/eclair/recovery/RecoveryFSM.scala | 128 +++++++++++++----- .../src/main/scala/fr/acinq/eclair/Boot.scala | 8 +- .../acinq/eclair/recovery/RecoveryTool.scala | 14 +- 3 files changed, 114 insertions(+), 36 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 8b96e6e0ed..952ea6cfae 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -2,22 +2,29 @@ package fr.acinq.eclair.recovery import java.net.InetSocketAddress -import akka.actor.{Actor, ActorRef, Props} -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Crypto.PublicKey +import akka.actor.{Actor, ActorRef, ActorSelection, PoisonPill, Props} +import fr.acinq.bitcoin.{ByteVector32, Transaction} +import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.EclairWallet -import fr.acinq.eclair.channel.{Channel, HasCommitments} +import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient} +import fr.acinq.eclair.channel.{Channel, HasCommitments, PleasePublishYourCommitment} import fr.acinq.eclair.crypto.TransportHandler -import fr.acinq.eclair.io.Peer.{ConnectedData, FinalChannelId} +import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect, FinalChannelId} import fr.acinq.eclair.io.Switchboard.peerActorName import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected, Switchboard} import fr.acinq.eclair.recovery.RecoveryFSM._ import fr.acinq.eclair.wire._ -import fr.acinq.eclair.{wire, _} import grizzled.slf4j.Logging -class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef) extends Actor with Logging { +import scala.concurrent.{Await, Future} +import scala.concurrent.duration._ +import scala.util.{Failure, Success} + +class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends Actor with Logging { + + implicit val ec = context.system.dispatcher + val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) context.system.eventStream.subscribe(self, classOf[PeerConnected]) self ! RecoveryConnect(nodeURI) @@ -25,37 +32,100 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A override def receive: Receive = waitingForConnection() def waitingForConnection(): Receive = { - case c@RecoveryConnect(nodeURI: NodeURI) => + case RecoveryConnect(nodeURI: NodeURI) => logger.info(s"creating new recovery peer") - val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId, authenticator, blockchain, router, relayer, wallet, self))) + val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId, authenticator, blockchain, router, relayer, wallet))) peer ! Peer.Init(previousKnownAddress = None, storedChannels = Set.empty) peer ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) context.become(waitingForConnection()) - case PeerConnected(_, nodeId) if nodeId == nodeURI.nodeId => + case PeerConnected(peer, nodeId) if nodeId == nodeURI.nodeId => logger.info(s"Connected to remote $nodeId") - context.become(waitingForChannel()) + context.become(waitingForRemoteChannelInfo(DATA_WAIT_FOR_REMOTE_INFO(peer))) } - def waitingForChannel(): Receive = { - case ChannelFound(channelId) => + def waitingForRemoteChannelInfo(d: DATA_WAIT_FOR_REMOTE_INFO): Receive = { + case ChannelFound(channelId, reestablish) => logger.info(s"peer=${nodeURI.nodeId} knows channelId=$channelId") - context.become(waitingForChannel()) + + lookupFundingTx(channelId) match { + case None => + logger.info(s"could not find funding transaction...disconnecting") + d.peer ! Disconnect + self ! PoisonPill + + case Some((fundingTx, outIndex)) => + logger.info(s"found unspent channel funding_tx=${fundingTx.txid} outputIndex=$outIndex") + logger.info(s"asking remote to close the channel") + d.peer ! Error(channelId, PleasePublishYourCommitment(channelId).toString) + context.system.scheduler.scheduleOnce(10 seconds)(self ! CheckCommitmentPublished) + context.become(waitForRemoteToPublishCommitment(DATA_WAIT_FOR_REMOTE_PUBLISH(d.peer, reestablish, fundingTx, outIndex))) + } + } + + def waitForRemoteToPublishCommitment(d: DATA_WAIT_FOR_REMOTE_PUBLISH): Receive = { + case CheckCommitmentPublished => + bitcoinClient.lookForSpendingTx(None, d.fundingTx.txid.toHex, d.fundingOutIndex).onComplete { + case Success(commitTx) => + recoverFromCommitment(commitTx) + logger.info(s"recovery done") + d.peer ! Disconnect + self ! PoisonPill + case Failure(_) => context.system.scheduler.scheduleOnce(10 seconds)(self ! CheckCommitmentPublished) + } + } + + def recoverFromCommitment(commitTx: Transaction) = { + logger.info("we made it!") + } + + def lookupFundingTx(channelId: ByteVector32): Option[(Transaction, Int)] = { + val candidateFundingTxIds = fundingIds(channelId) + logger.info(s"computed funding txids=${candidateFundingTxIds.map(_._1)}") + val fundingTx_opt = Await.result(Future.sequence(candidateFundingTxIds.map { case (txId, _) => + transactionExists(txId) + }).map(_.flatten.headOption), 60 seconds) + + fundingTx_opt.map { funding => + (funding, candidateFundingTxIds.find(_._1 == funding.txid).map(_._2).get) + } + } + + /** + * Extracts the funding_txid and output index from channelId + */ + def fundingIds(channelId: ByteVector32, limit: Int = 5): Seq[(ByteVector32, Int)] = { + 0 until limit map { i => + (fr.acinq.eclair.toLongId(channelId.reverse, i), i) + } + } + + def transactionExists(txId: ByteVector32): Future[Option[Transaction]] = { + bitcoinClient.getTransaction(txId.toHex).collect { + case tx: Transaction => Some(tx) + }.recover { + case _ => None + } } } object RecoveryFSM { + val actorName = "recovery-fsm-actor" + sealed trait State case object WAIT_FOR_CONNECTION extends State case object WAIT_FOR_CHANNEL extends State sealed trait Data - case object Empty extends Data + case class DATA_WAIT_FOR_REMOTE_INFO(peer: ActorRef) extends Data + case class DATA_WAIT_FOR_REMOTE_PUBLISH(peer: ActorRef, channelReestablish: ChannelReestablish, fundingTx: Transaction, fundingOutIndex: Int) extends Data sealed trait Event case class RecoveryConnect(remote: NodeURI) extends Event - case class ChannelFound(channelId: ByteVector32) extends Event + case class ChannelFound(channelId: ByteVector32, reestablish: ChannelReestablish) extends Event + case class SendErrorToRemote(error: Error) extends Event + case object CheckCommitmentPublished extends Event } class RecoverySwitchBoard(nodeParams: NodeParams, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet) extends Switchboard(nodeParams, authenticator, watcher, router, relayer, wallet) { @@ -65,7 +135,7 @@ class RecoverySwitchBoard(nodeParams: NodeParams, authenticator: ActorRef, watch case Some(peer) => peer case None => log.info(s"creating new recovery peer current=${context.children.size}") - val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet, self)), name = peerActorName(remoteNodeId)) + val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet)), name = peerActorName(remoteNodeId)) peer ! Peer.Init(previousKnownAddress, offlineChannels) peer } @@ -73,25 +143,21 @@ class RecoverySwitchBoard(nodeParams: NodeParams, authenticator: ActorRef, watch } -class RecoveryPeer(override val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet, recoveryFSM: ActorRef) extends Peer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet) { +class RecoveryPeer(override val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet) extends Peer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet) { + + def recoveryFSM: ActorSelection = context.system.actorSelection(context.system / RecoveryFSM.actorName) override def whenConnected(event: Event): State = event match { - case Event(msg: HasChannelId, d: ConnectedData) => + case Event(SendErrorToRemote(error), d: ConnectedData) => + d.transport ! error + stay + + case Event(msg: ChannelReestablish, d: ConnectedData) => d.transport ! TransportHandler.ReadAck(msg) - recoveryFSM ! ChannelFound(msg.channelId) - d.channels.get(FinalChannelId(msg.channelId)) match { - case Some(channel) => channel forward msg - case None => d.transport ! wire.Error(msg.channelId, Peer.UNKNOWN_CHANNEL_MESSAGE) - } + recoveryFSM ! ChannelFound(msg.channelId, msg) stay case _ => super.whenConnected(event) } -} - -//class RecoveryChannel(override val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: PublicKey, blockchain: ActorRef, router: ActorRef, relayer: ActorRef, origin_opt: Option[ActorRef] = None) extends Channel(nodeParams, wallet, remoteNodeId, blockchain, router, relayer, origin_opt) { -// -// log.info("!! Using recovery channel !!") -// -//} +} \ No newline at end of file diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala index 7120e2156e..6921b56659 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala @@ -44,11 +44,11 @@ object Boot extends App with Logging { implicit val system: ActorSystem = ActorSystem("eclair-node") implicit val ec: ExecutionContext = system.dispatcher - val tempConfig = ConfigFactory.load() - val setup = if(tempConfig.hasPath("recovery-tool")){ - new Setup(new File("/tmp/eclair_recovery_datadir")) { + val setup = if(ConfigFactory.load().hasPath("eclair.recovery-tool")){ + new Setup(datadir) { + logger.info(s"recovery mode enabled") override def getSwitchboard(authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet): ActorRef = { - system.actorOf(SimpleSupervisor.props(Props(new RecoverySwitchBoard(nodeParams, authenticator, watcher, router, relayer, wallet)), "switchboard", SupervisorStrategy.Resume)) + system.actorOf(SimpleSupervisor.props(Props(new RecoverySwitchBoard(nodeParams, authenticator, watcher, router, relayer, wallet)), "recovery-switchboard", SupervisorStrategy.Resume)) } } } else { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala index 11ccd3ceb3..51d535958a 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala @@ -1,8 +1,10 @@ package fr.acinq.eclair.recovery import akka.actor.Props +import com.softwaremill.sttp.okhttp.OkHttpFutureBackend import fr.acinq.eclair.io.{NodeURI, Peer} import fr.acinq.eclair.Kit +import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinJsonRPCClient} import grizzled.slf4j.Logging import scala.util.{Failure, Random, Success, Try} @@ -15,7 +17,17 @@ object RecoveryTool extends Logging { println(s"\n ### Welcome to the eclair recovery tool ### \n") val nodeUri = getInput[NodeURI]("Please insert the URI of the target node: ", NodeURI.parse) println(s"### Attempting channel recovery now - good luck! ###") - appKit.system.actorOf(Props(new RecoveryFSM(nodeUri, appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer))) + + implicit val shttp = OkHttpFutureBackend() + + val bitcoinRpcClient = new BasicBitcoinJsonRPCClient( + user = appKit.nodeParams.config.getString("bitcoind.rpcuser"), + password = appKit.nodeParams.config.getString("bitcoind.rpcpassword"), + host = appKit.nodeParams.config.getString("bitcoind.host"), + port = appKit.nodeParams.config.getInt("bitcoind.rpcport") + ) + + appKit.system.actorOf(Props(new RecoveryFSM(nodeUri, appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) } private def getInput[T](msg: String, parse: String => T): T = { From c0931cc42c4e5b3afd07bfa13c3abeb4416e730c Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 31 Oct 2019 15:21:59 +0100 Subject: [PATCH 09/26] Reduce polling time to 5 seconds, renaming --- .../acinq/eclair/recovery/RecoveryFSM.scala | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 952ea6cfae..7d51ec0940 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -57,32 +57,40 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A logger.info(s"found unspent channel funding_tx=${fundingTx.txid} outputIndex=$outIndex") logger.info(s"asking remote to close the channel") d.peer ! Error(channelId, PleasePublishYourCommitment(channelId).toString) - context.system.scheduler.scheduleOnce(10 seconds)(self ! CheckCommitmentPublished) + context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckCommitmentPublished) context.become(waitForRemoteToPublishCommitment(DATA_WAIT_FOR_REMOTE_PUBLISH(d.peer, reestablish, fundingTx, outIndex))) } } def waitForRemoteToPublishCommitment(d: DATA_WAIT_FOR_REMOTE_PUBLISH): Receive = { case CheckCommitmentPublished => + logger.info(s"looking for the commitment transaction") bitcoinClient.lookForSpendingTx(None, d.fundingTx.txid.toHex, d.fundingOutIndex).onComplete { case Success(commitTx) => - recoverFromCommitment(commitTx) + recoverFromCommitment(commitTx, d.channelReestablish) logger.info(s"recovery done") d.peer ! Disconnect self ! PoisonPill - case Failure(_) => context.system.scheduler.scheduleOnce(10 seconds)(self ! CheckCommitmentPublished) + case Failure(_) => + context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckCommitmentPublished) } } - def recoverFromCommitment(commitTx: Transaction) = { + def recoverFromCommitment(commitTx: Transaction, channelReestablish: ChannelReestablish) = { + // extract our funding pubkey from witness + // compute channel key path from funding pubkey + // compute points necessary to redeem out outputs + // create txs and broadcast logger.info("we made it!") } + /** + * Given a channelId tries to guess the fundingTxId and retrieve the funding transaction + */ def lookupFundingTx(channelId: ByteVector32): Option[(Transaction, Int)] = { val candidateFundingTxIds = fundingIds(channelId) - logger.info(s"computed funding txids=${candidateFundingTxIds.map(_._1)}") val fundingTx_opt = Await.result(Future.sequence(candidateFundingTxIds.map { case (txId, _) => - transactionExists(txId) + getTransaction(txId) }).map(_.flatten.headOption), 60 seconds) fundingTx_opt.map { funding => @@ -91,7 +99,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A } /** - * Extracts the funding_txid and output index from channelId + * Extracts the funding_txid and output index from channelId, brute forces the ids up to @param limit */ def fundingIds(channelId: ByteVector32, limit: Int = 5): Seq[(ByteVector32, Int)] = { 0 until limit map { i => @@ -99,7 +107,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A } } - def transactionExists(txId: ByteVector32): Future[Option[Transaction]] = { + def getTransaction(txId: ByteVector32): Future[Option[Transaction]] = { bitcoinClient.getTransaction(txId.toHex).collect { case tx: Transaction => Some(tx) }.recover { @@ -155,6 +163,7 @@ class RecoveryPeer(override val nodeParams: NodeParams, remoteNodeId: PublicKey, case Event(msg: ChannelReestablish, d: ConnectedData) => d.transport ! TransportHandler.ReadAck(msg) recoveryFSM ! ChannelFound(msg.channelId, msg) + // when recovering we don't immediately reply channel_reestablish/error stay case _ => super.whenConnected(event) From a2752c2e134aa1f1b6b7e36534d31a1b67c4c9d0 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 31 Oct 2019 15:42:43 +0100 Subject: [PATCH 10/26] WIP recover from commitment --- .../acinq/eclair/recovery/RecoveryFSM.scala | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 7d51ec0940..f7d1831f15 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -3,19 +3,21 @@ package fr.acinq.eclair.recovery import java.net.InetSocketAddress import akka.actor.{Actor, ActorRef, ActorSelection, PoisonPill, Props} -import fr.acinq.bitcoin.{ByteVector32, Transaction} +import fr.acinq.bitcoin.{ByteVector32, OP_2, OP_CHECKMULTISIG, OP_PUSHDATA, Script, ScriptWitness, Transaction} import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient} import fr.acinq.eclair.channel.{Channel, HasCommitments, PleasePublishYourCommitment} -import fr.acinq.eclair.crypto.TransportHandler +import fr.acinq.eclair.crypto.{KeyManager, TransportHandler} import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect, FinalChannelId} import fr.acinq.eclair.io.Switchboard.peerActorName import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected, Switchboard} import fr.acinq.eclair.recovery.RecoveryFSM._ +import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire._ import grizzled.slf4j.Logging +import scodec.bits.ByteVector import scala.concurrent.{Await, Future} import scala.concurrent.duration._ @@ -76,11 +78,20 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A } } + // extract our funding pubkey from witness + // compute channel key path from funding pubkey + // compute points necessary to redeem our outputs + // create txs and broadcast def recoverFromCommitment(commitTx: Transaction, channelReestablish: ChannelReestablish) = { - // extract our funding pubkey from witness - // compute channel key path from funding pubkey - // compute points necessary to redeem out outputs - // create txs and broadcast + val ScriptWitness(ByteVector.empty :: sig1 :: sig2 :: redeemScript :: Nil) = commitTx.txIn.head.witness + val (pubKey1, pubKey2) = Script.parse(redeemScript) match { + case OP_2 :: OP_PUSHDATA(key1, _) :: OP_PUSHDATA(key2, _) :: OP_2 :: OP_CHECKMULTISIG :: Nil => (key1, key2) + case _ => throw new IllegalArgumentException(s"error script doesn't match. script=$redeemScript") + } + + val commitmentNumber = Transactions.decodeTxNumber(commitTx.txIn.head.sequence, commitTx.lockTime) + assert(commitmentNumber == channelReestablish.nextLocalCommitmentNumber - 1) + logger.info("we made it!") } From 9ac669706105de5784821dbce0e739d90024a2a2 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 31 Oct 2019 15:45:52 +0100 Subject: [PATCH 11/26] Wire recoveryFSM to EclairImpl --- .../src/main/scala/fr/acinq/eclair/Eclair.scala | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index da053e6478..36163c2186 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -21,9 +21,11 @@ import java.util.UUID import akka.actor.{ActorRef, Props} import akka.pattern._ import akka.util.Timeout +import com.softwaremill.sttp.okhttp.OkHttpFutureBackend import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector32, Satoshi} import fr.acinq.eclair.TimestampQueryFilters._ +import fr.acinq.eclair.blockchain.bitcoind.rpc.BasicBitcoinJsonRPCClient import fr.acinq.eclair.channel.Register.{Forward, ForwardShortId} import fr.acinq.eclair.channel._ import fr.acinq.eclair.db.{IncomingPayment, NetworkFee, OutgoingPayment, Stats} @@ -294,8 +296,15 @@ class EclairImpl(appKit: Kit) extends Eclair { override def usableBalances()(implicit timeout: Timeout): Future[Iterable[UsableBalances]] = (appKit.relayer ? GetUsableBalances).mapTo[Iterable[UsableBalances]] override def doRecovery(uri: NodeURI): Unit = { + implicit val shttp = OkHttpFutureBackend() - ??? -// val recoveryFSM = appKit.system.actorOf(Props(new RecoveryFSM(appKit.nodeParams, appKit.))) + val bitcoinRpcClient = new BasicBitcoinJsonRPCClient( + user = appKit.nodeParams.config.getString("bitcoind.rpcuser"), + password = appKit.nodeParams.config.getString("bitcoind.rpcpassword"), + host = appKit.nodeParams.config.getString("bitcoind.host"), + port = appKit.nodeParams.config.getInt("bitcoind.rpcport") + ) + + appKit.system.actorOf(Props(new RecoveryFSM(uri, appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) } } From aab4902d59682c46ca5dc66b1f8d4a6eaf193881 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 31 Oct 2019 18:59:07 +0100 Subject: [PATCH 12/26] Extract channel key from witness, make claim transaction --- .../acinq/eclair/recovery/RecoveryFSM.scala | 90 ++++++++++++++----- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index f7d1831f15..55129db270 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -3,13 +3,13 @@ package fr.acinq.eclair.recovery import java.net.InetSocketAddress import akka.actor.{Actor, ActorRef, ActorSelection, PoisonPill, Props} -import fr.acinq.bitcoin.{ByteVector32, OP_2, OP_CHECKMULTISIG, OP_PUSHDATA, Script, ScriptWitness, Transaction} +import fr.acinq.bitcoin.{ByteVector32, OP_2, OP_CHECKMULTISIG, OP_PUSHDATA, OutPoint, Script, ScriptWitness, Transaction} import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient} -import fr.acinq.eclair.channel.{Channel, HasCommitments, PleasePublishYourCommitment} -import fr.acinq.eclair.crypto.{KeyManager, TransportHandler} +import fr.acinq.eclair.channel.{Channel, ChannelClosed, Commitments, DATA_CLOSING, HasCommitments, INPUT_RESTORED, PleasePublishYourCommitment} +import fr.acinq.eclair.crypto.{Generators, KeyManager, TransportHandler} import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect, FinalChannelId} import fr.acinq.eclair.io.Switchboard.peerActorName import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected, Switchboard} @@ -29,6 +29,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) context.system.eventStream.subscribe(self, classOf[PeerConnected]) + context.system.eventStream.subscribe(self, classOf[ChannelClosed]) self ! RecoveryConnect(nodeURI) override def receive: Receive = waitingForConnection() @@ -69,30 +70,41 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A logger.info(s"looking for the commitment transaction") bitcoinClient.lookForSpendingTx(None, d.fundingTx.txid.toHex, d.fundingOutIndex).onComplete { case Success(commitTx) => - recoverFromCommitment(commitTx, d.channelReestablish) - logger.info(s"recovery done") - d.peer ! Disconnect - self ! PoisonPill + logger.info(s"found commitTx=${commitTx.txid}") + + val commitmentNumber = Transactions.decodeTxNumber(commitTx.txIn.head.sequence, commitTx.lockTime) + assert(commitmentNumber == d.channelReestablish.nextLocalCommitmentNumber - 1) + + val fundingPubKey = recoverFundingKeyFromCommitment(nodeParams, commitTx, d.channelReestablish, commitmentNumber) + val channelKeyPath = KeyManager.channelKeyPath(fundingPubKey) + val commitmentPoint = nodeParams.keyManager.commitmentPoint(channelKeyPath, commitmentNumber) + val paymentBasePoint = nodeParams.keyManager.paymentPoint(channelKeyPath) + val localPaymentKey = Generators.derivePubKey(paymentBasePoint.publicKey, commitmentPoint) + + // FIXME: add final script pubkey + val claimTx = Transactions.makeClaimP2WPKHOutputTx(commitTx, nodeParams.dustLimit, localPaymentKey, ByteVector.empty, nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(6)) + val sig = nodeParams.keyManager.sign(claimTx, paymentBasePoint, d.channelReestablish.myCurrentPerCommitmentPoint.get) + val claimSigned = Transactions.addSigs(claimTx, localPaymentKey, sig) + bitcoinClient.publishTransaction(claimSigned.tx) + context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckClaimPublished) + context.become(waitToPublishClaimTx(DATA_WAIT_FOR_CLAIM_TX(d.peer, claimSigned.tx))) + case Failure(_) => context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckCommitmentPublished) } } - // extract our funding pubkey from witness - // compute channel key path from funding pubkey - // compute points necessary to redeem our outputs - // create txs and broadcast - def recoverFromCommitment(commitTx: Transaction, channelReestablish: ChannelReestablish) = { - val ScriptWitness(ByteVector.empty :: sig1 :: sig2 :: redeemScript :: Nil) = commitTx.txIn.head.witness - val (pubKey1, pubKey2) = Script.parse(redeemScript) match { - case OP_2 :: OP_PUSHDATA(key1, _) :: OP_PUSHDATA(key2, _) :: OP_2 :: OP_CHECKMULTISIG :: Nil => (key1, key2) - case _ => throw new IllegalArgumentException(s"error script doesn't match. script=$redeemScript") - } - - val commitmentNumber = Transactions.decodeTxNumber(commitTx.txIn.head.sequence, commitTx.lockTime) - assert(commitmentNumber == channelReestablish.nextLocalCommitmentNumber - 1) - - logger.info("we made it!") + def waitToPublishClaimTx(d: DATA_WAIT_FOR_CLAIM_TX): Receive = { + case CheckClaimPublished => + bitcoinClient.getTransaction(d.claimTx.txid.toHex).onComplete { + case Success(claimTx) => + logger.info(s"claim transaction published txid=${claimTx.txid}") + d.peer ! Disconnect + self ! PoisonPill + case Failure(_) => + bitcoinClient.publishTransaction(d.claimTx) + context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckClaimPublished) + } } /** @@ -139,12 +151,46 @@ object RecoveryFSM { sealed trait Data case class DATA_WAIT_FOR_REMOTE_INFO(peer: ActorRef) extends Data case class DATA_WAIT_FOR_REMOTE_PUBLISH(peer: ActorRef, channelReestablish: ChannelReestablish, fundingTx: Transaction, fundingOutIndex: Int) extends Data + case class DATA_WAIT_FOR_CLAIM_TX(peer: ActorRef, claimTx: Transaction) extends Data sealed trait Event case class RecoveryConnect(remote: NodeURI) extends Event case class ChannelFound(channelId: ByteVector32, reestablish: ChannelReestablish) extends Event case class SendErrorToRemote(error: Error) extends Event case object CheckCommitmentPublished extends Event + case object CheckClaimPublished extends Event + + // extract our funding pubkey from witness + def recoverFundingKeyFromCommitment(nodeParams: NodeParams, commitTx: Transaction, channelReestablish: ChannelReestablish, commitmentNumber: Long): PublicKey = { + val (key1, key2) = extractKeysFromWitness(commitTx.txIn.head.witness, channelReestablish, commitmentNumber) + + if(isOurChannelKey(nodeParams.keyManager, commitTx, key1, commitmentNumber)) + key1 + else if(isOurChannelKey(nodeParams.keyManager, commitTx, key2, commitmentNumber)) + key2 + else + throw new IllegalArgumentException("key not found") + } + + def extractKeysFromWitness(witness: ScriptWitness, channelReestablish: ChannelReestablish, commitmentNumber: Long): (PublicKey, PublicKey) = { + val ScriptWitness(Seq(ByteVector.empty, sig1, sig2, redeemScript)) = witness + + Script.parse(redeemScript) match { + case OP_2 :: OP_PUSHDATA(key1, _) :: OP_PUSHDATA(key2, _) :: OP_2 :: OP_CHECKMULTISIG :: Nil => (PublicKey(key1), PublicKey(key2)) + case _ => throw new IllegalArgumentException(s"commitTx redeem script doesn't match, script=$redeemScript") + } + } + + def isOurChannelKey(keyManager: KeyManager, commitTx: Transaction, key: PublicKey, commitmentNumber: Long): Boolean = { + val channelKeyPath = KeyManager.channelKeyPath(key) + val commitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitmentNumber) + val paymentBasePoint = keyManager.paymentPoint(channelKeyPath).publicKey + val localPaymentKey = Generators.derivePubKey(paymentBasePoint, commitmentPoint) + + val toRemoteScriptPubkey = Script.write(Script.pay2wpkh(localPaymentKey)) + commitTx.txOut.exists(_.publicKeyScript == toRemoteScriptPubkey) + } + } class RecoverySwitchBoard(nodeParams: NodeParams, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet) extends Switchboard(nodeParams, authenticator, watcher, router, relayer, wallet) { From bc1c612124660bff2794cd5b80fe6a1a8985c1f0 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 31 Oct 2019 19:12:41 +0100 Subject: [PATCH 13/26] fixup! Declare Peer shared function `whenConnected` to return a StateFunction --- eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala | 6 ++---- .../main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index 531aa3b9e2..76f273115e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -213,11 +213,9 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: A stay } - when(CONNECTED) { - case event => whenConnected(event) - } + when(CONNECTED)(whenConnected) - def whenConnected(event: Event): State = event match { + def whenConnected: StateFunction = { case Event(StateTimeout, _: ConnectedData) => // the first ping is sent after the connection has been quiet for a while // we don't want to send pings right after connection, because peer will be syncing and may not be able to diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 55129db270..1ba13ea2bd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -212,7 +212,7 @@ class RecoveryPeer(override val nodeParams: NodeParams, remoteNodeId: PublicKey, def recoveryFSM: ActorSelection = context.system.actorSelection(context.system / RecoveryFSM.actorName) - override def whenConnected(event: Event): State = event match { + override def whenConnected: StateFunction = { case Event(SendErrorToRemote(error), d: ConnectedData) => d.transport ! error stay @@ -223,7 +223,7 @@ class RecoveryPeer(override val nodeParams: NodeParams, remoteNodeId: PublicKey, // when recovering we don't immediately reply channel_reestablish/error stay - case _ => super.whenConnected(event) + case event => super.whenConnected(event) } } \ No newline at end of file From 462395bbc297a54f4631284a746c86477273c98d Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 4 Nov 2019 10:22:22 +0100 Subject: [PATCH 14/26] Use local final script pubkey in recovery claim-tx --- .../main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 1ba13ea2bd..546f6a36a6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -8,7 +8,7 @@ import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient} -import fr.acinq.eclair.channel.{Channel, ChannelClosed, Commitments, DATA_CLOSING, HasCommitments, INPUT_RESTORED, PleasePublishYourCommitment} +import fr.acinq.eclair.channel.{Channel, ChannelClosed, Commitments, DATA_CLOSING, HasCommitments, Helpers, INPUT_RESTORED, PleasePublishYourCommitment} import fr.acinq.eclair.crypto.{Generators, KeyManager, TransportHandler} import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect, FinalChannelId} import fr.acinq.eclair.io.Switchboard.peerActorName @@ -29,7 +29,6 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) context.system.eventStream.subscribe(self, classOf[PeerConnected]) - context.system.eventStream.subscribe(self, classOf[ChannelClosed]) self ! RecoveryConnect(nodeURI) override def receive: Receive = waitingForConnection() @@ -49,7 +48,6 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A def waitingForRemoteChannelInfo(d: DATA_WAIT_FOR_REMOTE_INFO): Receive = { case ChannelFound(channelId, reestablish) => logger.info(s"peer=${nodeURI.nodeId} knows channelId=$channelId") - lookupFundingTx(channelId) match { case None => logger.info(s"could not find funding transaction...disconnecting") @@ -81,8 +79,8 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A val paymentBasePoint = nodeParams.keyManager.paymentPoint(channelKeyPath) val localPaymentKey = Generators.derivePubKey(paymentBasePoint.publicKey, commitmentPoint) - // FIXME: add final script pubkey - val claimTx = Transactions.makeClaimP2WPKHOutputTx(commitTx, nodeParams.dustLimit, localPaymentKey, ByteVector.empty, nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(6)) + val finalScriptPubkey = Helpers.getFinalScriptPubKey(wallet, nodeParams.chainHash) + val claimTx = Transactions.makeClaimP2WPKHOutputTx(commitTx, nodeParams.dustLimit, localPaymentKey, finalScriptPubkey, nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(6)) val sig = nodeParams.keyManager.sign(claimTx, paymentBasePoint, d.channelReestablish.myCurrentPerCommitmentPoint.get) val claimSigned = Transactions.addSigs(claimTx, localPaymentKey, sig) bitcoinClient.publishTransaction(claimSigned.tx) From 76d0a4a831bfa229cb6b3671eb1c430c0e00437e Mon Sep 17 00:00:00 2001 From: Andrea Date: Tue, 5 Nov 2019 16:38:33 +0100 Subject: [PATCH 15/26] Use remotePerCommitment point to derive our localPaymentKey, add tests --- .../acinq/eclair/recovery/RecoveryFSM.scala | 18 +++--- .../eclair/recovery/RecoveryFSMSpec.scala | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 546f6a36a6..aad24b22ee 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -21,7 +21,7 @@ import scodec.bits.ByteVector import scala.concurrent.{Await, Future} import scala.concurrent.duration._ -import scala.util.{Failure, Success} +import scala.util.{Failure, Success, Try} class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends Actor with Logging { @@ -70,8 +70,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A case Success(commitTx) => logger.info(s"found commitTx=${commitTx.txid}") - val commitmentNumber = Transactions.decodeTxNumber(commitTx.txIn.head.sequence, commitTx.lockTime) - assert(commitmentNumber == d.channelReestablish.nextLocalCommitmentNumber - 1) + val commitmentNumber = d.channelReestablish.nextRemoteRevocationNumber - 1 val fundingPubKey = recoverFundingKeyFromCommitment(nodeParams, commitTx, d.channelReestablish, commitmentNumber) val channelKeyPath = KeyManager.channelKeyPath(fundingPubKey) @@ -162,12 +161,12 @@ object RecoveryFSM { def recoverFundingKeyFromCommitment(nodeParams: NodeParams, commitTx: Transaction, channelReestablish: ChannelReestablish, commitmentNumber: Long): PublicKey = { val (key1, key2) = extractKeysFromWitness(commitTx.txIn.head.witness, channelReestablish, commitmentNumber) - if(isOurChannelKey(nodeParams.keyManager, commitTx, key1, commitmentNumber)) + if(isOurFundingKey(nodeParams.keyManager, commitTx, key1, channelReestablish.myCurrentPerCommitmentPoint.get, commitmentNumber)) key1 - else if(isOurChannelKey(nodeParams.keyManager, commitTx, key2, commitmentNumber)) + else if(isOurFundingKey(nodeParams.keyManager, commitTx, key2, channelReestablish.myCurrentPerCommitmentPoint.get, commitmentNumber)) key2 else - throw new IllegalArgumentException("key not found") + throw new IllegalArgumentException("key not found, output trimmed?") } def extractKeysFromWitness(witness: ScriptWitness, channelReestablish: ChannelReestablish, commitmentNumber: Long): (PublicKey, PublicKey) = { @@ -179,13 +178,12 @@ object RecoveryFSM { } } - def isOurChannelKey(keyManager: KeyManager, commitTx: Transaction, key: PublicKey, commitmentNumber: Long): Boolean = { + def isOurFundingKey(keyManager: KeyManager, commitTx: Transaction, key: PublicKey, remotePerCommitmentPoint: PublicKey, commitmentNumber: Long): Boolean = { val channelKeyPath = KeyManager.channelKeyPath(key) - val commitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitmentNumber) val paymentBasePoint = keyManager.paymentPoint(channelKeyPath).publicKey - val localPaymentKey = Generators.derivePubKey(paymentBasePoint, commitmentPoint) - + val localPaymentKey = Generators.derivePubKey(paymentBasePoint, remotePerCommitmentPoint) val toRemoteScriptPubkey = Script.write(Script.pay2wpkh(localPaymentKey)) + commitTx.txOut.exists(_.publicKeyScript == toRemoteScriptPubkey) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala new file mode 100644 index 0000000000..972c784d97 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala @@ -0,0 +1,57 @@ +package fr.acinq.eclair.recovery + +import akka.testkit.TestProbe +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.bitcoin.{OP_2, OP_CHECKMULTISIG, OP_PUSHDATA, Script, ScriptWitness, Transaction} +import fr.acinq.eclair.{TestConstants, TestkitBaseClass} +import fr.acinq.eclair.channel.{DATA_NORMAL, INPUT_DISCONNECTED, INPUT_RECONNECTED, NORMAL, OFFLINE} +import fr.acinq.eclair.channel.states.StateTestsHelperMethods +import fr.acinq.eclair.wire.{ChannelReestablish, Init} +import org.scalatest.{FunSuite, FunSuiteLike, Outcome} + +import scala.concurrent.duration._ + +class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods { + + type FixtureParam = SetupFixture + + override def withFixture(test: OneArgTest): Outcome = { + val setup = init() + import setup._ + within(30 seconds) { + reachNormal(setup, test.tags) + awaitCond(alice.stateName == NORMAL) + awaitCond(bob.stateName == NORMAL) + withFixture(test.toNoArgTest(setup)) + } + } + + test("extract funding keys from witness") { f => + import f._ + + val probe = TestProbe() + + val aliceStateData = alice.stateData.asInstanceOf[DATA_NORMAL] + val commitTx = aliceStateData.commitments.localCommit.publishableTxs.commitTx + val aliceCommitNumber = aliceStateData.commitments.localCommit.index + val fundingKeyPath = aliceStateData.commitments.localParams.fundingKeyPath + + // disconnect the peers to obtain a channel_reestablish on reconnection + probe.send(alice, INPUT_DISCONNECTED) + probe.send(bob, INPUT_DISCONNECTED) + awaitCond(alice.stateName == OFFLINE) + awaitCond(bob.stateName == OFFLINE) + + val aliceInit = Init(TestConstants.Alice.nodeParams.globalFeatures, TestConstants.Alice.nodeParams.localFeatures) + val bobInit = Init(TestConstants.Bob.nodeParams.globalFeatures, TestConstants.Bob.nodeParams.localFeatures) + + // reconnect the input in alice's channel + probe.send(alice, INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit)) + val aliceBobReestablish = alice2bob.expectMsgType[ChannelReestablish] + + val aliceFundingKey = TestConstants.Alice.nodeParams.keyManager.fundingPublicKey(fundingKeyPath).publicKey + val (key1, key2) = RecoveryFSM.extractKeysFromWitness(commitTx.tx.txIn.head.witness, aliceBobReestablish, aliceCommitNumber) + assert(aliceFundingKey == key1 || aliceFundingKey == key2) + } + +} From e6c07d2a9f87c398fd8e277d9771e137a8693d02 Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 6 Nov 2019 10:20:40 +0100 Subject: [PATCH 16/26] Test funding pubkey extraction and channel keypath computation in in RecoveryFSM --- .../acinq/eclair/recovery/RecoveryFSM.scala | 16 +++++------ .../eclair/recovery/RecoveryFSMSpec.scala | 28 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index aad24b22ee..44e70fc006 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -72,7 +72,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A val commitmentNumber = d.channelReestablish.nextRemoteRevocationNumber - 1 - val fundingPubKey = recoverFundingKeyFromCommitment(nodeParams, commitTx, d.channelReestablish, commitmentNumber) + val fundingPubKey = recoverFundingKeyFromCommitment(nodeParams, commitTx, d.channelReestablish) val channelKeyPath = KeyManager.channelKeyPath(fundingPubKey) val commitmentPoint = nodeParams.keyManager.commitmentPoint(channelKeyPath, commitmentNumber) val paymentBasePoint = nodeParams.keyManager.paymentPoint(channelKeyPath) @@ -158,18 +158,18 @@ object RecoveryFSM { case object CheckClaimPublished extends Event // extract our funding pubkey from witness - def recoverFundingKeyFromCommitment(nodeParams: NodeParams, commitTx: Transaction, channelReestablish: ChannelReestablish, commitmentNumber: Long): PublicKey = { - val (key1, key2) = extractKeysFromWitness(commitTx.txIn.head.witness, channelReestablish, commitmentNumber) + def recoverFundingKeyFromCommitment(nodeParams: NodeParams, commitTx: Transaction, channelReestablish: ChannelReestablish): PublicKey = { + val (key1, key2) = extractKeysFromWitness(commitTx.txIn.head.witness, channelReestablish) - if(isOurFundingKey(nodeParams.keyManager, commitTx, key1, channelReestablish.myCurrentPerCommitmentPoint.get, commitmentNumber)) + if(isOurFundingKey(nodeParams.keyManager, commitTx, key1, channelReestablish)) key1 - else if(isOurFundingKey(nodeParams.keyManager, commitTx, key2, channelReestablish.myCurrentPerCommitmentPoint.get, commitmentNumber)) + else if(isOurFundingKey(nodeParams.keyManager, commitTx, key2, channelReestablish)) key2 else throw new IllegalArgumentException("key not found, output trimmed?") } - def extractKeysFromWitness(witness: ScriptWitness, channelReestablish: ChannelReestablish, commitmentNumber: Long): (PublicKey, PublicKey) = { + def extractKeysFromWitness(witness: ScriptWitness, channelReestablish: ChannelReestablish): (PublicKey, PublicKey) = { val ScriptWitness(Seq(ByteVector.empty, sig1, sig2, redeemScript)) = witness Script.parse(redeemScript) match { @@ -178,10 +178,10 @@ object RecoveryFSM { } } - def isOurFundingKey(keyManager: KeyManager, commitTx: Transaction, key: PublicKey, remotePerCommitmentPoint: PublicKey, commitmentNumber: Long): Boolean = { + def isOurFundingKey(keyManager: KeyManager, commitTx: Transaction, key: PublicKey, channelReestablish: ChannelReestablish): Boolean = { val channelKeyPath = KeyManager.channelKeyPath(key) val paymentBasePoint = keyManager.paymentPoint(channelKeyPath).publicKey - val localPaymentKey = Generators.derivePubKey(paymentBasePoint, remotePerCommitmentPoint) + val localPaymentKey = Generators.derivePubKey(paymentBasePoint, channelReestablish.myCurrentPerCommitmentPoint.get) val toRemoteScriptPubkey = Script.write(Script.pay2wpkh(localPaymentKey)) commitTx.txOut.exists(_.publicKeyScript == toRemoteScriptPubkey) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala index 972c784d97..20d61d0576 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala @@ -1,12 +1,11 @@ package fr.acinq.eclair.recovery import akka.testkit.TestProbe -import fr.acinq.bitcoin.Crypto.PublicKey -import fr.acinq.bitcoin.{OP_2, OP_CHECKMULTISIG, OP_PUSHDATA, Script, ScriptWitness, Transaction} import fr.acinq.eclair.{TestConstants, TestkitBaseClass} -import fr.acinq.eclair.channel.{DATA_NORMAL, INPUT_DISCONNECTED, INPUT_RECONNECTED, NORMAL, OFFLINE} +import fr.acinq.eclair.channel.{CMD_SIGN, DATA_NORMAL, INPUT_DISCONNECTED, INPUT_RECONNECTED, NORMAL, OFFLINE} import fr.acinq.eclair.channel.states.StateTestsHelperMethods -import fr.acinq.eclair.wire.{ChannelReestablish, Init} +import RecoveryFSM._ +import fr.acinq.eclair.wire.{ChannelReestablish, CommitSig, Init, RevokeAndAck} import org.scalatest.{FunSuite, FunSuiteLike, Outcome} import scala.concurrent.duration._ @@ -26,15 +25,13 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods { } } - test("extract funding keys from witness") { f => + test("recover out funding key and channel keypath from the remote commit tx") { f => import f._ val probe = TestProbe() - val aliceStateData = alice.stateData.asInstanceOf[DATA_NORMAL] - val commitTx = aliceStateData.commitments.localCommit.publishableTxs.commitTx - val aliceCommitNumber = aliceStateData.commitments.localCommit.index - val fundingKeyPath = aliceStateData.commitments.localParams.fundingKeyPath + val aliceFundingKey = TestConstants.Alice.nodeParams.keyManager.fundingPublicKey(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localParams.fundingKeyPath).publicKey + val remotePublishedCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx // disconnect the peers to obtain a channel_reestablish on reconnection probe.send(alice, INPUT_DISCONNECTED) @@ -45,13 +42,16 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods { val aliceInit = Init(TestConstants.Alice.nodeParams.globalFeatures, TestConstants.Alice.nodeParams.localFeatures) val bobInit = Init(TestConstants.Bob.nodeParams.globalFeatures, TestConstants.Bob.nodeParams.localFeatures) - // reconnect the input in alice's channel - probe.send(alice, INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit)) - val aliceBobReestablish = alice2bob.expectMsgType[ChannelReestablish] + // reconnect the input in bob's channel, bob will send to alice a channel_reestablish + probe.send(bob, INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit)) + val bobAliceReestablish = bob2alice.expectMsgType[ChannelReestablish] - val aliceFundingKey = TestConstants.Alice.nodeParams.keyManager.fundingPublicKey(fundingKeyPath).publicKey - val (key1, key2) = RecoveryFSM.extractKeysFromWitness(commitTx.tx.txIn.head.witness, aliceBobReestablish, aliceCommitNumber) + val (key1, key2) = extractKeysFromWitness(remotePublishedCommitTx.txIn.head.witness, bobAliceReestablish) assert(aliceFundingKey == key1 || aliceFundingKey == key2) + assert(isOurFundingKey(TestConstants.Alice.nodeParams.keyManager, remotePublishedCommitTx, aliceFundingKey, bobAliceReestablish)) + + val recoveredFundingKey = recoverFundingKeyFromCommitment(TestConstants.Alice.nodeParams, remotePublishedCommitTx, bobAliceReestablish) + assert(recoveredFundingKey == aliceFundingKey) } } From 8672a4d0a273a204441c347ca705b8f15dc407e3 Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 6 Nov 2019 10:42:17 +0100 Subject: [PATCH 17/26] Remove unused variables, improve logging, renaming --- .../acinq/eclair/recovery/RecoveryFSM.scala | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 44e70fc006..191cf43066 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -27,6 +27,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A implicit val ec = context.system.dispatcher val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) + val CHECK_CLAIM_POLL_INTERVAL = 3 seconds context.system.eventStream.subscribe(self, classOf[PeerConnected]) self ! RecoveryConnect(nodeURI) @@ -70,24 +71,24 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A case Success(commitTx) => logger.info(s"found commitTx=${commitTx.txid}") - val commitmentNumber = d.channelReestablish.nextRemoteRevocationNumber - 1 - + val remotePerCommitmentSecret = d.channelReestablish.myCurrentPerCommitmentPoint.get val fundingPubKey = recoverFundingKeyFromCommitment(nodeParams, commitTx, d.channelReestablish) val channelKeyPath = KeyManager.channelKeyPath(fundingPubKey) - val commitmentPoint = nodeParams.keyManager.commitmentPoint(channelKeyPath, commitmentNumber) val paymentBasePoint = nodeParams.keyManager.paymentPoint(channelKeyPath) - val localPaymentKey = Generators.derivePubKey(paymentBasePoint.publicKey, commitmentPoint) + val localPaymentKey = Generators.derivePubKey(paymentBasePoint.publicKey, remotePerCommitmentSecret) val finalScriptPubkey = Helpers.getFinalScriptPubKey(wallet, nodeParams.chainHash) val claimTx = Transactions.makeClaimP2WPKHOutputTx(commitTx, nodeParams.dustLimit, localPaymentKey, finalScriptPubkey, nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(6)) - val sig = nodeParams.keyManager.sign(claimTx, paymentBasePoint, d.channelReestablish.myCurrentPerCommitmentPoint.get) + val sig = nodeParams.keyManager.sign(claimTx, paymentBasePoint, remotePerCommitmentSecret) val claimSigned = Transactions.addSigs(claimTx, localPaymentKey, sig) + logger.info(s"successfully created claim-main-output transaction txid=${claimSigned.tx.txid}") + logger.info(s"publishing transaction") bitcoinClient.publishTransaction(claimSigned.tx) - context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckClaimPublished) + context.system.scheduler.scheduleOnce(CHECK_CLAIM_POLL_INTERVAL)(self ! CheckClaimPublished) context.become(waitToPublishClaimTx(DATA_WAIT_FOR_CLAIM_TX(d.peer, claimSigned.tx))) case Failure(_) => - context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckCommitmentPublished) + context.system.scheduler.scheduleOnce(CHECK_CLAIM_POLL_INTERVAL)(self ! CheckCommitmentPublished) } } @@ -100,7 +101,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A self ! PoisonPill case Failure(_) => bitcoinClient.publishTransaction(d.claimTx) - context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckClaimPublished) + context.system.scheduler.scheduleOnce(CHECK_CLAIM_POLL_INTERVAL)(self ! CheckClaimPublished) } } @@ -170,7 +171,7 @@ object RecoveryFSM { } def extractKeysFromWitness(witness: ScriptWitness, channelReestablish: ChannelReestablish): (PublicKey, PublicKey) = { - val ScriptWitness(Seq(ByteVector.empty, sig1, sig2, redeemScript)) = witness + val ScriptWitness(Seq(ByteVector.empty, _, _, redeemScript)) = witness Script.parse(redeemScript) match { case OP_2 :: OP_PUSHDATA(key1, _) :: OP_PUSHDATA(key2, _) :: OP_2 :: OP_CHECKMULTISIG :: Nil => (PublicKey(key1), PublicKey(key2)) From dac49211048df5e9da7bfd421aa68f845d17b71c Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 6 Nov 2019 11:07:24 +0100 Subject: [PATCH 18/26] Search for commitTx in mempool first, log destination address of claim-tx --- .../acinq/eclair/recovery/RecoveryFSM.scala | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 191cf43066..707b1ecea2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -3,7 +3,7 @@ package fr.acinq.eclair.recovery import java.net.InetSocketAddress import akka.actor.{Actor, ActorRef, ActorSelection, PoisonPill, Props} -import fr.acinq.bitcoin.{ByteVector32, OP_2, OP_CHECKMULTISIG, OP_PUSHDATA, OutPoint, Script, ScriptWitness, Transaction} +import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, ByteVector32, OP_0, OP_2, OP_CHECKMULTISIG, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHDATA, OutPoint, Script, ScriptWitness, Transaction} import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.EclairWallet @@ -27,7 +27,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A implicit val ec = context.system.dispatcher val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) - val CHECK_CLAIM_POLL_INTERVAL = 3 seconds + val CHECK_POLL_INTERVAL = 3 seconds context.system.eventStream.subscribe(self, classOf[PeerConnected]) self ! RecoveryConnect(nodeURI) @@ -67,11 +67,11 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A def waitForRemoteToPublishCommitment(d: DATA_WAIT_FOR_REMOTE_PUBLISH): Receive = { case CheckCommitmentPublished => logger.info(s"looking for the commitment transaction") - bitcoinClient.lookForSpendingTx(None, d.fundingTx.txid.toHex, d.fundingOutIndex).onComplete { + lookForCommitTx(d.fundingTx.txid, d.fundingOutIndex).onComplete { case Success(commitTx) => logger.info(s"found commitTx=${commitTx.txid}") - val remotePerCommitmentSecret = d.channelReestablish.myCurrentPerCommitmentPoint.get + val Some(remotePerCommitmentSecret) = d.channelReestablish.myCurrentPerCommitmentPoint val fundingPubKey = recoverFundingKeyFromCommitment(nodeParams, commitTx, d.channelReestablish) val channelKeyPath = KeyManager.channelKeyPath(fundingPubKey) val paymentBasePoint = nodeParams.keyManager.paymentPoint(channelKeyPath) @@ -81,14 +81,13 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A val claimTx = Transactions.makeClaimP2WPKHOutputTx(commitTx, nodeParams.dustLimit, localPaymentKey, finalScriptPubkey, nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(6)) val sig = nodeParams.keyManager.sign(claimTx, paymentBasePoint, remotePerCommitmentSecret) val claimSigned = Transactions.addSigs(claimTx, localPaymentKey, sig) - logger.info(s"successfully created claim-main-output transaction txid=${claimSigned.tx.txid}") - logger.info(s"publishing transaction") + logger.info(s"publishing claim-main-output transaction address=${scriptPubKeyToAddress(finalScriptPubkey)} txid=${claimSigned.tx.txid}") bitcoinClient.publishTransaction(claimSigned.tx) - context.system.scheduler.scheduleOnce(CHECK_CLAIM_POLL_INTERVAL)(self ! CheckClaimPublished) + context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckClaimPublished) context.become(waitToPublishClaimTx(DATA_WAIT_FOR_CLAIM_TX(d.peer, claimSigned.tx))) case Failure(_) => - context.system.scheduler.scheduleOnce(CHECK_CLAIM_POLL_INTERVAL)(self ! CheckCommitmentPublished) + context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckCommitmentPublished) } } @@ -101,7 +100,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A self ! PoisonPill case Failure(_) => bitcoinClient.publishTransaction(d.claimTx) - context.system.scheduler.scheduleOnce(CHECK_CLAIM_POLL_INTERVAL)(self ! CheckClaimPublished) + context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckClaimPublished) } } @@ -136,6 +135,27 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A } } + /** + * Lookup a commitTx spending the fundingTx in the mempool and then in the blocks + */ + def lookForCommitTx(fundingTxId: ByteVector32, fundingOutIndex: Int): Future[Transaction] = { + bitcoinClient.getMempool().map { mempoolTxs => + mempoolTxs.find(_.txIn.exists(_.outPoint == OutPoint(fundingTxId, fundingOutIndex))).get + }.recoverWith { case _ => + bitcoinClient.lookForSpendingTx(None, fundingTxId.toHex, fundingOutIndex) + } + } + + def scriptPubKeyToAddress(scriptPubKey: ByteVector) = Script.parse(scriptPubKey) match { + case OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubKeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil => + Base58Check.encode(Base58.Prefix.PubkeyAddressTestnet, pubKeyHash) + case OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil => + Base58Check.encode(Base58.Prefix.ScriptAddressTestnet, scriptHash) + case OP_0 :: OP_PUSHDATA(pubKeyHash, _) :: Nil if pubKeyHash.length == 20 => Bech32.encodeWitnessAddress("bcrt", 0, pubKeyHash) + case OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil if scriptHash.length == 32 => Bech32.encodeWitnessAddress("bcrt", 0, scriptHash) + case _ => ??? + } + } object RecoveryFSM { From c9c32626a0c72d88307bfc805c4c5dd3d3e3a75b Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 6 Nov 2019 15:32:07 +0100 Subject: [PATCH 19/26] Extend recoveryFSMSpec to test the channelId -> fundingTxId conversion --- .../main/scala/fr/acinq/eclair/Eclair.scala | 4 +- .../acinq/eclair/recovery/RecoveryFSM.scala | 37 +++++--- .../states/StateTestsHelperMethods.scala | 5 +- .../eclair/recovery/RecoveryFSMSpec.scala | 92 +++++++++++++++++-- .../acinq/eclair/recovery/RecoveryTool.scala | 4 +- 5 files changed, 117 insertions(+), 25 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 36163c2186..ca5831e19d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -35,6 +35,7 @@ import fr.acinq.eclair.payment.PaymentInitiator.SendPaymentRequest import fr.acinq.eclair.payment.PaymentLifecycle.ReceivePayment import fr.acinq.eclair.payment._ import fr.acinq.eclair.recovery.RecoveryFSM +import fr.acinq.eclair.recovery.RecoveryFSM.RecoveryConnect import fr.acinq.eclair.router.{ChannelDesc, RouteRequest, RouteResponse, Router} import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} import scodec.bits.ByteVector @@ -305,6 +306,7 @@ class EclairImpl(appKit: Kit) extends Eclair { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - appKit.system.actorOf(Props(new RecoveryFSM(uri, appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) + val recoveryFSM = appKit.system.actorOf(Props(new RecoveryFSM(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) + recoveryFSM ! RecoveryConnect(uri) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 707b1ecea2..96094b6c29 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -4,11 +4,11 @@ import java.net.InetSocketAddress import akka.actor.{Actor, ActorRef, ActorSelection, PoisonPill, Props} import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, ByteVector32, OP_0, OP_2, OP_CHECKMULTISIG, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHDATA, OutPoint, Script, ScriptWitness, Transaction} -import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient} -import fr.acinq.eclair.channel.{Channel, ChannelClosed, Commitments, DATA_CLOSING, HasCommitments, Helpers, INPUT_RESTORED, PleasePublishYourCommitment} +import fr.acinq.eclair.channel.{HasCommitments, Helpers, PleasePublishYourCommitment} import fr.acinq.eclair.crypto.{Generators, KeyManager, TransportHandler} import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect, FinalChannelId} import fr.acinq.eclair.io.Switchboard.peerActorName @@ -23,32 +23,33 @@ import scala.concurrent.{Await, Future} import scala.concurrent.duration._ import scala.util.{Failure, Success, Try} -class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends Actor with Logging { +class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends Actor with Logging { implicit val ec = context.system.dispatcher val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) val CHECK_POLL_INTERVAL = 3 seconds context.system.eventStream.subscribe(self, classOf[PeerConnected]) - self ! RecoveryConnect(nodeURI) - override def receive: Receive = waitingForConnection() + override def receive: Receive = waitingForConnection(None) - def waitingForConnection(): Receive = { + def waitingForConnection(remoteNodeUri: Option[NodeURI]): Receive = { case RecoveryConnect(nodeURI: NodeURI) => logger.info(s"creating new recovery peer") val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId, authenticator, blockchain, router, relayer, wallet))) peer ! Peer.Init(previousKnownAddress = None, storedChannels = Set.empty) peer ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) - context.become(waitingForConnection()) - case PeerConnected(peer, nodeId) if nodeId == nodeURI.nodeId => - logger.info(s"Connected to remote $nodeId") - context.become(waitingForRemoteChannelInfo(DATA_WAIT_FOR_REMOTE_INFO(peer))) + context.become(waitingForConnection(Some(nodeURI))) + case PeerConnected(peer, nodeId) if remoteNodeUri.forall(_.nodeId == nodeId) => + logger.info(s"connected to remote $nodeId") + context.become(waitingForRemoteChannelInfo(DATA_WAIT_FOR_REMOTE_INFO(peer, nodeId))) + case GetState => sender ! RECOVERY_WAIT_FOR_CONNECTION } def waitingForRemoteChannelInfo(d: DATA_WAIT_FOR_REMOTE_INFO): Receive = { + case GetState => sender ! RECOVERY_WAIT_FOR_CHANNEL case ChannelFound(channelId, reestablish) => - logger.info(s"peer=${nodeURI.nodeId} knows channelId=$channelId") + logger.info(s"peer=${d.remoteNodeId} knows channelId=$channelId") lookupFundingTx(channelId) match { case None => logger.info(s"could not find funding transaction...disconnecting") @@ -89,6 +90,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A case Failure(_) => context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckCommitmentPublished) } + case GetState => sender ! RECOVERY_WAIT_FOR_COMMIT_PUBLISHED } def waitToPublishClaimTx(d: DATA_WAIT_FOR_CLAIM_TX): Receive = { @@ -102,6 +104,7 @@ class RecoveryFSM(nodeURI: NodeURI, val nodeParams: NodeParams, authenticator: A bitcoinClient.publishTransaction(d.claimTx) context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckClaimPublished) } + case GetState => sender ! RECOVERY_WAIT_FOR_CLAIM_PUBLISHED } /** @@ -162,21 +165,27 @@ object RecoveryFSM { val actorName = "recovery-fsm-actor" + // formatter: off + // those states are used in the test sealed trait State - case object WAIT_FOR_CONNECTION extends State - case object WAIT_FOR_CHANNEL extends State + case object RECOVERY_WAIT_FOR_CONNECTION extends State + case object RECOVERY_WAIT_FOR_CHANNEL extends State + case object RECOVERY_WAIT_FOR_COMMIT_PUBLISHED extends State + case object RECOVERY_WAIT_FOR_CLAIM_PUBLISHED extends State sealed trait Data - case class DATA_WAIT_FOR_REMOTE_INFO(peer: ActorRef) extends Data + case class DATA_WAIT_FOR_REMOTE_INFO(peer: ActorRef, remoteNodeId: PublicKey) extends Data case class DATA_WAIT_FOR_REMOTE_PUBLISH(peer: ActorRef, channelReestablish: ChannelReestablish, fundingTx: Transaction, fundingOutIndex: Int) extends Data case class DATA_WAIT_FOR_CLAIM_TX(peer: ActorRef, claimTx: Transaction) extends Data sealed trait Event + case object GetState extends Event case class RecoveryConnect(remote: NodeURI) extends Event case class ChannelFound(channelId: ByteVector32, reestablish: ChannelReestablish) extends Event case class SendErrorToRemote(error: Error) extends Event case object CheckCommitmentPublished extends Event case object CheckClaimPublished extends Event + // formatter: on // extract our funding pubkey from witness def recoverFundingKeyFromCommitment(nodeParams: NodeParams, commitTx: Transaction, channelReestablish: ChannelReestablish): PublicKey = { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala index 9df32f77c7..6ba08b6c74 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala @@ -20,7 +20,7 @@ import java.util.UUID import akka.testkit.{TestFSMRef, TestKitBase, TestProbe} import fr.acinq.bitcoin.Crypto.PublicKey -import fr.acinq.bitcoin.{ByteVector32, Crypto} +import fr.acinq.bitcoin.{ByteVector32, Crypto, Transaction} import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} import fr.acinq.eclair.blockchain._ import fr.acinq.eclair.blockchain.fee.FeeTargets @@ -68,7 +68,7 @@ trait StateTestsHelperMethods extends TestKitBase with fixture.TestSuite with Pa } def reachNormal(setup: SetupFixture, - tags: Set[String] = Set.empty): Unit = { + tags: Set[String] = Set.empty): Transaction = { import setup._ val channelVersion = ChannelVersion.STANDARD val channelFlags = if (tags.contains("channels_public")) ChannelFlags.AnnounceChannel else ChannelFlags.Empty @@ -108,6 +108,7 @@ trait StateTestsHelperMethods extends TestKitBase with fixture.TestSuite with Pa // x2 because alice and bob share the same relayer channelUpdateListener.expectMsgType[LocalChannelUpdate] channelUpdateListener.expectMsgType[LocalChannelUpdate] + fundingTx } def makeCmdAdd(amount: MilliSatoshi, destination: PublicKey, currentBlockHeight: Long): (ByteVector32, CMD_ADD_HTLC) = { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala index 20d61d0576..9745c469d6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala @@ -1,31 +1,47 @@ package fr.acinq.eclair.recovery -import akka.testkit.TestProbe +import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.eclair.{TestConstants, TestkitBaseClass} -import fr.acinq.eclair.channel.{CMD_SIGN, DATA_NORMAL, INPUT_DISCONNECTED, INPUT_RECONNECTED, NORMAL, OFFLINE} +import fr.acinq.eclair.channel.{CMD_SIGN, Channel, DATA_NORMAL, Data, INPUT_DISCONNECTED, INPUT_RECONNECTED, NORMAL, OFFLINE, State} import fr.acinq.eclair.channel.states.StateTestsHelperMethods import RecoveryFSM._ +import akka.actor.Props +import fr.acinq.bitcoin.Transaction +import fr.acinq.eclair +import fr.acinq.eclair.blockchain.TestWallet +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCClient +import fr.acinq.eclair.io.PeerConnected import fr.acinq.eclair.wire.{ChannelReestablish, CommitSig, Init, RevokeAndAck} +import org.json4s.JsonAST +import org.json4s.JsonAST.{JNull, JObject, JString} +import org.mockito.scalatest.{IdiomaticMockito, MockitoSugar} import org.scalatest.{FunSuite, FunSuiteLike, Outcome} +import scala.concurrent.{Await, ExecutionContext, Future} import scala.concurrent.duration._ -class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods { +class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods with IdiomaticMockito { + + type FixtureParam = SetupFixtureFSM + + case class SetupFixtureFSM(alice: TestFSMRef[State, Data, Channel], + bob: TestFSMRef[State, Data, Channel], + bob2alice: TestProbe, + fundingTx: Transaction) - type FixtureParam = SetupFixture override def withFixture(test: OneArgTest): Outcome = { val setup = init() import setup._ within(30 seconds) { - reachNormal(setup, test.tags) + val fundingTx = reachNormal(setup, test.tags) awaitCond(alice.stateName == NORMAL) awaitCond(bob.stateName == NORMAL) - withFixture(test.toNoArgTest(setup)) + withFixture(test.toNoArgTest(SetupFixtureFSM(alice, bob, bob2alice, fundingTx))) } } - test("recover out funding key and channel keypath from the remote commit tx") { f => + test("recover our funding key and channel keypath from the remote commit tx") { f => import f._ val probe = TestProbe() @@ -54,4 +70,66 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods { assert(recoveredFundingKey == aliceFundingKey) } + test("find the funding transaction id from channel id"){ f => + import f._ + + val probe = TestProbe() + val aliceStateData = f.alice.stateData.asInstanceOf[DATA_NORMAL] + val fundingId = aliceStateData.commitments.commitInput.outPoint.txid + val channelId = aliceStateData.commitments.channelId + + // disconnect the peers to obtain a channel_reestablish on reconnection + probe.send(alice, INPUT_DISCONNECTED) + probe.send(bob, INPUT_DISCONNECTED) + awaitCond(alice.stateName == OFFLINE) + awaitCond(bob.stateName == OFFLINE) + + val aliceInit = Init(TestConstants.Alice.nodeParams.globalFeatures, TestConstants.Alice.nodeParams.localFeatures) + val bobInit = Init(TestConstants.Bob.nodeParams.globalFeatures, TestConstants.Bob.nodeParams.localFeatures) + + // reconnect the input in bob's channel, bob will send to alice a channel_reestablish + probe.send(bob, INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit)) + val bobAliceReestablish = bob2alice.expectMsgType[ChannelReestablish] + + val nodeParams = TestConstants.Alice.nodeParams + val authenticator = TestProbe() + val router = TestProbe() + val switchboard = TestProbe() + val watcher = TestProbe() + val relayer = TestProbe() + val remotePeer = TestProbe() + val remotePeerId = TestConstants.Bob.nodeParams.nodeId + + // given the channel id the recovery FSM guesses several funding tx ids, this mock rpc client replies with the funding transaction + // only if the query is correct, which means when the parameter txid is equal to the funding txid, + val bitcoinRpcClient = new BitcoinJsonRPCClient { + override def invoke(method: String, params: Any*)(implicit ec: ExecutionContext): Future[JsonAST.JValue] = method match { + case "getrawtransaction" if params.head.asInstanceOf[String] == fundingId.toHex => Future.successful( + JString(Transaction.write(fundingTx).toHex) + ) + case _ => Future.successful(JNull) + } + } + + val recoveryFSM = system.actorOf(Props(new RecoveryFSM(nodeParams, authenticator.ref, router.ref, switchboard.ref, new TestWallet, watcher.ref, relayer.ref, bitcoinRpcClient)), RecoveryFSM.actorName) + + probe.send(recoveryFSM, GetState) + probe.expectMsgType[RECOVERY_WAIT_FOR_CONNECTION.type] + + // skip peer connection + probe.send(recoveryFSM, PeerConnected(remotePeer.ref, remotePeerId)) + + probe.send(recoveryFSM, GetState) + probe.expectMsgType[RECOVERY_WAIT_FOR_CHANNEL.type] + + // send a ChannelFound event with channel_reestablish to the recoveryFSM -- the channel has been found + probe.send(recoveryFSM, ChannelFound(channelId, bobAliceReestablish)) + + probe.send(recoveryFSM, GetState) + probe.expectMsgType[RECOVERY_WAIT_FOR_COMMIT_PUBLISHED.type] + + // the recovery FSM replies with an error asking the remote to publish its commitment + remotePeer.expectMsgType[eclair.wire.Error] + } + } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala index 51d535958a..fef8cc2ebb 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala @@ -5,6 +5,7 @@ import com.softwaremill.sttp.okhttp.OkHttpFutureBackend import fr.acinq.eclair.io.{NodeURI, Peer} import fr.acinq.eclair.Kit import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinJsonRPCClient} +import fr.acinq.eclair.recovery.RecoveryFSM.RecoveryConnect import grizzled.slf4j.Logging import scala.util.{Failure, Random, Success, Try} @@ -27,7 +28,8 @@ object RecoveryTool extends Logging { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - appKit.system.actorOf(Props(new RecoveryFSM(nodeUri, appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) + val recoveryFSM = appKit.system.actorOf(Props(new RecoveryFSM(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) + recoveryFSM ! RecoveryConnect(nodeUri) } private def getInput[T](msg: String, parse: String => T): T = { From 212d57414cc16adf470a7efa6475f9da7b97eda8 Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 6 Nov 2019 16:05:17 +0100 Subject: [PATCH 20/26] Revert unnecessary changes --- .../bitcoind/rpc/ExtendedBitcoinClient.scala | 12 ------------ .../main/scala/fr/acinq/eclair/db/Databases.scala | 7 ++----- .../test/scala/fr/acinq/eclair/TestConstants.scala | 2 +- .../scala/fr/acinq/eclair/io/SwitchboardSpec.scala | 2 -- 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala index 49e290ff9f..ff277febca 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala @@ -48,18 +48,6 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) { case t: JsonRPCError if t.error.code == -5 => None } - def getBlock(blockHash: ByteVector32)(implicit ec: ExecutionContext): Future[Block] = - rpcClient.invoke("getblock", blockHash.toHex, 0).collect { - case JString(b) => Block.read(b) - } - - def getBlockHeight(blockHash: ByteVector32)(implicit ec: ExecutionContext): Future[Long] = - rpcClient.invoke("getblock", blockHash.toHex, 1) - .map(json => json \ "height") - .collect { - case JInt(height) => height.longValue() - } - def lookForSpendingTx(blockhash_opt: Option[String], txid: String, outputIndex: Int)(implicit ec: ExecutionContext): Future[Transaction] = for { blockhash <- blockhash_opt match { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index f23a9ed6cc..eb982b0f7e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -35,8 +35,6 @@ trait Databases { val pendingRelay: PendingRelayDb - val dbDir: Option[File] - def backup(file: File) : Unit } @@ -54,17 +52,16 @@ object Databases { val sqliteAudit = DriverManager.getConnection(s"jdbc:sqlite:${new File(dbdir, "audit.sqlite")}") SqliteUtils.obtainExclusiveLock(sqliteEclair) // there should only be one process writing to this file - databaseByConnections(sqliteAudit, sqliteNetwork, sqliteEclair, Some(dbdir)) + databaseByConnections(sqliteAudit, sqliteNetwork, sqliteEclair) } - def databaseByConnections(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection, dbDir_opt: Option[File]) = new Databases { + def databaseByConnections(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection) = new Databases { override val network = new SqliteNetworkDb(networkJdbc) override val audit = new SqliteAuditDb(auditJdbc) override val channels = new SqliteChannelsDb(eclairJdbc) override val peers = new SqlitePeersDb(eclairJdbc) override val payments = new SqlitePaymentsDb(eclairJdbc) override val pendingRelay = new SqlitePendingRelayDb(eclairJdbc) - override val dbDir: Option[File] = dbDir_opt override def backup(file: File): Unit = { SqliteUtils.using(eclairJdbc.createStatement()) { statement => { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 3535bab6b8..ff77c40a22 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -58,7 +58,7 @@ object TestConstants { def sqliteInMemory() = DriverManager.getConnection("jdbc:sqlite::memory:") - def inMemoryDb(connection: Connection = sqliteInMemory()): Databases = Databases.databaseByConnections(connection, connection, connection, None) + def inMemoryDb(connection: Connection = sqliteInMemory()): Databases = Databases.databaseByConnections(connection, connection, connection) object Alice { val seed = ByteVector32(ByteVector.fill(32)(1)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala index 442d12cd95..ca78e81b99 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala @@ -27,7 +27,6 @@ class SwitchboardSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit override val peers: PeersDb = Alice.nodeParams.db.peers override val payments: PaymentsDb = Alice.nodeParams.db.payments override val pendingRelay: PendingRelayDb = Alice.nodeParams.db.pendingRelay - override val dbDir: Option[File] = None override def backup(file: File): Unit = () } ) @@ -68,7 +67,6 @@ class SwitchboardSpec extends TestKit(ActorSystem("test")) with FunSuiteLike wit override val peers: PeersDb = Alice.nodeParams.db.peers override val payments: PaymentsDb = Alice.nodeParams.db.payments override val pendingRelay: PendingRelayDb = Alice.nodeParams.db.pendingRelay - override val dbDir: Option[File] = None override def backup(file: File): Unit = () } ) From fdac63da7334a7ced381da7491ad9271f0afe647 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 7 Nov 2019 16:30:59 +0100 Subject: [PATCH 21/26] Refactor RecoveryFSM to an actual akka.actor.FSM --- .../main/scala/fr/acinq/eclair/Eclair.scala | 2 +- .../acinq/eclair/recovery/RecoveryFSM.scala | 70 ++++++++++--------- .../eclair/recovery/RecoveryFSMSpec.scala | 16 ++--- .../acinq/eclair/recovery/RecoveryTool.scala | 2 +- 4 files changed, 43 insertions(+), 47 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index ca5831e19d..9e206ef12e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -306,7 +306,7 @@ class EclairImpl(appKit: Kit) extends Eclair { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - val recoveryFSM = appKit.system.actorOf(Props(new RecoveryFSM(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) + val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient), RecoveryFSM.actorName) recoveryFSM ! RecoveryConnect(uri) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 96094b6c29..de5acffb00 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -2,7 +2,7 @@ package fr.acinq.eclair.recovery import java.net.InetSocketAddress -import akka.actor.{Actor, ActorRef, ActorSelection, PoisonPill, Props} +import akka.actor.{ActorRef, ActorSelection, FSM, Props} import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, ByteVector32, OP_0, OP_2, OP_CHECKMULTISIG, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHDATA, OutPoint, Script, ScriptWitness, Transaction} import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.eclair.NodeParams @@ -10,7 +10,7 @@ import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient} import fr.acinq.eclair.channel.{HasCommitments, Helpers, PleasePublishYourCommitment} import fr.acinq.eclair.crypto.{Generators, KeyManager, TransportHandler} -import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect, FinalChannelId} +import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect} import fr.acinq.eclair.io.Switchboard.peerActorName import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected, Switchboard} import fr.acinq.eclair.recovery.RecoveryFSM._ @@ -21,9 +21,9 @@ import scodec.bits.ByteVector import scala.concurrent.{Await, Future} import scala.concurrent.duration._ -import scala.util.{Failure, Success, Try} +import scala.util.Success -class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends Actor with Logging { +class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends FSM[State, Data] with Logging { implicit val ec = context.system.dispatcher val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) @@ -31,45 +31,44 @@ class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: A context.system.eventStream.subscribe(self, classOf[PeerConnected]) - override def receive: Receive = waitingForConnection(None) + startWith(RECOVERY_WAIT_FOR_CONNECTION, Nothing) - def waitingForConnection(remoteNodeUri: Option[NodeURI]): Receive = { - case RecoveryConnect(nodeURI: NodeURI) => + when(RECOVERY_WAIT_FOR_CONNECTION) { + case Event(RecoveryConnect(nodeURI: NodeURI), Nothing) => logger.info(s"creating new recovery peer") val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId, authenticator, blockchain, router, relayer, wallet))) peer ! Peer.Init(previousKnownAddress = None, storedChannels = Set.empty) peer ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) - context.become(waitingForConnection(Some(nodeURI))) - case PeerConnected(peer, nodeId) if remoteNodeUri.forall(_.nodeId == nodeId) => + stay using DATA_WAIT_FOR_CONNECTION(nodeURI.nodeId) + + case Event(PeerConnected(peer, nodeId), d: DATA_WAIT_FOR_CONNECTION) if d.remoteNodeId == nodeId => logger.info(s"connected to remote $nodeId") - context.become(waitingForRemoteChannelInfo(DATA_WAIT_FOR_REMOTE_INFO(peer, nodeId))) - case GetState => sender ! RECOVERY_WAIT_FOR_CONNECTION + goto(RECOVERY_WAIT_FOR_CHANNEL) using DATA_WAIT_FOR_REMOTE_INFO(peer, nodeId) } - def waitingForRemoteChannelInfo(d: DATA_WAIT_FOR_REMOTE_INFO): Receive = { - case GetState => sender ! RECOVERY_WAIT_FOR_CHANNEL - case ChannelFound(channelId, reestablish) => + when(RECOVERY_WAIT_FOR_CHANNEL) { + case Event(ChannelFound(channelId, reestablish), d: DATA_WAIT_FOR_REMOTE_INFO) => logger.info(s"peer=${d.remoteNodeId} knows channelId=$channelId") lookupFundingTx(channelId) match { case None => logger.info(s"could not find funding transaction...disconnecting") d.peer ! Disconnect - self ! PoisonPill + stop() case Some((fundingTx, outIndex)) => logger.info(s"found unspent channel funding_tx=${fundingTx.txid} outputIndex=$outIndex") logger.info(s"asking remote to close the channel") d.peer ! Error(channelId, PleasePublishYourCommitment(channelId).toString) context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckCommitmentPublished) - context.become(waitForRemoteToPublishCommitment(DATA_WAIT_FOR_REMOTE_PUBLISH(d.peer, reestablish, fundingTx, outIndex))) + goto(RECOVERY_WAIT_FOR_COMMIT_PUBLISHED) using DATA_WAIT_FOR_REMOTE_PUBLISH(d.peer, reestablish, fundingTx, outIndex) } } - def waitForRemoteToPublishCommitment(d: DATA_WAIT_FOR_REMOTE_PUBLISH): Receive = { - case CheckCommitmentPublished => + when(RECOVERY_WAIT_FOR_COMMIT_PUBLISHED) { + case Event(CheckCommitmentPublished, d: DATA_WAIT_FOR_REMOTE_PUBLISH) => logger.info(s"looking for the commitment transaction") - lookForCommitTx(d.fundingTx.txid, d.fundingOutIndex).onComplete { - case Success(commitTx) => + Await.ready(lookForCommitTx(d.fundingTx.txid, d.fundingOutIndex), 30 seconds).value match { + case Some(Success(commitTx)) => logger.info(s"found commitTx=${commitTx.txid}") val Some(remotePerCommitmentSecret) = d.channelReestablish.myCurrentPerCommitmentPoint @@ -85,26 +84,27 @@ class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: A logger.info(s"publishing claim-main-output transaction address=${scriptPubKeyToAddress(finalScriptPubkey)} txid=${claimSigned.tx.txid}") bitcoinClient.publishTransaction(claimSigned.tx) context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckClaimPublished) - context.become(waitToPublishClaimTx(DATA_WAIT_FOR_CLAIM_TX(d.peer, claimSigned.tx))) + goto(RECOVERY_WAIT_FOR_CLAIM_PUBLISHED) using DATA_WAIT_FOR_CLAIM_TX(d.peer, claimSigned.tx) - case Failure(_) => + case _ => context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckCommitmentPublished) + stay() } - case GetState => sender ! RECOVERY_WAIT_FOR_COMMIT_PUBLISHED } - def waitToPublishClaimTx(d: DATA_WAIT_FOR_CLAIM_TX): Receive = { - case CheckClaimPublished => - bitcoinClient.getTransaction(d.claimTx.txid.toHex).onComplete { - case Success(claimTx) => + when(RECOVERY_WAIT_FOR_CLAIM_PUBLISHED) { + case Event(CheckClaimPublished, d: DATA_WAIT_FOR_CLAIM_TX) => + Await.ready(bitcoinClient.getTransaction(d.claimTx.txid.toHex), 30 seconds).value match { + case Some(Success(claimTx)) => logger.info(s"claim transaction published txid=${claimTx.txid}") d.peer ! Disconnect - self ! PoisonPill - case Failure(_) => + stop() + + case _ => bitcoinClient.publishTransaction(d.claimTx) context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckClaimPublished) + stay } - case GetState => sender ! RECOVERY_WAIT_FOR_CLAIM_PUBLISHED } /** @@ -156,17 +156,18 @@ class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: A Base58Check.encode(Base58.Prefix.ScriptAddressTestnet, scriptHash) case OP_0 :: OP_PUSHDATA(pubKeyHash, _) :: Nil if pubKeyHash.length == 20 => Bech32.encodeWitnessAddress("bcrt", 0, pubKeyHash) case OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil if scriptHash.length == 32 => Bech32.encodeWitnessAddress("bcrt", 0, scriptHash) - case _ => ??? + case _ => throw new IllegalArgumentException(s"non standard scriptPubkey=$scriptPubKey") } - } object RecoveryFSM { val actorName = "recovery-fsm-actor" + def props(nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) = + Props(new RecoveryFSM(nodeParams, authenticator, router, switchboard, wallet, blockchain, relayer, bitcoinJsonRPCClient)) + // formatter: off - // those states are used in the test sealed trait State case object RECOVERY_WAIT_FOR_CONNECTION extends State case object RECOVERY_WAIT_FOR_CHANNEL extends State @@ -174,12 +175,13 @@ object RecoveryFSM { case object RECOVERY_WAIT_FOR_CLAIM_PUBLISHED extends State sealed trait Data + case object Nothing extends Data + case class DATA_WAIT_FOR_CONNECTION(remoteNodeId: PublicKey) extends Data case class DATA_WAIT_FOR_REMOTE_INFO(peer: ActorRef, remoteNodeId: PublicKey) extends Data case class DATA_WAIT_FOR_REMOTE_PUBLISH(peer: ActorRef, channelReestablish: ChannelReestablish, fundingTx: Transaction, fundingOutIndex: Int) extends Data case class DATA_WAIT_FOR_CLAIM_TX(peer: ActorRef, claimTx: Transaction) extends Data sealed trait Event - case object GetState extends Event case class RecoveryConnect(remote: NodeURI) extends Event case class ChannelFound(channelId: ByteVector32, reestablish: ChannelReestablish) extends Event case class SendErrorToRemote(error: Error) extends Event diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala index 9745c469d6..f825376a6f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala @@ -5,7 +5,6 @@ import fr.acinq.eclair.{TestConstants, TestkitBaseClass} import fr.acinq.eclair.channel.{CMD_SIGN, Channel, DATA_NORMAL, Data, INPUT_DISCONNECTED, INPUT_RECONNECTED, NORMAL, OFFLINE, State} import fr.acinq.eclair.channel.states.StateTestsHelperMethods import RecoveryFSM._ -import akka.actor.Props import fr.acinq.bitcoin.Transaction import fr.acinq.eclair import fr.acinq.eclair.blockchain.TestWallet @@ -111,25 +110,20 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods with } } - val recoveryFSM = system.actorOf(Props(new RecoveryFSM(nodeParams, authenticator.ref, router.ref, switchboard.ref, new TestWallet, watcher.ref, relayer.ref, bitcoinRpcClient)), RecoveryFSM.actorName) - - probe.send(recoveryFSM, GetState) - probe.expectMsgType[RECOVERY_WAIT_FOR_CONNECTION.type] + val recoveryFSM = TestFSMRef(new RecoveryFSM(nodeParams, authenticator.ref, router.ref, switchboard.ref, new TestWallet, watcher.ref, relayer.ref, bitcoinRpcClient)) + recoveryFSM.setState(RECOVERY_WAIT_FOR_CONNECTION, DATA_WAIT_FOR_CONNECTION(remotePeerId)) // skip peer connection probe.send(recoveryFSM, PeerConnected(remotePeer.ref, remotePeerId)) - - probe.send(recoveryFSM, GetState) - probe.expectMsgType[RECOVERY_WAIT_FOR_CHANNEL.type] + awaitCond(recoveryFSM.stateName == RECOVERY_WAIT_FOR_CHANNEL) // send a ChannelFound event with channel_reestablish to the recoveryFSM -- the channel has been found probe.send(recoveryFSM, ChannelFound(channelId, bobAliceReestablish)) - probe.send(recoveryFSM, GetState) - probe.expectMsgType[RECOVERY_WAIT_FOR_COMMIT_PUBLISHED.type] - // the recovery FSM replies with an error asking the remote to publish its commitment remotePeer.expectMsgType[eclair.wire.Error] + + awaitCond(recoveryFSM.stateName == RECOVERY_WAIT_FOR_COMMIT_PUBLISHED) } } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala index fef8cc2ebb..60486d59fd 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala @@ -28,7 +28,7 @@ object RecoveryTool extends Logging { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - val recoveryFSM = appKit.system.actorOf(Props(new RecoveryFSM(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient)), RecoveryFSM.actorName) + val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient), RecoveryFSM.actorName) recoveryFSM ! RecoveryConnect(nodeUri) } From 2bb9b5d95baa3d63eb8651466f281a8c943f20a3 Mon Sep 17 00:00:00 2001 From: Andrea Date: Fri, 8 Nov 2019 12:02:24 +0100 Subject: [PATCH 22/26] Implement the necessary peer behaviour for the recovery tool in a separate class, revert changes to Switchboard and Peer. --- .../main/scala/fr/acinq/eclair/Eclair.scala | 2 +- .../main/scala/fr/acinq/eclair/Setup.scala | 9 +- .../main/scala/fr/acinq/eclair/io/Peer.scala | 8 +- .../acinq/eclair/recovery/RecoveryFSM.scala | 51 +-- .../acinq/eclair/recovery/RecoveryPeer.scala | 352 ++++++++++++++++++ .../fr/acinq/eclair/EclairImplSpec.scala | 1 - .../eclair/recovery/RecoveryFSMSpec.scala | 7 +- .../src/main/scala/fr/acinq/eclair/Boot.scala | 19 +- .../acinq/eclair/recovery/RecoveryTool.scala | 2 +- 9 files changed, 371 insertions(+), 80 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 9e206ef12e..ec5996ac05 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -306,7 +306,7 @@ class EclairImpl(appKit: Kit) extends Eclair { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient), RecoveryFSM.actorName) + val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.wallet, bitcoinRpcClient), RecoveryFSM.actorName) recoveryFSM ! RecoveryConnect(uri) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index 8e2930a8cc..aac5a02c72 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -44,7 +44,6 @@ import fr.acinq.eclair.crypto.LocalKeyManager import fr.acinq.eclair.db.{BackupHandler, Databases} import fr.acinq.eclair.io.{Authenticator, Server, Switchboard} import fr.acinq.eclair.payment._ -import fr.acinq.eclair.recovery.RecoverySwitchBoard import fr.acinq.eclair.router._ import fr.acinq.eclair.tor.TorProtocolHandler.OnionServiceVersion import fr.acinq.eclair.tor.{Controller, TorProtocolHandler} @@ -285,7 +284,7 @@ class Setup(datadir: File, register = system.actorOf(SimpleSupervisor.props(Props(new Register), "register", SupervisorStrategy.Resume)) relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, register, paymentHandler), "relayer", SupervisorStrategy.Resume)) authenticator = system.actorOf(SimpleSupervisor.props(Authenticator.props(nodeParams), "authenticator", SupervisorStrategy.Resume)) - switchboard = getSwitchboard(authenticator, watcher, router, relayer, wallet) + switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, authenticator, watcher, router, relayer, wallet), "switchboard", SupervisorStrategy.Resume)) server = system.actorOf(SimpleSupervisor.props(Server.props(nodeParams, authenticator, serverBindingAddress, Some(tcpBound)), "server", SupervisorStrategy.Restart)) paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, router, register), "payment-initiator", SupervisorStrategy.Restart)) _ = for (i <- 0 until config.getInt("autoprobe-count")) yield system.actorOf(SimpleSupervisor.props(Autoprobe.props(nodeParams, router, paymentInitiator), s"payment-autoprobe-$i", SupervisorStrategy.Restart)) @@ -301,7 +300,6 @@ class Setup(datadir: File, switchboard = switchboard, paymentInitiator = paymentInitiator, server = server, - authenticator = authenticator, wallet = wallet) zmqBlockTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) @@ -315,10 +313,6 @@ class Setup(datadir: File, } - def getSwitchboard(authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet): ActorRef = { - system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, authenticator, watcher, router, relayer, wallet), "switchboard", SupervisorStrategy.Resume)) - } - private def await[T](awaitable: Awaitable[T], atMost: Duration, messageOnTimeout: => String): T = try { Await.result(awaitable, atMost) } catch { @@ -370,7 +364,6 @@ case class Kit(nodeParams: NodeParams, switchboard: ActorRef, paymentInitiator: ActorRef, server: ActorRef, - authenticator: ActorRef, wallet: EclairWallet) case object BitcoinZMQConnectionTimeoutException extends RuntimeException("could not connect to bitcoind using zeromq") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index 76f273115e..0aee107c95 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -213,9 +213,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: A stay } - when(CONNECTED)(whenConnected) - - def whenConnected: StateFunction = { + when(CONNECTED) { case Event(StateTimeout, _: ConnectedData) => // the first ping is sent after the connection has been quiet for a while // we don't want to send pings right after connection, because peer will be syncing and may not be able to @@ -359,8 +357,8 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: A case Event(DelayedRebroadcast(rebroadcast), d: ConnectedData) => /** - * Send and count in a single iteration - */ + * Send and count in a single iteration + */ def sendAndCount(msgs: Map[_ <: RoutingMessage, Set[ActorRef]]): Int = msgs.foldLeft(0) { case (count, (_, origins)) if origins.contains(self) => // the announcement came from this peer, we don't send it back diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index de5acffb00..9b7906960c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -1,6 +1,5 @@ package fr.acinq.eclair.recovery -import java.net.InetSocketAddress import akka.actor.{ActorRef, ActorSelection, FSM, Props} import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, ByteVector32, OP_0, OP_2, OP_CHECKMULTISIG, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHDATA, OutPoint, Script, ScriptWitness, Transaction} @@ -11,7 +10,6 @@ import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBi import fr.acinq.eclair.channel.{HasCommitments, Helpers, PleasePublishYourCommitment} import fr.acinq.eclair.crypto.{Generators, KeyManager, TransportHandler} import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect} -import fr.acinq.eclair.io.Switchboard.peerActorName import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected, Switchboard} import fr.acinq.eclair.recovery.RecoveryFSM._ import fr.acinq.eclair.transactions.Transactions @@ -23,7 +21,7 @@ import scala.concurrent.{Await, Future} import scala.concurrent.duration._ import scala.util.Success -class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, val wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends FSM[State, Data] with Logging { +class RecoveryFSM(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends FSM[State, Data] with Logging { implicit val ec = context.system.dispatcher val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) @@ -34,11 +32,14 @@ class RecoveryFSM(val nodeParams: NodeParams, authenticator: ActorRef, router: A startWith(RECOVERY_WAIT_FOR_CONNECTION, Nothing) when(RECOVERY_WAIT_FOR_CONNECTION) { + case Event('connected, _) => + stay + case Event(RecoveryConnect(nodeURI: NodeURI), Nothing) => logger.info(s"creating new recovery peer") - val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId, authenticator, blockchain, router, relayer, wallet))) - peer ! Peer.Init(previousKnownAddress = None, storedChannels = Set.empty) - peer ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) + val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId))) + peer ! RecoveryPeer.Init(previousKnownAddress = None, storedChannels = Set.empty) + peer ! RecoveryPeer.Connect(nodeURI.nodeId, Some(nodeURI.address)) stay using DATA_WAIT_FOR_CONNECTION(nodeURI.nodeId) case Event(PeerConnected(peer, nodeId), d: DATA_WAIT_FOR_CONNECTION) if d.remoteNodeId == nodeId => @@ -164,8 +165,7 @@ object RecoveryFSM { val actorName = "recovery-fsm-actor" - def props(nodeParams: NodeParams, authenticator: ActorRef, router: ActorRef, switchboard: ActorRef, wallet: EclairWallet, blockchain: ActorRef, relayer: ActorRef, bitcoinJsonRPCClient: BitcoinJsonRPCClient) = - Props(new RecoveryFSM(nodeParams, authenticator, router, switchboard, wallet, blockchain, relayer, bitcoinJsonRPCClient)) + def props(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCClient: BitcoinJsonRPCClient) = Props(new RecoveryFSM(nodeParams, wallet, bitcoinJsonRPCClient)) // formatter: off sealed trait State @@ -219,39 +219,4 @@ object RecoveryFSM { commitTx.txOut.exists(_.publicKeyScript == toRemoteScriptPubkey) } -} - -class RecoverySwitchBoard(nodeParams: NodeParams, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet) extends Switchboard(nodeParams, authenticator, watcher, router, relayer, wallet) { - - override def createOrGetPeer(remoteNodeId: PublicKey, previousKnownAddress: Option[InetSocketAddress], offlineChannels: Set[HasCommitments]): ActorRef = { - getPeer(remoteNodeId) match { - case Some(peer) => peer - case None => - log.info(s"creating new recovery peer current=${context.children.size}") - val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet)), name = peerActorName(remoteNodeId)) - peer ! Peer.Init(previousKnownAddress, offlineChannels) - peer - } - } - -} - -class RecoveryPeer(override val nodeParams: NodeParams, remoteNodeId: PublicKey, authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet) extends Peer(nodeParams, remoteNodeId, authenticator, watcher, router, relayer, wallet) { - - def recoveryFSM: ActorSelection = context.system.actorSelection(context.system / RecoveryFSM.actorName) - - override def whenConnected: StateFunction = { - case Event(SendErrorToRemote(error), d: ConnectedData) => - d.transport ! error - stay - - case Event(msg: ChannelReestablish, d: ConnectedData) => - d.transport ! TransportHandler.ReadAck(msg) - recoveryFSM ! ChannelFound(msg.channelId, msg) - // when recovering we don't immediately reply channel_reestablish/error - stay - - case event => super.whenConnected(event) - } - } \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala new file mode 100644 index 0000000000..c0dae323b2 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala @@ -0,0 +1,352 @@ +package fr.acinq.eclair.recovery + +import java.net.InetSocketAddress + +import akka.actor.{ActorRef, ActorSelection, FSM, OneForOneStrategy, PoisonPill, Status, SupervisorStrategy, Terminated} +import akka.event.Logging.MDC +import com.google.common.net.HostAndPort +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.crypto.TransportHandler +import fr.acinq.eclair.io.Authenticator.{Authenticated, PendingAuth} +import fr.acinq.eclair.io._ +import fr.acinq.eclair.recovery.RecoveryPeer._ +import fr.acinq.eclair.recovery.RecoveryFSM.{ChannelFound, SendErrorToRemote} +import fr.acinq.eclair.router._ +import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelReestablish, GossipTimestampFilter, LightningMessage, NodeAddress, Ping, Pong, RoutingMessage} +import fr.acinq.eclair.{FSMDiagnosticActorLogging, Features, Logs, NodeParams, SimpleSupervisor, wire} +import scodec.bits.ByteVector + +import scala.compat.Platform +import scala.concurrent.duration._ +import scala.util.Random + +class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends FSMDiagnosticActorLogging[RecoveryPeer.State, RecoveryPeer.Data] { + + def recoveryFSM: ActorSelection = context.system.actorSelection(context.system / RecoveryFSM.actorName) + + val authenticator = context.system.actorOf(SimpleSupervisor.props(Authenticator.props(nodeParams), "authenticator", SupervisorStrategy.Resume)) + authenticator ! self // register this actor as the receiver of the authentication handshake + + startWith(INSTANTIATING, RecoveryPeer.Nothing()) + + when(INSTANTIATING) { + case Event(Init(previousKnownAddress, _), _) => + val channels = Map.empty[FinalChannelId, ActorRef] + val firstNextReconnectionDelay = nodeParams.maxReconnectInterval.minus(Random.nextInt(nodeParams.maxReconnectInterval.toSeconds.toInt / 2).seconds) + goto(DISCONNECTED) using DisconnectedData(previousKnownAddress, channels, firstNextReconnectionDelay) // when we restart, we will attempt to reconnect right away, but then we'll wait + } + + when(DISCONNECTED) { + case Event(p: PendingAuth, _) => + authenticator ! p + stay + + case Event(RecoveryPeer.Connect(_, Some(address)), d: DisconnectedData) => + val inetAddress = Peer.hostAndPort2InetSocketAddress(address) + context.actorOf(Client.props(nodeParams, self, inetAddress, remoteNodeId, origin_opt = Some(sender()))) + stay using d.copy(address_opt = Some(inetAddress)) + + case Event(Authenticated(_, transport, remoteNodeId1, address, outgoing, origin_opt), d: DisconnectedData) => + require(remoteNodeId == remoteNodeId1, s"invalid nodeid: $remoteNodeId != $remoteNodeId1") + log.debug(s"got authenticated connection to $remoteNodeId@${address.getHostString}:${address.getPort}") + transport ! TransportHandler.Listener(self) + context watch transport + val localInit = nodeParams.overrideFeatures.get(remoteNodeId) match { + case Some((gf, lf)) => wire.Init(globalFeatures = gf, localFeatures = lf) + case None => wire.Init(globalFeatures = nodeParams.globalFeatures, localFeatures = nodeParams.localFeatures) + } + log.info(s"using globalFeatures=${localInit.globalFeatures.toBin} and localFeatures=${localInit.localFeatures.toBin}") + transport ! localInit + + val address_opt = if (outgoing) { + // we store the node address upon successful outgoing connection, so we can reconnect later + // any previous address is overwritten + NodeAddress.fromParts(address.getHostString, address.getPort).map(nodeAddress => nodeParams.db.peers.addOrUpdatePeer(remoteNodeId, nodeAddress)) + Some(address) + } else None + + goto(INITIALIZING) using InitializingData(address_opt, transport, d.channels, origin_opt, localInit) + } + + when(INITIALIZING) { + case Event(remoteInit: wire.Init, d: InitializingData) => + d.transport ! TransportHandler.ReadAck(remoteInit) + log.info(s"peer is using globalFeatures=${remoteInit.globalFeatures.toBin} and localFeatures=${remoteInit.localFeatures.toBin}") + + if (Features.areSupported(remoteInit.localFeatures)) { + d.origin_opt.foreach(origin => origin ! "connected") + val rebroadcastDelay = Random.nextInt(nodeParams.routerConf.routerBroadcastInterval.toSeconds.toInt).seconds + log.info(s"rebroadcast will be delayed by $rebroadcastDelay") + goto(CONNECTED) using ConnectedData(d.address_opt, d.transport, d.localInit, remoteInit, d.channels.map { case (k: ChannelId, v) => (k, v) }, rebroadcastDelay) forMax (30 seconds) // forMax will trigger a StateTimeout + } else { + log.warning(s"incompatible features, disconnecting") + d.origin_opt.foreach(origin => origin ! Status.Failure(new RuntimeException("incompatible features"))) + d.transport ! PoisonPill + stay + } + + case Event(Authenticated(connection, _, _, _, _, origin_opt), _) => + // two connections in parallel + origin_opt.foreach(origin => origin ! Status.Failure(new RuntimeException("there is another connection attempt in progress"))) + // we kill this one + log.warning(s"killing parallel connection $connection") + connection ! PoisonPill + stay + + case Event(Terminated(actor), d: InitializingData) if actor == d.transport => + log.warning(s"lost connection to $remoteNodeId") + goto(DISCONNECTED) using DisconnectedData(d.address_opt, d.channels) + + case Event(Disconnect(nodeId), d: InitializingData) if nodeId == remoteNodeId => + log.info("disconnecting") + sender ! "disconnecting" + d.transport ! PoisonPill + stay + + case Event(unhandledMsg: LightningMessage, d: InitializingData) => + // we ack unhandled messages because we don't want to block further reads on the connection + d.transport ! TransportHandler.ReadAck(unhandledMsg) + log.warning(s"acking unhandled message $unhandledMsg") + stay + } + + when(CONNECTED) { + case Event(SendErrorToRemote(error), d: ConnectedData) => + log.info(s"recoveryFSM is sending an error to the peer") + d.transport ! error + stay + + case Event(msg: ChannelReestablish, d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(msg) + recoveryFSM ! ChannelFound(msg.channelId, msg) + // when recovering we don't immediately reply channel_reestablish/error + stay + + case Event(StateTimeout, _: ConnectedData) => + // the first ping is sent after the connection has been quiet for a while + // we don't want to send pings right after connection, because peer will be syncing and may not be able to + // answer to our ping quickly enough, which will make us close the connection + log.debug(s"no messages sent/received for a while, start sending pings") + self ! SendPing + setStateTimeout(CONNECTED, None) // cancels the state timeout (it will be reset with forMax) + stay + + case Event(SendPing, d: ConnectedData) => + if (d.expectedPong_opt.isEmpty) { + // no need to use secure random here + val pingSize = Random.nextInt(1000) + val pongSize = Random.nextInt(1000) + val ping = wire.Ping(pongSize, ByteVector.fill(pingSize)(0)) + setTimer(PingTimeout.toString, PingTimeout(ping), nodeParams.pingTimeout, repeat = false) + d.transport ! ping + stay using d.copy(expectedPong_opt = Some(ExpectedPong(ping))) + } else { + log.warning(s"can't send ping, already have one in flight") + stay + } + + case Event(PingTimeout(ping), d: ConnectedData) => + if (nodeParams.pingDisconnect) { + log.warning(s"no response to ping=$ping, closing connection") + d.transport ! PoisonPill + } else { + log.warning(s"no response to ping=$ping (ignored)") + } + stay + + case Event(ping@wire.Ping(pongLength, _), d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(ping) + if (pongLength <= 65532) { + // see BOLT 1: we reply only if requested pong length is acceptable + d.transport ! wire.Pong(ByteVector.fill(pongLength)(0.toByte)) + } else { + log.warning(s"ignoring invalid ping with pongLength=${ping.pongLength}") + } + stay + + case Event(pong@wire.Pong(data), d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(pong) + d.expectedPong_opt match { + case Some(ExpectedPong(ping, timestamp)) if ping.pongLength == data.length => + // we use the pong size to correlate between pings and pongs + val latency = Platform.currentTime - timestamp + log.debug(s"received pong with latency=$latency") + cancelTimer(PingTimeout.toString()) + // pings are sent periodically with some randomization + val nextDelay = nodeParams.pingInterval + Random.nextInt(10).seconds + setTimer(SendPing.toString, SendPing, nextDelay, repeat = false) + case None => + log.debug(s"received unexpected pong with size=${data.length}") + } + stay using d.copy(expectedPong_opt = None) + + case Event(err@wire.Error(channelId, reason), d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(err) + log.error(s"connection-level error! channelId=$channelId reason=${new String(reason.toArray)}") + d.transport ! err + stay + + case Event(msg: wire.OpenChannel, d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(msg) + log.info(s"peer sent us OpenChannel") + stay + + case Event(msg: wire.HasChannelId, d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(msg) + log.info(s"received $msg from $remoteNodeId") + stay + + case Event(msg: wire.HasTemporaryChannelId, d: ConnectedData) => + d.transport ! TransportHandler.ReadAck(msg) + log.info(s"received $msg from $remoteNodeId") + stay + + case Event(msg: wire.RoutingMessage, d: ConnectedData) => + log.info(s"peer sent us a $msg") + // ACK and do nothing + sender ! TransportHandler.ReadAck(msg) + stay + + case Event(readAck: TransportHandler.ReadAck, d: ConnectedData) => + // we just forward acks from router to transport + d.transport forward readAck + stay + + case Event(Disconnect(nodeId), d: ConnectedData) if nodeId == remoteNodeId => + log.info(s"disconnecting") + sender ! "disconnecting" + d.transport ! PoisonPill + stay + + case Event(Terminated(actor), d: ConnectedData) if actor == d.transport => + log.info(s"lost connection to $remoteNodeId") + stop(FSM.Normal) + + case Event(h: Authenticated, d: ConnectedData) => + log.info(s"got new transport while already connected, switching to new transport") + context unwatch d.transport + d.transport ! PoisonPill + self ! h + goto(DISCONNECTED) using DisconnectedData(d.address_opt, d.channels.collect { case (k: FinalChannelId, v) => (k, v) }) + + case Event(unhandledMsg: LightningMessage, d: ConnectedData) => + // we ack unhandled messages because we don't want to block further reads on the connection + d.transport ! TransportHandler.ReadAck(unhandledMsg) + log.warning(s"acking unhandled message $unhandledMsg") + stay + } + + whenUnhandled { + case Event(_: Peer.Connect, _) => + sender ! "already connected" + stay + + case Event(_: Peer.OpenChannel, _) => + sender ! Status.Failure(new RuntimeException("not connected")) + stay + + case Event(_: Rebroadcast, _) => stay // ignored + + case Event(_: DelayedRebroadcast, _) => stay // ignored + + case Event(_: RoutingState, _) => stay // ignored + + case Event(_: TransportHandler.ReadAck, _) => stay // ignored + + case Event(Peer.Reconnect, _) => stay // we got connected in the meantime + + case Event(SendPing, _) => stay // we got disconnected in the meantime + + case Event(_: Pong, _) => stay // we got disconnected before receiving the pong + + case Event(_: PingTimeout, _) => stay // we got disconnected after sending a ping + + case Event(_: BadMessage, _) => stay // we got disconnected while syncing + } + + /** + * The transition INSTANTIATING -> DISCONNECTED happens in 2 scenarios + * - Manual connection to a new peer: then when(DISCONNECTED) we expect a Peer.Connect from the switchboard + * - Eclair restart: The switchboard creates the peers and sends Init and then Peer.Reconnect to trigger reconnection attempts + * + * So when we see this transition we NO-OP because we don't want to start a Reconnect timer but the peer will receive the trigger + * (Connect/Reconnect) messages from the switchboard. + */ + onTransition { + case INSTANTIATING -> DISCONNECTED => () + } + + onTransition { + case _ -> CONNECTED => + context.system.eventStream.publish(PeerConnected(self, remoteNodeId)) + case CONNECTED -> DISCONNECTED => + context.system.eventStream.publish(PeerDisconnected(self, remoteNodeId)) + } + + onTermination { + case StopEvent(_, CONNECTED, d: ConnectedData) => + // the transition handler won't be fired if we go directly from CONNECTED to closed + context.system.eventStream.publish(PeerDisconnected(self, remoteNodeId)) + } + + // a failing channel won't be restarted, it should handle its states + override val supervisorStrategy = OneForOneStrategy(loggingEnabled = true) { case _ => SupervisorStrategy.Stop } + + initialize() + + override def mdc(currentMessage: Any): MDC = Logs.mdc(remoteNodeId_opt = Some(remoteNodeId)) + + +} + +object RecoveryPeer { + + val UNKNOWN_CHANNEL_MESSAGE = ByteVector.view("unknown channel".getBytes()) + + sealed trait Data { + def address_opt: Option[InetSocketAddress] + def channels: Map[_ <: ChannelId, ActorRef] // will be overridden by Map[FinalChannelId, ActorRef] or Map[ChannelId, ActorRef] + } + case class Nothing() extends Data { override def address_opt = None; override def channels = Map.empty } + case class DisconnectedData(address_opt: Option[InetSocketAddress], channels: Map[FinalChannelId, ActorRef], nextReconnectionDelay: FiniteDuration = 10 seconds) extends Data + case class InitializingData(address_opt: Option[InetSocketAddress], transport: ActorRef, channels: Map[FinalChannelId, ActorRef], origin_opt: Option[ActorRef], localInit: wire.Init) extends Data + case class ConnectedData(address_opt: Option[InetSocketAddress], transport: ActorRef, localInit: wire.Init, remoteInit: wire.Init, channels: Map[ChannelId, ActorRef], rebroadcastDelay: FiniteDuration, gossipTimestampFilter: Option[GossipTimestampFilter] = None, behavior: Behavior = Behavior(), expectedPong_opt: Option[ExpectedPong] = None) extends Data + case class ExpectedPong(ping: Ping, timestamp: Long = Platform.currentTime) + case class PingTimeout(ping: Ping) + + sealed trait State + case object INSTANTIATING extends State + case object DISCONNECTED extends State + case object INITIALIZING extends State + case object CONNECTED extends State + + case class Init(previousKnownAddress: Option[InetSocketAddress], storedChannels: Set[HasCommitments]) + case class Connect(nodeId: PublicKey, address_opt: Option[HostAndPort]) { + def uri: Option[NodeURI] = address_opt.map(NodeURI(nodeId, _)) + } + object Connect { + def apply(uri: NodeURI): Connect = new Connect(uri.nodeId, Some(uri.address)) + } + case object Reconnect + case class Disconnect(nodeId: PublicKey) + case object SendPing + case class PeerInfo(nodeId: PublicKey, state: String, address: Option[InetSocketAddress], channels: Int) + + case class PeerRoutingMessage(transport: ActorRef, remoteNodeId: PublicKey, message: RoutingMessage) + + case class DelayedRebroadcast(rebroadcast: Rebroadcast) + + sealed trait BadMessage + case class InvalidSignature(r: RoutingMessage) extends BadMessage + case class InvalidAnnouncement(c: ChannelAnnouncement) extends BadMessage + case class ChannelClosed(c: ChannelAnnouncement) extends BadMessage + + case class Behavior(fundingTxAlreadySpentCount: Int = 0, fundingTxNotFoundCount: Int = 0, ignoreNetworkAnnouncement: Boolean = false) + + sealed trait ChannelId { def id: ByteVector32 } + case class TemporaryChannelId(id: ByteVector32) extends ChannelId + case class FinalChannelId(id: ByteVector32) extends ChannelId + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 4c52c55407..35c2a40727 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -66,7 +66,6 @@ class EclairImplSpec extends TestKit(ActorSystem("test")) with fixture.FunSuiteL switchboard.ref, paymentInitiator.ref, server.ref, - authenticator.ref, new TestWallet() ) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala index f825376a6f..e1fcbe16e6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala @@ -91,11 +91,6 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods with val bobAliceReestablish = bob2alice.expectMsgType[ChannelReestablish] val nodeParams = TestConstants.Alice.nodeParams - val authenticator = TestProbe() - val router = TestProbe() - val switchboard = TestProbe() - val watcher = TestProbe() - val relayer = TestProbe() val remotePeer = TestProbe() val remotePeerId = TestConstants.Bob.nodeParams.nodeId @@ -110,7 +105,7 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods with } } - val recoveryFSM = TestFSMRef(new RecoveryFSM(nodeParams, authenticator.ref, router.ref, switchboard.ref, new TestWallet, watcher.ref, relayer.ref, bitcoinRpcClient)) + val recoveryFSM = TestFSMRef(new RecoveryFSM(nodeParams, new TestWallet, bitcoinRpcClient)) recoveryFSM.setState(RECOVERY_WAIT_FOR_CONNECTION, DATA_WAIT_FOR_CONNECTION(remotePeerId)) // skip peer connection diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala index 6921b56659..1d3fdac526 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala @@ -18,13 +18,12 @@ package fr.acinq.eclair import java.io.File -import akka.actor.{ActorRef, ActorSystem, Props, SupervisorStrategy} +import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.stream.{ActorMaterializer, BindFailedException} -import com.typesafe.config.{Config, ConfigFactory} +import com.typesafe.config.Config import fr.acinq.eclair.api.Service -import fr.acinq.eclair.blockchain.EclairWallet -import fr.acinq.eclair.recovery.{RecoverySwitchBoard, RecoveryTool} +import fr.acinq.eclair.recovery.RecoveryTool import grizzled.slf4j.Logging import kamon.Kamon @@ -43,17 +42,7 @@ object Boot extends App with Logging { plugins.foreach(plugin => logger.info(s"loaded plugin ${plugin.getClass.getSimpleName}")) implicit val system: ActorSystem = ActorSystem("eclair-node") implicit val ec: ExecutionContext = system.dispatcher - - val setup = if(ConfigFactory.load().hasPath("eclair.recovery-tool")){ - new Setup(datadir) { - logger.info(s"recovery mode enabled") - override def getSwitchboard(authenticator: ActorRef, watcher: ActorRef, router: ActorRef, relayer: ActorRef, wallet: EclairWallet): ActorRef = { - system.actorOf(SimpleSupervisor.props(Props(new RecoverySwitchBoard(nodeParams, authenticator, watcher, router, relayer, wallet)), "recovery-switchboard", SupervisorStrategy.Resume)) - } - } - } else { - new Setup(datadir) - } + val setup = new Setup(datadir) if (setup.config.getBoolean("enable-kamon")) { Kamon.init(setup.appConfig) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala index 60486d59fd..c93ab7d2d3 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala @@ -28,7 +28,7 @@ object RecoveryTool extends Logging { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.authenticator, appKit.router, appKit.switchboard, appKit.wallet, appKit.watcher, appKit.relayer, bitcoinRpcClient), RecoveryFSM.actorName) + val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.wallet, bitcoinRpcClient), RecoveryFSM.actorName) recoveryFSM ! RecoveryConnect(nodeUri) } From a087151c1485284abd731227d887d11d3bd12cd2 Mon Sep 17 00:00:00 2001 From: Andrea Date: Fri, 8 Nov 2019 14:48:35 +0100 Subject: [PATCH 23/26] Remove unused INSTANTIATING state from RecoveryPeer --- .../acinq/eclair/recovery/RecoveryFSM.scala | 1 - .../acinq/eclair/recovery/RecoveryPeer.scala | 50 ++----------------- 2 files changed, 4 insertions(+), 47 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 9b7906960c..907b297a17 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -38,7 +38,6 @@ class RecoveryFSM(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCCl case Event(RecoveryConnect(nodeURI: NodeURI), Nothing) => logger.info(s"creating new recovery peer") val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId))) - peer ! RecoveryPeer.Init(previousKnownAddress = None, storedChannels = Set.empty) peer ! RecoveryPeer.Connect(nodeURI.nodeId, Some(nodeURI.address)) stay using DATA_WAIT_FOR_CONNECTION(nodeURI.nodeId) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala index c0dae323b2..4d7b148aeb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala @@ -7,7 +7,6 @@ import akka.event.Logging.MDC import com.google.common.net.HostAndPort import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Crypto.PublicKey -import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.TransportHandler import fr.acinq.eclair.io.Authenticator.{Authenticated, PendingAuth} import fr.acinq.eclair.io._ @@ -29,14 +28,7 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends val authenticator = context.system.actorOf(SimpleSupervisor.props(Authenticator.props(nodeParams), "authenticator", SupervisorStrategy.Resume)) authenticator ! self // register this actor as the receiver of the authentication handshake - startWith(INSTANTIATING, RecoveryPeer.Nothing()) - - when(INSTANTIATING) { - case Event(Init(previousKnownAddress, _), _) => - val channels = Map.empty[FinalChannelId, ActorRef] - val firstNextReconnectionDelay = nodeParams.maxReconnectInterval.minus(Random.nextInt(nodeParams.maxReconnectInterval.toSeconds.toInt / 2).seconds) - goto(DISCONNECTED) using DisconnectedData(previousKnownAddress, channels, firstNextReconnectionDelay) // when we restart, we will attempt to reconnect right away, but then we'll wait - } + startWith(DISCONNECTED, DisconnectedData(address_opt = None, channels = Map.empty, nextReconnectionDelay = nodeParams.maxReconnectInterval)) when(DISCONNECTED) { case Event(p: PendingAuth, _) => @@ -239,18 +231,12 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends } whenUnhandled { - case Event(_: Peer.Connect, _) => + case Event(_: Connect, _) => sender ! "already connected" stay - case Event(_: Peer.OpenChannel, _) => - sender ! Status.Failure(new RuntimeException("not connected")) - stay - case Event(_: Rebroadcast, _) => stay // ignored - case Event(_: DelayedRebroadcast, _) => stay // ignored - case Event(_: RoutingState, _) => stay // ignored case Event(_: TransportHandler.ReadAck, _) => stay // ignored @@ -262,20 +248,6 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends case Event(_: Pong, _) => stay // we got disconnected before receiving the pong case Event(_: PingTimeout, _) => stay // we got disconnected after sending a ping - - case Event(_: BadMessage, _) => stay // we got disconnected while syncing - } - - /** - * The transition INSTANTIATING -> DISCONNECTED happens in 2 scenarios - * - Manual connection to a new peer: then when(DISCONNECTED) we expect a Peer.Connect from the switchboard - * - Eclair restart: The switchboard creates the peers and sends Init and then Peer.Reconnect to trigger reconnection attempts - * - * So when we see this transition we NO-OP because we don't want to start a Reconnect timer but the peer will receive the trigger - * (Connect/Reconnect) messages from the switchboard. - */ - onTransition { - case INSTANTIATING -> DISCONNECTED => () } onTransition { @@ -307,22 +279,19 @@ object RecoveryPeer { sealed trait Data { def address_opt: Option[InetSocketAddress] - def channels: Map[_ <: ChannelId, ActorRef] // will be overridden by Map[FinalChannelId, ActorRef] or Map[ChannelId, ActorRef] } - case class Nothing() extends Data { override def address_opt = None; override def channels = Map.empty } + case class Nothing() extends Data { override def address_opt = None } case class DisconnectedData(address_opt: Option[InetSocketAddress], channels: Map[FinalChannelId, ActorRef], nextReconnectionDelay: FiniteDuration = 10 seconds) extends Data case class InitializingData(address_opt: Option[InetSocketAddress], transport: ActorRef, channels: Map[FinalChannelId, ActorRef], origin_opt: Option[ActorRef], localInit: wire.Init) extends Data - case class ConnectedData(address_opt: Option[InetSocketAddress], transport: ActorRef, localInit: wire.Init, remoteInit: wire.Init, channels: Map[ChannelId, ActorRef], rebroadcastDelay: FiniteDuration, gossipTimestampFilter: Option[GossipTimestampFilter] = None, behavior: Behavior = Behavior(), expectedPong_opt: Option[ExpectedPong] = None) extends Data + case class ConnectedData(address_opt: Option[InetSocketAddress], transport: ActorRef, localInit: wire.Init, remoteInit: wire.Init, channels: Map[ChannelId, ActorRef], rebroadcastDelay: FiniteDuration, gossipTimestampFilter: Option[GossipTimestampFilter] = None, expectedPong_opt: Option[ExpectedPong] = None) extends Data case class ExpectedPong(ping: Ping, timestamp: Long = Platform.currentTime) case class PingTimeout(ping: Ping) sealed trait State - case object INSTANTIATING extends State case object DISCONNECTED extends State case object INITIALIZING extends State case object CONNECTED extends State - case class Init(previousKnownAddress: Option[InetSocketAddress], storedChannels: Set[HasCommitments]) case class Connect(nodeId: PublicKey, address_opt: Option[HostAndPort]) { def uri: Option[NodeURI] = address_opt.map(NodeURI(nodeId, _)) } @@ -334,17 +303,6 @@ object RecoveryPeer { case object SendPing case class PeerInfo(nodeId: PublicKey, state: String, address: Option[InetSocketAddress], channels: Int) - case class PeerRoutingMessage(transport: ActorRef, remoteNodeId: PublicKey, message: RoutingMessage) - - case class DelayedRebroadcast(rebroadcast: Rebroadcast) - - sealed trait BadMessage - case class InvalidSignature(r: RoutingMessage) extends BadMessage - case class InvalidAnnouncement(c: ChannelAnnouncement) extends BadMessage - case class ChannelClosed(c: ChannelAnnouncement) extends BadMessage - - case class Behavior(fundingTxAlreadySpentCount: Int = 0, fundingTxNotFoundCount: Int = 0, ignoreNetworkAnnouncement: Boolean = false) - sealed trait ChannelId { def id: ByteVector32 } case class TemporaryChannelId(id: ByteVector32) extends ChannelId case class FinalChannelId(id: ByteVector32) extends ChannelId From 9d4a9d9a0f7e7ca2307558307305479bfeaa3b42 Mon Sep 17 00:00:00 2001 From: Andrea Date: Fri, 8 Nov 2019 16:59:00 +0100 Subject: [PATCH 24/26] Remove Ping/Pong from RecoveryPeer --- .../acinq/eclair/recovery/RecoveryFSM.scala | 9 +- .../acinq/eclair/recovery/RecoveryPeer.scala | 180 +++--------------- 2 files changed, 31 insertions(+), 158 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 907b297a17..9455fc1caf 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -1,7 +1,7 @@ package fr.acinq.eclair.recovery -import akka.actor.{ActorRef, ActorSelection, FSM, Props} +import akka.actor.{ActorRef, FSM, Props} import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, ByteVector32, OP_0, OP_2, OP_CHECKMULTISIG, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHDATA, OutPoint, Script, ScriptWitness, Transaction} import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.eclair.NodeParams @@ -38,7 +38,7 @@ class RecoveryFSM(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCCl case Event(RecoveryConnect(nodeURI: NodeURI), Nothing) => logger.info(s"creating new recovery peer") val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId))) - peer ! RecoveryPeer.Connect(nodeURI.nodeId, Some(nodeURI.address)) + peer ! Peer.Connect(nodeURI.nodeId, Some(nodeURI.address)) stay using DATA_WAIT_FOR_CONNECTION(nodeURI.nodeId) case Event(PeerConnected(peer, nodeId), d: DATA_WAIT_FOR_CONNECTION) if d.remoteNodeId == nodeId => @@ -52,13 +52,13 @@ class RecoveryFSM(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCCl lookupFundingTx(channelId) match { case None => logger.info(s"could not find funding transaction...disconnecting") - d.peer ! Disconnect + d.peer ! Disconnect(d.remoteNodeId) stop() case Some((fundingTx, outIndex)) => logger.info(s"found unspent channel funding_tx=${fundingTx.txid} outputIndex=$outIndex") logger.info(s"asking remote to close the channel") - d.peer ! Error(channelId, PleasePublishYourCommitment(channelId).toString) + d.peer ! SendErrorToRemote(Error(channelId, PleasePublishYourCommitment(channelId).toString)) context.system.scheduler.scheduleOnce(5 seconds)(self ! CheckCommitmentPublished) goto(RECOVERY_WAIT_FOR_COMMIT_PUBLISHED) using DATA_WAIT_FOR_REMOTE_PUBLISH(d.peer, reestablish, fundingTx, outIndex) } @@ -188,7 +188,6 @@ object RecoveryFSM { case object CheckClaimPublished extends Event // formatter: on - // extract our funding pubkey from witness def recoverFundingKeyFromCommitment(nodeParams: NodeParams, commitTx: Transaction, channelReestablish: ChannelReestablish): PublicKey = { val (key1, key2) = extractKeysFromWitness(commitTx.txIn.head.witness, channelReestablish) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala index 4d7b148aeb..54b67b5eaa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala @@ -17,10 +17,6 @@ import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelReestablish, GossipTime import fr.acinq.eclair.{FSMDiagnosticActorLogging, Features, Logs, NodeParams, SimpleSupervisor, wire} import scodec.bits.ByteVector -import scala.compat.Platform -import scala.concurrent.duration._ -import scala.util.Random - class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends FSMDiagnosticActorLogging[RecoveryPeer.State, RecoveryPeer.Data] { def recoveryFSM: ActorSelection = context.system.actorSelection(context.system / RecoveryFSM.actorName) @@ -28,19 +24,20 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends val authenticator = context.system.actorOf(SimpleSupervisor.props(Authenticator.props(nodeParams), "authenticator", SupervisorStrategy.Resume)) authenticator ! self // register this actor as the receiver of the authentication handshake - startWith(DISCONNECTED, DisconnectedData(address_opt = None, channels = Map.empty, nextReconnectionDelay = nodeParams.maxReconnectInterval)) + startWith(DISCONNECTED, DisconnectedData(address_opt = None)) when(DISCONNECTED) { + // sent by Client after establishing a TCP connection case Event(p: PendingAuth, _) => authenticator ! p stay - case Event(RecoveryPeer.Connect(_, Some(address)), d: DisconnectedData) => + case Event(Peer.Connect(_, Some(address)), d: DisconnectedData) => val inetAddress = Peer.hostAndPort2InetSocketAddress(address) context.actorOf(Client.props(nodeParams, self, inetAddress, remoteNodeId, origin_opt = Some(sender()))) stay using d.copy(address_opt = Some(inetAddress)) - case Event(Authenticated(_, transport, remoteNodeId1, address, outgoing, origin_opt), d: DisconnectedData) => + case Event(Authenticated(_, transport, remoteNodeId1, address, _, origin_opt), d: DisconnectedData) => require(remoteNodeId == remoteNodeId1, s"invalid nodeid: $remoteNodeId != $remoteNodeId1") log.debug(s"got authenticated connection to $remoteNodeId@${address.getHostString}:${address.getPort}") transport ! TransportHandler.Listener(self) @@ -52,46 +49,23 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends log.info(s"using globalFeatures=${localInit.globalFeatures.toBin} and localFeatures=${localInit.localFeatures.toBin}") transport ! localInit - val address_opt = if (outgoing) { - // we store the node address upon successful outgoing connection, so we can reconnect later - // any previous address is overwritten - NodeAddress.fromParts(address.getHostString, address.getPort).map(nodeAddress => nodeParams.db.peers.addOrUpdatePeer(remoteNodeId, nodeAddress)) - Some(address) - } else None - - goto(INITIALIZING) using InitializingData(address_opt, transport, d.channels, origin_opt, localInit) + goto(INITIALIZING) using InitializingData(Some(address), transport, origin_opt, localInit) } when(INITIALIZING) { case Event(remoteInit: wire.Init, d: InitializingData) => d.transport ! TransportHandler.ReadAck(remoteInit) log.info(s"peer is using globalFeatures=${remoteInit.globalFeatures.toBin} and localFeatures=${remoteInit.localFeatures.toBin}") - - if (Features.areSupported(remoteInit.localFeatures)) { - d.origin_opt.foreach(origin => origin ! "connected") - val rebroadcastDelay = Random.nextInt(nodeParams.routerConf.routerBroadcastInterval.toSeconds.toInt).seconds - log.info(s"rebroadcast will be delayed by $rebroadcastDelay") - goto(CONNECTED) using ConnectedData(d.address_opt, d.transport, d.localInit, remoteInit, d.channels.map { case (k: ChannelId, v) => (k, v) }, rebroadcastDelay) forMax (30 seconds) // forMax will trigger a StateTimeout - } else { - log.warning(s"incompatible features, disconnecting") - d.origin_opt.foreach(origin => origin ! Status.Failure(new RuntimeException("incompatible features"))) - d.transport ! PoisonPill - stay + if(!Features.areSupported(remoteInit.localFeatures)) { + log.warning(s"peer has unsupported features, continuing anyway") } - - case Event(Authenticated(connection, _, _, _, _, origin_opt), _) => - // two connections in parallel - origin_opt.foreach(origin => origin ! Status.Failure(new RuntimeException("there is another connection attempt in progress"))) - // we kill this one - log.warning(s"killing parallel connection $connection") - connection ! PoisonPill - stay + goto(CONNECTED) using ConnectedData(d.address_opt, d.transport, d.localInit, remoteInit) case Event(Terminated(actor), d: InitializingData) if actor == d.transport => log.warning(s"lost connection to $remoteNodeId") - goto(DISCONNECTED) using DisconnectedData(d.address_opt, d.channels) + goto(DISCONNECTED) using DisconnectedData(d.address_opt) - case Event(Disconnect(nodeId), d: InitializingData) if nodeId == remoteNodeId => + case Event(Peer.Disconnect(nodeId), d: InitializingData) if nodeId == remoteNodeId => log.info("disconnecting") sender ! "disconnecting" d.transport ! PoisonPill @@ -116,88 +90,27 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends // when recovering we don't immediately reply channel_reestablish/error stay - case Event(StateTimeout, _: ConnectedData) => - // the first ping is sent after the connection has been quiet for a while - // we don't want to send pings right after connection, because peer will be syncing and may not be able to - // answer to our ping quickly enough, which will make us close the connection - log.debug(s"no messages sent/received for a while, start sending pings") - self ! SendPing - setStateTimeout(CONNECTED, None) // cancels the state timeout (it will be reset with forMax) - stay - - case Event(SendPing, d: ConnectedData) => - if (d.expectedPong_opt.isEmpty) { - // no need to use secure random here - val pingSize = Random.nextInt(1000) - val pongSize = Random.nextInt(1000) - val ping = wire.Ping(pongSize, ByteVector.fill(pingSize)(0)) - setTimer(PingTimeout.toString, PingTimeout(ping), nodeParams.pingTimeout, repeat = false) - d.transport ! ping - stay using d.copy(expectedPong_opt = Some(ExpectedPong(ping))) - } else { - log.warning(s"can't send ping, already have one in flight") - stay - } - case Event(PingTimeout(ping), d: ConnectedData) => - if (nodeParams.pingDisconnect) { - log.warning(s"no response to ping=$ping, closing connection") - d.transport ! PoisonPill - } else { - log.warning(s"no response to ping=$ping (ignored)") - } - stay - - case Event(ping@wire.Ping(pongLength, _), d: ConnectedData) => - d.transport ! TransportHandler.ReadAck(ping) - if (pongLength <= 65532) { - // see BOLT 1: we reply only if requested pong length is acceptable - d.transport ! wire.Pong(ByteVector.fill(pongLength)(0.toByte)) - } else { - log.warning(s"ignoring invalid ping with pongLength=${ping.pongLength}") - } - stay - - case Event(pong@wire.Pong(data), d: ConnectedData) => - d.transport ! TransportHandler.ReadAck(pong) - d.expectedPong_opt match { - case Some(ExpectedPong(ping, timestamp)) if ping.pongLength == data.length => - // we use the pong size to correlate between pings and pongs - val latency = Platform.currentTime - timestamp - log.debug(s"received pong with latency=$latency") - cancelTimer(PingTimeout.toString()) - // pings are sent periodically with some randomization - val nextDelay = nodeParams.pingInterval + Random.nextInt(10).seconds - setTimer(SendPing.toString, SendPing, nextDelay, repeat = false) - case None => - log.debug(s"received unexpected pong with size=${data.length}") - } - stay using d.copy(expectedPong_opt = None) - - case Event(err@wire.Error(channelId, reason), d: ConnectedData) => + case Event(err@wire.Error(channelId, reason), d: ConnectedData) if channelId == CHANNELID_ZERO => d.transport ! TransportHandler.ReadAck(err) log.error(s"connection-level error! channelId=$channelId reason=${new String(reason.toArray)}") - d.transport ! err - stay - - case Event(msg: wire.OpenChannel, d: ConnectedData) => - d.transport ! TransportHandler.ReadAck(msg) - log.info(s"peer sent us OpenChannel") - stay + d.transport ! wire.Error(err.channelId, UNKNOWN_CHANNEL_MESSAGE) + d.transport ! PoisonPill + goto(DISCONNECTED) using DisconnectedData(None) case Event(msg: wire.HasChannelId, d: ConnectedData) => d.transport ! TransportHandler.ReadAck(msg) - log.info(s"received $msg from $remoteNodeId") + log.info(s"received ${msg.getClass.getSimpleName} from $remoteNodeId") stay case Event(msg: wire.HasTemporaryChannelId, d: ConnectedData) => d.transport ! TransportHandler.ReadAck(msg) - log.info(s"received $msg from $remoteNodeId") + log.info(s"received ${msg.getClass.getSimpleName} from $remoteNodeId") stay - case Event(msg: wire.RoutingMessage, d: ConnectedData) => - log.info(s"peer sent us a $msg") - // ACK and do nothing + case Event(msg: wire.RoutingMessage, _) => + log.info(s"peer sent us a ${msg.getClass.getSimpleName}") + // ACK and do nothing, we're in recovery mode sender ! TransportHandler.ReadAck(msg) stay @@ -206,7 +119,7 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends d.transport forward readAck stay - case Event(Disconnect(nodeId), d: ConnectedData) if nodeId == remoteNodeId => + case Event(Peer.Disconnect(nodeId), d: ConnectedData) if nodeId == remoteNodeId => log.info(s"disconnecting") sender ! "disconnecting" d.transport ! PoisonPill @@ -221,7 +134,7 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends context unwatch d.transport d.transport ! PoisonPill self ! h - goto(DISCONNECTED) using DisconnectedData(d.address_opt, d.channels.collect { case (k: FinalChannelId, v) => (k, v) }) + goto(DISCONNECTED) using DisconnectedData(d.address_opt) case Event(unhandledMsg: LightningMessage, d: ConnectedData) => // we ack unhandled messages because we don't want to block further reads on the connection @@ -230,26 +143,6 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends stay } - whenUnhandled { - case Event(_: Connect, _) => - sender ! "already connected" - stay - - case Event(_: Rebroadcast, _) => stay // ignored - - case Event(_: RoutingState, _) => stay // ignored - - case Event(_: TransportHandler.ReadAck, _) => stay // ignored - - case Event(Peer.Reconnect, _) => stay // we got connected in the meantime - - case Event(SendPing, _) => stay // we got disconnected in the meantime - - case Event(_: Pong, _) => stay // we got disconnected before receiving the pong - - case Event(_: PingTimeout, _) => stay // we got disconnected after sending a ping - } - onTransition { case _ -> CONNECTED => context.system.eventStream.publish(PeerConnected(self, remoteNodeId)) @@ -275,36 +168,17 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends object RecoveryPeer { + val CHANNELID_ZERO = ByteVector32.Zeroes + val UNKNOWN_CHANNEL_MESSAGE = ByteVector.view("unknown channel".getBytes()) - sealed trait Data { - def address_opt: Option[InetSocketAddress] - } - case class Nothing() extends Data { override def address_opt = None } - case class DisconnectedData(address_opt: Option[InetSocketAddress], channels: Map[FinalChannelId, ActorRef], nextReconnectionDelay: FiniteDuration = 10 seconds) extends Data - case class InitializingData(address_opt: Option[InetSocketAddress], transport: ActorRef, channels: Map[FinalChannelId, ActorRef], origin_opt: Option[ActorRef], localInit: wire.Init) extends Data - case class ConnectedData(address_opt: Option[InetSocketAddress], transport: ActorRef, localInit: wire.Init, remoteInit: wire.Init, channels: Map[ChannelId, ActorRef], rebroadcastDelay: FiniteDuration, gossipTimestampFilter: Option[GossipTimestampFilter] = None, expectedPong_opt: Option[ExpectedPong] = None) extends Data - case class ExpectedPong(ping: Ping, timestamp: Long = Platform.currentTime) - case class PingTimeout(ping: Ping) + sealed trait Data + case class DisconnectedData(address_opt: Option[InetSocketAddress]) extends Data + case class InitializingData(address_opt: Option[InetSocketAddress], transport: ActorRef, origin_opt: Option[ActorRef], localInit: wire.Init) extends Data + case class ConnectedData(address_opt: Option[InetSocketAddress], transport: ActorRef, localInit: wire.Init, remoteInit: wire.Init) extends Data sealed trait State case object DISCONNECTED extends State case object INITIALIZING extends State case object CONNECTED extends State - - case class Connect(nodeId: PublicKey, address_opt: Option[HostAndPort]) { - def uri: Option[NodeURI] = address_opt.map(NodeURI(nodeId, _)) - } - object Connect { - def apply(uri: NodeURI): Connect = new Connect(uri.nodeId, Some(uri.address)) - } - case object Reconnect - case class Disconnect(nodeId: PublicKey) - case object SendPing - case class PeerInfo(nodeId: PublicKey, state: String, address: Option[InetSocketAddress], channels: Int) - - sealed trait ChannelId { def id: ByteVector32 } - case class TemporaryChannelId(id: ByteVector32) extends ChannelId - case class FinalChannelId(id: ByteVector32) extends ChannelId - } From 834f924666e852b233770bc8e628ea2cc2108f33 Mon Sep 17 00:00:00 2001 From: Andrea Date: Fri, 8 Nov 2019 17:14:17 +0100 Subject: [PATCH 25/26] Put nodeURI in RecoveryFSM fields, do not wait to receive a RecoveryConnect message, always fill the nodeId in Disconnect messages --- .../main/scala/fr/acinq/eclair/Eclair.scala | 3 +-- .../acinq/eclair/recovery/RecoveryFSM.scala | 19 +++++++++---------- .../acinq/eclair/recovery/RecoveryPeer.scala | 2 -- .../eclair/recovery/RecoveryFSMSpec.scala | 8 +++++--- .../acinq/eclair/recovery/RecoveryTool.scala | 3 +-- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index ec5996ac05..9169e139df 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -306,7 +306,6 @@ class EclairImpl(appKit: Kit) extends Eclair { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.wallet, bitcoinRpcClient), RecoveryFSM.actorName) - recoveryFSM ! RecoveryConnect(uri) + appKit.system.actorOf(RecoveryFSM.props(uri, appKit.nodeParams, appKit.wallet, bitcoinRpcClient), RecoveryFSM.actorName) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 9455fc1caf..7691abc586 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -7,10 +7,10 @@ import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient} -import fr.acinq.eclair.channel.{HasCommitments, Helpers, PleasePublishYourCommitment} -import fr.acinq.eclair.crypto.{Generators, KeyManager, TransportHandler} -import fr.acinq.eclair.io.Peer.{ConnectedData, Disconnect} -import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected, Switchboard} +import fr.acinq.eclair.channel.{Helpers, PleasePublishYourCommitment} +import fr.acinq.eclair.crypto.{Generators, KeyManager} +import fr.acinq.eclair.io.Peer.Disconnect +import fr.acinq.eclair.io.{NodeURI, Peer, PeerConnected} import fr.acinq.eclair.recovery.RecoveryFSM._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire._ @@ -21,7 +21,7 @@ import scala.concurrent.{Await, Future} import scala.concurrent.duration._ import scala.util.Success -class RecoveryFSM(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends FSM[State, Data] with Logging { +class RecoveryFSM(remoteNodeURI: NodeURI, nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCClient: BitcoinJsonRPCClient) extends FSM[State, Data] with Logging { implicit val ec = context.system.dispatcher val bitcoinClient = new ExtendedBitcoinClient(bitcoinJsonRPCClient) @@ -31,10 +31,9 @@ class RecoveryFSM(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCCl startWith(RECOVERY_WAIT_FOR_CONNECTION, Nothing) - when(RECOVERY_WAIT_FOR_CONNECTION) { - case Event('connected, _) => - stay + self ! RecoveryConnect(remoteNodeURI) + when(RECOVERY_WAIT_FOR_CONNECTION) { case Event(RecoveryConnect(nodeURI: NodeURI), Nothing) => logger.info(s"creating new recovery peer") val peer = context.actorOf(Props(new RecoveryPeer(nodeParams, nodeURI.nodeId))) @@ -97,7 +96,7 @@ class RecoveryFSM(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCCl Await.ready(bitcoinClient.getTransaction(d.claimTx.txid.toHex), 30 seconds).value match { case Some(Success(claimTx)) => logger.info(s"claim transaction published txid=${claimTx.txid}") - d.peer ! Disconnect + d.peer ! Disconnect(remoteNodeURI.nodeId) stop() case _ => @@ -164,7 +163,7 @@ object RecoveryFSM { val actorName = "recovery-fsm-actor" - def props(nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCClient: BitcoinJsonRPCClient) = Props(new RecoveryFSM(nodeParams, wallet, bitcoinJsonRPCClient)) + def props(nodeURI: NodeURI, nodeParams: NodeParams, wallet: EclairWallet, bitcoinJsonRPCClient: BitcoinJsonRPCClient) = Props(new RecoveryFSM(nodeURI, nodeParams, wallet, bitcoinJsonRPCClient)) // formatter: off sealed trait State diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala index 54b67b5eaa..b9e20e3823 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryPeer.scala @@ -67,7 +67,6 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends case Event(Peer.Disconnect(nodeId), d: InitializingData) if nodeId == remoteNodeId => log.info("disconnecting") - sender ! "disconnecting" d.transport ! PoisonPill stay @@ -121,7 +120,6 @@ class RecoveryPeer(val nodeParams: NodeParams, remoteNodeId: PublicKey) extends case Event(Peer.Disconnect(nodeId), d: ConnectedData) if nodeId == remoteNodeId => log.info(s"disconnecting") - sender ! "disconnecting" d.transport ! PoisonPill stay diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala index e1fcbe16e6..791559d8bd 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/recovery/RecoveryFSMSpec.scala @@ -5,11 +5,12 @@ import fr.acinq.eclair.{TestConstants, TestkitBaseClass} import fr.acinq.eclair.channel.{CMD_SIGN, Channel, DATA_NORMAL, Data, INPUT_DISCONNECTED, INPUT_RECONNECTED, NORMAL, OFFLINE, State} import fr.acinq.eclair.channel.states.StateTestsHelperMethods import RecoveryFSM._ +import com.google.common.net.HostAndPort import fr.acinq.bitcoin.Transaction import fr.acinq.eclair import fr.acinq.eclair.blockchain.TestWallet import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCClient -import fr.acinq.eclair.io.PeerConnected +import fr.acinq.eclair.io.{NodeURI, PeerConnected} import fr.acinq.eclair.wire.{ChannelReestablish, CommitSig, Init, RevokeAndAck} import org.json4s.JsonAST import org.json4s.JsonAST.{JNull, JObject, JString} @@ -105,7 +106,7 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods with } } - val recoveryFSM = TestFSMRef(new RecoveryFSM(nodeParams, new TestWallet, bitcoinRpcClient)) + val recoveryFSM = TestFSMRef(new RecoveryFSM(NodeURI(remotePeerId, HostAndPort.fromHost("localhost")), nodeParams, new TestWallet, bitcoinRpcClient)) recoveryFSM.setState(RECOVERY_WAIT_FOR_CONNECTION, DATA_WAIT_FOR_CONNECTION(remotePeerId)) // skip peer connection @@ -116,7 +117,8 @@ class RecoveryFSMSpec extends TestkitBaseClass with StateTestsHelperMethods with probe.send(recoveryFSM, ChannelFound(channelId, bobAliceReestablish)) // the recovery FSM replies with an error asking the remote to publish its commitment - remotePeer.expectMsgType[eclair.wire.Error] + val sendError = remotePeer.expectMsgType[SendErrorToRemote] + assert(sendError.error.toAscii.contains("please publish your local commitment")) awaitCond(recoveryFSM.stateName == RECOVERY_WAIT_FOR_COMMIT_PUBLISHED) } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala index c93ab7d2d3..6f0eba5d9e 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/recovery/RecoveryTool.scala @@ -28,8 +28,7 @@ object RecoveryTool extends Logging { port = appKit.nodeParams.config.getInt("bitcoind.rpcport") ) - val recoveryFSM = appKit.system.actorOf(RecoveryFSM.props(appKit.nodeParams, appKit.wallet, bitcoinRpcClient), RecoveryFSM.actorName) - recoveryFSM ! RecoveryConnect(nodeUri) + appKit.system.actorOf(RecoveryFSM.props(nodeUri, appKit.nodeParams, appKit.wallet, bitcoinRpcClient), RecoveryFSM.actorName) } private def getInput[T](msg: String, parse: String => T): T = { From b917c1086d4c1ba5d12ed1894a2bee3e7bb93d3f Mon Sep 17 00:00:00 2001 From: Andrea Date: Tue, 19 Nov 2019 10:26:22 +0100 Subject: [PATCH 26/26] Use reversed txid when matching on outpoints in transactions from mempool, improve log entry --- .../src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala index 7691abc586..b8537b1503 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/recovery/RecoveryFSM.scala @@ -80,7 +80,7 @@ class RecoveryFSM(remoteNodeURI: NodeURI, nodeParams: NodeParams, wallet: Eclair val claimTx = Transactions.makeClaimP2WPKHOutputTx(commitTx, nodeParams.dustLimit, localPaymentKey, finalScriptPubkey, nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(6)) val sig = nodeParams.keyManager.sign(claimTx, paymentBasePoint, remotePerCommitmentSecret) val claimSigned = Transactions.addSigs(claimTx, localPaymentKey, sig) - logger.info(s"publishing claim-main-output transaction address=${scriptPubKeyToAddress(finalScriptPubkey)} txid=${claimSigned.tx.txid}") + logger.info(s"publishing claim-main-output transaction: address=${scriptPubKeyToAddress(finalScriptPubkey)} txid=${claimSigned.tx.txid}") bitcoinClient.publishTransaction(claimSigned.tx) context.system.scheduler.scheduleOnce(CHECK_POLL_INTERVAL)(self ! CheckClaimPublished) goto(RECOVERY_WAIT_FOR_CLAIM_PUBLISHED) using DATA_WAIT_FOR_CLAIM_TX(d.peer, claimSigned.tx) @@ -142,7 +142,7 @@ class RecoveryFSM(remoteNodeURI: NodeURI, nodeParams: NodeParams, wallet: Eclair */ def lookForCommitTx(fundingTxId: ByteVector32, fundingOutIndex: Int): Future[Transaction] = { bitcoinClient.getMempool().map { mempoolTxs => - mempoolTxs.find(_.txIn.exists(_.outPoint == OutPoint(fundingTxId, fundingOutIndex))).get + mempoolTxs.find(_.txIn.exists(_.outPoint == OutPoint(fundingTxId.reverse, fundingOutIndex))).get }.recoverWith { case _ => bitcoinClient.lookForSpendingTx(None, fundingTxId.toHex, fundingOutIndex) }