Skip to content

Commit

Permalink
Add a separate configuration file for Eclair's onchain signer
Browse files Browse the repository at this point in the history
Eclair's onchain signer now has its own `eclair-signer.conf` configuration file in HOCON format.
It includes BIP39 mnemonic codes and passphrase, a wallet name and a timestamp.

When an `eclair-signer.conf` file is found, Eclair's API will return descriptors that can be imported into an
empty watch-only Bitcoin Wallet.
When wallet name in `eclair-signer.conf` matches the name of the Bitcoin Core wallet defined in `eclair.conf`
(`eclair.bitcoind.wallet`), Eclair will bypass Bitcoin Core and sign onchain transactions directly.
  • Loading branch information
sstone committed Jul 31, 2023
1 parent 45b6757 commit 0e116a6
Show file tree
Hide file tree
Showing 27 changed files with 211 additions and 177 deletions.
73 changes: 1 addition & 72 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,78 +165,7 @@ Setting these parameters lets you unblock long chains of unconfirmed channel fun
With the default `bitcoind` parameters, if your node created a chain of 25 unconfirmed funding transactions with a low-feerate, you wouldn't be able to use CPFP to raise their fees because your CPFP transaction would likely be rejected by
the rest of the network.

#### 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
is less secure than for Eclair (because it is shared among several services for example).

1) Create an empty, descriptor-enabled, watch-only wallet in Bitcoin Core:

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

2) Import public descriptors generated by 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`

This is an example of descriptors generated by Eclair:

```json
{
"wallet_name": "eclair",
"descriptors": [
{
"desc": "wpkh([0f09f381/84'/1'/0']tpubDCAVQRxWnkXjyYbsHdUsHA7krYSRyVS8EuWeWE6K2V34goMaUCdCTjfSFS8ZkE5iESaWQsZoM9HL7ZANi5bW7Ly3EqDqthEyvdZHSrBTNHq/0/*)#gj3mq2sl",
"timestamp": 1684150749,
"active": true,
"internal": false,
"range": [
0,
1003
],
"next": 4
},
{
"desc": "wpkh([0f09f381/84'/1'/0']tpubDCAVQRxWnkXjyYbsHdUsHA7krYSRyVS8EuWeWE6K2V34goMaUCdCTjfSFS8ZkE5iESaWQsZoM9HL7ZANi5bW7Ly3EqDqthEyvdZHSrBTNHq/1/*)#ex56alq8",
"timestamp": 1684150749,
"active": true,
"internal": true,
"range": [
0,
1001
],
"next": 2
}
]
}
```

You can combine the generation and import of descriptors with:

```shell
$ eclair-cli getdescriptors | jq --raw-output -c | xargs -0 bitcoin-cli -rpcwallet=eclair importdescriptors
```

3) Configure Eclair to handle private keys for this wallet

Add the following lines to your `eclair.conf` file:

```
eclair.bitcoind.wallet = eclair
eclair.bitcoind.use-eclair-signer = true
```

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

: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`.

:warning: to backup the private keys of this wallet you must backup the onchain seed file `onchain_seed.dat` that is located in your eclair
directory (default is `~/.eclair`) along with your channels and node seed files.
You can also configure Eclair to manage Bitcoin Core's private keys, see our [guides](./docs/Guides.md) for more details.

### Java Environment Variables

Expand Down
96 changes: 96 additions & 0 deletions docs/BitcoinCoreKeys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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
is less secure than for Eclair (because it is shared among several services for example).

Follow these steps to delegate onchain key management to eclair:

1) Generate or import a BIP39 mnemonic code and passphrase

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

2) Create an `eclair-signer.conf` configuration file add it to eclair's data directory

A signer configuration file uses the HOCON format that we already use for `eclair.conf` and must include the following options:

key | description
--------------------------|--------------------------------------------------------------------------
eclair.signer.wallet | wallet name
eclair.signer.mnemonics | BIP39 mnemonic words
eclair.signer.passphrase | passphrase
eclair.signer.timestamp | wallet creation UNIX timestamp. Set to the current time for new wallets.

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

You must set `eclair.signer.wallet` to a name that is different from your current Bitcoin Core wallet.

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

`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
}
]
```

You can combine the generation and import of descriptors with:

```shell
$ 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: 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`.

: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).
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
1 change: 1 addition & 0 deletions docs/Guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This section contains how-to guides for more advanced scenarios:

