Skip to content

Commit

Permalink
Add liquidity griefing protection for liquidity ads (#2982)
Browse files Browse the repository at this point in the history
* Allow funding without locks for liquidity griefing

When using liquidity ads, seller nodes may be vulnerable to liquidity
griefing attacks. They may disable utxo locking to protect against such
attacks, but the trade-off is that honest peers may also be affected by
having their funding transaction double-spent.

We thus expose a configuration flags to let node operators decide which
trade-off they choose, depending on how likely they think someone will
target them vs the UX they want to provide to their customers.

* Abort incoming channel after timeout

If a remote node starts opening a channel to us and then becomes
unresponsive, we abort the channel. This is particularly useful
when they're purchasing liquidity and we've locked utxos.
  • Loading branch information
t-bast authored Jan 24, 2025
1 parent 3249f2b commit 12df4ce
Show file tree
Hide file tree
Showing 23 changed files with 134 additions and 101 deletions.
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

0 comments on commit 12df4ce

Please sign in to comment.