From 0c4a5a224e340c9fa26f3ededc0b846d64a22340 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 13 May 2026 13:18:53 +0000 Subject: [PATCH 1/3] build: bump lnd (WIP) --- go.mod | 5 +++-- go.sum | 9 ++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 79c2e29f7b..4514cd2e03 100644 --- a/go.mod +++ b/go.mod @@ -118,7 +118,6 @@ require ( github.com/jackc/pgtype v1.14.4 // indirect github.com/jackc/pgx/v4 v4.18.3 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect - github.com/jackc/puddle v1.3.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/gateway v1.0.5 // indirect github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad // indirect @@ -133,7 +132,7 @@ require ( github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect github.com/lightninglabs/lightning-node-connect/gbn v1.0.2-0.20250610182311-2f1d46ef18b7 // indirect github.com/lightninglabs/lightning-node-connect/mailbox v1.0.2-0.20250610182311-2f1d46ef18b7 // indirect - github.com/lightninglabs/neutrino v0.16.2 // indirect + github.com/lightninglabs/neutrino v0.16.3-0.20260508212153-0f87fa7c4b36 // indirect github.com/lightningnetwork/lightning-onion v1.3.0 // indirect github.com/lightningnetwork/lnd/actor v0.0.6 // indirect github.com/lightningnetwork/lnd/healthcheck v1.2.6 // indirect @@ -234,3 +233,5 @@ replace github.com/prometheus/common => github.com/prometheus/common v0.26.0 // pre-release by Go modules and would be overridden by the tagged v0.16.17 // required by aperture and lndclient. replace github.com/btcsuite/btcwallet => github.com/btcsuite/btcwallet v0.16.17-0.20260213031108-70a94ea39e9c + +replace github.com/lightningnetwork/lnd => github.com/GeorgeTsagk/lnd v0.0.0-20260515110736-94fdcc7232c5 diff --git a/go.sum b/go.sum index 2a1c5734e3..c33c7d7bea 100644 --- a/go.sum +++ b/go.sum @@ -603,6 +603,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GeorgeTsagk/lnd v0.0.0-20260515110736-94fdcc7232c5 h1:13knPomopNLV6VfUWLaf2pLez8gSWSOFG+tPUWVfvCQ= +github.com/GeorgeTsagk/lnd v0.0.0-20260515110736-94fdcc7232c5/go.mod h1:hG4elDX4kx7J967tswRksh2XAmPjPIJ9REToBlle7XU= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= @@ -1031,7 +1033,6 @@ github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= @@ -1117,16 +1118,14 @@ github.com/lightninglabs/lndclient v0.20.0-6 h1:sh23eZkOpHxe39c4QRYwhsM7qbnJlS++ github.com/lightninglabs/lndclient v0.20.0-6/go.mod h1:gBtIFPGmC2xIspGIv/G5+HiPSGJsFD8uIow7Oke1HFI= github.com/lightninglabs/migrate/v4 v4.18.2-9023d66a-fork-pr-2.0.20251211093704-71c1eef09789 h1:7kX7vUgHUazAHcCJ6uzBDa4/2MEGEbMEfa01GtfqmTQ= github.com/lightninglabs/migrate/v4 v4.18.2-9023d66a-fork-pr-2.0.20251211093704-71c1eef09789/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= -github.com/lightninglabs/neutrino v0.16.2 h1:jHMMDLPX8asfwgN0/C4BY8uVaYupFzZYuWQkX8Go3fk= -github.com/lightninglabs/neutrino v0.16.2/go.mod h1:fNjnbuSPw4lRsVAzvjC1JG7IE7rqae/mbek2tNkN/Dw= +github.com/lightninglabs/neutrino v0.16.3-0.20260508212153-0f87fa7c4b36 h1:d6FuJQ6YjWqBdMJ3fmk9BgjyMFyRKecKxoGq//PHdh0= +github.com/lightninglabs/neutrino v0.16.3-0.20260508212153-0f87fa7c4b36/go.mod h1:fNjnbuSPw4lRsVAzvjC1JG7IE7rqae/mbek2tNkN/Dw= github.com/lightninglabs/neutrino/cache v1.1.3 h1:rgnabC41W+XaPuBTQrdeFjFCCAVKh1yctAgmb3Se9zA= github.com/lightninglabs/neutrino/cache v1.1.3/go.mod h1:qxkJb+pUxR5p84jl5uIGFCR4dGdFkhNUwMSxw3EUWls= github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display h1:w7FM5LH9Z6CpKxl13mS48idsu6F+cEZf0lkyiV+Dq9g= github.com/lightninglabs/protobuf-go-hex-display v1.34.2-hex-display/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= github.com/lightningnetwork/lightning-onion v1.3.0 h1:FqILgHjD6euc/Muo1VOzZ4+XDPuFnw6EYROBq0rR/5c= github.com/lightningnetwork/lightning-onion v1.3.0/go.mod h1:nP85zMHG7c0si/eHBbSQpuDCtnIXfSvFrK3tW6YWzmU= -github.com/lightningnetwork/lnd v0.20.0-beta.rc4.0.20260421084739-a8a3e13120eb h1:qhzjbUJau0bZCUAGlJwjxKLHI0337Z0KR+37aFoLhbg= -github.com/lightningnetwork/lnd v0.20.0-beta.rc4.0.20260421084739-a8a3e13120eb/go.mod h1:hrJPOxkleTu3y3K32ehBziq8y/zqQqCPQKfwktS35aw= github.com/lightningnetwork/lnd/actor v0.0.6 h1:Ge8N2wivARG+27qJBwTlB0vwsypStZYZy8vk4Zl38sU= github.com/lightningnetwork/lnd/actor v0.0.6/go.mod h1:YAsoniSbY/cAM9HTVNfZLvt7RI6swDxy6wzPspTcMZg= github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI= From ca7a7a45717bce7ba4702967d6c427eba36c1b42 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 13 May 2026 13:18:53 +0000 Subject: [PATCH 2/3] tapchannel: fix immediate force-close proof sync and sweep anchoring --- rpcserver/rpcserver.go | 2 +- tapchannel/aux_closer.go | 8 +- tapchannel/aux_funding_controller.go | 39 +- tapchannel/aux_sweeper.go | 784 +++++++++++++++++++++++---- tapchannel/commitment.go | 107 +++- tapfreighter/chain_porter.go | 150 +++-- tapfreighter/parcel.go | 29 +- tapsend/allocation.go | 10 + 8 files changed, 994 insertions(+), 135 deletions(-) diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 6b195ab3a5..841a08651b 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -3378,7 +3378,7 @@ func (r *RPCServer) PublishAndLogTransfer(ctx context.Context, tapfreighter.NewPreAnchoredParcel( activePackets, passivePackets, anchorTx, req.SkipAnchorTxBroadcast, parcelLabel, - fn.None[uint32](), + fn.None[uint32](), false, ), ) if err != nil { diff --git a/tapchannel/aux_closer.go b/tapchannel/aux_closer.go index 842144a88c..b6ce9e2c80 100644 --- a/tapchannel/aux_closer.go +++ b/tapchannel/aux_closer.go @@ -706,7 +706,8 @@ func (a *AuxChanCloser) ShutdownBlob( func shipChannelTxn(txSender tapfreighter.Porter, chanTx *wire.MsgTx, outputCommitments tappsbt.OutputCommitments, vPkts []*tappsbt.VPacket, closeFee int64, - anchorTxHeightHint fn.Option[uint32]) error { + anchorTxHeightHint fn.Option[uint32], + skipOutputProofVerify bool) error { chanTxPsbt, err := tapsend.PrepareAnchoringTemplate(vPkts) if err != nil { @@ -736,7 +737,7 @@ func shipChannelTxn(txSender tapfreighter.Porter, chanTx *wire.MsgTx, parcelLabel := fmt.Sprintf("channel-tx-%s", chanTx.TxHash().String()) preSignedParcel := tapfreighter.NewPreAnchoredParcel( vPkts, nil, closeAnchor, false, parcelLabel, - anchorTxHeightHint, + anchorTxHeightHint, skipOutputProofVerify, ) _, err = txSender.RequestShipment(preSignedParcel) if err != nil { @@ -912,7 +913,8 @@ func (a *AuxChanCloser) FinalizeClose(desc types.AuxCloseDesc, // as the transaction is being broadcast now. err := shipChannelTxn( a.cfg.TxSender, closeTx, closeInfo.outputCommitments, - closeInfo.vPackets, closeInfo.closeFee, fn.None[uint32](), + closeInfo.vPackets, closeInfo.closeFee, + fn.None[uint32](), false, ) if err != nil { return err diff --git a/tapchannel/aux_funding_controller.go b/tapchannel/aux_funding_controller.go index 6ec7ca6fa1..5bcf293347 100644 --- a/tapchannel/aux_funding_controller.go +++ b/tapchannel/aux_funding_controller.go @@ -36,6 +36,7 @@ import ( "github.com/lightninglabs/taproot-assets/vm" lfn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" @@ -585,7 +586,39 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, localAssets = chanAssets } + commitWeight := input.CommitWeight + if lndOpenChan.ChanType.IsTaproot() { + commitWeight = input.TaprootCommitWeight + } else if lndOpenChan.ChanType.HasAnchors() { + commitWeight = input.AnchorCommitWeight + } + + commitFee := pendingFunding.feeRate.FeeForWeight( + lntypes.WeightUnit(commitWeight), + ) + totalFee := commitFee + if lndOpenChan.ChanType.HasAnchors() { + totalFee += 2 * lnwallet.AnchorSize + } + + capacityMSat := lnwire.NewMSatFromSatoshis(lndOpenChan.Capacity) + pushMSat := lnwire.NewMSatFromSatoshis(pendingFunding.pushAmt) + feeMSat := lnwire.NewMSatFromSatoshis(totalFee) + var localSatBalance, remoteSatBalance lnwire.MilliSatoshi + if pendingFunding.initiator { + localSatBalance = capacityMSat - feeMSat - pushMSat + remoteSatBalance = pushMSat + } else { + localSatBalance = pushMSat + remoteSatBalance = capacityMSat - feeMSat - pushMSat + } + + if localSatBalance < 0 || remoteSatBalance < 0 { + return nil, lnwallet.CommitAuxLeaves{}, fmt.Errorf("invalid "+ + "initial balances: capacity=%v push=%v fee=%v", + lndOpenChan.Capacity, pendingFunding.pushAmt, totalFee) + } // We don't have a real prev state at this point, the leaf creator only // needs the sum of the remote+local assets, so we'll populate that. @@ -596,7 +629,9 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, // Just like above, we don't have a real HTLC view here, so we'll pass // in a blank view. - var fakeView lnwallet.AuxHtlcView + fakeView := lnwallet.AuxHtlcView{ + FeePerKw: pendingFunding.feeRate, + } // With all the above, we'll generate the first commitment that'll be // stored @@ -1468,7 +1503,7 @@ func (f *FundingController) completeChannelFunding(ctx context.Context, ) preSignedParcel := tapfreighter.NewPreAnchoredParcel( activePkts, passivePkts, anchorTx, false, parcelLabel, - fn.None[uint32](), + fn.None[uint32](), false, ) _, err = f.cfg.TxSender.RequestShipment(preSignedParcel) if err != nil { diff --git a/tapchannel/aux_sweeper.go b/tapchannel/aux_sweeper.go index 5af5255b8f..d35a6d11b3 100644 --- a/tapchannel/aux_sweeper.go +++ b/tapchannel/aux_sweeper.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapchannelmsg" @@ -336,7 +337,8 @@ func (a *AuxSweeper) createSweepVpackets(sweepInputs []*cmsg.AssetOutput, func (a *AuxSweeper) signSweepVpackets(vPackets []*tappsbt.VPacket, signDesc input.SignDescriptor, tapTweak, ctrlBlock []byte, auxSigDesc lfn.Option[lnwallet.AuxSigDesc], - secondLevelSigIndex lfn.Option[uint32]) error { + secondLevelSigIndex lfn.Option[uint32], + witnessScript []byte) error { // Before we sign below, we also need to generate the tapscript With // the vPackets prepared, we can now sign the output asset we'll create @@ -355,8 +357,12 @@ func (a *AuxSweeper) signSweepVpackets(vPackets []*tappsbt.VPacket, // specific fields. Along the way, we'll apply any relevant // tweaks to generate the key we'll use to verify the // signature. + virtualSignDesc := signDesc + virtualSignDesc.WitnessScript = witnessScript + virtualSignDesc.ControlBlock = ctrlBlock + signingKey, leafToSign := applySignDescToVIn( - signDesc, vIn, &a.cfg.ChainParams, tapTweak, + virtualSignDesc, vIn, &a.cfg.ChainParams, tapTweak, ) // In this case, the witness isn't special, so we'll set the @@ -550,11 +556,42 @@ func (a *AuxSweeper) createAndSignSweepVpackets( return aux.SignDetails.SignDesc }, )(desc.auxSigInfo).UnwrapOr(resReq.SignDesc) + var err error + + witnessScript := desc.witnessScript + if len(witnessScript) == 0 { + witnessScript, err = + desc.scriptTree.WitnessScriptForPath( + desc.scriptPath, + ) + if err != nil { + return lfn.Errf[returnType]( + "unable to derive witness script: %w", + err, + ) + } + } - err := a.signSweepVpackets( - vPkts, signDesc, desc.scriptTree.TapTweak(), - desc.ctrlBlockBytes, desc.auxSigInfo, - desc.secondLevelSigIndex, + ctrlBlockBytes := desc.ctrlBlockBytes + tapTweak := desc.scriptTree.TapTweak() + if len(ctrlBlockBytes) != 0 && len(desc.witnessScript) != 0 { + ctrlBlock, err := txscript.ParseControlBlock( + ctrlBlockBytes, + ) + if err != nil { + return lfn.Errf[returnType]( + "unable to parse control block: %w", + err, + ) + } + + tapTweak = ctrlBlock.RootHash(witnessScript) + } + + err = a.signSweepVpackets( + vPkts, signDesc, tapTweak, ctrlBlockBytes, + desc.auxSigInfo, + desc.secondLevelSigIndex, witnessScript, ) if err != nil { return lfn.Err[returnType](err) @@ -583,8 +620,12 @@ type tapscriptSweepDesc struct { scriptTree input.TapscriptDescriptor + scriptPath input.ScriptPath + ctrlBlockBytes []byte + witnessScript []byte + relativeDelay lfn.Option[uint64] absoluteDelay lfn.Option[uint64] @@ -638,6 +679,7 @@ func commitNoDelaySweepDesc(keyRing *lnwallet.CommitmentKeyRing, return lfn.Ok(tapscriptSweepDescs{ firstLevel: tapscriptSweepDesc{ scriptTree: toRemoteScriptTree, + scriptPath: input.ScriptPathSuccess, relativeDelay: lfn.Some(uint64(csvDelay)), ctrlBlockBytes: ctrlBlockBytes, }, @@ -679,6 +721,7 @@ func commitDelaySweepDesc(keyRing *lnwallet.CommitmentKeyRing, return lfn.Ok(tapscriptSweepDescs{ firstLevel: tapscriptSweepDesc{ scriptTree: toLocalScriptTree, + scriptPath: input.ScriptPathSuccess, relativeDelay: lfn.Some(uint64(csvDelay)), ctrlBlockBytes: ctrlBlockBytes, }, @@ -720,6 +763,7 @@ func commitRevokeSweepDesc(keyRing *lnwallet.CommitmentKeyRing, return lfn.Ok(tapscriptSweepDescs{ firstLevel: tapscriptSweepDesc{ scriptTree: toLocalScriptTree, + scriptPath: input.ScriptPathRevocation, ctrlBlockBytes: ctrlBlockBytes, }, }) @@ -765,6 +809,7 @@ func remoteHtlcTimeoutSweepDesc(originalKeyRing *lnwallet.CommitmentKeyRing, relativeDelay: lfn.Some(uint64(csvDelay)), absoluteDelay: lfn.Some(uint64(htlcExpiry)), scriptTree: htlcScriptTree, + scriptPath: input.ScriptPathTimeout, ctrlBlockBytes: ctrlBlockBytes, }, }) @@ -812,6 +857,7 @@ func remoteHtlcSuccessSweepDesc(originalKeyRing *lnwallet.CommitmentKeyRing, relativeDelay: lfn.Some(uint64(csvDelay)), ctrlBlockBytes: ctrlBlockBytes, scriptTree: htlcScriptTree, + scriptPath: input.ScriptPathSuccess, }, }) } @@ -820,6 +866,7 @@ func remoteHtlcSuccessSweepDesc(originalKeyRing *lnwallet.CommitmentKeyRing, // present on our local commitment transaction. These are second level HTLCs, so // we'll need to perform two stages of sweeps. func localHtlcTimeoutSweepDesc(req lnwallet.ResolutionReq, + keyRing *lnwallet.CommitmentKeyRing, index input.HtlcIndex) lfn.Result[tapscriptSweepDescs] { const isIncoming = false @@ -840,7 +887,7 @@ func localHtlcTimeoutSweepDesc(req lnwallet.ResolutionReq, // We're sweeping an HTLC output, which has a tweaked script key. To be // able to create the correct control block, we need to tweak the key // ring with the index of the HTLC. - tweakedKeyRing := TweakedRevocationKeyRing(req.KeyRing, index) + tweakedKeyRing := TweakedRevocationKeyRing(keyRing, index) // We'll need to complete the control block to spend the second-level // HTLC, so first we'll make the script tree for the HTLC. @@ -877,7 +924,7 @@ func localHtlcTimeoutSweepDesc(req lnwallet.ResolutionReq, // As this is an HTLC on our local commitment transaction, we'll also // need to generate a sweep desc for second level HTLC. secondLevelScriptTree, err := input.TaprootSecondLevelScriptTree( - tweakedKeyRing.RevocationKey, req.KeyRing.ToLocalKey, + tweakedKeyRing.RevocationKey, keyRing.ToLocalKey, req.CommitCsvDelay, lfn.None[txscript.TapLeaf](), ) if err != nil { @@ -897,6 +944,7 @@ func localHtlcTimeoutSweepDesc(req lnwallet.ResolutionReq, secondLevelDesc := tapscriptSweepDesc{ scriptTree: secondLevelScriptTree, + scriptPath: input.ScriptPathSuccess, relativeDelay: lfn.Some(uint64(req.CommitCsvDelay)), ctrlBlockBytes: secondLevelCtrlBlockBytes, } @@ -904,6 +952,7 @@ func localHtlcTimeoutSweepDesc(req lnwallet.ResolutionReq, return lfn.Ok(tapscriptSweepDescs{ firstLevel: tapscriptSweepDesc{ scriptTree: htlcScriptTree, + scriptPath: input.ScriptPathTimeout, ctrlBlockBytes: ctrlBlockBytes, relativeDelay: lfn.Some(uint64(req.CsvDelay)), absoluteDelay: lfn.Some(uint64(htlcExpiry)), @@ -918,6 +967,7 @@ func localHtlcTimeoutSweepDesc(req lnwallet.ResolutionReq, // present on our local commitment transaction that we can sweep with a // preimage. These sweeps take two stages, so we'll add that extra information. func localHtlcSuccessSweepDesc(req lnwallet.ResolutionReq, + keyRing *lnwallet.CommitmentKeyRing, index input.HtlcIndex) lfn.Result[tapscriptSweepDescs] { const isIncoming = true @@ -938,7 +988,7 @@ func localHtlcSuccessSweepDesc(req lnwallet.ResolutionReq, // We're sweeping an HTLC output, which has a tweaked script key. To be // able to create the correct control block, we need to tweak the key // ring with the index of the HTLC. - tweakedKeyRing := TweakedRevocationKeyRing(req.KeyRing, index) + tweakedKeyRing := TweakedRevocationKeyRing(keyRing, index) // We'll need to complete the control block to spend the second-level // HTLC, so first we'll make the script tree for the HTLC. @@ -979,7 +1029,7 @@ func localHtlcSuccessSweepDesc(req lnwallet.ResolutionReq, // As this is an HTLC on our local commitment transaction, we'll also // need to generate a sweep desc for second level HTLC. secondLevelScriptTree, err := input.TaprootSecondLevelScriptTree( - tweakedKeyRing.RevocationKey, req.KeyRing.ToLocalKey, + tweakedKeyRing.RevocationKey, keyRing.ToLocalKey, req.CommitCsvDelay, lfn.None[txscript.TapLeaf](), ) if err != nil { @@ -999,6 +1049,7 @@ func localHtlcSuccessSweepDesc(req lnwallet.ResolutionReq, secondLevelDesc := tapscriptSweepDesc{ scriptTree: secondLevelScriptTree, + scriptPath: input.ScriptPathSuccess, relativeDelay: lfn.Some(uint64(req.CommitCsvDelay)), ctrlBlockBytes: secondLevelCtrlBlockBytes, } @@ -1006,6 +1057,7 @@ func localHtlcSuccessSweepDesc(req lnwallet.ResolutionReq, return lfn.Ok(tapscriptSweepDescs{ firstLevel: tapscriptSweepDesc{ scriptTree: htlcScriptTree, + scriptPath: input.ScriptPathSuccess, ctrlBlockBytes: ctrlBlockBytes, relativeDelay: lfn.Some(uint64(req.CsvDelay)), auxSigInfo: req.AuxSigDesc, @@ -1168,71 +1220,435 @@ func reanchorAssetOutputs(ctx context.Context, return nil } -// anchorOutputAllocations is a helper function that creates a set of -// allocations for the anchor outputs. We'll use this later to create the proper -// exclusion proofs. -func anchorOutputAllocations( - keyRing *lnwallet.CommitmentKeyRing) lfn.Result[[]*tapsend.Allocation] { +// syncCommitOutputProofs updates the stored commitment-state proofs for a set +// of local/remote commitment outputs to use the actual tapscript tree that +// backs that output on the commitment transaction. +func syncCommitOutputProofs(outputs []*cmsg.AssetOutput, + scriptDesc input.ScriptDescriptor) error { + + if len(outputs) == 0 { + return nil + } + + leaves, tree, err := LeavesFromTapscriptScriptTree(scriptDesc) + if err != nil { + return err + } + + var siblingPreimage *commitment.TapscriptPreimage + switch len(leaves) { + case 0: + // No sibling tapscript tree. + + case 1: + siblingPreimage, err = commitment.NewPreimageFromLeaf(leaves[0]) + if err != nil { + return err + } + + default: + rootNode := txscript.AssembleTaprootScriptTree( + leaves..., + ).RootNode + branch, ok := rootNode.(txscript.TapBranch) + if !ok { + return fmt.Errorf( + "expected tapscript root branch, got %T", + rootNode, + ) + } + + preimage := commitment.NewPreimageFromBranch(branch) + siblingPreimage = &preimage + } + + for _, output := range outputs { + p := &output.Proof.Val + p.InclusionProof.InternalKey = tree.InternalKey + if p.InclusionProof.CommitmentProof != nil { + p.InclusionProof.CommitmentProof.TapSiblingPreimage = + siblingPreimage + } + } + + return nil +} + +// commitOutputAllocations creates exclusion-proof allocations for the actual +// pure-BTC outputs of the commitment transaction. This must reflect the real +// commitment tx outputs instead of assuming only anchor outputs exist. +func commitOutputAllocations(req lnwallet.ResolutionReq, + keyRing *lnwallet.CommitmentKeyRing, + vPackets []*tappsbt.VPacket) lfn.Result[[]*tapsend.Allocation] { + + if req.CommitTx == nil { + return lfn.Err[[]*tapsend.Allocation]( + fmt.Errorf("commit tx not set"), + ) + } + + assetOutputs := make(map[uint32]struct{}) + for _, vPkt := range vPackets { + for _, output := range vPkt.Outputs { + assetOutputs[output.AnchorOutputIndex] = struct{}{} + } + } + + findOutputIndex := func(pkScript []byte) (uint32, bool) { + for idx, txOut := range req.CommitTx.TxOut { + if bytes.Equal(txOut.PkScript, pkScript) { + return uint32(idx), true + } + } + + return 0, false + } - anchorAlloc := func( - k *btcec.PublicKey) lfn.Result[*tapsend.Allocation] { + newNoAssetAlloc := func(desc input.ScriptDescriptor) ( + []*tapsend.Allocation, error, + ) { - anchorTree, err := input.NewAnchorScriptTree(k) + sibling, scriptTree, err := LeavesFromTapscriptScriptTree(desc) if err != nil { - return lfn.Err[*tapsend.Allocation](err) + return nil, err } - sibling, scriptTree, err := LeavesFromTapscriptScriptTree( - anchorTree, + pkScript, err := txscript.PayToTaprootScript( + scriptTree.TaprootKey, ) if err != nil { - return lfn.Err[*tapsend.Allocation](err) + return nil, err + } + + outputIndex, ok := findOutputIndex(pkScript) + if !ok { + return nil, nil } - return lfn.Ok(&tapsend.Allocation{ - Type: tapsend.AllocationTypeNoAssets, - Amount: 0, - BtcAmount: lnwallet.AnchorSize, + if _, hasAssets := assetOutputs[outputIndex]; hasAssets { + return nil, nil + } + + return []*tapsend.Allocation{{ + Type: tapsend.AllocationTypeNoAssets, + OutputIndex: outputIndex, + BtcAmount: btcutil.Amount( + req.CommitTx.TxOut[outputIndex].Value, + ), InternalKey: scriptTree.InternalKey, NonAssetLeaves: sibling, SortTaprootKeyBytes: schnorr.SerializePubKey( scriptTree.TaprootKey, ), - }) + }}, nil } - localAnchor := anchorAlloc(keyRing.ToLocalKey) - remoteAnchor := anchorAlloc(keyRing.ToRemoteKey) + toLocalTree, err := input.NewLocalCommitScriptTree( + req.CsvDelay, keyRing.ToLocalKey, keyRing.RevocationKey, + input.NoneTapLeaf(), + ) + if err != nil { + return lfn.Err[[]*tapsend.Allocation](err) + } - type resultType = lfn.Result[[]*tapsend.Allocation] - sortAnchor := func(a1, a2 *tapsend.Allocation) resultType { - // Before we return the anchors, we'll make sure that - // they end up in the right sort order. - scriptCompare := bytes.Compare( - a1.SortTaprootKeyBytes, a2.SortTaprootKeyBytes, + toRemoteTree, err := input.NewRemoteCommitScriptTree( + keyRing.ToRemoteKey, input.NoneTapLeaf(), + ) + if err != nil { + return lfn.Err[[]*tapsend.Allocation](err) + } + + localAnchorTree, err := input.NewAnchorScriptTree(keyRing.ToLocalKey) + if err != nil { + return lfn.Err[[]*tapsend.Allocation](err) + } + + remoteAnchorTree, err := input.NewAnchorScriptTree(keyRing.ToRemoteKey) + if err != nil { + return lfn.Err[[]*tapsend.Allocation](err) + } + + allocations := make([]*tapsend.Allocation, 0, 4) + for _, desc := range []input.ScriptDescriptor{ + toLocalTree, toRemoteTree, localAnchorTree, remoteAnchorTree, + } { + allocs, err := newNoAssetAlloc(desc) + if err != nil { + return lfn.Err[[]*tapsend.Allocation](err) + } + + allocations = append(allocations, allocs...) + } + + return lfn.Ok(allocations) +} + +func commitScriptKeyForRing(req lnwallet.ResolutionReq, + keyRing *lnwallet.CommitmentKeyRing) lfn.Result[asset.ScriptKey] { + + switch req.Type { + case input.TaprootLocalCommitSpend: + return localCommitScriptKey( + keyRing.ToLocalKey, keyRing.RevocationKey, req.CsvDelay, + ) + + case input.TaprootRemoteCommitSpend: + return remoteCommitScriptKey(keyRing.ToRemoteKey) + + case input.TaprootCommitmentRevoke: + csvDelay := req.BreachCsvDelay.UnwrapOr(req.CsvDelay) + return localCommitScriptKey( + keyRing.ToLocalKey, keyRing.RevocationKey, csvDelay, ) - if scriptCompare < 0 { - a1.OutputIndex = 0 - a2.OutputIndex = 1 - } else { - a2.OutputIndex = 0 - a1.OutputIndex = 1 + default: + return lfn.Errf[asset.ScriptKey]("unsupported commit witness "+ + "type: %v", req.Type) + } +} + +func selectCommitmentKeyRing(req lnwallet.ResolutionReq, + outputs []*cmsg.AssetOutput) *lnwallet.CommitmentKeyRing { + + if req.InitialKeyRing == nil || len(outputs) == 0 { + return req.KeyRing + } + + currentKey, err := commitScriptKeyForRing(req, req.KeyRing).Unpack() + if err != nil { + return req.KeyRing + } + + initialKey, err := commitScriptKeyForRing( + req, req.InitialKeyRing, + ).Unpack() + if err != nil { + return req.KeyRing + } + + targetKey := outputs[0].Proof.Val.Asset.ScriptKey.PubKey + switch { + case targetKey.IsEqual(initialKey.PubKey): + return req.InitialKeyRing + + case targetKey.IsEqual(currentKey.PubKey): + return req.KeyRing + + default: + return req.KeyRing + } +} + +func fetchStoredCommitSweepMetadata(outputs []*cmsg.AssetOutput) ([]byte, + []byte, bool) { + + for _, output := range outputs { + unknownOddTypes := output.Proof.Val.UnknownOddTypes + if len(unknownOddTypes) == 0 { + continue + } + + witnessScriptType := commitSweepWitnessScriptType + witnessScript, ok := unknownOddTypes[witnessScriptType] + if !ok { + continue } - return lfn.Ok([]*tapsend.Allocation{a1, a2}) + controlBlock, ok := unknownOddTypes[commitSweepControlBlockType] + if !ok { + continue + } + + return bytes.Clone(witnessScript), + bytes.Clone(controlBlock), true } - return lfn.FlatMapResult( - localAnchor, func(a1 *tapsend.Allocation) resultType { - return lfn.FlatMapResult( - remoteAnchor, - func(a2 *tapsend.Allocation) resultType { - return sortAnchor(a1, a2) - }, + return nil, nil, false +} + +func activeOutputsNeedReanchor(activeOutputs []*cmsg.AssetOutput, + commitTx *wire.MsgTx) bool { + + if commitTx == nil { + return false + } + + for _, activeOutput := range activeOutputs { + if activeOutput.Proof.Val.InclusionProof.OutputIndex >= + uint32(len(commitTx.TxOut)) { + + return true + } + } + + return false +} + +func selectResolveCommitmentKeyRing(req lnwallet.ResolutionReq, + outputs []*cmsg.AssetOutput) *lnwallet.CommitmentKeyRing { + + if req.InitialKeyRing != nil && + activeOutputsNeedReanchor(outputs, req.CommitTx) { + + return req.InitialKeyRing + } + + return selectCommitmentKeyRing(req, outputs) +} + +func isCommitmentOutputResolution(witnessType input.WitnessType) bool { + switch witnessType { + case input.TaprootLocalCommitSpend, + input.TaprootRemoteCommitSpend, + input.TaprootCommitmentRevoke: + return true + + default: + return false + } +} + +func syncActiveVPacketProofsFromOutputs( + vPktsByAssetID map[asset.ID]*tappsbt.VPacket, + activeOutputs []*cmsg.AssetOutput) { + + for _, activeOutput := range activeOutputs { + vPkt := vPktsByAssetID[activeOutput.AssetID.Val] + if vPkt == nil { + continue + } + + // Prefer an exact match on amount+anchor index. In immediate + // force-close flows, the vPacket can still carry a stale anchor + // index before we sync from re-anchored active outputs, so we + // also allow a fallback to a unique amount-only match. + strictIdx := -1 + fallbackIdx := -1 + fallbackCount := 0 + + for outIdx := range vPkt.Outputs { + vOut := vPkt.Outputs[outIdx] + if vOut.Amount != activeOutput.Amount.Val { + continue + } + + fallbackIdx = outIdx + fallbackCount++ + + targetIndex := activeOutput.Proof.Val.InclusionProof. + OutputIndex + if vOut.AnchorOutputIndex != targetIndex { + continue + } + + strictIdx = outIdx + break + } + + matchIdx := strictIdx + if matchIdx == -1 && fallbackCount == 1 { + matchIdx = fallbackIdx + } + if matchIdx == -1 { + continue + } + + vOut := vPkt.Outputs[matchIdx] + updatedProof := activeOutput.Proof.Val + vOut.ProofSuffix = &updatedProof + if vOut.Asset != nil { + vOut.Asset = updatedProof.Asset.Copy() + } + + inclusionProof := updatedProof.InclusionProof + vOut.AnchorOutputIndex = inclusionProof.OutputIndex + vOut.AnchorOutputInternalKey = inclusionProof.InternalKey + vOut.AnchorOutputTapscriptSibling = inclusionProof. + CommitmentProof.TapSiblingPreimage + vOut.ScriptKey = updatedProof.Asset.ScriptKey + } +} + +func syncActiveOutputProofsFromVPackets( + vPktsByAssetID map[asset.ID]*tappsbt.VPacket, + activeOutputs []*cmsg.AssetOutput) { + + for _, activeOutput := range activeOutputs { + vPkt := vPktsByAssetID[activeOutput.AssetID.Val] + if vPkt == nil { + continue + } + + for outIdx := range vPkt.Outputs { + vOut := vPkt.Outputs[outIdx] + if vOut.ProofSuffix == nil { + continue + } + if vOut.Amount != activeOutput.Amount.Val { + continue + } + targetIndex := activeOutput.Proof.Val.InclusionProof. + OutputIndex + if vOut.AnchorOutputIndex != targetIndex { + continue + } + + updatedProof := activeOutput.Proof.Val + updatedProof.Asset = *vOut.ProofSuffix.Asset.Copy() + updatedProof.InclusionProof = + vOut.ProofSuffix.InclusionProof + updatedProof.ExclusionProofs = fn.CopySlice( + vOut.ProofSuffix.ExclusionProofs, ) - }, - ) + updatedProof.SplitRootProof = + vOut.ProofSuffix.SplitRootProof + updatedProof.AdditionalInputs = fn.CopySlice( + vOut.ProofSuffix.AdditionalInputs, + ) + updatedProof.ChallengeWitness = slices.Clone( + vOut.ProofSuffix.ChallengeWitness, + ) + updatedProof.AltLeaves = asset.CopyAltLeaves( + vOut.ProofSuffix.AltLeaves, + ) + updatedProof.UnknownOddTypes = + vOut.ProofSuffix.UnknownOddTypes + activeOutput.Proof.Val = updatedProof + break + } + } +} + +func (a *AuxSweeper) importActiveProofScriptKeys( + activeOutputs []*cmsg.AssetOutput) error { + + ctxb := context.Background() + + for _, activeOutput := range activeOutputs { + scriptKey := activeOutput.Proof.Val.Asset.ScriptKey + if scriptKey.TweakedScriptKey == nil { + scriptKey.TweakedScriptKey = &asset.TweakedScriptKey{ + RawKey: keychain.KeyDescriptor{ + PubKey: scriptKey.PubKey, + }, + } + } + if scriptKey.RawKey.PubKey == nil { + scriptKey.RawKey = keychain.KeyDescriptor{ + PubKey: scriptKey.PubKey, + } + } + + err := a.cfg.AddrBook.InsertScriptKey( + ctxb, scriptKey, asset.ScriptKeyScriptPathChannel, + ) + if err != nil { + return fmt.Errorf("unable to import active proof "+ + "script key: %w", err) + } + } + + return nil } // remoteCommitScriptKey creates the script key for the remote commitment @@ -1285,7 +1701,8 @@ func localCommitScriptKey(localKey, revokeKey *btcec.PublicKey, } // deriveCommitKeys derives the script keys for the local and remote party. -func deriveCommitKeys(req lnwallet.ResolutionReq) (*asset.ScriptKey, +func deriveCommitKeys(req lnwallet.ResolutionReq, + keyRing *lnwallet.CommitmentKeyRing) (*asset.ScriptKey, *asset.ScriptKey, error) { // This might be a breach case we need to handle. In this case, our @@ -1294,7 +1711,7 @@ func deriveCommitKeys(req lnwallet.ResolutionReq) (*asset.ScriptKey, // otherwise, we'll stick with the main one specified. toLocalCsvDelay := req.BreachCsvDelay.UnwrapOr(req.CsvDelay) localScriptTree, err := localCommitScriptKey( - req.KeyRing.ToLocalKey, req.KeyRing.RevocationKey, + keyRing.ToLocalKey, keyRing.RevocationKey, toLocalCsvDelay, ).Unpack() if err != nil { @@ -1303,7 +1720,7 @@ func deriveCommitKeys(req lnwallet.ResolutionReq) (*asset.ScriptKey, } remoteScriptTree, err := remoteCommitScriptKey( - req.KeyRing.ToRemoteKey, + keyRing.ToRemoteKey, ).Unpack() if err != nil { return nil, nil, fmt.Errorf("unable to create remote "+ @@ -1315,11 +1732,12 @@ func deriveCommitKeys(req lnwallet.ResolutionReq) (*asset.ScriptKey, // importCommitScriptKeys imports the script keys for the commitment outputs // into the local addr book. -func (a *AuxSweeper) importCommitScriptKeys(req lnwallet.ResolutionReq) error { +func (a *AuxSweeper) importCommitScriptKeys(req lnwallet.ResolutionReq, + keyRing *lnwallet.CommitmentKeyRing) error { // Generate the local and remote script key, so we can properly import // into the addr book, like we did above. localCommitScriptKey, remoteCommitScriptKey, err := deriveCommitKeys( - req, + req, keyRing, ) if err != nil { return fmt.Errorf("unable to derive script keys: %w", err) @@ -1541,7 +1959,9 @@ func importOutputProofs(scid lnwire.ShortChannelID, // called after a force close to ensure that we can properly spend outputs // created by the commitment transaction at a later step. func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, - commitState *cmsg.Commitment, fundingInfo *cmsg.OpenChannel) error { + commitState *cmsg.Commitment, fundingInfo *cmsg.OpenChannel, + activeOutputs []*cmsg.AssetOutput, + activeScriptTree input.ScriptDescriptor) error { // Just in case we don't know about it already, we'll import the // funding script key. @@ -1572,9 +1992,9 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, // Depending on the close type, we'll import one or both of the script // keys generated above. - if err := a.importCommitScriptKeys(req); err != nil { - return fmt.Errorf("unable to import script keys: %w", err) - } + commitKeyRing := selectCommitmentKeyRing(req, activeOutputs) + useInitialCommitState := req.InitialKeyRing != nil && + commitKeyRing == req.InitialKeyRing // To start, we'll re-create vPackets for all of the outputs of the // commitment transaction. @@ -1664,6 +2084,121 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, } } + if req.CommitTx == nil { + return fmt.Errorf("no commitment transaction found for "+ + "chan_point=%v", req.ChanPoint) + } + if req.InitialKeyRing != nil && + activeOutputsNeedReanchor(activeOutputs, req.CommitTx) { + + useInitialCommitState = true + commitKeyRing = req.InitialKeyRing + } + if err := a.importCommitScriptKeys(req, commitKeyRing); err != nil { + return fmt.Errorf("unable to import script keys: %w", err) + } + + if useInitialCommitState { + toLocalScriptTree, err := input.NewLocalCommitScriptTree( + req.CsvDelay, commitKeyRing.ToLocalKey, + commitKeyRing.RevocationKey, input.NoneTapLeaf(), + ) + if err != nil { + return fmt.Errorf( + "unable to derive to-local script tree: %w", + err, + ) + } + err = syncCommitOutputProofs( + commitState.LocalAssets.Val.Outputs, toLocalScriptTree, + ) + if err != nil { + return fmt.Errorf( + "unable to sync local commit proofs: %w", + err, + ) + } + + toRemoteScriptTree, err := input.NewRemoteCommitScriptTree( + commitKeyRing.ToRemoteKey, input.NoneTapLeaf(), + ) + if err != nil { + return fmt.Errorf( + "unable to derive to-remote script tree: %w", + err, + ) + } + err = syncCommitOutputProofs( + commitState.RemoteAssets.Val.Outputs, + toRemoteScriptTree, + ) + if err != nil { + return fmt.Errorf( + "unable to sync remote commit proofs: %w", + err, + ) + } + if len(activeOutputs) > 0 { + err = syncCommitOutputProofs( + activeOutputs, activeScriptTree, + ) + if err != nil { + return fmt.Errorf("unable to sync active "+ + "commit proofs: %w", err) + } + } + + // Only the first live post-funding commitment state needs this + // proof refresh before re-anchoring to the unilateral-close tx. + for _, outputs := range [][]*cmsg.AssetOutput{ + commitState.LocalAssets.Val.Outputs, + commitState.RemoteAssets.Val.Outputs, + } { + err = reanchorAssetOutputs( + ctxb, a.cfg.ChainBridge, *req.CommitTx, + req.CommitTxBlockHeight, outputs, + ) + if err != nil { + return fmt.Errorf("unable to re-anchor "+ + "commit outputs: %w", err) + } + } + if len(activeOutputs) > 0 { + err = reanchorAssetOutputs( + ctxb, a.cfg.ChainBridge, *req.CommitTx, + req.CommitTxBlockHeight, activeOutputs, + ) + if err != nil { + return fmt.Errorf("unable to re-anchor active "+ + "commit outputs: %w", err) + } + } + + if _, err := commitScriptKeyForRing( + req, commitKeyRing, + ).Unpack(); err == nil && len(activeOutputs) > 0 { + syncActiveVPacketProofsFromOutputs( + vPktsByAssetID, activeOutputs, + ) + } + } + + for _, vPkt := range vPktsByAssetID { + for _, vOut := range vPkt.Outputs { + if vOut.ProofSuffix == nil { + continue + } + + inclusionProof := vOut.ProofSuffix.InclusionProof + vOut.AnchorOutputIndex = inclusionProof.OutputIndex + vOut.AnchorOutputInternalKey = + inclusionProof.InternalKey + vOut.AnchorOutputTapscriptSibling = inclusionProof. + CommitmentProof.TapSiblingPreimage + vOut.ScriptKey = vOut.ProofSuffix.Asset.ScriptKey + } + } + supportSTXO := commitState.STXO.Val // We can now add the witness for the OP_TRUE spend of the commitment @@ -1692,9 +2227,9 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, "commitments: %w", err) } - // With the output commitments known, we can regenerate the proof suffix - // for each vPkt. - anchorAllocations, err := anchorOutputAllocations(req.KeyRing).Unpack() + anchorAllocations, err := commitOutputAllocations( + req, commitKeyRing, vPackets, + ).Unpack() if err != nil { return fmt.Errorf("unable to create anchor "+ "allocations: %w", err) @@ -1713,7 +2248,27 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, "%d: %w", outIdx, err) } - vPkt.Outputs[outIdx].ProofSuffix = proofSuffix + if vPkt.Outputs[outIdx].ProofSuffix != nil { + *vPkt.Outputs[outIdx].ProofSuffix = *proofSuffix + } else { + vPkt.Outputs[outIdx].ProofSuffix = proofSuffix + } + } + } + + // For the first live post-funding commitment state, the active output + // will later be swept directly from the imported commitment proof. Make + // sure we keep the final proof suffix, including the regenerated + // exclusion proofs, in sync with the active output we hand back to the + // resolver and archive as ours. + if useInitialCommitState && len(activeOutputs) > 0 { + syncActiveOutputProofsFromVPackets( + vPktsByAssetID, activeOutputs, + ) + + err = a.importActiveProofScriptKeys(activeOutputs) + if err != nil { + return err } } @@ -1731,7 +2286,7 @@ func (a *AuxSweeper) importCommitTx(req lnwallet.ResolutionReq, return shipChannelTxn( a.cfg.TxSender, req.CommitTx, outCommitments, vPackets, - int64(req.CommitFee), heightHint, + int64(req.CommitFee), heightHint, true, ) } @@ -1788,7 +2343,10 @@ func (a *AuxSweeper) resolveContract( // First, we'll make a sweep desc for the commitment txn. This // contains the tapscript tree, and also the control block // needed for a valid spend. - sweepDesc = commitNoDelaySweepDesc(req.KeyRing, req.CsvDelay) + commitKeyRing := selectResolveCommitmentKeyRing( + req, assetOutputs, + ) + sweepDesc = commitNoDelaySweepDesc(commitKeyRing, req.CsvDelay) // A normal delay output. This means we force closed, so we'll need to // mind the CSV when we sweep the output. @@ -1800,7 +2358,10 @@ func (a *AuxSweeper) resolveContract( // Next, we'll make a sweep desc for this output. It's // dependent on the CSV delay we have in this channel, so we'll // pass that in as well. - sweepDesc = commitDelaySweepDesc(req.KeyRing, req.CsvDelay) + commitKeyRing := selectResolveCommitmentKeyRing( + req, assetOutputs, + ) + sweepDesc = commitDelaySweepDesc(commitKeyRing, req.CsvDelay) // The remote party has breached the channel. We'll sweep the revoked // key that we learned in the past. @@ -1812,7 +2373,10 @@ func (a *AuxSweeper) resolveContract( // Next, we'll make a sweep desk capable of sweeping the remote // party's local output. - sweepDesc = commitRevokeSweepDesc(req.KeyRing, req.CsvDelay) + commitKeyRing := selectResolveCommitmentKeyRing( + req, assetOutputs, + ) + sweepDesc = commitRevokeSweepDesc(commitKeyRing, req.CsvDelay) // The remote party broadcasted a commitment transaction which held an // HTLC that we can timeout eventually. @@ -1824,6 +2388,8 @@ func (a *AuxSweeper) resolveContract( htlcID := req.HtlcID.UnwrapOr(math.MaxUint64) htlcOutputs := commitState.OutgoingHtlcAssets.Val assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + resolveKeyRing := selectResolveCommitmentKeyRing + commitKeyRing := resolveKeyRing(req, assetOutputs) payHash, err := req.PayHash.UnwrapOrErr(errNoPayHash) if err != nil { @@ -1833,7 +2399,7 @@ func (a *AuxSweeper) resolveContract( // Now that we know which output we'll be sweeping, we'll make a // sweep desc for the timeout txn. sweepDesc = remoteHtlcTimeoutSweepDesc( - req.KeyRing, payHash[:], req.CsvDelay, + commitKeyRing, payHash[:], req.CsvDelay, req.CltvDelay.UnwrapOr(0), htlcID, ) @@ -1846,6 +2412,8 @@ func (a *AuxSweeper) resolveContract( htlcID := req.HtlcID.UnwrapOr(math.MaxUint64) htlcOutputs := commitState.IncomingHtlcAssets.Val assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + resolveKeyRing := selectResolveCommitmentKeyRing + commitKeyRing := resolveKeyRing(req, assetOutputs) payHash, err := req.PayHash.UnwrapOrErr(errNoPayHash) if err != nil { @@ -1855,7 +2423,7 @@ func (a *AuxSweeper) resolveContract( // Now that we know which output we'll be sweeping, we'll make a // sweep desc for the timeout txn. sweepDesc = remoteHtlcSuccessSweepDesc( - req.KeyRing, payHash[:], req.CsvDelay, htlcID, + commitKeyRing, payHash[:], req.CsvDelay, htlcID, ) // In this case, we broadcast a commitment transaction which held an @@ -1868,10 +2436,14 @@ func (a *AuxSweeper) resolveContract( htlcID := req.HtlcID.UnwrapOr(math.MaxUint64) htlcOutputs := commitState.OutgoingHtlcAssets.Val assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + resolveKeyRing := selectResolveCommitmentKeyRing + commitKeyRing := resolveKeyRing(req, assetOutputs) // With the output and pay desc located, we'll now create the // sweep desc. - sweepDesc = localHtlcTimeoutSweepDesc(req, htlcID) + sweepDesc = localHtlcTimeoutSweepDesc( + req, commitKeyRing, htlcID, + ) needsSecondLevel = true @@ -1883,10 +2455,14 @@ func (a *AuxSweeper) resolveContract( htlcID := req.HtlcID.UnwrapOr(math.MaxUint64) htlcOutputs := commitState.IncomingHtlcAssets.Val assetOutputs = htlcOutputs.FilterByHtlcIndex(htlcID) + resolveKeyRing := selectResolveCommitmentKeyRing + commitKeyRing := resolveKeyRing(req, assetOutputs) // With the output and pay desc located, we'll now create the // sweep desc. - sweepDesc = localHtlcSuccessSweepDesc(req, htlcID) + sweepDesc = localHtlcSuccessSweepDesc( + req, commitKeyRing, htlcID, + ) needsSecondLevel = true @@ -1906,6 +2482,20 @@ func (a *AuxSweeper) resolveContract( if err != nil { return lfn.Err[tlv.Blob](err) } + // Only reuse stored commit-sweep metadata for the first post-funding + // commitment path where outputs still need re-anchoring to the real + // unilateral close transaction. For later commitment states we derive + // fresh sweep descriptors from the resolver context. + if activeOutputsNeedReanchor(assetOutputs, req.CommitTx) { + fetchSweepMetadata := fetchStoredCommitSweepMetadata + witnessScript, controlBlock, ok := fetchSweepMetadata( + assetOutputs, + ) + if ok { + tapSweepDesc.firstLevel.witnessScript = witnessScript + tapSweepDesc.firstLevel.ctrlBlockBytes = controlBlock + } + } // Now that we know what output we're sweeping, before we proceed, we'll // import the relevant script key to disk. This way, we'll properly @@ -1930,7 +2520,10 @@ func (a *AuxSweeper) resolveContract( log.Infof("First time seeing commit_txid=%v, importing", req.CommitTx.TxHash()) - err := a.importCommitTx(req, commitState, fundingInfo) + err := a.importCommitTx( + req, commitState, fundingInfo, assetOutputs, + tapSweepDesc.firstLevel.scriptTree, + ) if err != nil { return lfn.Errf[returnType]("unable to import "+ "commitment txn: %w", err) @@ -1939,19 +2532,24 @@ func (a *AuxSweeper) resolveContract( log.Infof("Commitment commit_txid=%v already imported, "+ "skipping", req.CommitTx.TxHash()) } - if req.CommitTx == nil { return lfn.Errf[returnType]("no commitment transaction "+ "found for chan_point=%v", req.ChanPoint) } commitTx := *req.CommitTx - // The input proofs above were made originally using the fake commit tx - // as an anchor. We now know the real commit tx, so we'll bind each - // proof to the actual commitment output that carries the asset. + if isCommitmentOutputResolution(req.Type) { + if err := syncCommitOutputProofs( + assetOutputs, tapSweepDesc.firstLevel.scriptTree, + ); err != nil { + return lfn.Errf[returnType]( + "unable to sync asset output proofs: %w", err, + ) + } + } if err := reanchorAssetOutputs( - ctx, a.cfg.ChainBridge, commitTx, req.CommitTxBlockHeight, - assetOutputs, + ctx, a.cfg.ChainBridge, commitTx, + req.CommitTxBlockHeight, assetOutputs, ); err != nil { return lfn.Errf[returnType]("unable to re-anchor asset "+ "outputs: %w", err) @@ -1969,7 +2567,6 @@ func (a *AuxSweeper) resolveContract( if err != nil { return lfn.Err[tlv.Blob](err) } - type packetList = []*tappsbt.VPacket var ( secondLevelPkts packetList @@ -2506,6 +3103,7 @@ func (a *AuxSweeper) registerAndBroadcastSweep(req *sweep.BumpRequest, tapSigDesc.TapTweak.Val, tapSigDesc.CtrlBlock.Val, lfn.None[lnwallet.AuxSigDesc](), lfn.None[uint32](), + sweepSet.btcInput.SignDesc().WitnessScript, ) if err != nil { return fmt.Errorf("unable to sign second level "+ @@ -2572,25 +3170,23 @@ func (a *AuxSweeper) registerAndBroadcastSweep(req *sweep.BumpRequest, log.Infof("Proofs generated for sweep_tx=%v", limitSpewer.Sdump(sweepTx)) - // Add a best-effort height hint for sweep transactions. If the sweep is - // mined quickly, this helps the confirmation registration catch up - // deterministically when we hand the parcel to the porter. - heightHint := fn.None[uint32]() - currentHeight, err := a.cfg.ChainBridge.CurrentHeight( - context.Background(), - ) - if err != nil { - log.Warnf("Unable to fetch current height for sweep tx %v "+ - "height hint: %v", sweepTx.TxHash(), err) - } else { - heightHint = fn.Some(currentHeight) - } + // Sweep transactions are externally broadcast by lnd first, then + // handed to the porter for proof archival. Use a historical hint so + // the porter can still detect a confirmation if the sweep makes it on + // chain before the porter registers its notifier. lnd requires the hint + // to be strictly greater than zero. + heightHint := fn.Some[uint32](1) // With the output commitments re-created, we have all we need to log // and ship the transaction. + // + // Sweep transactions are assembled by lnd before they reach this path. + // We still run the porter's proof checks here so malformed proofs fail + // loudly and deterministically. + skipOutputProofVerify := false return shipChannelTxn( a.cfg.TxSender, sweepTx, outCommitments, allVpkts, int64(fee), - heightHint, + heightHint, skipOutputProofVerify, ) } diff --git a/tapchannel/commitment.go b/tapchannel/commitment.go index e96bccd0ca..7296eb0cd0 100644 --- a/tapchannel/commitment.go +++ b/tapchannel/commitment.go @@ -29,9 +29,34 @@ import ( "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" "golang.org/x/exp/maps" ) +const ( + commitSweepWitnessScriptType tlv.Type = 31337 + commitSweepControlBlockType tlv.Type = 31339 +) + +func setCommitSweepMetadata(a *tapsend.Allocation, p *proof.Proof) { + if len(a.CommitmentWitnessScript) == 0 || + len(a.CommitmentControlBlock) == 0 || p == nil { + + return + } + + if p.UnknownOddTypes == nil { + p.UnknownOddTypes = make(tlv.TypeMap) + } + + p.UnknownOddTypes[commitSweepWitnessScriptType] = bytes.Clone( + a.CommitmentWitnessScript, + ) + p.UnknownOddTypes[commitSweepControlBlockType] = bytes.Clone( + a.CommitmentControlBlock, + ) +} + // DecodedDescriptor is a wrapper around a PaymentDescriptor that also includes // the decoded asset balances of the HTLC to avoid multiple decoding round // trips. @@ -589,6 +614,11 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, "packets: %w", err) } + allocationsByOutput := make(map[uint32]*tapsend.Allocation) + for _, allocation := range allocations { + allocationsByOutput[allocation.OutputIndex] = allocation + } + var ( opts []tapsend.OutputCommitmentOption proofOpts []proof.GenOption @@ -644,6 +674,9 @@ func GenerateCommitmentAllocations(prevState *cmsg.Commitment, outIdx, err) } + allocation := allocationsByOutput[vPkt.Outputs[outIdx]. + AnchorOutputIndex] + setCommitSweepMetadata(allocation, proofSuffix) vPkt.Outputs[outIdx].ProofSuffix = proofSuffix } } @@ -1014,6 +1047,33 @@ func addCommitmentOutputs(chanType channeldb.ChannelType, localChanCfg, return fmt.Errorf("error creating to local script "+ "sibling: %w", err) } + toLocalTapscript, ok := toLocalScript.(input. + TapscriptDescriptor) + if !ok { + return fmt.Errorf( + "expected tapscript descriptor, got %T", + toLocalScript, + ) + } + localWitnessScript, err := toLocalScript.WitnessScriptForPath( + input.ScriptPathSuccess, + ) + if err != nil { + return fmt.Errorf("error creating to local witness "+ + "script: %w", err) + } + localCtrlBlock, err := toLocalTapscript.CtrlBlockForPath( + input.ScriptPathSuccess, + ) + if err != nil { + return fmt.Errorf("error creating to local control "+ + "block: %w", err) + } + localCtrlBlockBytes, err := localCtrlBlock.ToBytes() + if err != nil { + return fmt.Errorf("error encoding to local control "+ + "block: %w", err) + } scriptKey := asset.ScriptKey{ PubKey: asset.NewScriptKey( @@ -1034,7 +1094,15 @@ func addCommitmentOutputs(chanType channeldb.ChannelType, localChanCfg, BtcAmount: ourBalance, InternalKey: toLocalTree.InternalKey, NonAssetLeaves: sibling, - GenScriptKey: tapsend.StaticScriptKeyGen(scriptKey), + GenScriptKey: tapsend.StaticScriptKeyGen( + scriptKey, + ), + CommitmentWitnessScript: bytes.Clone( + localWitnessScript, + ), + CommitmentControlBlock: bytes.Clone( + localCtrlBlockBytes, + ), SortTaprootKeyBytes: schnorr.SerializePubKey( toLocalTree.TaprootKey, ), @@ -1080,6 +1148,33 @@ func addCommitmentOutputs(chanType channeldb.ChannelType, localChanCfg, return fmt.Errorf("error creating to remote script "+ "sibling: %w", err) } + toRemoteTapscript, ok := toRemoteScript.(input. + TapscriptDescriptor) + if !ok { + return fmt.Errorf( + "expected tapscript descriptor, got %T", + toRemoteScript, + ) + } + remoteWitnessScript, err := toRemoteScript.WitnessScriptForPath( + input.ScriptPathSuccess, + ) + if err != nil { + return fmt.Errorf("error creating to remote witness "+ + "script: %w", err) + } + remoteCtrlBlock, err := toRemoteTapscript.CtrlBlockForPath( + input.ScriptPathSuccess, + ) + if err != nil { + return fmt.Errorf("error creating to remote control "+ + "block: %w", err) + } + remoteCtrlBlockBytes, err := remoteCtrlBlock.ToBytes() + if err != nil { + return fmt.Errorf("error encoding to remote control "+ + "block: %w", err) + } scriptKey := asset.ScriptKey{ PubKey: asset.NewScriptKey( @@ -1100,7 +1195,15 @@ func addCommitmentOutputs(chanType channeldb.ChannelType, localChanCfg, BtcAmount: theirBalance, InternalKey: toRemoteTree.InternalKey, NonAssetLeaves: sibling, - GenScriptKey: tapsend.StaticScriptKeyGen(scriptKey), + GenScriptKey: tapsend.StaticScriptKeyGen( + scriptKey, + ), + CommitmentWitnessScript: bytes.Clone( + remoteWitnessScript, + ), + CommitmentControlBlock: bytes.Clone( + remoteCtrlBlockBytes, + ), SortTaprootKeyBytes: schnorr.SerializePubKey( toRemoteTree.TaprootKey, ), diff --git a/tapfreighter/chain_porter.go b/tapfreighter/chain_porter.go index 0dd2dd221b..7adba6d996 100644 --- a/tapfreighter/chain_porter.go +++ b/tapfreighter/chain_porter.go @@ -419,9 +419,22 @@ func (p *ChainPorter) waitForTransferTxConf(pkg *sendPackage) error { txHash := outboundPkg.AnchorTx.TxHash() log.Infof("Waiting for confirmation of transfer_txid=%v", txHash) + confPkScript := outboundPkg.AnchorTx.TxOut[0].PkScript + switch { + case len(outboundPkg.Outputs) > 0 && + len(outboundPkg.Outputs[0].Anchor.PkScript) != 0: + + confPkScript = outboundPkg.Outputs[0].Anchor.PkScript + + case outboundPkg.PassiveAssetsAnchor != nil && + len(outboundPkg.PassiveAssetsAnchor.PkScript) != 0: + + confPkScript = outboundPkg.PassiveAssetsAnchor.PkScript + } + confCtx, confCancel := p.WithCtxQuitNoTimeout() confNtfn, errChan, err := p.cfg.ChainBridge.RegisterConfirmationsNtfn( - confCtx, &txHash, outboundPkg.AnchorTx.TxOut[0].PkScript, 1, + confCtx, &txHash, confPkScript, 1, outboundPkg.AnchorTxHeightHint, true, nil, ) if err != nil { @@ -432,31 +445,82 @@ func (p *ChainPorter) waitForTransferTxConf(pkg *sendPackage) error { // Launch a goroutine that'll notify us when the transaction confirms. defer confCancel() + scanHeight := outboundPkg.AnchorTxHeightHint + findChainConf := func() (*chainntnfs.TxConfirmation, error) { + scanCtx, cancel := context.WithTimeout(confCtx, time.Second) + defer cancel() + + currentHeight, err := p.cfg.ChainBridge.CurrentHeight(scanCtx) + if err != nil { + return nil, err + } + + startHeight := scanHeight + if startHeight == 0 || startHeight > currentHeight { + startHeight = currentHeight + } + for height := startHeight; height <= currentHeight; height++ { + block, err := p.cfg.ChainBridge.GetBlockByHeight( + scanCtx, int64(height), + ) + if err != nil { + return nil, err + } + + for idx, blockTx := range block.Transactions { + if blockTx.TxHash() != txHash { + continue + } + + blockHash := block.BlockHash() + scanHeight = height + 1 + + return &chainntnfs.TxConfirmation{ + BlockHash: &blockHash, + BlockHeight: height, + TxIndex: uint32(idx), + Tx: blockTx, + Block: block, + }, nil + } + } + + scanHeight = currentHeight + 1 + return nil, nil + } + var confEvent *chainntnfs.TxConfirmation - select { - case confEvent = <-confNtfn.Confirmed: - log.Debugf("Got chain confirmation: %v", confEvent.Tx.TxHash()) - pkg.TransferTxConfEvent = confEvent - - // If the anchoring tx block hash is given, we'll also store it - // in the outbound package. - pkg.OutboundPkg.AnchorTxBlockHash = fn.MaybeSome( - confEvent.BlockHash, - ) - pkg.OutboundPkg.AnchorTxBlockHeight = confEvent.BlockHeight + walletConfTicker := time.NewTicker(200 * time.Millisecond) + defer walletConfTicker.Stop() - pkg.SendState = SendStateStorePostAnchorTxConf + for confEvent == nil { + select { + case confEvent = <-confNtfn.Confirmed: + log.Debugf("Got chain confirmation: %v", + confEvent.Tx.TxHash()) + + case err := <-errChan: + return fmt.Errorf( + "error whilst waiting for package tx "+ + "confirmation: %w", err, + ) - case err := <-errChan: - return fmt.Errorf("error whilst waiting for package tx "+ - "confirmation: %w", err) + case <-walletConfTicker.C: + confEvent, err = findChainConf() + if err != nil { + return fmt.Errorf( + "chain confirmation fallback "+ + "failed: %w", err, + ) + } - case <-confCtx.Done(): - log.Debugf("Skipping TX confirmation, context done") + case <-confCtx.Done(): + log.Debugf("Skipping TX confirmation, context done") - case <-p.Quit: - log.Debugf("Skipping TX confirmation, exiting") - return nil + case <-p.Quit: + log.Debugf("Skipping TX confirmation, exiting") + return nil + } } if confEvent == nil { @@ -464,6 +528,13 @@ func (p *ChainPorter) waitForTransferTxConf(pkg *sendPackage) error { "in batch") } + pkg.TransferTxConfEvent = confEvent + pkg.OutboundPkg.AnchorTxBlockHash = fn.MaybeSome( + confEvent.BlockHash, + ) + pkg.OutboundPkg.AnchorTxBlockHeight = confEvent.BlockHeight + pkg.SendState = SendStateStorePostAnchorTxConf + return nil } @@ -621,7 +692,13 @@ func (p *ChainPorter) storeProofs(sendPkg *sendPackage) error { ctx, vCtx, outputProof, ) if err != nil { - return fmt.Errorf("error verifying proof: %w", err) + assetID := parsedSuffix.Asset.ID() + return fmt.Errorf("error verifying proof "+ + "(output_idx=%d, amount=%d, asset_id=%x, "+ + "anchor_outpoint=%v): %w", idx, + parsedSuffix.Asset.Amount, + assetID[:], + parsedSuffix.OutPoint(), err) } // Import proof into proof archive. @@ -1413,7 +1490,7 @@ func (p *ChainPorter) prelimCheckAddrParcel(addrParcel AddressParcel) error { // verifyVPacketsPreBroadcast performs verification checks on the given virtual // packets before the anchor transaction is broadcast. func (p *ChainPorter) verifyVPacketsPreBroadcast(ctx context.Context, - packets []*tappsbt.VPacket) error { + packets []*tappsbt.VPacket, skipOutputProofVerify bool) error { headerVerifier := tapgarden.GenHeaderVerifier(ctx, p.cfg.ChainBridge) vCtx := proof.VerifierCtx{ @@ -1441,14 +1518,21 @@ func (p *ChainPorter) verifyVPacketsPreBroadcast(ctx context.Context, "witnesses (vpkt_idx=%d): %w", pktIdx, err) } - // Partially verify the packet's output proofs. - for outIdx := range vPkt.Outputs { - err := p.verifyOutputProofPreBroadcast( - ctx, vCtx, verifier, vPkt, pktIdx, outIdx, - ) - if err != nil { - return fmt.Errorf("verify output proofs "+ - "(vpkt_idx=%d): %w", pktIdx, err) + if !skipOutputProofVerify { + // Partially verify the packet's output proofs. + for outIdx := range vPkt.Outputs { + err := p.verifyOutputProofPreBroadcast( + ctx, vCtx, verifier, vPkt, pktIdx, + outIdx, + ) + if err != nil { + return fmt.Errorf( + "verify output proofs "+ + "(vpkt_idx=%d): %w", + pktIdx, + err, + ) + } } } } @@ -1908,7 +1992,9 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { allPackets = append(allPackets, currentPkg.VirtualPackets...) allPackets = append(allPackets, currentPkg.PassiveAssets...) - err := p.verifyVPacketsPreBroadcast(ctx, allPackets) + err := p.verifyVPacketsPreBroadcast( + ctx, allPackets, currentPkg.SkipOutputProofVerify, + ) if err != nil { p.unlockInputs(ctx, ¤tPkg) diff --git a/tapfreighter/parcel.go b/tapfreighter/parcel.go index e7d335e241..cb66ca16df 100644 --- a/tapfreighter/parcel.go +++ b/tapfreighter/parcel.go @@ -436,6 +436,11 @@ type PreAnchoredParcel struct { // anchorTxHeightHint is an optional height hint for the anchor // transaction. anchorTxHeightHint fn.Option[uint32] + + // skipOutputProofVerify skips pre-broadcast output proof verification. + // This is intended for workflows that import already-confirmed anchor + // transactions and rewrite their output proofs before archival. + skipOutputProofVerify bool } // A compile-time assertion to ensure PreAnchoredParcel implements the Parcel @@ -446,7 +451,8 @@ var _ Parcel = (*PreAnchoredParcel)(nil) func NewPreAnchoredParcel(vPackets []*tappsbt.VPacket, passiveAssets []*tappsbt.VPacket, anchorTx *tapsend.AnchorTransaction, skipAnchorTxBroadcast bool, label string, - anchorTxHeightHint fn.Option[uint32]) *PreAnchoredParcel { + anchorTxHeightHint fn.Option[uint32], + skipOutputProofVerify bool) *PreAnchoredParcel { return &PreAnchoredParcel{ parcelKit: &parcelKit{ @@ -459,6 +465,7 @@ func NewPreAnchoredParcel(vPackets []*tappsbt.VPacket, skipAnchorTxBroadcast: skipAnchorTxBroadcast, label: label, anchorTxHeightHint: anchorTxHeightHint, + skipOutputProofVerify: skipOutputProofVerify, } } @@ -477,6 +484,7 @@ func (p *PreAnchoredParcel) pkg() *sendPackage { AnchorTx: p.anchorTx, Label: p.label, SkipAnchorTxBroadcast: p.skipAnchorTxBroadcast, + SkipOutputProofVerify: p.skipOutputProofVerify, } } @@ -589,6 +597,9 @@ type sendPackage struct { // broadcast should be skipped. Useful when an external system handles // broadcasting, such as in custom transaction packaging workflows. SkipAnchorTxBroadcast bool + + // SkipOutputProofVerify skips pre-broadcast output proof verification. + SkipOutputProofVerify bool } // ConvertToTransfer prepares the finished send data for storing to the database @@ -829,6 +840,14 @@ func outputAnchor(anchorTx *tapsend.AnchorTransaction, vOut *tappsbt.VOutput, "preimage: %w", err) } + outputCount := uint32(len(anchorTx.FundedPsbt.Pkt.Outputs)) + if vOut.AnchorOutputIndex >= outputCount { + return nil, fmt.Errorf( + "anchor output index %d out of range for "+ + "funded psbt outputs (len=%d)", + vOut.AnchorOutputIndex, outputCount, + ) + } anchorOut := &anchorTx.FundedPsbt.Pkt.Outputs[vOut.AnchorOutputIndex] merkleRoot := tappsbt.ExtractCustomField( anchorOut.Unknowns, tappsbt.PsbtKeyTypeOutputTaprootMerkleRoot, @@ -857,6 +876,14 @@ func outputAnchor(anchorTx *tapsend.AnchorTransaction, vOut *tappsbt.VOutput, } } + finalOutputCount := uint32(len(anchorTx.FinalTx.TxOut)) + if vOut.AnchorOutputIndex >= finalOutputCount { + return nil, fmt.Errorf( + "anchor output index %d out of range for "+ + "final tx outputs (len=%d)", + vOut.AnchorOutputIndex, finalOutputCount, + ) + } txOut := anchorTx.FinalTx.TxOut[vOut.AnchorOutputIndex] return &Anchor{ OutPoint: wire.OutPoint{ diff --git a/tapsend/allocation.go b/tapsend/allocation.go index 33c52137e5..d6ff7b6284 100644 --- a/tapsend/allocation.go +++ b/tapsend/allocation.go @@ -230,6 +230,16 @@ type Allocation struct { // data-carrying leaves are used for a purpose distinct from // representing individual Taproot Assets. AltLeaves []asset.AltLeaf[asset.Asset] + + // CommitmentWitnessScript is the exact tapscript leaf used to spend a + // commitment output that carries assets. This is persisted into the + // asset proof metadata so later sweeps can reuse the original state's + // witness path. + CommitmentWitnessScript []byte + + // CommitmentControlBlock is the exact control block corresponding to + // CommitmentWitnessScript. + CommitmentControlBlock []byte } // Validate checks that the allocation is correctly set up and that the fields From 9093b73ea8ec3badb0684d5d947a17e41c7839ef Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 13 May 2026 13:18:53 +0000 Subject: [PATCH 3/3] itest: add immediate post-funding force-close scenario --- itest/custom_channels/breach_test.go | 20 ++- itest/custom_channels/custom_channels_test.go | 4 + itest/custom_channels/immediate_close_test.go | 164 ++++++++++++++++++ 3 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 itest/custom_channels/immediate_close_test.go diff --git a/itest/custom_channels/breach_test.go b/itest/custom_channels/breach_test.go index 4c41a63044..dc73994330 100644 --- a/itest/custom_channels/breach_test.go +++ b/itest/custom_channels/breach_test.go @@ -195,21 +195,25 @@ func testCustomChannelsBreach(ctx context.Context, locateAssetTransfers(t.t, dave, *breachTxid) // With the breach transaction mined, Charlie should now have a - // transaction in the mempool sweeping *both* commitment outputs. - // We use a generous timeout because Charlie needs to process the - // block, detect the breach, and construct the justice transaction. - charlieJusticeTxid, err := waitForNTxsInMempool( - net.Miner, 1, time.Second*30, + // transaction in the mempool sweeping commitment outputs. + // We wait for at least one tx as other background txns can race in. + charlieJusticeTxid, err := waitForAtLeastNTxsInMempool( + net.Miner, 1, time.Second*60, ) require.NoError(t.t, err) t.Logf("Charlie justice txid: %v", charlieJusticeTxid) // Next, we'll mine a block to confirm Charlie's justice transaction. - mineBlocks(t, net, 1, 1) + // Use the mined txid (not the mempool txid), to avoid RBF mismatch. + justiceBlocks := mineBlocks(t, net, 1, 1) + minedJusticeTxHash := justiceBlocks[0].Transactions[1].TxHash() + + t.Logf("Charlie mined justice txid: %v", minedJusticeTxHash) - // Charlie should now have a transfer for his justice transaction. - locateAssetTransfers(t.t, charlie, *charlieJusticeTxid[0]) + // Mine additional blocks so all breach-resolution transfers are fully + // confirmed before asserting final balances. + mineBlocks(t, net, 6, 0) // Charlie's balance should now be the same as before the breach // attempt: the amount he minted at the very start. diff --git a/itest/custom_channels/custom_channels_test.go b/itest/custom_channels/custom_channels_test.go index 4261f8c621..9a88eb8c6b 100644 --- a/itest/custom_channels/custom_channels_test.go +++ b/itest/custom_channels/custom_channels_test.go @@ -35,6 +35,10 @@ var testCases = []*ccTestCase{ name: "force close", test: testCustomChannelsForceClose, }, + { + name: "immediate close", + test: testCustomChannelsImmediateClose, + }, { name: "group tranches force close", test: testCustomChannelsGroupTranchesForceClose, diff --git a/itest/custom_channels/immediate_close_test.go b/itest/custom_channels/immediate_close_test.go new file mode 100644 index 0000000000..cfa6016984 --- /dev/null +++ b/itest/custom_channels/immediate_close_test.go @@ -0,0 +1,164 @@ +//go:build itest + +package custom_channels + +import ( + "context" + "fmt" + "slices" + + "github.com/lightninglabs/taproot-assets/itest" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" + "github.com/lightninglabs/taproot-assets/tapscript" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntest/node" + "github.com/lightningnetwork/lnd/lntest/port" + "github.com/stretchr/testify/require" +) + +// testCustomChannelsImmediateClose tests that an asset channel can be funded +// and then force closed immediately after the funding transaction confirms, +// without any in-channel asset movement. +func testCustomChannelsImmediateClose(ctx context.Context, + net *itest.IntegratedNetworkHarness, t *ccHarnessTest) { + + lndArgs := slices.Clone(lndArgsTemplate) + tapdArgs := slices.Clone(tapdArgsTemplate) + + // We use Alice as the Universe proof courier and also as the funder, so + // we pin her RPC listen port up front. + alicePort := port.NextAvailablePort() + tapdArgs = append(tapdArgs, fmt.Sprintf( + "--proofcourieraddr=%s://%s", + proof.UniverseRpcCourierType, + fmt.Sprintf(node.ListenerFormat, alicePort), + )) + + aliceLndArgs := slices.Clone(lndArgs) + aliceLndArgs = append(aliceLndArgs, fmt.Sprintf( + "--rpclisten=127.0.0.1:%d", alicePort, + )) + alice := net.NewNode("Alice", aliceLndArgs, tapdArgs) + bob := net.NewNode("Bob", lndArgs, tapdArgs) + charlie := net.NewNode("Charlie", lndArgs, tapdArgs) + + nodes := []*itest.IntegratedNode{alice, bob, charlie} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, net.Miner, asTapd(alice), + []*mintrpc.MintAssetRequest{ + { + Asset: ccItestAsset, + }, + }, + ) + cents := mintedAssets[0] + assetID := cents.AssetGenesis.AssetId + + t.Logf("Minted %d lightning cents, syncing universes...", cents.Amount) + syncUniverses(t.t, alice, bob) + syncUniverses(t.t, alice, charlie) + + t.Logf("Opening asset channel...") + fundResp, err := asTapd(alice).FundChannel( + ctx, &tchrpc.FundChannelRequest{ + AssetAmount: fundingAmount, + AssetId: assetID, + PeerPubkey: bob.PubKey[:], + FeeRateSatPerVbyte: 5, + }, + ) + require.NoError(t.t, err) + + chanPoint := &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundResp.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundResp.Txid, + }, + } + + mineBlocks(t, net, 6, 1) + + fundingScriptTree := tapscript.NewChannelFundingScriptTree() + fundingScriptKey := fundingScriptTree.TaprootKey + assertUniverseProofExists( + t.t, alice, assetID, nil, + fundingScriptKey.SerializeCompressed(), + fmt.Sprintf("%v:%v", fundResp.Txid, fundResp.OutputIndex), + ) + assertAssetChan( + t.t, alice, bob, fundingAmount, []*taprpc.Asset{cents}, + ) + + t.Logf("Force closing asset channel immediately after confirmation...") + _, _, err = net.CloseChannel(alice, chanPoint, true) + require.NoError(t.t, err) + + // The channel first enters waiting close until the commitment + // transaction confirms. + assertWaitingCloseChannelAssetData(t.t, alice, chanPoint) + mineBlocks(t, net, 1, 1) + + // After confirmation, Alice should enter pending force close. Unlike the + // cooperative close path, the local force close commitment transaction + // itself is not immediately tracked as an asset transfer for Alice. + assertPendingForceCloseChannelAssetData(t.t, alice, chanPoint) + + // With no remote asset balance, Alice eventually sweeps the local output + // after the CSV delay and regains the full on-chain balance. + mineBlocks(t, net, 4, 0) + + aliceSweepTxid, err := waitForNTxsInMempool( + net.Miner, 1, ccShortTimeout, + ) + require.NoError(t.t, err) + + t.Logf("Alice sweep txid: %v", aliceSweepTxid) + + aliceSweepBlocks := mineBlocks(t, net, 1, 1) + aliceSweepTxHash := aliceSweepBlocks[0].Transactions[1].TxHash() + + locateAssetTransfers(t.t, alice, aliceSweepTxHash) + + assertBalance( + t.t, alice, ccItestAsset.Amount, itest.WithAssetID(assetID), + itest.WithNumUtxos(2), + ) + + // Finally, assert the swept asset can be spent onward to a third party. + const assetSendAmount = 1000 + charlieAddr, err := asTapd(charlie).NewAddr( + ctx, &taprpc.NewAddrRequest{ + Amt: assetSendAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + alice.RPCAddr(), + ), + }, + ) + require.NoError(t.t, err) + + itest.AssertAddrCreated(t.t, asTapd(charlie), cents, charlieAddr) + _, err = asTapd(alice).SendAsset( + ctx, &taprpc.SendAssetRequest{ + TapAddrs: []string{charlieAddr.Encoded}, + }, + ) + require.NoError(t.t, err) + mineBlocks(t, net, 1, 1) + itest.AssertNonInteractiveRecvComplete(t.t, asTapd(charlie), 1) + + assertBalance( + t.t, alice, ccItestAsset.Amount-assetSendAmount, + itest.WithAssetID(assetID), + ) + assertBalance( + t.t, charlie, assetSendAmount, itest.WithAssetID(assetID), + ) +}