1
0
Fork 0
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:
Bastien Teinturier 2022-09-06 16:17:52 +02:00 committed by GitHub
parent ee1136c040
commit 611b79635e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 126 additions and 8 deletions

View file

@ -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

View file

@ -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