Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Delegate Bitcoin Core's private key management to Eclair #2613

Merged
merged 8 commits into from
Sep 21, 2023
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ limitdescendantcount=20

Setting these parameters lets you unblock long chains of unconfirmed channel funding transactions by using child-pays-for-parent (CPFP) to make them confirm.

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

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
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](./ManagingBitcoinCoreKeys.md)
* [Use Tor with Eclair](./Tor.md)
* [Multipart Payments](./MultipartPayments.md)
* [Trampoline Payments](./TrampolinePayments.md)
Expand Down
80 changes: 80 additions & 0 deletions docs/ManagingBitcoinCoreKeys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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 feature was designed to take advantage of deployment 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 on-chain key management to eclair:

### 1. Generate 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. Bitcoin core will rescan the blockchain from this UNIX timestamp. Set it to the wallet creation timestamp for simplicity, or a later date if you only have recent UTXOs and you know what you are doing.

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

### 3. Use Eclair to generate descriptors and import them into a new bitcoin wallet

Restart eclair, without changing `eclair.bitcoind.wallet` (so it uses the default wallet or the previously used bitcoin wallet for existing nodes).

Create a new empty, decriptor-enabled wallet on your new Bitcoin Core node.

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

Generate the descriptors with your Eclair node and import them into a Bitcoin node with the following commands:

```shell
$ eclair-cli getdescriptors | jq --raw-output -c > descriptors.json
$ cat descriptors.json | xargs -0 bitcoin-cli -rpcwallet=eclair importdescriptors
```

Bitcoin core will import descriptors and rescan the blockchain from the time set in `eclair-signer.conf`.
This can take a long time (if you're moving an old existing node to a new setup for example) and your Bitcoin Core node will not be usable until it's done.

### 4. Configure Eclair to use the wallet you created and restart Eclair

In your `eclair.conf`, set `eclair.bitcoind.wallet` to the name of the wallet in `eclair-signer.conf`, 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 on-chain funds.

: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 on-chain 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 initialize a backup on-chain 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).

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.
6 changes: 6 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ eclair.on-chain-fees.confirmation-priority {

This configuration section replaces the previous `eclair.on-chain-fees.target-blocks` section.

### Managing Bitcoin Core wallet keys

You can now use Eclair to manage the private keys for on-chain funds monitored by a Bitcoin Core watch-only wallet.

See `docs/BitcoinCoreKeys.md` for more details.

### API changes

- `bumpforceclose` can be used to make a force-close confirm faster, by spending the anchor output (#2743)
Expand Down
52 changes: 39 additions & 13 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptors, WalletTx}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
Expand Down Expand Up @@ -130,7 +131,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 @@ -180,6 +181,10 @@ trait Eclair {

def payOfferBlocking(offer: Offer, amount: MilliSatoshi, quantity: Long, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[PaymentEvent]

def getOnChainMasterPubKey(account: Long): String

def getDescriptors(account: Long): Descriptors

def stop(): Future[Unit]
}

Expand Down Expand Up @@ -352,9 +357,20 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}
}

override def sendOnChain(address: String, amount: Satoshi, confirmationTarget: Long): Future[ByteVector32] = {
override def sendOnChain(address: String, amount: Satoshi, confirmationTargetOrFeerate: Either[Long, FeeratePerByte]): Future[ByteVector32] = {
val feeRate = 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.sendToAddress(address, amount, confirmationTarget)
case w: BitcoinCoreClient =>
addressToPublicKeyScript(appKit.nodeParams.chainHash, address) match {
case Right(pubkeyScript) => w.sendToPubkeyScript(pubkeyScript, amount, feeRate)
case Left(failure) => Future.failed(new IllegalArgumentException(s"invalid address ($failure)"))
}
case _ => Future.failed(new IllegalArgumentException("this call is only available with a bitcoin core backend"))
}
}
Expand Down Expand Up @@ -665,16 +681,16 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}
}

def payOfferInternal(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String],
connectDirectly: Boolean,
blocking: Boolean)(implicit timeout: Timeout): Future[Any] = {
private def payOfferInternal(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String],
connectDirectly: Boolean,
blocking: Boolean)(implicit timeout: Timeout): Future[Any] = {
if (externalId_opt.exists(_.length > externalIdMaxLength)) {
return Future.failed(new IllegalArgumentException(s"externalId is too long: cannot exceed $externalIdMaxLength characters"))
}
Expand Down Expand Up @@ -717,6 +733,16 @@ 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): Descriptors = appKit.nodeParams.onChainKeyManager_opt match {
case Some(keyManager) => keyManager.descriptors(account)
case _ => throw new RuntimeException("on-chain seed is not configured")
}

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

override def stop(): Future[Unit] = {
// README: do not make this smarter or more complex !
// eclair can simply and cleanly be stopped by killing its process without fear of losing data, payments, ... and it should remain this way.
Expand Down
11 changes: 7 additions & 4 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ 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}
import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager, OnChainKeyManager}
import fr.acinq.eclair.db._
import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy}
import fr.acinq.eclair.io.PeerConnection
import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
import fr.acinq.eclair.router.Announcements.AddressException
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios}
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.router.{Graph, PathFindingExperimentConf}
import fr.acinq.eclair.router.Router.{MessageRouteParams, MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries}
import fr.acinq.eclair.tor.Socks5ProxyParams
import fr.acinq.eclair.wire.protocol._
import grizzled.slf4j.Logging
Expand All @@ -54,6 +54,7 @@ import scala.jdk.CollectionConverters._
*/
case class NodeParams(nodeKeyManager: NodeKeyManager,
channelKeyManager: ChannelKeyManager,
onChainKeyManager_opt: Option[OnChainKeyManager],
instanceId: UUID, // a unique instance ID regenerated after each restart
private val blockHeight: AtomicLong,
private val feerates: AtomicReference[FeeratesPerKw],
Expand Down Expand Up @@ -101,7 +102,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
def currentFeerates: FeeratesPerKw = feerates.get()

/** Only to be used in tests. */
def setFeerates(value: FeeratesPerKw) = feerates.set(value)
def setFeerates(value: FeeratesPerKw): Unit = feerates.set(value)

/** Returns the features that should be used in our init message with the given peer. */
def initFeaturesFor(nodeId: PublicKey): Features[InitFeature] = overrideInitFeatures.getOrElse(nodeId, features).initFeatures()
Expand Down Expand Up @@ -211,7 +212,8 @@ object NodeParams extends Logging {
}
}

def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager,
def makeNodeParams(config: Config, instanceId: UUID,
nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, onChainKeyManager_opt: Option[OnChainKeyManager],
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 @@ -475,6 +477,7 @@ object NodeParams extends Logging {
NodeParams(
nodeKeyManager = nodeKeyManager,
channelKeyManager = channelKeyManager,
onChainKeyManager_opt = onChainKeyManager_opt,
instanceId = instanceId,
blockHeight = blockHeight,
feerates = feerates,
Expand Down
Loading