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 207fce90e..83f1e2066 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -24,11 +24,12 @@ import akka.pattern._ import akka.util.Timeout import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, Script, TxId, addressToPublicKeyScript} +import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Crypto, DeterministicWallet, OutPoint, Satoshi, Script, Transaction, TxId, addressToPublicKeyScript} import fr.acinq.eclair.ApiTypes.ChannelNotFound import fr.acinq.eclair.balance.CheckBalance.GlobalBalance import fr.acinq.eclair.balance.{BalanceActor, ChannelsListener} import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchFundingSpentTriggered import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptors, WalletTx} import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw} @@ -67,6 +68,9 @@ case class VerifiedMessage(valid: Boolean, publicKey: PublicKey) case class SendOnionMessageResponsePayload(tlvs: TlvStream[OnionMessagePayloadTlv]) case class SendOnionMessageResponse(sent: Boolean, failureMessage: Option[String], response: Option[SendOnionMessageResponsePayload]) + +case class SpendFromChannelPrep(fundingTxIndex: Long, localFundingPubkey: PublicKey, inputAmount: Satoshi, unsignedTx: Transaction) +case class SpendFromChannelResult(signedTx: Transaction) // @formatter:on case class EnableFromFutureHtlcResponse(enabled: Boolean, failureMessage: Option[String]) @@ -102,6 +106,8 @@ trait Eclair { def forceClose(channels: List[ApiTypes.ChannelIdentifier])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_FORCECLOSE]]]] + def forceCloseResetFundingIndex(channel: ApiTypes.ChannelIdentifier, resetFundingTxIndex: Int)(implicit timeout: Timeout): Future[CommandResponse[CMD_FORCECLOSE]] + def bumpForceCloseFee(channels: List[ApiTypes.ChannelIdentifier], confirmationTarget: ConfirmationTarget)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]]]] def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] @@ -174,6 +180,8 @@ trait Eclair { def globalBalance()(implicit timeout: Timeout): Future[GlobalBalance] + def resetBalance()(implicit timeout: Timeout): Future[Option[GlobalBalance]] + def signMessage(message: ByteVector): SignedMessage def verifyMessage(message: ByteVector, recoverableSignature: ByteVector): VerifiedMessage @@ -193,9 +201,15 @@ trait Eclair { def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse] def stop(): Future[Unit] + + def manualWatchFundingSpent(channelId: ByteVector32, tx: Transaction): TxId + + def spendFromChannelAddressPrep(outPoint: OutPoint, fundingKeyPath: DeterministicWallet.KeyPath, fundingTxIndex: Long, address: String, feerate: FeeratePerKw): Future[SpendFromChannelPrep] + + def spendFromChannelAddress(fundingKeyPath: DeterministicWallet.KeyPath, fundingTxIndex: Long, remoteFundingPubkey: PublicKey, remoteSig: ByteVector64, unsignedTx: Transaction): Future[SpendFromChannelResult] } -class EclairImpl(appKit: Kit) extends Eclair with Logging { +class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChannelAddress { implicit val ec: ExecutionContext = appKit.system.dispatcher implicit val scheduler: Scheduler = appKit.system.scheduler.toTyped @@ -278,6 +292,10 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { sendToChannels(channels, CMD_FORCECLOSE(ActorRef.noSender)) } + override def forceCloseResetFundingIndex(channel: ApiTypes.ChannelIdentifier, resetFundingTxIndex: Int)(implicit timeout: Timeout): Future[CommandResponse[CMD_FORCECLOSE]] = { + sendToChannel[CMD_FORCECLOSE, CommandResponse[CMD_FORCECLOSE]](channel, CMD_FORCECLOSE(ActorRef.noSender, resetFundingTxIndex_opt = Some(resetFundingTxIndex))) + } + override def bumpForceCloseFee(channels: List[ApiTypes.ChannelIdentifier], confirmationTarget: ConfirmationTarget)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]]]] = { sendToChannelsTyped(channels, cmdBuilder = CMD_BUMP_FORCE_CLOSE_FEE(_, confirmationTarget)) } @@ -658,6 +676,10 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { } yield globalBalance } + override def resetBalance()(implicit timeout: Timeout): Future[Option[GlobalBalance]] = { + appKit.balanceActor.ask(res => BalanceActor.ResetBalance(res)) + } + override def signMessage(message: ByteVector): SignedMessage = { val bytesToSign = SignedMessage.signedBytes(message) val (signature, recoveryId) = appKit.nodeParams.nodeKeyManager.signDigest(bytesToSign) @@ -808,4 +830,10 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { sys.exit(0) Future.successful(()) } + + override def manualWatchFundingSpent(channelId: ByteVector32, tx: Transaction): TxId = { + appKit.register ! Register.Forward(null, channelId, WatchFundingSpentTriggered(tx)) + tx.txid + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/SpendFromChannelAddress.scala b/eclair-core/src/main/scala/fr/acinq/eclair/SpendFromChannelAddress.scala new file mode 100644 index 000000000..6b9746883 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/SpendFromChannelAddress.scala @@ -0,0 +1,58 @@ +package fr.acinq.eclair + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector64, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut, addressToPublicKeyScript} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.transactions.Scripts.multiSig2of2 +import fr.acinq.eclair.transactions.{Scripts, Transactions} +import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, InputInfo, PlaceHolderPubKey, PlaceHolderSig, TxOwner} +import scodec.bits.ByteVector + +import scala.concurrent.Future + +trait SpendFromChannelAddress { + + this: EclairImpl => + + /** these dummy witnesses are used as a placeholder to accurately compute the weight */ + private val dummy2of2Witness = Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, PlaceHolderPubKey, PlaceHolderPubKey) + + private def buildTx(outPoint: OutPoint, outputAmount: Satoshi, pubKeyScript: ByteVector, witness: ScriptWitness) = Transaction(2, + txIn = Seq(TxIn(outPoint, ByteVector.empty, 0, witness)), + txOut = Seq(TxOut(outputAmount, pubKeyScript)), + lockTime = 0) + + override def spendFromChannelAddressPrep(outPoint: OutPoint, fundingKeyPath: DeterministicWallet.KeyPath, fundingTxIndex: Long, address: String, feerate: FeeratePerKw): Future[SpendFromChannelPrep] = { + for { + inputTx <- appKit.wallet.getTransaction(outPoint.txid) + inputAmount = inputTx.txOut(outPoint.index.toInt).amount + Right(pubKeyScript) = addressToPublicKeyScript(appKit.nodeParams.chainHash, address).map(Script.write) + // build the tx a first time with a zero amount to compute the weight + fee = Transactions.weight2fee(feerate, buildTx(outPoint, 0.sat, pubKeyScript, dummy2of2Witness).weight()) + _ = assert(inputAmount - fee > Transactions.dustLimit(pubKeyScript), s"amount insufficient (fee=$fee)") + unsignedTx = buildTx(outPoint, inputAmount - fee, pubKeyScript, dummy2of2Witness) + // the following are not used, but need to be sent to the counterparty + localFundingPubkey = appKit.nodeParams.channelKeyManager.fundingPublicKey(fundingKeyPath, fundingTxIndex).publicKey + } yield SpendFromChannelPrep(fundingTxIndex, localFundingPubkey, inputAmount, unsignedTx) + } + + override def spendFromChannelAddress(fundingKeyPath: DeterministicWallet.KeyPath, fundingTxIndex: Long, remoteFundingPubkey: PublicKey, remoteSig: ByteVector64, unsignedTx: Transaction): Future[SpendFromChannelResult] = { + for { + _ <- Future.successful(()) + outPoint = unsignedTx.txIn.head.outPoint + inputTx <- appKit.wallet.getTransaction(outPoint.txid) + localFundingPubkey = appKit.nodeParams.channelKeyManager.fundingPublicKey(fundingKeyPath, fundingTxIndex) + fundingRedeemScript = multiSig2of2(localFundingPubkey.publicKey, remoteFundingPubkey) + inputInfo = InputInfo(outPoint, inputTx.txOut(outPoint.index.toInt), fundingRedeemScript) + localSig = appKit.nodeParams.channelKeyManager.sign( + Transactions.SpliceTx(inputInfo, unsignedTx), // classify as splice, doesn't really matter + localFundingPubkey, + TxOwner.Local, // unused + DefaultCommitmentFormat // unused + ) + witness = Scripts.witness2of2(localSig, remoteSig, localFundingPubkey.publicKey, remoteFundingPubkey) + signedTx = unsignedTx.updateWitness(0, witness) + } yield SpendFromChannelResult(signedTx) + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/balance/BalanceActor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/balance/BalanceActor.scala index c0acb0bf9..cc3237378 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/balance/BalanceActor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/balance/BalanceActor.scala @@ -22,6 +22,7 @@ object BalanceActor { // @formatter:off sealed trait Command + final case class ResetBalance(replyTo: ActorRef[Option[GlobalBalance]]) extends Command private final case object TickBalance extends Command final case class GetGlobalBalance(replyTo: ActorRef[Try[GlobalBalance]], channels: Map[ByteVector32, PersistentChannelData]) extends Command private final case class WrappedChannels(wrapped: ChannelsListener.GetChannelsResponse) extends Command @@ -52,6 +53,12 @@ private class BalanceActor(context: ActorContext[Command], * @return */ def apply(refBalance_opt: Option[GlobalBalance], previousBalance_opt: Option[GlobalBalance]): Behavior[Command] = Behaviors.receiveMessage { + case ResetBalance(replyTo) => + log.info("resetting balance") + // we use the last balance as new reference + val newRefBalance_opt = previousBalance_opt + replyTo ! previousBalance_opt + apply(refBalance_opt = newRefBalance_opt, previousBalance_opt = previousBalance_opt) case TickBalance => log.debug("checking balance...") channelsListener ! ChannelsListener.GetChannels(context.messageAdapter[ChannelsListener.GetChannelsResponse](WrappedChannels)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 3ca5761b1..b3b125597 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -227,7 +227,7 @@ final case class ClosingFeerates(preferred: FeeratePerKw, min: FeeratePerKw, max sealed trait CloseCommand extends HasReplyToCommand final case class CMD_CLOSE(replyTo: ActorRef, scriptPubKey: Option[ByteVector], feerates: Option[ClosingFeerates]) extends CloseCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent -final case class CMD_FORCECLOSE(replyTo: ActorRef) extends CloseCommand +final case class CMD_FORCECLOSE(replyTo: ActorRef, resetFundingTxIndex_opt: Option[Int] = None) extends CloseCommand final case class CMD_BUMP_FORCE_CLOSE_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]], confirmationTarget: ConfirmationTarget) extends Command sealed trait ChannelFundingCommand extends Command { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index ac2ad76d4..776f06a93 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -20,6 +20,7 @@ import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, actorRefAdapter} import akka.actor.{Actor, ActorContext, ActorRef, FSM, OneForOneStrategy, PossiblyHarmful, Props, SupervisorStrategy, typed} import akka.event.Logging.MDC +import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction, TxId} import fr.acinq.eclair.Logs.LogCategory @@ -2626,12 +2627,31 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(c: CMD_FORCECLOSE, d) => d match { - case data: PersistentChannelData => + case data: ChannelDataWithCommitments => val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo - replyTo ! RES_SUCCESS(c, data.channelId) - val failure = ForcedLocalCommit(data.channelId) - handleLocalError(failure, data, Some(c)) - case _: TransientChannelData => + val failure = ForcedLocalCommit(d.channelId) + c.resetFundingTxIndex_opt match { + case Some(resetFundingTxIndex) => + val isActive = data.commitments.active.exists(_.fundingTxIndex == resetFundingTxIndex) + val nextFundingUnconfirmed = data.commitments.active.filter(_.fundingTxIndex > resetFundingTxIndex).forall(_.localFundingStatus.isInstanceOf[LocalFundingStatus.UnconfirmedFundingTx]) + if (isActive && nextFundingUnconfirmed) { + // The commitment hasn't been deactivated yet and more recent funding transactions are unconfirmed, so + // we may try force-closing using this commitment index. Note however that if a more recent funding + // transaction confirms first, our closing attempt will permanently fail, we will have lost data about + // the latest confirmed funding transaction and may not be able to get our funds back. Use with extreme + // caution! + log.warning("force-closing with fundingTxIndex reset to {} (concurrent funding transactions: {})", resetFundingTxIndex, data.commitments.active.filter(_.fundingTxIndex > resetFundingTxIndex).map(_.fundingTxId).mkString(", ")) + replyTo ! RES_SUCCESS(c, data.channelId) + val resetData = data.modify(_.commitments.active).using(_.filter(_.fundingTxIndex <= resetFundingTxIndex)) + handleLocalError(failure, resetData, Some(c)) + } else { + handleCommandError(CommandUnavailableInThisState(d.channelId, "forcecloseresetfundingindex", stateName), c) + } + case None => + replyTo ! RES_SUCCESS(c, data.channelId) + handleLocalError(failure, data, Some(c)) + } + case _ => handleCommandError(CommandUnavailableInThisState(d.channelId, "forceclose", stateName), c) } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala index 52d17f494..bffb9b118 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala @@ -17,8 +17,13 @@ package fr.acinq.eclair.api.handlers import akka.http.scaladsl.server.Route +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, OutPoint, Transaction, TxId} import fr.acinq.eclair.api.Service import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.api.serde.FormParamExtractors._ +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} trait Control { this: Service with EclairDirectives => @@ -29,6 +34,40 @@ trait Control { complete(eclairApi.enableFromFutureHtlc()) } - val controlRoutes: Route = enableFromFutureHtlc + val resetBalance: Route = postRequest("resetbalance") { implicit t => + complete(eclairApi.resetBalance()) + } + + val forceCloseResetFundingIndex: Route = postRequest("forcecloseresetfundingindex") { implicit t => + withChannelIdentifier { channel => + formFields("resetFundingIndex".as[Int]) { + resetFundingIndex => + complete(eclairApi.forceCloseResetFundingIndex(channel, resetFundingIndex)) + } + } + } + + val manualWatchFundingSpent: Route = postRequest("manualwatchfundingspent") { implicit t => + formFields(channelIdFormParam, "tx") { + (channelId, tx) => + complete(eclairApi.manualWatchFundingSpent(channelId, Transaction.read(tx))) + } + } + + val spendFromChannelAddressPrep: Route = postRequest("spendfromchanneladdressprep") { implicit t => + formFields("t".as[ByteVector32], "o".as[Int], "kp", "fi".as[Int], "address", "f".as[FeeratePerByte]) { + (txId, outputIndex, keyPath, fundingTxIndex, address, feerate) => + complete(eclairApi.spendFromChannelAddressPrep(OutPoint(TxId(txId), outputIndex), KeyPath(keyPath), fundingTxIndex, address, FeeratePerKw(feerate))) + } + } + + val spendFromChannelAddress: Route = postRequest("spendfromchanneladdress") { implicit t => + formFields("kp", "fi".as[Int], "p".as[PublicKey], "s".as[ByteVector64], "tx") { + (keyPath, fundingTxIndex, remoteFundingPubkey, remoteSig, unsignedTx) => + complete(eclairApi.spendFromChannelAddress(KeyPath(keyPath), fundingTxIndex, remoteFundingPubkey, remoteSig, Transaction.read(unsignedTx))) + } + } + + val controlRoutes: Route = enableFromFutureHtlc ~ resetBalance ~ forceCloseResetFundingIndex ~ manualWatchFundingSpent ~ spendFromChannelAddressPrep ~ spendFromChannelAddress } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala index e318a82c6..688ead9de 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.api.serde import akka.http.scaladsl.unmarshalling.Unmarshaller import akka.util.Timeout import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, OutPoint, Satoshi, TxId} import fr.acinq.eclair.api.directives.RouteFormat import fr.acinq.eclair.api.serde.JsonSupport._ import fr.acinq.eclair.blockchain.fee.{ConfirmationPriority, FeeratePerByte} @@ -45,6 +45,8 @@ object FormParamExtractors { implicit val bytes32ListUnmarshaller: Unmarshaller[String, List[ByteVector32]] = listUnmarshaller(bin => ByteVector32.fromValidHex(bin)) + implicit val bytes64Unmarshaller: Unmarshaller[String, ByteVector64] = Unmarshaller.strict { bin => ByteVector64.fromValidHex(bin) } + implicit val bolt11Unmarshaller: Unmarshaller[String, Bolt11Invoice] = Unmarshaller.strict { rawRequest => Bolt11Invoice.fromString(rawRequest).get } implicit val shortChannelIdUnmarshaller: Unmarshaller[String, ShortChannelId] = Unmarshaller.strict { str => ShortChannelId.fromCoordinates(str).get }