* [Customize Logging](./Logging.md)
* [Customize Features](./Features.md)
* [Manage Bitcoin Core's private keys](./BitcoinCoreKeys.md)
* [Use Tor with Eclair](./Tor.md)
* [Multipart Payments](./MultipartPayments.md)
* [Trampoline Payments](./TrampolinePayments.md)
Expand Down
14 changes: 10 additions & 4 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ 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.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.WalletTx
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptors, WalletTx}
import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
Expand Down Expand Up @@ -180,7 +180,7 @@ trait Eclair {

def getOnchainMasterPubKey(account: Long): String

def getDescriptors(account: Long): (List[String], List[String])
def getDescriptors(account: Long): Descriptors

def stop(): Future[Unit]
}
Expand Down Expand Up @@ -705,9 +705,15 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
payOfferInternal(offer, amount, quantity, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent]
}

override def getDescriptors(account: Long): (List[String], List[String]) = this.appKit.nodeParams.onchainKeyManager.getDescriptors(account)
override def getDescriptors(account: Long): Descriptors = appKit.wallet match {
case bitcoinCoreClient: BitcoinCoreClient if bitcoinCoreClient.onchainKeyManager_opt.isDefined => bitcoinCoreClient.onchainKeyManager_opt.get.getDescriptors(account)
case _ => throw new RuntimeException("onchain seed is not configured")
}

override def getOnchainMasterPubKey(account: Long): String = this.appKit.nodeParams.onchainKeyManager.getOnchainMasterPubKey(account)
override def getOnchainMasterPubKey(account: Long): String = appKit.wallet match {
case bitcoinCoreClient: BitcoinCoreClient if bitcoinCoreClient.onchainKeyManager_opt.isDefined => bitcoinCoreClient.onchainKeyManager_opt.get.getOnchainMasterPubKey(account)
case _ => throw new RuntimeException("onchain seed is not configured")
}

