1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-03-13 11:35:47 +01:00

Implement the option_simple_close protocol

We introduce a new `NEGOTIATING_SIMPLE` state where we exchange the
`closing_complete` and `closing_sig` messages, and allow RBF-ing previous
transactions and updating our closing script.

We stay in that state until one of the transactions confirms, or a force
close is detected. This is important to ensure we're able to correctly
reconnect and negotiate RBF candidates.

We keep this separate from the previous `NEGOTIATING` state to make it
easier to remove support for the older mutual close protocols once we're
confident the network has been upgraded.
This commit is contained in:
t-bast 2024-12-17 16:29:43 +01:00
parent 8a9e63703f
commit 67aa29ff7e
No known key found for this signature in database
GPG key ID: 34F377B0100ED6BB
21 changed files with 935 additions and 82 deletions

View file

@ -4,7 +4,17 @@
## Major changes
<insert changes>
### Simplified mutual close
This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1096).
This protocol allows both channel participants to decide exactly how much fees they're willing to pay to close the channel.
Each participant obtains a channel closing transaction where they are paying the fees.
Once closing transactions are broadcast, they can be RBF-ed by calling the `close` RPC again with a higher feerate:
```sh
./eclair-cli close --channelId=<channel_id> --preferredFeerateSatByte=<rbf_feerate>
```
### Peer storage

View file

@ -213,6 +213,7 @@ object CheckBalance {
case (r, d: DATA_NORMAL) => r.modify(_.normal).using(updateMainAndHtlcBalance(d.commitments, knownPreimages))
case (r, d: DATA_SHUTDOWN) => r.modify(_.shutdown).using(updateMainAndHtlcBalance(d.commitments, knownPreimages))
case (r, d: DATA_NEGOTIATING) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit))
case (r, d: DATA_NEGOTIATING_SIMPLE) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit))
case (r, d: DATA_CLOSING) =>
Closing.isClosingTypeAlreadyKnown(d) match {
case None if d.mutualClosePublished.nonEmpty && d.localCommitPublished.isEmpty && d.remoteCommitPublished.isEmpty && d.nextRemoteCommitPublished.isEmpty && d.revokedCommitPublished.isEmpty =>

View file

@ -72,6 +72,7 @@ case object WAIT_FOR_DUAL_FUNDING_READY extends ChannelState
case object NORMAL extends ChannelState
case object SHUTDOWN extends ChannelState
case object NEGOTIATING extends ChannelState
case object NEGOTIATING_SIMPLE extends ChannelState
case object CLOSING extends ChannelState
case object CLOSED extends ChannelState
case object OFFLINE extends ChannelState
@ -653,6 +654,16 @@ final case class DATA_NEGOTIATING(commitments: Commitments,
require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation")
require(!commitments.params.localParams.paysClosingFees || closingTxProposed.forall(_.nonEmpty), "initiator must have at least one closing signature for every negotiation attempt because it initiates the closing")
}
final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments,
lastClosingFeerate: FeeratePerKw,
localScriptPubKey: ByteVector, remoteScriptPubKey: ByteVector,
// Closing transactions we created, where we pay the fees (unsigned).
proposedClosingTxs: List[ClosingTxs],
// Closing transactions we published: this contains our local transactions for
// which they sent a signature, and their closing transactions that we signed.
publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments {
def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid))
}
final case class DATA_CLOSING(commitments: Commitments,
waitingSince: BlockHeight, // how long since we initiated the closing
finalScriptPubKey: ByteVector, // where to send all on-chain funds

View file

@ -116,7 +116,10 @@ case class FeerateTooDifferent (override val channelId: Byte
case class InvalidAnnouncementSignatures (override val channelId: ByteVector32, annSigs: AnnouncementSignatures) extends ChannelException(channelId, s"invalid announcement signatures: $annSigs")
case class InvalidCommitmentSignature (override val channelId: ByteVector32, fundingTxId: TxId, fundingTxIndex: Long, unsignedCommitTx: Transaction) extends ChannelException(channelId, s"invalid commitment signature: fundingTxId=$fundingTxId fundingTxIndex=$fundingTxIndex commitTxId=${unsignedCommitTx.txid} commitTx=$unsignedCommitTx")
case class InvalidHtlcSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid htlc signature: txId=$txId")
case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed")
case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output")
case class InvalidCloseSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid close signature: txId=$txId")
case class InvalidCloseeScript (override val channelId: ByteVector32, received: ByteVector, expected: ByteVector) extends ChannelException(channelId, s"invalid closee script used in closing_complete: our latest script is $expected, you're using $received")
case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: txId=$txId")
case class CommitSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"commit sig count mismatch: expected=$expected actual=$actual")
case class HtlcSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"htlc sig count mismatch: expected=$expected actual=$actual")

View file

