Skip to content

Commit

Permalink
Add support for bech32m bitcoin wallets
Browse files Browse the repository at this point in the history
These changes allow eclair to be used with a bitcoin core wallet configured to generate bech32m (p2tr) addresses and change addresses.
The wallet still needs to be able to generate bech32 (p2wpkh) addresses in some cases (support for static_remote_key for non anchor channels for example).
  • Loading branch information
sstone committed Aug 23, 2024
1 parent c45d278 commit 2353b24
Show file tree
Hide file tree
Showing 37 changed files with 717 additions and 193 deletions.
15 changes: 15 additions & 0 deletions eclair-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,21 @@
<version>4.1.94.Final</version>
</dependency>
<!-- BITCOIN -->
<dependency>
<groupId>fr.acinq.bitcoin</groupId>
<artifactId>bitcoin-kmp-jvm</artifactId>
<version>0.20.0</version>
</dependency>
<dependency>
<groupId>fr.acinq.secp256k1</groupId>
<artifactId>secp256k1-kmp-jvm</artifactId>
<version>0.15.0</version>
</dependency>
<dependency>
<groupId>fr.acinq.secp256k1</groupId>
<artifactId>secp256k1-kmp-jni-jvm</artifactId>
<version>0.15.0</version>
</dependency>
<dependency>
<groupId>fr.acinq</groupId>
<artifactId>bitcoin-lib_${scala.version.short}</artifactId>
Expand Down
3 changes: 2 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Cryp
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.AddressType
import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptors, WalletTx}
Expand Down Expand Up @@ -760,7 +761,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}

override def getOnChainMasterPubKey(account: Long): String = appKit.nodeParams.onChainKeyManager_opt match {
case Some(keyManager) => keyManager.masterPubKey(account)
case Some(keyManager) => keyManager.masterPubKey(account, AddressType.Bech32)
case _ => throw new RuntimeException("on-chain seed is not configured")
}

