Skip to content

Commit

Permalink
add a funding fee budget
Browse files Browse the repository at this point in the history
This is based on #2780, with a slightly different implementation.

The fee is checked right after calling `fundrawtransaction`, which makes it work for both the single and dual funding cases.

Also we use the `feeBudget` terminology instead of `maxFee`.
  • Loading branch information
pm47 committed Jan 9, 2024
1 parent 61f1e1f commit 568e04d
Show file tree
Hide file tree
Showing 38 changed files with 136 additions and 112 deletions.
11 changes: 8 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ trait Eclair {

def disconnect(nodeId: PublicKey)(implicit timeout: Timeout): Future[String]

def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeeratePerByte_opt: Option[FeeratePerByte], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse]
def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse]

def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]

Expand Down Expand Up @@ -205,17 +205,22 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
(appKit.switchboard ? Peer.Disconnect(nodeId)).mapTo[Peer.DisconnectResponse].map(_.toString)
}

override def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeeratePerByte_opt: Option[FeeratePerByte], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse] = {
override def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse] = {
// we want the open timeout to expire *before* the default ask timeout, otherwise user will get a generic response
val openTimeout = openTimeout_opt.getOrElse(Timeout(20 seconds))
val fundingFeeBudget = fundingFeeBudget_opt.getOrElse{
val proportionalFee = Helpers.getRelayFees(appKit.nodeParams, nodeId, announceChannel_opt.getOrElse(appKit.nodeParams.channelConf.channelFlags.announceChannel)).feeProportionalMillionths.toDouble / 1e6
fundingAmount * proportionalFee
}
for {
_ <- Future.successful(0)
open = Peer.OpenChannel(
remoteNodeId = nodeId,
fundingAmount = fundingAmount,
channelType_opt = channelType_opt,
pushAmount_opt = pushAmount_opt,
fundingTxFeerate_opt = fundingFeeratePerByte_opt.map(FeeratePerKw(_)),
fundingTxFeerate_opt = fundingFeerate_opt.map(FeeratePerKw(_)),
fundingTxFeeBudget_opt = Some(fundingFeeBudget),
channelFlags_opt = announceChannel_opt.map(announceChannel => ChannelFlags(announceChannel = announceChannel)),
timeout_opt = Some(openTimeout))
res <- (appKit.switchboard ? open).mapTo[OpenChannelResponse]
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)(implicit ec: ExecutionContext): Future[FundTransactionResponse]
def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, 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 All @@ -55,7 +55,7 @@ trait OnChainChannelFunder {
def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[TxId]

/** Create a fully signed channel funding transaction with the provided pubkeyScript. */
def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse]
def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRate: FeeratePerKw, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[MakeFundingTxResponse]

/**
* Committing *must* include publishing the transaction on the network.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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 Down Expand Up @@ -233,7 +234,10 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag

//------------------------- FUNDING -------------------------//

def fundTransaction(tx: Transaction, options: FundTransactionOptions)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
/**
* @param feeBudget max allowed fee, if the transaction returned by bitcoin core has a higher fee a funding error is returned.
*/
def fundTransaction(tx: Transaction, options: FundTransactionOptions, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
rpcClient.invoke("fundrawtransaction", tx.toString(), options).flatMap(json => {
val JString(hex) = json \ "hex"
val JInt(changePos) = json \ "changepos"
Expand All @@ -243,21 +247,23 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag

val walletInputs = fundedTx.txIn.map(_.outPoint).toSet -- tx.txIn.map(_.outPoint).toSet
val addedOutputs = fundedTx.txOut.size - tx.txOut.size
val feeSat = toSatoshi(fee)
Try {
require(addedOutputs <= 1, "more than one change output added")
require(addedOutputs == 0 || changePos >= 0, "change output added, but position not returned")
require(options.changePosition.isEmpty || changePos_opt.isEmpty || changePos_opt == options.changePosition, "change output added at wrong position")
feeBudget_opt.foreach(feeBudget => require(feeSat <= feeBudget, s"mining fee is higher than budget ($feeSat > $feeBudget)"))

FundTransactionResponse(fundedTx, toSatoshi(fee), changePos_opt)
FundTransactionResponse(fundedTx, feeSat, changePos_opt)
} match {
case Success(response) => Future.successful(response)
case Failure(error) => unlockOutpoints(walletInputs.toSeq).flatMap(_ => Future.failed(error))
}
})
}

def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, inputWeights = externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq))
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)
}