@ -59,6 +59,7 @@ object Helpers {
case d: DATA_NORMAL => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
case d: DATA_SHUTDOWN => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
case d: DATA_NEGOTIATING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
case d: DATA_NEGOTIATING_SIMPLE => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
case d: DATA_CLOSING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
}
@ -709,6 +710,96 @@ object Helpers {
}
}
/** We are the closer: we sign closing transactions for which we pay the fees. */
def makeSimpleClosingTx(currentBlockHeight: BlockHeight, keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw): Either[ChannelException, (ClosingTxs, ClosingComplete)] = {
// We must convert the feerate to a fee: we must build dummy transactions to compute their weight.
val closingFee = {
val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey)
dummyClosingTxs.preferred_opt match {
case Some(dummyTx) =>
val dummySignedTx = Transactions.addSigs(dummyTx, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig)
SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.tx.weight()))
case None => return Left(CannotGenerateClosingTx(commitment.channelId))
}
}
// Now that we know the fee we're ready to pay, we can create our closing transactions.
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey)
// The actual fee we're paying will be bigger than the one we previously computed if we omit our output.
val actualFee = closingTxs.preferred_opt match {
case Some(closingTx) if closingTx.fee > 0.sat => closingTx.fee
case _ => return Left(CannotGenerateClosingTx(commitment.channelId))
}
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
val closingComplete = ClosingComplete(commitment.channelId, localScriptPubkey, remoteScriptPubkey, actualFee, currentBlockHeight.toLong, TlvStream(Set(
closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndCloseeOutputs(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
closingTxs.localOnly_opt.map(tx => ClosingTlv.CloserOutputOnly(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
closingTxs.remoteOnly_opt.map(tx => ClosingTlv.CloseeOutputOnly(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
).flatten[ClosingTlv]))
Right(closingTxs, closingComplete)
}
/**
* We are the closee: we choose one of the closer's transactions and sign it back.
*
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that they
* are not using our latest script (race condition between our closing_complete and theirs).
*/
def signSimpleClosingTx(keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete): Either[ChannelException, (ClosingTx, ClosingSig)] = {
val closingFee = SimpleClosingTxFee.PaidByThem(closingComplete.fees)
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, closingComplete.lockTime, localScriptPubkey, remoteScriptPubkey)
// If our output isn't dust, they must provide a signature for a transaction that includes it.
// Note that we're the closee, so we look for signatures including the closee output.
(closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match {
case (Some(_), Some(_)) if closingComplete.closerAndCloseeOutputsSig_opt.isEmpty && closingComplete.closeeOutputOnlySig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
case (Some(_), None) if closingComplete.closerAndCloseeOutputsSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
case (None, Some(_)) if closingComplete.closeeOutputOnlySig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
case _ => ()
}
// We choose the closing signature that matches our preferred closing transaction.
val closingTxsWithSigs = Seq(
closingComplete.closerAndCloseeOutputsSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserAndCloseeOutputs(localSig)))),
closingComplete.closeeOutputOnlySig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloseeOutputOnly(localSig)))),
closingComplete.closerOutputOnlySig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserOutputOnly(localSig)))),
).flatten
closingTxsWithSigs.headOption match {
case Some((closingTx, remoteSig, sigToTlv)) =>
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
Transactions.checkSpendable(signedClosingTx) match {
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
case Success(_) => Right(signedClosingTx, ClosingSig(commitment.channelId, remoteScriptPubkey, localScriptPubkey, closingComplete.fees, closingComplete.lockTime, TlvStream(sigToTlv(localSig))))
}
case None => Left(MissingCloseSignature(commitment.channelId))
}
}
/**
* We are the closer: they sent us their signature so we should now have a fully signed closing transaction.
*
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that we
* sent another closing_complete before receiving their closing_sig, which is now obsolete: we ignore it and wait
* for their next closing_sig that will match our latest closing_complete.
*/
def receiveSimpleClosingSig(keyManager: ChannelKeyManager, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig): Either[ChannelException, ClosingTx] = {
val closingTxsWithSig = Seq(
closingSig.closerAndCloseeOutputsSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig))),
closingSig.closerOutputOnlySig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig))),
closingSig.closeeOutputOnlySig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig))),
).flatten
closingTxsWithSig.headOption match {
case Some((closingTx, remoteSig)) =>
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
Transactions.checkSpendable(signedClosingTx) match {
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
case Success(_) => Right(signedClosingTx)
}
case None => Left(MissingCloseSignature(commitment.channelId))
}
}
/**
* Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk
* that the closing transaction will not be relayed to miners' mempool and will not confirm.

View file

@ -734,10 +734,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
}
// are there pending signed changes on either side? we need to have received their last revocation!
if (d.commitments.hasNoPendingHtlcsOrFeeUpdate) {
// there are no pending signed changes, let's go directly to NEGOTIATING
if (d.commitments.params.localParams.paysClosingFees) {
// there are no pending signed changes, let's directly negotiate a closing transaction
if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) {
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, sendList)
} else if (d.commitments.params.localParams.paysClosingFees) {
// we pay the closing fees, so we initiate the negotiation by sending the first closing_signed
val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.closingFeerates)
val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.closingFeerates)
goto(NEGOTIATING) using DATA_NEGOTIATING(d.commitments, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending sendList :+ closingSigned
} else {
// we are not the channel initiator, will wait for their closing_signed
@ -1513,9 +1515,11 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
log.debug("received a new sig:\n{}", commitments1.latest.specs2String)
context.system.eventStream.publish(ChannelSignatureReceived(self, commitments1))
if (commitments1.hasNoPendingHtlcsOrFeeUpdate) {
if (d.commitments.params.localParams.paysClosingFees) {
if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) {
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, revocation :: Nil)
} else if (d.commitments.params.localParams.paysClosingFees) {
// we pay the closing fees, so we initiate the negotiation by sending the first closing_signed
val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates)
val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates)
goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending revocation :: closingSigned :: Nil
} else {
// we are not the channel initiator, will wait for their closing_signed
@ -1555,9 +1559,11 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
}
if (commitments1.hasNoPendingHtlcsOrFeeUpdate) {
log.debug("switching to NEGOTIATING spec:\n{}", commitments1.latest.specs2String)
if (d.commitments.params.localParams.paysClosingFees) {
if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) {
startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, Nil)
} else if (d.commitments.params.localParams.paysClosingFees) {
// we pay the closing fees, so we initiate the negotiation by sending the first closing_signed
val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates)
val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates)
goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending closingSigned
} else {
// we are not the channel initiator, will wait for their closing_signed
@ -1572,6 +1578,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case Left(cause) => handleLocalError(cause, d, Some(revocation))
}
case Event(shutdown: Shutdown, d: DATA_SHUTDOWN) =>
if (shutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) {
log.debug("our peer updated their shutdown script (previous={}, current={})", d.remoteShutdown.scriptPubKey, shutdown.scriptPubKey)
}
stay() using d.copy(remoteShutdown = shutdown) storing()
case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d)
case Event(ProcessCurrentBlockHeight(c), d: DATA_SHUTDOWN) => handleNewBlock(c, d)
@ -1579,17 +1591,18 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case Event(c: CurrentFeerates.BitcoinCore, d: DATA_SHUTDOWN) => handleCurrentFeerate(c, d)
case Event(c: CMD_CLOSE, d: DATA_SHUTDOWN) =>
c.feerates match {
case Some(feerates) if c.feerates != d.closingFeerates =>
if (c.scriptPubKey.nonEmpty && !c.scriptPubKey.contains(d.localShutdown.scriptPubKey)) {
log.warning("cannot update closing script when closing is already in progress")
handleCommandError(ClosingAlreadyInProgress(d.channelId), c)
} else {
log.info("updating our closing feerates: {}", feerates)
handleCommandSuccess(c, d.copy(closingFeerates = c.feerates)) storing()
}
case _ =>
handleCommandError(ClosingAlreadyInProgress(d.channelId), c)
val useSimpleClose = Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)
val localShutdown_opt = c.scriptPubKey match {
case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey && useSimpleClose => Some(Shutdown(d.channelId, scriptPubKey))
case _ => None
}
if (c.scriptPubKey.exists(_ != d.localShutdown.scriptPubKey) && !useSimpleClose) {
handleCommandError(ClosingAlreadyInProgress(d.channelId), c)
} else if (localShutdown_opt.nonEmpty || c.feerates.nonEmpty) {
val d1 = d.copy(localShutdown = localShutdown_opt.getOrElse(d.localShutdown), closingFeerates = c.feerates.orElse(d.closingFeerates))
handleCommandSuccess(c, d1) storing() sending localShutdown_opt.toSeq
} else {
handleCommandError(ClosingAlreadyInProgress(d.channelId), c)
}
case Event(e: Error, d: DATA_SHUTDOWN) => handleRemoteError(e, d)
@ -1597,17 +1610,18 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
})
when(NEGOTIATING)(handleExceptions {
// Upon reconnection, nodes must re-transmit their shutdown message, so we may receive it now.
case Event(remoteShutdown: Shutdown, d: DATA_NEGOTIATING) =>
if (remoteShutdown != d.remoteShutdown) {
// This is a spec violation: it will likely lead to a disagreement when exchanging closing_signed and a force-close.
log.warning("received unexpected shutdown={} (previous={})", remoteShutdown, d.remoteShutdown)
if (remoteShutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) {
// This may lead to a signature mismatch if our peer changed their script without using option_simple_close.
log.warning("received shutdown changing remote script, this may lead to a signature mismatch: previous={}, current={}", d.remoteShutdown.scriptPubKey, remoteShutdown.scriptPubKey)
stay() using d.copy(remoteShutdown = remoteShutdown) storing()
} else {
stay()
}
stay()
case Event(c: ClosingSigned, d: DATA_NEGOTIATING) =>
val (remoteClosingFee, remoteSig) = (c.feeSatoshis, c.signature)
Closing.MutualClose.checkClosingSignature(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, remoteClosingFee, remoteSig) match {
MutualClose.checkClosingSignature(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, remoteClosingFee, remoteSig) match {
case Right((signedClosingTx, closingSignedRemoteFees)) =>
val lastLocalClosingSigned_opt = d.closingTxProposed.last.lastOption
if (lastLocalClosingSigned_opt.exists(_.localClosingSigned.feeSatoshis == remoteClosingFee)) {
@ -1630,7 +1644,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case Some(ClosingSignedTlv.FeeRange(minFee, maxFee)) if !d.commitments.params.localParams.paysClosingFees =>
// if we are not paying the closing fees and they proposed a fee range, we pick a value in that range and they should accept it without further negotiation
// we don't care much about the closing fee since they're paying it (not us) and we can use CPFP if we want to speed up confirmation
val localClosingFees = Closing.MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)
val localClosingFees = MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)
if (maxFee < localClosingFees.min) {
log.warning("their highest closing fee is below our minimum fee: {} < {}", maxFee, localClosingFees.min)
stay() sending Warning(d.channelId, s"closing fee range must not be below ${localClosingFees.min}")
@ -1645,7 +1659,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
log.info("accepting their closing fee={}", remoteClosingFee)
handleMutualClose(signedClosingTx, Left(d.copy(bestUnpublishedClosingTx_opt = Some(signedClosingTx)))) sending closingSignedRemoteFees
} else {
val (closingTx, closingSigned) = Closing.MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, ClosingFees(closingFee, minFee, maxFee))
val (closingTx, closingSigned) = MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, ClosingFees(closingFee, minFee, maxFee))
log.info("proposing closing fee={} in their fee range (min={} max={})", closingSigned.feeSatoshis, minFee, maxFee)
val closingTxProposed1 = (d.closingTxProposed: @unchecked) match {
case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned))
@ -1657,9 +1671,9 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
val lastLocalClosingFee_opt = lastLocalClosingSigned_opt.map(_.localClosingSigned.feeSatoshis)
val (closingTx, closingSigned) = {
// if we are not the channel initiator and we were waiting for them to send their first closing_signed, we don't have a lastLocalClosingFee, so we compute a firstClosingFee
val localClosingFees = Closing.MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)
val nextPreferredFee = Closing.MutualClose.nextClosingFee(lastLocalClosingFee_opt.getOrElse(localClosingFees.preferred), remoteClosingFee)
Closing.MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, localClosingFees.copy(preferred = nextPreferredFee))
val localClosingFees = MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)
val nextPreferredFee = MutualClose.nextClosingFee(lastLocalClosingFee_opt.getOrElse(localClosingFees.preferred), remoteClosingFee)
MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, localClosingFees.copy(preferred = nextPreferredFee))
}
val closingTxProposed1 = (d.closingTxProposed: @unchecked) match {
case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned))
@ -1688,7 +1702,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
handleCommandError(ClosingAlreadyInProgress(d.channelId), c)
} else {
log.info("updating our closing feerates: {}", feerates)
val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, Some(feerates))
val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, Some(feerates))
val closingTxProposed1 = d.closingTxProposed match {
case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned))
case previousNegotiations => previousNegotiations :+ List(ClosingTxProposed(closingTx, closingSigned))
@ -1703,6 +1717,68 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
})
when(NEGOTIATING_SIMPLE)(handleExceptions {
case Event(shutdown: Shutdown, d: DATA_NEGOTIATING_SIMPLE) =>
if (shutdown.scriptPubKey != d.remoteScriptPubKey) {
// This may lead to a signature mismatch: peers must use closing_complete to update their closing script.
log.warning("received shutdown changing remote script, this may lead to a signature mismatch: previous={}, current={}", d.remoteScriptPubKey, shutdown.scriptPubKey)
stay() using d.copy(remoteScriptPubKey = shutdown.scriptPubKey) storing()
} else {
stay()
}
case Event(c: CMD_CLOSE, d: DATA_NEGOTIATING_SIMPLE) =>
val localScript = c.scriptPubKey.getOrElse(d.localScriptPubKey)
val closingFeerate = c.feerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates))
if (closingFeerate < d.lastClosingFeerate) {
val err = InvalidRbfFeerate(d.channelId, closingFeerate, d.lastClosingFeerate * 1.2)
handleCommandError(err, c)
} else {
MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, d.remoteScriptPubKey, closingFeerate) match {
case Left(f) => handleCommandError(f, c)
case Right((closingTxs, closingComplete)) =>
log.debug("signing local mutual close transactions: {}", closingTxs)
handleCommandSuccess(c, d.copy(lastClosingFeerate = closingFeerate, localScriptPubKey = localScript, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs)) storing() sending closingComplete
}
}
case Event(closingComplete: ClosingComplete, d: DATA_NEGOTIATING_SIMPLE) =>
// Note that if there is a failure here and we don't send our closing_sig, they may eventually disconnect.
// On reconnection, we will retransmit shutdown with our latest scripts, so future signing attempts should work.
if (closingComplete.closeeScriptPubKey != d.localScriptPubKey) {
log.warning("their closing_complete is not using our latest script: this may happen if we changed our script while they were sending closing_complete")
// No need to persist their latest script, they will re-sent it on reconnection.
stay() using d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey) sending Warning(d.channelId, InvalidCloseeScript(d.channelId, closingComplete.closeeScriptPubKey, d.localScriptPubKey).getMessage)
} else {
MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, closingComplete.closeeScriptPubKey, closingComplete.closerScriptPubKey, closingComplete) match {
case Left(f) =>
log.warning("invalid closing_complete: {}", f.getMessage)
stay() sending Warning(d.channelId, f.getMessage)
case Right((signedClosingTx, closingSig)) =>
log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx)
val d1 = d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx)
stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig
}
}
case Event(closingSig: ClosingSig, d: DATA_NEGOTIATING_SIMPLE) =>
// Note that if we sent two closing_complete in a row, without waiting for their closing_sig for the first one,
// this will fail because we only care about our latest closing_complete. This is fine, we should receive their
// closing_sig for the last closing_complete afterwards.
MutualClose.receiveSimpleClosingSig(keyManager, d.commitments.latest, d.proposedClosingTxs.last, closingSig) match {
case Left(f) =>
log.warning("invalid closing_sig: {}", f.getMessage)
stay() sending Warning(d.channelId, f.getMessage)
case Right(signedClosingTx) =>
log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx)
val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx)
stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = true)
}
case Event(e: Error, d: DATA_NEGOTIATING_SIMPLE) => handleRemoteError(e, d)
})
when(CLOSING)(handleExceptions {
case Event(c: HtlcSettlementCommand, d: DATA_CLOSING) =>
(c match {
@ -2352,6 +2428,11 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
goto(NEGOTIATING) using d.copy(closingTxProposed = closingTxProposed1) sending d.localShutdown
}
case Event(_: ChannelReestablish, d: DATA_NEGOTIATING_SIMPLE) =>
// We retransmit our shutdown: we may have updated our script and they may not have received it.
val localShutdown = Shutdown(d.channelId, d.localScriptPubKey)
goto(NEGOTIATING_SIMPLE) using d sending localShutdown
// This handler is a workaround for an issue in lnd: starting with versions 0.10 / 0.11, they sometimes fail to send
// a channel_reestablish when reconnecting a channel that recently got confirmed, and instead send a channel_ready
// first and then go silent. This is due to a race condition on their side, so we trigger a reconnection, hoping that
@ -2507,6 +2588,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case d: DATA_NORMAL => d.copy(commitments = commitments1)
case d: DATA_SHUTDOWN => d.copy(commitments = commitments1)
case d: DATA_NEGOTIATING => d.copy(commitments = commitments1)
case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = commitments1)
case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = commitments1)
case d: DATA_CLOSING => d.copy(commitments = commitments1)
}
@ -2534,6 +2616,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case d: DATA_NORMAL => d.copy(commitments = commitments1)
case d: DATA_SHUTDOWN => d.copy(commitments = commitments1)
case d: DATA_NEGOTIATING => d.copy(commitments = commitments1)
case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = commitments1)
case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = commitments1)
case d: DATA_CLOSING => d // there is a dedicated handler in CLOSING state
}
@ -2550,6 +2633,23 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
// if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that
handleMutualClose(d.bestUnpublishedClosingTx_opt.get, Left(d))
case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty =>
if (!d.publishedClosingTxs.exists(_.tx.txid == tx.txid)) {
// They published one of our closing transactions without sending us their signature (or we ignored them because
// of a race with our closing_complete). We need to publish it ourselves to record the fees and watch for confirmation.
val closingTx = d.findClosingTx(tx).get.copy(tx = tx)
stay() using d.copy(publishedClosingTxs = d.publishedClosingTxs :+ closingTx) storing() calling doPublish(closingTx, localPaysClosingFees = true)
} else {
// This is one of the transactions we published.
stay()
}
case Event(WatchTxConfirmedTriggered(_, _, tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty =>
val closingType = MutualClose(d.findClosingTx(tx).get)
log.info("channel closed (type={})", EventType.Closed(closingType).label)
context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, d.commitments))
goto(CLOSED) using d storing()
case Event(WatchFundingSpentTriggered(tx), d: ChannelDataWithCommitments) =>
if (d.commitments.all.map(_.fundingTxId).contains(tx.txid)) {
// if the spending tx is itself a funding tx, this is a splice and there is nothing to do
@ -2623,7 +2723,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case (SYNCING, NORMAL, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("syncing->normal", d2, sendToPeer = d2.channelAnnouncement.isEmpty))
case (NORMAL, OFFLINE, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("normal->offline", d2, sendToPeer = false))
case (OFFLINE, OFFLINE, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("offline->offline", d2, sendToPeer = false))
case (NORMAL | SYNCING | OFFLINE, SHUTDOWN | NEGOTIATING | CLOSING | CLOSED | ERR_INFORMATION_LEAK | WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT, d: DATA_NORMAL, _) => Some(EmitLocalChannelDown(d))
case (NORMAL | SYNCING | OFFLINE, SHUTDOWN | NEGOTIATING | NEGOTIATING_SIMPLE | CLOSING | CLOSED | ERR_INFORMATION_LEAK | WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT, d: DATA_NORMAL, _) => Some(EmitLocalChannelDown(d))
case _ => None
}
emitEvent_opt.foreach {

View file

@ -16,13 +16,14 @@
package fr.acinq.eclair.channel.fsm
import akka.actor.{ActorRef, FSM, Status}
import akka.actor.FSM
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Script}
import fr.acinq.eclair.Features
import fr.acinq.eclair.channel.Helpers.Closing.MutualClose
import fr.acinq.eclair.channel._
import fr.acinq.eclair.db.PendingCommandsDb
import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.wire.protocol.{HtlcSettlementMessage, LightningMessage, UpdateMessage}
import fr.acinq.eclair.wire.protocol.{HtlcSettlementMessage, LightningMessage, Shutdown, UpdateMessage}
import scodec.bits.ByteVector
import scala.concurrent.duration.DurationInt
@ -106,6 +107,7 @@ trait CommonHandlers {
case d: DATA_NORMAL if d.localShutdown.isDefined => d.localShutdown.get.scriptPubKey
case d: DATA_SHUTDOWN => d.localShutdown.scriptPubKey
case d: DATA_NEGOTIATING => d.localShutdown.scriptPubKey
case d: DATA_NEGOTIATING_SIMPLE => d.localScriptPubKey
case d: DATA_CLOSING => d.finalScriptPubKey
case d =>
d.commitments.params.localParams.upfrontShutdownScript_opt match {
@ -130,4 +132,20 @@ trait CommonHandlers {
finalScriptPubKey
}
def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerates: Option[ClosingFeerates], toSend: List[LightningMessage]) = {
val localScript = localShutdown.scriptPubKey
val remoteScript = remoteShutdown.scriptPubKey
val closingFeerate = closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates))
MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, commitments.latest, localScript, remoteScript, closingFeerate) match {
case Left(f) =>
log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage)
val d = DATA_NEGOTIATING_SIMPLE(commitments, closingFeerate, localScript, remoteScript, Nil, Nil)
goto(NEGOTIATING_SIMPLE) using d storing() sending toSend
case Right((closingTxs, closingComplete)) =>
log.debug("signing local mutual close transactions: {}", closingTxs)
val d = DATA_NEGOTIATING_SIMPLE(commitments, closingFeerate, localScript, remoteScript, closingTxs :: Nil, Nil)
goto(NEGOTIATING_SIMPLE) using d storing() sending toSend :+ closingComplete
}
}
}

View file

@ -87,6 +87,10 @@ trait ErrorHandlers extends CommonHandlers {
log.info(s"we have a valid closing tx, publishing it instead of our commitment: closingTxId=${bestUnpublishedClosingTx.tx.txid}")
// if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that
handleMutualClose(bestUnpublishedClosingTx, Left(negotiating))
case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty =>
// We have published at least one mutual close transaction, it's better to use it instead of our local commit.
val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs)
goto(CLOSING) using closing storing()
case dd: ChannelDataWithCommitments =>
// We publish our commitment even if we have nothing at stake: it's a nice thing to do because it lets our peer
// get their funds back without delays.
@ -133,6 +137,10 @@ trait ErrorHandlers extends CommonHandlers {
case negotiating@DATA_NEGOTIATING(_, _, _, _, Some(bestUnpublishedClosingTx)) =>
// if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that
handleMutualClose(bestUnpublishedClosingTx, Left(negotiating))
case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty =>
// We have published at least one mutual close transaction, it's better to use it instead of our local commit.
val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs)
goto(CLOSING) using closing storing()
// NB: we publish the commitment even if we have nothing at stake (in a dataloss situation our peer will send us an error just for that)
case hasCommitments: ChannelDataWithCommitments =>
if (e.toAscii == "internal error") {
@ -211,6 +219,7 @@ trait ErrorHandlers extends CommonHandlers {
val nextData = d match {
case closing: DATA_CLOSING => closing.copy(localCommitPublished = Some(localCommitPublished))
case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, negotiating.closingTxProposed.flatten.map(_.unsignedTx), localCommitPublished = Some(localCommitPublished))
case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, localCommitPublished = Some(localCommitPublished))
case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, localCommitPublished = Some(localCommitPublished))
}
goto(CLOSING) using nextData storing() calling doPublish(localCommitPublished, commitment)
@ -257,6 +266,7 @@ trait ErrorHandlers extends CommonHandlers {
val nextData = d match {
case closing: DATA_CLOSING => closing.copy(remoteCommitPublished = Some(remoteCommitPublished))
case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), remoteCommitPublished = Some(remoteCommitPublished))
case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, remoteCommitPublished = Some(remoteCommitPublished))
case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, remoteCommitPublished = Some(remoteCommitPublished))
}
goto(CLOSING) using nextData storing() calling doPublish(remoteCommitPublished, commitments)
@ -275,6 +285,7 @@ trait ErrorHandlers extends CommonHandlers {
val nextData = d match {
case closing: DATA_CLOSING => closing.copy(nextRemoteCommitPublished = Some(remoteCommitPublished))
case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), nextRemoteCommitPublished = Some(remoteCommitPublished))
case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, remoteCommitPublished = Some(remoteCommitPublished))
// NB: if there is a next commitment, we can't be in DATA_WAIT_FOR_FUNDING_CONFIRMED so we don't have the case where fundingTx is defined
case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, nextRemoteCommitPublished = Some(remoteCommitPublished))
}
@ -314,6 +325,7 @@ trait ErrorHandlers extends CommonHandlers {
val nextData = d match {
case closing: DATA_CLOSING => closing.copy(revokedCommitPublished = closing.revokedCommitPublished :+ revokedCommitPublished)
case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), revokedCommitPublished = revokedCommitPublished :: Nil)
case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, revokedCommitPublished = revokedCommitPublished :: Nil)
// NB: if there is a revoked commitment, we can't be in DATA_WAIT_FOR_FUNDING_CONFIRMED so we don't have the case where fundingTx is defined
case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, revokedCommitPublished = revokedCommitPublished :: Nil)
}

View file

@ -274,6 +274,7 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
case _: DATA_NORMAL => false
case _: DATA_SHUTDOWN => true
case _: DATA_NEGOTIATING => true
case _: DATA_NEGOTIATING_SIMPLE => true
case _: DATA_CLOSING => true
case _: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => true
}

View file

@ -198,6 +198,7 @@ object PeerReadyNotifier {
case channel.NORMAL => true
case channel.SHUTDOWN => true
case channel.NEGOTIATING => true
case channel.NEGOTIATING_SIMPLE => true
case channel.CLOSING => true
case channel.CLOSED => true
case channel.WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => true

View file

@ -643,6 +643,7 @@ object CustomTypeHints {
classOf[DATA_NORMAL],
classOf[DATA_SHUTDOWN],
classOf[DATA_NEGOTIATING],
classOf[DATA_NEGOTIATING_SIMPLE],
classOf[DATA_CLOSING],
classOf[DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT]
), typeHintFieldName = "type")

View file

@ -835,6 +835,77 @@ object Transactions {
ClosingTx(commitTxInput, tx, toLocalOutput)
}
// @formatter:off
/** We always create multiple versions of each closing transaction, where fees are either paid by us or by our peer. */
sealed trait SimpleClosingTxFee
object SimpleClosingTxFee {
case class PaidByUs(fee: Satoshi) extends SimpleClosingTxFee
case class PaidByThem(fee: Satoshi) extends SimpleClosingTxFee
}
// @formatter:on
/** Each closing attempt can result in multiple potential closing transactions, depending on which outputs are included. */
case class ClosingTxs(localAndRemote_opt: Option[ClosingTx], localOnly_opt: Option[ClosingTx], remoteOnly_opt: Option[ClosingTx]) {
/** Preferred closing transaction for this closing attempt. */
val preferred_opt: Option[ClosingTx] = localAndRemote_opt.orElse(localOnly_opt).orElse(remoteOnly_opt)
val all: Seq[ClosingTx] = Seq(localAndRemote_opt, localOnly_opt, remoteOnly_opt).flatten
override def toString: String = s"localAndRemote=${localAndRemote_opt.map(_.tx.toString()).getOrElse("n/a")}, localOnly=${localOnly_opt.map(_.tx.toString()).getOrElse("n/a")}, remoteOnly=${remoteOnly_opt.map(_.tx.toString()).getOrElse("n/a")}"
}
def makeSimpleClosingTxs(input: InputInfo, spec: CommitmentSpec, fee: SimpleClosingTxFee, lockTime: Long, localScriptPubKey: ByteVector, remoteScriptPubKey: ByteVector): ClosingTxs = {
require(spec.htlcs.isEmpty, "there shouldn't be any pending htlcs")
val txNoOutput = Transaction(2, Seq(TxIn(input.outPoint, ByteVector.empty, sequence = 0xFFFFFFFDL)), Nil, lockTime)
// We compute the remaining balance for each side after paying the closing fees.
// This lets us decide whether outputs can be included in the closing transaction or not.
val (toLocalAmount, toRemoteAmount) = fee match {
case SimpleClosingTxFee.PaidByUs(fee) => (spec.toLocal.truncateToSatoshi - fee, spec.toRemote.truncateToSatoshi)
case SimpleClosingTxFee.PaidByThem(fee) => (spec.toLocal.truncateToSatoshi, spec.toRemote.truncateToSatoshi - fee)
}
// An OP_RETURN script may be provided, but only when burning all of the peer's balance to fees.
val toLocalOutput_opt = if (toLocalAmount >= dustLimit(localScriptPubKey)) {
val amount = if (isOpReturn(localScriptPubKey)) 0.sat else toLocalAmount
Some(TxOut(amount, localScriptPubKey))
} else {
None
}
val toRemoteOutput_opt = if (toRemoteAmount >= dustLimit(remoteScriptPubKey)) {
val amount = if (isOpReturn(remoteScriptPubKey)) 0.sat else toRemoteAmount
Some(TxOut(amount, remoteScriptPubKey))
} else {
None
}
// We may create multiple closing transactions based on which outputs may be included.
(toLocalOutput_opt, toRemoteOutput_opt) match {
case (Some(toLocalOutput), Some(toRemoteOutput)) =>
val txLocalAndRemote = LexicographicalOrdering.sort(txNoOutput.copy(txOut = Seq(toLocalOutput, toRemoteOutput)))
val toLocalOutputInfo = findPubKeyScriptIndex(txLocalAndRemote, localScriptPubKey).map(index => OutputInfo(index, toLocalOutput.amount, localScriptPubKey)).toOption
ClosingTxs(
localAndRemote_opt = Some(ClosingTx(input, txLocalAndRemote, toLocalOutputInfo)),
// We also provide a version of the transaction without the remote output, which they may want to omit if not economical to spend.
localOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toLocalOutput)), Some(OutputInfo(0, toLocalOutput.amount, localScriptPubKey)))),
remoteOnly_opt = None
)
case (Some(toLocalOutput), None) =>
ClosingTxs(
localAndRemote_opt = None,
localOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toLocalOutput)), Some(OutputInfo(0, toLocalOutput.amount, localScriptPubKey)))),
remoteOnly_opt = None
)
case (None, Some(toRemoteOutput)) =>
ClosingTxs(
localAndRemote_opt = None,
localOnly_opt = None,
remoteOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toRemoteOutput)), None))
)
case (None, None) => ClosingTxs(None, None, None)
}
}
def findPubKeyScriptIndex(tx: Transaction, pubkeyScript: ByteVector): Either[TxGenerationSkipped, Int] = {
val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == pubkeyScript)
if (outputIndex >= 0) {

View file

@ -681,7 +681,7 @@ private[channel] object ChannelCodecs4 {
("remotePushAmount" | millisatoshi) ::
("status" | interactiveTxWaitingForSigsCodec) ::
("remoteChannelData_opt" | optional(bool8, varsizebinarydata))).as[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED]
val DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED_02_Codec: Codec[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED] = (
("commitments" | commitmentsCodecWithoutFirstRemoteCommitIndex) ::
("localPushAmount" | millisatoshi) ::
@ -754,6 +754,19 @@ private[channel] object ChannelCodecs4 {
("closingTxProposed" | listOfN(uint16, listOfN(uint16, lengthDelimited(closingTxProposedCodec)))) ::
("bestUnpublishedClosingTx_opt" | optional(bool8, closingTxCodec))).as[DATA_NEGOTIATING]
private val closingTxsCodec: Codec[ClosingTxs] = (
("localAndRemote_opt" | optional(bool8, closingTxCodec)) ::
("localOnly_opt" | optional(bool8, closingTxCodec)) ::
("remoteOnly_opt" | optional(bool8, closingTxCodec))).as[ClosingTxs]
val DATA_NEGOTIATING_SIMPLE_14_Codec: Codec[DATA_NEGOTIATING_SIMPLE] = (
("commitments" | commitmentsCodec) ::
("lastClosingFeerate" | feeratePerKw) ::
("localScriptPubKey" | varsizebinarydata) ::
("remoteScriptPubKey" | varsizebinarydata) ::
("proposedClosingTxs" | listOfN(uint16, closingTxsCodec)) ::
("publishedClosingTxs" | listOfN(uint16, closingTxCodec))).as[DATA_NEGOTIATING_SIMPLE]
val DATA_CLOSING_07_Codec: Codec[DATA_CLOSING] = (
("commitments" | commitmentsCodecWithoutFirstRemoteCommitIndex) ::
("waitingSince" | blockHeight) ::
@ -789,6 +802,7 @@ private[channel] object ChannelCodecs4 {
// Order matters!
val channelDataCodec: Codec[PersistentChannelData] = discriminated[PersistentChannelData].by(uint16)
.typecase(0x14, Codecs.DATA_NEGOTIATING_SIMPLE_14_Codec)
.typecase(0x13, Codecs.DATA_WAIT_FOR_DUAL_FUNDING_SIGNED_13_Codec)
.typecase(0x12, Codecs.DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT_12_Codec)
.typecase(0x11, Codecs.DATA_CLOSING_11_Codec)

View file

@ -79,11 +79,12 @@ object TestDatabases {
case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = freeze2(d.commitments))
case d: DATA_NORMAL => d.copy(commitments = freeze2(d.commitments))
.modify(_.spliceStatus).using {
case s: SpliceStatus.SpliceWaitingForSigs => s
case _ => SpliceStatus.NoSplice
}
case s: SpliceStatus.SpliceWaitingForSigs => s
case _ => SpliceStatus.NoSplice
}
case d: DATA_CLOSING => d.copy(commitments = freeze2(d.commitments))
case d: DATA_NEGOTIATING => d.copy(commitments = freeze2(d.commitments))
case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = freeze2(d.commitments))
case d: DATA_SHUTDOWN => d.copy(commitments = freeze2(d.commitments))
}
@ -132,6 +133,7 @@ object TestDatabases {
}
object TestPgDatabases {
import _root_.io.zonky.test.db.postgres.embedded.EmbeddedPostgres
/** single instance */

View file

@ -31,11 +31,12 @@ import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet, OnchainPub
import fr.acinq.eclair.channel._
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.publish.TxPublisher
import fr.acinq.eclair.channel.publish.TxPublisher.PublishReplaceableTx
import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx}
import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory
import fr.acinq.eclair.payment.send.SpontaneousRecipient
import fr.acinq.eclair.payment.{Invoice, OutgoingPaymentPacket}
import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, Route}
import fr.acinq.eclair.testutils.PimpTestProbe.convert
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.wire.protocol._
@ -93,8 +94,10 @@ object ChannelStateTestsTags {
val RejectRbfAttempts = "reject_rbf_attempts"
/** If set, the non-initiator will require a 1-block delay between RBF attempts. */
val DelayRbfAttempts = "delay_rbf_attempts"
/** If set, channels will adapt their max HTLC amount to the available balance */
val AdaptMaxHtlcAmount = "adapt-max-htlc-amount"
/** If set, channels will adapt their max HTLC amount to the available balance. */
val AdaptMaxHtlcAmount = "adapt_max_htlc_amount"
/** If set, closing will use option_simple_close. */
val SimpleClose = "option_simple_close"
}
trait ChannelStateTestsBase extends Assertions with Eventually {
@ -190,6 +193,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually {
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroConf))(_.updated(Features.ZeroConf, FeatureSupport.Optional))
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional))
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional))
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional))
.initFeatures()
val bobInitFeatures = Bob.nodeParams.features
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableWumbo))(_.removed(Features.Wumbo))
@ -202,6 +206,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually {
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroConf))(_.updated(Features.ZeroConf, FeatureSupport.Optional))
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional))
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional))
.modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional))
.initFeatures()
val channelType = ChannelTypes.defaultFromFeatures(aliceInitFeatures, bobInitFeatures, announceChannel = channelFlags.announceChannel)
@ -515,23 +520,41 @@ trait ChannelStateTestsBase extends Assertions with Eventually {
s2r.forward(r)
r2s.expectMsgType[Shutdown]
r2s.forward(s)
// agreeing on a closing fee
var sCloseFee, rCloseFee = 0.sat
do {
sCloseFee = s2r.expectMsgType[ClosingSigned].feeSatoshis
if (s.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) {
s2r.expectMsgType[ClosingComplete]
s2r.forward(r)
rCloseFee = r2s.expectMsgType[ClosingSigned].feeSatoshis
r2s.expectMsgType[ClosingComplete]
r2s.forward(s)
} while (sCloseFee != rCloseFee)
s2blockchain.expectMsgType[TxPublisher.PublishTx]
s2blockchain.expectMsgType[WatchTxConfirmed]
r2blockchain.expectMsgType[TxPublisher.PublishTx]
r2blockchain.expectMsgType[WatchTxConfirmed]
eventually {
assert(s.stateName == CLOSING)
assert(r.stateName == CLOSING)
r2s.expectMsgType[ClosingSig]
r2s.forward(s)
val sTx = r2blockchain.expectMsgType[PublishFinalTx].tx
r2blockchain.expectWatchTxConfirmed(sTx.txid)
s2r.expectMsgType[ClosingSig]
s2r.forward(r)
val rTx = s2blockchain.expectMsgType[PublishFinalTx].tx
s2blockchain.expectWatchTxConfirmed(rTx.txid)
assert(s2blockchain.expectMsgType[PublishFinalTx].tx.txid == sTx.txid)
s2blockchain.expectWatchTxConfirmed(sTx.txid)
assert(r2blockchain.expectMsgType[PublishFinalTx].tx.txid == rTx.txid)
r2blockchain.expectWatchTxConfirmed(rTx.txid)
} else {
// agreeing on a closing fee
var sCloseFee, rCloseFee = 0.sat
do {
sCloseFee = s2r.expectMsgType[ClosingSigned].feeSatoshis
s2r.forward(r)
rCloseFee = r2s.expectMsgType[ClosingSigned].feeSatoshis
r2s.forward(s)
} while (sCloseFee != rCloseFee)
s2blockchain.expectMsgType[TxPublisher.PublishTx]
s2blockchain.expectMsgType[WatchTxConfirmed]
r2blockchain.expectMsgType[TxPublisher.PublishTx]
r2blockchain.expectMsgType[WatchTxConfirmed]
eventually {
assert(s.stateName == CLOSING)
assert(r.stateName == CLOSING)
}
}
// both nodes are now in CLOSING state with a mutual close tx pending for confirmation
}
def localClose(s: TestFSMRef[ChannelState, ChannelData, Channel], s2blockchain: TestProbe): LocalCommitPublished = {

View file

@ -20,7 +20,7 @@ import akka.testkit.TestProbe
import com.softwaremill.quicklens.ModifyPimp
import fr.acinq.bitcoin.ScriptFlags
import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Transaction}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Script, Transaction}
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw}
import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates}
@ -33,7 +33,7 @@ import fr.acinq.eclair.payment.relay.Relayer._
import fr.acinq.eclair.payment.send.SpontaneousRecipient
import fr.acinq.eclair.transactions.Transactions.ClaimLocalAnchorOutputTx
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingSigned, CommitSig, Error, FailureMessageCodecs, FailureReason, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc}
import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32}
import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
import scodec.bits.ByteVector
@ -911,6 +911,25 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit
assert(alice.stateData.asInstanceOf[DATA_SHUTDOWN].closingFeerates.contains(closingFeerates2))
}
test("recv CMD_CLOSE with updated script") { f =>
import f._
val sender = TestProbe()
val script = Script.write(Script.pay2wpkh(randomKey().publicKey))
alice ! CMD_CLOSE(sender.ref, Some(script), None)
sender.expectMsgType[RES_FAILURE[CMD_CLOSE, ClosingAlreadyInProgress]]
}
test("recv CMD_CLOSE with updated script (option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
val sender = TestProbe()
val script = Script.write(Script.pay2wpkh(randomKey().publicKey))
alice ! CMD_CLOSE(sender.ref, Some(script), None)
sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
assert(alice2bob.expectMsgType[Shutdown].scriptPubKey == script)
alice2bob.forward(bob)
awaitCond(bob.stateData.asInstanceOf[DATA_SHUTDOWN].remoteShutdown.scriptPubKey == script)
}
test("recv CMD_FORCECLOSE") { f =>
import f._

View file

@ -17,20 +17,22 @@
package fr.acinq.eclair.channel.states.g
import akka.testkit.TestProbe
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, Transaction}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, Script, Transaction}
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw}
import fr.acinq.eclair.channel.Helpers.Closing
import fr.acinq.eclair.channel._
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx}
import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx, SetChannelId}
import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM
import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags}
import fr.acinq.eclair.testutils.PimpTestProbe._
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.transactions.Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat
import fr.acinq.eclair.wire.protocol.ClosingSignedTlv.FeeRange
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingSigned, Error, Shutdown, TlvStream, Warning}
import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32}
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Shutdown, TlvStream, Warning}
import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey}
import org.scalatest.Inside.inside
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
@ -63,11 +65,15 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
alice2bob.forward(bob, aliceShutdown)
val bobShutdown = bob2alice.expectMsgType[Shutdown]
bob2alice.forward(alice, bobShutdown)
awaitCond(alice.stateName == NEGOTIATING)
assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey))
awaitCond(bob.stateName == NEGOTIATING)
assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey))
if (alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) {
awaitCond(alice.stateName == NEGOTIATING_SIMPLE)
awaitCond(bob.stateName == NEGOTIATING_SIMPLE)
} else {
awaitCond(alice.stateName == NEGOTIATING)
assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey))
awaitCond(bob.stateName == NEGOTIATING)
assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey))
}
}
def bobClose(f: FixtureParam, feerates: Option[ClosingFeerates] = None): Unit = {
@ -79,11 +85,15 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
bob2alice.forward(alice, bobShutdown)
val aliceShutdown = alice2bob.expectMsgType[Shutdown]
alice2bob.forward(bob, aliceShutdown)
awaitCond(alice.stateName == NEGOTIATING)
assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey))
awaitCond(bob.stateName == NEGOTIATING)
assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey))
if (bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) {
awaitCond(alice.stateName == NEGOTIATING_SIMPLE)
awaitCond(bob.stateName == NEGOTIATING_SIMPLE)
} else {
awaitCond(alice.stateName == NEGOTIATING)
assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey))
awaitCond(bob.stateName == NEGOTIATING)
assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey))
}
}
def buildFeerates(feerate: FeeratePerKw, minFeerate: FeeratePerKw = FeeratePerKw(250 sat)): FeeratesPerKw =
@ -473,6 +483,211 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
bob2blockchain.expectMsgType[WatchTxConfirmed]
}
test("recv ClosingComplete (both outputs)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
aliceClose(f)
val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete]
assert(aliceClosingComplete.fees > 0.sat)
assert(aliceClosingComplete.closerAndCloseeOutputsSig_opt.nonEmpty)
assert(aliceClosingComplete.closerOutputOnlySig_opt.nonEmpty)
assert(aliceClosingComplete.closeeOutputOnlySig_opt.isEmpty)
val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete]
assert(bobClosingComplete.fees > 0.sat)
assert(bobClosingComplete.closerAndCloseeOutputsSig_opt.nonEmpty)
assert(bobClosingComplete.closerOutputOnlySig_opt.nonEmpty)
assert(bobClosingComplete.closeeOutputOnlySig_opt.isEmpty)
alice2bob.forward(bob, aliceClosingComplete)
val bobClosingSig = bob2alice.expectMsgType[ClosingSig]
assert(bobClosingSig.fees == aliceClosingComplete.fees)
assert(bobClosingSig.lockTime == aliceClosingComplete.lockTime)
bob2alice.forward(alice, bobClosingSig)
val aliceTx = alice2blockchain.expectMsgType[PublishFinalTx]
assert(aliceTx.desc == "closing")
assert(aliceTx.fee > 0.sat)
alice2blockchain.expectWatchTxConfirmed(aliceTx.tx.txid)
inside(bob2blockchain.expectMsgType[PublishFinalTx]) { p =>
assert(p.tx.txid == aliceTx.tx.txid)
assert(p.fee == 0.sat)
}
bob2blockchain.expectWatchTxConfirmed(aliceTx.tx.txid)
assert(alice.stateName == NEGOTIATING_SIMPLE)
bob2alice.forward(alice, bobClosingComplete)
val aliceClosingSig = alice2bob.expectMsgType[ClosingSig]
assert(aliceClosingSig.fees == bobClosingComplete.fees)
assert(aliceClosingSig.lockTime == bobClosingComplete.lockTime)
alice2bob.forward(bob, aliceClosingSig)
val bobTx = bob2blockchain.expectMsgType[PublishFinalTx]
assert(bobTx.desc == "closing")
assert(bobTx.fee > 0.sat)
bob2blockchain.expectWatchTxConfirmed(bobTx.tx.txid)
inside(alice2blockchain.expectMsgType[PublishFinalTx]) { p =>
assert(p.tx.txid == bobTx.tx.txid)
assert(p.fee == 0.sat)
}
assert(aliceTx.tx.txid != bobTx.tx.txid)
alice2blockchain.expectWatchTxConfirmed(bobTx.tx.txid)
assert(bob.stateName == NEGOTIATING_SIMPLE)
}
test("recv ClosingComplete (single output)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f =>
import f._
aliceClose(f)
val closingComplete = alice2bob.expectMsgType[ClosingComplete]
assert(closingComplete.closerAndCloseeOutputsSig_opt.isEmpty)
assert(closingComplete.closerOutputOnlySig_opt.nonEmpty)
assert(closingComplete.closeeOutputOnlySig_opt.isEmpty)
// Bob has nothing at stake.
bob2alice.expectNoMessage(100 millis)
alice2bob.forward(bob, closingComplete)
bob2alice.expectMsgType[ClosingSig]
bob2alice.forward(alice)
val closingTx = alice2blockchain.expectMsgType[PublishFinalTx]
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.tx.txid)
alice2blockchain.expectWatchTxConfirmed(closingTx.tx.txid)
bob2blockchain.expectWatchTxConfirmed(closingTx.tx.txid)
assert(alice.stateName == NEGOTIATING_SIMPLE)
assert(bob.stateName == NEGOTIATING_SIMPLE)
}
test("recv ClosingComplete (single output, trimmed)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f =>
import f._
val (r, htlc) = addHtlc(250_000 msat, alice, bob, alice2bob, bob2alice)
crossSign(alice, bob, alice2bob, bob2alice)
fulfillHtlc(htlc.id, r, bob, alice, bob2alice, alice2bob)
crossSign(bob, alice, bob2alice, alice2bob)
aliceClose(f)
val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete]
assert(aliceClosingComplete.closerAndCloseeOutputsSig_opt.isEmpty)
assert(aliceClosingComplete.closerOutputOnlySig_opt.nonEmpty)
assert(aliceClosingComplete.closeeOutputOnlySig_opt.isEmpty)
val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete]
assert(bobClosingComplete.closerAndCloseeOutputsSig_opt.isEmpty)
assert(bobClosingComplete.closerOutputOnlySig_opt.isEmpty)
assert(bobClosingComplete.closeeOutputOnlySig_opt.nonEmpty)
bob2alice.forward(alice, bobClosingComplete)
val aliceClosingSig = alice2bob.expectMsgType[ClosingSig]
alice2bob.forward(bob, aliceClosingSig)
val bobTx = bob2blockchain.expectMsgType[PublishFinalTx]
assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx.tx.txid)
bob2blockchain.expectWatchTxConfirmed(bobTx.tx.txid)
alice2blockchain.expectWatchTxConfirmed(bobTx.tx.txid)
assert(alice.stateName == NEGOTIATING_SIMPLE)
assert(bob.stateName == NEGOTIATING_SIMPLE)
}
test("recv ClosingComplete (missing closee output)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
aliceClose(f)
val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete]
val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete]
alice2bob.forward(bob, aliceClosingComplete.copy(tlvStream = TlvStream(ClosingTlv.CloserOutputOnly(aliceClosingComplete.closerOutputOnlySig_opt.get))))
// Bob expects to receive a signature for a closing transaction containing his output, so he ignores Alice's
// closing_complete instead of sending back his closing_sig.
bob2alice.expectMsgType[Warning]
bob2alice.expectNoMessage(100 millis)
bob2alice.forward(alice, bobClosingComplete)
val aliceClosingSig = alice2bob.expectMsgType[ClosingSig]
alice2bob.forward(bob, aliceClosingSig.copy(tlvStream = TlvStream(ClosingTlv.CloseeOutputOnly(aliceClosingSig.closerAndCloseeOutputsSig_opt.get))))
bob2alice.expectMsgType[Warning]
bob2alice.expectNoMessage(100 millis)
bob2blockchain.expectNoMessage(100 millis)
}
test("recv ClosingComplete (with concurrent script update)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
aliceClose(f)
alice2bob.expectMsgType[ClosingComplete]
alice2bob.forward(bob)
bob2alice.expectMsgType[ClosingComplete]
bob2alice.forward(alice)
val aliceTx1 = bob2blockchain.expectMsgType[PublishFinalTx]
assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTx1.tx.txid)
val bobTx1 = alice2blockchain.expectMsgType[PublishFinalTx]
assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTx1.tx.txid)
alice2bob.expectMsgType[ClosingSig]
alice2bob.forward(bob)
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx1.tx.txid)
assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTx1.tx.txid)
bob2alice.expectMsgType[ClosingSig]
bob2alice.forward(alice)
assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx1.tx.txid)
assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTx1.tx.txid)
val aliceScript1 = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].localScriptPubKey
val bobScript1 = bob.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].localScriptPubKey
// Alice sends another closing_complete, updating her script and the fees.
val probe = TestProbe()
val aliceScript2 = Script.write(Script.pay2wpkh(randomKey().publicKey))
val aliceFeerate2 = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].lastClosingFeerate * 1.25
alice ! CMD_CLOSE(probe.ref, Some(aliceScript2), Some(ClosingFeerates(aliceFeerate2, aliceFeerate2, aliceFeerate2)))
probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
inside(alice2bob.expectMsgType[ClosingComplete]) { msg =>
assert(msg.fees > aliceTx1.fee)
assert(msg.closerScriptPubKey == aliceScript2)
assert(msg.closeeScriptPubKey == bobScript1)
}
// Bob also sends closing_complete concurrently, updating his script and the fees.
val bobScript2 = Script.write(Script.pay2wpkh(randomKey().publicKey))
val bobFeerate2 = bob.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].lastClosingFeerate * 1.25
bob ! CMD_CLOSE(probe.ref, Some(bobScript2), Some(ClosingFeerates(bobFeerate2, bobFeerate2, bobFeerate2)))
probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
inside(bob2alice.expectMsgType[ClosingComplete]) { msg =>
assert(msg.fees > bobTx1.fee)
assert(msg.closerScriptPubKey == bobScript2)
assert(msg.closeeScriptPubKey == aliceScript1)
}
// Those messages are ignored because they don't match the latest version of each participant's scripts.
alice2bob.forward(bob)
bob2alice.forward(alice)
alice2bob.expectMsgType[Warning]
alice2bob.expectNoMessage(100 millis)
bob2alice.expectMsgType[Warning]
bob2alice.expectNoMessage(100 millis)
// Alice retries with a higher fee, now that she received Bob's latest script.
val aliceFeerate3 = aliceFeerate2 * 1.25
alice ! CMD_CLOSE(probe.ref, Some(aliceScript2), Some(ClosingFeerates(aliceFeerate3, aliceFeerate3, aliceFeerate3)))
probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
inside(alice2bob.expectMsgType[ClosingComplete]) { msg =>
assert(msg.closerScriptPubKey == aliceScript2)
assert(msg.closeeScriptPubKey == bobScript2)
}
alice2bob.forward(bob)
val bobClosingSig3 = bob2alice.expectMsgType[ClosingSig]
assert(bobClosingSig3.closerScriptPubKey == aliceScript2)
assert(bobClosingSig3.closeeScriptPubKey == bobScript2)
// Before receiving Bob's closing_sig, Alice updates her script again.
val aliceFeerate4 = aliceFeerate3 * 1.25
val aliceScript4 = Script.write(Script.pay2wpkh(randomKey().publicKey))
alice ! CMD_CLOSE(probe.ref, Some(aliceScript4), Some(ClosingFeerates(aliceFeerate4, aliceFeerate4, aliceFeerate4)))
probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
inside(alice2bob.expectMsgType[ClosingComplete]) { msg =>
assert(msg.closerScriptPubKey == aliceScript4)
assert(msg.closeeScriptPubKey == bobScript2)
}
alice2bob.forward(bob)
val bobClosingSig4 = bob2alice.expectMsgType[ClosingSig]
assert(bobClosingSig4.closerScriptPubKey == aliceScript4)
assert(bobClosingSig4.closeeScriptPubKey == bobScript2)
// The first closing_sig is ignored because it's not using Alice's latest script.
bob2alice.forward(alice, bobClosingSig3)
alice2bob.expectMsgType[Warning]
alice2blockchain.expectNoMessage(100 millis)
// The second closing_sig lets Alice broadcast a new version of her closing transaction.
bob2alice.forward(alice, bobClosingSig4)
val aliceTx4 = alice2blockchain.expectMsgType[PublishFinalTx]
assert(aliceTx4.fee > aliceTx1.fee)
assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTx4.tx.txid)
alice2blockchain.expectNoMessage(100 millis)
alice2bob.expectNoMessage(100 millis)
}
test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f =>
import f._
aliceClose(f)
@ -533,6 +748,92 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(bob.stateName == CLOSING)
}
test("recv WatchFundingSpentTriggered (signed closing tx)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
bobClose(f)
// Alice and Bob publish a first closing tx.
val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete]
alice2bob.forward(bob, aliceClosingComplete1)
val bobClosingComplete1 = bob2alice.expectMsgType[ClosingComplete]
bob2alice.forward(alice, bobClosingComplete1)
val aliceClosingSig1 = alice2bob.expectMsgType[ClosingSig]
val bobTx1 = alice2blockchain.expectMsgType[PublishFinalTx].tx
alice2blockchain.expectWatchTxConfirmed(bobTx1.txid)
val bobClosingSig1 = bob2alice.expectMsgType[ClosingSig]
val aliceTx1 = bob2blockchain.expectMsgType[PublishFinalTx].tx
bob2blockchain.expectWatchTxConfirmed(aliceTx1.txid)
alice2bob.forward(bob, aliceClosingSig1)
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx1.txid)
bob2blockchain.expectWatchTxConfirmed(bobTx1.txid)
bob2alice.forward(alice, bobClosingSig1)
assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx1.txid)
alice2blockchain.expectWatchTxConfirmed(aliceTx1.txid)
// Alice updates her closing script.
alice ! CMD_CLOSE(TestProbe().ref, Some(Script.write(Script.pay2wpkh(randomKey().publicKey))), None)
alice2bob.expectMsgType[ClosingComplete]
alice2bob.forward(bob)
val bobClosingSig = bob2alice.expectMsgType[ClosingSig]
bob2alice.forward(alice, bobClosingSig)
val aliceTx2 = alice2blockchain.expectMsgType[PublishFinalTx].tx
alice2blockchain.expectWatchTxConfirmed(aliceTx2.txid)
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx2.txid)
bob2blockchain.expectWatchTxConfirmed(aliceTx2.txid)
// They first receive a watch event for the older transaction, then the new one.
alice ! WatchFundingSpentTriggered(aliceTx1)
alice ! WatchFundingSpentTriggered(bobTx1)
alice ! WatchFundingSpentTriggered(aliceTx2)
alice2blockchain.expectNoMessage(100 millis)
assert(alice.stateName == NEGOTIATING_SIMPLE)
bob ! WatchFundingSpentTriggered(aliceTx1)
bob ! WatchFundingSpentTriggered(bobTx1)
bob ! WatchFundingSpentTriggered(aliceTx2)
bob2blockchain.expectNoMessage(100 millis)
assert(bob.stateName == NEGOTIATING_SIMPLE)
}
test("recv WatchFundingSpentTriggered (unsigned closing tx)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
bobClose(f)
val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete]
alice2bob.forward(bob, aliceClosingComplete)
val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete]
bob2alice.forward(alice, bobClosingComplete)
alice2bob.expectMsgType[ClosingSig]
val bobTx = alice2blockchain.expectMsgType[PublishFinalTx].tx
alice2blockchain.expectWatchTxConfirmed(bobTx.txid)
bob2alice.expectMsgType[ClosingSig]
val aliceTx = bob2blockchain.expectMsgType[PublishFinalTx].tx
bob2blockchain.expectWatchTxConfirmed(aliceTx.txid)
alice ! WatchFundingSpentTriggered(aliceTx)
assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx.txid)
alice2blockchain.expectWatchTxConfirmed(aliceTx.txid)
alice2blockchain.expectNoMessage(100 millis)
bob ! WatchFundingSpentTriggered(bobTx)
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx.txid)
bob2blockchain.expectWatchTxConfirmed(bobTx.txid)
bob2blockchain.expectNoMessage(100 millis)
}
test("recv WatchFundingSpentTriggered (unrecognized commit)") { f =>
import f._
bobClose(f)
alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0))
alice2blockchain.expectNoMessage(100 millis)
assert(alice.stateName == NEGOTIATING)
}
test("recv WatchFundingSpentTriggered (unrecognized commit, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
bobClose(f)
alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0))
alice2blockchain.expectNoMessage(100 millis)
assert(alice.stateName == NEGOTIATING_SIMPLE)
}
test("recv CMD_CLOSE") { f =>
import f._
bobClose(f)
@ -573,12 +874,57 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
awaitCond(bob.stateName == CLOSING)
}
test("recv WatchFundingSpentTriggered (unrecognized commit)") { f =>
test("recv CMD_CLOSE with RBF feerate too low", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
bobClose(f)
alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0))
alice2blockchain.expectNoMessage(100 millis)
assert(alice.stateName == NEGOTIATING)
alice.setFeerates(buildFeerates(FeeratePerKw(500 sat)))
aliceClose(f)
alice2bob.expectMsgType[ClosingComplete]
alice2bob.forward(bob)
bob2alice.expectMsgType[ClosingComplete] // ignored
val bobClosingSig = bob2alice.expectMsgType[ClosingSig]
bob2alice.forward(alice, bobClosingSig)
val probe = TestProbe()
alice ! CMD_CLOSE(probe.ref, None, Some(ClosingFeerates(FeeratePerKw(450 sat), FeeratePerKw(450 sat), FeeratePerKw(450 sat))))
probe.expectMsgType[RES_FAILURE[CMD_CLOSE, InvalidRbfFeerate]]
alice ! CMD_CLOSE(probe.ref, None, Some(ClosingFeerates(FeeratePerKw(500 sat), FeeratePerKw(500 sat), FeeratePerKw(500 sat))))
probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
}
test("receive INPUT_RESTORED", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
aliceClose(f)
alice2bob.expectMsgType[ClosingComplete]
alice2bob.forward(bob)
val aliceTx = bob2blockchain.expectMsgType[PublishFinalTx].tx
assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTx.txid)
bob2alice.expectMsgType[ClosingComplete]
bob2alice.forward(alice)
val bobTx = alice2blockchain.expectMsgType[PublishFinalTx].tx
assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTx.txid)
alice2bob.expectMsgType[ClosingSig]
alice2bob.forward(bob)
bob2alice.expectMsgType[ClosingSig] // Alice doesn't receive Bob's closing_sig
val aliceData = alice.underlyingActor.nodeParams.db.channels.getChannel(channelId(alice)).get
// Alice restarts before receiving Bob's closing_sig: she cannot publish her own closing transaction, but will
// detect it when receiving it in her mempool (or in the blockchain).
alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing)
alice ! INPUT_RESTORED(aliceData)
alice2blockchain.expectMsgType[SetChannelId]
alice2blockchain.expectMsgType[WatchFundingSpent]
awaitCond(alice.stateName == OFFLINE)
// Alice's transaction (published by Bob) confirms.
alice ! WatchFundingSpentTriggered(aliceTx)
inside(alice2blockchain.expectMsgType[PublishFinalTx]) { p =>
assert(p.tx.txid == aliceTx.txid)
assert(p.fee > 0.sat)
}
assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTx.txid)
alice ! WatchTxConfirmedTriggered(BlockHeight(100), 3, aliceTx)
awaitCond(alice.stateName == CLOSED)
}
test("recv Error") { f =>
@ -593,4 +939,28 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == tx.txid)
}
test("recv Error (option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
aliceClose(f)
val closingComplete = alice2bob.expectMsgType[ClosingComplete]
alice2bob.forward(bob, closingComplete)
bob2alice.expectMsgType[ClosingComplete]
val closingSig = bob2alice.expectMsgType[ClosingSig]
bob2alice.forward(alice, closingSig)
val closingTx = alice2blockchain.expectMsgType[PublishFinalTx].tx
alice2blockchain.expectWatchTxConfirmed(closingTx.txid)
assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.txid)
bob2blockchain.expectWatchTxConfirmed(closingTx.txid)
alice ! Error(ByteVector32.Zeroes, "oops")
awaitCond(alice.stateName == CLOSING)
assert(alice.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.nonEmpty)
alice2blockchain.expectNoMessage(100 millis) // we have a mutual close transaction, so we don't publish the commit tx
bob ! Error(ByteVector32.Zeroes, "oops")
awaitCond(bob.stateName == CLOSING)
assert(bob.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.nonEmpty)
bob2blockchain.expectNoMessage(100 millis) // we have a mutual close transaction, so we don't publish the commit tx
}
}

