Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add high-level helpers for taproot channels #3028

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open

Conversation

sstone
Copy link
Member

@sstone sstone commented Mar 5, 2025

We add a new specific commitment format for taproot channels, and high-level methods for creating and spending taproot channel transactions.
There are no functional changes: protocol messages and codecs have not been updated to support taproot channels, these new methods are only used in unit tests.

We add a new specific commitment format for taproot channels, and high-level methods for creating and spending taproot channel transactions.
@sstone sstone requested a review from t-bast March 5, 2025 11:18
Copy link
Member

@t-bast t-bast left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I think the changes to Transactions.scala show that our existing architecture doesn't work at all for both segwit and taproot...it's frankly a big red flag that it may be worth rethinking the architecture of those types more in-depth now.

I don't have the solution, but I think we should spend more time prototyping bigger re-works of this architecture to be more future-proof. I'll spend some time next week thinking about it on my side, and it would be good to involve @pm47 as well to make sure we get it right this time, and it will be easy to add the zero-fee commitment variants we want to introduce in the future as well.

@@ -92,16 +98,25 @@ object Transactions {
*/
case object ZeroFeeHtlcTxAnchorOutputsCommitmentFormat extends AnchorOutputsCommitmentFormat

case object SimpleTaprootChannelCommitmentFormat extends AnchorOutputsCommitmentFormat {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't inherit AnchorOutputsCommitmentFormat, this should just inherit CommitmentFormat so that we have the opportunity to treat it differently than anchor outputs everywhere we pattern match on the commitmentFormat. It's dangerous to inherit AnchorOutputsCommitmentFormat because we may miss some code paths and inherit behavior that we don't want.

We shouldn't add useTaproot: Boolean in CommitmentFormat either: when we have multiple commitment formats that use taproot in the future, we will introduce a parent trait for it.

@@ -77,15 +77,15 @@ case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMax
def isProposedFeerateTooHigh(commitmentFormat: CommitmentFormat, networkFeerate: FeeratePerKw, proposedFeerate: FeeratePerKw): Boolean = {
commitmentFormat match {
case Transactions.DefaultCommitmentFormat => networkFeerate * ratioHigh < proposedFeerate
case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | UnsafeLegacyAnchorOutputsCommitmentFormat => networkFeerate * ratioHigh < proposedFeerate
case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | UnsafeLegacyAnchorOutputsCommitmentFormat | SimpleTaprootChannelCommitmentFormat => networkFeerate * ratioHigh < proposedFeerate
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to https://coin.dance/nodes#nodeVersions, 1/3 of the bitcoin network already uses bitcoind 28.x, which means they support 1-parent-1-child package relay even when the parent transaction is below the mempool feerate. By the time we ship taproot, wouldn't it be a good opportunity to always set the feerate at 1 sat/byte for those channels and disable update_fee entirely?

@@ -223,7 +223,8 @@ object Scripts {

/** Extract the payment preimage from a 2nd-stage HTLC Success transaction's witness script */
def extractPreimageFromHtlcSuccess: PartialFunction[ScriptWitness, ByteVector32] = {
case ScriptWitness(Seq(ByteVector.empty, _, _, paymentPreimage, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage)
case ScriptWitness(Seq(ByteVector.empty, _, _, paymentPreimage, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) // standard channels
case ScriptWitness(Seq(remoteSig, localSig, paymentPreimage, _, _)) if remoteSig.size == 65 && localSig.size == 65 && paymentPreimage.size == 32 => ByteVector32(paymentPreimage) // simple taproot channels
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also update:

  • extractPreimageFromClaimHtlcSuccess
  • extractPaymentHashFromHtlcTimeout
  • extractPaymentHashFromClaimHtlcTimeout

We should also enrich HelpersSpec.scala to verify that this works (maybe we cannot do it in this PR since we don't yet support creating taproot channels, but it should be on the TODO-list for the next PRs).

@@ -307,6 +308,8 @@ object Scripts {

implicit def scala2kmpscript(input: Seq[fr.acinq.bitcoin.scalacompat.ScriptElt]): java.util.List[fr.acinq.bitcoin.ScriptElt] = input.map(e => scala2kmp(e)).asJava

def musig2FundingScript(pubkey1: PublicKey, pubkey2: PublicKey): Seq[ScriptElt] = Script.pay2tr(musig2Aggregate(pubkey1, pubkey2), None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about adding a mandatory useTaproot: Boolean parameter to the existing multiSig2of2 function instead? That guarantees that we correctly update all the code paths that use it?

// @formatter:off
case class OutputInfo(index: Long, amount: Satoshi, publicKeyScript: ByteVector)

sealed trait InputInfo {
val outPoint: OutPoint
val txOut: TxOut
val isP2tr: Boolean = Try(Script.isPay2tr(Script.parse(txOut.publicKeyScript))).getOrElse(false)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need this, we can rely on whether this is a TaprootInput instead?

@@ -129,7 +144,7 @@ object Transactions {
Satoshi(FeeratePerKw.MinimumRelayFeeRate * vsize / 1000)
}
/** Sighash flags to use when signing the transaction. */
def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = SIGHASH_ALL
def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = if (input.isP2tr) SIGHASH_DEFAULT else SIGHASH_ALL
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of relying on input.isP2tr (which we should remove) we should pattern match in InputInfo and use SIGHASH_DEFAULT in the TaprootInput case?

Comment on lines +196 to +197
).getRight
session.verify(psig.partialSig, psig.nonce, remotePubKey)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't safe since getRight returns a nullable, which breaks our usual Scala assumptions that we never have risks of NullPointerException, can you map or cast to an Option to make this safe?

override def sign(privateKey: PrivateKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): ByteVector64 = input match {
case t: InputInfo.TaprootInput =>
val Some(scriptTree: ScriptTree.Branch) = t.scriptTree_opt
Transaction.signInputTaprootScriptPath(privateKey, tx, 0, Seq(input.txOut), SigHash.SIGHASH_SINGLE | SigHash.SIGHASH_ANYONECANPAY, KotlinUtils.kmp2scala(scriptTree.getRight.hash()))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't use SIGHASH_SINGLE | SIGHASH_ANYONECANPAY regardless of the txOwner: you must use sighash(txOwner, commitmentFormat), if we don't provide a SIGHASH_ALL for our local sig we're creating a big vulnerability! Same everywhere else.

override def desc: String = "htlc-success"

override def sign(privateKey: PrivateKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): ByteVector64 = input match {
case t: InputInfo.TaprootInput =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We must provide an implementation of sign whenever the input is using taproot. This isn't correctly reflected in the function hierarchy: in the default sign function from TransactionWithInputInfo we have a case _: InputInfo.TaprootInput => ByteVector64.Zeroes. We shouldn't have this case, it's a red flag that the architecture is wrong.

Maybe a solution is to remove the default implementation of def sign(key: PrivateKey, sighashType: Int): ByteVector64 from TransactionWithInputInfo to force implementation. And the current SegwitInput can be provided as a helper function (to ensure we don't duplicate the same code everywhere).

Comment on lines +387 to +388
// these nonces are generated on the fly at during a "simple" closing session and can be forgotten once the session ends
// @volatile var localNonce_opt: Option[(SecretNonce, IndividualNonce)] = None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's that? That definitely shouldn't be how we handle this...the nonces will be in the NEGOTIATING_SIMPLE state or in a var in Channel.scala, but I don't think it should be here with a volatile...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants