From 3ed32319ba3f63634e242a68b40af45a5269d9af Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 2 Sep 2025 13:08:47 +0200 Subject: [PATCH 1/6] tapchannel: parameterize stxo for commitments 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. --- tapchannel/aux_funding_controller.go | 1 + tapchannel/aux_leaf_creator.go | 8 ++++---- tapchannel/commitment.go | 25 ++++++++++++++++++++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/tapchannel/aux_funding_controller.go b/tapchannel/aux_funding_controller.go index c456e917ec..6440a901e8 100644 --- a/tapchannel/aux_funding_controller.go +++ b/tapchannel/aux_funding_controller.go @@ -571,6 +571,7 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, fakePrevState, lndOpenChan, assetOpenChan, whoseCommit, localSatBalance, remoteSatBalance, fakeView, pendingFunding.chainParams, keyRing.GetForParty(whoseCommit), + false, ) if err != nil { return nil, lnwallet.CommitAuxLeaves{}, err diff --git a/tapchannel/aux_leaf_creator.go b/tapchannel/aux_leaf_creator.go index 75796115d1..3b9b465143 100644 --- a/tapchannel/aux_leaf_creator.go +++ b/tapchannel/aux_leaf_creator.go @@ -53,7 +53,7 @@ func FetchLeavesFromView(chainParams *address.ChainParams, allocations, newCommitment, err := GenerateCommitmentAllocations( prevState, in.ChannelState, chanAssetState, in.WhoseCommit, in.OurBalance, in.TheirBalance, in.UnfilteredView, chainParams, - in.KeyRing, + in.KeyRing, false, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable to generate "+ @@ -129,7 +129,7 @@ func FetchLeavesFromCommit(chainParams *address.ChainParams, leaf, err := CreateSecondLevelHtlcTx( chanState, com.CommitTx, htlc.Amt.ToSatoshis(), keys, chainParams, htlcOutputs, cltvTimeout, - htlc.HtlcIndex, + htlc.HtlcIndex, false, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable "+ @@ -170,7 +170,7 @@ func FetchLeavesFromCommit(chainParams *address.ChainParams, leaf, err := CreateSecondLevelHtlcTx( chanState, com.CommitTx, htlc.Amt.ToSatoshis(), keys, chainParams, htlcOutputs, cltvTimeout, - htlc.HtlcIndex, + htlc.HtlcIndex, false, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable "+ @@ -251,7 +251,7 @@ func ApplyHtlcView(chainParams *address.ChainParams, _, newCommitment, err := GenerateCommitmentAllocations( prevState, in.ChannelState, chanAssetState, in.WhoseCommit, in.OurBalance, in.TheirBalance, in.UnfilteredView, chainParams, - in.KeyRing, + in.KeyRing, false, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable to generate "+ diff --git a/tapchannel/commitment.go b/tapchannel/commitment.go index d802bed092..df453f2f11 100644 --- a/tapchannel/commitment.go +++ b/tapchannel/commitment.go @@ -497,7 +497,7 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, whoseCommit lntypes.ChannelParty, ourBalance, theirBalance lnwire.MilliSatoshi, originalView lnwallet.AuxHtlcView, chainParams *address.ChainParams, - keys lnwallet.CommitmentKeyRing) ([]*tapsend.Allocation, + keys lnwallet.CommitmentKeyRing, stxo bool) ([]*tapsend.Allocation, *cmsg.Commitment, error) { log.Tracef("Generating allocations, whoseCommit=%v, ourBalance=%d, "+ @@ -589,8 +589,18 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, "packets: %w", err) } + var ( + opts []tapsend.OutputCommitmentOption + proofOpts []proof.GenOption + ) + + if !stxo { + opts = append(opts, tapsend.WithNoSTXOProofs()) + proofOpts = append(proofOpts, proof.WithNoSTXOProofs()) + } + outCommitments, err := tapsend.CreateOutputCommitments( - vPackets, tapsend.WithNoSTXOProofs(), + vPackets, opts..., ) if err != nil { return nil, nil, fmt.Errorf("unable to create output "+ @@ -626,7 +636,7 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, fakeCommitTx, vPkt, outCommitments, outIdx, vPackets, tapsend.NonAssetExclusionProofs( allocations, - ), proof.WithNoSTXOProofs(), + ), proofOpts..., ) if err != nil { return nil, nil, fmt.Errorf("unable to create "+ @@ -1432,7 +1442,7 @@ func CreateSecondLevelHtlcTx(chanState lnwallet.AuxChanState, commitTx *wire.MsgTx, htlcAmt btcutil.Amount, keys lnwallet.CommitmentKeyRing, chainParams *address.ChainParams, htlcOutputs []*cmsg.AssetOutput, htlcTimeout fn.Option[uint32], - htlcIndex uint64) (input.AuxTapLeaf, error) { + htlcIndex uint64, stxo bool) (input.AuxTapLeaf, error) { none := input.NoneTapLeaf() @@ -1445,8 +1455,13 @@ func CreateSecondLevelHtlcTx(chanState lnwallet.AuxChanState, "packets: %w", err) } + var opts []tapsend.OutputCommitmentOption + if !stxo { + opts = append(opts, tapsend.WithNoSTXOProofs()) + } + outCommitments, err := tapsend.CreateOutputCommitments( - vPackets, tapsend.WithNoSTXOProofs(), + vPackets, opts..., ) if err != nil { return none, fmt.Errorf("unable to create output commitments: "+ From a6ab8484c7006401c842d3f787f567df08d4e1ea Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 2 Sep 2025 13:07:50 +0200 Subject: [PATCH 2/6] tapchannel: include stxo proofs on channel funding 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. --- tapcfg/server.go | 1 + tapchannel/aux_funding_controller.go | 78 ++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/tapcfg/server.go b/tapcfg/server.go index 75d83f549f..5f492ecc31 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -628,6 +628,7 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, AssetSyncer: addrBook, FeatureBits: lndFeatureBitsVerifier, IgnoreChecker: ignoreCheckerOpt, + AuxChanNegotiator: auxChanNegotiator, ErrChan: mainErrChan, }, ) diff --git a/tapchannel/aux_funding_controller.go b/tapchannel/aux_funding_controller.go index 6440a901e8..ff9d7f5c7c 100644 --- a/tapchannel/aux_funding_controller.go +++ b/tapchannel/aux_funding_controller.go @@ -28,6 +28,7 @@ import ( "github.com/lightninglabs/taproot-assets/rfq" cmsg "github.com/lightninglabs/taproot-assets/tapchannelmsg" "github.com/lightninglabs/taproot-assets/tapdb" + "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightninglabs/taproot-assets/tapfreighter" "github.com/lightninglabs/taproot-assets/tapgarden" "github.com/lightninglabs/taproot-assets/tappsbt" @@ -41,6 +42,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/msgmux" + "github.com/lightningnetwork/lnd/routing/route" ) const ( @@ -269,6 +271,11 @@ type FundingControllerCfg struct { // a proof should be ignored. IgnoreChecker lfn.Option[proof.IgnoreChecker] + // AuxChanNegotiator is responsible for producing the extra tlv blob + // that is encapsulated in the init and reestablish peer messages. This + // helps us communicate custom feature bits with our peer. + AuxChanNegotiator *tapfeatures.AuxChannelNegotiator + // ErrChan is used to report errors back to the main server. ErrChan chan<- error } @@ -419,6 +426,8 @@ type pendingAssetFunding struct { initiator bool + stxo bool + amt uint64 pushAmt btcutil.Amount @@ -462,7 +471,9 @@ func (p *pendingAssetFunding) assetOutputs() []*cmsg.AssetOutput { } // addToFundingCommitment adds a new asset to the funding commitment. -func (p *pendingAssetFunding) addToFundingCommitment(a *asset.Asset) error { +func (p *pendingAssetFunding) addToFundingCommitment(a *asset.Asset, + stxo bool) error { + newCommitment, err := commitment.FromAssets( fn.Ptr(commitment.TapCommitmentV2), a, ) @@ -470,6 +481,22 @@ func (p *pendingAssetFunding) addToFundingCommitment(a *asset.Asset) error { return fmt.Errorf("unable to create commitment: %w", err) } + // If our peer supports STXO we go ahead and append the + // appropriate alt leaves to the VOutput. + if stxo { + altLeaves, err := asset.CollectSTXO(a) + if err != nil { + return err + } + + err = newCommitment.MergeAltLeaves(altLeaves) + if err != nil { + return err + } + + p.stxo = stxo + } + newCommitment, err = commitment.TrimSplitWitnesses( &newCommitment.Version, newCommitment, ) @@ -524,7 +551,8 @@ func (p *pendingAssetFunding) addInputProofChunk( func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, lndOpenChan lnwallet.AuxChanState, assetOpenChan *cmsg.OpenChannel, keyRing lntypes.Dual[lnwallet.CommitmentKeyRing], - whoseCommit lntypes.ChannelParty) ([]byte, lnwallet.CommitAuxLeaves, + whoseCommit lntypes.ChannelParty, + stxo bool) ([]byte, lnwallet.CommitAuxLeaves, error) { chanAssets := assetOpenChan.FundedAssets.Val.Outputs @@ -571,7 +599,7 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, fakePrevState, lndOpenChan, assetOpenChan, whoseCommit, localSatBalance, remoteSatBalance, fakeView, pendingFunding.chainParams, keyRing.GetForParty(whoseCommit), - false, + stxo, ) if err != nil { return nil, lnwallet.CommitAuxLeaves{}, err @@ -614,12 +642,14 @@ func (p *pendingAssetFunding) toAuxFundingDesc(req *bindFundingReq, // This will be the information for the very first state (state 0). localCommitBlob, localAuxLeaves, err := newCommitBlobAndLeaves( p, req.openChan, openChanDesc, req.keyRing, lntypes.Local, + p.stxo, ) if err != nil { return nil, err } remoteCommitBlob, remoteAuxLeaves, err := newCommitBlobAndLeaves( p, req.openChan, openChanDesc, req.keyRing, lntypes.Remote, + p.stxo, ) if err != nil { return nil, err @@ -1087,14 +1117,19 @@ func (f *FundingController) signAllVPackets(ctx context.Context, // complete, but unsigned PSBT packet that can be used to create out asset // channel. func (f *FundingController) anchorVPackets(fundedPkt *tapsend.FundedPsbt, - allPackets []*tappsbt.VPacket) ([]*proof.Proof, error) { + allPackets []*tappsbt.VPacket, stxo bool) ([]*proof.Proof, error) { log.Infof("Anchoring funding vPackets to funding PSBT") + var opts []tapsend.OutputCommitmentOption + if !stxo { + opts = append(opts, tapsend.WithNoSTXOProofs()) + } + // Given the set of vPackets we've created, we'll now merge them all to // create a map from output index to final tap commitment. outputCommitments, err := tapsend.CreateOutputCommitments( - allPackets, tapsend.WithNoSTXOProofs(), + allPackets, opts..., ) if err != nil { return nil, fmt.Errorf("unable to create new output "+ @@ -1123,11 +1158,16 @@ func (f *FundingController) anchorVPackets(fundedPkt *tapsend.FundedPsbt, for idx := range allPackets { vPkt := allPackets[idx] + var opts []proof.GenOption + if !stxo { + opts = append(opts, proof.WithNoSTXOProofs()) + } + for vOutIdx := range vPkt.Outputs { proofSuffix, err := tapsend.CreateProofSuffix( fundedPkt.Pkt.UnsignedTx, fundedPkt.Pkt.Outputs, vPkt, outputCommitments, vOutIdx, allPackets, - proof.WithNoSTXOProofs(), + opts..., ) if err != nil { return nil, fmt.Errorf("unable to create "+ @@ -1220,7 +1260,8 @@ func (f *FundingController) sendAssetFundingCreated(ctx context.Context, // ultimately broadcasting the funding transaction. func (f *FundingController) completeChannelFunding(ctx context.Context, fundingState *pendingAssetFunding, - fundedVpkt *tapfreighter.FundedVPacket) (*wire.OutPoint, error) { + fundedVpkt *tapfreighter.FundedVPacket, + stxoEnabled bool) (*wire.OutPoint, error) { log.Debugf("Finalizing funding vPackets and PSBT...") @@ -1331,7 +1372,7 @@ func (f *FundingController) completeChannelFunding(ctx context.Context, // PSBT. This'll update all the pkScripts for our funding output and // change. fundingOutputProofs, err := f.anchorVPackets( - finalFundedPsbt, signedPkts, + finalFundedPsbt, signedPkts, stxoEnabled, ) if err != nil { return nil, fmt.Errorf("unable to anchor vPackets: %w", err) @@ -1546,11 +1587,17 @@ func (f *FundingController) processFundingMsg(ctx context.Context, "proof: %w", err) } + features := f.cfg.AuxChanNegotiator.GetPeerFeatures( + route.Vertex(msg.PeerPub.SerializeCompressed()), + ) + + supportSTXO := features.HasFeature(tapfeatures.STXOOptional) + // If we reached this point, then the asset output and all // inputs are valid, so we'll store the funding asset // commitment. err = assetFunding.addToFundingCommitment( - &assetProof.AssetOutput.Val, + &assetProof.AssetOutput.Val, supportSTXO, ) if err != nil { return tempPID, fmt.Errorf("unable to create "+ @@ -1739,6 +1786,15 @@ func (f *FundingController) processFundingReq(fundingFlows fundingFlowIndex, maxNumAssetIDs) } + // Now let's see if we should be using STXOs for this channel funding. + features := f.cfg.AuxChanNegotiator.GetPeerFeatures( + route.Vertex(fundReq.PeerPub.SerializeCompressed()), + ) + + supportSTXO := features.HasFeature(tapfeatures.STXOOptional) + + fundingState.stxo = supportSTXO + // Now that we know the final funding asset root along with the splits, // we can derive the tapscript root that'll be used alongside the // internal key (which we'll only learn from lnd later as we finalize @@ -1751,7 +1807,7 @@ func (f *FundingController) processFundingReq(fundingFlows fundingFlowIndex, } err = fundingState.addToFundingCommitment( - fundingOut.Asset.Copy(), + fundingOut.Asset.Copy(), supportSTXO, ) if err != nil { return fmt.Errorf("unable to add asset to funding "+ @@ -1815,7 +1871,7 @@ func (f *FundingController) processFundingReq(fundingFlows fundingFlowIndex, } chanPoint, err := f.completeChannelFunding( - fundReq.ctx, fundingState, fundingVpkt, + fundReq.ctx, fundingState, fundingVpkt, supportSTXO, ) if err != nil { // If anything went wrong during the funding process, From c72fde0e5ce73487b8f1cc20a5ee402e3cfbeb06 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 8 Sep 2025 11:53:33 +0200 Subject: [PATCH 3/6] tapchannelmsg: include stxo flag in commitment blob 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. --- rfq/manager_test.go | 1 + tapchannel/auf_leaf_signer_test.go | 2 +- tapchannel/aux_funding_controller.go | 1 + tapchannel/commitment.go | 6 +++--- tapchannelmsg/custom_channel_data_test.go | 9 +++++---- tapchannelmsg/records.go | 8 +++++++- tapchannelmsg/records_test.go | 18 +++++++++++++++++- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/rfq/manager_test.go b/rfq/manager_test.go index 0df3a7d2e0..f6b95b0663 100644 --- a/rfq/manager_test.go +++ b/rfq/manager_test.go @@ -111,6 +111,7 @@ func createChannelWithCustomData(t *testing.T, id asset.ID, localBalance, ), }, nil, nil, lnwallet.CommitAuxLeaves{}, + false, ), OpenChan: *tpchmsg.NewOpenChannel( []*tpchmsg.AssetOutput{ diff --git a/tapchannel/auf_leaf_signer_test.go b/tapchannel/auf_leaf_signer_test.go index 08cfb99263..05786a890c 100644 --- a/tapchannel/auf_leaf_signer_test.go +++ b/tapchannel/auf_leaf_signer_test.go @@ -111,7 +111,7 @@ func setupAuxLeafSigner(t *testing.T, numJobs int32) (*AuxLeafSigner, } com := cmsg.NewCommitment( - nil, nil, outgoingHtlcs, nil, lnwallet.CommitAuxLeaves{}, + nil, nil, outgoingHtlcs, nil, lnwallet.CommitAuxLeaves{}, false, ) cancelChan := make(chan struct{}) diff --git a/tapchannel/aux_funding_controller.go b/tapchannel/aux_funding_controller.go index ff9d7f5c7c..ed49ff3ce2 100644 --- a/tapchannel/aux_funding_controller.go +++ b/tapchannel/aux_funding_controller.go @@ -587,6 +587,7 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, // needs the sum of the remote+local assets, so we'll populate that. fakePrevState := cmsg.NewCommitment( localAssets, remoteAssets, nil, nil, lnwallet.CommitAuxLeaves{}, + stxo, ) // Just like above, we don't have a real HTLC view here, so we'll pass diff --git a/tapchannel/commitment.go b/tapchannel/commitment.go index df453f2f11..e96bccd0ca 100644 --- a/tapchannel/commitment.go +++ b/tapchannel/commitment.go @@ -651,7 +651,7 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, // Next, we can convert the allocations to auxiliary leaves and from // those construct our Commitment struct that will in the end also hold // our proof suffixes. - newCommitment, err := ToCommitment(allocations, vPackets) + newCommitment, err := ToCommitment(allocations, vPackets, stxo) if err != nil { return nil, nil, fmt.Errorf("unable to convert to commitment: "+ "%w", err) @@ -1155,7 +1155,7 @@ func LeavesFromTapscriptScriptTree( // ToCommitment converts the allocations to a Commitment struct. func ToCommitment(allocations []*tapsend.Allocation, - vPackets []*tappsbt.VPacket) (*cmsg.Commitment, error) { + vPackets []*tappsbt.VPacket, stxo bool) (*cmsg.Commitment, error) { var ( localAssets []*cmsg.AssetOutput @@ -1278,7 +1278,7 @@ func ToCommitment(allocations []*tapsend.Allocation, return cmsg.NewCommitment( localAssets, remoteAssets, outgoingHtlcs, incomingHtlcs, - auxLeaves, + auxLeaves, stxo, ), nil } diff --git a/tapchannelmsg/custom_channel_data_test.go b/tapchannelmsg/custom_channel_data_test.go index 8cbc4d39b5..7ae5a186d2 100644 --- a/tapchannelmsg/custom_channel_data_test.go +++ b/tapchannelmsg/custom_channel_data_test.go @@ -50,6 +50,7 @@ func TestReadChannelCustomData(t *testing.T) { }, map[input.HtlcIndex][]*AssetOutput{ 2: {output4}, }, lnwallet.CommitAuxLeaves{}, + false, ) fundingBlob := fundingState.Bytes() @@ -157,19 +158,19 @@ func TestReadBalanceCustomData(t *testing.T) { openChannel1 := NewCommitment( []*AssetOutput{output1}, []*AssetOutput{output2}, nil, nil, - lnwallet.CommitAuxLeaves{}, + lnwallet.CommitAuxLeaves{}, false, ) openChannel2 := NewCommitment( []*AssetOutput{output2}, []*AssetOutput{output3}, nil, nil, - lnwallet.CommitAuxLeaves{}, + lnwallet.CommitAuxLeaves{}, false, ) pendingChannel1 := NewCommitment( []*AssetOutput{output3}, nil, nil, nil, - lnwallet.CommitAuxLeaves{}, + lnwallet.CommitAuxLeaves{}, false, ) pendingChannel2 := NewCommitment( nil, []*AssetOutput{output1}, nil, nil, - lnwallet.CommitAuxLeaves{}, + lnwallet.CommitAuxLeaves{}, false, ) var customChannelData bytes.Buffer diff --git a/tapchannelmsg/records.go b/tapchannelmsg/records.go index 0bb06e9757..b93d027dec 100644 --- a/tapchannelmsg/records.go +++ b/tapchannelmsg/records.go @@ -453,13 +453,17 @@ type Commitment struct { // AuxLeaves are the auxiliary leaves that correspond to the commitment. AuxLeaves tlv.RecordT[tlv.TlvType4, AuxLeaves] + + // STXO is a flag indicating whether this commitment supports stxo + // proofs. + STXO tlv.RecordT[tlv.TlvType5, bool] } // NewCommitment creates a new Commitment record with the given local and remote // assets, and incoming and outgoing HTLCs. func NewCommitment(localAssets, remoteAssets []*AssetOutput, outgoingHtlcs, incomingHtlcs map[input.HtlcIndex][]*AssetOutput, - auxLeaves lnwallet.CommitAuxLeaves) *Commitment { + auxLeaves lnwallet.CommitAuxLeaves, stxo bool) *Commitment { return &Commitment{ LocalAssets: tlv.NewRecordT[tlv.TlvType0]( @@ -485,6 +489,7 @@ func NewCommitment(localAssets, remoteAssets []*AssetOutput, outgoingHtlcs, auxLeaves.IncomingHtlcLeaves, ), ), + STXO: tlv.NewPrimitiveRecord[tlv.TlvType5](stxo), } } @@ -496,6 +501,7 @@ func (c *Commitment) records() []tlv.Record { c.OutgoingHtlcAssets.Record(), c.IncomingHtlcAssets.Record(), c.AuxLeaves.Record(), + c.STXO.Record(), } } diff --git a/tapchannelmsg/records_test.go b/tapchannelmsg/records_test.go index 1f6222b608..8a1d2cf7e0 100644 --- a/tapchannelmsg/records_test.go +++ b/tapchannelmsg/records_test.go @@ -215,6 +215,7 @@ func TestCommitment(t *testing.T) { name: "commitment with empty HTLC maps", commitment: NewCommitment( nil, nil, nil, nil, lnwallet.CommitAuxLeaves{}, + false, ), }, { @@ -228,7 +229,21 @@ func TestCommitment(t *testing.T) { NewAssetOutput( [32]byte{1}, 1000, *randProof, ), - }, nil, nil, lnwallet.CommitAuxLeaves{}, + }, nil, nil, lnwallet.CommitAuxLeaves{}, false, + ), + }, + { + name: "commitment with balances and stxo", + commitment: NewCommitment( + []*AssetOutput{ + NewAssetOutput( + [32]byte{1}, 1000, *randProof, + ), + }, []*AssetOutput{ + NewAssetOutput( + [32]byte{1}, 1000, *randProof, + ), + }, nil, nil, lnwallet.CommitAuxLeaves{}, true, ), }, { @@ -319,6 +334,7 @@ func TestCommitment(t *testing.T) { }, }, }, + false, ), }, } From a7e4ccd4fecef2ded333a3e3d75a73f100de6abd Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 2 Sep 2025 13:12:51 +0200 Subject: [PATCH 4/6] tapchannel: add stxo support for aux leaves 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. --- server.go | 8 +++++-- tapchannel/aux_leaf_creator.go | 44 +++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/server.go b/server.go index 6b8900778e..874cc0e8ec 100644 --- a/server.go +++ b/server.go @@ -868,7 +868,9 @@ func (s *Server) FetchLeavesFromView( // 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.FetchLeavesFromView(s.chainParams, in) + return tapchannel.FetchLeavesFromView( + s.chainParams, in, s.cfg.AuxChanNegotiator, + ) } // FetchLeavesFromCommit attempts to fetch the auxiliary leaves that @@ -924,7 +926,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, + ) } // InlineParseCustomData replaces any custom data binary blob in the given RPC diff --git a/tapchannel/aux_leaf_creator.go b/tapchannel/aux_leaf_creator.go index 3b9b465143..bbf0737ea1 100644 --- a/tapchannel/aux_leaf_creator.go +++ b/tapchannel/aux_leaf_creator.go @@ -10,11 +10,13 @@ import ( "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/fn" cmsg "github.com/lightninglabs/taproot-assets/tapchannelmsg" + "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightningnetwork/lnd/channeldb" lfn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" lnwl "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" ) @@ -24,10 +26,19 @@ const ( DefaultTimeout = 30 * time.Second ) +// FeatureBitFetcher is responsible for fetching feature bits by referencing a +// channel ID. +type FeatureBitFetcher interface { + // GetChannelFeatures returns the negotiated features that are active + // over the channel identifier by the provided channelID. + GetChannelFeatures(cid lnwire.ChannelID) lnwire.FeatureVector +} + // FetchLeavesFromView attempts to fetch the auxiliary leaves that correspond to // the passed aux blob, and pending fully evaluated HTLC view. func FetchLeavesFromView(chainParams *address.ChainParams, - in lnwl.CommitDiffAuxInput) lfn.Result[lnwl.CommitDiffAuxResult] { + in lnwl.CommitDiffAuxInput, + bitFetcher FeatureBitFetcher) lfn.Result[lnwl.CommitDiffAuxResult] { type returnType = lnwl.CommitDiffAuxResult @@ -50,10 +61,18 @@ func FetchLeavesFromView(chainParams *address.ChainParams, "commit state: %w", err)) } + features := bitFetcher.GetChannelFeatures( + lnwire.NewChanIDFromOutPoint( + in.ChannelState.FundingOutpoint, + ), + ) + + supportsSTXO := features.HasFeature(tapfeatures.STXOOptional) + allocations, newCommitment, err := GenerateCommitmentAllocations( prevState, in.ChannelState, chanAssetState, in.WhoseCommit, in.OurBalance, in.TheirBalance, in.UnfilteredView, chainParams, - in.KeyRing, false, + in.KeyRing, supportsSTXO, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable to generate "+ @@ -98,6 +117,8 @@ func FetchLeavesFromCommit(chainParams *address.ChainParams, "commitment: %w", err)) } + supportSTXO := commitment.STXO.Val + incomingHtlcs := commitment.IncomingHtlcAssets.Val.HtlcOutputs incomingHtlcLeaves := commitment.AuxLeaves.Val.IncomingHtlcLeaves. Val.HtlcAuxLeaves @@ -129,7 +150,7 @@ func FetchLeavesFromCommit(chainParams *address.ChainParams, leaf, err := CreateSecondLevelHtlcTx( chanState, com.CommitTx, htlc.Amt.ToSatoshis(), keys, chainParams, htlcOutputs, cltvTimeout, - htlc.HtlcIndex, false, + htlc.HtlcIndex, supportSTXO, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable "+ @@ -170,7 +191,7 @@ func FetchLeavesFromCommit(chainParams *address.ChainParams, leaf, err := CreateSecondLevelHtlcTx( chanState, com.CommitTx, htlc.Amt.ToSatoshis(), keys, chainParams, htlcOutputs, cltvTimeout, - htlc.HtlcIndex, false, + htlc.HtlcIndex, supportSTXO, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable "+ @@ -225,7 +246,8 @@ func FetchLeavesFromRevocation( // channel's blob. Given the old blob, and an HTLC view, then a new // blob should be returned that reflects the pending updates. func ApplyHtlcView(chainParams *address.ChainParams, - in lnwl.CommitDiffAuxInput) lfn.Result[lfn.Option[tlv.Blob]] { + in lnwl.CommitDiffAuxInput, + bitFetcher FeatureBitFetcher) lfn.Result[lfn.Option[tlv.Blob]] { type returnType = lfn.Option[tlv.Blob] @@ -248,10 +270,20 @@ func ApplyHtlcView(chainParams *address.ChainParams, "commit state: %w", err)) } + features := bitFetcher.GetChannelFeatures( + lnwire.NewChanIDFromOutPoint( + in.ChannelState.FundingOutpoint, + ), + ) + + supportSTXO := features.HasFeature( + tapfeatures.STXOOptional, + ) + _, newCommitment, err := GenerateCommitmentAllocations( prevState, in.ChannelState, chanAssetState, in.WhoseCommit, in.OurBalance, in.TheirBalance, in.UnfilteredView, chainParams, - in.KeyRing, false, + in.KeyRing, supportSTXO, ) if err != nil { return lfn.Err[returnType](fmt.Errorf("unable to generate "+ From 9a4a046c401c535a7b2dd211d7f05b86a682b5cd Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 2 Sep 2025 13:15:30 +0200 Subject: [PATCH 5/6] tapchannel: use stxo on aux chan closer 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. --- tapcfg/server.go | 1 + tapchannel/aux_closer.go | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tapcfg/server.go b/tapcfg/server.go index 5f492ecc31..ef4b51ba78 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -660,6 +660,7 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, GroupVerifier: groupVerifier, ChainBridge: chainBridge, IgnoreChecker: ignoreCheckerOpt, + AuxChanNegotiator: auxChanNegotiator, }, ) auxSweeper := tapchannel.NewAuxSweeper( diff --git a/tapchannel/aux_closer.go b/tapchannel/aux_closer.go index 451c21d698..cadaca4cb9 100644 --- a/tapchannel/aux_closer.go +++ b/tapchannel/aux_closer.go @@ -15,6 +15,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapchannelmsg" + "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightninglabs/taproot-assets/tapfreighter" "github.com/lightninglabs/taproot-assets/tapgarden" "github.com/lightninglabs/taproot-assets/tappsbt" @@ -66,6 +67,11 @@ type AuxChanCloserCfg struct { // IgnoreChecker is an optional function that can be used to check if // a proof should be ignored. IgnoreChecker lfn.Option[proof.IgnoreChecker] + + // AuxChanNegotiator is responsible for producing the extra tlv blob + // that is encapsulated in the init and reestablish peer messages. This + // helps us communicate custom feature bits with our peer. + AuxChanNegotiator *tapfeatures.AuxChannelNegotiator } // assetCloseInfo houses the information we need to finalize the close of an @@ -454,11 +460,21 @@ func (a *AuxChanCloser) AuxCloseOutputs( "packets: %w", err) } + features := a.cfg.AuxChanNegotiator.GetChannelFeatures( + lnwire.NewChanIDFromOutPoint(desc.ChanPoint), + ) + supportSTXO := features.HasFeature(tapfeatures.STXOOptional) + + var opts []tapsend.OutputCommitmentOption + if !supportSTXO { + opts = append(opts, tapsend.WithNoSTXOProofs()) + } + // With the outputs prepared, we can now create the set of output // commitments, then with the output index locations known, we can set // the output indexes in the allocations. outCommitments, err := tapsend.CreateOutputCommitments( - vPackets, tapsend.WithNoSTXOProofs(), + vPackets, opts..., ) if err != nil { return none, fmt.Errorf("unable to create output "+ @@ -733,10 +749,22 @@ func (a *AuxChanCloser) FinalizeClose(desc chancloser.AuxCloseDesc, closeInfo.allocations, ) + features := a.cfg.AuxChanNegotiator.GetChannelFeatures( + lnwire.NewChanIDFromOutPoint(desc.ChanPoint), + ) + supportSTXO := features.HasFeature( + tapfeatures.STXOOptional, + ) + + var opts []proof.GenOption + if !supportSTXO { + opts = append(opts, proof.WithNoSTXOProofs()) + } + proofSuffix, err := tapsend.CreateProofSuffixCustom( closeTx, vPkt, closeInfo.outputCommitments, outIdx, closeInfo.vPackets, exclusionCreator, - proof.WithNoSTXOProofs(), + opts..., ) if err != nil { return fmt.Errorf("unable to create proof "+ From 92189161aa7a5df6939cc97d5ba5460124ad3c4c Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 8 Sep 2025 11:57:10 +0200 Subject: [PATCH 6/6] tapchannel: support stxo on aux sweeper 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. --- tapchannel/aux_sweeper.go | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tapchannel/aux_sweeper.go b/tapchannel/aux_sweeper.go index 1d741ec01f..d3249cb877 100644 --- a/tapchannel/aux_sweeper.go +++ b/tapchannel/aux_sweeper.go @@ -1585,6 +1585,8 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, } } + supportSTXO := commitState.STXO.Val + // We can now add the witness for the OP_TRUE spend of the commitment // output to the vPackets. vPackets := maps.Values(vPktsByAssetID) @@ -1593,8 +1595,18 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, "packets: %w", err) } + var ( + opts []tapsend.OutputCommitmentOption + proofOpts []proof.GenOption + ) + + if !supportSTXO { + opts = append(opts, tapsend.WithNoSTXOProofs()) + proofOpts = append(proofOpts, proof.WithNoSTXOProofs()) + } + outCommitments, err := tapsend.CreateOutputCommitments( - vPackets, tapsend.WithNoSTXOProofs(), + vPackets, opts..., ) if err != nil { return fmt.Errorf("unable to create output "+ @@ -1614,8 +1626,7 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, for outIdx := range vPkt.Outputs { proofSuffix, err := tapsend.CreateProofSuffixCustom( req.CommitTx, vPkt, outCommitments, outIdx, - vPackets, exclusionCreator, - proof.WithNoSTXOProofs(), + vPackets, exclusionCreator, proofOpts..., ) if err != nil { return fmt.Errorf("unable to create "+ @@ -2225,9 +2236,7 @@ func (a *AuxSweeper) sweepContracts(inputs []input.Input, // Now that we have our set of resolutions, we'll make a new commitment // out of all the vPackets contained. - outCommitments, err := tapsend.CreateOutputCommitments( - directPkts, tapsend.WithNoSTXOProofs(), - ) + outCommitments, err := tapsend.CreateOutputCommitments(directPkts) if err != nil { return lfn.Errf[returnType]("unable to create "+ "output commitments: %w", err) @@ -2408,9 +2417,7 @@ func (a *AuxSweeper) registerAndBroadcastSweep(req *sweep.BumpRequest, } // Now that we have our vPkts, we'll re-create the output commitments. - outCommitments, err := tapsend.CreateOutputCommitments( - vPkts.allPkts(), tapsend.WithNoSTXOProofs(), - ) + outCommitments, err := tapsend.CreateOutputCommitments(vPkts.allPkts()) if err != nil { return fmt.Errorf("unable to create output "+ "commitments: %w", err) @@ -2454,7 +2461,7 @@ func (a *AuxSweeper) registerAndBroadcastSweep(req *sweep.BumpRequest, proofSuffix, err := tapsend.CreateProofSuffixCustom( sweepTx, vPkt, outCommitments, outIdx, allVpkts, - exclusionCreator, proof.WithNoSTXOProofs(), + exclusionCreator, ) if err != nil { return fmt.Errorf("unable to create proof "+