private def processPsbt(psbt: Psbt, sign: Boolean = true, sighashType: Option[Int] = None)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = {
Expand Down Expand Up @@ -302,7 +308,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag
}
}

def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, targetFeerate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = {
def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, targetFeerate: FeeratePerKw, feeBudget_opt: Option[Satoshi] = None)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = {

def verifyAndSign(tx: Transaction, fees: Satoshi, requestedFeeRate: FeeratePerKw): Future[MakeFundingTxResponse] = {
import KotlinUtils._
Expand Down Expand Up @@ -338,7 +344,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))
FundTransactionResponse(tx, fee, _) <- fundTransaction(partialFundingTx, FundTransactionOptions(feerate), feeBudget_opt = feeBudget_opt)
lockedUtxos = tx.txIn.map(_.outPoint)
signedTx <- unlockIfFails(lockedUtxos)(verifyAndSign(tx, fee, feerate))
} yield signedTx
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ case class INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId: ByteVector32,
dualFunded: Boolean,
commitTxFeerate: FeeratePerKw,
fundingTxFeerate: FeeratePerKw,
fundingTxFeeBudget_opt: Option[Satoshi],
pushAmount_opt: Option[MilliSatoshi],
requireConfirmedInputs: Boolean,
localParams: LocalParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
targetFeerate = open.fundingFeerate,
requireConfirmedInputs = RequireConfirmedInputs(forLocal = open.requireConfirmedInputs, forRemote = accept.requireConfirmedInputs)
)
val purpose = InteractiveTxBuilder.FundingTx(open.commitmentFeerate, open.firstPerCommitmentPoint)
val purpose = InteractiveTxBuilder.FundingTx(open.commitmentFeerate, open.firstPerCommitmentPoint, feeBudget_opt = None)
val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
randomBytes32(),
nodeParams, fundingParams,
Expand Down Expand Up @@ -275,7 +275,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
targetFeerate = d.lastSent.fundingFeerate,
requireConfirmedInputs = RequireConfirmedInputs(forLocal = accept.requireConfirmedInputs, forRemote = d.lastSent.requireConfirmedInputs)
)
val purpose = InteractiveTxBuilder.FundingTx(d.lastSent.commitmentFeerate, accept.firstPerCommitmentPoint)
val purpose = InteractiveTxBuilder.FundingTx(d.lastSent.commitmentFeerate, accept.firstPerCommitmentPoint, feeBudget_opt = d.init.fundingTxFeeBudget_opt)
val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
randomBytes32(),
nodeParams, fundingParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers {
log.info("remote will use fundingMinDepth={}", accept.minimumDepth)
val localFundingPubkey = keyManager.fundingPublicKey(init.localParams.fundingKeyPath, fundingTxIndex = 0)
val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingPubkey.publicKey, accept.fundingPubkey)))
wallet.makeFundingTx(fundingPubkeyScript, init.fundingAmount, init.fundingTxFeerate).pipeTo(self)
wallet.makeFundingTx(fundingPubkeyScript, init.fundingAmount, init.fundingTxFeerate, init.fundingTxFeeBudget_opt).pipeTo(self)
val params = ChannelParams(init.temporaryChannelId, init.channelConfig, channelFeatures, init.localParams, remoteParams, open.channelFlags)
goto(WAIT_FOR_FUNDING_INTERNAL) using DATA_WAIT_FOR_FUNDING_INTERNAL(params, init.fundingAmount, init.pushAmount_opt.getOrElse(0 msat), init.commitTxFeerate, accept.fundingPubkey, accept.firstPerCommitmentPoint, d.initFunder.replyTo)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ object InteractiveTxBuilder {
def localHtlcs: Set[DirectedHtlc]
def htlcBalance: MilliSatoshi = localHtlcs.toSeq.map(_.add.amountMsat).sum
}
case class FundingTx(commitTxFeerate: FeeratePerKw, remotePerCommitmentPoint: PublicKey) extends Purpose {
case class FundingTx(commitTxFeerate: FeeratePerKw, remotePerCommitmentPoint: PublicKey, feeBudget_opt: Option[Satoshi]) extends Purpose {
override val previousLocalBalance: MilliSatoshi = 0 msat
override val previousRemoteBalance: MilliSatoshi = 0 msat
override val previousFundingAmount: Satoshi = 0 sat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,11 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response
*/
private def fund(txNotFunded: Transaction, currentInputs: Seq[OutgoingInput], unusableInputs: Set[UnusableInput]): Behavior[Command] = {
val sharedInputWeight = fundingParams.sharedInput_opt.toSeq.map(i => i.info.outPoint -> i.weight.toLong).toMap
context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, replaceable = true, externalInputsWeight = sharedInputWeight)) {
val feeBudget_opt = purpose match {
case p: FundingTx => p.feeBudget_opt
case _ => None
}
context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, replaceable = true, externalInputsWeight = sharedInputWeight, feeBudget_opt = feeBudget_opt)) {
case Failure(t) => WalletFailure(t)
case Success(result) => FundTransactionResult(result.tx, result.changePosition)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
// The anchor transaction is paying for the weight of the commitment transaction.
// We remove the weight of the artificially added change output, because we will remove that output after funding.
val anchorWeight = Seq(InputWeight(anchorTx.txInfo.input.outPoint, anchorInputWeight + commitTxWeight - KotlinUtils.scala2kmp(dummyChangeOutput).weight()))
bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate, inputWeights = anchorWeight)).flatMap { fundTxResponse =>
bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate, inputWeights = anchorWeight), feeBudget_opt = None).flatMap { fundTxResponse =>
// 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)
// The commitment transaction was already paying some fees that we're paying again in the anchor transaction since
Expand All @@ -464,7 +464,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
case _: HtlcSuccessTx => commitment.params.commitmentFormat.htlcSuccessInputWeight
case _: HtlcTimeoutTx => commitment.params.commitmentFormat.htlcTimeoutInputWeight
})
bitcoinClient.fundTransaction(htlcTx.txInfo.tx, FundTransactionOptions(targetFeerate, changePosition = Some(1), inputWeights = Seq(htlcInputWeight))).map(fundTxResponse => {
bitcoinClient.fundTransaction(htlcTx.txInfo.tx, FundTransactionOptions(targetFeerate, changePosition = Some(1), inputWeights = Seq(htlcInputWeight)), feeBudget_opt = None).map(fundTxResponse => {
// 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))
(htlcTx.updateTx(fundedTx), fundTxResponse.amountIn)
Expand Down
3 changes: 2 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnchainP
val fundingTxFeerate = c.fundingTxFeerate_opt.getOrElse(nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeerates))
val commitTxFeerate = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentFeerates, remoteNodeId, channelType.commitmentFormat, c.fundingAmount)
log.info(s"requesting a new channel with type=$channelType fundingAmount=${c.fundingAmount} dualFunded=$dualFunded pushAmount=${c.pushAmount_opt} fundingFeerate=$fundingTxFeerate temporaryChannelId=$temporaryChannelId localParams=$localParams")
channel ! INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId, c.fundingAmount, dualFunded, commitTxFeerate, fundingTxFeerate, c.pushAmount_opt, requireConfirmedInputs, localParams, d.peerConnection, d.remoteInit, c.channelFlags_opt.getOrElse(nodeParams.channelConf.channelFlags), channelConfig, channelType, c.channelOrigin, replyTo)
channel ! INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId, c.fundingAmount, dualFunded, commitTxFeerate, fundingTxFeerate, c.fundingTxFeeBudget_opt, c.pushAmount_opt, requireConfirmedInputs, localParams, d.peerConnection, d.remoteInit, c.channelFlags_opt.getOrElse(nodeParams.channelConf.channelFlags), channelConfig, channelType, c.channelOrigin, replyTo)
stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel))

case Event(open: protocol.OpenChannel, d: ConnectedData) =>
Expand Down Expand Up @@ -505,6 +505,7 @@ object Peer {
channelType_opt: Option[SupportedChannelType],
pushAmount_opt: Option[MilliSatoshi],
fundingTxFeerate_opt: Option[FeeratePerKw],
fundingTxFeeBudget_opt: Option[Satoshi],
channelFlags_opt: Option[ChannelFlags],
timeout_opt: Option[Timeout],
requireConfirmedInputsOverride_opt: Option[Boolean] = None,
Expand Down
Loading

0 comments on commit 568e04d

Please sign in to comment.