diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala index 12c39a8922..166755237b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala @@ -40,11 +40,14 @@ import scala.concurrent.{ExecutionContext, Future} import scala.util.Try /** - * A blockchain watcher that: - * - receives bitcoin events (new blocks and new txes) directly from the bitcoin network - * - also uses bitcoin-core rpc api, most notably for tx confirmation count and blockcount (because reorgs) * Created by PM on 21/02/2016. */ + +/** + * A blockchain watcher that: + * - receives bitcoin events (new blocks and new txs) directly from the bitcoin network + * - also uses bitcoin-core rpc api, most notably for tx confirmation count and block count (because reorgs) + */ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: ExtendedBitcoinClient)(implicit ec: ExecutionContext = ExecutionContext.global) extends Actor with ActorLogging { import ZmqWatcher._ @@ -180,13 +183,15 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend case PublishAsap(tx) => val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) - val csvTimeout = Scripts.csvTimeout(tx) - if (csvTimeout > 0) { - require(tx.txIn.size == 1, s"watcher only supports tx with 1 input, this tx has ${tx.txIn.size} inputs") - val parentTxid = tx.txIn.head.outPoint.txid - log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parenttxid=$parentTxid tx={}", tx) - val parentPublicKey = fr.acinq.bitcoin.Script.write(fr.acinq.bitcoin.Script.pay2wsh(tx.txIn.head.witness.stack.last)) - self ! WatchConfirmed(self, parentTxid, parentPublicKey, minDepth = 1, BITCOIN_PARENT_TX_CONFIRMED(tx)) + val csvTimeouts = Scripts.csvTimeouts(tx) + if (csvTimeouts.nonEmpty) { + // watcher supports txs with multiple csv-delayed inputs: we watch all delayed parents and try to publish every + // time a parent's relative delays are satisfied, so we will eventually succeed. + csvTimeouts.foreach { case (parentTxId, csvTimeout) => + log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parentTxId=$parentTxId tx={}", tx) + val parentPublicKeyScript = Script.write(Script.pay2wsh(tx.txIn.find(_.outPoint.txid == parentTxId).get.witness.stack.last)) + self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(tx)) + } } else if (cltvTimeout > blockCount) { log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)") val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx) @@ -197,11 +202,9 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend log.info(s"parent tx of txid=${tx.txid} has been confirmed") val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) - val csvTimeout = Scripts.csvTimeout(tx) - val absTimeout = math.max(blockHeight + csvTimeout, cltvTimeout) - if (absTimeout > blockCount) { - log.info(s"delaying publication of txid=${tx.txid} until block=$absTimeout (curblock=$blockCount)") - val block2tx1 = block2tx.updated(absTimeout, block2tx.getOrElse(absTimeout, Seq.empty[Transaction]) :+ tx) + if (cltvTimeout > blockCount) { + log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)") + val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx) context become watching(watches, watchedUtxos, block2tx1, nextTick) } else publish(tx) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala index feacac1762..2530711228 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala @@ -173,13 +173,15 @@ class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor wi case PublishAsap(tx) => val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) - val csvTimeout = Scripts.csvTimeout(tx) - if (csvTimeout > 0) { - require(tx.txIn.size == 1, s"watcher only supports tx with 1 input, this tx has ${tx.txIn.size} inputs") - val parentTxid = tx.txIn.head.outPoint.txid - log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parenttxid=$parentTxid tx={}", tx) - val parentPublicKeyScript = WatchConfirmed.extractPublicKeyScript(tx.txIn.head.witness) - self ! WatchConfirmed(self, parentTxid, parentPublicKeyScript, minDepth = 1, BITCOIN_PARENT_TX_CONFIRMED(tx)) + val csvTimeouts = Scripts.csvTimeouts(tx) + if (csvTimeouts.nonEmpty) { + // watcher supports txs with multiple csv-delayed inputs: we watch all delayed parents and try to publish every + // time a parent's relative delays are satisfied, so we will eventually succeed. + csvTimeouts.foreach { case (parentTxId, csvTimeout) => + log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parentTxId=$parentTxId tx={}", tx) + val parentPublicKeyScript = WatchConfirmed.extractPublicKeyScript(tx.txIn.find(_.outPoint.txid == parentTxId).get.witness) + self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(tx)) + } } else if (cltvTimeout > blockCount) { log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)") val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx) @@ -193,11 +195,9 @@ class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor wi log.info(s"parent tx of txid=${tx.txid} has been confirmed") val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) - val csvTimeout = Scripts.csvTimeout(tx) - val absTimeout = math.max(blockHeight + csvTimeout, cltvTimeout) - if (absTimeout > blockCount) { - log.info(s"delaying publication of txid=${tx.txid} until block=$absTimeout (curblock=$blockCount)") - val block2tx1 = block2tx.updated(absTimeout, block2tx.getOrElse(absTimeout, Seq.empty[Transaction]) :+ tx) + if (cltvTimeout > blockCount) { + log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)") + val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx) context become running(height, tip, watches, scriptHashStatus, block2tx1, sent) } else { publish(tx) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index 148c1bd4c9..8026bb7df9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -1317,7 +1317,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId val revokedCommitPublished1 = d.revokedCommitPublished.map { rev => val (rev1, tx_opt) = Closing.claimRevokedHtlcTxOutputs(keyManager, d.commitments, rev, tx, nodeParams.onChainFeeConf.feeEstimator) tx_opt.foreach(claimTx => blockchain ! PublishAsap(claimTx)) - tx_opt.foreach(claimTx => blockchain ! WatchSpent(self, tx, claimTx.txIn.head.outPoint.index.toInt, BITCOIN_OUTPUT_SPENT)) + tx_opt.foreach(claimTx => blockchain ! WatchSpent(self, tx, claimTx.txIn.filter(_.outPoint.txid == tx.txid).head.outPoint.index.toInt, BITCOIN_OUTPUT_SPENT)) rev1 } stay using d.copy(revokedCommitPublished = revokedCommitPublished1) storing() @@ -2116,10 +2116,10 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId } /** - * This helper method will publish txes only if they haven't yet reached minDepth + * This helper method will publish txs only if they haven't yet reached minDepth */ - def publishIfNeeded(txes: Iterable[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { - val (skip, process) = txes.partition(Closing.inputsAlreadySpent(_, irrevocablySpent)) + def publishIfNeeded(txs: Iterable[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { + val (skip, process) = txs.partition(Closing.inputsAlreadySpent(_, irrevocablySpent)) process.foreach { tx => log.info(s"publishing txid=${tx.txid}") blockchain ! PublishAsap(tx) @@ -2128,20 +2128,20 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId } /** - * This helper method will watch txes only if they haven't yet reached minDepth + * This helper method will watch txs only if they haven't yet reached minDepth */ - def watchConfirmedIfNeeded(txes: Iterable[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { - val (skip, process) = txes.partition(Closing.inputsAlreadySpent(_, irrevocablySpent)) + def watchConfirmedIfNeeded(txs: Iterable[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { + val (skip, process) = txs.partition(Closing.inputsAlreadySpent(_, irrevocablySpent)) process.foreach(tx => blockchain ! WatchConfirmed(self, tx, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(tx))) skip.foreach(tx => log.info(s"no need to watch txid=${tx.txid}, it has already been confirmed")) } /** - * This helper method will watch txes only if the utxo they spend hasn't already been irrevocably spent + * This helper method will watch txs only if the utxo they spend hasn't already been irrevocably spent */ - def watchSpentIfNeeded(parentTx: Transaction, txes: Iterable[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { - val (skip, process) = txes.partition(Closing.inputsAlreadySpent(_, irrevocablySpent)) - process.foreach(tx => blockchain ! WatchSpent(self, parentTx, tx.txIn.head.outPoint.index.toInt, BITCOIN_OUTPUT_SPENT)) + def watchSpentIfNeeded(parentTx: Transaction, txs: Iterable[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { + val (skip, process) = txs.partition(Closing.inputsAlreadySpent(_, irrevocablySpent)) + process.foreach(tx => blockchain ! WatchSpent(self, parentTx, tx.txIn.filter(_.outPoint.txid == parentTx.txid).head.outPoint.index.toInt, BITCOIN_OUTPUT_SPENT)) skip.foreach(tx => log.info(s"no need to watch txid=${tx.txid}, it has already been confirmed")) } @@ -2226,7 +2226,6 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId def handleRemoteSpentOther(tx: Transaction, d: HasCommitments) = { log.warning(s"funding tx spent in txid=${tx.txid}") - Helpers.Closing.claimRevokedRemoteCommitTxOutputs(keyManager, d.commitments, tx, nodeParams.db.channels, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) match { case Some(revokedCommitPublished) => log.warning(s"txid=${tx.txid} was a revoked commitment, publishing the penalty tx") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 945df1ecfb..8e9ffc588f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -676,11 +676,11 @@ object Helpers { * * @return a [[RevokedCommitPublished]] object containing penalty transactions if the tx is a revoked commitment */ - def claimRevokedRemoteCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, tx: Transaction, db: ChannelsDb, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): Option[RevokedCommitPublished] = { + def claimRevokedRemoteCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, commitTx: Transaction, db: ChannelsDb, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): Option[RevokedCommitPublished] = { import commitments._ - require(tx.txIn.size == 1, "commitment tx should have 1 input") + require(commitTx.txIn.size == 1, "commitment tx should have 1 input") val channelKeyPath = keyManager.keyPath(localParams, channelVersion) - val obscuredTxNumber = Transactions.decodeTxNumber(tx.txIn.head.sequence, tx.lockTime) + val obscuredTxNumber = Transactions.decodeTxNumber(commitTx.txIn.head.sequence, commitTx.lockTime) val localPaymentPoint = localParams.walletStaticPaymentBasepoint.getOrElse(keyManager.paymentPoint(channelKeyPath).publicKey) // this tx has been published by remote, so we need to invert local/remote params val txnumber = Transactions.obscuredCommitTxNumber(obscuredTxNumber, !localParams.isFunder, remoteParams.paymentBasepoint, localPaymentPoint) @@ -707,13 +707,13 @@ object Helpers { log.info(s"channel uses option_static_remotekey to pay directly to our wallet, there is nothing to do") None case v if v.hasAnchorOutputs => generateTx("claim-remote-delayed-output") { - Transactions.makeClaimRemoteDelayedOutputTx(tx, localParams.dustLimit, localPaymentPoint, localParams.defaultFinalScriptPubKey, feeratePerKwMain).right.map(claimMain => { + Transactions.makeClaimRemoteDelayedOutputTx(commitTx, localParams.dustLimit, localPaymentPoint, localParams.defaultFinalScriptPubKey, feeratePerKwMain).right.map(claimMain => { val sig = keyManager.sign(claimMain, keyManager.paymentPoint(channelKeyPath), TxOwner.Local, commitmentFormat) Transactions.addSigs(claimMain, sig) }) } case _ => generateTx("claim-p2wpkh-output") { - Transactions.makeClaimP2WPKHOutputTx(tx, localParams.dustLimit, localPaymentPubkey, localParams.defaultFinalScriptPubKey, feeratePerKwMain).right.map(claimMain => { + Transactions.makeClaimP2WPKHOutputTx(commitTx, localParams.dustLimit, localPaymentPubkey, localParams.defaultFinalScriptPubKey, feeratePerKwMain).right.map(claimMain => { val sig = keyManager.sign(claimMain, keyManager.paymentPoint(channelKeyPath), remotePerCommitmentPoint, TxOwner.Local, commitmentFormat) Transactions.addSigs(claimMain, localPaymentPubkey, sig) }) @@ -722,7 +722,7 @@ object Helpers { // then we punish them by stealing their main output val mainPenaltyTx = generateTx("main-penalty") { - Transactions.makeMainPenaltyTx(tx, localParams.dustLimit, remoteRevocationPubkey, localParams.defaultFinalScriptPubKey, localParams.toSelfDelay, remoteDelayedPaymentPubkey, feeratePerKwPenalty).right.map(txinfo => { + Transactions.makeMainPenaltyTx(commitTx, localParams.dustLimit, remoteRevocationPubkey, localParams.defaultFinalScriptPubKey, localParams.toSelfDelay, remoteDelayedPaymentPubkey, feeratePerKwPenalty).right.map(txinfo => { val sig = keyManager.sign(txinfo, keyManager.revocationPoint(channelKeyPath), remotePerCommitmentSecret, TxOwner.Local, commitmentFormat) Transactions.addSigs(txinfo, sig) }) @@ -739,10 +739,10 @@ object Helpers { .toMap // and finally we steal the htlc outputs - val htlcPenaltyTxs = tx.txOut.zipWithIndex.collect { case (txOut, outputIndex) if htlcsRedeemScripts.contains(txOut.publicKeyScript) => + val htlcPenaltyTxs = commitTx.txOut.zipWithIndex.collect { case (txOut, outputIndex) if htlcsRedeemScripts.contains(txOut.publicKeyScript) => val htlcRedeemScript = htlcsRedeemScripts(txOut.publicKeyScript) generateTx("htlc-penalty") { - Transactions.makeHtlcPenaltyTx(tx, outputIndex, htlcRedeemScript, localParams.dustLimit, localParams.defaultFinalScriptPubKey, feeratePerKwPenalty).right.map(htlcPenalty => { + Transactions.makeHtlcPenaltyTx(commitTx, outputIndex, htlcRedeemScript, localParams.dustLimit, localParams.defaultFinalScriptPubKey, feeratePerKwPenalty).right.map(htlcPenalty => { val sig = keyManager.sign(htlcPenalty, keyManager.revocationPoint(channelKeyPath), remotePerCommitmentSecret, TxOwner.Local, commitmentFormat) Transactions.addSigs(htlcPenalty, sig, remoteRevocationPubkey) }) @@ -750,7 +750,7 @@ object Helpers { }.toList.flatten RevokedCommitPublished( - commitTx = tx, + commitTx = commitTx, claimMainOutputTx = mainTx.map(_.tx), mainPenaltyTx = mainPenaltyTx.map(_.tx), htlcPenaltyTxs = htlcPenaltyTxs.map(_.tx), @@ -776,8 +776,8 @@ object Helpers { log.info(s"looks like txid=${htlcTx.txid} could be a 2nd level htlc tx spending revoked commit txid=${revokedCommitPublished.commitTx.txid}") // Let's assume that htlcTx is an HtlcSuccessTx or HtlcTimeoutTx and try to generate a tx spending its output using a revocation key import commitments._ - val tx = revokedCommitPublished.commitTx - val obscuredTxNumber = Transactions.decodeTxNumber(tx.txIn.head.sequence, tx.lockTime) + val commitTx = revokedCommitPublished.commitTx + val obscuredTxNumber = Transactions.decodeTxNumber(commitTx.txIn.head.sequence, commitTx.lockTime) val channelKeyPath = keyManager.keyPath(localParams, channelVersion) val localPaymentPoint = localParams.walletStaticPaymentBasepoint.getOrElse(keyManager.paymentPoint(channelKeyPath).publicKey) // this tx has been published by remote, so we need to invert local/remote params @@ -1115,7 +1115,7 @@ object Helpers { } /** - * This helper function tells if the utxo consumed by the given transaction has already been irrevocably spent (possibly by this very transaction) + * This helper function tells if some of the utxos consumed by the given transaction have already been irrevocably spent (possibly by this very transaction). * * It can be useful to: * - not attempt to publish this tx when we know this will fail @@ -1127,14 +1127,11 @@ object Helpers { * @return true if we know for sure that the utxos consumed by the tx have already irrevocably been spent, false otherwise */ def inputsAlreadySpent(tx: Transaction, irrevocablySpent: Map[OutPoint, ByteVector32]): Boolean = { - require(tx.txIn.size == 1, "only tx with one input is supported") - val outPoint = tx.txIn.head.outPoint - irrevocablySpent.contains(outPoint) + tx.txIn.exists(txIn => irrevocablySpent.contains(txIn.outPoint)) } /** * This helper function returns the fee paid by the given transaction. - * * It relies on the current channel data to find the parent tx and compute the fee, and also provides a description. * * @param tx a tx for which we want to compute the fee @@ -1142,10 +1139,12 @@ object Helpers { * @return if the parent tx is found, a tuple (fee, description) */ def networkFeePaid(tx: Transaction, d: DATA_CLOSING): Option[(Satoshi, String)] = { - // only funder pays the fee - if (d.commitments.localParams.isFunder) { - // we build a map with all known txes (that's not particularly efficient, but it doesn't really matter) - val txes: Map[ByteVector32, (Transaction, String)] = ( + val isCommitTx = tx.txIn.map(_.outPoint).contains(d.commitments.commitInput.outPoint) + // only the funder pays the fee for the commit tx, but 2nd-stage and 3rd-stage tx fees are paid by their recipients + // we can compute the fees only for transactions with a single parent for which we know the output amount + if (tx.txIn.size == 1 && (d.commitments.localParams.isFunder || !isCommitTx)) { + // we build a map with all known txs (that's not particularly efficient, but it doesn't really matter) + val txs: Map[ByteVector32, (Transaction, String)] = ( d.mutualClosePublished.map(_ -> "mutual") ++ d.localCommitPublished.map(_.commitTx).map(_ -> "local-commit").toSeq ++ d.localCommitPublished.flatMap(_.claimMainDelayedOutputTx).map(_ -> "local-main-delayed") ++ @@ -1169,20 +1168,15 @@ object Helpers { .map { case (tx, desc) => tx.txid -> (tx, desc) } // will allow easy lookup of parent transaction .toMap - def fee(child: Transaction): Option[Satoshi] = { - require(child.txIn.size == 1, "transaction must have exactly one input") - val outPoint = child.txIn.head.outPoint - val parentTxOut_opt = if (outPoint == d.commitments.commitInput.outPoint) { - Some(d.commitments.commitInput.txOut) - } - else { - txes.get(outPoint.txid) map { case (parent, _) => parent.txOut(outPoint.index.toInt) } - } - parentTxOut_opt map (parentTxOut => parentTxOut.amount - child.txOut.map(_.amount).sum) - } - - txes.get(tx.txid) flatMap { - case (_, desc) => fee(tx).map(_ -> desc) + txs.get(tx.txid).flatMap { + case (_, desc) => + val parentTxOut_opt = if (isCommitTx) { + Some(d.commitments.commitInput.txOut) + } else { + val outPoint = tx.txIn.head.outPoint + txs.get(outPoint.txid).map { case (parent, _) => parent.txOut(outPoint.index.toInt) } + } + parentTxOut_opt.map(parentTxOut => parentTxOut.amount - tx.txOut.map(_.amount).sum).map(_ -> desc) } } else { None diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala index a82bd60acc..1e2585e900 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala @@ -93,22 +93,31 @@ object Scripts { 0 } - /** - * @return the number of confirmations of the tx parent before which it can be published - */ - def csvTimeout(tx: Transaction): Long = { - def sequenceToBlockHeight(sequence: Long): Long = { - if ((sequence & TxIn.SEQUENCE_LOCKTIME_DISABLE_FLAG) != 0) 0 - else { - require((sequence & TxIn.SEQUENCE_LOCKTIME_TYPE_FLAG) == 0, "CSV timeout must use block heights, not block times") - sequence & TxIn.SEQUENCE_LOCKTIME_MASK - } + private def sequenceToBlockHeight(sequence: Long): Long = { + if ((sequence & TxIn.SEQUENCE_LOCKTIME_DISABLE_FLAG) != 0) { + 0 + } else { + require((sequence & TxIn.SEQUENCE_LOCKTIME_TYPE_FLAG) == 0, "CSV timeout must use block heights, not block times") + sequence & TxIn.SEQUENCE_LOCKTIME_MASK } + } + /** + * @return the number of confirmations of each parent before which the given transaction can be published. + */ + def csvTimeouts(tx: Transaction): Map[ByteVector32, Long] = { if (tx.version < 2) { - 0 + Map.empty } else { - tx.txIn.map(_.sequence).map(sequenceToBlockHeight).max + tx.txIn.foldLeft(Map.empty[ByteVector32, Long]) { case (current, txIn) => + val csvTimeout = sequenceToBlockHeight(txIn.sequence) + if (csvTimeout > 0) { + val maxCsvTimeout = math.max(csvTimeout, current.getOrElse(txIn.outPoint.txid, 0L)) + current + (txIn.outPoint.txid -> maxCsvTimeout) + } else { + current + } + } } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 2a43a48a17..b7d92989bd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -16,8 +16,6 @@ package fr.acinq.eclair.transactions -import java.nio.ByteOrder - import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey, ripemd160} import fr.acinq.bitcoin.Script._ import fr.acinq.bitcoin.SigVersion._ @@ -29,6 +27,7 @@ import fr.acinq.eclair.transactions.Scripts._ import fr.acinq.eclair.wire.UpdateAddHtlc import scodec.bits.ByteVector +import java.nio.ByteOrder import scala.util.Try /** @@ -224,11 +223,11 @@ object Transactions { */ def obscuredCommitTxNumber(commitTxNumber: Long, isFunder: Boolean, localPaymentBasePoint: PublicKey, remotePaymentBasePoint: PublicKey): Long = { // from BOLT 3: SHA256(payment-basepoint from open_channel || payment-basepoint from accept_channel) - val h = if (isFunder) + val h = if (isFunder) { Crypto.sha256(localPaymentBasePoint.value ++ remotePaymentBasePoint.value) - else + } else { Crypto.sha256(remotePaymentBasePoint.value ++ localPaymentBasePoint.value) - + } val blind = Protocol.uint64((h.takeRight(6).reverse ++ ByteVector.fromValidHex("0000")).toArray, ByteOrder.LITTLE_ENDIAN) commitTxNumber ^ blind } @@ -241,6 +240,7 @@ object Transactions { * @return the actual commit tx number that was blinded and stored in locktime and sequence fields */ def getCommitTxNumber(commitTx: Transaction, isFunder: Boolean, localPaymentBasePoint: PublicKey, remotePaymentBasePoint: PublicKey): Long = { + require(commitTx.txIn.size == 1, "commitment tx should have 1 input") val blind = obscuredCommitTxNumber(0, isFunder, localPaymentBasePoint, remotePaymentBasePoint) val obscured = decodeTxNumber(commitTx.txIn.head.sequence, commitTx.lockTime) obscured ^ blind @@ -735,7 +735,8 @@ object Transactions { } def sign(txinfo: TransactionWithInputInfo, key: PrivateKey, sighashType: Int): ByteVector64 = { - require(txinfo.tx.txIn.lengthCompare(1) == 0, "only one input allowed") + // NB: the tx may have multiple inputs, we will only sign the one provided in txinfo.input. Bear in mind that the + // signature will be invalidated if other inputs are added *afterwards* and sighashType was SIGHASH_ALL. sign(txinfo.tx, txinfo.input.redeemScript, txinfo.input.txOut.amount, key, sighashType) } @@ -806,8 +807,10 @@ object Transactions { closingTx.copy(tx = closingTx.tx.updateWitness(0, witness)) } - def checkSpendable(txinfo: TransactionWithInputInfo): Try[Unit] = - Try(Transaction.correctlySpends(txinfo.tx, Map(txinfo.tx.txIn.head.outPoint -> txinfo.input.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + def checkSpendable(txinfo: TransactionWithInputInfo): Try[Unit] = { + // NB: we don't verify the other inputs as they should only be wallet inputs used to RBF the transaction + Try(Transaction.correctlySpends(txinfo.tx, Map(txinfo.input.outPoint -> txinfo.input.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + } def checkSig(txinfo: TransactionWithInputInfo, sig: ByteVector64, pubKey: PublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): Boolean = { val sighash = txinfo.sighash(txOwner, commitmentFormat) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index f6a9b8f7ab..6310c2441c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.Crypto.{PrivateKey, ripemd160, sha256} import fr.acinq.bitcoin.Script.{pay2wpkh, pay2wsh, write} -import fr.acinq.bitcoin.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, Protocol, SIGHASH_ALL, SIGHASH_ANYONECANPAY, SIGHASH_NONE, SIGHASH_SINGLE, Satoshi, SatoshiLong, Script, Transaction, TxOut, millibtc2satoshi} +import fr.acinq.bitcoin.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OutPoint, Protocol, SIGHASH_ALL, SIGHASH_ANYONECANPAY, SIGHASH_NONE, SIGHASH_SINGLE, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut, millibtc2satoshi} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.transactions.CommitmentOutput.{InHtlc, OutHtlc} @@ -54,6 +54,27 @@ class TransactionsSpec extends AnyFunSuite with Logging { val localDustLimit = Satoshi(546) val feeratePerKw = FeeratePerKw(22000 sat) + test("extract csv and cltv timeouts") { + val parentTxId1 = randomBytes32 + val parentTxId2 = randomBytes32 + val parentTxId3 = randomBytes32 + val txIn = Seq( + TxIn(OutPoint(parentTxId1.reverse, 3), Nil, 3), + TxIn(OutPoint(parentTxId2.reverse, 1), Nil, 4), + TxIn(OutPoint(parentTxId3.reverse, 0), Nil, 5), + TxIn(OutPoint(randomBytes32, 4), Nil, 0), + TxIn(OutPoint(parentTxId1.reverse, 2), Nil, 5), + ) + val tx = Transaction(2, txIn, Nil, 10) + val expected = Map( + parentTxId1 -> 5, + parentTxId2 -> 4, + parentTxId3 -> 5, + ) + assert(expected === Scripts.csvTimeouts(tx)) + assert(10 === Scripts.cltvTimeout(tx)) + } + test("encode/decode sequence and locktime (one example)") { val txnumber = 0x11F71FB268DL