override def stop(): Future[Unit] = {
// README: do not make this smarter or more complex !
Expand Down
10 changes: 3 additions & 7 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import fr.acinq.eclair.channel.ChannelFlags
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.fsm.Channel.{ChannelConf, UnhandledExceptionStrategy}
import fr.acinq.eclair.crypto.Noise.KeyPair
import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager, OnchainKeyManager}
import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager}
import fr.acinq.eclair.db._
import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy}
import fr.acinq.eclair.io.PeerConnection
Expand Down Expand Up @@ -54,7 +54,6 @@ import scala.jdk.CollectionConverters._
*/
case class NodeParams(nodeKeyManager: NodeKeyManager,
channelKeyManager: ChannelKeyManager,
onchainKeyManager: OnchainKeyManager,
instanceId: UUID, // a unique instance ID regenerated after each restart
private val blockHeight: AtomicLong,
private val feerates: AtomicReference[FeeratesPerKw],
Expand Down Expand Up @@ -164,7 +163,6 @@ object NodeParams extends Logging {
val oldSeedPath = new File(datadir, "seed.dat")
val nodeSeedFilename: String = "node_seed.dat"
val channelSeedFilename: String = "channel_seed.dat"
val onchainSeedFilename: String = "onchain_seed.dat"

def getSeed(filename: String): ByteVector = {
val seedPath = new File(datadir, filename)
Expand All @@ -182,8 +180,7 @@ object NodeParams extends Logging {

val nodeSeed = getSeed(nodeSeedFilename)
val channelSeed = getSeed(channelSeedFilename)
val onchainSeed = getSeed(onchainSeedFilename)
Seeds(nodeSeed, channelSeed, onchainSeed)
Seeds(nodeSeed, channelSeed)
}

private val chain2Hash: Map[String, ByteVector32] = Map(
Expand Down Expand Up @@ -214,7 +211,7 @@ object NodeParams extends Logging {
}
}

def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, onchainKeyManager: OnchainKeyManager,
def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager,
torAddress_opt: Option[NodeAddress], database: Databases, blockHeight: AtomicLong, feerates: AtomicReference[FeeratesPerKw],
pluginParams: Seq[PluginParams] = Nil): NodeParams = {
// check configuration for keys that have been renamed
Expand Down Expand Up @@ -478,7 +475,6 @@ object NodeParams extends Logging {
NodeParams(
nodeKeyManager = nodeKeyManager,
channelKeyManager = channelKeyManager,
onchainKeyManager = onchainKeyManager,
instanceId = instanceId,
blockHeight = blockHeight,
feerates = feerates,
Expand Down
17 changes: 7 additions & 10 deletions eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ import scala.util.{Failure, Success}
class Setup(val datadir: File,
pluginParams: Seq[PluginParams],
seeds_opt: Option[Seeds] = None,
db: Option[Databases] = None)(implicit system: ActorSystem) extends Logging {
db: Option[Databases] = None,
onchainKeyManager_opt: Option[LocalOnchainKeyManager] = None)(implicit system: ActorSystem) extends Logging {

implicit val timeout: Timeout = Timeout(30 seconds)
implicit val formats: org.json4s.Formats = org.json4s.DefaultFormats
Expand All @@ -95,17 +96,13 @@ class Setup(val datadir: File,

datadir.mkdirs()
val config = system.settings.config.getConfig("eclair")
val Seeds(nodeSeed, channelSeed, onchainSeed) = seeds_opt.getOrElse(NodeParams.getSeeds(datadir))
val Seeds(nodeSeed, channelSeed) = seeds_opt.getOrElse(NodeParams.getSeeds(datadir))
val chain = config.getString("chain")

val chaindir = new File(datadir, chain)
chaindir.mkdirs()
val nodeKeyManager = new LocalNodeKeyManager(nodeSeed, NodeParams.hashFromChain(chain))
val channelKeyManager = new LocalChannelKeyManager(channelSeed, NodeParams.hashFromChain(chain))
val onchainKeyManager = {
val passphrase = if (config.hasPath("bitcoind.eclair-signer-passphrase")) config.getString("bitcoind.eclair-signer-passphrase") else ""
new LocalOnchainKeyManager(onchainSeed, NodeParams.hashFromChain(chain), passphrase)
}

/**
* This counter holds the current blockchain height.
Expand Down Expand Up @@ -188,7 +185,7 @@ class Setup(val datadir: File,
logger.info(s"connecting to database with instanceId=$instanceId")
val databases = Databases.init(config.getConfig("db"), instanceId, chaindir, db)

val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, onchainKeyManager, initTor(), databases, blockHeight, feeratesPerKw, pluginParams)
val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, initTor(), databases, blockHeight, feeratesPerKw, pluginParams)

logger.info(s"nodeid=${nodeParams.nodeId} alias=${nodeParams.alias}")
assert(bitcoinChainHash == nodeParams.chainHash, s"chainHash mismatch (conf=${nodeParams.chainHash} != bitcoind=$bitcoinChainHash)")
Expand Down Expand Up @@ -250,8 +247,7 @@ class Setup(val datadir: File,

finalPubkey = new AtomicReference[PublicKey](null)
pubkeyRefreshDelay = FiniteDuration(config.getDuration("bitcoind.final-pubkey-refresh-delay").getSeconds, TimeUnit.SECONDS)
useEclairSigner = if (config.hasPath("bitcoind.use-eclair-signer")) config.getBoolean("bitcoind.use-eclair-signer") else false
bitcoinClient = new BitcoinCoreClient(bitcoin, if (useEclairSigner) Some(onchainKeyManager) else None) with OnchainPubkeyCache {
bitcoinClient = new BitcoinCoreClient(bitcoin, onchainKeyManager_opt.orElse(LocalOnchainKeyManager.load(datadir, nodeParams.chainHash))) 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 All @@ -260,6 +256,7 @@ class Setup(val datadir: File,
key
}
}
_ = if (bitcoinClient.useEclairSigner) logger.info("using eclair to sign bitcoin core transactions")
initialPubkey <- bitcoinClient.getP2wpkhPubkey()
_ = finalPubkey.set(initialPubkey)

Expand Down Expand Up @@ -426,7 +423,7 @@ class Setup(val datadir: File,

object Setup {

final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector, onchainSeed: ByteVector)
final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector)

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}

class BasicBitcoinJsonRPCClient(rpcAuthMethod: BitcoinJsonRPCAuthMethod, host: String = "127.0.0.1", port: Int = 8332, ssl: Boolean = false, wallet: Option[String] = None)(implicit sb: SttpBackend[Future, _]) extends BitcoinJsonRPCClient {
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 {

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}

class BatchingBitcoinJsonRPCClient(rpcClient: BasicBitcoinJsonRPCClient)(implicit system: ActorSystem, ec: ExecutionContext) extends BitcoinJsonRPCClient {
override def wallet: Option[String] = rpcClient.wallet

implicit val timeout: Timeout = Timeout(1 hour)

Expand Down
Loading

0 comments on commit 0e116a6

Please sign in to comment.