Skip to content

Commit 5d6e556

Browse files
sstonet-bast
andauthored
Support p2tr bitcoin wallet (#3026)
* Add an address type parameter to our bitcoin core RPC client We defined an new AddressType type that can optionally be passed to calls tp `getReceiveAddress()`. This type can represent p2wpkh or p2tr addresses. * Add a "chain hash" property to the bitcoin rpc client * Replace getP2wpkhPubkeyHashForChange() with getChangePublicKeyScript() We should not hardcode the type of change output that we want but instead use the default change type configured in bitcoin core. * Simplify SingleKeyOnChainWallet Use a key manager to generate a local address and sign transactions (instead of signing them manually). * LocalOnChainKeyManager: support signing BIP86 transactions * make InteractiveTxBuilder compatible with p2tr wallets If our wallets adds p2tr inputs, to sign them we need to know all the utxos spent by the tx we're building, not just the one spent by the tx we're signing. * Cache both onchain public key and public key scripts * Test bitcoin core change output type selection If the -changetype option is not set, then bitcoin core will fund transactions with inputs that match transaction's outputs (i.e it will add p2wpkh change outputs if the tx sends to p2wpkh outputs, p2tr change outputs if it sends to p2tr outputs...). * Integration tests: change default bitcoin core address type to p2tr * Rework `OnChainAddressRefresher` * Reformat / clean-up code and comments * Improve tests * sendonchain: unlock utxos if publishing fails * Add signing test with mixed p2wpkh/p2tr inputs and an eclair-backed bitcoin core wallet --------- Co-authored-by: Bastien Teinturier <[email protected]>
1 parent b98930b commit 5d6e556

32 files changed

+880
-351
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import fr.acinq.eclair.balance.{BalanceActor, ChannelsListener}
3030
import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance
3131
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchFundingSpentTriggered
3232
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
33-
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptors, WalletTx}
33+
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{AddressType, Descriptors, WalletTx}
3434
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw}
3535
import fr.acinq.eclair.channel._
3636
import fr.acinq.eclair.crypto.Sphinx
@@ -132,7 +132,7 @@ trait Eclair {
132132

133133
def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[OfferData]]
134134

135-
def newAddress(): Future[String]
135+
def newAddress(addressType_opt: Option[AddressType] = None): Future[String]
136136

137137
def receivedInfo(paymentHash: ByteVector32)(implicit timeout: Timeout): Future[Option[IncomingPayment]]
138138

@@ -413,9 +413,9 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan
413413
appKit.nodeParams.db.offers.listOffers(onlyActive)
414414
}
415415

416-
override def newAddress(): Future[String] = {
416+
override def newAddress(addressType_opt: Option[AddressType] = None): Future[String] = {
417417
appKit.wallet match {
418-
case w: BitcoinCoreClient => w.getReceiveAddress()
418+
case w: BitcoinCoreClient => w.getReceiveAddress(addressType_opt)
419419
case _ => Future.failed(new IllegalArgumentException("this call is only available with a bitcoin core backend"))
420420
}
421421
}
@@ -837,7 +837,7 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan
837837
}
838838

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

eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ import akka.actor.{ActorRef, ActorSystem, Props, SupervisorStrategy, typed}
2323
import akka.pattern.after
2424
import akka.util.Timeout
2525
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
26-
import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, BlockId, ByteVector32, Satoshi, Script, addressToPublicKeyScript}
26+
import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, BlockId, ByteVector32, Satoshi, Script, ScriptElt, addressToPublicKeyScript}
27+
import fr.acinq.eclair.NodeParams.hashFromChain
2728
import fr.acinq.eclair.Setup.Seeds
2829
import fr.acinq.eclair.balance.{BalanceActor, ChannelsListener}
2930
import fr.acinq.eclair.blockchain._
3031
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BatchingBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCAuthMethod}
3132
import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor
32-
import fr.acinq.eclair.blockchain.bitcoind.{OnchainPubkeyRefresher, ZmqWatcher}
33+
import fr.acinq.eclair.blockchain.bitcoind.{OnChainAddressRefresher, ZmqWatcher}
3334
import fr.acinq.eclair.blockchain.fee._
3435
import fr.acinq.eclair.channel.Register
3536
import fr.acinq.eclair.channel.fsm.Channel
@@ -177,6 +178,7 @@ class Setup(val datadir: File,
177178
}
178179

