Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ eclair {
max-attempts = 5 // maximum number of RBF attempts our peer is allowed to make
attempt-delta-blocks = 6 // minimum number of blocks between RBF attempts
}
// Duration after which we abort a channel creation. If our peer seems unresponsive and doesn't complete the
// funding protocol in time, they're likely buggy or malicious.
timeout = 60 seconds
}

dust-limit-satoshis = 546
Expand Down Expand Up @@ -343,6 +346,21 @@ eclair {
// This doesn't involve trust from the buyer or the seller.
"from_channel_balance"
]
// When selling your liquidity, malicious nodes can attempt a liquidity griefing attack against your node where
// they initiate a purchase and stop responding before finalizing the funding transaction. By default, the utxos
// used for this funding transaction will be locked to avoid accidentally double-spending ourselves. The funding
// attempt will automatically be cancelled after a timeout and utxos unlocked, but the attacker will have succeeded
// in "locking" some of our liquidity for a short duration. By continually repeating this process, they may prevent
// your node from successfully selling liquidity to honest nodes. More details can be found here:
// https://delvingbitcoin.org/t/liquidity-griefing-in-multi-party-transaction-protocols/264
//
// The only way to fully prevent this attack is to set the following parameter to false, which means that utxos
// will never be locked during funding attempts, and can thus immediately be reused until an honest node completes
// a liquidity purchase. However, there is a drawback: it means that all funding transactions may now be double
// spent accidentally, even funding transactions with honest nodes, which requires retrying the purchase when it
// fails and isn't a very good UX. It is up to the node operator to decide which trade-off they're comfortable with
// and to set this flag accordingly.
lock-utxos-during-funding = true
}

// On-the-fly funding leverages liquidity ads to fund channels with wallet peers based on their payment patterns.
Expand Down
2 changes: 1 addition & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}

