Skip to content

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.

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

@sstone sstone force-pushed the taproot-helpers branch 3 times, most recently from a544e88 to 51fb974 Compare March 12, 2025 19:43
We add a new specific commitment format for taproot channels, and high-level methods for creating and spending taproot channel transactions.
@sstone sstone force-pushed the taproot-helpers branch from 4bd9575 to 71a3df4 Compare April 1, 2025 15:34
@sstone
Copy link
Member Author

sstone commented Apr 1, 2025

Squashed and rebased on master at 650681f

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.

Sorry, I know this may feel like bikeshedding, but it really isn't: the type architecture isn't completely satisfying yet, there is some type duplication that shows that we haven't really found the right design to correctly support both segwitv0 and taproot.

I know it's painful to iterate and try various approaches, but it will save us a lot of time in the future: refactoring after the fact will be way more costly because we will need to migrate data and change a lot more code...so I'd rather spend more time now trying out variations of the types until we have something that really "fits" than merging something that has design smells. However the burden shouldn't be only on you: if you want me to spend more time trying out my refactorings until it fully compiles, I can spend time on this, just let me know!

Comment on lines +109 to +112
// commit tx witness is just a single 64 bytes signature
override val commitWeight = 960
// HTLC output weight remains the same
override val htlcOutputWeight = 172
Copy link
Member

Choose a reason for hiding this comment

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

Am I understanding correctly that those two values should be specified in Bolt 3 because that's required for both nodes to compute the same value for the commitment fees? I don't see it in the spec PR, it is worth adding a comment to make sure laolu adds them?

Also, I'm surprised that the htlcOutputWeight is the same for taproot channels: the txOut is now using p2tr which uses 35 bytes, while p2wsh uses 34 bytes? I'm surprised that the unit test passes...am I missing something?

The other values below are not strictly necessary since HTLC txs don't pay any fees in this commitment format, but they're useful for our fee estimation when we're doing CPFP.

Comment on lines 206 to 208
case class HtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long, confirmationTarget: ConfirmationTarget.Absolute) extends HtlcTx { override def desc: String = "htlc-success" }
case class HtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long, confirmationTarget: ConfirmationTarget.Absolute) extends HtlcTx { override def desc: String = "htlc-timeout" }
case class HtlcDelayedTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "htlc-delayed" }
Copy link
Member

Choose a reason for hiding this comment

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

nit: why the unnecessary change for all those class definitions below? I thought something had changed in the code itself but then realized it's just formatting? Can you revert to minimize the diff?

Copy link
Member Author

Choose a reason for hiding this comment

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

done in 0d4748d

Comment on lines +545 to +568
sealed trait CommitmentOutputLink[T <: CommitmentOutput] {
val output: TxOut
val commitmentOutput: T

def filter[R <: CommitmentOutput : ClassTag]: Option[CommitmentOutputLink[R]] = commitmentOutput match {
case r: R => Some(this.asInstanceOf[CommitmentOutputLink[R]])
case _ => None
}
}

/** Type alias for a collection of commitment output links */
type CommitmentOutputs = Seq[CommitmentOutputLink[CommitmentOutput]]

object CommitmentOutputLink {
case class SegwitLink[T <: CommitmentOutput : ClassTag](output: TxOut, redeemScript: Seq[ScriptElt], commitmentOutput: T) extends CommitmentOutputLink[T]

case class TaprootLink[T <: CommitmentOutput : ClassTag](output: TxOut, internalKey: XonlyPublicKey, scriptTree_opt: Option[ScriptTree], commitmentOutput: T) extends CommitmentOutputLink[T]

def apply[T <: CommitmentOutput : ClassTag](output: TxOut, redeemScript: Seq[ScriptElt], commitmentOutput: T): SegwitLink[T] = SegwitLink(output, redeemScript, commitmentOutput)

def apply[T <: CommitmentOutput : ClassTag](output: TxOut, internalKey: XonlyPublicKey, scriptTree_opt: Option[ScriptTree], commitmentOutput: T): TaprootLink[T] = TaprootLink(output, internalKey, scriptTree_opt, commitmentOutput)

def apply[T <: CommitmentOutput : ClassTag](output: TxOut, internalKey: XonlyPublicKey, scriptTree: ScriptTree, commitmentOutput: T): TaprootLink[T] = TaprootLink(output, internalKey, Some(scriptTree), commitmentOutput)

Copy link
Member

Choose a reason for hiding this comment

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

This is a design smell, we shouldn't have to duplicate all of those classes...it took me a while to figure out what exactly was wrong, here was my thought process:

