Skip to content

Commit

Permalink
Create eclair-backed wallet automatically on startup
Browse files Browse the repository at this point in the history
When eclair starts, if it is configured to manage bitcoin core's onchain key, and the configured wallet does not exist yet,
and eclair descriptor's timestamps are less then 2 hours old, eclair will automatically create the configured wallet with the
appropriate options and import its descriptors.
  • Loading branch information
sstone committed Jul 31, 2023
1 parent 0e116a6 commit af91698
Show file tree
Hide file tree
Showing 11 changed files with 179 additions and 97 deletions.
113 changes: 68 additions & 45 deletions docs/BitcoinCoreKeys.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# Using Eclair to manage your Bitcoin Core wallet's private keys

You can configure Eclair to control (and never expose) the private keys of your Bitcoin Core wallet. This is very useful if your Bitcoin and Eclair nodes run on different machines for example, with a setup for the Bitcoin host that
You can configure Eclair to control (and never expose) the private keys of your Bitcoin Core wallet. This feature was designed to take advantage of deployements where your Eclair node runs in a
"trusted" runtime environment, but is also very useful if your Bitcoin and Eclair nodes run on different machines for example, with a setup for the Bitcoin host that
is less secure than for Eclair (because it is shared among several services for example).

## Configuring Eclair and Bitcoin Core to use a new Eclair-backed bitcoin wallet

Follow these steps to delegate onchain key management to eclair:

1) Generate or import a BIP39 mnemonic code and passphrase
1) Generate a BIP39 mnemonic code and passphrase

You can use any BIP39-compatible tool, including most hardware wallets.

Expand All @@ -24,46 +27,82 @@ This is an example of `eclair-signer.conf` configuration file:

```hocon
{
eclair {
signer {
wallet = "eclair"
mnemonics = "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title"
passphrase = ""
timestamp = 1686055705
}
}
eclair {
signer {
wallet = "eclair"
mnemonics = "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title"
passphrase = ""
timestamp = 1686055705
}
}
}
```

You must set `eclair.signer.wallet` to a name that is different from your current Bitcoin Core wallet.
3) Configure Eclair to handle private keys for this wallet

Set `eclair.bitcoind.wallet` to the name of the wallet just created (`eclair` in the example above) and restart Eclair.
Eclair will automatically create a new, empty, descriptor-enabled, watch-only wallet in Bitcoin Core and import its descriptors.

:warning: Eclair will not import descriptors if the timestamp set in your `eclair-signer.conf` is more than 2 hours old. If the mnmeonics and
passphrase that your are using are new, you can safely update this timestamp, but if they have been used before you will need to follow
the steps described in the next section.

You now have a Bitcoin Core watch-only wallet for which only your Eclair node can sign transactions. This Bitcoin Core wallet can
safely be copied to another Bitcoin Core node to monitor your onchain funds.

You can also use `eclair-cli getmasterxpub` to get a BIP32 extended public key that you can import into any compatible Bitcoin wallet
to create a watch-only wallet (Electrum for example) that you can use to monitor your Bitcoin Core balance.

:warning: this means that your Bitcoin Core wallet cannot send funds on its own (since it cannot access private keys to sign transactions).
To send funds onchain you must use `eclair-cli sendonchain`.

:warning: to backup the private keys of this wallet you must either backup your mnemonic code and passphrase, or backup the `eclair-signer.conf` file in your eclair
directory (default is `~/.eclair`) along with your channels and node seed files.

:warning: You can also initialise a backup onchain wallet with the same mnemonic code and passphrase (on a hardware wallet for example), but be warned that using them may interfere with your node's operations (for example you may end up
double-spending funding transactions generated by your node).

## Importing an existing Eclair-backed bitcoin core wallet

The steps above describe how you can simply switch to a new Eclair-backed bitcoin wallet.
If you are already using an Eclair-backed bitcoin wallet that you want to move to another setup, you have several options:

3) Create an empty, descriptor-enabled, watch-only wallet in Bitcoin Core:
### Copy the bitcoin core wallet and `eclair-signer.conf`

Copy your wallet file to a new Bitcoin Core node, and load it with `bitcoin-cli loadwallet`. Bitcoin Core may need to scan the blockchain which could take some time.
Copy `eclair-signer.conf` to your Eclair data directory and set `eclair.bitcoind.wallet` to the name of the wallet configured in `eclair-signer.conf` (and which should also be the name of your Bitcoin Core wallet).
Once your wallet has been imported, just restart Eclair.

### Create an empty wallet manually and import Eclair descriptors

1) Create an empty, descriptor-enabled, watch-only wallet in Bitcoin Core:
:warning: The name must match the one that you set in `eclair-signer.conf` (here we use "eclair")