View file

@ -335,6 +335,18 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
awaitCond(alice.stateName == CLOSED)
}
test("recv WatchTxConfirmedTriggered (mutual close, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
val mutualCloseTx = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].publishedClosingTxs.last
alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mutualCloseTx.tx)
awaitCond(alice.stateName == CLOSED)
bob ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mutualCloseTx.tx)
awaitCond(bob.stateName == CLOSED)
}
test("recv WatchFundingSpentTriggered (local commit)") { f =>
import f._
// an error occurs and alice publishes her commit tx
@ -859,6 +871,18 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
assert(!listener.expectMsgType[LocalChannelUpdate].channelUpdate.channelFlags.isEnabled)
}
test("recv WatchFundingSpentTriggered (remote commit, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
// Bob publishes his last current commit tx, the one it had when entering NEGOTIATING state.
val bobCommitTx = bobCommitTxs.last.commitTx.tx
val closingState = remoteClose(bobCommitTx, alice, alice2blockchain)
assert(closingState.claimHtlcTxs.isEmpty)
val txPublished = txListener.expectMsgType[TransactionPublished]
assert(txPublished.tx == bobCommitTx)
assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the remote commit
}
test("recv CMD_BUMP_FORCE_CLOSE_FEE (remote commit)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
import f._
@ -904,10 +928,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
awaitCond(alice.stateName == CLOSED)
}
test("recv WatchTxConfirmedTriggered (remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey)) { f =>
test("recv WatchTxConfirmedTriggered (remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey), Tag(ChannelStateTestsTags.SimpleClose)) { f =>
import f._
mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
assert(alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures == ChannelFeatures(Features.StaticRemoteKey))
assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].commitments.params.channelFeatures == ChannelFeatures(Features.StaticRemoteKey))
// bob publishes his last current commit tx, the one it had when entering NEGOTIATING state
val bobCommitTx = bobCommitTxs.last.commitTx.tx
assert(bobCommitTx.txOut.size == 2) // two main outputs