  • the only information that you actually want to abstract away is the "redeem" details for this output
    • for pay2wsh this was simply redeemScript: Seq[ScriptElt]
    • for taproot is is the internal key and the redeem path (key path or script path, with script tree details)
  • instead of duplicating CommitmentOutputLink, that means we should simply refactor this field
  • we actually already did almost that with InputInfo, but the issue is that it also contains the outPoint, which we don't know yet when creating the commit-tx, leading to a chicken-and-egg situation
  • but that just means we didn't factor the right layer when doing that change for InputInfo (my bad!): we should the outPoint and txOut fields of InputInfo ended up duplicated in SegwitInput and TaprootInput, which was a hint that this wasn't the right abstraction!

My suggestion is thus to rework the InputInfo hierarchy as follows:

  /** This trait contains redeem information necessary to spend different types of segwit inputs. */
  sealed trait RedeemInfo
  object RedeemInfo {
    /**
     * @param redeemScript the actual script must be known to redeem p2wsh inputs.
     */
    case class P2wsh(redeemScript: ByteVector) extends RedeemInfo
    object P2wsh {
      def apply(redeemScript: Seq[ScriptElt]): P2wsh = P2wsh(Script.write(redeemScript))
    }
    /**
     * @param internalKey the private key associated with this public key will be used to sign.
     * @param scriptTree_opt the script tree must be known if there is one, even when spending via the key path.
     */
    case class TaprootKeyPath(internalKey: XonlyPublicKey, scriptTree_opt: Option[ScriptTree]) extends RedeemInfo
    /**
     * @param internalKey we need the internal key, even if we don't have the private key, to spend via a script path.
     * @param scriptTree we need the complete script tree to spend taproot inputs.
     * @param leafHash hash of the leaf script we're spending (must belong to the tree).
     */
    case class TaprootScriptPath(internalKey: XonlyPublicKey, scriptTree: ScriptTree, leafHash: ByteVector32) extends RedeemInfo {
      require(findScript(scriptTree, leafHash).nonEmpty, "script tree must contain the provided leaf")

      /**
       * TODO: this won't be needed once findScript is added to bitcoin-kmp, remove when updating
       * @return the leaf that matches `leafHash`
       */
      private def findScript(scriptTree: ScriptTree, leafHash: ByteVector32): Option[ScriptTree.Leaf] = scriptTree match {
        case l: ScriptTree.Leaf => if (l.hash() == KotlinUtils.scala2kmp(leafHash)) Some(l) else None
        case b: ScriptTree.Branch => findScript(b.getLeft, leafHash) orElse findScript(b.getRight, leafHash)
      }
    }
  }
  
  case class InputInfo(outPoint: OutPoint, txOut: TxOut, redeemInfo: RedeemInfo) {
    val isTaproot: Boolean = redeemInfo match {
      case _: RedeemInfo.P2wsh => false
      case _: RedeemInfo.TaprootKeyPath => true
      case _: RedeemInfo.TaprootScriptPath => true
    }
  }

This way the CommitmentOutputLink can simply be changed to:

case class CommitmentOutputLink[T <: CommitmentOutput](output: TxOut, redeemInfo: RedeemInfo, commitmentOutput: T)

Note that I haven't tried applying this until everything compiled, so I may have missed something. If you want me to spend more time prototyping this on top of your branch, let me know.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll try and improve these types. The main issue is that a taproot output can be spent in may different ways (through a script leaf, or the key path with the revocation key for example) and this is specified when it is spent, not when it is created.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm experimenting with