```shell
$ bitcoin-cli -named createwallet wallet_name=eclair disable_private_keys=true blank=true descriptors=true load_on_startup=true
```

4) Import public descriptors generated by Eclair
2) Import public descriptors generated by Eclair

Copy `eclair-signer.conf` to your Eclair data directory but do not change `eclair.bitcoind.wallet`, and restart Eclair.

`eclair-cli listdescriptors` will return public wallet descriptors in a format that is compatible with Bitcoin Core, and that you can import with `bitcoin-cli -rpcwallet=eclair importdescriptors`
For now, this descriptors follow the BIP84 standard (p2wpkh outputs).
This is an example of descriptors generated by Eclair:

```json
[
{
"desc": "wpkh([0d9250da/84h/1h/0h]tpubDDGF9PnrXww2h1mKNjKiXoqdDFGEcZGCZUNq7g26LdzKXKiE31RrFWsogPy1uMLrbG8ksQ8eJS6u6KFLjYUUSVJRuwmMD2SYCr8uG1TcRgM/0/*)#jz5n2pcp",
"internal": false,
"timestamp": 1686055705,
"active": true
},
{
"desc": "wpkh([0d9250da/84h/1h/0h]tpubDDGF9PnrXww2h1mKNjKiXoqdDFGEcZGCZUNq7g26LdzKXKiE31RrFWsogPy1uMLrbG8ksQ8eJS6u6KFLjYUUSVJRuwmMD2SYCr8uG1TcRgM/1/*)#rk3jh5ge",
"internal": true,
"timestamp": 1686055705,
"active": true
}
{
"desc": "wpkh([0d9250da/84h/1h/0h]tpubDDGF9PnrXww2h1mKNjKiXoqdDFGEcZGCZUNq7g26LdzKXKiE31RrFWsogPy1uMLrbG8ksQ8eJS6u6KFLjYUUSVJRuwmMD2SYCr8uG1TcRgM/0/*)#jz5n2pcp",
"internal": false,
"timestamp": 1686055705,
"active": true
},
{
"desc": "wpkh([0d9250da/84h/1h/0h]tpubDDGF9PnrXww2h1mKNjKiXoqdDFGEcZGCZUNq7g26LdzKXKiE31RrFWsogPy1uMLrbG8ksQ8eJS6u6KFLjYUUSVJRuwmMD2SYCr8uG1TcRgM/1/*)#rk3jh5ge",
"internal": true,
"timestamp": 1686055705,
"active": true
}
]
```

