1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-03-12 10:30:45 +01:00

Add advanced api control methods (#3024)

Those are advanced, unsafe api methods useful for debugging only, and are left purposefully undocumented.
This commit is contained in:
Pierre-Marie Padiou 2025-03-05 11:08:10 +01:00 committed by GitHub
parent c7a288b91f
commit 4729876cac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 164 additions and 10 deletions

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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))

View file

@ -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 {

View file

@ -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)
}

View file

@ -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
}

View file

@ -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 }