179180
val bitcoinClient = new BasicBitcoinJsonRPCClient(
181+
chainHash = hashFromChain(chain),
180182
rpcAuthMethod = rpcAuthMethod,
181183
host = config.getString("bitcoind.host"),
182184
port = config.getInt("bitcoind.rpcport"),
@@ -262,6 +264,7 @@ class Setup(val datadir: File,
262264
_ <- feeratesRetrieved.future
263265

264266
finalPubkey = new AtomicReference[PublicKey](null)
267+
finalPubkeyScript = new AtomicReference[Seq[ScriptElt]](null)
265268
pubkeyRefreshDelay = FiniteDuration(config.getDuration("bitcoind.final-pubkey-refresh-delay").getSeconds, TimeUnit.SECONDS)
266269
// there are 3 possibilities regarding onchain key management:
267270
// 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.
@@ -270,18 +273,27 @@ class Setup(val datadir: File,
270273
// This is how you would create a new bitcoin wallet whose private keys are managed by Eclair.
271274
// 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`.
272275
// 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.
273-
bitcoinClient = new BitcoinCoreClient(bitcoin, nodeParams.liquidityAdsConfig.lockUtxos, if (bitcoin.wallet == onChainKeyManager_opt.map(_.walletName)) onChainKeyManager_opt else None) with OnchainPubkeyCache {
274-
val refresher: typed.ActorRef[OnchainPubkeyRefresher.Command] = system.spawn(Behaviors.supervise(OnchainPubkeyRefresher(this, finalPubkey, pubkeyRefreshDelay)).onFailure(typed.SupervisorStrategy.restart), name = "onchain-address-manager")
276+
bitcoinClient = new BitcoinCoreClient(bitcoin, nodeParams.liquidityAdsConfig.lockUtxos, if (bitcoin.wallet == onChainKeyManager_opt.map(_.walletName)) onChainKeyManager_opt else None) with OnChainPubkeyCache {
277+
val refresher: typed.ActorRef[OnChainAddressRefresher.Command] = system.spawn(Behaviors.supervise(OnChainAddressRefresher(this, finalPubkey, finalPubkeyScript, pubkeyRefreshDelay)).onFailure(typed.SupervisorStrategy.restart), name = "onchain-address-manager")
275278

276279
override def getP2wpkhPubkey(renew: Boolean): PublicKey = {
277280
val key = finalPubkey.get()
278-
if (renew) refresher ! OnchainPubkeyRefresher.Renew
281+
if (renew) refresher ! OnChainAddressRefresher.RenewPubkey
279282
key
280283
}
284+
285+
override def getReceivePublicKeyScript(renew: Boolean): Seq[ScriptElt] = {
286+
val script = finalPubkeyScript.get()
287+
if (renew) refresher ! OnChainAddressRefresher.RenewPubkeyScript
288+
script
289+
}
281290
}
282291
_ = if (bitcoinClient.useEclairSigner) logger.info("using eclair to sign bitcoin core transactions")
283292
initialPubkey <- bitcoinClient.getP2wpkhPubkey()
284293
_ = finalPubkey.set(initialPubkey)
294+
// We use the default address type configured on the Bitcoin Core node.
295+
initialPubkeyScript <- bitcoinClient.getReceivePublicKeyScript(addressType_opt = None)
296+
_ = finalPubkeyScript.set(initialPubkeyScript)
285297

286298
// If we started funding a transaction and restarted before signing it, we may have utxos that stay locked forever.
287299
// We want to do something about it: we can unlock them automatically, or let the node operator decide what to do.
@@ -472,7 +484,7 @@ case class Kit(nodeParams: NodeParams,
472484
postman: typed.ActorRef[Postman.Command],
473485
offerManager: typed.ActorRef[OfferManager.Command],
474486
defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand],
475-
wallet: OnChainWallet with OnchainPubkeyCache)
487+
wallet: OnChainWallet with OnChainPubkeyCache)
476488

477489
object Kit {
478490

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ package fr.acinq.eclair.blockchain
1818

1919
import fr.acinq.bitcoin.psbt.Psbt
2020
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
21-
import fr.acinq.bitcoin.scalacompat.{OutPoint, Satoshi, Transaction, TxId}
21+
import fr.acinq.bitcoin.scalacompat.{OutPoint, Satoshi, ScriptElt, Transaction, TxId}
22+
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.AddressType
2223
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2324
import scodec.bits.ByteVector
2425

@@ -116,22 +117,27 @@ trait OnChainChannelFunder {
116117
/** This trait lets users generate on-chain addresses and public keys. */
117118
trait OnChainAddressGenerator {
118119

119-
/**
120-
* @param label used if implemented with bitcoin core, can be ignored by implementation
121-
*/
122-
def getReceiveAddress(label: String = "")(implicit ec: ExecutionContext): Future[String]
120+
/** Generate the public key script for a new wallet address. */
121+
def getReceivePublicKeyScript(addressType_opt: Option[AddressType] = None)(implicit ec: ExecutionContext): Future[Seq[ScriptElt]]
123122

124123
/** Generate a p2wpkh wallet address and return the corresponding public key. */
125124
def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[PublicKey]
126125

127126
}
128127

129-
trait OnchainPubkeyCache {
128+
/** A caching layer for [[OnChainAddressGenerator]] that provides synchronous access to wallet addresses and keys. */
129+
trait OnChainPubkeyCache {
130+
131+
/**
132+
* @param renew applies after requesting the current pubkey, and is asynchronous.
133+
*/
134+
def getP2wpkhPubkey(renew: Boolean): PublicKey
130135

131136
/**
132-
* @param renew applies after requesting the current pubkey, and is asynchronous
137+
* @param renew applies after requesting the current script, and is asynchronous.
133138
*/
134-
def getP2wpkhPubkey(renew: Boolean = true): PublicKey
139+
def getReceivePublicKeyScript(renew: Boolean): Seq[ScriptElt]
140+
135141
}
136142

137143
/** This trait lets users check the wallet's on-chain balance. */
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package fr.acinq.eclair.blockchain.bitcoind
2+
3+
4+
import akka.actor.typed.Behavior
5+
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler}
6+
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
7+
import fr.acinq.bitcoin.scalacompat.{Script, ScriptElt}
8+
import fr.acinq.eclair.blockchain.OnChainAddressGenerator
9+
10+
import java.util.concurrent.atomic.AtomicReference
11+
import scala.concurrent.ExecutionContext.Implicits.global
12+
import scala.concurrent.duration.FiniteDuration
13+
import scala.util.{Failure, Success}
14+
15+
/**
16+
* Handles the renewal of on-chain addresses generated by bitcoin core and used to receive on-chain funds when channels get closed.
17+
*/
18+
object OnChainAddressRefresher {
19+
20+
// @formatter:off
21+
sealed trait Command
22+
case object RenewPubkey extends Command
23+
case object RenewPubkeyScript extends Command
24+
private case class SetPubkey(pubkey: PublicKey) extends Command
25+
private case class SetPubkeyScript(script: Seq[ScriptElt]) extends Command
26+
private case class Error(reason: Throwable) extends Command
27+
private case object Done extends Command
28+
// @formatter:on
29+
30+
def apply(generator: OnChainAddressGenerator, finalPubkey: AtomicReference[PublicKey], finalPubkeyScript: AtomicReference[Seq[ScriptElt]], delay: FiniteDuration): Behavior[Command] = {
31+
Behaviors.setup { context =>
32+
Behaviors.withTimers { timers =>
33+
val refresher = new OnChainAddressRefresher(generator, finalPubkey, finalPubkeyScript, context, timers, delay)
34+
refresher.idle()
35+
}
36+
}
37+
}
38+
}
39+
40+
private class OnChainAddressRefresher(generator: OnChainAddressGenerator,
41+
finalPubkey: AtomicReference[PublicKey],
42+
finalPubkeyScript: AtomicReference[Seq[ScriptElt]],
43+
context: ActorContext[OnChainAddressRefresher.Command],
44+
timers: TimerScheduler[OnChainAddressRefresher.Command], delay: FiniteDuration) {
45+
46+
import OnChainAddressRefresher._
47+
48+
/** In that state, we're ready to renew our on-chain address whenever requested. */
49+
def idle(): Behavior[Command] = Behaviors.receiveMessage {
50+
case RenewPubkey =>
51+
context.log.debug("renewing pubkey (current={})", finalPubkey.get())
52+
context.pipeToSelf(generator.getP2wpkhPubkey()) {
53+
case Success(pubkey) => SetPubkey(pubkey)
54+
case Failure(reason) => Error(reason)
55+
}
56+
renewing()
57+
case RenewPubkeyScript =>
58+
context.log.debug("renewing script (current={})", Script.write(finalPubkeyScript.get()).toHex)
59+
context.pipeToSelf(generator.getReceivePublicKeyScript()) {
60+
case Success(script) => SetPubkeyScript(script)
61+
case Failure(reason) => Error(reason)
62+
}
63+
renewing()
64+
case cmd =>
65+
context.log.debug("ignoring command={} while idle", cmd)
66+
Behaviors.same
67+
}
68+
69+
/** We ignore concurrent requests while waiting for bitcoind to respond. */
70+
private def renewing(): Behavior[Command] = Behaviors.receiveMessage {
71+
case SetPubkey(pubkey) =>
72+
timers.startSingleTimer(Done, delay)
73+
delaying(Some(pubkey), None)
74+
case SetPubkeyScript(script) =>
75+
timers.startSingleTimer(Done, delay)
76+
delaying(None, Some(script))
77+
case Error(reason) =>
78+
context.log.error("cannot renew public key or script", reason)
79+
idle()
80+
case cmd =>
81+
context.log.debug("ignoring command={} while waiting for bitcoin core's response", cmd)
82+
Behaviors.same
83+
}
84+
85+
/**
86+
* After receiving our new script or pubkey from bitcoind, we wait before updating our current values.
87+
* While waiting, we ignore additional requests to renew.
88+
*
89+
* This ensures that a burst of requests during a mass force-close use the same final on-chain address instead of
90+
* creating a lot of address churn on our bitcoin wallet.
91+
*
92+
* Note that while we're updating our final script, we will ignore requests to update our final public key (and the
93+
* other way around). This is fine, since the public key is only used:
94+
* - when opening static_remotekey channels, which is disabled by default
95+
* - when closing channels with peers that don't support shutdown_anysegwit (which should be widely supported)
96+
*
97+
* In practice, we most likely always use [[RenewPubkeyScript]].
98+
*/
99+
private def delaying(nextPubkey_opt: Option[PublicKey], nextScript_opt: Option[Seq[ScriptElt]]): Behavior[Command] = Behaviors.receiveMessage {
100+
case Done =>
101+
nextPubkey_opt.foreach { nextPubkey =>
102+
context.log.info("setting pubkey to {}", nextPubkey)
103+
finalPubkey.set(nextPubkey)
104+
}
105+
nextScript_opt.foreach { nextScript =>
106+
context.log.info("setting script to {}", Script.write(nextScript).toHex)
107+
finalPubkeyScript.set(nextScript)
108+
}
109+
idle()
110+
case cmd =>
111+
context.log.debug("rate-limiting command={}", cmd)
112+
Behaviors.same
113+
}
114+
115+
}

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/OnchainPubkeyRefresher.scala

Lines changed: 0 additions & 63 deletions
This file was deleted.

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BasicBitcoinJsonRPCClient.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package fr.acinq.eclair.blockchain.bitcoind.rpc
1818

19+
import fr.acinq.bitcoin.scalacompat.BlockHash
1920
import fr.acinq.eclair.KamonExt
2021
import fr.acinq.eclair.blockchain.Monitoring.{Metrics, Tags}
2122
import fr.acinq.eclair.json._
@@ -32,7 +33,7 @@ import java.util.concurrent.atomic.AtomicReference
3233
import scala.concurrent.{ExecutionContext, Future}
3334
import scala.util.{Failure, Success, Try}
3435

35-
class BasicBitcoinJsonRPCClient(rpcAuthMethod: BitcoinJsonRPCAuthMethod, host: String = "127.0.0.1", port: Int = 8332, ssl: Boolean = false, override val wallet: Option[String] = None)(implicit sb: SttpBackend[Future, _]) extends BitcoinJsonRPCClient {
36+
class BasicBitcoinJsonRPCClient(override val chainHash: BlockHash, rpcAuthMethod: BitcoinJsonRPCAuthMethod, host: String = "127.0.0.1", port: Int = 8332, ssl: Boolean = false, override val wallet: Option[String] = None)(implicit sb: SttpBackend[Future, _]) extends BitcoinJsonRPCClient {
3637

3738
implicit val formats: Formats = DefaultFormats.withBigDecimal +
3839
ByteVector32Serializer + ByteVector32KmpSerializer +

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BatchingBitcoinJsonRPCClient.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package fr.acinq.eclair.blockchain.bitcoind.rpc
1919
import akka.actor.{ActorSystem, Props}
2020
import akka.pattern.ask
2121
import akka.util.Timeout
22+
import fr.acinq.bitcoin.scalacompat.BlockHash
2223
import fr.acinq.eclair.KamonExt
2324
import fr.acinq.eclair.blockchain.Monitoring.Metrics
2425
import org.json4s.JsonAST
@@ -27,6 +28,7 @@ import scala.concurrent.duration._
2728
import scala.concurrent.{ExecutionContext, Future}
2829

2930
class BatchingBitcoinJsonRPCClient(rpcClient: BasicBitcoinJsonRPCClient)(implicit system: ActorSystem, ec: ExecutionContext) extends BitcoinJsonRPCClient {
31+
override def chainHash: BlockHash = rpcClient.chainHash
3032
override def wallet: Option[String] = rpcClient.wallet
3133

3234
implicit val timeout: Timeout = Timeout(1 hour)

0 commit comments

Comments
 (0)