diff --git a/docs/BitcoinCoreKeys.md b/docs/BitcoinCoreKeys.md index b01f5c5b64..ba8b9de14f 100644 --- a/docs/BitcoinCoreKeys.md +++ b/docs/BitcoinCoreKeys.md @@ -6,22 +6,22 @@ is less secure than for Eclair (because it is shared among several services for ## Configuring Eclair and Bitcoin Core to use a new Eclair-backed bitcoin wallet -Follow these steps to delegate onchain key management to eclair: +Follow these steps to delegate on-chain key management to eclair: -1) Generate 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. -2) Create an `eclair-signer.conf` configuration file add it to eclair's data directory +### 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 + 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. + 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: @@ -38,7 +38,7 @@ This is an example of `eclair-signer.conf` configuration file: } ``` -3) Configure Eclair to handle private keys for this wallet +### 3. Configure Eclair to handle private keys for this wallet Set `eclair.bitcoind.wallet` to the name of the wallet in your `eclair-signer.conf` file (`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. @@ -48,45 +48,38 @@ passphrase that your are using are new, you can safely update this timestamp, bu 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. +safely be copied to another Bitcoin Core node to monitor your on-chain 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`. +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 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 +: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). -## Importing an existing Eclair-backed bitcoin core wallet +## 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: +The steps above described how you can simply switch to a new Eclair-backed bitcoin wallet. +Follow the steps below if you are already using an Eclair-backed bitcoin wallet that you want to move to another Bitcoin Core node. -### Copy the bitcoin core wallet and `eclair-signer.conf` +### 1. Create an empty, descriptor-enabled, watch-only wallet in Bitcoin Core -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. +Start by creating a watch-only wallet on your new Bitcoin Core node. -### 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") +: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 ``` -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. +### 2. Import public descriptors generated by Eclair -`eclair-cli getdescriptors` 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` +Calling `eclair-cli getdescriptors` on your existing Eclair node will return public wallet descriptors in a format that is compatible with Bitcoin Core. This is an example of descriptors generated by Eclair: ```json @@ -106,7 +99,7 @@ This is an example of descriptors generated by Eclair: ] ``` -You can generate the descriptors with your Eclair node and import them into a Bitcoin node with the following commands: +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 @@ -115,6 +108,15 @@ $ cat descriptors.json | xargs -0 bitcoin-cli -rpcwallet=eclair importdescriptor :warning: Importing descriptors can take a long time, and your Bitcoin Core node will not be usable until it's done -3) Configure Eclair to handle private keys for this wallet +### 3. Configure Eclair to use your new Bitcoin Core node + +Once your new Bitcoin Core node has finished importing the descriptors, it is ready to be used by Eclair. + +In your `eclair.conf`: + +- set `eclair.bitcoind.host` to the address of your new Bitcoin Core node +- set `eclair.bitcoind.wallet` to the name of the wallet in `eclair-signer.conf` +- set `eclair.bitcoind.zmqblock` and `eclair.bitcoind.zmqtx` to use your new Bitcoin Core node +- update other field in the `eclair.bitcoind` section if necessary (`rpcport`, `auth`, `rpcuser`, `rpcuser`, etc) -Set `eclair.bitcoind.wallet` to the name of the wallet in `eclair-signer.conf` and restart Eclair. +Restart Eclair and it will start using your new Bitcoin Core node. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index f49d7df1a8..b7ee3bdee2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -181,7 +181,7 @@ 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 getOnChainMasterPubKey(account: Long): String def getDescriptors(account: Long): Descriptors @@ -681,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")) } @@ -733,14 +733,14 @@ 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.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 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.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 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] = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 9b7ce7535c..8bdeedd873 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -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} +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 @@ -33,8 +33,8 @@ 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 @@ -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], @@ -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() @@ -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 @@ -475,6 +477,7 @@ object NodeParams extends Logging { NodeParams( nodeKeyManager = nodeKeyManager, channelKeyManager = channelKeyManager, + onChainKeyManager_opt = onChainKeyManager_opt, instanceId = instanceId, blockHeight = blockHeight, feerates = feerates, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index f284cfb061..f1653bf615 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -34,7 +34,7 @@ import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel.Register import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.crypto.WeakEntropyPool -import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager, LocalOnchainKeyManager} +import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager, LocalOnChainKeyManager} import fr.acinq.eclair.db.Databases.FileBackup import fr.acinq.eclair.db.FileBackupHandler.FileBackupParams import fr.acinq.eclair.db.{Databases, DbEventHandler, FileBackupHandler} @@ -121,7 +121,7 @@ class Setup(val datadir: File, // early checks PortChecker.checkAvailable(serverBindingAddress) - val onchainKeyManager_opt = LocalOnchainKeyManager.load(datadir, NodeParams.hashFromChain(chain)) + val onChainKeyManager_opt = LocalOnChainKeyManager.load(datadir, NodeParams.hashFromChain(chain)) val (bitcoin, bitcoinChainHash) = { val wallet = { @@ -142,18 +142,6 @@ class Setup(val datadir: File, port = config.getInt("bitcoind.rpcport"), wallet = wallet) - def createEclairBackedWallet(wallets: List[String]): Future[Boolean] = { - if (wallet.exists(name => wallets.contains(name))) { - // wallet already exists - Future.successful(true) - } else { - new BitcoinCoreClient(bitcoinClient, onchainKeyManager_opt).createEclairBackedWallet().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. @@ -161,8 +149,11 @@ class Setup(val datadir: File, .collect { case JArray(values) => values.map(value => value.extract[String]) } - walletCreated <- createEclairBackedWallet(wallets) - _ = assert(walletCreated, "Cannot create eclair-backed wallet, check logs for details") + eclairBackedWalletOk <- onChainKeyManager_opt match { + case Some(keyManager) if !wallets.contains(keyManager.wallet) => keyManager.createWallet(bitcoinClient) + case _ => Future.successful(true) + } + _ = assert(eclairBackedWalletOk || onChainKeyManager_opt.map(_.wallet) != wallet, s"cannot create eclair-backed wallet=${onChainKeyManager_opt.map(_.wallet)}, check logs for details") progress = (json \ "verificationprogress").extract[Double] ibd = (json \ "initialblockdownload").extract[Boolean] blocks = (json \ "blocks").extract[Long] @@ -201,7 +192,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, initTor(), databases, blockHeight, feeratesPerKw, pluginParams) + val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, onChainKeyManager_opt, 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)") @@ -236,9 +227,7 @@ class Setup(val datadir: File, minFeeratePerByte = FeeratePerByte(Satoshi(config.getLong("on-chain-fees.min-feerate"))) smoothFeerateWindow = config.getInt("on-chain-fees.smoothing-window") feeProvider = nodeParams.chainHash match { - case Block.RegtestGenesisBlock.hash => - FallbackFeeProvider(ConstantFeeProvider(defaultFeerates) :: Nil, minFeeratePerByte) - case Block.SignetGenesisBlock.hash => + case Block.RegtestGenesisBlock.hash | Block.SignetGenesisBlock.hash => FallbackFeeProvider(ConstantFeeProvider(defaultFeerates) :: Nil, minFeeratePerByte) case _ => FallbackFeeProvider(SmoothFeeProvider(BitcoinCoreFeeProvider(bitcoin, defaultFeerates), smoothFeerateWindow) :: Nil, minFeeratePerByte) @@ -263,7 +252,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) with OnchainPubkeyCache { + bitcoinClient = new BitcoinCoreClient(bitcoin, if (bitcoin.wallet == onChainKeyManager_opt.map(_.wallet)) 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") override def getP2wpkhPubkey(renew: Boolean): PublicKey = { @@ -325,7 +314,7 @@ class Setup(val datadir: File, routerTimeout = after(FiniteDuration(config.getDuration("router.init-timeout").getSeconds, TimeUnit.SECONDS), using = system.scheduler)(Future.failed(new RuntimeException("Router initialization timed out"))) _ <- Future.firstCompletedOf(routerInitialized.future :: routerTimeout :: Nil) - _ = bitcoinClient.getReceiveAddress().map(address => logger.info(s"initial wallet address=$address")) + _ = bitcoinClient.getReceiveAddress().map(address => logger.info(s"initial address=$address for bitcoin wallet=${bitcoinClient.rpcClient.wallet.getOrElse("")}")) channelsListener = system.spawn(ChannelsListener(channelsListenerReady), name = "channels-listener") _ <- channelsListenerReady.future @@ -479,8 +468,6 @@ 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") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala index 47c5bb0c0d..0138f857af 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala @@ -19,7 +19,6 @@ package fr.acinq.eclair.blockchain import fr.acinq.bitcoin.psbt.Psbt import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, Transaction} -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.ProcessPsbtResponse import fr.acinq.eclair.blockchain.fee.FeeratePerKw import scodec.bits.ByteVector @@ -34,12 +33,13 @@ trait OnChainChannelFunder { import OnChainWallet._ - /** Fund the provided transaction by adding inputs (and a change output if necessary). */ - def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty)(implicit ec: ExecutionContext): Future[FundTransactionResponse] - /** - * sign a PSBT. Result may be partially signed: only inputs known to our bitcoin core wallet will be signed + * Fund the provided transaction by adding inputs (and a change output if necessary). + * Callers must verify that the resulting transaction isn't sending funds to unexpected addresses (malicious bitcoin node). */ + def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty)(implicit ec: ExecutionContext): Future[FundTransactionResponse] + + /** Sign a PSBT. Result may be partially signed: only inputs known to our bitcoin wallet will be signed. */ def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int])(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] /** @@ -125,4 +125,30 @@ object OnChainWallet { final case class FundTransactionResponse(tx: Transaction, fee: Satoshi, changePosition: Option[Int]) { val amountIn: Satoshi = fee + tx.txOut.map(_.amount).sum } + + final case class ProcessPsbtResponse(psbt: Psbt, complete: Boolean) { + + import fr.acinq.bitcoin.psbt.UpdateFailure + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + + /** Transaction with all available witnesses. */ + val partiallySignedTx: Transaction = { + var tx = psbt.getGlobal.getTx + for (i <- 0 until psbt.getInputs.size()) { + Option(psbt.getInputs.get(i).getScriptWitness).foreach { witness => + tx = tx.updateWitness(i, witness) + } + } + tx + } + + /** Extract a fully signed transaction if the psbt is finalized. */ + val finalTx_opt: Either[UpdateFailure, Transaction] = { + val extracted: Either[UpdateFailure, fr.acinq.bitcoin.Transaction] = psbt.extract() + extracted match { + case Left(f) => Left(f) + case Right(tx) => Right(tx) + } + } + } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 6e646d9a9e..7ae6e641c2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -16,16 +16,16 @@ package fr.acinq.eclair.blockchain.bitcoind.rpc -import fr.acinq.bitcoin.psbt.{Psbt, UpdateFailure} +import fr.acinq.bitcoin.psbt.Psbt 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} +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.crypto.keymanager.OnchainKeyManager +import fr.acinq.eclair.crypto.keymanager.OnChainKeyManager import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol.ChannelAnnouncement import fr.acinq.eclair.{BlockHeight, TimestampSecond, TxCoordinates} @@ -35,7 +35,6 @@ 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} @@ -48,45 +47,20 @@ import scala.util.{Failure, Success, Try} * The Bitcoin Core client provides some high-level utility methods to interact with Bitcoin Core. * * @param rpcClient bitcoin core JSON rpc client - * @param onchainKeyManager_opt optional onchain key manager. If provided and its wallet name matches our rpcClient wallet's name, it will be used to sign transactions (it is assumed that bitcoin - * core uses a watch-only wallet with descriptors generated by Eclair with this onchain key manager) + * @param onChainKeyManager_opt optional on-chain key manager. If provided it will be used to sign transactions (it is assumed that bitcoin + * core uses a watch-only wallet with descriptors generated by Eclair with this on-chain key manager) */ -class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManager_opt: Option[OnchainKeyManager] = None) extends OnChainWallet with Logging { +class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManager_opt: Option[OnChainKeyManager] = None) extends OnChainWallet with Logging { import BitcoinCoreClient._ implicit val formats: Formats = org.json4s.DefaultFormats - 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" - success - }) - } + onChainKeyManager_opt.foreach { keyManager => + require(rpcClient.wallet.contains(keyManager.wallet), s"eclair-backed bitcoin wallet mismatch: eclair-signer.conf uses wallet=${keyManager.wallet}, but eclair.conf uses wallet=${rpcClient.wallet.getOrElse("")}") } - def createEclairBackedWallet()(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.getWalletTimestamp() < (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 { - // arguments to createwallet: wallet_name, disable_private_keys=true, blank=true, passphrase="", avoid_reuse=false, descriptors=true, load_on_startup=true - // we create an empty watch-only wallet, and then import our public key descriptors - _ <- 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 - } - } + val useEclairSigner = onChainKeyManager_opt.nonEmpty //------------------------- TRANSACTIONS -------------------------// @@ -259,6 +233,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag } //------------------------- FUNDING -------------------------// + def fundTransaction(tx: Transaction, options: FundTransactionOptions)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { rpcClient.invoke("fundrawtransaction", tx.toString(), options).flatMap(json => { val JString(hex) = json \ "hex" @@ -272,12 +247,10 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag Try { require(addedOutputs <= 1, "more than one change output added") require(addedOutputs == 0 || changePos >= 0, "change output added, but position not returned") - require(changePos < 0 || !tx.txOut.contains(fundedTx.txOut(changePos.intValue)), "existing output returned as change output") - require(options.changePosition.isEmpty || changePos == options.changePosition.get || changePos == -1, "change position added at wrong position") + require(options.changePosition.isEmpty || changePos_opt.isEmpty || changePos_opt == options.changePosition, "change output added at wrong position") FundTransactionResponse(fundedTx, toSatoshi(fee), changePos_opt) - } - match { + } match { case Success(response) => Future.successful(response) case Failure(error) => unlockOutpoints(walletInputs.toSeq).flatMap(_ => Future.failed(error)) } @@ -288,76 +261,72 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, inputWeights = externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq)) } - def processPsbt(psbt: Psbt, sign: Boolean = true, sighashType: Option[Int] = None)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = { - // we use an explicit map here because this RPC calls takes a String for the "sighashtype" parameter with an explicitly limited list of valid values - val sighashStrings = Map( - SigHash.SIGHASH_DEFAULT -> "DEFAULT", - SigHash.SIGHASH_ALL -> "ALL", - SigHash.SIGHASH_NONE -> "NONE", - SigHash.SIGHASH_SINGLE -> "SINGLE", - (SigHash.SIGHASH_ALL | SigHash.SIGHASH_ANYONECANPAY) -> "ALL|ANYONECANPAY", - (SigHash.SIGHASH_NONE | SigHash.SIGHASH_ANYONECANPAY) -> "NONE|ANYONECANPAY", - (SigHash.SIGHASH_SINGLE | SigHash.SIGHASH_ANYONECANPAY) -> "SINGLE|ANYONECANPAY") - val sighash = sighashType.map(s => sighashStrings.getOrElse(s, throw new IllegalArgumentException(s"invalid sighash flag ${sighashType}"))) + private def processPsbt(psbt: Psbt, sign: Boolean = true, sighashType: Option[Int] = None)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = { + // This RPC call takes a string for the "sighashtype" parameter with an explicitly limited list of valid values. + val sighash = sighashType match { + case None => None + case Some(sighash) if sighash == SigHash.SIGHASH_DEFAULT => Some("DEFAULT") + case Some(sighash) if sighash == SigHash.SIGHASH_ALL => Some("ALL") + case Some(sighash) if sighash == SigHash.SIGHASH_NONE => Some("NONE") + case Some(sighash) if sighash == SigHash.SIGHASH_SINGLE => Some("SINGLE") + case Some(sighash) if sighash == (SigHash.SIGHASH_ALL | SigHash.SIGHASH_ANYONECANPAY) => Some("ALL|ANYONECANPAY") + case Some(sighash) if sighash == (SigHash.SIGHASH_NONE | SigHash.SIGHASH_ANYONECANPAY) => Some("NONE|ANYONECANPAY") + case Some(sighash) if sighash == (SigHash.SIGHASH_SINGLE | SigHash.SIGHASH_ANYONECANPAY) => Some("SINGLE|ANYONECANPAY") + case _ => return Future.failed(new IllegalArgumentException(s"invalid sighash flag $sighashType")) + } val encoded = Base64.getEncoder.encodeToString(Psbt.write(psbt).toByteArray) val params = Seq(encoded, sign) ++ sighash.toSeq rpcClient.invoke("walletprocesspsbt", params: _*).map(json => { val JString(base64) = json \ "psbt" val JBool(complete) = json \ "complete" val decoded = Psbt.read(Base64.getDecoder.decode(base64)) - require(decoded.isRight, s"cannot decode psbt from $base64") + require(decoded.isRight, s"cannot decode processed psbt=$base64") ProcessPsbtResponse(decoded.getRight, complete) }) } - def utxoUpdatePsbt(psbt: Psbt)(implicit ec: ExecutionContext): Future[Psbt] = { + private def utxoUpdatePsbt(psbt: Psbt)(implicit ec: ExecutionContext): Future[Psbt] = { val encoded = Base64.getEncoder.encodeToString(Psbt.write(psbt).toByteArray) - rpcClient.invoke("utxoupdatepsbt", encoded).map(json => { val JString(base64) = json - val bin = Base64.getDecoder.decode(base64) - val decoded = Psbt.read(bin) - require(decoded.isRight, s"cannot decode psbt from $base64") - val psbt = decoded.getRight - psbt + val decoded = Psbt.read(Base64.getDecoder.decode(base64)) + require(decoded.isRight, s"cannot decode updated psbt=$base64") + decoded.getRight }) } - private def unlockIfFails[T](txid: ByteVector32, locked: Seq[OutPoint])(f: => Future[T])(implicit ec: ExecutionContext): Future[T] = { - // if signature fails (e.g. because wallet is encrypted) we need to unlock the utxos - f.recoverWith { case _ => - unlockOutpoints(locked) - .recover { case t: Throwable => // no-op, just add a log in case of failure - logger.warn(s"Cannot unlock failed transaction's UTXOs txid=${txid}", t) - t - } - .flatMap(_ => f) // return signTransaction error - .recoverWith { case _ => f } // return signTransaction error + private def unlockIfFails[T](locked: Seq[OutPoint])(f: => Future[T])(implicit ec: ExecutionContext): Future[T] = { + f.transformWith { + // We preserve the original failure, regardless of the result of the utxo unlocking call. + case Failure(f) => unlockOutpoints(locked).transformWith { _ => Future.failed(f) } + case Success(result) => Future.successful(result) } } def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, targetFeerate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { - import KotlinUtils._ - def sign(tx: Transaction, fees: Satoshi, requestedFeeRate: FeeratePerKw): Future[MakeFundingTxResponse] = { - val outputIndex = Transactions.findPubKeyScriptIndex(tx, pubkeyScript) getOrElse { - throw new RuntimeException(s"cannot find expected funding output") + def verifyAndSign(tx: Transaction, fees: Satoshi, requestedFeeRate: FeeratePerKw): Future[MakeFundingTxResponse] = { + import KotlinUtils._ + + val fundingOutputIndex = Transactions.findPubKeyScriptIndex(tx, pubkeyScript) match { + case Left(_) => return Future.failed(new RuntimeException("cannot find expected funding output: bitcoin core may be malicious")) + case Right(outputIndex) => outputIndex } - val ourInputs = tx.txIn.indices.toList // all inputs are supposed to be ours - val ourOutputs = tx.txOut.indices.toList filterNot (_ == outputIndex) // all outputs except for the funding output are supposed to be ours + val ourInputs = tx.txIn.indices.toList // all inputs are supposed to belong to our bitcoin wallet + val ourOutputs = tx.txOut.indices.toList.filterNot(_ == fundingOutputIndex) // all outputs except for the funding output are supposed to belong to our bitcoin wallet val psbt = new Psbt(tx) for { signed <- signPsbt(psbt, ourInputs, ourOutputs) extracted = signed.psbt.extract() _ = require(extracted.isRight, s"signing psbt failed with ${extracted.getLeft}") actualFees = kmp2scala(signed.psbt.computeFees()) - _ = require(actualFees == fees, s"actual funding fees $actualFees do not match bitcoin core fee $fees") + _ = require(actualFees == fees, s"actual funding fees $actualFees do not match returned fees $fees: bitcoin core may be malicious") fundingTx = kmp2scala(extracted.getRight) actualFeerate = Transactions.fee2rate(actualFees, fundingTx.weight()) - maxFeerate = requestedFeeRate + requestedFeeRate / 2 - _ = require(actualFeerate < maxFeerate, s"actual feerate $actualFeerate is more than 50% above requested fee rate $targetFeerate") - _ = logger.debug(s"created funding txid=${extracted.getRight.txid} outputIndex=$outputIndex fee=${fees}") - } yield MakeFundingTxResponse(fundingTx, outputIndex, fees) + maxFeerate = requestedFeeRate * 1.5 + _ = require(actualFeerate < maxFeerate, s"actual feerate $actualFeerate is more than 50% above requested feerate $targetFeerate") + _ = logger.debug(s"created funding txid=${fundingTx.txid} outputIndex=$fundingOutputIndex fee=$fees") + } yield MakeFundingTxResponse(fundingTx, fundingOutputIndex, fees) } val partialFundingTx = Transaction( @@ -372,7 +341,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag // we ask bitcoin core to add inputs to the funding tx, and use the specified change address FundTransactionResponse(tx, fee, _) <- fundTransaction(partialFundingTx, FundTransactionOptions(feerate)) lockedUtxos = tx.txIn.map(_.outPoint) - signedTx <- unlockIfFails(tx.txid, lockedUtxos)(sign(tx, fee, feerate)) + signedTx <- unlockIfFails(lockedUtxos)(verifyAndSign(tx, fee, feerate)) } yield signedTx } @@ -425,12 +394,11 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag } else { val unsignedTx = Transaction(2, outpoints.toSeq.map(o => TxIn(o, Seq.empty, 0)), Seq(TxOut(amountIn - missingFees, Script.pay2wpkh(changePubkeyHash))), 0) signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, unsignedTx.txOut.indices).transformWith { - case Failure(ex) => Future.failed(new IllegalArgumentException("tx signing failed: some inputs don't belong to our wallet", ex)) - case Success(response) => - response.extractFinalTx match { - case Left(error) => Future.failed(new IllegalArgumentException("tx signing failed: some inputs don't belong to our wallet", new RuntimeException(error.toString))) - case Right(tx) => publishTransaction(tx).map(_ => tx) - } + case Failure(ex) => Future.failed(new IllegalArgumentException("tx signing failed", ex)) + case Success(response) => response.finalTx_opt match { + case Left(error) => Future.failed(new IllegalArgumentException(s"tx signing failed: ${error.toString}")) + case Right(tx) => publishTransaction(tx).map(_ => tx) + } } } } @@ -470,22 +438,22 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag //------------------------- SIGNING -------------------------// def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int])(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = { - - def sign(psbt: Psbt): Future[ProcessPsbtResponse] = if (useEclairSigner) { - val onchainKeyManager = onchainKeyManager_opt.getOrElse(throw new RuntimeException("We are configured to use an eclair signer has not been loaded")) // this should not be possible - onchainKeyManager.signPsbt(psbt, ourInputs, ourOutputs) match { - case Success(signedPsbt) => Future.successful(ProcessPsbtResponse(signedPsbt, signedPsbt.extract().isRight)) - case Failure(error) => Future.failed(error) - } - } else { - processPsbt(psbt, sign = true) + onChainKeyManager_opt match { + case Some(keyManager) => + for { + updated <- utxoUpdatePsbt(psbt) + filled <- processPsbt(updated, sign = false) // just fill input and output HD paths + signed <- keyManager.sign(filled.psbt, ourInputs, ourOutputs) match { + case Success(signedPsbt) => Future.successful(ProcessPsbtResponse(signedPsbt, signedPsbt.extract().isRight)) + case Failure(error) => Future.failed(error) + } + } yield signed + case None => + for { + updated <- utxoUpdatePsbt(psbt) + signed <- processPsbt(updated, sign = true) + } yield signed } - - for { - updated <- utxoUpdatePsbt(psbt) - filled <- processPsbt(updated, sign = false) // called with sign=false to fill input and output HD paths - signed <- sign(filled.psbt) - } yield signed } //------------------------- PUBLISHING -------------------------// @@ -572,11 +540,13 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag val extracted = PublicKey(ByteVector.fromValidHex(rawKey)) // check that when we manage private keys we can re-compute the public key we got from bitcoin core // and that the address and public key match - if (useEclairSigner) { - val onchainKeyManager = onchainKeyManager_opt.getOrElse(throw new RuntimeException("We are configured to use an eclair signer has not been loaded")) // this should not be possible - val keyPath1 = keyPath.replace('h', '\'') // our bitcoin lib expects a ' suffix for hardened indexes and does not yet accept the h suffix - val computed = onchainKeyManager.getPublicKey(DeterministicWallet.KeyPath(keyPath1)) - require(computed == (extracted, address), "cannot recompute pubkey generated by bitcoin core") + onChainKeyManager_opt match { + case Some(keyManager) => + // TODO: bitcoin-kmp doesn't accept 'h' for hardened indexes, we should fix this. + val keyPath1 = keyPath.replace('h', '\'') + val computed = keyManager.derivePublicKey(DeterministicWallet.KeyPath(keyPath1)) + if (computed != (extracted, address)) return Future.failed(new RuntimeException("cannot recompute pubkey generated by bitcoin core")) + case None => () } extracted } @@ -592,21 +562,16 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag pubKey <- extractPublicKey(address) } yield pubKey - /** - * @return the public key hash of a bech32 raw change address. - */ - def getP2wpkhPubkeyHashForChange()(implicit ec: ExecutionContext): Future[ByteVector] = { - rpcClient.invoke("getrawchangeaddress", "bech32").collect { - case JString(changeAddress) => - val pubkeyHash = ByteVector.view(Bech32.decodeWitnessAddress(changeAddress).getThird) - pubkeyHash - } - } - + /** @return the public key hash of a bech32 raw change address. */ + def getP2wpkhPubkeyHashForChange()(implicit ec: ExecutionContext): Future[ByteVector] = for { + JString(changeAddress) <- rpcClient.invoke("getrawchangeaddress", "bech32") + _ <- extractPublicKey(changeAddress) + pubkeyHash = ByteVector.view(Bech32.decodeWitnessAddress(changeAddress).getThird) + } yield pubkeyHash /** - * Asks Bitcoin Core to fund and broadcsat a tx that sends funds to a given pubkey script - * If the current wallet uses Eclair to sign transaction, then we'll use our onchain key manager to sign the transaction, + * Ask Bitcoin Core to fund and broadcast a tx that sends funds to a given pubkey script. + * If the current wallet uses Eclair to sign transaction, then we'll use our on-chain key manager to sign the transaction, * with the following assumptions: * - all inputs belong to us * - all outputs except for the one that sends to `pubkeyScript` belong to us @@ -622,22 +587,23 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onchainKeyManag val theirOutput = TxOut(amount, pubkeyScript) val tx = Transaction(version = 2, txIn = Nil, txOut = theirOutput :: Nil, lockTime = 0) for { - fundedTx <- fundTransaction(tx, feeratePerKw, true) + fundedTx <- fundTransaction(tx, feeratePerKw, replaceable = true) lockedOutputs = fundedTx.tx.txIn.map(_.outPoint) theirOutputPos = fundedTx.tx.txOut.indexOf(theirOutput) - signedPsbt <- unlockIfFails(fundedTx.tx.txid, lockedOutputs)(signPsbt(new Psbt(fundedTx.tx), fundedTx.tx.txIn.indices, fundedTx.tx.txOut.indices.filterNot(_ == theirOutputPos))) - signedTx = signedPsbt.finalTx + signedPsbt <- unlockIfFails(lockedOutputs)(signPsbt(new Psbt(fundedTx.tx), fundedTx.tx.txIn.indices, fundedTx.tx.txOut.indices.filterNot(_ == theirOutputPos))) + _ = require(signedPsbt.finalTx_opt.isRight, s"transaction was not fully signed (${signedPsbt.finalTx_opt.left.toOption.get}): bitcoin core may be malicious") + signedTx = signedPsbt.finalTx_opt.toOption.get actualFees = kmp2scala(signedPsbt.psbt.computeFees()) actualFeerate = Transactions.fee2rate(actualFees, signedTx.weight()) - maxFeerate = feeratePerKw + feeratePerKw / 2 - _ = require(actualFeerate < maxFeerate, s"actual fee rate $actualFeerate is more than 50% above requested fee rate $feeratePerKw") + maxFeerate = feeratePerKw * 1.5 + _ = require(actualFeerate < maxFeerate, s"actual feerate $actualFeerate is more than 50% above requested feerate $feeratePerKw") txid <- publishTransaction(signedTx) } yield txid } def sendToPubkeyScript(pubkeyScript: Seq[ScriptElt], amount: Satoshi, feeratePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[ByteVector32] = sendToPubkeyScript(Script.write(pubkeyScript), amount, feeratePerKw) - // calls Bitcoin Core's sendtoaddress RPC call directly. Will fail if wallet is using an external signer + /** Calls Bitcoin Core's sendtoaddress RPC call directly. Will fail if wallet is using an external signer. */ def sendToAddress(address: String, amount: Satoshi, confirmationTarget: Long)(implicit ec: ExecutionContext): Future[ByteVector32] = { rpcClient.invoke( "sendtoaddress", @@ -762,45 +728,6 @@ object BitcoinCoreClient { } } - case class ProcessPsbtResponse(psbt: Psbt, complete: Boolean) { - - import KotlinUtils._ - - // Extract a fully signed transaction from `psbt` - // If the transaction is just partially signed, this method will fail and you must call extractPartiallySignedTx instead - def extractFinalTx: Either[UpdateFailure, Transaction] = { - val extracted = psbt.extract() - if (extracted.isLeft) Left(extracted.getLeft) else Right(extracted.getRight) - } - - // Extract a partially signed transaction from `psbt` - def extractPartiallySignedTx: Transaction = { - var partiallySignedTx: Transaction = psbt.getGlobal.getTx - for (i <- 0 until psbt.getInputs.size()) { - val scriptWitness = psbt.getInputs.get(i).getScriptWitness - if (scriptWitness != null) { - partiallySignedTx = partiallySignedTx.updateWitness(i, scriptWitness) - } - } - partiallySignedTx - } - - def finalTx = extractFinalTx.getOrElse(throw new RuntimeException("cannot extract transaction from psbt")) - } - - case class PreviousTx(txid: ByteVector32, vout: Long, scriptPubKey: String, redeemScript: String, witnessScript: String, amount: BigDecimal) - - object PreviousTx { - def apply(inputInfo: Transactions.InputInfo, witness: ScriptWitness): PreviousTx = PreviousTx( - inputInfo.outPoint.txid, - inputInfo.outPoint.index, - inputInfo.txOut.publicKeyScript.toHex, - inputInfo.redeemScript.toHex, - ScriptWitness.write(witness).toHex, - inputInfo.txOut.amount.toBtc.toBigDecimal - ) - } - /** * Information about a transaction currently in the mempool. * diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index 8e4d216833..998f8e5d11 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -773,28 +773,30 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon if (unsignedTx.localInputs.isEmpty) { context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, Nil, sharedSig_opt))) } else { - // we only sign our wallet inputs, and check that we can spend our wallet outputs - val ourWalletInputs = unsignedTx.localInputs.map(i => tx.txIn.indexWhere(_.outPoint == OutPoint(i.previousTx, i.previousTxOutput.toInt))) - val ourWalletOutputs = unsignedTx.localOutputs.collect { - // README!! there are 2 types of local outputs: - // Change, which go back into our wallet - // NonChange, which go to an external address (typically during a splice-out) - // Here we only keep outputs which are ours i.e go back into our wallet - // And we trust that NonChange outputs are valid. This only works if the entry point for creating such outputs is trusted (for example, a secure API call) - case Output.Local.Change(_, amount, pubkeyScript) => tx.txOut.indexWhere(output => output.amount == amount && output.publicKeyScript == pubkeyScript) + // We only sign our wallet inputs, and check that we can spend our wallet outputs. + val ourWalletInputs = unsignedTx.localInputs.map(i => tx.txIn.indexWhere(_.outPoint == i.outPoint)) + val ourWalletOutputs = unsignedTx.localOutputs.flatMap { + case Output.Local.Change(_, amount, pubkeyScript) => Some(tx.txOut.indexWhere(output => output.amount == amount && output.publicKeyScript == pubkeyScript)) + // Non-change outputs may go to an external address (typically during a splice-out). + // Here we only keep outputs which are ours i.e explicitly go back into our wallet. + // We trust that non-change outputs are valid: this only works if the entry point for creating such outputs is trusted (for example, a secure API call). + case _: Output.Local.NonChange => None } context.pipeToSelf(wallet.signPsbt(new Psbt(tx), ourWalletInputs, ourWalletOutputs).map { response => val localOutpoints = unsignedTx.localInputs.map(_.outPoint).toSet - val partiallySignedTx = response.extractPartiallySignedTx - // partially signed PSBT must include spent amounts for all inputs that were signed, and we can "trust" these amounts because they are included - // in the hash that we signed (see BIP143). If our bitcoin node lied about them, then our signatures are invalid + val partiallySignedTx = response.partiallySignedTx + // Partially signed PSBT must include spent amounts for all inputs that were signed, and we can "trust" these amounts because they are included + // in the hash that we signed (see BIP143). If our bitcoin node lied about them, then our signatures are invalid. val actualLocalAmountIn = ourWalletInputs.map(i => kmp2scala(response.psbt.getInput(i).getWitnessUtxo.amount)).sum val expectedLocalAmountIn = unsignedTx.localInputs.map(i => i.previousTx.txOut(i.previousTxOutput.toInt).amount).sum - require(actualLocalAmountIn == expectedLocalAmountIn, s"local spent amount ${actualLocalAmountIn} does not match what we expect ($expectedLocalAmountIn") + require(actualLocalAmountIn == expectedLocalAmountIn, s"local spent amount $actualLocalAmountIn does not match what we expect ($expectedLocalAmountIn): bitcoin core may be malicious") val actualLocalAmountOut = ourWalletOutputs.map(i => partiallySignedTx.txOut(i).amount).sum - val expectedLocalAmountOut = unsignedTx.localOutputs.collect { case c: Output.Local.Change => c.amount }.sum - require(actualLocalAmountOut == expectedLocalAmountOut, s"local output amount ${actualLocalAmountOut} does not match what we expect ($expectedLocalAmountOut") + val expectedLocalAmountOut = unsignedTx.localOutputs.map { + case c: Output.Local.Change => c.amount + case _: Output.Local.NonChange => 0.sat + }.sum + require(actualLocalAmountOut == expectedLocalAmountOut, s"local output amount $actualLocalAmountOut does not match what we expect ($expectedLocalAmountOut): bitcoin core may be malicious") val sigs = partiallySignedTx.txIn.filter(txIn => localOutpoints.contains(txIn.outPoint)).map(_.witness) PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, partiallySignedTx, sigs, sharedSig_opt)) }) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index 160fdb2d1b..d5f8957e35 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.eventstream.EventStream import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.psbt.{Psbt, UpdateFailure} +import fr.acinq.bitcoin.psbt.Psbt import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, Script, Transaction, TxOut} import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.OnChainWallet @@ -295,7 +295,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, } Behaviors.receiveMessagePartial { case AddInputsOk(fundedTx, totalAmountIn) => - log.info("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc) + log.debug("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc) sign(fundedTx, targetFeerate, totalAmountIn) case AddInputsFailed(reason) => if (reason.getMessage.contains("Insufficient funds")) { @@ -355,41 +355,39 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, private def signWalletInputs(locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { import fr.acinq.bitcoin.scalacompat.KotlinUtils._ - // we finalize (sign) the input that we control, and will then ask our bitcoin client to sign wallet inputs + // We create a PSBT with the non-wallet input already signed: val psbt = new Psbt(locallySignedTx.txInfo.tx) - val updated: Either[UpdateFailure, Psbt] = psbt.updateWitnessInput(locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.input.txOut, null, fr.acinq.bitcoin.Script.parse(locallySignedTx.txInfo.input.redeemScript), null, java.util.Map.of()) - val finalized = updated.flatMap(_.finalizeWitnessInput(0, locallySignedTx.txInfo.tx.txIn.head.witness)) - finalized match { + .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)) + psbt match { case Left(failure) => - log.error(s"cannot sign ${cmd.desc}: ", failure) + log.error(s"cannot sign ${cmd.desc}: $failure") unlockAndStop(locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, TxPublisher.TxRejectedReason.UnknownTxFailure) case Right(psbt1) => - // the transaction that we want to fund/replace has one input, the first one. Additional inputs are provided by our onchain wallet. - val ourWalletInputs = locallySignedTx.txInfo.tx.txIn.indices.drop(1) - // for "claim anchor txs" there is a single change output that sends to our onchain wallet - // for htlc txs the first output is the one we want to fund/bump, additional outputs send to our onchain wallet + // The transaction that we want to fund/replace has one input, the first one. Additional inputs are provided by our on-chain wallet. + val ourWalletInputs = locallySignedTx.txInfo.tx.txIn.indices.tail + // For "claim anchor txs" there is a single change output that sends to our on-chain wallet. + // For htlc txs the first output is the one we want to fund/bump, additional outputs send to our on-chain wallet. val ourWalletOutputs = locallySignedTx match { case _: ClaimLocalAnchorWithWitnessData => Seq(0) - case _: HtlcWithWitnessData => locallySignedTx.txInfo.tx.txOut.indices.drop(1) + case _: HtlcWithWitnessData => locallySignedTx.txInfo.tx.txOut.indices.tail } context.pipeToSelf(bitcoinClient.signPsbt(psbt1, ourWalletInputs, ourWalletOutputs)) { case Success(processPsbtResponse) => - val signedTx = processPsbtResponse.finalTx - val actualFees = kmp2scala(processPsbtResponse.psbt.computeFees()) - val actualWeight = locallySignedTx match { - case _: ClaimLocalAnchorWithWitnessData => signedTx.weight() + dummySignedCommitTx(cmd.commitment).tx.weight() - case _ => - locallySignedTx.txInfo match { - case _: HtlcSuccessTx => cmd.commitment.params.commitmentFormat.htlcSuccessInputWeight + signedTx.weight() - case _: HtlcTimeoutTx => cmd.commitment.params.commitmentFormat.htlcTimeoutInputWeight + signedTx.weight() + processPsbtResponse.finalTx_opt match { + case Right(signedTx) => + val actualFees = kmp2scala(processPsbtResponse.psbt.computeFees()) + val actualWeight = locallySignedTx match { + case _: ClaimLocalAnchorWithWitnessData => signedTx.weight() + dummySignedCommitTx(cmd.commitment).tx.weight() case _ => signedTx.weight() } - } - val actualFeerate = Transactions.fee2rate(actualFees, actualWeight) - if (actualFeerate >= txFeerate * 2) { - SignWalletInputsFailed(new RuntimeException(s"actual fee rate $actualFeerate is more than twice the requested fee rate $txFeerate")) - } else { - SignWalletInputsOk(signedTx) + val actualFeerate = Transactions.fee2rate(actualFees, actualWeight) + if (actualFeerate >= txFeerate * 2) { + SignWalletInputsFailed(new RuntimeException(s"actual fee rate $actualFeerate is more than twice the requested fee rate $txFeerate")) + } else { + SignWalletInputsOk(signedTx) + } + case Left(failure) => SignWalletInputsFailed(new RuntimeException(s"could not sign psbt: $failure")) } case Failure(reason) => SignWalletInputsFailed(reason) } @@ -439,31 +437,32 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, // The anchor transaction is paying for the weight of the commitment transaction. val anchorWeight = Seq(InputWeight(anchorTx.txInfo.input.outPoint, anchorInputWeight + commitTx.weight())) - - def makeSingleOutputTx(fundTxResponse: OnChainWallet.FundTransactionResponse): Future[Transaction] = fundTxResponse.changePosition match { - case Some(changePos) => - val changeOutput = fundTxResponse.tx.txOut(changePos).copy(amount = fundTxResponse.tx.txOut.map(_.amount).sum) - val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput)) - Future.successful(txSingleOutput) - case None => - bitcoinClient.getP2wpkhPubkeyHashForChange().map(pubkeyHash => { - // replace PlaceHolderPubKey with a real wallet key - val fundedTx = fundTxResponse.tx.copy(txOut = Seq(TxOut(dustLimit, Script.pay2wpkh(pubkeyHash)))) - fundedTx - }) + def makeSingleOutputTx(fundTxResponse: OnChainWallet.FundTransactionResponse): Future[Transaction] = { + // Bitcoin Core may not preserve the order of inputs, we need to make sure the anchor is the first input. + val txIn = anchorTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn.filterNot(_.outPoint == anchorTx.txInfo.input.outPoint) + fundTxResponse.changePosition match { + case Some(changePos) => + val changeOutput = fundTxResponse.tx.txOut(changePos).copy(amount = fundTxResponse.tx.txOut.map(_.amount).sum) + val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(changeOutput)) + Future.successful(txSingleOutput) + case None => + bitcoinClient.getP2wpkhPubkeyHashForChange().map(pubkeyHash => { + // replace PlaceHolderPubKey with a real wallet key + fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(TxOut(dustLimit, Script.pay2wpkh(pubkeyHash)))) + }) + } } for { fundTxResponse <- bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate, inputWeights = anchorWeight)) txSingleOutput <- makeSingleOutputTx(fundTxResponse) - changeOutput = txSingleOutput.txOut(0) - ourWalletInputs = fundTxResponse.tx.txIn.indices.filterNot(i => fundTxResponse.tx.txIn(i).outPoint == anchorTx.txInfo.input.outPoint) + changeOutput = txSingleOutput.txOut.head + ourWalletInputs = txSingleOutput.txIn.indices.tail // all inputs except the first one ourWalletOutputs = Seq(0) // one change output // We ask bitcoind to sign the wallet inputs to learn their final weight and adjust the change amount. - psbt = new Psbt(txSingleOutput) - processPsbtResponse <- bitcoinClient.signPsbt(psbt, ourWalletInputs, ourWalletOutputs) + processPsbtResponse <- bitcoinClient.signPsbt(new Psbt(txSingleOutput), ourWalletInputs, ourWalletOutputs) // we cannot extract the final tx from the psbt because it is not fully signed yet - partiallySignedTx = processPsbtResponse.extractPartiallySignedTx + partiallySignedTx = processPsbtResponse.partiallySignedTx dummySignedTx = addSigs(anchorTx.updateTx(partiallySignedTx).txInfo, PlaceHolderSig) packageWeight = commitTx.weight() + dummySignedTx.tx.weight() // above, we asked bitcoin core to use the package weight to estimate fees when it built and funded this transaction, so we @@ -473,7 +472,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, anchorTxFee = weight2fee(targetFeerate, packageWeight) - weight2fee(commitment.localCommit.spec.commitTxFeerate, commitTx.weight()) changeAmount = dustLimit.max(fundTxResponse.amountIn - anchorTxFee) - fundedTx = fundTxResponse.tx.copy(txOut = Seq(changeOutput.copy(amount = changeAmount))) + fundedTx = txSingleOutput.copy(txOut = Seq(changeOutput.copy(amount = changeAmount))) } yield { (anchorTx.updateTx(fundedTx), fundTxResponse.amountIn) } @@ -487,17 +486,17 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, case _: HtlcTimeoutTx => commitment.params.commitmentFormat.htlcTimeoutInputWeight }) bitcoinClient.fundTransaction(htlcTx.txInfo.tx, FundTransactionOptions(targetFeerate, changePosition = Some(1), inputWeights = Seq(htlcInputWeight))).flatMap(fundTxResponse => { - val ourWalletInputs = fundTxResponse.tx.txIn.indices.filterNot(i => fundTxResponse.tx.txIn(i).outPoint == htlcTx.txInfo.input.outPoint) - val ourWalletOutputs = if (fundTxResponse.tx.txOut.size > 1) Seq(1) else Nil // there may not be a change output - val unsignedTx = htlcTx.updateTx(fundTxResponse.tx) - val psbt = new Psbt(fundTxResponse.tx) - bitcoinClient.signPsbt(psbt, ourWalletInputs, ourWalletOutputs).map(processPsbtResponse => { + // Bitcoin Core may not preserve the order of inputs, we need to make sure the htlc is the first input. + val fundedTx = fundTxResponse.tx.copy(txIn = htlcTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn.filterNot(_.outPoint == htlcTx.txInfo.input.outPoint)) + val ourWalletInputs = fundedTx.txIn.indices.tail // all inputs except the first one + val ourWalletOutputs = if (fundedTx.txOut.size > 1) Seq(1) else Nil // there may not be a change output + val unsignedTx = htlcTx.updateTx(fundedTx) + bitcoinClient.signPsbt(new Psbt(fundedTx), ourWalletInputs, ourWalletOutputs).map(processPsbtResponse => { val actualFees: Satoshi = processPsbtResponse.psbt.computeFees() - require(actualFees == fundTxResponse.fee, s"Bitcoin Core fees (${fundTxResponse.fee} do not match ours ($actualFees)") - val packageWeight = fundTxResponse.tx.weight() + htlcInputWeight.weight + require(actualFees == fundTxResponse.fee, s"Bitcoin Core fees (${fundTxResponse.fee}) do not match ours ($actualFees)") + val packageWeight = fundedTx.weight() + htlcInputWeight.weight val actualFeerate = Transactions.fee2rate(fundTxResponse.fee, packageWeight.toInt) require(actualFeerate < targetFeerate * 2, s"actual fee rate $actualFeerate is more than twice the requested fee rate $targetFeerate") - (unsignedTx, fundTxResponse.amountIn) }) }) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManager.scala new file mode 100644 index 0000000000..35cdeeed07 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManager.scala @@ -0,0 +1,239 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.crypto.keymanager + +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.psbt.{Psbt, UpdateFailure} +import fr.acinq.bitcoin.scalacompat.DeterministicWallet._ +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto, DeterministicWallet, MnemonicCode, Satoshi, Script, computeBIP84Address} +import fr.acinq.eclair.TimestampSecond +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptor, Descriptors} +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCClient +import grizzled.slf4j.Logging +import org.json4s.{JArray, JBool} +import scodec.bits.ByteVector + +import java.io.File +import scala.concurrent.duration.DurationInt +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsScala} +import scala.util.{Failure, Success, Try} + +object LocalOnChainKeyManager extends Logging { + def descriptorChecksum(span: String): String = fr.acinq.bitcoin.Descriptor.checksum(span) + + /** + * Load a configuration file and create an on-chain key manager + * + * @param datadir eclair data directory + * @param chainHash chain we're on + * @return a LocalOnChainKeyManager instance if a configuration file exists + */ + def load(datadir: File, chainHash: ByteVector32): Option[LocalOnChainKeyManager] = { + // we use a specific file instead of adding values to eclair's configuration file because it is available everywhere + // in the code through the actor system's settings and we'd like to restrict access to the on-chain wallet seed + val file = new File(datadir, "eclair-signer.conf") + if (file.exists()) { + val config = ConfigFactory.parseFile(file) + val wallet = config.getString("eclair.signer.wallet") + val mnemonics = config.getString("eclair.signer.mnemonics") + val passphrase = config.getString("eclair.signer.passphrase") + val timestamp = config.getLong("eclair.signer.timestamp") + val keyManager = new LocalOnChainKeyManager(wallet, MnemonicCode.toSeed(mnemonics, passphrase), TimestampSecond(timestamp), chainHash) + logger.info(s"using on-chain key manager wallet=$wallet xpub=${keyManager.masterPubKey(0)}") + Some(keyManager) + } else { + None + } + } +} + +class LocalOnChainKeyManager(override val wallet: String, seed: ByteVector, override val walletTimestamp: TimestampSecond, chainHash: ByteVector32) extends OnChainKeyManager with Logging { + + import LocalOnChainKeyManager._ + + // Master key derived from our seed. We use it to generate a BIP84 wallet that can be used: + // - to generate a watch-only wallet with any BIP84-compatible bitcoin wallet + // - to generate descriptors that can be imported into Bitcoin Core to create a watch-only wallet which can be used + // by Eclair to fund transactions (only Eclair will be able to sign wallet inputs) + private val master = DeterministicWallet.generate(seed) + private val fingerprint = DeterministicWallet.fingerprint(master) & 0xFFFFFFFFL + private val fingerPrintHex = String.format("%8s", fingerprint.toHexString).replace(' ', '0') + // Root BIP32 on-chain path: we use BIP84 (p2wpkh) paths: m/84'/{0'/1'} + private val rootPath = chainHash match { + case Block.RegtestGenesisBlock.hash | Block.TestnetGenesisBlock.hash | Block.SignetGenesisBlock.hash => "84'/1'" + case Block.LivenetGenesisBlock.hash => "84'/0'" + case _ => throw new IllegalArgumentException(s"invalid chain hash $chainHash") + } + private val rootKey = DeterministicWallet.derivePrivateKey(master, KeyPath(rootPath)) + + override def masterPubKey(account: Long): String = { + val prefix = chainHash match { + case Block.RegtestGenesisBlock.hash | Block.TestnetGenesisBlock.hash | Block.SignetGenesisBlock.hash => vpub + case Block.LivenetGenesisBlock.hash => zpub + case _ => throw new IllegalArgumentException(s"invalid chain hash $chainHash") + } + // master pubkey for account 0 is m/84'/{0'/1'}/0' + val accountPub = DeterministicWallet.publicKey(DeterministicWallet.derivePrivateKey(rootKey, hardened(account))) + DeterministicWallet.encode(accountPub, prefix) + } + + override def derivePublicKey(keyPath: KeyPath): (Crypto.PublicKey, String) = { + val pub = DeterministicWallet.derivePrivateKey(master, keyPath).publicKey + val address = computeBIP84Address(pub, chainHash) + (pub, address) + } + + override def createWallet(rpcClient: BitcoinJsonRPCClient)(implicit ec: ExecutionContext): Future[Boolean] = { + if (walletTimestamp < (TimestampSecond.now() - 2.hours)) { + logger.warn(s"eclair-backed wallet descriptors for wallet=$wallet are too old to be automatically imported into bitcoin core, you will need to manually import them and select how far back to rescan") + Future.successful(false) + } else { + logger.info(s"creating a new on-chain eclair-backed wallet in bitcoind: $wallet") + rpcClient.invoke("createwallet", wallet, /* disable_private_keys */ true, /* blank */ true, /* passphrase */ "", /* avoid_reuse */ false, /* descriptors */ true, /* load_on_startup */ true).flatMap(_ => { + logger.info(s"importing new descriptors ${descriptors(0).descriptors}") + rpcClient.invoke("importdescriptors", descriptors(0).descriptors).collect { + case JArray(results) => results.forall(item => { + val JBool(success) = item \ "success" + success + }) + } + }).recover { e => + logger.error("cannot create eclair-backed on-chain wallet: ", e) + false + } + } + } + + override def descriptors(account: Long): Descriptors = { + // TODO: we should use 'h' everywhere once bitcoin-kmp supports it. + val keyPath = s"$rootPath/$account'".replace('\'', 'h') + val prefix = chainHash match { + case Block.LivenetGenesisBlock.hash => xpub + case _ => tpub + } + val accountPub = DeterministicWallet.publicKey(DeterministicWallet.derivePrivateKey(rootKey, hardened(account))) + // descriptors for account 0 are: + // 84'/{0'/1'}/0'/0/* for main addresses + // 84'/{0'/1'}/0'/1/* for change addresses + val receiveDesc = s"wpkh([$fingerPrintHex/$keyPath]${encode(accountPub, prefix)}/0/*)" + val changeDesc = s"wpkh([$fingerPrintHex/$keyPath]${encode(accountPub, prefix)}/1/*)" + Descriptors(wallet_name = wallet, descriptors = List( + Descriptor(desc = s"$receiveDesc#${descriptorChecksum(receiveDesc)}", internal = false, active = true, timestamp = walletTimestamp.toLong), + Descriptor(desc = s"$changeDesc#${descriptorChecksum(changeDesc)}", internal = true, active = true, timestamp = walletTimestamp.toLong), + )) + } + + override def sign(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int]): Try[Psbt] = { + for { + spent <- spentAmount(psbt, ourInputs) + change <- changeAmount(psbt, ourOutputs) + _ = logger.debug(s"signing txid=${psbt.getGlobal.getTx.txid} fees=${psbt.computeFees()} spent=$spent change=$change") + _ <- Try { + ourOutputs.foreach(i => require(isOurOutput(psbt, i), s"could not verify output $i: bitcoin core may be malicious")) + } + signed <- ourInputs.foldLeft(Try(psbt)) { + case (Failure(psbt), _) => Failure(psbt) + case (Success(psbt), i) => signPsbtInput(psbt, i) + } + } yield signed + } + + private def spentAmount(psbt: Psbt, ourInputs: Seq[Int]): Try[Satoshi] = Try { + ourInputs.map(i => { + val input = psbt.getInput(i) + require(input != null, s"input $i is missing from psbt: bitcoin core may be malicious") + require(input.getWitnessUtxo != null, s"input $i does not have a witness utxo: bitcoin core may be malicious") + fr.acinq.bitcoin.scalacompat.KotlinUtils.kmp2scala(input.getWitnessUtxo.amount) + }).sum + } + + private def changeAmount(psbt: Psbt, ourOutputs: Seq[Int]): Try[Satoshi] = Try { + ourOutputs.map(i => { + require(psbt.getGlobal.getTx.txOut.size() > i, s"output $i is missing from psbt: bitcoin core may be malicious") + fr.acinq.bitcoin.scalacompat.KotlinUtils.kmp2scala(psbt.getGlobal.getTx.txOut.get(i).amount) + }).sum + } + + /** Check that an output belongs to us (i.e. we can recompute its public key from its bip32 path). */ + private def isOurOutput(psbt: Psbt, outputIndex: Int): Boolean = { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + if (psbt.getOutputs.size() <= outputIndex || psbt.getGlobal.getTx.txOut.size() <= outputIndex) { + return false + } + val output = psbt.getOutputs.get(outputIndex) + val txOut = psbt.getGlobal.getTx.txOut.get(outputIndex) + output.getDerivationPaths.asScala.headOption match { + case Some((pub, keypath)) => + val (expectedPubKey, _) = derivePublicKey(KeyPath(keypath.getKeyPath.path.asScala.toSeq.map(_.longValue()))) + val expectedScript = Script.write(Script.pay2wpkh(expectedPubKey)) + if (expectedPubKey != kmp2scala(pub)) { + logger.warn(s"public key mismatch (expected=$expectedPubKey, actual=$pub): bitcoin core may be malicious") + false + } else if (kmp2scala(txOut.publicKeyScript) != expectedScript) { + logger.warn(s"script mismatch (expected=$expectedScript, actual=${txOut.publicKeyScript}): bitcoin core may be malicious") + false + } else { + true + } + case None => + logger.warn("derivation path is missing: bitcoin core may be malicious") + false + } + } + + private def signPsbtInput(psbt: Psbt, pos: Int): Try[Psbt] = Try { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + import fr.acinq.bitcoin.{Script, SigHash} + + val input = psbt.getInput(pos) + require(input != null, s"input $pos is missing from psbt: bitcoin core may be malicious") + + // For each wallet input, Bitcoin Core will provide: + // - the output that was spent, in the PSBT's witness utxo field + // - the actual transaction that was spent, in the PSBT's non-witness utxo field + // We check that these fields are consistent and match the outpoint that is spent in the PSBT. + // This prevents attacks where Bitcoin Core would lie about the amount being spent and make us pay very high fees. + require(input.getNonWitnessUtxo != null, "non-witness utxo is missing: bitcoin core may be malicious") + require(input.getNonWitnessUtxo.txid == psbt.getGlobal.getTx.txIn.get(pos).outPoint.txid, "utxo txid mismatch: bitcoin core may be malicious") + require(input.getNonWitnessUtxo.txOut.get(psbt.getGlobal.getTx.txIn.get(pos).outPoint.index.toInt) == input.getWitnessUtxo, "utxo mismatch: bitcoin core may be malicious") + + // We must use SIGHASH_ALL, otherwise we would be vulnerable to "signature reuse" attacks. + // When unspecified, the sighash used will be SIGHASH_ALL. + require(Option(input.getSighashType).forall(_ == SigHash.SIGHASH_ALL), s"input sighash must be SIGHASH_ALL (got=${input.getSighashType}): bitcoin core may be malicious") + + // Check that we're signing a p2wpkh input and that the keypath is provided and correct. + require(input.getDerivationPaths.size() == 1, "bip32 derivation path is missing: bitcoin core may be malicious") + val (pub, keypath) = input.getDerivationPaths.asScala.toSeq.head + val priv = fr.acinq.bitcoin.DeterministicWallet.derivePrivateKey(master.priv, keypath.getKeyPath).getPrivateKey + require(priv.publicKey() == pub, s"derived public key doesn't match (expected=$pub actual=${priv.publicKey()}): bitcoin core may be malicious") + val expectedScript = ByteVector(Script.write(Script.pay2wpkh(pub))) + require(kmp2scala(input.getWitnessUtxo.publicKeyScript) == expectedScript, s"script mismatch (expected=$expectedScript, actual=${input.getWitnessUtxo.publicKeyScript}): bitcoin core may be malicious") + + // Update the input with the right script for a p2wpkh input, which is a *p2pkh* script, then sign and finalize. + val updated: Either[UpdateFailure, Psbt] = psbt.updateWitnessInput(psbt.getGlobal.getTx.txIn.get(pos).outPoint, input.getWitnessUtxo, null, Script.pay2pkh(pub), SigHash.SIGHASH_ALL, input.getDerivationPaths) + val signed = updated.flatMap(_.sign(priv, pos)) + val finalized = signed.flatMap(s => { + require(s.getSig.last.toInt == SigHash.SIGHASH_ALL, "signature must end with SIGHASH_ALL") + s.getPsbt.finalizeWitnessInput(pos, Script.witnessPay2wpkh(pub, s.getSig)) + }) + finalized match { + case Right(psbt) => psbt + case Left(failure) => throw new RuntimeException(s"cannot sign psbt input, error = $failure") + } + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalOnchainKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalOnchainKeyManager.scala deleted file mode 100644 index d92fb83e38..0000000000 --- a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalOnchainKeyManager.scala +++ /dev/null @@ -1,180 +0,0 @@ -package fr.acinq.eclair.crypto.keymanager - -import com.typesafe.config.ConfigFactory -import fr.acinq.bitcoin.ScriptWitness -import fr.acinq.bitcoin.psbt.{Psbt, UpdateFailure} -import fr.acinq.bitcoin.scalacompat.DeterministicWallet._ -import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto, DeterministicWallet, MnemonicCode, computeBIP84Address} -import fr.acinq.eclair.TimestampSecond -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptor, Descriptors} -import grizzled.slf4j.Logging -import scodec.bits.ByteVector - -import java.io.File -import scala.jdk.CollectionConverters.MapHasAsScala -import scala.util.Try - -object LocalOnchainKeyManager extends Logging { - def descriptorChecksum(span: String): String = fr.acinq.bitcoin.Descriptor.checksum(span) - - /** - * Load a configuration file and create an onchain key manager - * - * @param datadir eclair data directory - * @param chainHash chain we're on - * @return a LocalOnchainKeyManager instance if a configuration file exists - */ - def load(datadir: File, chainHash: ByteVector32): Option[LocalOnchainKeyManager] = { - // we use a specific file instead of adding values to eclair's configuration file because it is available everywhere in the code through - // the actor system's settings and we'd like to restrict access to the onchain wallet seed - val file = new File(datadir, "eclair-signer.conf") - if (file.exists()) { - val config = ConfigFactory.parseFile(file) - val wallet = config.getString("eclair.signer.wallet") - val mnemonics = config.getString("eclair.signer.mnemonics") - val passphrase = config.getString("eclair.signer.passphrase") - val timestamp = config.getLong("eclair.signer.timestamp") - val keyManager = new LocalOnchainKeyManager(wallet, MnemonicCode.toSeed(mnemonics, passphrase), TimestampSecond(timestamp), chainHash) - logger.info(s"using onchain key manager wallet=${wallet} xpub=${keyManager.getOnchainMasterPubKey(0)}") - Some(keyManager) - } else { - None - } - } -} - -class LocalOnchainKeyManager(override val wallet: String, seed: ByteVector, timestamp: TimestampSecond, chainHash: ByteVector32) extends OnchainKeyManager with Logging { - - import LocalOnchainKeyManager._ - - // master key. we will use it to generate a BIP84 wallet that can be used: - // - to generate a watch-only wallet with any BIP84-compatible bitcoin wallet - // - to generate descriptors that can be import into Bitcoin Core to create a watch-only wallet which can be used - // by Eclair to fund transactions (only Eclair will be able to sign wallet inputs). - - // m / purpose' / coin_type' / account' / change / address_index - private val master = DeterministicWallet.generate(seed) - private val fingerprint = DeterministicWallet.fingerprint(master) & 0xFFFFFFFFL - private val fingerPrintHex = String.format("%8s", fingerprint.toHexString).replace(' ', '0') - // root bip32 onchain path - // we use BIP84 (p2wpkh) path: 84'/{0'/1'} - private val rootPath = chainHash match { - case Block.RegtestGenesisBlock.hash | Block.TestnetGenesisBlock.hash | Block.SignetGenesisBlock.hash => "84'/1'" - case Block.LivenetGenesisBlock.hash => "84'/0'" - case _ => throw new IllegalArgumentException(s"invalid chain hash ${chainHash}") - } - private val rootKey = DeterministicWallet.derivePrivateKey(master, KeyPath(rootPath)) - - - override def getOnchainMasterPubKey(account: Long): String = { - val prefix = chainHash match { - case Block.RegtestGenesisBlock.hash | Block.TestnetGenesisBlock.hash | Block.SignetGenesisBlock.hash => vpub - case Block.LivenetGenesisBlock.hash => zpub - case _ => throw new IllegalArgumentException(s"invalid chain hash ${chainHash}") - } - // master pubkey for account 0 is m/84'/{0'/1'}/0' - val accountPub = DeterministicWallet.publicKey(DeterministicWallet.derivePrivateKey(rootKey, hardened(account))) - DeterministicWallet.encode(accountPub, prefix) - } - - override def getWalletTimestamp(): TimestampSecond = timestamp - - override def getDescriptors(account: Long): Descriptors = { - val keyPath = s"$rootPath/$account'".replace('\'', 'h') // Bitcoin Core understands both ' and h suffix for hardened derivation, and h is much easier to parse for external tools - val prefix: Int = chainHash match { - case Block.LivenetGenesisBlock.hash => xpub - case _ => tpub - } - val accountPub = DeterministicWallet.publicKey(DeterministicWallet.derivePrivateKey(rootKey, hardened(account))) - // descriptors for account 0 are: - // 84'/{0'/1'}/0'/0/* for main addresses - // 84'/{0'/1'}/0'/1/* for change addresses - val receiveDesc = s"wpkh([${this.fingerPrintHex}/$keyPath]${encode(accountPub, prefix)}/0/*)" - 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 = timestamp.toLong), - Descriptor(desc = s"$changeDesc#${descriptorChecksum(changeDesc)}", internal = true, active = true, timestamp = timestamp.toLong), - )) - } - - override def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int]): Try[Psbt] = Try { - import fr.acinq.bitcoin.scalacompat.KotlinUtils._ - - val spent = ourInputs.map(i => kmp2scala(psbt.getInput(i).getWitnessUtxo.amount)).sum - val backtous = ourOutputs.map(i => kmp2scala(psbt.getGlobal.getTx.txOut.get(i).amount)).sum - - logger.info(s"signing ${psbt.getGlobal.getTx.txid} fees ${psbt.computeFees()} spent $spent to_us $backtous") - ourOutputs.foreach(i => isOurOutput(psbt, i)) - ourInputs.foldLeft(psbt) { (p, i) => sigbnPsbtInput(p, i) } - } - - - override def getPublicKey(keyPath: KeyPath): (Crypto.PublicKey, String) = { - import fr.acinq.bitcoin.scalacompat.KotlinUtils._ - val pub = getPrivateKey(keyPath.keyPath).publicKey() - val address = computeBIP84Address(pub, chainHash) - (pub, address) - } - - private def getPrivateKey(keyPath: fr.acinq.bitcoin.KeyPath) = fr.acinq.bitcoin.DeterministicWallet.derivePrivateKey(master.priv, keyPath).getPrivateKey - - // check that an output belongs to us i.e. we can recompute its public from its bip32 path - private def isOurOutput(psbt: Psbt, outputIndex: Int) = { - val output = psbt.getOutputs.get(outputIndex) - val txout = psbt.getGlobal.getTx.txOut.get(outputIndex) - output.getDerivationPaths.size() match { - case 1 => - output.getDerivationPaths.asScala.foreach { case (pub, keypath) => - val check = getPrivateKey(keypath.getKeyPath).publicKey() - require(pub == check, s"cannot compute public key for $txout") - require(txout.publicKeyScript.contentEquals(fr.acinq.bitcoin.Script.write(fr.acinq.bitcoin.Script.pay2wpkh(pub))), s"output pubkeyscript does not match ours for $txout") - } - case _ => throw new IllegalArgumentException(s"cannot verify that $txout sends to us") - } - } - - private def sigbnPsbtInput(psbt: Psbt, pos: Int): Psbt = { - import fr.acinq.bitcoin.scalacompat.KotlinUtils.eitherkmp2either - import fr.acinq.bitcoin.{Script, SigHash} - - val input = psbt.getInput(pos) - - // For each wallet input, Bitcoin Core will provide - // - the output that was spent, in the PSBT's witness utxo field - // - the actual transaction that was spent, in the PSBT's non-witness utxo field - // we check that this fields are consistent and match the outpoint that is spent in the PSBT - // this prevents attacks where bitcoin core would lie about the amount being spent and make us pay very high fees - require(input.getNonWitnessUtxo != null, "non-witness utxo is missing") - require(input.getNonWitnessUtxo.txid == psbt.getGlobal.getTx.txIn.get(pos).outPoint.txid, "utxo txid mismatch") - require(input.getNonWitnessUtxo.txOut.get(psbt.getGlobal.getTx.txIn.get(pos).outPoint.index.toInt) == input.getWitnessUtxo, "utxo mismatch") - - // not using SIGHASH_ALL would make us vulnerable to "signature reuse" attacks - // here null means unspecified means SIGHASH_ALL - require(Option(input.getSighashType).forall(_ == SigHash.SIGHASH_ALL), "input sighashtype must be SIGHASH_ALL") - - // check that we're signing a p2wpkh input and that the keypath is provided and correct - require(Script.isPay2wpkh(input.getWitnessUtxo.publicKeyScript.toByteArray), "spent input is not p2wpkh") - require(input.getDerivationPaths.size() == 1, "invalid bip32 path") - val (pub, keypath) = input.getDerivationPaths.asScala.toSeq.head - - // use provided bip32 path to compute the private key - val priv = fr.acinq.bitcoin.DeterministicWallet.derivePrivateKey(master.priv, keypath.getKeyPath).getPrivateKey - require(priv.publicKey() == pub, "cannot compute private key") - - // update the input with the right script for a p2wpkh input, which is a * p2pkh * script - // then sign and finalize the psbt input - - val updated: Either[UpdateFailure, Psbt] = psbt.updateWitnessInput(psbt.getGlobal.getTx.txIn.get(pos).outPoint, input.getWitnessUtxo, null, Script.pay2pkh(pub), SigHash.SIGHASH_ALL, input.getDerivationPaths) - val signed = updated.flatMap(_.sign(priv, pos)) - val finalized = signed.flatMap(s => { - val sig = s.getSig - require(sig.get(sig.size() - 1).toInt == SigHash.SIGHASH_ALL, "signature must end with SIGHASH_ALL") - s.getPsbt.finalizeWitnessInput(pos, new ScriptWitness().push(sig).push(pub.value)) - }) - finalized match { - case Right(psbt) => psbt - case Left(failure) => throw new RuntimeException(s"cannot sign psbt input, error = $failure") - } - } -} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/OnChainKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/OnChainKeyManager.scala new file mode 100644 index 0000000000..867289b4bd --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/OnChainKeyManager.scala @@ -0,0 +1,70 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.crypto.keymanager + +import fr.acinq.bitcoin.psbt.Psbt +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath +import fr.acinq.eclair.TimestampSecond +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.Descriptors +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCClient + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +trait OnChainKeyManager { + def wallet: String + + /** + * @return the creation time of the wallet managed by this key manager + */ + def walletTimestamp(): TimestampSecond + + /** + * Create a bitcoin core watch-only wallet with private keys owned by this key manager instance. + * This should only be called if the corresponding wallet doesn't already exist. + */ + def createWallet(rpcClient: BitcoinJsonRPCClient)(implicit ec: ExecutionContext): Future[Boolean] + + /** + * @param account account number (0 is used by most wallets) + * @return the on-chain pubkey for this account, which can then be imported into a BIP39-compatible wallet such as Electrum + */ + def masterPubKey(account: Long): String + + /** + * @param keyPath BIP32 path + * @return the (public key, address) pair for this BIP32 path starting from the master key + */ + def derivePublicKey(keyPath: KeyPath): (PublicKey, String) + + /** + * @param account account number + * @return a pair of (main, change) wallet descriptors that can be imported into an on-chain wallet + */ + def descriptors(account: Long): Descriptors + + /** + * Sign the inputs provided in [[ourInputs]] and verifies that [[ourOutputs]] belong to our bitcoin wallet. + * + * @param psbt input psbt + * @param ourInputs index of inputs that belong to our on-chain wallet and need to be signed + * @param ourOutputs index of outputs that belong to our on-chain wallet + * @return a signed psbt, where all our inputs are signed + */ + def sign(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int]): Try[Psbt] +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/OnchainKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/OnchainKeyManager.scala deleted file mode 100644 index 07008e70bf..0000000000 --- a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/OnchainKeyManager.scala +++ /dev/null @@ -1,49 +0,0 @@ -package fr.acinq.eclair.crypto.keymanager - -import fr.acinq.bitcoin.psbt.Psbt -import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath -import fr.acinq.eclair.TimestampSecond -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.Descriptors - -import scala.util.Try - -trait OnchainKeyManager { - def wallet: String - - /** - * - * @param account account number (0 is used by most wallets) - * @return the onchain pubkey for this account, which can then be imported into a BIP39-compatible wallet such as Electrum - */ - def getOnchainMasterPubKey(account: Long): String - - /** - * - * @param keyPath BIP path - * @return the (public key, address) pair for this BIP32 path - */ - def getPublicKey(keyPath: KeyPath): (PublicKey, String) - - /** - * - * @return the creation time of the wallet managed by this key manager - */ - def getWalletTimestamp(): TimestampSecond - - /** - * - * @param account account number - * @return a pair of (main, change) wallet descriptors that can be imported into an onchain wallet - */ - def getDescriptors(account: Long): Descriptors - - /** - * - * @param psbt input psbt - * @param ourInputs index of inputs that belong to our onchain wallet and need to be signed - * @param ourOutputs index of outputs that belong to our onchain wallet - * @return a signed psbt, where all our inputs are signed - */ - def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int]): Try[Psbt] -} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index ae8b9cbf32..5f20a00a69 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -42,7 +42,7 @@ class StartupSpec extends AnyFunSuite { val nodeKeyManager = new LocalNodeKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val db = TestDatabases.inMemoryDb() - NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, db, blockCount, feerates) + NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, None, db, blockCount, feerates) } test("check configuration") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 21bd98694c..e84425cd4e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -81,6 +81,7 @@ object TestConstants { def nodeParams: NodeParams = NodeParams( nodeKeyManager, channelKeyManager, + onChainKeyManager_opt = None, blockHeight = new AtomicLong(defaultBlockHeight), feerates = new AtomicReference(FeeratesPerKw.single(feeratePerKw)), alias = "alice", @@ -244,6 +245,7 @@ object TestConstants { def nodeParams: NodeParams = NodeParams( nodeKeyManager, channelKeyManager, + onChainKeyManager_opt = None, blockHeight = new AtomicLong(defaultBlockHeight), feerates = new AtomicReference(FeeratesPerKw.single(feeratePerKw)), alias = "bob", diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index 8fe0f72281..8caca5b45b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -21,10 +21,8 @@ import fr.acinq.bitcoin.psbt.Psbt import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut} import fr.acinq.bitcoin.{Bech32, SigHash, SigVersion} -import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance} +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, ProcessPsbtResponse} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.SignTransactionResponse -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.ProcessPsbtResponse import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.{randomBytes32, randomKey} @@ -55,7 +53,7 @@ class DummyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { Future.successful(FundTransactionResponse(tx, 0 sat, None)) } - override def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int])(implicit ec: ExecutionContext): Future[BitcoinCoreClient.ProcessPsbtResponse] = Future.successful(ProcessPsbtResponse(psbt, complete = true)) + override def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int])(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = Future.successful(ProcessPsbtResponse(psbt, complete = true)) override def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] = { published += (tx.txid -> tx) @@ -155,7 +153,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { Future.successful(FundTransactionResponse(fundedTx, fee, Some(tx.txOut.length))) } - private def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = { + private def signTransaction(tx: Transaction): Future[SignTransactionResponse] = { val signedTx = tx.txIn.zipWithIndex.foldLeft(tx) { case (currentTx, (txIn, index)) => inputs.find(_.txid == txIn.outPoint.txid) match { case Some(inputTx) => @@ -181,9 +179,8 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { case (currentPsbt, (txIn, index)) => inputs.find(_.txid == txIn.outPoint.txid) match { case Some(inputTx) => val sig = Transaction.signInput(tx, index, Script.pay2pkh(pubkey), SigHash.SIGHASH_ALL, inputTx.txOut.head.amount, SigVersion.SIGVERSION_WITNESS_V0, privkey) - val updated = currentPsbt.updateWitnessInput(txIn.outPoint, inputTx.txOut(txIn.outPoint.index.toInt), null, Script.pay2pkh(pubkey).map(scala2kmp).asJava, null, java.util.Map.of()) - val finalized = updated.getRight.finalizeWitnessInput(txIn.outPoint, Script.witnessPay2wpkh(pubkey, sig)) - finalized.getRight + val updated = currentPsbt.updateWitnessInput(txIn.outPoint, inputTx.txOut(txIn.outPoint.index.toInt), null, Script.pay2pkh(pubkey).map(scala2kmp).asJava, null, java.util.Map.of()).getRight + updated.finalizeWitnessInput(txIn.outPoint, Script.witnessPay2wpkh(pubkey, sig)).getRight case None => currentPsbt } } @@ -195,7 +192,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { val tx = Transaction(2, Nil, Seq(TxOut(amount, pubkeyScript)), 0) for { fundedTx <- fundTransaction(tx, feeRatePerKw, replaceable = true) - signedTx <- signTransaction(fundedTx.tx, allowIncomplete = true) + signedTx <- signTransaction(fundedTx.tx) } yield MakeFundingTxResponse(signedTx.tx, 0, fundedTx.fee) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index 073cb06fdb..505340a82b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -24,14 +24,14 @@ import fr.acinq.bitcoin.psbt.{Psbt, UpdateFailure} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Block, Btc, BtcDouble, ByteVector32, Crypto, DeterministicWallet, MilliBtcDouble, MnemonicCode, OP_DROP, OP_PUSHDATA, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut, addressFromPublicKeyScript, addressToPublicKeyScript, computeBIP84Address, computeP2PkhAddress, computeP2WpkhAddress} import fr.acinq.bitcoin.{Bech32, SigHash, SigVersion} -import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance} +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, ProcessPsbtResponse} import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpendP2WPKH} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.{BitcoinReq, SignTransactionResponse} import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.UserPassword import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCClient, JsonRPCError} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} -import fr.acinq.eclair.crypto.keymanager.LocalOnchainKeyManager +import fr.acinq.eclair.crypto.keymanager.LocalOnChainKeyManager import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.{BlockHeight, TestConstants, TestKitBaseClass, TimestampSecond, randomBytes32, randomKey} import grizzled.slf4j.Logging @@ -63,17 +63,14 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assume(!useEclairSigner) val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val walletPassword = Random.alphanumeric.take(8).mkString sender.send(bitcoincli, BitcoinReq("encryptwallet", walletPassword)) sender.expectMsgType[JString](60 seconds) restartBitcoind(sender) - val f = for { - address <- bitcoinClient.getReceiveAddress() - txid <- bitcoinClient.sendToPubkeyScript(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).toOption.get, 10000.sat, FeeratePerKw(FeeratePerByte(3.sat))) - } yield txid - f.pipeTo(sender.ref) + val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) + bitcoinClient.makeFundingTx(pubkeyScript, 50 millibtc, FeeratePerKw(10000 sat)).pipeTo(sender.ref) val error = sender.expectMsgType[Failure].cause.asInstanceOf[JsonRPCError].error assert(error.message.contains("Please enter the wallet passphrase with walletpassphrase first")) @@ -84,7 +81,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("fund transactions") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val txToRemote = { val txNotFunded = Transaction(2, Nil, TxOut(150000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, 0) @@ -150,10 +147,10 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A sender.expectMsg(true) } { - // check that bitcoin core is not lying to us + // we check that bitcoin core is not malicious and trying to steal funds. val txNotFunded = Transaction(2, Nil, TxOut(150000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, 0) - def makeEvilBitcoinClient(changePosMod: (Int) => Int, txMod: Transaction => Transaction): BitcoinCoreClient = { + def makeEvilBitcoinClient(changePosMod: Int => Int, txMod: Transaction => Transaction): BitcoinCoreClient = { val badRpcClient = new BitcoinJsonRPCClient { override def wallet: Option[String] = if (useEclairSigner) Some("eclair") else None @@ -166,36 +163,27 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A case _ => bitcoinClient.rpcClient.invoke(method, params: _*)(ec) } } - new BitcoinCoreClient(badRpcClient, if (useEclairSigner) Some(onchainKeyManager) else None) + new BitcoinCoreClient(badRpcClient, if (useEclairSigner) Some(onChainKeyManager) else None) } - // verify that bitcoin core is not lying to us - { - val bitcoinClient1 = makeEvilBitcoinClient((pos: Int) => -1, (tx: Transaction) => tx) - bitcoinClient1.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) - sender.expectMsgType[Failure] - } { - val bitcoinClient1 = makeEvilBitcoinClient((pos: Int) => pos, (tx: Transaction) => tx.copy(txOut = tx.txOut.reverse)) - bitcoinClient1.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + // bitcoin core doesn't specify change position. + val evilBitcoinClient = makeEvilBitcoinClient(_ => -1, tx => tx) + evilBitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) sender.expectMsgType[Failure] } { - val bitcoinClient1 = makeEvilBitcoinClient((pos: Int) => pos, (tx: Transaction) => tx.copy(txOut = tx.txOut.head +: tx.txOut)) - bitcoinClient1.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + // bitcoin core tries to send twice the amount we wanted by duplicating the output. + val evilBitcoinClient = makeEvilBitcoinClient(pos => pos, tx => tx.copy(txOut = tx.txOut ++ txNotFunded.txOut)) + evilBitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) sender.expectMsgType[Failure] } { - val bitcoinClient1 = makeEvilBitcoinClient((pos: Int) => 1, (tx: Transaction) => tx.copy(txOut = tx.txOut.reverse)) - bitcoinClient1.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw, changePosition = Some(0))).pipeTo(sender.ref) + // bitcoin core ignores our specified change position. + val evilBitcoinClient = makeEvilBitcoinClient(_ => 1, tx => tx.copy(txOut = tx.txOut.reverse)) + evilBitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw, changePosition = Some(0))).pipeTo(sender.ref) sender.expectMsgType[Failure] } - { - val bitcoinClient1 = makeEvilBitcoinClient((pos: Int) => pos, (tx: Transaction) => tx) - bitcoinClient1.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw, changePosition = Some(0))).pipeTo(sender.ref) - bitcoinClient.rollback(sender.expectMsgType[FundTransactionResponse].tx).pipeTo(sender.ref) - sender.expectMsg(true) - } } } @@ -203,7 +191,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() - val defaultWallet = makeBitcoinCoreClient + val defaultWallet = makeBitcoinCoreClient() val walletExternalFunds = new BitcoinCoreClient(createWallet("external_inputs", sender)) // We receive some funds on an address that belongs to our wallet. Seq(25 millibtc, 15 millibtc, 20 millibtc).foreach(amount => { @@ -222,7 +210,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A defaultWallet.fundTransaction(txNotFunded, FundTransactionOptions(FeeratePerKw(2500 sat), changePosition = Some(1))).pipeTo(sender.ref) val fundedTx = sender.expectMsgType[FundTransactionResponse].tx defaultWallet.signPsbt(new Psbt(fundedTx), fundedTx.txIn.indices, Nil).pipeTo(sender.ref) - val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get defaultWallet.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) (OutPoint(signedTx, 0), script, signedTx.txOut(0)) @@ -261,9 +249,9 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A // And let bitcoind sign the wallet input. walletExternalFunds.signPsbt(new Psbt(fundedTx2.tx), fundedTx2.tx.txIn.indices, Nil).pipeTo(sender.ref) val psbt = sender.expectMsgType[ProcessPsbtResponse].psbt - val updated: Either[UpdateFailure, Psbt] = psbt.updateWitnessInput(outpoint1, txOut1, null, null, null, java.util.Map.of()) - val finalized = updated.flatMap(_.finalizeWitnessInput(0, Script.witnessMultiSigMofN(Seq(alicePriv, bobPriv).map(_.publicKey), Seq(externalSig)))) - val signedTx: Transaction = finalized.flatMap(_.extract()).getOrElse(throw new RuntimeException("cannot compute transaction")) + val signedTx: Transaction = psbt.updateWitnessInput(outpoint1, txOut1, null, null, null, java.util.Map.of()).getRight + .finalizeWitnessInput(0, Script.witnessMultiSigMofN(Seq(alicePriv, bobPriv).map(_.publicKey), Seq(externalSig))).getRight + .extract().getRight walletExternalFunds.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) @@ -292,10 +280,9 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A // We sign our external input. val externalSig = Transaction.signInput(fundedTx.tx, 0, inputScript2, SigHash.SIGHASH_ALL, 300_000 sat, SigVersion.SIGVERSION_WITNESS_V0, alicePriv) - - val updated: Either[UpdateFailure, Psbt] = psbt.updateWitnessInput(externalOutpoint, tx2.txOut(0), null, null, null, java.util.Map.of()) - val finalized = updated.flatMap(_.finalizeWitnessInput(0, Script.witnessMultiSigMofN(Seq(alicePriv, carolPriv).map(_.publicKey), Seq(externalSig)))) - val signedTx: Transaction = finalized.flatMap(_.extract()).getOrElse(throw new RuntimeException("cannot compute transaction")) + val signedTx: Transaction = psbt.updateWitnessInput(externalOutpoint, tx2.txOut(0), null, null, null, java.util.Map.of()).getRight + .finalizeWitnessInput(0, Script.witnessMultiSigMofN(Seq(alicePriv, carolPriv).map(_.publicKey), Seq(externalSig))).getRight + .extract().getRight walletExternalFunds.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) @@ -331,9 +318,9 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A // We sign our external input. val externalSig = Transaction.signInput(fundedTx.tx, 0, inputScript2, SigHash.SIGHASH_ALL, 300_000 sat, SigVersion.SIGVERSION_WITNESS_V0, alicePriv) - val updated: Either[UpdateFailure, Psbt] = psbt.updateWitnessInput(OutPoint(tx2, 0), tx2.txOut(0), null, null, null, java.util.Map.of()) - val finalized = updated.flatMap(_.finalizeWitnessInput(0, Script.witnessMultiSigMofN(Seq(alicePriv, carolPriv).map(_.publicKey), Seq(externalSig)))) - val signedTx: Transaction = finalized.flatMap(_.extract()).getOrElse(throw new RuntimeException("cannot compute transaction")) + val signedTx: Transaction = psbt.updateWitnessInput(OutPoint(tx2, 0), tx2.txOut(0), null, null, null, java.util.Map.of()).getRight + .finalizeWitnessInput(0, Script.witnessMultiSigMofN(Seq(alicePriv, carolPriv).map(_.publicKey), Seq(externalSig))).getRight + .extract().getRight walletExternalFunds.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) // We have replaced the previous transaction. @@ -380,7 +367,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("create/commit/rollback funding txs") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() bitcoinClient.onChainBalance().pipeTo(sender.ref) assert(sender.expectMsgType[OnChainBalance].confirmed > 0.sat) @@ -431,7 +418,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("ensure feerate is always above min-relay-fee") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) // 200 sat/kw is below the min-relay-fee @@ -444,7 +431,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("unlock failed funding txs") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() bitcoinClient.onChainBalance().pipeTo(sender.ref) assert(sender.expectMsgType[OnChainBalance].confirmed > 0.sat) @@ -471,7 +458,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() generateBlocks(1) // generate a block to ensure we start with an empty mempool // create a first transaction with multiple inputs @@ -489,7 +476,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A sender.expectMsg(fundedTx.txIn.map(_.outPoint).toSet) bitcoinClient.signPsbt(new Psbt(fundedTx), fundedTx.txIn.indices, Nil).pipeTo(sender.ref) - val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) // once the tx is published, the inputs should be automatically unlocked @@ -510,7 +497,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A sender.expectMsg(fundedTx.txIn.map(_.outPoint).toSet) bitcoinClient.signPsbt(new Psbt(fundedTx), fundedTx.txIn.indices, Nil).pipeTo(sender.ref) - val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) // once the tx is published, the inputs should be automatically unlocked @@ -532,7 +519,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val sender = TestProbe() val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() // create a huge tx so we make sure it has > 2 inputs bitcoinClient.makeFundingTx(pubkeyScript, 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) @@ -540,16 +527,12 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(fundingTx.txIn.length > 2) // spend the first 2 inputs - val tx1 = { - val tx = fundingTx.copy( - txIn = fundingTx.txIn.take(2), - txOut = fundingTx.txOut.updated(outputIndex, fundingTx.txOut(outputIndex).copy(amount = 50 btc)) - ) - bitcoinClient.fundTransaction(tx, FeeratePerKw(1500 sat), true).pipeTo(sender.ref) - sender.expectMsgType[FundTransactionResponse].tx - } + val tx1 = fundingTx.copy( + txIn = fundingTx.txIn.take(2), + txOut = fundingTx.txOut.updated(outputIndex, fundingTx.txOut(outputIndex).copy(amount = 50 btc)) + ) bitcoinClient.signPsbt(new Psbt(tx1), tx1.txIn.indices, Nil).pipeTo(sender.ref) - val tx2 = sender.expectMsgType[ProcessPsbtResponse].finalTx + val tx2 = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.commit(tx2).pipeTo(sender.ref) sender.expectMsg(true) @@ -576,14 +559,14 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val txNotFunded = Transaction(2, Nil, Seq(TxOut(200_000 sat, Script.pay2wpkh(randomKey().publicKey))), 0) bitcoinClient.fundTransaction(txNotFunded, FeeratePerKw(FeeratePerByte(1 sat)), replaceable = true).pipeTo(sender.ref) val txFunded1 = sender.expectMsgType[FundTransactionResponse].tx assert(txFunded1.txIn.nonEmpty) bitcoinClient.signPsbt(new Psbt(txFunded1), txFunded1.txIn.indices, Nil).pipeTo(sender.ref) - val signedTx1 = sender.expectMsgType[ProcessPsbtResponse].finalTx + val signedTx1 = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.publishTransaction(signedTx1).pipeTo(sender.ref) assert(sender.expectMsgType[Failure].cause.getMessage.contains("min relay fee not met")) @@ -597,7 +580,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(txFunded2.txid != txFunded1.txid) txFunded1.txIn.foreach(txIn => assert(txFunded2.txIn.map(_.outPoint).contains(txIn.outPoint))) bitcoinClient.signPsbt(new Psbt(txFunded2), txFunded2.txIn.indices, Nil).pipeTo(sender.ref) - val signedTx2 = sender.expectMsgType[ProcessPsbtResponse].finalTx + val signedTx2 = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.publishTransaction(signedTx2).pipeTo(sender.ref) sender.expectMsg(signedTx2.txid) awaitAssert({ @@ -609,7 +592,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("unlock outpoints correctly") { val sender = TestProbe() val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() { // test #1: unlock outpoints that are actually locked @@ -649,14 +632,14 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val nonWalletKey = randomKey() val opts = FundTransactionOptions(TestConstants.feeratePerKw, changePosition = Some(1)) bitcoinClient.fundTransaction(Transaction(2, Nil, Seq(TxOut(250000 sat, Script.pay2wpkh(nonWalletKey.publicKey))), 0), opts).pipeTo(sender.ref) val fundedTx = sender.expectMsgType[FundTransactionResponse].tx bitcoinClient.signPsbt(new Psbt(fundedTx), fundedTx.txIn.indices, Nil).pipeTo(sender.ref) - val txToRemote = sender.expectMsgType[ProcessPsbtResponse].finalTx + val txToRemote = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.publishTransaction(txToRemote).pipeTo(sender.ref) sender.expectMsg(txToRemote.txid) generateBlocks(1) @@ -674,11 +657,10 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A bitcoinClient.signPsbt(new Psbt(txWithNonWalletInput), txWithNonWalletInput.txIn.indices.tail, Nil).pipeTo(sender.ref) val signTxResponse1 = sender.expectMsgType[ProcessPsbtResponse] assert(!signTxResponse1.complete) - signTxResponse1.extractPartiallySignedTx.txIn.tail.foreach(walletTxIn => assert(walletTxIn.witness.stack.nonEmpty)) + signTxResponse1.partiallySignedTx.txIn.tail.foreach(walletTxIn => assert(walletTxIn.witness.stack.nonEmpty)) // if the non-wallet inputs are signed, bitcoind signs the remaining wallet inputs. val nonWalletSig = Transaction.signInput(txWithNonWalletInput, 0, Script.pay2pkh(nonWalletKey.publicKey), bitcoin.SigHash.SIGHASH_ALL, txToRemote.txOut.head.amount, bitcoin.SigVersion.SIGVERSION_WITNESS_V0, nonWalletKey) - val nonWalletWitness = ScriptWitness(Seq(nonWalletSig, nonWalletKey.publicKey.value)) val txWithSignedNonWalletInput = txWithNonWalletInput.updateWitness(0, nonWalletWitness) val psbt = new Psbt(txWithSignedNonWalletInput) @@ -687,12 +669,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A bitcoinClient.signPsbt(psbt1, txWithSignedNonWalletInput.txIn.indices.tail, Nil).pipeTo(sender.ref) val signTxResponse2 = sender.expectMsgType[ProcessPsbtResponse] assert(signTxResponse2.complete) - Transaction.correctlySpends(signTxResponse2.finalTx, txToRemote +: walletInputTxs, bitcoin.ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - } - { - // bitcoind does not sign inputs that have already been confirmed. - //bitcoinClient.signPsbt(new Psbt(fundedTx), fundedTx.txIn.indices, Nil).pipeTo(sender.ref) - //sender.expectMsgType[Failure] + Transaction.correctlySpends(signTxResponse2.finalTx_opt.toOption.get, txToRemote +: walletInputTxs, bitcoin.ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } { // bitcoind lets us double-spend ourselves. @@ -708,7 +685,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A bitcoinClient.fundTransaction(Transaction(2, Nil, Seq(TxOut(125000 sat, Script.pay2wpkh(nonWalletKey.publicKey))), 0), opts).pipeTo(sender.ref) val fundedTx = sender.expectMsgType[FundTransactionResponse].tx bitcoinClient.signPsbt(new Psbt(fundedTx), fundedTx.txIn.indices, Nil).pipeTo(sender.ref) - val unconfirmedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx + val unconfirmedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.publishTransaction(unconfirmedTx).pipeTo(sender.ref) sender.expectMsg(unconfirmedTx.txid) // bitcoind lets us use this unconfirmed non-wallet input. @@ -718,10 +695,9 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val nonWalletSig = Transaction.signInput(txWithUnconfirmedInput, 0, Script.pay2pkh(nonWalletKey.publicKey), bitcoin.SigHash.SIGHASH_ALL, unconfirmedTx.txOut.head.amount, bitcoin.SigVersion.SIGVERSION_WITNESS_V0, nonWalletKey) val nonWalletWitness = ScriptWitness(Seq(nonWalletSig, nonWalletKey.publicKey.value)) val txWithSignedUnconfirmedInput = txWithUnconfirmedInput.updateWitness(0, nonWalletWitness) - val previousTx = PreviousTx(Transactions.InputInfo(OutPoint(unconfirmedTx.txid, 0), unconfirmedTx.txOut.head, Script.pay2pkh(nonWalletKey.publicKey)), nonWalletWitness) val psbt = new Psbt(txWithSignedUnconfirmedInput) - val updated: Either[UpdateFailure, Psbt] = psbt.updateWitnessInput(psbt.getGlobal.getTx.txIn.get(0).outPoint, unconfirmedTx.txOut(0), null, fr.acinq.bitcoin.Script.pay2pkh(nonWalletKey.publicKey), SigHash.SIGHASH_ALL, psbt.getInput(0).getDerivationPaths) - val Right(psbt1) = updated.flatMap(_.finalizeWitnessInput(0, nonWalletWitness)) + val Right(psbt1) = psbt.updateWitnessInput(psbt.getGlobal.getTx.txIn.get(0).outPoint, unconfirmedTx.txOut(0), null, fr.acinq.bitcoin.Script.pay2pkh(nonWalletKey.publicKey), SigHash.SIGHASH_ALL, psbt.getInput(0).getDerivationPaths) + .flatMap(_.finalizeWitnessInput(0, nonWalletWitness)) bitcoinClient.signPsbt(psbt1, txWithSignedUnconfirmedInput.txIn.indices.tail, Nil).pipeTo(sender.ref) assert(sender.expectMsgType[ProcessPsbtResponse].complete) } @@ -731,7 +707,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val priv = randomKey() val noInputTx = Transaction(2, Nil, TxOut(6.btc.toSatoshi, Script.pay2wpkh(priv.publicKey)) :: Nil, 0) @@ -739,7 +715,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val fundTxResponse = sender.expectMsgType[FundTransactionResponse] val changePos = fundTxResponse.changePosition.get bitcoinClient.signPsbt(new Psbt(fundTxResponse.tx), fundTxResponse.tx.txIn.indices, Nil).pipeTo(sender.ref) - val tx = sender.expectMsgType[ProcessPsbtResponse].finalTx + val tx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get // we publish the tx a first time bitcoinClient.publishTransaction(tx).pipeTo(sender.ref) @@ -780,7 +756,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() // that tx has inputs that don't exist val txWithUnknownInputs = Transaction.read("02000000000101b9e2a3f518fd74e696d258fed3c78c43f84504e76c99212e01cf225083619acf00000000000d0199800136b34b00000000001600145464ce1e5967773922506e285780339d72423244040047304402206795df1fd93c285d9028c384aacf28b43679f1c3f40215fd7bd1abbfb816ee5a022047a25b8c128e692d4717b6dd7b805aa24ecbbd20cfd664ab37a5096577d4a15d014730440220770f44121ed0e71ec4b482dded976f2febd7500dfd084108e07f3ce1e85ec7f5022025b32dc0d551c47136ce41bfb80f5a10de95c0babb22a3ae2d38e6688b32fcb20147522102c2662ab3e4fa18a141d3be3317c6ee134aff10e6cd0a91282a25bf75c0481ebc2102e952dd98d79aa796289fa438e4fdeb06ed8589ff2a0f032b0cfcb4d7b564bc3252aea58d1120") @@ -800,23 +776,23 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val signTxResponse = sender.expectMsgType[ProcessPsbtResponse] assert(signTxResponse.complete) - val txWithNoOutputs = signTxResponse.finalTx.copy(txOut = Nil) + val txWithNoOutputs = signTxResponse.finalTx_opt.toOption.get.copy(txOut = Nil) bitcoinClient.publishTransaction(txWithNoOutputs).pipeTo(sender.ref) sender.expectMsgType[Failure] bitcoinClient.getBlockHeight().pipeTo(sender.ref) val blockHeight = sender.expectMsgType[BlockHeight] - val txWithFutureCltv = signTxResponse.finalTx.copy(lockTime = blockHeight.toLong + 1) + val txWithFutureCltv = signTxResponse.finalTx_opt.toOption.get.copy(lockTime = blockHeight.toLong + 1) bitcoinClient.publishTransaction(txWithFutureCltv).pipeTo(sender.ref) sender.expectMsgType[Failure] - bitcoinClient.publishTransaction(signTxResponse.finalTx).pipeTo(sender.ref) - sender.expectMsg(signTxResponse.finalTx.txid) + bitcoinClient.publishTransaction(signTxResponse.finalTx_opt.toOption.get).pipeTo(sender.ref) + sender.expectMsg(signTxResponse.finalTx_opt.toOption.get.txid) } test("send and list transactions") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() bitcoinClient.onChainBalance().pipeTo(sender.ref) val initialBalance = sender.expectMsgType[OnChainBalance] @@ -852,16 +828,16 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val sender = TestProbe() val address = getNewAddress(sender) - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() def spendWalletTx(tx: Transaction, fees: Satoshi): Transaction = { val amount = tx.txOut.map(_.amount).sum - fees val unsignedTx = Transaction(version = 2, - txIn = tx.txOut.indices.map(i => TxIn(OutPoint(tx, i), Nil, fr.acinq.bitcoin.TxIn.SEQUENCE_FINAL)), + txIn = tx.txOut.indices.map(i => TxIn(OutPoint(tx, i), Nil, 0)), txOut = TxOut(amount, addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).toOption.get) :: Nil, lockTime = 0) bitcoinClient.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(sender.ref) - val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) signedTx @@ -897,20 +873,20 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("abandon transaction") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() // Broadcast a wallet transaction. val opts = FundTransactionOptions(TestConstants.feeratePerKw, changePosition = Some(1)) bitcoinClient.fundTransaction(Transaction(2, Nil, Seq(TxOut(250000 sat, Script.pay2wpkh(randomKey().publicKey))), 0), opts).pipeTo(sender.ref) val fundedTx1 = sender.expectMsgType[FundTransactionResponse].tx - signTransaction(bitcoinClient, fundedTx1, Nil).pipeTo(sender.ref) + signTransaction(bitcoinClient, fundedTx1).pipeTo(sender.ref) val signedTx1 = sender.expectMsgType[SignTransactionResponse].tx bitcoinClient.publishTransaction(signedTx1).pipeTo(sender.ref) sender.expectMsg(signedTx1.txid) // Double-spend that transaction. val fundedTx2 = fundedTx1.copy(txOut = TxOut(200000 sat, Script.pay2wpkh(randomKey().publicKey)) +: fundedTx1.txOut.tail) - signTransaction(bitcoinClient, fundedTx2, Nil).pipeTo(sender.ref) + signTransaction(bitcoinClient, fundedTx2).pipeTo(sender.ref) val signedTx2 = sender.expectMsgType[SignTransactionResponse].tx assert(signedTx2.txid != signedTx1.txid) bitcoinClient.publishTransaction(signedTx2).pipeTo(sender.ref) @@ -935,7 +911,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("bump transaction fees with child-pays-for-parent (single tx)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val tx = sendToAddress(getNewAddress(sender), 150_000 sat) assert(tx.txOut.length == 2) // there must be a change output val changeOutput = if (tx.txOut.head.amount == 150_000.sat) 1 else 0 @@ -962,7 +938,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("bump transaction fees with child-pays-for-parent (small package)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val fundingFeerate = FeeratePerKw(1000 sat) val remoteFundingPrivKey = randomKey() @@ -973,7 +949,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(fundingFeerate, changePosition = Some(1))).pipeTo(sender.ref) val fundTxResponse = sender.expectMsgType[FundTransactionResponse] assert(fundTxResponse.changePosition.contains(1)) - signTransaction(bitcoinClient, fundTxResponse.tx, Nil).pipeTo(sender.ref) + signTransaction(bitcoinClient, fundTxResponse.tx).pipeTo(sender.ref) val signTxResponse = sender.expectMsgType[SignTransactionResponse] assert(signTxResponse.complete) bitcoinClient.publishTransaction(signTxResponse.tx).pipeTo(sender.ref) @@ -1018,7 +994,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("bump transaction fees with child-pays-for-parent (complex package)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val currentFeerate = FeeratePerKw(500 sat) // We create two separate trees of transactions that will be bumped together: @@ -1042,7 +1018,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val txNotFunded = Transaction(2, txIn, txOut, 0) bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(currentFeerate, changePosition = Some(txOut.length))).pipeTo(sender.ref) val fundTxResponse = sender.expectMsgType[FundTransactionResponse] - signTransaction(bitcoinClient, fundTxResponse.tx, Nil).pipeTo(sender.ref) + signTransaction(bitcoinClient, fundTxResponse.tx).pipeTo(sender.ref) val signTxResponse = sender.expectMsgType[SignTransactionResponse] assert(signTxResponse.complete) bitcoinClient.publishTransaction(signTxResponse.tx).pipeTo(sender.ref) @@ -1113,7 +1089,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("cannot bump transaction fees (unknown transaction)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() bitcoinClient.cpfp(Set(OutPoint(randomBytes32(), 0), OutPoint(randomBytes32(), 3)), FeeratePerKw(1500 sat)).pipeTo(sender.ref) val failure = sender.expectMsgType[Failure] assert(failure.cause.getMessage.contains("some transactions could not be found")) @@ -1121,7 +1097,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("cannot bump transaction fees (invalid outpoint index)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val tx = sendToAddress(getNewAddress(sender), 150_000 sat) assert(tx.txOut.length == 2) // there must be a change output bitcoinClient.getMempoolTx(tx.txid).pipeTo(sender.ref) @@ -1136,7 +1112,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("cannot bump transaction fees (transaction already confirmed)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val tx = sendToAddress(getNewAddress(sender), 45_000 sat) generateBlocks(1) @@ -1147,23 +1123,23 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("cannot bump transaction fees (non-wallet input)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val txNotFunded = Transaction(2, Nil, TxOut(50_000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, 0) bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(FeeratePerKw(1000 sat), changePosition = Some(1))).pipeTo(sender.ref) val fundTxResponse = sender.expectMsgType[FundTransactionResponse] - signTransaction(bitcoinClient, fundTxResponse.tx, Nil).pipeTo(sender.ref) + signTransaction(bitcoinClient, fundTxResponse.tx).pipeTo(sender.ref) val signTxResponse = sender.expectMsgType[SignTransactionResponse] bitcoinClient.publishTransaction(signTxResponse.tx).pipeTo(sender.ref) sender.expectMsg(signTxResponse.tx.txid) bitcoinClient.cpfp(Set(OutPoint(signTxResponse.tx, 0)), FeeratePerKw(1500 sat)).pipeTo(sender.ref) val failure = sender.expectMsgType[Failure] - assert(failure.cause.getMessage.contains("some inputs don't belong to our wallet")) + assert(failure.cause.getMessage.contains("tx signing failed")) } test("cannot bump transaction fees (amount too low)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val tx = sendToAddress(getNewAddress(sender), 2500 sat) val outputIndex = if (tx.txOut.head.amount == 2500.sat) 0 else 1 bitcoinClient.cpfp(Set(OutPoint(tx, outputIndex)), FeeratePerKw(50_000 sat)).pipeTo(sender.ref) @@ -1173,7 +1149,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("detect if tx has been double-spent") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() // first let's create a tx val noInputTx1 = Transaction(2, Nil, Seq(TxOut(500_000 sat, Script.pay2wpkh(randomKey().publicKey))), 0) @@ -1211,7 +1187,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("detect if tx has been double-spent (with unconfirmed inputs)") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() val priv = randomKey() // Let's create one confirmed and one unconfirmed utxo. @@ -1273,7 +1249,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("find spending transaction of a given output") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() bitcoinClient.getBlockHeight().pipeTo(sender.ref) val blockHeight = sender.expectMsgType[BlockHeight] @@ -1321,9 +1297,9 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("get pubkey for p2wpkh receive address") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() - // eclair onchain key nmanager does not yet support taproot descriptors + // eclair on-chain key manager does not yet support taproot descriptors bitcoinClient.getReceiveAddress().pipeTo(sender.ref) val defaultAddress = sender.expectMsgType[String] val decoded = Bech32.decodeWitnessAddress(defaultAddress) @@ -1344,7 +1320,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A test("generate segwit change outputs") { val sender = TestProbe() - val bitcoinClient = makeBitcoinCoreClient + val bitcoinClient = makeBitcoinCoreClient() // Even when we pay a legacy address, our change output must use segwit, otherwise it won't be usable for lightning channels. val pubKey = randomKey().publicKey @@ -1367,7 +1343,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val wallet = new BitcoinCoreClient(createWallet("mempool_eviction", sender)) wallet.getP2wpkhPubkey().pipeTo(sender.ref) val walletPubKey = sender.expectMsgType[PublicKey] - val miner = makeBitcoinCoreClient + val miner = makeBitcoinCoreClient() miner.getP2wpkhPubkey().pipeTo(sender.ref) val nonWalletPubKey = sender.expectMsgType[PublicKey] // We use a large input script to be able to fill the mempool with a few transactions. @@ -1381,7 +1357,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val txNotFunded = Transaction(2, Nil, mainOutput +: outputsWithLargeScript, 0) miner.fundTransaction(txNotFunded, FundTransactionOptions(FeeratePerKw(500 sat), changePosition = Some(outputs.length))).pipeTo(sender.ref) val fundedTx = sender.expectMsgType[FundTransactionResponse].tx - signTransaction(miner, fundedTx, allowIncomplete = false).pipeTo(sender.ref) + signTransaction(miner, fundedTx).pipeTo(sender.ref) val signedTx = sender.expectMsgType[SignTransactionResponse].tx miner.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) @@ -1400,7 +1376,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A psbt = psbt.finalizeWitnessInput(i, ScriptWitness(Seq(ByteVector(1), bigInputScript))).getRight }) wallet.signPsbt(psbt, Seq(0), Nil).pipeTo(sender.ref) - val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get assert(390_000 <= signedTx.weight() && signedTx.weight() <= 400_000) // standard transactions cannot exceed 400 000 WU wallet.publishTransaction(signedTx).pipeTo(sender.ref) sender.expectMsg(signedTx.txid) @@ -1429,41 +1405,42 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A class BitcoinCoreClientWithEclairSignerSpec extends BitcoinCoreClientSpec { override def useEclairSigner = true + private def createWallet(seed: ByteVector): (BitcoinCoreClient, LocalOnChainKeyManager) = { + val name = s"eclair_${seed.toString()}" + val onChainKeyManager = new LocalOnChainKeyManager(name, seed, TimestampSecond.now(), Block.RegtestGenesisBlock.hash) + val jsonRpcClient = new BasicBitcoinJsonRPCClient(rpcAuthMethod = bitcoinrpcauthmethod, host = "localhost", port = bitcoindRpcPort, wallet = Some(name)) + (new BitcoinCoreClient(jsonRpcClient, Some(onChainKeyManager)), onChainKeyManager) + } + test("wallets managed by eclair implement BIP84") { val sender = TestProbe() val entropy = randomBytes32() - val hex = entropy.toString() - val mnemmonics = MnemonicCode.toMnemonics(entropy) - val seed = MnemonicCode.toSeed(mnemmonics, "") + val seed = MnemonicCode.toSeed(MnemonicCode.toMnemonics(entropy), "") val master = DeterministicWallet.generate(seed) - - val onchainKeyManager = new LocalOnchainKeyManager(s"eclair_$hex", seed, TimestampSecond.now(), Block.RegtestGenesisBlock.hash) - val jsonRpcClient = new BasicBitcoinJsonRPCClient(rpcAuthMethod = bitcoinrpcauthmethod, host = "localhost", port = bitcoindRpcPort, wallet = Some(s"eclair_$hex")) - val wallet1 = new BitcoinCoreClient(jsonRpcClient, Some(onchainKeyManager)) - wallet1.createEclairBackedWallet().pipeTo(sender.ref) + val (wallet, keyManager) = createWallet(seed) + keyManager.createWallet(wallet.rpcClient).pipeTo(sender.ref) sender.expectMsg(true) // this account xpub can be used to create a watch-only wallet val accountXPub = DeterministicWallet.encode( DeterministicWallet.publicKey(DeterministicWallet.derivePrivateKey(master, DeterministicWallet.KeyPath("m/84'/1'/0'"))), DeterministicWallet.vpub) - assert(onchainKeyManager.getOnchainMasterPubKey(0) == accountXPub) - + assert(wallet.onChainKeyManager_opt.get.masterPubKey(0) == accountXPub) def getBip32Path(address: String): DeterministicWallet.KeyPath = { - wallet1.rpcClient.invoke("getaddressinfo", address).pipeTo(sender.ref) - val JString(bip32path) = (sender.expectMsgType[JValue] \ "hdkeypath") + wallet.rpcClient.invoke("getaddressinfo", address).pipeTo(sender.ref) + val JString(bip32path) = sender.expectMsgType[JValue] \ "hdkeypath" DeterministicWallet.KeyPath(bip32path) } (0 to 10).foreach { _ => - wallet1.getReceiveAddress().pipeTo(sender.ref) + wallet.getReceiveAddress().pipeTo(sender.ref) val address = sender.expectMsgType[String] val bip32path = getBip32Path(address) assert(bip32path.path.length == 5 && bip32path.toString().startsWith("m/84'/1'/0'/0")) assert(computeBIP84Address(DeterministicWallet.derivePrivateKey(master, bip32path).publicKey, Block.RegtestGenesisBlock.hash) == address) - wallet1.getP2wpkhPubkeyHashForChange().pipeTo(sender.ref) + wallet.getP2wpkhPubkeyHashForChange().pipeTo(sender.ref) val Right(changeAddress) = addressFromPublicKeyScript(Block.RegtestGenesisBlock.hash, Script.pay2wpkh(sender.expectMsgType[ByteVector])) val bip32ChangePath = getBip32Path(changeAddress) assert(bip32ChangePath.path.length == 5 && bip32ChangePath.toString().startsWith("m/84'/1'/0'/1")) @@ -1471,30 +1448,26 @@ class BitcoinCoreClientWithEclairSignerSpec extends BitcoinCoreClientSpec { } } - test("use eclair to manage onchain keys") { + test("use eclair to manage on-chain keys") { val sender = TestProbe() (1 to 10).foreach { _ => - val seed = randomBytes32() - val hex = seed.toString() - val onchainKeyManager = new LocalOnchainKeyManager(s"eclair_$hex", seed, TimestampSecond.now(), Block.RegtestGenesisBlock.hash) - val jsonRpcClient = new BasicBitcoinJsonRPCClient(rpcAuthMethod = bitcoinrpcauthmethod, host = "localhost", port = bitcoindRpcPort, wallet = Some(s"eclair_$hex")) - val wallet1 = new BitcoinCoreClient(jsonRpcClient, Some(onchainKeyManager)) - wallet1.createEclairBackedWallet().pipeTo(sender.ref) + val (wallet, keyManager) = createWallet(randomBytes32()) + keyManager.createWallet(wallet.rpcClient).pipeTo(sender.ref) sender.expectMsg(true) - wallet1.getReceiveAddress().pipeTo(sender.ref) + wallet.getReceiveAddress().pipeTo(sender.ref) val address = sender.expectMsgType[String] - // we can send to an onchain address if eclair signs the transactions + // we can send to an on-chain address if eclair signs the transactions sendToAddress(address, 100_0000.sat) generateBlocks(1) // but bitcoin core's sendtoaddress RPC call will fail because wallets uses an external signer - wallet1.sendToAddress(address, 50_000.sat, 3).pipeTo(sender.ref) + wallet.sendToAddress(address, 50_000.sat, 3).pipeTo(sender.ref) val error = sender.expectMsgType[Failure] assert(error.cause.getMessage.contains("Private keys are disabled for this wallet")) - wallet1.sendToPubkeyScript(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).toOption.get, 50_000.sat, FeeratePerKw(FeeratePerByte(5.sat))).pipeTo(sender.ref) + wallet.sendToPubkeyScript(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).toOption.get, 50_000.sat, FeeratePerKw(FeeratePerByte(5.sat))).pipeTo(sender.ref) sender.expectMsgType[ByteVector32] } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala index 9d4c2426b7..4aabf798be 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala @@ -22,11 +22,10 @@ import akka.testkit.{TestKitBase, TestProbe} import fr.acinq.bitcoin.psbt.Psbt import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.{Block, Btc, BtcAmount, MilliBtc, MnemonicCode, Satoshi, Transaction, TxOut, addressToPublicKeyScript, computeP2WpkhAddress} -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.PreviousTx import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.{SafeCookie, UserPassword} import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCAuthMethod, BitcoinJsonRPCClient} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKB, FeeratePerKw} -import fr.acinq.eclair.crypto.keymanager.LocalOnchainKeyManager +import fr.acinq.eclair.crypto.keymanager.LocalOnChainKeyManager import fr.acinq.eclair.integration.IntegrationSpec import fr.acinq.eclair.{BlockHeight, TestUtils, TimestampSecond, randomKey} import grizzled.slf4j.Logging @@ -87,9 +86,7 @@ trait BitcoindService extends Logging { | } |} |""".stripMargin - val onchainKeyManager = { - new LocalOnchainKeyManager("eclair", MnemonicCode.toSeed(mnemonics, passphrase), TimestampSecond.now(), Block.RegtestGenesisBlock.hash) - } + val onChainKeyManager = new LocalOnChainKeyManager("eclair", MnemonicCode.toSeed(mnemonics, passphrase), TimestampSecond.now(), Block.RegtestGenesisBlock.hash) def startBitcoind(useCookie: Boolean = false, defaultAddressType_opt: Option[String] = None, @@ -136,7 +133,7 @@ trait BitcoindService extends Logging { })) } - def makeBitcoinCoreClient: BitcoinCoreClient = new BitcoinCoreClient(bitcoinrpcclient, Some(onchainKeyManager)) + def makeBitcoinCoreClient(): BitcoinCoreClient = new BitcoinCoreClient(bitcoinrpcclient, if (useEclairSigner) Some(onChainKeyManager) else None) def stopBitcoind(): Unit = { // gracefully stopping bitcoin will make it store its state cleanly to disk, which is good for later debugging @@ -174,7 +171,7 @@ trait BitcoindService extends Logging { val sender = TestProbe() waitForBitcoindUp(sender) if (useEclairSigner) { - makeBitcoinCoreClient.createEclairBackedWallet().pipeTo(sender.ref) + onChainKeyManager.createWallet(bitcoinrpcclient).pipeTo(sender.ref) sender.expectMsg(true) } else { sender.send(bitcoincli, BitcoinReq("createwallet", defaultWallet)) @@ -185,6 +182,7 @@ trait BitcoindService extends Logging { awaitCond(currentBlockHeight(sender) >= BlockHeight(150), max = 3 minutes, interval = 2 second) } + /** Generate blocks to a given address, or to our wallet if no address is provided. */ def generateBlocks(blockCount: Int, address: Option[String] = None, timeout: FiniteDuration = 10 seconds)(implicit system: ActorSystem): Unit = { val sender = TestProbe() val addressToUse = address match { @@ -237,11 +235,11 @@ trait BitcoindService extends Logging { } val probe = TestProbe() val tx = Transaction(version = 2, Nil, TxOut(amountSat, addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).toOption.get) :: Nil, lockTime = 0) - val client = makeBitcoinCoreClient + val client = makeBitcoinCoreClient() val f = for { - funded <- client.fundTransaction(tx, FeeratePerKw(FeeratePerByte(Satoshi(10))), true) + funded <- client.fundTransaction(tx, FeeratePerKw(FeeratePerByte(Satoshi(10))), replaceable = true) signed <- client.signPsbt(new Psbt(funded.tx), funded.tx.txIn.indices, Nil) - txid <- client.publishTransaction(signed.finalTx) + txid <- client.publishTransaction(signed.finalTx_opt.toOption.get) tx <- client.getTransaction(txid) } yield tx f.pipeTo(probe.ref) @@ -262,13 +260,9 @@ trait BitcoindService extends Logging { Transaction.read(rawTx) } - def signTransaction(client: BitcoinCoreClient, tx: Transaction): Future[SignTransactionResponse] = signTransaction(client, tx, Nil) - - def signTransaction(client: BitcoinCoreClient, tx: Transaction, allowIncomplete: Boolean): Future[SignTransactionResponse] = signTransaction(client, tx, Nil, allowIncomplete) - - def signTransaction(client: BitcoinCoreClient, tx: Transaction, previousTxs: Seq[PreviousTx], allowIncomplete: Boolean = false): Future[SignTransactionResponse] = { + def signTransaction(client: BitcoinCoreClient, tx: Transaction): Future[SignTransactionResponse] = { import fr.acinq.bitcoin.scalacompat.KotlinUtils._ - client.signPsbt(new Psbt(tx), tx.txIn.indices, Nil).map(p => SignTransactionResponse(p.extractFinalTx.getOrElse(p.extractPartiallySignedTx), p.extractFinalTx.isRight)) + client.signPsbt(new Psbt(tx), tx.txIn.indices, Nil).map(p => SignTransactionResponse(p.finalTx_opt.getOrElse(p.partiallySignedTx), p.finalTx_opt.isRight)) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala index 8641e2bec0..ff6ddec1d4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala @@ -24,9 +24,9 @@ import com.softwaremill.quicklens.{ModifyPimp, QuicklensAt} import fr.acinq.bitcoin.psbt.Psbt import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, OP_1, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxOut, addressToPublicKeyScript} -import fr.acinq.eclair.blockchain.OnChainWallet.FundTransactionResponse +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, ProcessPsbtResponse} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{MempoolTx, ProcessPsbtResponse, Utxo} +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{MempoolTx, Utxo} import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.blockchain.{OnChainWallet, SingleKeyOnChainWallet} @@ -63,11 +63,11 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit wallet.getReceiveAddress().pipeTo(probe.ref) val walletAddress = probe.expectMsgType[String] val tx = Transaction(version = 2, Nil, TxOut(amount, addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, walletAddress).toOption.get) :: Nil, lockTime = 0) - val client = makeBitcoinCoreClient + val client = makeBitcoinCoreClient() val f = for { - funded <- client.fundTransaction(tx, FeeratePerKw(FeeratePerByte(10.sat)), true) + funded <- client.fundTransaction(tx, FeeratePerKw(FeeratePerByte(10.sat)), replaceable = true) signed <- client.signPsbt(new Psbt(funded.tx), funded.tx.txIn.indices, Nil) - txid <- client.publishTransaction(signed.finalTx) + txid <- client.publishTransaction(signed.finalTx_opt.toOption.get) } yield txid f.pipeTo(probe.ref) probe.expectMsgType[ByteVector32] @@ -1004,11 +1004,11 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit walletA.getP2wpkhPubkey().pipeTo(probe.ref) val publicKey = probe.expectMsgType[PublicKey] val tx = Transaction(2, Nil, TxOut(100_000 sat, Script.pay2wpkh(publicKey)) +: (1 to 2500).map(_ => TxOut(5000 sat, Script.pay2wpkh(randomKey().publicKey))), 0) - val minerWallet = makeBitcoinCoreClient + val minerWallet = makeBitcoinCoreClient() minerWallet.fundTransaction(tx, FeeratePerKw(500 sat), replaceable = true).pipeTo(probe.ref) val unsignedTx = probe.expectMsgType[FundTransactionResponse].tx minerWallet.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(probe.ref) - val signedTx = probe.expectMsgType[ProcessPsbtResponse].finalTx + val Right(signedTx) = probe.expectMsgType[ProcessPsbtResponse].finalTx_opt assert(Transaction.write(signedTx).length >= 65_000) minerWallet.publishTransaction(signedTx).pipeTo(probe.ref) probe.expectMsgType[ByteVector32] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 507d3a248d..6245b5f5db 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -36,7 +36,7 @@ import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop, Up import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} -import fr.acinq.eclair.crypto.keymanager.LocalOnchainKeyManager +import fr.acinq.eclair.crypto.keymanager.LocalOnChainKeyManager import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol.{CommitSig, RevokeAndAck} @@ -1655,7 +1655,7 @@ class ReplaceableTxPublisherWithEclairSignerSpec extends ReplaceableTxPublisherS // we use the wallet name as a passphrase to make sure we get a new empty wallet val entropy = ByteVector.fromValidHex("01" * 32) val seed = MnemonicCode.toSeed(MnemonicCode.toMnemonics(entropy), walletName) - val keyManager = new LocalOnchainKeyManager(walletName, seed, TimestampSecond.now(), Block.RegtestGenesisBlock.hash) + val keyManager = new LocalOnChainKeyManager(walletName, seed, TimestampSecond.now(), Block.RegtestGenesisBlock.hash) val walletRpcClient = new BasicBitcoinJsonRPCClient(rpcAuthMethod = bitcoinrpcauthmethod, host = "localhost", port = bitcoindRpcPort, wallet = Some(walletName)) val walletClient = new BitcoinCoreClient(walletRpcClient, Some(keyManager)) with OnchainPubkeyCache { lazy val pubkey = { @@ -1665,9 +1665,9 @@ class ReplaceableTxPublisherWithEclairSignerSpec extends ReplaceableTxPublisherS override def getP2wpkhPubkey(renew: Boolean): PublicKey = pubkey } - walletClient.createEclairBackedWallet().pipeTo(probe.ref) + keyManager.createWallet(walletRpcClient).pipeTo(probe.ref) probe.expectMsg(true) - + (walletRpcClient, walletClient) } } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalOnchainKeyManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManagerSpec.scala similarity index 83% rename from eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalOnchainKeyManagerSpec.scala rename to eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManagerSpec.scala index 0c5c7c26fb..353d34c3da 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalOnchainKeyManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManagerSpec.scala @@ -11,17 +11,17 @@ import java.util.Base64 import scala.jdk.CollectionConverters.SeqHasAsJava import scala.util.{Failure, Success} -class LocalOnchainKeyManagerSpec extends AnyFunSuite { +class LocalOnChainKeyManagerSpec extends AnyFunSuite { test("sign psbt (non-reg test)") { val entropy = ByteVector.fromValidHex("01" * 32) val seed = MnemonicCode.toSeed(MnemonicCode.toMnemonics(entropy), "") - val onchainKeyManager = new LocalOnchainKeyManager("eclair", seed, TimestampSecond.now(), Block.TestnetGenesisBlock.hash) + val onChainKeyManager = new LocalOnChainKeyManager("eclair", seed, TimestampSecond.now(), Block.TestnetGenesisBlock.hash) // data generated by bitcoin core on regtest val psbt = Psbt.read( Base64.getDecoder.decode("cHNidP8BAHECAAAAAfZo4nGIyTg77MFmEBkQH1Au3Jl8vzB2WWQGGz/MbyssAAAAAAD9////ArAHPgUAAAAAFgAU6j9yVvLg66Zu3GM/xHbmXT0yvyiAlpgAAAAAABYAFODscQh3N7lmDYyV5yrHpGL2Zd4JAAAAAAABAH0CAAAAAaNdmqUNlziIjSaif3JUcvJWdyF0U5bYq13NMe+LbaBZAAAAAAD9////AjSp1gUAAAAAFgAUjfFMfBg8ulo/874n3+0ode7ka0BAQg8AAAAAACIAIPUn/XU17DfnvDkj8gn2twG3jtr2Z7sthy9K2MPTdYkaAAAAAAEBHzSp1gUAAAAAFgAUjfFMfBg8ulo/874n3+0ode7ka0AiBgM+PDdyxsVisa66SyBxiUvhEam8lEP64yujvVsEcGaqIxgPCfOBVAAAgAEAAIAAAACAAQAAAAMAAAAAIgIDWmAhb/sCV9+HjwFpPuy2TyEBi/Y11wrEHZUihe3N80EYDwnzgVQAAIABAACAAAAAgAEAAAAFAAAAAAA=") ).getRight - val Success(psbt1) = onchainKeyManager.signPsbt(psbt, psbt.getInputs.toArray().indices, Seq(0)) + val Success(psbt1) = onChainKeyManager.sign(psbt, psbt.getInputs.toArray().indices, Seq(0)) val tx = psbt1.extract() assert(tx.isRight) } @@ -30,19 +30,18 @@ class LocalOnchainKeyManagerSpec extends AnyFunSuite { import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val seed = ByteVector.fromValidHex("01" * 32) - val onchainKeyManager = new LocalOnchainKeyManager("eclair", seed, TimestampSecond.now(), Block.TestnetGenesisBlock.hash) + val onChainKeyManager = new LocalOnChainKeyManager("eclair", seed, TimestampSecond.now(), Block.TestnetGenesisBlock.hash) // create a watch-only BIP84 wallet from our key manager xpub - val (_, accountPub) = DeterministicWallet.ExtendedPublicKey.decode(onchainKeyManager.getOnchainMasterPubKey(0)) + val (_, accountPub) = DeterministicWallet.ExtendedPublicKey.decode(onChainKeyManager.masterPubKey(0)) val mainPub = DeterministicWallet.derivePublicKey(accountPub, 0) - val changePub = DeterministicWallet.derivePublicKey(accountPub, 1) def getPublicKey(index: Long) = DeterministicWallet.derivePublicKey(mainPub, index).publicKey val utxos = Seq( - Transaction(version = 2, txIn = Nil, txOut = TxOut(Satoshi(1000_000), Script.pay2wpkh(getPublicKey(0))) :: Nil, lockTime = 0), - Transaction(version = 2, txIn = Nil, txOut = TxOut(Satoshi(1100_000), Script.pay2wpkh(getPublicKey(1))) :: Nil, lockTime = 0), - Transaction(version = 2, txIn = Nil, txOut = TxOut(Satoshi(1200_000), Script.pay2wpkh(getPublicKey(2))) :: Nil, lockTime = 0), + Transaction(version = 2, txIn = Nil, txOut = TxOut(Satoshi(1_000_000), Script.pay2wpkh(getPublicKey(0))) :: Nil, lockTime = 0), + Transaction(version = 2, txIn = Nil, txOut = TxOut(Satoshi(1_100_000), Script.pay2wpkh(getPublicKey(1))) :: Nil, lockTime = 0), + Transaction(version = 2, txIn = Nil, txOut = TxOut(Satoshi(1_200_000), Script.pay2wpkh(getPublicKey(2))) :: Nil, lockTime = 0), ) val bip32paths = Seq( new KeyPathWithMaster(0, new fr.acinq.bitcoin.KeyPath("m/84'/1'/0'/0/0")), @@ -66,13 +65,13 @@ class LocalOnchainKeyManagerSpec extends AnyFunSuite { { // sign all inputs and outputs - val Success(psbt1) = onchainKeyManager.signPsbt(psbt, Seq(0, 1, 2), Seq(0)) + val Success(psbt1) = onChainKeyManager.sign(psbt, Seq(0, 1, 2), Seq(0)) val signedTx = psbt1.extract().getRight Transaction.correctlySpends(signedTx, utxos, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } { // sign the first 2 inputs only - val Success(psbt1) = onchainKeyManager.signPsbt(psbt, Seq(0, 1), Seq(0)) + val Success(psbt1) = onChainKeyManager.sign(psbt, Seq(0, 1), Seq(0)) // extracting the final tx fails because no all inputs as signed assert(psbt1.extract().isLeft) assert(psbt1.getInput(2).getScriptWitness == null) @@ -80,19 +79,19 @@ class LocalOnchainKeyManagerSpec extends AnyFunSuite { { // provide a wrong derivation path for the first input val updated = psbt.updateWitnessInput(OutPoint(utxos(0), 0), utxos(0).txOut(0), null, Script.pay2pkh(getPublicKey(0)).map(scala2kmp).asJava, null, java.util.Map.of(getPublicKey(0), bip32paths(2))).getRight // wrong bip32 path - val Failure(error) = onchainKeyManager.signPsbt(updated, Seq(0, 1, 2), Seq(0)) - assert(error.getMessage.contains("cannot compute private key")) + val Failure(error) = onChainKeyManager.sign(updated, Seq(0, 1, 2), Seq(0)) + assert(error.getMessage.contains("derived public key doesn't match")) } { // provide a wrong derivation path for the first output val updated = psbt.updateWitnessOutput(0, null, null, java.util.Map.of(getPublicKey(0), bip32paths(1))).getRight // wrong path - val Failure(error) = onchainKeyManager.signPsbt(updated, Seq(0, 1, 2), Seq(0)) - assert(error.getMessage.contains("cannot compute public key")) + val Failure(error) = onChainKeyManager.sign(updated, Seq(0, 1, 2), Seq(0)) + assert(error.getMessage.contains("could not verify output 0")) } { // lie about the amount being spent val updated = psbt.updateWitnessInput(OutPoint(utxos(0), 0), utxos(0).txOut(0).copy(amount = Satoshi(10)), null, Script.pay2pkh(getPublicKey(0)).map(scala2kmp).asJava, null, java.util.Map.of(getPublicKey(0), bip32paths(0))).getRight - val Failure(error) = onchainKeyManager.signPsbt(updated, Seq(0, 1, 2), Seq(0)) + val Failure(error) = onChainKeyManager.sign(updated, Seq(0, 1, 2), Seq(0)) assert(error.getMessage.contains("utxo mismatch")) } { @@ -105,15 +104,14 @@ class LocalOnchainKeyManagerSpec extends AnyFunSuite { p4 <- p3.updateNonWitnessInput(utxos(1), 0, null, null, java.util.Map.of()) p5 <- p4.updateWitnessOutput(0, null, null, java.util.Map.of(getPublicKey(0), bip32paths(0))) } yield p5 - - val Failure(error) = onchainKeyManager.signPsbt(psbt, Seq(0, 1, 2), Seq(0)) + val Failure(error) = onChainKeyManager.sign(psbt, Seq(0, 1, 2), Seq(0)) assert(error.getMessage.contains("non-witness utxo is missing")) } { // use sighash type != SIGHASH_ALL val updated = psbt.updateWitnessInput(OutPoint(utxos(0), 0), utxos(0).txOut(0), null, Script.pay2pkh(getPublicKey(0)).map(scala2kmp).asJava, SigHash.SIGHASH_SINGLE, java.util.Map.of(getPublicKey(0), bip32paths(0))).getRight - val Failure(error) = onchainKeyManager.signPsbt(updated, Seq(0, 1, 2), Seq(0)) - assert(error.getMessage.contains("input sighashtype must be SIGHASH_ALL")) + val Failure(error) = onChainKeyManager.sign(updated, Seq(0, 1, 2), Seq(0)) + assert(error.getMessage.contains("input sighash must be SIGHASH_ALL")) } } @@ -130,7 +128,7 @@ class LocalOnchainKeyManagerSpec extends AnyFunSuite { ) data.foreach(dnc => { val Array(desc, checksum) = dnc.split('#') - assert(checksum == LocalOnchainKeyManager.descriptorChecksum(desc)) + assert(checksum == LocalOnChainKeyManager.descriptorChecksum(desc)) }) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 6fb7853f27..3f1138d4b3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -17,7 +17,7 @@ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.crypto.TransportHandler -import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager, LocalOnchainKeyManager} +import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager} import fr.acinq.eclair.io.Peer.OpenChannelResponse import fr.acinq.eclair.io.PeerConnection.ConnectionResult import fr.acinq.eclair.io.{Peer, PeerConnection, PendingChannelsRateLimiter, Switchboard} @@ -66,15 +66,16 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat def nodeParamsFor(alias: String, seed: ByteVector32): NodeParams = { NodeParams.makeNodeParams( - config = ConfigFactory.load().getConfig("eclair"), - instanceId = UUID.randomUUID(), - nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash), - channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash), - torAddress_opt = None, - database = TestDatabases.inMemoryDb(), - blockHeight = new AtomicLong(400_000), - feerates = new AtomicReference(FeeratesPerKw.single(FeeratePerKw(253 sat))) - ).modify(_.alias).setTo(alias) + config = ConfigFactory.load().getConfig("eclair"), + instanceId = UUID.randomUUID(), + nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash), + channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash), + onChainKeyManager_opt = None, + torAddress_opt = None, + database = TestDatabases.inMemoryDb(), + blockHeight = new AtomicLong(400_000), + feerates = new AtomicReference(FeeratesPerKw.single(FeeratePerKw(253 sat))) + ).modify(_.alias).setTo(alias) .modify(_.chainHash).setTo(Block.RegtestGenesisBlock.hash) .modify(_.routerConf.routerBroadcastInterval).setTo(1 second) .modify(_.peerConnectionConf.maxRebroadcastDelay).setTo(1 second) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/OnChain.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/OnChain.scala index e89b94c7ce..13ee82cbee 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/OnChain.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/OnChain.scala @@ -73,15 +73,14 @@ trait OnChain { val getmasterxpub: Route = postRequest("getmasterxpub") { implicit t => formFields("account".as[Long].?) { account_opt => - val xpub = this.eclairApi.getOnchainMasterPubKey(account_opt.getOrElse(0L)) + val xpub = eclairApi.getOnChainMasterPubKey(account_opt.getOrElse(0L)) complete(new JObject(List("xpub" -> JString(xpub)))) } } val getdescriptors: Route = postRequest("getdescriptors") { implicit t => - formFields("account".as[Long].?) { - (account_opt) => - val descriptors = this.eclairApi.getDescriptors(account_opt.getOrElse(0L)) + formFields("account".as[Long].?) { account_opt => + val descriptors = eclairApi.getDescriptors(account_opt.getOrElse(0L)) complete(descriptors.descriptors) } }