Expand Down
4 changes: 3 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ object NodeParams extends Logging {

private val chain2Hash: Map[String, BlockHash] = Map(
"regtest" -> Block.RegtestGenesisBlock.hash,
"testnet" -> Block.TestnetGenesisBlock.hash,
"testnet" -> Block.Testnet3GenesisBlock.hash,
"testnet3" -> Block.Testnet3GenesisBlock.hash,
"testnet4" -> Block.Testnet4GenesisBlock.hash,
"signet" -> Block.SignetGenesisBlock.hash,
"mainnet" -> Block.LivenetGenesisBlock.hash
)
Expand Down
16 changes: 14 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ class Setup(val datadir: File,
_ <- feeratesRetrieved.future

finalPubkey = new AtomicReference[PublicKey](null)
finalPubkeyScript = new AtomicReference[ByteVector](null)
pubkeyRefreshDelay = FiniteDuration(config.getDuration("bitcoind.final-pubkey-refresh-delay").getSeconds, TimeUnit.SECONDS)
// there are 3 possibilities regarding onchain key management:
// 1) there is no `eclair-signer.conf` file in Eclair's data directory, Eclair will not manage Bitcoin core keys, and Eclair's API will not return bitcoin core descriptors. This is the default mode.
Expand All @@ -274,18 +275,29 @@ class Setup(val datadir: File,
// 3) there is an `eclair-signer.conf` file in Eclair's data directory, and the name of the wallet set in `eclair-signer.conf` matches the `eclair.bitcoind.wallet` setting in `eclair.conf`.
// Eclair will assume that this is a watch-only bitcoin wallet that has been created from descriptors generated by Eclair, and will manage its private keys, and here we pass the onchain key manager to our bitcoin client.
bitcoinClient = new BitcoinCoreClient(bitcoin, if (bitcoin.wallet == onChainKeyManager_opt.map(_.walletName)) onChainKeyManager_opt else None) with OnchainPubkeyCache {
val refresher: typed.ActorRef[OnchainPubkeyRefresher.Command] = system.spawn(Behaviors.supervise(OnchainPubkeyRefresher(this, finalPubkey, pubkeyRefreshDelay)).onFailure(typed.SupervisorStrategy.restart), name = "onchain-address-manager")

val refresher: typed.ActorRef[OnchainPubkeyRefresher.Command] = system.spawn(Behaviors.supervise(OnchainPubkeyRefresher(bitcoinChainHash, this, finalPubkey, finalPubkeyScript, pubkeyRefreshDelay)).onFailure(typed.SupervisorStrategy.restart), name = "onchain-address-manager")

override def getP2wpkhPubkey(renew: Boolean): PublicKey = {
val key = finalPubkey.get()
if (renew) refresher ! OnchainPubkeyRefresher.Renew
if (renew) refresher ! OnchainPubkeyRefresher.RenewPubkey
key
}

override def getPubkeyScript(renew: Boolean): ByteVector = {
val script = finalPubkeyScript.get()
if (renew) refresher ! OnchainPubkeyRefresher.RenewPubkeyScript
script
}
}
_ = if (bitcoinClient.useEclairSigner) logger.info("using eclair to sign bitcoin core transactions")
initialPubkey <- bitcoinClient.getP2wpkhPubkey()
_ = finalPubkey.set(initialPubkey)

initialAddress <- bitcoinClient.getReceiveAddress()
Right(initialPubkeyScript) = addressToPublicKeyScript(bitcoinChainHash, initialAddress)
_ = finalPubkeyScript.set(Script.write(initialPubkeyScript))

// If we started funding a transaction and restarted before signing it, we may have utxos that stay locked forever.
// We want to do something about it: we can unlock them automatically, or let the node operator decide what to do.
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package fr.acinq.eclair.blockchain

import fr.acinq.bitcoin.psbt.Psbt
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{OutPoint, Satoshi, Transaction, TxId}
import fr.acinq.bitcoin.scalacompat.{BlockHash, OutPoint, Satoshi, Script, Transaction, TxId, addressToPublicKeyScript}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import scodec.bits.ByteVector

Expand All @@ -28,6 +28,18 @@ import scala.concurrent.{ExecutionContext, Future}
* Created by PM on 06/07/2017.
*/

sealed trait AddressType

object AddressType {
case object Bech32 extends AddressType {
override def toString: String = "bech32"
}

case object Bech32m extends AddressType {
override def toString: String = "bech32m"
}
}

/** This trait lets users fund lightning channels. */
trait OnChainChannelFunder {

Expand Down Expand Up @@ -119,7 +131,7 @@ trait OnChainAddressGenerator {
/**
* @param label used if implemented with bitcoin core, can be ignored by implementation
*/
def getReceiveAddress(label: String = "")(implicit ec: ExecutionContext): Future[String]
def getReceiveAddress(label: String = "", addressType_opt: Option[AddressType] = None)(implicit ec: ExecutionContext): Future[String]

/** Generate a p2wpkh wallet address and return the corresponding public key. */
def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[PublicKey]
Expand All @@ -132,6 +144,8 @@ trait OnchainPubkeyCache {
* @param renew applies after requesting the current pubkey, and is asynchronous
*/
def getP2wpkhPubkey(renew: Boolean = true): PublicKey

def getPubkeyScript(renew: Boolean = true): ByteVector
}

/** This trait lets users check the wallet's on-chain balance. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,61 +3,89 @@ package fr.acinq.eclair.blockchain.bitcoind

import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler}
import fr.acinq.bitcoin.scalacompat.{BlockHash, Script, addressToPublicKeyScript}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.blockchain.OnChainAddressGenerator
import scodec.bits.ByteVector

import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.FiniteDuration
import scala.util.{Failure, Success}

/**
* Handles the renewal of public keys generated by bitcoin core and used to send onchain funds to when channels get closed
* Handles the renewal of public keys and public key scripts generated by bitcoin core and used to send onchain funds to when channels get closed
*/
object OnchainPubkeyRefresher {

// @formatter:off
sealed trait Command
case object Renew extends Command
private case class Set(pubkey: PublicKey) extends Command
case object RenewPubkey extends Command
private case class SetPubkey(pubkey: PublicKey) extends Command
case object RenewPubkeyScript extends Command
private case class SetPubkeyScript(pubkeyScript: ByteVector) extends Command
private case class Error(reason: Throwable) extends Command
private case object Done extends Command
// @formatter:on

def apply(generator: OnChainAddressGenerator, finalPubkey: AtomicReference[PublicKey], delay: FiniteDuration): Behavior[Command] = {
def apply(chainHash: BlockHash, generator: OnChainAddressGenerator, finalPubkey: AtomicReference[PublicKey], finalPubkeyScript: AtomicReference[ByteVector], delay: FiniteDuration): Behavior[Command] = {
Behaviors.setup { context =>
Behaviors.withTimers { timers =>
new OnchainPubkeyRefresher(generator, finalPubkey, context, timers, delay).idle()
new OnchainPubkeyRefresher(chainHash, generator, finalPubkey, finalPubkeyScript, context, timers, delay).idle()
}
}
}
}

private class OnchainPubkeyRefresher(generator: OnChainAddressGenerator, finalPubkey: AtomicReference[PublicKey], context: ActorContext[OnchainPubkeyRefresher.Command], timers: TimerScheduler[OnchainPubkeyRefresher.Command], delay: FiniteDuration) {
private class OnchainPubkeyRefresher(chainHash: BlockHash, generator: OnChainAddressGenerator, finalPubkey: AtomicReference[PublicKey], finalPubkeyScript: AtomicReference[ByteVector], context: ActorContext[OnchainPubkeyRefresher.Command], timers: TimerScheduler[OnchainPubkeyRefresher.Command], delay: FiniteDuration) {

import OnchainPubkeyRefresher._

def idle(): Behavior[Command] = Behaviors.receiveMessagePartial {
case Renew =>
context.log.debug(s"received Renew current script is ${finalPubkey.get()}")
case RenewPubkey =>
context.log.debug(s"received RenewPubkey current pubkey is ${finalPubkey.get()}")
context.pipeToSelf(generator.getP2wpkhPubkey()) {
case Success(pubkey) => Set(pubkey)
case Success(pubkey) => SetPubkey(pubkey)
case Failure(reason) => Error(reason)
}
Behaviors.receiveMessagePartial {
case Set(script) =>
case SetPubkey(script) =>
timers.startSingleTimer(Done, delay) // wait a bit to avoid generating too many addresses in case of mass channel force-close
waiting(script)
case Error(reason) =>
context.log.error("cannot generate new onchain address", reason)
Behaviors.same
}
case RenewPubkeyScript =>
context.log.debug(s"received Renew current script is ${finalPubkeyScript.get()}")
context.pipeToSelf(generator.getReceiveAddress("")) {
case Success(address) => addressToPublicKeyScript(chainHash, address) match {
case Right(script) => SetPubkeyScript(Script.write(script))
case Left(error) => Error(error.getCause)
}
case Failure(reason) => Error(reason)
}
Behaviors.receiveMessagePartial {
case SetPubkeyScript(script) =>
timers.startSingleTimer(Done, delay) // wait a bit to avoid generating too many addresses in case of mass channel force-close
waiting(script)
case Error(reason) =>
context.log.error("cannot generate new onchain address", reason)
Behaviors.same
}
}

def waiting(pubkey: PublicKey): Behavior[Command] = Behaviors.receiveMessagePartial {
case Done =>
context.log.info(s"setting final onchain public key to $pubkey")
finalPubkey.set(pubkey)
idle()
}

def waiting(script: PublicKey): Behavior[Command] = Behaviors.receiveMessagePartial {
def waiting(script: ByteVector): Behavior[Command] = Behaviors.receiveMessagePartial {
case Done =>
context.log.info(s"setting final onchain script to $script")
finalPubkey.set(script)
finalPubkeyScript.set(script)
idle()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat._
import fr.acinq.bitcoin.{Bech32, Block, SigHash}
import fr.acinq.eclair.ShortChannelId.coordinates
import fr.acinq.eclair.blockchain.OnChainWallet
import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, ProcessPsbtResponse}
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{GetTxWithMetaResponse, UtxoStatus, ValidateResult}
import fr.acinq.eclair.blockchain.fee.{FeeratePerKB, FeeratePerKw}
import fr.acinq.eclair.blockchain.{AddressType, OnChainWallet}
import fr.acinq.eclair.crypto.keymanager.OnChainKeyManager
import fr.acinq.eclair.json.SatoshiSerializer
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.wire.protocol.ChannelAnnouncement
import fr.acinq.eclair.{BlockHeight, TimestampSecond, TxCoordinates}
Expand Down Expand Up @@ -550,10 +549,29 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag
}
}

def getReceiveAddress(label: String)(implicit ec: ExecutionContext): Future[String] = for {
JString(address) <- rpcClient.invoke("getnewaddress", label)
_ <- extractPublicKey(address)
} yield address
private def verifyAddress(address: String)(implicit ec: ExecutionContext): Future[String] = {
for {
addressInfo <- rpcClient.invoke("getaddressinfo", address)
JString(keyPath) = addressInfo \ "hdkeypath"
} yield {
// check that when we manage private keys we can re-compute the address we got from bitcoin core
onChainKeyManager_opt match {
case Some(keyManager) =>
val (_, computed) = keyManager.derivePublicKey(DeterministicWallet.KeyPath(keyPath))
if (computed != address) return Future.failed(new RuntimeException("cannot recompute address generated by bitcoin core"))
case None => ()
}
address
}
}

def getReceiveAddress(label: String, addressType_opt: Option[AddressType] = None)(implicit ec: ExecutionContext): Future[String] = {
val params = List(label) ++ addressType_opt.map(_.toString).toList
for {
JString(address) <- rpcClient.invoke("getnewaddress", params: _*)
_ <- verifyAddress(address)
} yield address
}

def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = for {
JString(address) <- rpcClient.invoke("getnewaddress", "", "bech32")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ object ExplorerApi {
case class BlockcypherExplorer()(implicit val sb: SttpBackend[Future, _]) extends Explorer {
override val name = "blockcypher.com"
override val baseUris = Map(
Block.TestnetGenesisBlock.hash -> uri"https://api.blockcypher.com/v1/btc/test3",
Block.Testnet3GenesisBlock.hash -> uri"https://api.blockcypher.com/v1/btc/test3",
Block.LivenetGenesisBlock.hash -> uri"https://api.blockcypher.com/v1/btc/main"
)

Expand Down Expand Up @@ -208,12 +208,12 @@ object ExplorerApi {
override val name = "blockstream.info"
override val baseUris = if (useTorEndpoints) {
Map(
Block.TestnetGenesisBlock.hash -> uri"http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/testnet/api",
Block.Testnet3GenesisBlock.hash -> uri"http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/testnet/api",
Block.LivenetGenesisBlock.hash -> uri"http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/api"
)
} else {
Map(
Block.TestnetGenesisBlock.hash -> uri"https://blockstream.info/testnet/api",
Block.Testnet3GenesisBlock.hash -> uri"https://blockstream.info/testnet/api",
Block.LivenetGenesisBlock.hash -> uri"https://blockstream.info/api"
)
}
Expand All @@ -224,13 +224,13 @@ object ExplorerApi {
override val name = "mempool.space"
override val baseUris = if (useTorEndpoints) {
Map(
Block.TestnetGenesisBlock.hash -> uri"http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/testnet/api",
Block.Testnet3GenesisBlock.hash -> uri"http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/testnet/api",
Block.LivenetGenesisBlock.hash -> uri"http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api",
Block.SignetGenesisBlock.hash -> uri"http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/signet/api"
)
} else {
Map(
Block.TestnetGenesisBlock.hash -> uri"https://mempool.space/testnet/api",
Block.Testnet3GenesisBlock.hash -> uri"https://mempool.space/testnet/api",
Block.LivenetGenesisBlock.hash -> uri"https://mempool.space/api",
Block.SignetGenesisBlock.hash -> uri"https://mempool.space/signet/api"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@

package fr.acinq.eclair.channel.fsm

import akka.actor.{ActorRef, FSM, Status}
import akka.actor.FSM
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Script}
import fr.acinq.eclair.Features
import fr.acinq.eclair.channel._
import fr.acinq.eclair.db.PendingCommandsDb
import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.wire.protocol.{HtlcSettlementMessage, LightningMessage, UpdateMessage}
import fr.acinq.eclair.{Features, InitFeature}
import scodec.bits.ByteVector

import scala.concurrent.duration.DurationInt
Expand Down Expand Up @@ -115,17 +115,21 @@ trait CommonHandlers {
upfrontShutdownScript
} else {
log.info("ignoring pre-generated shutdown script, because option_upfront_shutdown_script is disabled")
generateFinalScriptPubKey()
generateFinalScriptPubKey(data.commitments.params.localParams.initFeatures, data.commitments.params.remoteParams.initFeatures)
}
case None =>
// normal case: we don't pre-generate shutdown scripts
generateFinalScriptPubKey()
generateFinalScriptPubKey(data.commitments.params.localParams.initFeatures, data.commitments.params.remoteParams.initFeatures)
}
}

private def generateFinalScriptPubKey(): ByteVector = {
val finalPubKey = wallet.getP2wpkhPubkey()
val finalScriptPubKey = Script.write(Script.pay2wpkh(finalPubKey))
private def generateFinalScriptPubKey(localFeatures: Features[InitFeature], remoteFeatures: Features[InitFeature]): ByteVector = {
val allowAnySegwit = Features.canUseFeature(localFeatures, remoteFeatures, Features.ShutdownAnySegwit)
val finalScriptPubKey = if (allowAnySegwit) {
wallet.getPubkeyScript()
} else {
Script.write(Script.pay2wpkh(wallet.getP2wpkhPubkey()))
}
log.info(s"using finalScriptPubkey=$finalScriptPubKey")
finalScriptPubKey
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,17 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,

// We create a PSBT with the non-wallet input already signed:
val psbt = new Psbt(locallySignedTx.txInfo.tx)
.updateWitnessInput(locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.input.txOut, null, fr.acinq.bitcoin.Script.parse(locallySignedTx.txInfo.input.redeemScript), fr.acinq.bitcoin.SigHash.SIGHASH_ALL, java.util.Map.of())
.flatMap(_.finalizeWitnessInput(0, locallySignedTx.txInfo.tx.txIn.head.witness))
.updateWitnessInput(
locallySignedTx.txInfo.input.outPoint,
locallySignedTx.txInfo.input.txOut,
null,
fr.acinq.bitcoin.Script.parse(locallySignedTx.txInfo.input.redeemScript),
fr.acinq.bitcoin.SigHash.SIGHASH_ALL,
java.util.Map.of(),
null,
null,
java.util.Map.of()
).flatMap(_.finalizeWitnessInput(0, locallySignedTx.txInfo.tx.txIn.head.witness))
psbt match {
case Left(failure) =>
log.error(s"cannot sign ${cmd.desc}: $failure")
Expand Down
Loading

0 comments on commit 2353b24

Please sign in to comment.