mirror of
https://github.com/ACINQ/eclair.git
synced 2025-02-23 14:40:34 +01:00
More lenient interactive-tx
RBF validation (#2402)
When fee-bumping an interactive-tx, we want to be more lenient and accept transactions that improve the feerate, even if peers didn't contribute equally to the feerate increase. This is particularly useful for scenarios where the non-initiator dedicated a full utxo for the channel and doesn't want to expose new utxos when bumping the fees (or doesn't have more utxos to allocate).
This commit is contained in:
parent
ee1136c040
commit
611b79635e
2 changed files with 126 additions and 8 deletions
|
@ -324,10 +324,20 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
|
|||
filterInputs(fundedTx, currentInputs, unusableInputs)
|
||||
}
|
||||
case WalletFailure(t) =>
|
||||
log.error("could not fund interactive tx: ", t)
|
||||
// We use a generic exception and don't send the internal error to the peer.
|
||||
replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId))
|
||||
unlockAndStop(currentInputs.map(toOutPoint).toSet ++ unusableInputs.map(_.outpoint))
|
||||
if (previousTransactions.nonEmpty && !fundingParams.isInitiator) {
|
||||
// We don't have enough funds to reach the desired feerate, but this is an RBF attempt that we did not initiate.
|
||||
// It still makes sense for us to contribute whatever we're able to (by using our previous set of inputs and
|
||||
// outputs): the final feerate will be less than what the initiator intended, but it's still better than being
|
||||
// stuck with a low feerate transaction that won't confirm.
|
||||
log.warn("could not fund interactive tx at {}, re-using previous inputs and outputs", fundingParams.targetFeerate)
|
||||
val previousTx = previousTransactions.head.tx
|
||||
stash.unstashAll(buildTx(FundingContributions(previousTx.localInputs, previousTx.localOutputs)))
|
||||
} else {
|
||||
log.error("could not fund interactive tx: ", t)
|
||||
// We use a generic exception and don't send the internal error to the peer.
|
||||
replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId))
|
||||
unlockAndStop(currentInputs.map(toOutPoint).toSet ++ unusableInputs.map(_.outpoint))
|
||||
}
|
||||
case msg: ReceiveMessage =>
|
||||
stash.stash(msg)
|
||||
Behaviors.same
|
||||
|
@ -606,10 +616,30 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
|
|||
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
|
||||
}
|
||||
|
||||
val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, minimumWeight)
|
||||
if (sharedTx.fees < minimumFee) {
|
||||
log.warn("invalid interactive tx: below the target feerate (target={}, actual={})", fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, minimumWeight))
|
||||
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
|
||||
previousTransactions.headOption match {
|
||||
case Some(previousTx) =>
|
||||
// This is an RBF attempt: even if our peer does not contribute to the feerate increase, we'd like to broadcast
|
||||
// the new transaction if it has a better feerate than the previous one. This is better than being stuck with
|
||||
// a transaction that doesn't confirm.
|
||||
val remoteInputsUnchanged = previousTx.tx.remoteInputs.map(_.outPoint).toSet == sharedTx.remoteInputs.map(_.outPoint).toSet
|
||||
val remoteOutputsUnchanged = previousTx.tx.remoteOutputs.map(o => TxOut(o.amount, o.pubkeyScript)).toSet == sharedTx.remoteOutputs.map(o => TxOut(o.amount, o.pubkeyScript)).toSet
|
||||
if (remoteInputsUnchanged && remoteOutputsUnchanged) {
|
||||
log.info("peer did not contribute to the feerate increase to {}: they used the same inputs and outputs", fundingParams.targetFeerate)
|
||||
}
|
||||
val previousUnsignedTx = previousTx.tx.buildUnsignedTx()
|
||||
val previousMinimumWeight = previousUnsignedTx.weight() + previousUnsignedTx.txIn.length * minimumWitnessWeight
|
||||
val previousFeerate = Transactions.fee2rate(previousTx.tx.fees, previousMinimumWeight)
|
||||
val nextFeerate = Transactions.fee2rate(sharedTx.fees, minimumWeight)
|
||||
if (nextFeerate <= previousFeerate) {
|
||||
log.warn("invalid interactive tx: next feerate isn't greater than previous feerate (previous={}, next={})", previousFeerate, nextFeerate)
|
||||
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
|
||||
}
|
||||
case None =>
|
||||
val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, minimumWeight)
|
||||
if (sharedTx.fees < minimumFee) {
|
||||
log.warn("invalid interactive tx: below the target feerate (target={}, actual={})", fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, minimumWeight))
|
||||
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
|
||||
}
|
||||
}
|
||||
|
||||
// The transaction must double-spend every previous attempt, otherwise there is a risk that two funding transactions
|
||||
|
|
|
@ -696,6 +696,94 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
|
|||
}
|
||||
}
|
||||
|
||||
test("rbf with previous contributions from the non-initiator") {
|
||||
val initialFeerate = FeeratePerKw(5_000 sat)
|
||||
val fundingA = 100_000 sat
|
||||
val utxosA = Seq(70_000 sat, 60_000 sat)
|
||||
val fundingB = 25_000 sat
|
||||
val utxosB = Seq(27_500 sat)
|
||||
withFixture(fundingA, utxosA, fundingB, utxosB, initialFeerate, 660 sat, 0) { f =>
|
||||
import f._
|
||||
|
||||
alice ! Start(alice2bob.ref, Nil)
|
||||
bob ! Start(bob2alice.ref, Nil)
|
||||
|
||||
// Alice --- tx_add_input --> Bob
|
||||
val inputA1 = f.forwardAlice2Bob[TxAddInput]
|
||||
// Alice <-- tx_add_input --- Bob
|
||||
val inputB = f.forwardBob2Alice[TxAddInput]
|
||||
// Alice --- tx_add_input --> Bob
|
||||
val inputA2 = f.forwardAlice2Bob[TxAddInput]
|
||||
// Alice <-- tx_complete --- Bob
|
||||
f.forwardBob2Alice[TxComplete]
|
||||
// Alice --- tx_add_output --> Bob
|
||||
f.forwardAlice2Bob[TxAddOutput]
|
||||
// Alice <-- tx_complete --- Bob
|
||||
f.forwardBob2Alice[TxComplete]
|
||||
// Alice --- tx_add_output --> Bob
|
||||
f.forwardAlice2Bob[TxAddOutput]
|
||||
// Alice <-- tx_complete --- Bob
|
||||
f.forwardBob2Alice[TxComplete]
|
||||
// Alice --- tx_complete --> Bob
|
||||
f.forwardAlice2Bob[TxComplete]
|
||||
// Alice --- commit_sig --> Bob
|
||||
f.forwardAlice2Bob[CommitSig]
|
||||
// Alice <-- commit_sig --- Bob
|
||||
f.forwardBob2Alice[CommitSig]
|
||||
// Alice <-- tx_signatures --- Bob
|
||||
val txB1 = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction]
|
||||
alice ! ReceiveTxSigs(txB1.localSigs)
|
||||
val txA1 = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction]
|
||||
assert(initialFeerate * 0.9 <= txA1.feerate && txA1.feerate <= initialFeerate * 1.25)
|
||||
val probe = TestProbe()
|
||||
walletA.publishTransaction(txA1.signedTx).pipeTo(probe.ref)
|
||||
probe.expectMsg(txA1.signedTx.txid)
|
||||
|
||||
// Bob didn't have enough funds to add a change output.
|
||||
// If we want to increase the feerate, Bob cannot contribute more than what he has already contributed.
|
||||
// However, it still makes sense for Bob to contribute whatever he's able to, the final feerate will simply be
|
||||
// slightly less than what Alice intended, but it's better than being stuck with a low feerate.
|
||||
aliceRbf ! Start(alice2bob.ref, Seq(txA1))
|
||||
bobRbf ! Start(bob2alice.ref, Seq(txB1))
|
||||
|
||||
// Alice --- tx_add_input --> Bob
|
||||
assert(f.forwardRbfAlice2Bob[TxAddInput] == inputA1)
|
||||
// Alice <-- tx_add_input --- Bob
|
||||
assert(f.forwardRbfBob2Alice[TxAddInput] == inputB)
|
||||
// Alice --- tx_add_input --> Bob
|
||||
assert(f.forwardRbfAlice2Bob[TxAddInput] == inputA2)
|
||||
// Alice <-- tx_complete --- Bob
|
||||
f.forwardRbfBob2Alice[TxComplete]
|
||||
// Alice --- tx_add_output --> Bob
|
||||
f.forwardRbfAlice2Bob[TxAddOutput]
|
||||
// Alice <-- tx_complete --- Bob
|
||||
f.forwardRbfBob2Alice[TxComplete]
|
||||
// Alice --- tx_add_output --> Bob
|
||||
f.forwardRbfAlice2Bob[TxAddOutput]
|
||||
// Alice <-- tx_complete --- Bob
|
||||
f.forwardRbfBob2Alice[TxComplete]
|
||||
// Alice --- tx_complete --> Bob
|
||||
f.forwardRbfAlice2Bob[TxComplete]
|
||||
// Alice --- commit_sig --> Bob
|
||||
f.forwardRbfAlice2Bob[CommitSig]
|
||||
// Alice <-- commit_sig --- Bob
|
||||
f.forwardRbfBob2Alice[CommitSig]
|
||||
// Alice <-- tx_signatures --- Bob
|
||||
val txB2 = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction]
|
||||
aliceRbf ! ReceiveTxSigs(txB2.localSigs)
|
||||
val succeeded = alice2bob.expectMsgType[Succeeded]
|
||||
val rbfFeerate = succeeded.fundingParams.targetFeerate
|
||||
assert(rbfFeerate == FeeratePerKw(7500 sat))
|
||||
val txA2 = succeeded.sharedTx.asInstanceOf[FullySignedSharedTransaction]
|
||||
assert(rbfFeerate * 0.75 <= txA2.feerate && txA2.feerate <= rbfFeerate * 1.25)
|
||||
assert(txA1.signedTx.txIn.map(_.outPoint).toSet == txA2.signedTx.txIn.map(_.outPoint).toSet)
|
||||
assert(txA2.signedTx.txOut.map(_.amount).sum < txA1.signedTx.txOut.map(_.amount).sum)
|
||||
assert(txA1.tx.fees < txA2.tx.fees)
|
||||
walletA.publishTransaction(txA2.signedTx).pipeTo(probe.ref)
|
||||
probe.expectMsg(txA2.signedTx.txid)
|
||||
}
|
||||
}
|
||||
|
||||
test("not enough funds for rbf attempt") {
|
||||
val targetFeerate = FeeratePerKw(10_000 sat)
|
||||
val fundingA = 80_000 sat
|
||||
|
|
Loading…
Add table
Reference in a new issue