Add API methods to spend funds sent to taproot channel addresses (#3220)
What changed, and why it matters
This commit adds new API methods to let users spend funds that were accidentally sent to taproot-style Lightning channel addresses. It extends existing recovery tools to support a newer address format. There is no indication in the commit that this fixes a security vulnerability; it appears to be a feature addition.
No security action required. Treat as a normal feature commit. If reviewing further, verify that the new taproot signing path correctly validates the unsigned transaction and nonce ownership before aggregating signatures.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change extends the existing spendFromChannelAddress* API calls to support taproot channels. It adds MuSig2 nonce handling, a new spendfromtaprootchanneladdress HTTP endpoint, and a form-field unmarshaller for IndividualNonce. The existing non-taproot endpoint remains unchanged. No security bug, bounds issue, or unsafe cryptographic operation is visible in the diff.
Changed components
eclair-core Eclair APIeclair-node Control HTTP APISpendFromChannelAddress traitFormParamExtractorsInspect captured patch +48 / −14
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 1c7ec9a..ceb96c1 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
@@ -23,6 +23,7 @@ import akka.actor.{ActorRef, typed}
import akka.pattern._
import akka.util.Timeout
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
+import fr.acinq.bitcoin.scalacompat.Musig2.IndividualNonce
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
@@ -32,6 +33,7 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchFundingSpentTriggered
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{AddressType, Descriptors, WalletTx}
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw}
+import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
@@ -69,7 +71,7 @@ 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 SpendFromChannelPrep(fundingTxIndex: Long, localFundingPubkey: PublicKey, localNonce_opt: Option[IndividualNonce], inputAmount: Satoshi, unsignedTx: Transaction)
case class SpendFromChannelResult(signedTx: Transaction)
// @formatter:on
@@ -212,7 +214,7 @@ trait Eclair {
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]
+ def spendFromChannelAddress(fundingKeyPath: DeterministicWallet.KeyPath, fundingTxIndex: Long, remoteFundingPubkey: PublicKey, localNonce_opt: Option[IndividualNonce], remoteSig: ChannelSpendSignature, unsignedTx: Transaction): Future[SpendFromChannelResult]
}
class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChannelAddress {
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/SpendFromChannelAddress.scala b/eclair-core/src/main/scala/fr/acinq/eclair/SpendFromChannelAddress.scala
index 5196498..8d7cff2 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/SpendFromChannelAddress.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/SpendFromChannelAddress.scala
@@ -1,7 +1,8 @@
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.bitcoin.scalacompat.Musig2.{IndividualNonce, LocalNonce}
+import fr.acinq.bitcoin.scalacompat.{DeterministicWallet, Musig2, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut, addressToPublicKeyScript}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.{ChannelConfig, ChannelSpendSignature}
import fr.acinq.eclair.transactions.Transactions._
@@ -9,11 +10,15 @@ import fr.acinq.eclair.transactions.{Scripts, Transactions}
import scodec.bits.ByteVector
import scala.concurrent.Future
+import scala.jdk.CollectionConverters.ConcurrentMapHasAsScala
trait SpendFromChannelAddress {
this: EclairImpl =>
+ import java.util.concurrent.ConcurrentHashMap
+ private val nonces = new ConcurrentHashMap[IndividualNonce, LocalNonce]().asScala
+
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)),
@@ -26,27 +31,41 @@ trait SpendFromChannelAddress {
Right(pubKeyScript) = addressToPublicKeyScript(appKit.nodeParams.chainHash, address).map(Script.write)
channelKeys = appKit.nodeParams.channelKeyManager.channelKeys(ChannelConfig.standard, fundingKeyPath)
localFundingPubkey = channelKeys.fundingKey(fundingTxIndex).publicKey
+ isTaproot = Script.isPay2tr(Script.parse(pubKeyScript))
+ (localNonce_opt, dummyWitness) = if (isTaproot) {
+ val serverNonce = Musig2.generateNonce(randomBytes32(), Right(localFundingPubkey), Seq(localFundingPubkey), None, None)
+ nonces.put(serverNonce.publicNonce, serverNonce)
+ Some(serverNonce.publicNonce) -> Script.witnessKeyPathPay2tr(PlaceHolderSig)
+ } else {
+ None -> Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, localFundingPubkey, localFundingPubkey)
+ }
// build the tx a first time with a zero amount to compute the weight
- dummyWitness = Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, localFundingPubkey, localFundingPubkey)
fee = Transactions.weight2fee(feerate, buildTx(outPoint, 0.sat, pubKeyScript, dummyWitness).weight())
_ = assert(inputAmount - fee > Scripts.dustLimit(pubKeyScript), s"amount insufficient (fee=$fee)")
unsignedTx = buildTx(outPoint, inputAmount - fee, pubKeyScript, dummyWitness)
- } yield SpendFromChannelPrep(fundingTxIndex, localFundingPubkey, inputAmount, unsignedTx)
+ } yield SpendFromChannelPrep(fundingTxIndex, localFundingPubkey, localNonce_opt, inputAmount, unsignedTx)
}
- override def spendFromChannelAddress(fundingKeyPath: DeterministicWallet.KeyPath, fundingTxIndex: Long, remoteFundingPubkey: PublicKey, remoteSig: ByteVector64, unsignedTx: Transaction): Future[SpendFromChannelResult] = {
+ override def spendFromChannelAddress(fundingKeyPath: DeterministicWallet.KeyPath, fundingTxIndex: Long, remoteFundingPubkey: PublicKey, localNonce_opt: Option[IndividualNonce], remoteSig: ChannelSpendSignature, unsignedTx: Transaction): Future[SpendFromChannelResult] = {
for {
_ <- Future.successful(())
outPoint = unsignedTx.txIn.head.outPoint
inputTx <- appKit.wallet.getTransaction(outPoint.txid)
- channelKeys = appKit.nodeParams.channelKeyManager.channelKeys(ChannelConfig.standard, fundingKeyPath)
- localFundingKey = channelKeys.fundingKey(fundingTxIndex)
inputInfo = InputInfo(outPoint, inputTx.txOut(outPoint.index.toInt))
// classify as splice, doesn't really matter
tx = Transactions.SpliceTx(inputInfo, unsignedTx)
- localSig = tx.sign(localFundingKey, remoteFundingPubkey, extraUtxos = Map.empty)
- signedTx = tx.aggregateSigs(localFundingKey.publicKey, remoteFundingPubkey, localSig, ChannelSpendSignature.IndividualSignature(remoteSig))
+ channelKeys = appKit.nodeParams.channelKeyManager.channelKeys(ChannelConfig.standard, fundingKeyPath)
+ localFundingKey = channelKeys.fundingKey(fundingTxIndex)
+ signedTx = remoteSig match {
+ case individualRemoteSig: ChannelSpendSignature.IndividualSignature =>
+ val localSig = tx.sign(localFundingKey, remoteFundingPubkey, extraUtxos = Map.empty)
+ tx.aggregateSigs(localFundingKey.publicKey, remoteFundingPubkey, localSig, individualRemoteSig)
+ case remotePartialSig: ChannelSpendSignature.PartialSignatureWithNonce =>
+ val localPrivateNonce = nonces(localNonce_opt.get)
+ val Right(localSig) = tx.partialSign(localFundingKey, remoteFundingPubkey, extraUtxos = Map.empty, localNonce = localPrivateNonce, publicNonces = Seq(localPrivateNonce.publicNonce, remotePartialSig.nonce))
+ val Right(signedTx) = tx.aggregateSigs(localFundingKey.publicKey, remoteFundingPubkey, localSig, remotePartialSig, extraUtxos = Map.empty)
+ signedTx
+ }
} yield SpendFromChannelResult(signedTx)
}
-
}
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 f5892a7..a4806d5 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
@@ -19,11 +19,14 @@ 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.Musig2.IndividualNonce
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}
+import fr.acinq.eclair.blockchain.fee.FeeratePerByte
+import fr.acinq.eclair.channel.ChannelSpendSignature
+import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce
trait Control {
this: Service with EclairDirectives =>
@@ -64,10 +67,17 @@ trait Control {
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)))
+ complete(eclairApi.spendFromChannelAddress(KeyPath(keyPath), fundingTxIndex, remoteFundingPubkey, localNonce_opt = None, ChannelSpendSignature.IndividualSignature(remoteSig), Transaction.read(unsignedTx)))
}
}
- val controlRoutes: Route = enableFromFutureHtlc ~ resetBalance ~ forceCloseResetFundingIndex ~ manualWatchFundingSpent ~ spendFromChannelAddressPrep ~ spendFromChannelAddress
+ val spendFromTaprootChannelAddress: Route = postRequest("spendfromtaprootchanneladdress") { implicit t =>
+ formFields("kp", "fi".as[Int], "p".as[PublicKey], "localNonce".as[IndividualNonce], "remoteNonce".as[IndividualNonce], "remoteSig".as[ByteVector32], "tx".as[String]) {
+ (keyPath, fundingTxIndex, remoteFundingPubkey, localNonce, remoteNonce, remoteSig, unsignedTx) =>
+ complete(eclairApi.spendFromChannelAddress(KeyPath(keyPath), fundingTxIndex, remoteFundingPubkey, localNonce_opt = Some(localNonce), PartialSignatureWithNonce(remoteSig, remoteNonce), Transaction.read(unsignedTx)))
+ }
+ }
+
+ val controlRoutes: Route = enableFromFutureHtlc ~ resetBalance ~ forceCloseResetFundingIndex ~ manualWatchFundingSpent ~ spendFromChannelAddressPrep ~ spendFromChannelAddress ~ spendFromTaprootChannelAddress
}
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 688ead9..df97be0 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,6 +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.Musig2.IndividualNonce
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, OutPoint, Satoshi, TxId}
import fr.acinq.eclair.api.directives.RouteFormat
import fr.acinq.eclair.api.serde.JsonSupport._
@@ -39,6 +40,8 @@ object FormParamExtractors {
implicit val publicKeyUnmarshaller: Unmarshaller[String, PublicKey] = Unmarshaller.strict { rawPubKey => PublicKey(ByteVector.fromValidHex(rawPubKey)) }
+ implicit val individualNonceUnmarshaller: Unmarshaller[String, IndividualNonce] = Unmarshaller.strict { str => IndividualNonce(ByteVector.fromValidHex(str)) }
+
implicit val bytesUnmarshaller: Unmarshaller[String, ByteVector] = Unmarshaller.strict { str => ByteVector.fromValidHex(str) }
implicit val bytes32Unmarshaller: Unmarshaller[String, ByteVector32] = Unmarshaller.strict { bin => ByteVector32.fromValidHex(bin) }
Why this scored 11/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.