Expand All @@ -73,24 +112,8 @@ You can combine the generation and import of descriptors with:
$ eclair-cli getdescriptors | jq --raw-output -c | xargs -0 bitcoin-cli -rpcwallet=eclair importdescriptors
```

:warning: If you are restoring an existing `eclair-signer.conf` file with a timestamp that is fairly old, importing descriptors can take a long time, and your
Bitcoin Core node will not be usable until it's done

5) Configure Eclair to handle private keys for this wallet

Set `eclair.bitcoind.wallet` to the name of the wallet just created (`eclair` in the example above) and restart Eclair.

You now have a Bitcoin Core watch-only wallet for which only your Eclair node can sign transactions. This Bitcoin Core wallet can
safely be copied to another Bitcoin Core node to monitor your onchain funds.

You can also use `eclair-cli getmasterxpub` to get a BIP32 extended public key that you can import into any compatible Bitcoin wallet
to create a watch-only wallet (Electrum for example) that you can use to monitor your Bitcoin Core balance.
:warning: Importing descriptors can take a long time, and your Bitcoin Core node will not be usable until it's done

:warning: this means that your Bitcoin Core wallet cannot send funds on its on (since it cannot access private keys to sign transactions).
To send funds onchain you must use `eclair-cli sendonchain`.
3) Configure Eclair to handle private keys for this wallet

:warning: to backup the private keys of this wallet you must either backup your mnemonic code and passphrase, or backup the `eclair-signer.conf` file in your eclair
directory (default is `~/.eclair`) along with your channels and node seed files.

:warning: You can also initialise a backup onchain wallet with the same mnemonic code and passphrase (a hardware wallet for example), but be warned that using them may interfere with your node's operations (for example you may end up
double-spending funding transactions generated by your node).
Set `eclair.bitcoind.wallet` to the name of the wallet in `eclair-signer.conf` restart Eclair.
2 changes: 1 addition & 1 deletion docs/Configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

* [Configuration file](#configuration-file)
* [Changing the data directory](#changing-the-data-directory)
* [Splitting the configuration](#splitting-the-configuration)\
* [Splitting the configuration](#splitting-the-configuration)
* [Options reference](#options-reference)
* [Customize features](#customize-features)
* [Customize feerate tolerance](#customize-feerate-tolerance)
Expand Down
18 changes: 11 additions & 7 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ trait Eclair {

def sentInfo(id: PaymentIdentifier)(implicit timeout: Timeout): Future[Seq[OutgoingPayment]]

def sendOnChain(address: String, amount: Satoshi, confirmationTarget: Long): Future[ByteVector32]
def sendOnChain(address: String, amount: Satoshi, confirmationTargetOrFeerate: Either[Long, FeeratePerByte]): Future[ByteVector32]

def cpfpBumpFees(targetFeeratePerByte: FeeratePerByte, outpoints: Set[OutPoint]): Future[ByteVector32]

Expand Down Expand Up @@ -341,13 +341,17 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}
}

override def sendOnChain(address: String, amount: Satoshi, confirmationTarget: Long): Future[ByteVector32] = {
val feeRate = if (confirmationTarget < 3) appKit.nodeParams.currentFeerates.fast
else if (confirmationTarget > 6) appKit.nodeParams.currentFeerates.slow
else appKit.nodeParams.currentFeerates.medium

override def sendOnChain(address: String, amount: Satoshi, confirmationTargetOrFeerate: Either[Long, FeeratePerByte]): Future[ByteVector32] = {
val feeRatePerKw = confirmationTargetOrFeerate match {
case Left(blocks) =>
if (blocks < 3)
appKit.nodeParams.currentFeerates.fast
else if (blocks > 6) appKit.nodeParams.currentFeerates.slow
else appKit.nodeParams.currentFeerates.medium
case Right(feeratePerByte) => FeeratePerKw(feeratePerByte)
}
appKit.wallet match {
case w: BitcoinCoreClient => w.sendToPubkeyScript(Script.write(addressToPublicKeyScript(appKit.nodeParams.chainHash, address)), amount, feeRate)
case w: BitcoinCoreClient => w.sendToPubkeyScript(Script.write(addressToPublicKeyScript(appKit.nodeParams.chainHash, address)), amount, feeRatePerKw)
case _ => Future.failed(new IllegalArgumentException("this call is only available with a bitcoin core backend"))
}
}
Expand Down
23 changes: 20 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ import scala.util.{Failure, Success}
class Setup(val datadir: File,
pluginParams: Seq[PluginParams],
seeds_opt: Option[Seeds] = None,
db: Option[Databases] = None,
onchainKeyManager_opt: Option[LocalOnchainKeyManager] = None)(implicit system: ActorSystem) extends Logging {
db: Option[Databases] = None)(implicit system: ActorSystem) extends Logging {

implicit val timeout: Timeout = Timeout(30 seconds)
implicit val formats: org.json4s.Formats = org.json4s.DefaultFormats
Expand Down Expand Up @@ -122,6 +121,8 @@ class Setup(val datadir: File,
// early checks
PortChecker.checkAvailable(serverBindingAddress)

val onchainKeyManager_opt = LocalOnchainKeyManager.load(datadir, NodeParams.hashFromChain(chain))

val (bitcoin, bitcoinChainHash) = {
val wallet = {
val name = config.getString("bitcoind.wallet")
Expand All @@ -140,13 +141,27 @@ class Setup(val datadir: File,
host = config.getString("bitcoind.host"),
port = config.getInt("bitcoind.rpcport"),
wallet = wallet)

def createDescriptorWallet(wallets: List[String]): Future[Boolean] = {
if (wallet.exists(name => wallets.contains(name))) {
// wallet already exists
Future.successful(true)
} else {
new BitcoinCoreClient(bitcoinClient, onchainKeyManager_opt).createDescriptorWallet().recover { case e =>
logger.error(s"cannot create descriptor wallet", e)
throw BitcoinWalletNotCreatedException(wallet.getOrElse(""))
}
}
}

val future = for {
json <- bitcoinClient.invoke("getblockchaininfo").recover { case e => throw BitcoinRPCConnectionException(e) }
// Make sure wallet support is enabled in bitcoind.
wallets <- bitcoinClient.invoke("listwallets").recover { case e => throw BitcoinWalletDisabledException(e) }
.collect {
case JArray(values) => values.map(value => value.extract[String])
}
true <- createDescriptorWallet(wallets)
progress = (json \ "verificationprogress").extract[Double]
ibd = (json \ "initialblockdownload").extract[Boolean]
blocks = (json \ "blocks").extract[Long]
Expand Down Expand Up @@ -247,7 +262,7 @@ class Setup(val datadir: File,

finalPubkey = new AtomicReference[PublicKey](null)
pubkeyRefreshDelay = FiniteDuration(config.getDuration("bitcoind.final-pubkey-refresh-delay").getSeconds, TimeUnit.SECONDS)
bitcoinClient = new BitcoinCoreClient(bitcoin, onchainKeyManager_opt.orElse(LocalOnchainKeyManager.load(datadir, nodeParams.chainHash))) with OnchainPubkeyCache {
bitcoinClient = new BitcoinCoreClient(bitcoin, onchainKeyManager_opt) with OnchainPubkeyCache {
val refresher: typed.ActorRef[OnchainPubkeyRefresher.Command] = system.spawn(Behaviors.supervise(OnchainPubkeyRefresher(this, finalPubkey, pubkeyRefreshDelay)).onFailure(typed.SupervisorStrategy.restart), name = "onchain-address-manager")

override def getP2wpkhPubkey(renew: Boolean): PublicKey = {
Expand Down Expand Up @@ -463,6 +478,8 @@ case class BitcoinDefaultWalletException(loaded: List[String]) extends RuntimeEx

case class BitcoinWalletNotLoadedException(wallet: String, loaded: List[String]) extends RuntimeException(s"configured wallet \"$wallet\" not in the set of loaded bitcoind wallets: ${loaded.map("\"" + _ + "\"").mkString("[", ",", "]")}")

case class BitcoinWalletNotCreatedException(wallet: String) extends RuntimeException(s"configured wallet \"$wallet\" does not exist and could not be created.")

case object EmptyAPIPasswordException extends RuntimeException("must set a password for the json-rpc api")

case object IncompatibleDBException extends RuntimeException("database is not compatible with this version of eclair")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import org.json4s.JsonAST._
import scodec.bits.ByteVector

import java.util.Base64
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters.ListHasAsScala
import scala.util.{Failure, Success, Try}
Expand All @@ -58,6 +59,34 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag

val useEclairSigner = onchainKeyManager_opt.exists(m => rpcClient.wallet.contains(m.wallet))

//------------------------- WALLET -------------------------//
def importDescriptors(descriptors: Seq[Descriptor])(implicit ec: ExecutionContext): Future[Boolean] = {
rpcClient.invoke("importdescriptors", descriptors).collect {
case JArray(results) => results.forall(item => {
val JBool(success) = item \ "success"
val JArray(_) = item \ "warnings"
success
})
}
}

def createDescriptorWallet()(implicit ec: ExecutionContext): Future[Boolean] = {
onchainKeyManager_opt match {
case None => Future.successful(true) // no eclair-backed wallet is configured
case Some(onchainKeyManager) if !rpcClient.wallet.contains(onchainKeyManager.wallet) => Future.successful(true) // configured wallet has a different name
case Some(onchainKeyManager) if !onchainKeyManager.getDescriptors(0).descriptors.forall(desc => TimestampSecond(desc.timestamp) >= TimestampSecond.now() - 2.hours) =>
logger.warn(s"descriptors are too old, you will need to manually import them and select how far back to rescan")
Future.failed(new RuntimeException("Could not import descriptors, please check logs for details"))
case Some(onchainKeyManager) =>
logger.info(s"Creating a new on-chain eclair-backed wallet in bitcoind: ${onchainKeyManager.wallet}")
for {
_ <- rpcClient.invoke("createwallet", onchainKeyManager.wallet, true, true, "", false, true, true)
_ = logger.info(s"importing new descriptors ${onchainKeyManager.getDescriptors(0).descriptors}")
result <- importDescriptors(onchainKeyManager.getDescriptors(0).descriptors)
} yield result
}
}

//------------------------- TRANSACTIONS -------------------------//

def getTransaction(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Transaction] =
Expand Down Expand Up @@ -783,7 +812,7 @@ object BitcoinCoreClient {

def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue)

case class Descriptor(desc: String, internal: Boolean = false, timestamp: Either[String, Long] = Left("now"), active: Boolean = true)
case class Descriptor(desc: String, internal: Boolean = false, timestamp: Long, active: Boolean = true)

case class Descriptors(wallet_name: String, descriptors: Seq[Descriptor])
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ class LocalOnchainKeyManager(override val wallet: String, seed: ByteVector, time
val changeDesc = s"wpkh([${this.fingerPrintHex}/$keyPath]${encode(accountPub, prefix)}/1/*)"

Descriptors(wallet_name = wallet, descriptors = List(
Descriptor(desc = s"$receiveDesc#${descriptorChecksum(receiveDesc)}", internal = false, active = true, timestamp = Right(timestamp.toLong)),
Descriptor(desc = s"$changeDesc#${descriptorChecksum(changeDesc)}", internal = true, active = true, timestamp = Right(timestamp.toLong)),
Descriptor(desc = s"$receiveDesc#${descriptorChecksum(receiveDesc)}", internal = false, active = true, timestamp = timestamp.toLong),
Descriptor(desc = s"$changeDesc#${descriptorChecksum(changeDesc)}", internal = true, active = true, timestamp = timestamp.toLong),
))
}

Expand Down
Loading

0 comments on commit af91698

Please sign in to comment.