Skip to content

Conversation

GeorgeTsagk
Copy link
Member

@GeorgeTsagk GeorgeTsagk commented Sep 2, 2025

Description

Adds support for v1 proofs for all channel related transactions (funding/commitments/closing/sweeping).

We also guard this feature behind a feature bit, which must be set and supported by both channel peers in order to use v1 proofs.

For some cases during sweeping, we don't really care about the feature being supported by the remote party as we're sweeping locally to our own address.

Fixes #1582

@GeorgeTsagk
Copy link
Member Author

LiT tests are expected to fail, the related LiT PR is here

tapsend/send.go Outdated
@@ -995,7 +1013,7 @@ func CreateOutputCommitments(packets []*tappsbt.VPacket,

// commitPacket creates the output commitments for a virtual packet and merges
// it with the existing commitments for the anchor outputs.
func commitPacket(vPkt *tappsbt.VPacket, noSTXOProofs bool,
func commitPacket(vPkt *tappsbt.VPacket, noSTXOProofs, existingSTXOLeaves bool,
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps this should take a mini options/config struct? A common bug that can arise with two side-by-side params w/ the same type is that a caller accidentally passes them in w/ the wrong order.

Copy link
Member Author

Choose a reason for hiding this comment

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

Given that commitPacket is only a helper used within this package, with just one call site, I'll just wrap the two flags in a config struct to make sure the field name shows up next to the value, in order to prevent such mistakes

tapsend/send.go Outdated

// Verify that the new set of alt leaves has
// unique keys.
err := asset.AddLeafKeysVerifyUnique(
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 this protection, or assertion at a higher level?

When can this happen (non-unique STXO leaves passed in) within the context of the protocol?

Copy link
Member Author

Choose a reason for hiding this comment

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

When funding a channel we need to "prematurely" create and include the alt leaves on the commitment because LND will cache the tapscript for future reference. That's the only case where we pre-generate the alt leaves, and that's why we added the option here for this "soft" check.

Generally we want to error out on alt leaf key conflict, but for this specific case we want to let this tool know that they may already exist -- in which case we only append the new leaves if no conflict occurs.

Copy link
Member Author

Choose a reason for hiding this comment

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

The rationale behind adding the option is to be able to re-use the commitPacket helper even from the chan funding call site, where the above special conditions are met.

tapsend/send.go Outdated
vOut.AltLeaves = append(
vOut.AltLeaves, stxoAssets...,
)
}
Copy link
Member

Choose a reason for hiding this comment

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

We don't actually handle the error case here, so what good is this check?

Copy link
Member Author

Choose a reason for hiding this comment

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

If there was a leaf key conflict we avoid touching the existing alt leaves. We only append if the set contains unique keys -- the err == nil case.

Could change to instead keep just the unique values, but didn't want to overengineer it.

@@ -889,7 +892,9 @@ func (s *Server) ApplyHtlcView(

// The aux leaf creator is fully stateless, and we don't need to wait
// for the server to be started before being able to use it.
return tapchannel.ApplyHtlcView(s.chainParams, in)
return tapchannel.ApplyHtlcView(
s.chainParams, in, s.cfg.AuxChanNegotiator,
Copy link
Member

Choose a reason for hiding this comment

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

First thought at seeing this change: do we actually need to propagate this all the way here just to do something like fetch the set of aux leaves, or to apply an HTLC view?

Even before a channel is created, we know if we're going to use the STXO feature and also noop feature (for new channels). Therefore, when preparing the funding and commitment blobs, we can just store this information right there and there. So then we don't need to query for feature bits each time we want to make a new commitment or validate one from the remote peer.

The other dependnacy that pops up here is: these methods needs to always work, even if we're not currently connected to a peer. IIUC, this new aux negotiator interface can only query live peer state (what did they send us in init, etc).

Copy link
Member Author

Choose a reason for hiding this comment

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

Therefore, when preparing the funding and commitment blobs, we can just store this information right there and there

Where exactly are you referring to? If you're referring to the funding/commit blobs then we can't really rely on those values for future decisions. Given that (at least for a short time window) downgrading from v1 will be allowed, then we need to know what the latest state of our peer is. Last commitment could have been using v1, but then upon reconnection our peer could stop supporting it.

The other dependnacy that pops up here is: these methods needs to always work, even if we're not currently connected to a peer. IIUC, this new aux negotiator interface can only query live peer state (what did they send us in init, etc).

I think for ApplyHTLCView we know that the channel must be active for the method to be called, which means we have communicated/agreed upon feature bits with corresponding peer. You're right for some of these cases that we could skip using the AuxChanNegotiator and rely on the commit blob, will change those hooks accordingly.

Generally, if the method affects the creation of the next commitment we'll want to consult the negotiator. If it just cares about some existing commitment, we could look up the flag in the blob.

@@ -50,10 +65,16 @@ func FetchLeavesFromView(chainParams *address.ChainParams,
"commit state: %w", err))
}

supportsSTXO := bitFetcher.GetChannelFeatures(
Copy link
Member

Choose a reason for hiding this comment

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

In this case, we have asccess to the blobs that we have lnd store (funding + commitment), so we can just read that state.

Copy link
Member

Choose a reason for hiding this comment

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

Same goes for all the other instances in this commit.

IMO, we only need to query this the very first time when we create a channel.

Copy link
Member

Choose a reason for hiding this comment

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

Thinking about things anew, I think the ideal implementation here would actually set a feature bit in the channel type message. Otherwise, we're falling back to the old implicit channel type negotiation, which we wanted to get away from by making the type of the channel being negotiated explicit.

With this route, an extension point would actually go near the funding manager in lnd, which is what decides if we understand a feature bit combo to open a new channel type.

Copy link
Member Author

Choose a reason for hiding this comment

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

See #1779 (comment), focusing on closing remark

features := a.cfg.AuxChanNegotiator.GetChannelFeatures(
lnwire.NewChanIDFromOutPoint(desc.ChanPoint),
)
supportSTXO := features.HasFeature(tapfeatures.STXOOptional)
Copy link
Member

Choose a reason for hiding this comment

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

Same here, we already know if the channel was created with STXO support or not.

Or is the idea that if they update after the fact, we still want to use stxos in the new outputs?

Copy link
Member Author

Choose a reason for hiding this comment

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

At the time of running the above code lines we're crafting the coop close transaction. Both parties need to agree on whether they use v1 or not in order to craft the same transaction.

Could be the case that we used v0 for the whole lifecycle of the channel, then peer updated right before coop closing.


// STXO is a flag indicating whether this commitment supports stxo
// proofs.
STXO tlv.RecordT[tlv.TlvType5, bool]
Copy link
Member

Choose a reason for hiding this comment

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

Cool, so if we're using this here, then I think we can remove many of the perior instances in the earlier commits where we're doing queries on feature bits after channel funding.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not really. This entry is juts a historical entry.

The fact that the last commit used STXO doesn't mean that the next commit will use STXO too. Our peer could downgrade in the meantime.

Copy link
Member Author

Choose a reason for hiding this comment

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

The above entry was only really introduced for our contract resolver

req.resp <- a.resolveContract(req.req)

specifically the part where we import the commit tx:

supportSTXO := commitState.STXO.Val

@github-project-automation github-project-automation bot moved this from 🏗 In progress to 👀 In review in Taproot-Assets Project Board Sep 9, 2025
@GeorgeTsagk GeorgeTsagk force-pushed the feature-bits branch 2 times, most recently from 3d4c18f to f9e10d7 Compare September 11, 2025 09:16
We change the commitment related generations to now include stxo proofs
if the setting allows so. Previously we would always exclude stxo
proofs, but now we add them if both parties understand & expect them.
Copy link
Contributor

@gijswijs gijswijs left a comment

Choose a reason for hiding this comment

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

Did a first pass and mad comments throughout. Starts to look good! This PR depends on the litd itest working, right? (lightninglabs/lightning-terminal#1138)

tapsend/send.go Outdated
// existingSTXOLeaves indicates whether we should be aware that alt
// leaves that correspond to stxo assets may already exist in asset
// outputs of the vpsbt.
existingSTXOLeaves bool
Copy link
Contributor

Choose a reason for hiding this comment

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

I understand the reason of this boolean and premature tapscript generation that is referenced by lnd, but I don't like it.
This complexity makes the code harder to reason about and could be fragile. Isn't there a way to establishes a single point of responsibility for adding STXO?

Copy link
Member Author

@GeorgeTsagk GeorgeTsagk Sep 15, 2025

Choose a reason for hiding this comment

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

Thanks for this comment.

I was getting ready to write a long explanation on how this isn't a quirk of the commitment related toolkit but a characteristic of the chan funding flow -- i.e we need to craft the correct commitment even though we haven't yet finalized the vpsbt that creates it.

Then I realized that in my latest push, where I simplified a lot of code, I also omitted the (now unnecessary) part of actually appending the alt leaves to the vOut. This means that even though we do collect STXOs prematurely, we don't really attach it to the vOut, leaving it "clean" for the toolkit to handle it. This means that we actually never hit a case where there's duplicate alt leaves, as we'd never really persist them between preparation & finalization phase.

Will just drop the commit that adds WithExistingSTXOLeaves altogether.

Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting. But if we don't append them to the vOut why do we still collect the STXOs prematurely? Where do we use them in the chan funding flow?

Copy link
Member Author

Choose a reason for hiding this comment

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

We directly merge them into the commitment that will be used to derive the tapscript for LND. The vOut alt leaves are just the placeholder for keeping the leaves somewhere, until we'd eventually want to merge them into the tap commitment.

So now we simply skip the step of adding the alt leaves to the vOut and later when the helper functions get the vPSBT they'll be free to re-collect/append the alt leaves without conflicts or extra ticks needed

Copy link
Contributor

Choose a reason for hiding this comment

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

Had some back and forth on this with @GeorgeTsagk and an LLM who will remain anonymous. I'm posting it here for future reference.

  1. Premature Stage: Funding Transaction Construction (addToFundingCommitment)
    This stage computes the correct TapscriptRoot for the on-chain P2TR funding output script. LND needs this root early, before the final proofs are generated.
    Call Flow:
    FundingController.processFundingReq -> pendingAssetFunding.addToFundingCommitment
    Action: addToFundingCommitment calls asset.CollectSTXO and merges the resulting leaves directly into an in-memory commitment.TapCommitment. This allows for the correct TapscriptRoot to be provided to LND. This process does not modify any VPacket's AltLeaves.
  2. Finalization Stage: Final Proof Generation (commitPacket)
    This stage generates the final Taproot Asset proofs for the assets being created in the channel after the funding transaction is fully known.
    Call Flow:
    FundingController.processFundingReq -> FundingController.completeChannelFunding -> FundingController.anchorVPackets -> tapsend.CreateOutputCommitments -> commitPacket
    Action: The commitPacket function is called on the VPackets representing the channel assets. It calls asset.CollectSTXO again and appends the resulting leaves to the vOut.AltLeaves slice before creating the final commitment for the proof.

This commit updates the aux funding controller to conditionally use stxo
proofs, depending on whether the feature bit was negotiated or not with
the remote peer. Our funding commitment will now include the alt leaves
for stxos if the flag is set.

The funder needs to construct the correct tapscript early on, as LND
will query the tapscript root before we reach the finalization phase.
That's why we manually calculate and merge the stxo alt leaves in the
funding process.

The fundee now also needs to calculate and merge the alt leaves that
result from the asset outputs, in order to arrive to the same tapscript
root.
We extend the commitment blob to also store a flag, indicating whether
STXO was active when that commitment was created. This can be useful for
future sweeps that need to know whether that commitment used stxo alt
leaves, which affects the reconstruction of the tap commitment.
We also add stxo support for the aux leaf creation. This is crucial and
needs to be the same across both channel parties. We rely on the
consistency of the feature bit for whether we'll include the stxo alt
leaves or not. A disagreement here could lead to a force close.
For the cooperative channel closing we also need to check if the parties
have agreed upon the usage of stxo proofs. If that is the case, we'll
include those commitments in the coop channel closing transaction &
proof.
This commit adds stxo support to the aux sweeper. When importing a
commit tx to the wallet we now consult the commit blob flag in order to
find out whether that commitment was using stxo proofs or not.
@GeorgeTsagk
Copy link
Member Author

Did a first pass and mad comments throughout. Starts to look good! This PR depends on the litd itest working, right? (lightninglabs/lightning-terminal#1138)

Yeap. Will update the LiT PR soon to reflect all of recent changes made here

Copy link
Contributor

@gijswijs gijswijs left a comment

Choose a reason for hiding this comment

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

Nearly there! I made some small remarks and some questions.


log.Infof("Anchoring funding vPackets to funding PSBT")

// We have collected the stxo leaves for the outputs in a previous step.
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment doesn't make any sense anymore now that we avoided alt leaves collisions anyway.

@@ -1713,6 +1766,15 @@ func (f *FundingController) processFundingReq(fundingFlows fundingFlowIndex,
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

This block should be pushed a few lines down, such that the registration of the defer is still directly below the creation of the unlockLeases closure.

@@ -24,10 +26,19 @@ const (
DefaultTimeout = 30 * time.Second
)

// FeatureBitFetcher is responsible for fetching feature bits either by
// referencing a remote peer, or an established channel.
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment isn't up to date anymore, now that it's impossible to reference a peer. It only works byu referencing a channel.

outCommitments, err := tapsend.CreateOutputCommitments(
directPkts, tapsend.WithNoSTXOProofs(),
)
outCommitments, err := tapsend.CreateOutputCommitments(directPkts)
Copy link
Contributor

Choose a reason for hiding this comment

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

For my understanding: From now on we always create a sweeping Tx with stxo proofs.
Why didn't we do so before? Oli needed the WithNoSTXOProofs to make the channel commitments work, but it seems to me that the sweeping Tx could always have had stxo proofs. (This is more a question about Oli's PR than about this PR, but I'm still wondering.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: 👀 In review
Development

Successfully merging this pull request may close these issues.

3 participants