View file

@ -580,7 +580,7 @@ class StandardChannelIntegrationSpec extends ChannelIntegrationSpec {
fundee.register ! Register.Forward(sender.ref.toTyped[Any], channelId, CMD_CLOSE(sender.ref, None, None))
sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
// we then wait for C and F to negotiate the closing fee
awaitCond(stateListener.expectMsgType[ChannelStateChanged](max = 60 seconds).currentState == CLOSING, max = 60 seconds)
awaitCond(stateListener.expectMsgType[ChannelStateChanged](max = 60 seconds).currentState == NEGOTIATING_SIMPLE, max = 60 seconds)
// and close the channel
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
awaitCond({

View file

@ -10,7 +10,7 @@ import com.typesafe.config.ConfigFactory
import fr.acinq.bitcoin.scalacompat.ByteVector32
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.blockchain.CurrentBlockHeight
import fr.acinq.eclair.channel.NEGOTIATING
import fr.acinq.eclair.channel.{NEGOTIATING, NEGOTIATING_SIMPLE}
import fr.acinq.eclair.io.Switchboard.GetPeerInfo
import fr.acinq.eclair.io.{Peer, PeerConnected, PeerReadyManager, Switchboard}
import fr.acinq.eclair.payment.relay.AsyncPaymentTriggerer._
@ -166,7 +166,7 @@ class AsyncPaymentTriggererSpec extends ScalaTestWithActorTestKit(ConfigFactory.
system.eventStream ! EventStream.Publish(PeerConnected(peer.ref.toClassic, remoteNodeId, null))
val request2 = switchboard.expectMessageType[Switchboard.GetPeerInfo]
request2.replyTo ! Peer.PeerInfo(peer.ref.toClassic, remoteNodeId, Peer.CONNECTED, None, None, Set(TestProbe().ref.toClassic))
peer.expectMessageType[Peer.GetPeerChannels].replyTo ! Peer.PeerChannels(remoteNodeId, Seq(Peer.ChannelInfo(null, NEGOTIATING, null)))
peer.expectMessageType[Peer.GetPeerChannels].replyTo ! Peer.PeerChannels(remoteNodeId, Seq(Peer.ChannelInfo(null, NEGOTIATING_SIMPLE, null)))
probe.expectNoMessage(100 millis)
probe2.expectMessage(AsyncPaymentTriggered)
}

View file

@ -19,7 +19,7 @@ package fr.acinq.eclair.transactions
import fr.acinq.bitcoin.SigHash._
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, ripemd160, sha256}
import fr.acinq.bitcoin.scalacompat.Script.{pay2wpkh, pay2wsh, write}
import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OutPoint, Protocol, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut, millibtc2satoshi}
import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OP_PUSHDATA, OP_RETURN, OutPoint, Protocol, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut, millibtc2satoshi}
import fr.acinq.eclair.TestUtils.randomTxId
import fr.acinq.eclair._
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw}
@ -828,6 +828,56 @@ class TransactionsSpec extends AnyFunSuite with Logging {
val toRemoteIndex = (toLocal.index + 1) % 2
assert(closingTx.tx.txOut(toRemoteIndex.toInt).amount == 250_000.sat)
}
{
// Different amounts, both outputs untrimmed, local is closer (option_simple_close):
val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 250_000_000 msat)
val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(5_000 sat), 0, localPubKeyScript, remotePubKeyScript)
assert(closingTxs.localAndRemote_opt.nonEmpty)
assert(closingTxs.localOnly_opt.nonEmpty)
assert(closingTxs.remoteOnly_opt.isEmpty)
val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get
assert(localAndRemote.publicKeyScript == localPubKeyScript)
assert(localAndRemote.amount == 145_000.sat)
val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get
assert(localOnly.publicKeyScript == localPubKeyScript)
assert(localOnly.amount == 145_000.sat)
}
{
// Remote is using OP_RETURN (option_simple_close): we set their output amount to 0 sat.
val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_500_000 msat)
val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil)
val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(5_000 sat), 0, localPubKeyScript, remotePubKeyScript)
assert(closingTxs.localAndRemote_opt.nonEmpty)
assert(closingTxs.localOnly_opt.nonEmpty)
assert(closingTxs.remoteOnly_opt.isEmpty)
val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get
assert(localAndRemote.publicKeyScript == localPubKeyScript)
assert(localAndRemote.amount == 145_000.sat)
val remoteOutput = closingTxs.localAndRemote_opt.get.tx.txOut((localAndRemote.index.toInt + 1) % 2)
assert(remoteOutput.amount == 0.sat)
assert(remoteOutput.publicKeyScript == remotePubKeyScript)
val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get
assert(localOnly.publicKeyScript == localPubKeyScript)
assert(localOnly.amount == 145_000.sat)
}
{
// Remote is using OP_RETURN (option_simple_close) and paying the fees: we set their output amount to 0 sat.
val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 10_000_000 msat)
val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil)
val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(5_000 sat), 0, localPubKeyScript, remotePubKeyScript)
assert(closingTxs.localAndRemote_opt.nonEmpty)
assert(closingTxs.localOnly_opt.nonEmpty)
assert(closingTxs.remoteOnly_opt.isEmpty)
val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get
assert(localAndRemote.publicKeyScript == localPubKeyScript)
assert(localAndRemote.amount == 150_000.sat)
val remoteOutput = closingTxs.localAndRemote_opt.get.tx.txOut((localAndRemote.index.toInt + 1) % 2)
assert(remoteOutput.amount == 0.sat)
assert(remoteOutput.publicKeyScript == remotePubKeyScript)
val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get
assert(localOnly.publicKeyScript == localPubKeyScript)
assert(localOnly.amount == 150_000.sat)
}
{
// Same amounts, both outputs untrimmed, local is fundee:
val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 150_000_000 msat)
@ -851,6 +901,29 @@ class TransactionsSpec extends AnyFunSuite with Logging {
assert(toLocal.amount == 150_000.sat)
assert(toLocal.index == 0)
}
{
// Their output is trimmed (option_simple_close):
val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_000_000 msat)
val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(800 sat), 0, localPubKeyScript, remotePubKeyScript)
assert(closingTxs.all.size == 1)
assert(closingTxs.localOnly_opt.nonEmpty)
val toLocal = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get
assert(toLocal.publicKeyScript == localPubKeyScript)
assert(toLocal.amount == 150_000.sat)
assert(toLocal.index == 0)
}
{
// Their OP_RETURN output is trimmed (option_simple_close):
val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_000_000 msat)
val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil)
val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(1_001 sat), 0, localPubKeyScript, remotePubKeyScript)
assert(closingTxs.all.size == 1)
assert(closingTxs.localOnly_opt.nonEmpty)
val toLocal = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get
assert(toLocal.publicKeyScript == localPubKeyScript)
assert(toLocal.amount == 150_000.sat)
assert(toLocal.index == 0)
}
{
// Our output is trimmed:
val spec = CommitmentSpec(Set.empty, feeratePerKw, 50_000 msat, 150_000_000 msat)
@ -858,6 +931,14 @@ class TransactionsSpec extends AnyFunSuite with Logging {
assert(closingTx.tx.txOut.length == 1)
assert(closingTx.toLocalOutput.isEmpty)
}
{
// Our output is trimmed (option_simple_close):
val spec = CommitmentSpec(Set.empty, feeratePerKw, 1_000_000 msat, 150_000_000 msat)
val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(800 sat), 0, localPubKeyScript, remotePubKeyScript)
assert(closingTxs.all.size == 1)
assert(closingTxs.remoteOnly_opt.nonEmpty)
assert(closingTxs.remoteOnly_opt.flatMap(_.toLocalOutput).isEmpty)
}
{
// Both outputs are trimmed:
val spec = CommitmentSpec(Set.empty, feeratePerKw, 50_000 msat, 10_000 msat)