override def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse] = {
appKit.nodeParams.willFundRates_opt match {
appKit.nodeParams.liquidityAdsConfig.rates_opt match {
case Some(willFundRates) if willFundRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) =>
appKit.nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc()
Future.successful(EnableFromFutureHtlcResponse(appKit.nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed, None))
Expand Down
5 changes: 3 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
onionMessageConfig: OnionMessageConfig,
purgeInvoicesInterval: Option[FiniteDuration],
revokedHtlcInfoCleanerConfig: RevokedHtlcInfoCleaner.Config,
willFundRates_opt: Option[LiquidityAds.WillFundRates],
liquidityAdsConfig: LiquidityAds.Config,
peerWakeUpConfig: PeerReadyNotifier.WakeUpConfig,
onTheFlyFundingConfig: OnTheFlyFunding.Config,
peerStorageConfig: PeerStorageConfig) {
Expand Down Expand Up @@ -591,6 +591,7 @@ object NodeParams extends Logging {
channelOpenerWhitelist = channelOpenerWhitelist,
maxPendingChannelsPerPeer = maxPendingChannelsPerPeer,
maxTotalPendingChannelsPrivateNodes = maxTotalPendingChannelsPrivateNodes,
channelFundingTimeout = FiniteDuration(config.getDuration("channel.funding.timeout").getSeconds, TimeUnit.SECONDS),
remoteRbfLimits = Channel.RemoteRbfLimits(config.getInt("channel.funding.remote-rbf-limits.max-attempts"), config.getInt("channel.funding.remote-rbf-limits.attempt-delta-blocks")),
quiescenceTimeout = FiniteDuration(config.getDuration("channel.quiescence-timeout").getSeconds, TimeUnit.SECONDS),
balanceThresholds = config.getConfigList("channel.channel-update.balance-thresholds").asScala.map(conf => BalanceThreshold(Satoshi(conf.getLong("available-sat")), Satoshi(conf.getLong("max-htlc-sat")))).toSeq,
Expand Down Expand Up @@ -683,7 +684,7 @@ object NodeParams extends Logging {
batchSize = config.getInt("db.revoked-htlc-info-cleaner.batch-size"),
interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS)
),
willFundRates_opt = willFundRates_opt,
liquidityAdsConfig = LiquidityAds.Config(rates_opt = willFundRates_opt, lockUtxos = config.getBoolean("liquidity-ads.lock-utxos-during-funding")),
peerWakeUpConfig = PeerReadyNotifier.WakeUpConfig(
enabled = config.getBoolean("peer-wake-up.enabled"),
timeout = FiniteDuration(config.getDuration("peer-wake-up.timeout").getSeconds, TimeUnit.SECONDS),
Expand Down
6 changes: 3 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ class Setup(val datadir: File,
// This is how you would create a new bitcoin wallet whose private keys are managed by Eclair.
// 3) there is an `eclair-signer.conf` file in Eclair's data directory, and the name of the wallet set in `eclair-signer.conf` matches the `eclair.bitcoind.wallet` setting in `eclair.conf`.
// Eclair will assume that this is a watch-only bitcoin wallet that has been created from descriptors generated by Eclair, and will manage its private keys, and here we pass the onchain key manager to our bitcoin client.
bitcoinClient = new BitcoinCoreClient(bitcoin, if (bitcoin.wallet == onChainKeyManager_opt.map(_.walletName)) onChainKeyManager_opt else None) with OnchainPubkeyCache {
bitcoinClient = new BitcoinCoreClient(bitcoin, nodeParams.liquidityAdsConfig.lockUtxos, if (bitcoin.wallet == onChainKeyManager_opt.map(_.walletName)) 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 = {
Expand Down Expand Up @@ -325,9 +325,9 @@ class Setup(val datadir: File,
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqblock"), ZMQActor.Topics.HashBlock, Some(zmqBlockConnected))), "zmqblock", SupervisorStrategy.Restart))
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqtx"), ZMQActor.Topics.RawTx, Some(zmqTxConnected))), "zmqtx", SupervisorStrategy.Restart))
val watcherBitcoinClient = if (config.getBoolean("bitcoind.batch-watcher-requests")) {
new BitcoinCoreClient(new BatchingBitcoinJsonRPCClient(bitcoin))
new BitcoinCoreClient(new BatchingBitcoinJsonRPCClient(bitcoin), nodeParams.liquidityAdsConfig.lockUtxos)
} else {
new BitcoinCoreClient(bitcoin)
new BitcoinCoreClient(bitcoin, nodeParams.liquidityAdsConfig.lockUtxos)
}
system.spawn(Behaviors.supervise(ZmqWatcher(nodeParams, blockHeight, watcherBitcoinClient)).onFailure(typed.SupervisorStrategy.resume), "watcher")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ trait OnChainChannelFunder {
* 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, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse]
def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse]

/**
* Sign a PSBT. Result may be partially signed: only inputs known to our bitcoin wallet will be signed. *
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFu
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.json.SatoshiSerializer
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.wire.protocol.ChannelAnnouncement
import fr.acinq.eclair.{BlockHeight, TimestampSecond, TxCoordinates}
Expand All @@ -51,7 +50,7 @@ import scala.util.{Failure, Success, Try}
* @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 lockUtxos: Boolean = true, val onChainKeyManager_opt: Option[OnChainKeyManager] = None) extends OnChainWallet with Logging {

import BitcoinCoreClient._

Expand Down Expand Up @@ -262,8 +261,23 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag
})
}

def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi] = None)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, inputWeights = externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq), feeBudget_opt = feeBudget_opt)
def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi] = None)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
val options = FundTransactionOptions(
BigDecimal(FeeratePerKB(feeRate).toLong).bigDecimal.scaleByPowerOfTen(-8),
replaceable,
// We must either *always* lock inputs selected for funding or *never* lock them, otherwise locking wouldn't work
// at all, as the following scenario highlights:
// - we fund a transaction for which we don't lock utxos
// - we fund another unrelated transaction for which we lock utxos
// - the second transaction ends up using the same utxos as the first one
// - but the first transaction confirms, invalidating the second one
// This would break the assumptions of the second transaction: its inputs are locked, so it doesn't expect to
// potentially be double-spent.
lockUtxos,
changePosition,
if (externalInputsWeight.isEmpty) None else Some(externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq)
)
fundTransaction(tx, options, feeBudget_opt = feeBudget_opt)
}

private def processPsbt(psbt: Psbt, sign: Boolean = true, sighashType: Option[Int] = None)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = {
Expand Down Expand Up @@ -344,7 +358,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag
// TODO: we should check that mempoolMinFee is not dangerously high
feerate <- mempoolMinFee().map(minFee => FeeratePerKw(minFee).max(targetFeerate))
// we ask bitcoin core to add inputs to the funding tx, and use the specified change address
FundTransactionResponse(tx, fee, _) <- fundTransaction(partialFundingTx, FundTransactionOptions(feerate), feeBudget_opt = feeBudget_opt)
FundTransactionResponse(tx, fee, _) <- fundTransaction(partialFundingTx, feerate, feeBudget_opt = feeBudget_opt)
lockedUtxos = tx.txIn.map(_.outPoint)
signedTx <- unlockIfFails(lockedUtxos)(verifyAndSign(tx, fee, feerate))
} yield signedTx
Expand Down Expand Up @@ -706,26 +720,6 @@ object BitcoinCoreClient {

case class FundTransactionOptions(feeRate: BigDecimal, replaceable: Boolean, lockUnspents: Boolean, changePosition: Option[Int], input_weights: Option[Seq[InputWeight]])

object FundTransactionOptions {
def apply(feerate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, inputWeights: Seq[InputWeight] = Nil): FundTransactionOptions = {
FundTransactionOptions(
BigDecimal(FeeratePerKB(feerate).toLong).bigDecimal.scaleByPowerOfTen(-8),
replaceable,
// We must *always* lock inputs selected for funding, otherwise locking wouldn't work at all, as the following
// scenario highlights:
// - we fund a transaction for which we don't lock utxos
// - we fund another unrelated transaction for which we lock utxos
// - the second transaction ends up using the same utxos as the first one
// - but the first transaction confirms, invalidating the second one
// This would break the assumptions of the second transaction: its inputs are locked, so it doesn't expect to
// potentially be double-spent.
lockUnspents = true,
changePosition,
if (inputWeights.isEmpty) None else Some(inputWeights)
)
}
}

/**
* Information about a transaction currently in the mempool.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ object Channel {
channelOpenerWhitelist: Set[PublicKey],
maxPendingChannelsPerPeer: Int,
maxTotalPendingChannelsPrivateNodes: Int,
channelFundingTimeout: FiniteDuration,
remoteRbfLimits: RemoteRbfLimits,
quiescenceTimeout: FiniteDuration,
balanceThresholds: Seq[BalanceThreshold],
Expand Down Expand Up @@ -1000,7 +1001,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
val parentCommitment = d.commitments.latest.commitment
val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey
val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey)
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, msg.requestFunding_opt, nodeParams.willFundRates_opt, msg.useFeeCredit_opt) match {
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, msg.requestFunding_opt, nodeParams.liquidityAdsConfig.rates_opt, msg.useFeeCredit_opt) match {
case Left(t) =>
log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage)
Expand Down Expand Up @@ -1122,7 +1123,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, rbf.latestFundingTx.createdAt, rbf.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage)
case Right(rbf) =>
val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, msg.requestFunding_opt, nodeParams.willFundRates_opt, feeCreditUsed_opt = None) match {
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, msg.requestFunding_opt, nodeParams.liquidityAdsConfig.rates_opt, feeCreditUsed_opt = None) match {
case Left(t) =>
log.warning("rejecting rbf request with invalid liquidity ads: {}", t.getMessage)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
stay() using d.copy(status = DualFundingStatus.RbfAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, d.latestFundingTx.createdAt, d.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage)
} else {
val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = true, msg.requestFunding_opt, nodeParams.willFundRates_opt, None) match {
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = true, msg.requestFunding_opt, nodeParams.liquidityAdsConfig.rates_opt, None) match {
case Left(t) =>
log.warning("rejecting rbf attempt: invalid liquidity ads request ({})", t.getMessage)
stay() using d.copy(status = DualFundingStatus.RbfAborted) sending TxAbort(d.channelId, t.getMessage)
Expand Down
Loading
Loading