  • a new output type, with subtypes for segwit and taproot
  • a new input type, that takes one of the input types defined above and specifies how it should be spent
  sealed trait OutputSpendingInfo
  object OutputSpendingInfo {
    case class Segwit(redeemScript: Seq[ScriptElt]) extends OutputSpendingInfo
    case class Taproot(internalKey: XonlyPublicKey, scriptTree_opt: Option[ScriptTree]) extends OutputSpendingInfo
  }
  sealed trait InputSpendingInfo
  object InputSpendingInfo {
    case class Segwit(output: OutputSpendingInfo.Segwit) extends InputSpendingInfo
    object Segwit {
      def apply(redeemScript: Seq[ScriptElt]) = new Segwit(OutputSpendingInfo.Segwit(redeemScript))
      def apply(redeemScript: ByteVector) = new Segwit(OutputSpendingInfo.Segwit(Script.parse(redeemScript)))
    }
    case class TaprootKeyPath(output: OutputSpendingInfo.Taproot) extends InputSpendingInfo
    object TaprootKeyPath {
      def apply(internalKey: XonlyPublicKey, scriptTree_opt: Option[ScriptTree]) = new TaprootKeyPath(OutputSpendingInfo.Taproot(internalKey, scriptTree_opt))
    }
    case class TaprootScriptPath(output: OutputSpendingInfo.Taproot, leafHash: ByteVector32) extends InputSpendingInfo {
      val scriptTree: ScriptTree = output.scriptTree_opt.getOrElse(throw new IllegalArgumentException("missing taproot script"))
      val leaf: ScriptTree.Leaf = TaprootScriptPath.findScript(scriptTree, leafHash).getOrElse(throw new IllegalArgumentException("script tree must contain the provided leaf"))
    }
    object TaprootScriptPath {
      import KotlinUtils._

      def apply(internalKey: XonlyPublicKey, scriptTree: ScriptTree, leafHash: ByteVector32) = new TaprootScriptPath(OutputSpendingInfo.Taproot(internalKey, Some(scriptTree)), leafHash)
      /**
       * TODO: this won't be needed once findScript is added to bitcoin-kmp, remove when updating bitcoin-kmp
       * @return the leaf that matches `leafHash`
       */
      def findScript(scriptTree: ScriptTree, leafHash: ByteVector32): Option[ScriptTree.Leaf] = scriptTree match {
        case l: ScriptTree.Leaf => if (l.hash() == scala2kmp(leafHash)) Some(l) else None
        case b: ScriptTree.Branch => findScript(b.getLeft, leafHash) orElse findScript(b.getRight, leafHash)
      }
    }
  }

  case class InputInfo(outPoint: OutPoint, txOut: TxOut, spendingInfo: InputSpendingInfo)

and for CommitmentOutputLink:

  case class CommitmentOutputLink[T <: CommitmentOutput](output: TxOut, outputSpendingInfo: OutputSpendingInfo, commitmentOutput: T)

I'll publish a new branch as soon as everything compiles and all tests pass

case InputInfo.SegwitInput(_, _, redeemScript) => Scripts.witnessToLocalDelayedWithRevocationSig(revocationSig, redeemScript)
case t: InputInfo.TaprootInput =>
t.redeemPath match {
case RedeemPath.ScriptPath(scriptTree: ScriptTree.Branch, _) => Script.witnessScriptPathPay2tr(t.internalKey, scriptTree.getRight.asInstanceOf[ScriptTree.Leaf], ScriptWitness(Seq(revocationSig)), scriptTree)
Copy link
Member

Choose a reason for hiding this comment

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

Why don't you use the leaf field of ScriptPath directly? This comment applies to all of those addSigs methods.

Copy link
Member Author

Choose a reason for hiding this comment

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

done in 788d492

Comment on lines 662 to 663
//assert(Try(claimAnchorOutputTx.sign(anchorKey, TxOwner.Local, commitmentFormat, Map.empty)).isFailure)
//assert(Try(claimAnchorOutputTx.sign(anchorKey, TxOwner.Local, commitmentFormat, walletInputs.take(1))).isFailure)
Copy link
Member

Choose a reason for hiding this comment

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

Can you restore this? You've got some commented lines in this file that look like they need some clean-up after you experimented with stuff.

Copy link
Member Author

Choose a reason for hiding this comment

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

done in 0d4748d

def assertWitnessWeightMatches(witness: ScriptWitness, expectedWeight: Int, commitmentFormat: CommitmentFormat): Unit =
assertWeightMatches(164 + ScriptWitness.write(witness).size.toInt, expectedWeight, commitmentFormat)

def generateCommitAndHtlcTxs(commitmentFormat: CommitmentFormat): Unit = {
Copy link
Member

Choose a reason for hiding this comment

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

Nice, having this common function that we can run for all commitment formats is going to be very useful!

@sstone
Copy link
Member Author

sstone commented May 7, 2025

closed in favour of #3075

@sstone sstone closed this May 7, 2025
@sstone sstone deleted the taproot-helpers branch May 7, 2025 15:21
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.

3 participants