From bbb6d7b8b15078e8c7a265dff7b7fb34fcde5795 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:05 +0300 Subject: [PATCH 01/16] lnwallet: populate resolution blob for htlc retribution --- lnwallet/channel.go | 87 +++++++++++++++++++++++++++++++++++----- lnwallet/channel_test.go | 3 ++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 78ca895655a..903a37f3e1a 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2197,6 +2197,7 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, br, ourAmt, theirAmt, err = createBreachRetribution( revokedLog, spendTx, chanState, keyRing, commitmentSecret, leaseExpiry, auxResult.AuxLeaves, + auxResolver, ) if err != nil { return nil, err @@ -2211,7 +2212,7 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, // are confident that no legacy format is in use. br, ourAmt, theirAmt, err = createBreachRetributionLegacy( revokedLogLegacy, chanState, keyRing, commitmentSecret, - ourScript, theirScript, leaseExpiry, + ourScript, theirScript, leaseExpiry, auxResolver, ) if err != nil { return nil, err @@ -2401,8 +2402,9 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, func createHtlcRetribution(chanState *channeldb.OpenChannel, keyRing *CommitmentKeyRing, commitHash chainhash.Hash, commitmentSecret *btcec.PrivateKey, leaseExpiry uint32, - htlc *channeldb.HTLCEntry, - auxLeaves fn.Option[CommitAuxLeaves]) (HtlcRetribution, error) { + htlc *channeldb.HTLCEntry, auxLeaves fn.Option[CommitAuxLeaves], + spendTx *wire.MsgTx, auxResolver fn.Option[AuxContractResolver], + revokedLog *channeldb.RevocationLog) (HtlcRetribution, error) { var emptyRetribution HtlcRetribution @@ -2505,6 +2507,69 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, copy(secondLevelTapTweak[:], scriptTree.TapTweak()) } + // Determine the witness type for this HTLC revocation based on the + // channel type and whether it's incoming or outgoing. + var htlcWitnessType input.StandardWitnessType + isTaproot := chanState.ChanType.IsTaproot() + switch { + case isTaproot && htlc.Incoming.Val: + htlcWitnessType = input.TaprootHtlcAcceptedRevoke + + case isTaproot && !htlc.Incoming.Val: + htlcWitnessType = input.TaprootHtlcOfferedRevoke + + case !isTaproot && htlc.Incoming.Val: + htlcWitnessType = input.HtlcAcceptedRevoke + + case !isTaproot && !htlc.Incoming.Val: + htlcWitnessType = input.HtlcOfferedRevoke + } + + // Only taproot channels can be modified by aux channels, so we + // only need to resolve the aux blob for taproot channel types. + var resolutionBlob fn.Option[tlv.Blob] + if isTaproot { + htlcIDOpt := fn.MapOption( + func(v tlv.BigSizeT[uint64]) input.HtlcIndex { + return v.Int() + }, + )(htlc.HtlcIndex.ValOpt()) + + resolveReq := ResolutionReq{ + ChanPoint: chanState.FundingOutpoint, + ChanType: chanState.ChanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + FundingBlob: chanState.CustomBlob, + Type: htlcWitnessType, + CloseType: Breach, + CommitTx: spendTx, + SignDesc: signDesc, + KeyRing: keyRing, + CsvDelay: theirDelay, + CommitFee: chanState.RemoteCommitment.CommitFee, + PayHash: fn.Some([32]byte(htlc.RHash.Val)), + HtlcID: htlcIDOpt, + CltvDelay: fn.Some(htlc.RefundTimeout.Val), + } + if revokedLog != nil { + resolveReq.CommitBlob = revokedLog.CustomBlob.ValOpt() + } + + resolveBlob := fn.MapOptionZ( + auxResolver, + func(a AuxContractResolver) fn.Result[tlv.Blob] { + return a.ResolveContract(resolveReq) + }, + ) + if err := resolveBlob.Err(); err != nil { + return emptyRetribution, fmt.Errorf("unable to "+ + "aux resolve HTLC: %w", err) + } + + resolutionBlob = resolveBlob.OkToSome() + } + return HtlcRetribution{ SignDesc: signDesc, OutPoint: wire.OutPoint{ @@ -2514,6 +2579,7 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, SecondLevelWitnessScript: secondLevelWitnessScript, IsIncoming: htlc.Incoming.Val, SecondLevelTapTweak: secondLevelTapTweak, + ResolutionBlob: resolutionBlob, }, nil } @@ -2527,9 +2593,9 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, func createBreachRetribution(revokedLog *channeldb.RevocationLog, spendTx *wire.MsgTx, chanState *channeldb.OpenChannel, keyRing *CommitmentKeyRing, commitmentSecret *btcec.PrivateKey, - leaseExpiry uint32, - auxLeaves fn.Option[CommitAuxLeaves]) (*BreachRetribution, int64, int64, - error) { + leaseExpiry uint32, auxLeaves fn.Option[CommitAuxLeaves], + auxResolver fn.Option[AuxContractResolver]) (*BreachRetribution, + int64, int64, error) { commitHash := revokedLog.CommitTxHash @@ -2538,7 +2604,8 @@ func createBreachRetribution(revokedLog *channeldb.RevocationLog, for i, htlc := range revokedLog.HTLCEntries { hr, err := createHtlcRetribution( chanState, keyRing, commitHash.Val, - commitmentSecret, leaseExpiry, htlc, auxLeaves, + commitmentSecret, leaseExpiry, htlc, auxLeaves, spendTx, + auxResolver, revokedLog, ) if err != nil { return nil, 0, 0, err @@ -2644,8 +2711,9 @@ func createBreachRetribution(revokedLog *channeldb.RevocationLog, func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chanState *channeldb.OpenChannel, keyRing *CommitmentKeyRing, commitmentSecret *btcec.PrivateKey, - ourScript, theirScript input.ScriptDescriptor, - leaseExpiry uint32) (*BreachRetribution, int64, int64, error) { + ourScript, theirScript input.ScriptDescriptor, leaseExpiry uint32, + auxResolver fn.Option[AuxContractResolver]) (*BreachRetribution, + int64, int64, error) { commitHash := revokedLog.CommitTx.TxHash() ourOutpoint := wire.OutPoint{ @@ -2692,6 +2760,7 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chanState, keyRing, commitHash, commitmentSecret, leaseExpiry, entry, fn.None[CommitAuxLeaves](), + revokedLog.CommitTx, auxResolver, nil, ) if err != nil { return nil, 0, 0, err diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index ab96d339749..22db9b30603 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -10118,6 +10118,7 @@ func TestCreateHtlcRetribution(t *testing.T) { hr, err := createHtlcRetribution( aliceChannel.channelState, keyRing, commitHash, dummyPrivate, leaseExpiry, htlc, fn.None[CommitAuxLeaves](), + nil, fn.None[AuxContractResolver](), nil, ) // Expect no error. require.NoError(t, err) @@ -10324,6 +10325,7 @@ func TestCreateBreachRetribution(t *testing.T) { aliceChannel.channelState, keyRing, dummyPrivate, leaseExpiry, fn.None[CommitAuxLeaves](), + fn.None[AuxContractResolver](), ) // Check the error if expected. @@ -10382,6 +10384,7 @@ func TestCreateBreachRetributionLegacy(t *testing.T) { br, ourAmt, theirAmt, err := createBreachRetributionLegacy( &revokedLog, aliceChannel.channelState, keyRing, dummyPrivate, ourScript, theirScript, leaseExpiry, + fn.None[AuxContractResolver](), ) require.NoError(t, err) From 6d14c142e5e9c399c10526a6068b9e05bf187eb6 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:06 +0300 Subject: [PATCH 02/16] sweep+contractcourt: notify aux sweeper only on confirmed sweeps --- contractcourt/breach_arbitrator.go | 114 ++++++++++++++++++++++++----- sweep/fee_bumper.go | 2 + sweep/interface.go | 6 +- sweep/mock_test.go | 2 +- 4 files changed, 104 insertions(+), 20 deletions(-) diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index 6e12086008a..a232039ad61 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -690,6 +690,76 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend) ( return totalFunds, revokedFunds } +// notifyConfirmedJusticeTx checks if any of the spend details match one of our +// justice transactions. If a confirmed justice transaction is detected and we +// haven't already notified about it, we call NotifyBroadcast on the aux sweeper +// to generate asset-level proofs. +func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, + justiceTxs *justiceTxVariants, notifiedTxs map[chainhash.Hash]bool) { + + // Check each spend to see if it's from one of our justice txs. + for _, s := range spends { + spendingTxHash := *s.detail.SpenderTxHash + + // Skip if we've already notified about this transaction. + if notifiedTxs[spendingTxHash] { + continue + } + + // Helper to check if a justice tx matches the spending tx. + matchesJusticeTx := func(jtx *justiceTxCtx) bool { + if jtx == nil { + return false + } + hash := jtx.justiceTx.TxHash() + + return spendingTxHash.IsEqual(&hash) + } + + var justiceCtx *justiceTxCtx + switch { + case matchesJusticeTx(justiceTxs.spendAll): + justiceCtx = justiceTxs.spendAll + + case matchesJusticeTx(justiceTxs.spendCommitOuts): + justiceCtx = justiceTxs.spendCommitOuts + + case matchesJusticeTx(justiceTxs.spendHTLCs): + justiceCtx = justiceTxs.spendHTLCs + } + + // If this is one of our justice txs, notify the aux sweeper. + if justiceCtx != nil { + bumpReq := sweep.BumpRequest{ + Inputs: justiceCtx.inputs, + DeliveryAddress: justiceCtx.sweepAddr, + ExtraTxOut: justiceCtx.extraTxOut, + } + + err := fn.MapOptionZ( + b.cfg.AuxSweeper, + func(aux sweep.AuxSweeper) error { + // The transaction is already confirmed, + // so we pass skipBroadcast=true. + return aux.NotifyBroadcast( + &bumpReq, s.detail.SpendingTx, + justiceCtx.fee, nil, true, + ) + }, + ) + if err != nil { + brarLog.Errorf("Failed to notify aux sweeper "+ + "of confirmed justice tx %v: %v", + spendingTxHash, err) + } else { + // Mark this transaction as notified to avoid + // duplicate calls. + notifiedTxs[spendingTxHash] = true + } + } + } +} + // exactRetribution is a goroutine which is executed once a contract breach has // been detected by a breachObserver. This function is responsible for // punishing a counterparty for violating the channel contract by sweeping ALL @@ -726,6 +796,25 @@ func (b *BreachArbitrator) exactRetribution( // SpendEvents between each attempt to not re-register unnecessarily. spendNtfns := make(map[wire.OutPoint]*chainntnfs.SpendEvent) + // Track which justice transactions we've already notified the aux + // sweeper about, to avoid duplicate NotifyBroadcast calls. This is + // needed because waitForSpendEvent registers a separate spend + // subscription for each breached output. When a single justice tx + // spends all outputs, the chain notifier delivers a SpendDetail to + // each subscription independently. Since the goroutines race to + // resolve their select on spendEv.Spend before the exit channel is + // closed, multiple goroutines will typically commit to the spend + // case and write to the allSpends channel, producing multiple + // entries in the returned []spend slice that all reference the same + // SpenderTxHash. Without deduplication, we would call + // NotifyBroadcast once per breached output rather than once per + // confirmed justice tx. + // + // Note that this map is not persisted across restarts. If lnd + // restarts mid-breach, the aux sweeper's NotifyBroadcast must + // handle duplicate calls idempotently. + notifiedJusticeTxs := make(map[chainhash.Hash]bool) + // Compute both the total value of funds being swept and the // amount of funds that were revoked from the counter party. var totalFunds, revokedFunds btcutil.Amount @@ -744,24 +833,6 @@ justiceTxBroadcast: brarLog.Debugf("Broadcasting justice tx: %v", lnutils.SpewLogClosure( finalTx)) - // As we're about to broadcast our breach transaction, we'll notify the - // aux sweeper of our broadcast attempt first. - err = fn.MapOptionZ(b.cfg.AuxSweeper, func(aux sweep.AuxSweeper) error { - bumpReq := sweep.BumpRequest{ - Inputs: finalTx.inputs, - DeliveryAddress: finalTx.sweepAddr, - ExtraTxOut: finalTx.extraTxOut, - } - - return aux.NotifyBroadcast( - &bumpReq, finalTx.justiceTx, finalTx.fee, nil, - ) - }) - if err != nil { - brarLog.Errorf("unable to notify broadcast: %w", err) - return - } - // We'll now attempt to broadcast the transaction which finalized the // channel's retribution against the cheating counter party. label := labels.MakeLabel(labels.LabelTypeJusticeTransaction, nil) @@ -806,6 +877,13 @@ Loop: for { select { case spends := <-spendChan: + // Check if any of the spends represent a confirmed + // justice transaction, and if so, notify the aux + // sweeper. + b.notifyConfirmedJusticeTx( + spends, justiceTxs, notifiedJusticeTxs, + ) + // Update the breach info with the new spends. t, r := updateBreachInfo(breachInfo, spends) totalFunds += t diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index e0d5d751616..f73a2b0d8a9 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -738,8 +738,10 @@ func (t *TxPublisher) broadcast(record *monitorRecord) (*BumpResult, error) { // Before we go to broadcast, we'll notify the aux sweeper, if it's // present of this new broadcast attempt. err := fn.MapOptionZ(t.cfg.AuxSweeper, func(aux AuxSweeper) error { + const skipBroadcast = false return aux.NotifyBroadcast( record.req, tx, record.fee, record.outpointToTxIndex, + skipBroadcast, ) }) if err != nil { diff --git a/sweep/interface.go b/sweep/interface.go index 6c8c2cfad28..669db5f2a49 100644 --- a/sweep/interface.go +++ b/sweep/interface.go @@ -92,7 +92,11 @@ type AuxSweeper interface { // NotifyBroadcast is used to notify external callers of the broadcast // of a sweep transaction, generated by the passed BumpRequest. + // The skipBroadcast parameter indicates whether the transaction is + // already confirmed on-chain (true for breach sweeps) or needs to be + // broadcast (false for normal sweeps). NotifyBroadcast(req *BumpRequest, tx *wire.MsgTx, totalFees btcutil.Amount, - outpointToTxIndex map[wire.OutPoint]int) error + outpointToTxIndex map[wire.OutPoint]int, + skipBroadcast bool) error } diff --git a/sweep/mock_test.go b/sweep/mock_test.go index e6e254e8e11..55d9a4fa31e 100644 --- a/sweep/mock_test.go +++ b/sweep/mock_test.go @@ -359,7 +359,7 @@ func (m *MockAuxSweeper) ExtraBudgetForInputs( // NotifyBroadcast is used to notify external callers of the broadcast // of a sweep transaction, generated by the passed BumpRequest. func (*MockAuxSweeper) NotifyBroadcast(_ *BumpRequest, _ *wire.MsgTx, - _ btcutil.Amount, _ map[wire.OutPoint]int) error { + _ btcutil.Amount, _ map[wire.OutPoint]int, _ bool) error { return nil } From ffb282dc0ce257e75a29df4dfea4a07f1c252cea Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:07 +0300 Subject: [PATCH 03/16] lnwallet: pass breach height to HTLC resolution requests Pass the breach height through the call chain from NewBreachRetribution to createHtlcRetribution, and include it in the ResolutionReq for HTLC outputs via CommitTxBlockHeight. This is required for taproot-assets to properly reanchor asset proofs when sweeping revoked HTLCs. The reanchorAssetOutputs function needs the block height to fetch the block containing the breach commitment transaction and construct valid proofs for the sweep. Without this, the CommitTxBlockHeight defaults to 0, causing tapd to look for the commitment tx in the genesis block and fail with: "commit tx not found in block". See: lightninglabs/taproot-assets@815021fd (tapchannel: add reanchorAssetOutputs for proof reanchoring) --- lnwallet/channel.go | 52 ++++++++++++++++++++++------------------ lnwallet/channel_test.go | 6 ++--- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 903a37f3e1a..55cc92b170d 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2197,7 +2197,7 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, br, ourAmt, theirAmt, err = createBreachRetribution( revokedLog, spendTx, chanState, keyRing, commitmentSecret, leaseExpiry, auxResult.AuxLeaves, - auxResolver, + auxResolver, breachHeight, ) if err != nil { return nil, err @@ -2213,6 +2213,7 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, br, ourAmt, theirAmt, err = createBreachRetributionLegacy( revokedLogLegacy, chanState, keyRing, commitmentSecret, ourScript, theirScript, leaseExpiry, auxResolver, + breachHeight, ) if err != nil { return nil, err @@ -2404,7 +2405,8 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, commitmentSecret *btcec.PrivateKey, leaseExpiry uint32, htlc *channeldb.HTLCEntry, auxLeaves fn.Option[CommitAuxLeaves], spendTx *wire.MsgTx, auxResolver fn.Option[AuxContractResolver], - revokedLog *channeldb.RevocationLog) (HtlcRetribution, error) { + revokedLog *channeldb.RevocationLog, + breachHeight uint32) (HtlcRetribution, error) { var emptyRetribution HtlcRetribution @@ -2535,22 +2537,26 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, }, )(htlc.HtlcIndex.ValOpt()) + cs := chanState resolveReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanState.ChanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - FundingBlob: chanState.CustomBlob, - Type: htlcWitnessType, - CloseType: Breach, - CommitTx: spendTx, - SignDesc: signDesc, - KeyRing: keyRing, - CsvDelay: theirDelay, - CommitFee: chanState.RemoteCommitment.CommitFee, - PayHash: fn.Some([32]byte(htlc.RHash.Val)), - HtlcID: htlcIDOpt, - CltvDelay: fn.Some(htlc.RefundTimeout.Val), + ChanPoint: cs.FundingOutpoint, + ChanType: cs.ChanType, + ShortChanID: cs.ShortChanID(), + Initiator: cs.IsInitiator, + FundingBlob: cs.CustomBlob, + Type: htlcWitnessType, + CloseType: Breach, + CommitTx: spendTx, + CommitTxBlockHeight: breachHeight, + SignDesc: signDesc, + KeyRing: keyRing, + CsvDelay: theirDelay, + CommitFee: cs.RemoteCommitment.CommitFee, + PayHash: fn.Some( + [32]byte(htlc.RHash.Val), + ), + HtlcID: htlcIDOpt, + CltvDelay: fn.Some(htlc.RefundTimeout.Val), } if revokedLog != nil { resolveReq.CommitBlob = revokedLog.CustomBlob.ValOpt() @@ -2594,8 +2600,8 @@ func createBreachRetribution(revokedLog *channeldb.RevocationLog, spendTx *wire.MsgTx, chanState *channeldb.OpenChannel, keyRing *CommitmentKeyRing, commitmentSecret *btcec.PrivateKey, leaseExpiry uint32, auxLeaves fn.Option[CommitAuxLeaves], - auxResolver fn.Option[AuxContractResolver]) (*BreachRetribution, - int64, int64, error) { + auxResolver fn.Option[AuxContractResolver], + breachHeight uint32) (*BreachRetribution, int64, int64, error) { commitHash := revokedLog.CommitTxHash @@ -2605,7 +2611,7 @@ func createBreachRetribution(revokedLog *channeldb.RevocationLog, hr, err := createHtlcRetribution( chanState, keyRing, commitHash.Val, commitmentSecret, leaseExpiry, htlc, auxLeaves, spendTx, - auxResolver, revokedLog, + auxResolver, revokedLog, breachHeight, ) if err != nil { return nil, 0, 0, err @@ -2712,8 +2718,8 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chanState *channeldb.OpenChannel, keyRing *CommitmentKeyRing, commitmentSecret *btcec.PrivateKey, ourScript, theirScript input.ScriptDescriptor, leaseExpiry uint32, - auxResolver fn.Option[AuxContractResolver]) (*BreachRetribution, - int64, int64, error) { + auxResolver fn.Option[AuxContractResolver], + breachHeight uint32) (*BreachRetribution, int64, int64, error) { commitHash := revokedLog.CommitTx.TxHash() ourOutpoint := wire.OutPoint{ @@ -2760,7 +2766,7 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chanState, keyRing, commitHash, commitmentSecret, leaseExpiry, entry, fn.None[CommitAuxLeaves](), - revokedLog.CommitTx, auxResolver, nil, + revokedLog.CommitTx, auxResolver, nil, breachHeight, ) if err != nil { return nil, 0, 0, err diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index 22db9b30603..ffc026caccb 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -10118,7 +10118,7 @@ func TestCreateHtlcRetribution(t *testing.T) { hr, err := createHtlcRetribution( aliceChannel.channelState, keyRing, commitHash, dummyPrivate, leaseExpiry, htlc, fn.None[CommitAuxLeaves](), - nil, fn.None[AuxContractResolver](), nil, + nil, fn.None[AuxContractResolver](), nil, 0, ) // Expect no error. require.NoError(t, err) @@ -10325,7 +10325,7 @@ func TestCreateBreachRetribution(t *testing.T) { aliceChannel.channelState, keyRing, dummyPrivate, leaseExpiry, fn.None[CommitAuxLeaves](), - fn.None[AuxContractResolver](), + fn.None[AuxContractResolver](), 0, ) // Check the error if expected. @@ -10384,7 +10384,7 @@ func TestCreateBreachRetributionLegacy(t *testing.T) { br, ourAmt, theirAmt, err := createBreachRetributionLegacy( &revokedLog, aliceChannel.channelState, keyRing, dummyPrivate, ourScript, theirScript, leaseExpiry, - fn.None[AuxContractResolver](), + fn.None[AuxContractResolver](), 0, ) require.NoError(t, err) From 4272b15d6922195a7737710f5fed11e0876fc263 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:08 +0300 Subject: [PATCH 04/16] contractcourt: add tests for notifyConfirmedJusticeTx Add unit tests for the notifyConfirmedJusticeTx function to verify correct behavior when detecting confirmed justice transactions and notifying the aux sweeper. Test cases cover: - Detection of each justice tx variant (spendAll, spendCommitOuts, spendHTLCs) - Skipping already-notified transactions via notifiedTxs map - No notification for unrelated transactions - Multiple spends with mixed matching/non-matching - Graceful handling of nil justice tx contexts - Operation without an aux sweeper configured --- contractcourt/breach_arbitrator_test.go | 311 ++++++++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go index 869a0093e01..7f0e56f311d 100644 --- a/contractcourt/breach_arbitrator_test.go +++ b/contractcourt/breach_arbitrator_test.go @@ -32,6 +32,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/shachain" + "github.com/lightningnetwork/lnd/sweep" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -2623,3 +2624,313 @@ func TestUpdateBreachInfoCountsFinalTaprootRevokedFunds(t *testing.T) { require.Equal(t, revokedAmt, revoked) require.Empty(t, breachInfo.breachedOutputs) } + +// mockAuxSweeperNotify is a mock implementation of sweep.AuxSweeper that tracks +// calls to NotifyBroadcast for testing purposes. +type mockAuxSweeperNotify struct { + notifyCalls []notifyCall + notifyErr error +} + +// notifyCall records the parameters of a NotifyBroadcast call. +type notifyCall struct { + req *sweep.BumpRequest + tx *wire.MsgTx + fee btcutil.Amount + skipBroadcast bool +} + +// DeriveSweepAddr implements sweep.AuxSweeper. +func (m *mockAuxSweeperNotify) DeriveSweepAddr(_ []input.Input, + _ lnwallet.AddrWithKey) fn.Result[sweep.SweepOutput] { + + return fn.Ok(sweep.SweepOutput{}) +} + +// ExtraBudgetForInputs implements sweep.AuxSweeper. +func (m *mockAuxSweeperNotify) ExtraBudgetForInputs( + _ []input.Input) fn.Result[btcutil.Amount] { + + return fn.Ok(btcutil.Amount(0)) +} + +// NotifyBroadcast implements sweep.AuxSweeper and records the call. +func (m *mockAuxSweeperNotify) NotifyBroadcast(req *sweep.BumpRequest, + tx *wire.MsgTx, fee btcutil.Amount, + _ map[wire.OutPoint]int, skipBroadcast bool) error { + + m.notifyCalls = append(m.notifyCalls, notifyCall{ + req: req, + tx: tx, + fee: fee, + skipBroadcast: skipBroadcast, + }) + + return m.notifyErr +} + +// TestNotifyConfirmedJusticeTx tests that notifyConfirmedJusticeTx correctly +// identifies confirmed justice transactions and notifies the aux sweeper. +func TestNotifyConfirmedJusticeTx(t *testing.T) { + t.Parallel() + + // Create test transactions for each justice tx variant. + spendAllTx := &wire.MsgTx{Version: 1} + spendCommitOutsTx := &wire.MsgTx{Version: 2} + spendHTLCsTx := &wire.MsgTx{Version: 3} + unrelatedTx := &wire.MsgTx{Version: 4} + + spendAllHash := spendAllTx.TxHash() + spendCommitOutsHash := spendCommitOutsTx.TxHash() + spendHTLCsHash := spendHTLCsTx.TxHash() + unrelatedHash := unrelatedTx.TxHash() + + // Create justice tx contexts. + spendAllCtx := &justiceTxCtx{ + justiceTx: spendAllTx, + fee: btcutil.Amount(1000), + } + spendCommitOutsCtx := &justiceTxCtx{ + justiceTx: spendCommitOutsTx, + fee: btcutil.Amount(2000), + } + spendHTLCsCtx := &justiceTxCtx{ + justiceTx: spendHTLCsTx, + fee: btcutil.Amount(3000), + } + + tests := []struct { + name string + spends []spend + justiceTxs *justiceTxVariants + notifiedTxs map[chainhash.Hash]bool + expectedCalls int + expectedFees []btcutil.Amount + expectedSkipFlag bool + }{ + { + name: "spendAll variant detected", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 1, + expectedFees: []btcutil.Amount{1000}, + expectedSkipFlag: true, + }, + { + name: "spendCommitOuts variant detected", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendCommitOutsHash, + SpendingTx: spendCommitOutsTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendCommitOuts: spendCommitOutsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 1, + expectedFees: []btcutil.Amount{2000}, + expectedSkipFlag: true, + }, + { + name: "spendHTLCs variant detected", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendHTLCsHash, + SpendingTx: spendHTLCsTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendHTLCs: spendHTLCsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 1, + expectedFees: []btcutil.Amount{3000}, + expectedSkipFlag: true, + }, + { + name: "skip already notified transaction", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + }, + notifiedTxs: map[chainhash.Hash]bool{ + spendAllHash: true, + }, + expectedCalls: 0, + }, + { + name: "no match - unrelated transaction", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &unrelatedHash, + SpendingTx: unrelatedTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + spendCommitOuts: spendCommitOutsCtx, + spendHTLCs: spendHTLCsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 0, + }, + { + name: "multiple spends - only matching ones notified", + spends: []spend{ + { + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }, + { + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &unrelatedHash, + SpendingTx: unrelatedTx, + SpendingHeight: 100, + }, + }, + { + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendHTLCsHash, + SpendingTx: spendHTLCsTx, + SpendingHeight: 100, + }, + }, + }, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + spendHTLCs: spendHTLCsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 2, + expectedFees: []btcutil.Amount{1000, 3000}, + expectedSkipFlag: true, + }, + { + name: "nil justice txs - no panic", + spends: []spend{}, + justiceTxs: &justiceTxVariants{ + spendAll: nil, + spendCommitOuts: nil, + spendHTLCs: nil, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create mock aux sweeper. + mockSweeper := &mockAuxSweeperNotify{} + + // Create a minimal BreachArbitrator with the mock. + brar := &BreachArbitrator{ + cfg: &BreachConfig{ + AuxSweeper: fn.Some[sweep.AuxSweeper]( + mockSweeper, + ), + }, + } + + // Call the function under test. + brar.notifyConfirmedJusticeTx( + tc.spends, tc.justiceTxs, tc.notifiedTxs, + ) + + // Verify the number of NotifyBroadcast calls. + require.Len(t, mockSweeper.notifyCalls, + tc.expectedCalls, "unexpected number of "+ + "NotifyBroadcast calls") + + // Verify the fees if we expected calls. + for i, call := range mockSweeper.notifyCalls { + if i < len(tc.expectedFees) { + require.Equal(t, tc.expectedFees[i], + call.fee, + "unexpected fee for call %d", i) + } + + // Verify skipBroadcast is always true for + // confirmed justice txs. + require.Equal(t, tc.expectedSkipFlag, + call.skipBroadcast, + "skipBroadcast should be true") + } + + // Verify notifiedTxs map was updated for successful + // notifications. + for _, call := range mockSweeper.notifyCalls { + txHash := call.tx.TxHash() + require.True(t, tc.notifiedTxs[txHash], + "tx %v should be marked as notified", + txHash) + } + }) + } +} + +// TestNotifyConfirmedJusticeTxNoAuxSweeper verifies that the function handles +// the case where no aux sweeper is configured. +func TestNotifyConfirmedJusticeTxNoAuxSweeper(t *testing.T) { + t.Parallel() + + spendAllTx := &wire.MsgTx{Version: 1} + spendAllHash := spendAllTx.TxHash() + + spends := []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }} + + justiceTxs := &justiceTxVariants{ + spendAll: &justiceTxCtx{ + justiceTx: spendAllTx, + fee: btcutil.Amount(1000), + }, + } + + // Create BreachArbitrator with no aux sweeper. + brar := &BreachArbitrator{ + cfg: &BreachConfig{ + AuxSweeper: fn.None[sweep.AuxSweeper](), + }, + } + + notifiedTxs := make(map[chainhash.Hash]bool) + + // Should not panic and should not mark as notified since there's no + // aux sweeper to notify. + brar.notifyConfirmedJusticeTx( + spends, justiceTxs, notifiedTxs, + ) + + // The tx should still be marked as notified even without an aux + // sweeper, to avoid repeated processing. + require.True(t, notifiedTxs[spendAllHash], + "tx should be marked as notified") +} From 46ea4d70a967c978913dbd5512a7ac7bf81cecfe Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:09 +0300 Subject: [PATCH 05/16] lnwallet: extend FetchLeavesFromRevocation with channel state params Second-level HTLC auxiliary leaves need to be computed at runtime from the channel state, key ring, and commitment transaction, rather than being stored in the commitment blob. Extend the FetchLeavesFromRevocation interface method to accept these additional parameters so that implementers can derive the leaves on the fly. --- lnwallet/aux_leaf_store.go | 8 ++++++-- lnwallet/channel.go | 5 ++++- lnwallet/mock.go | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lnwallet/aux_leaf_store.go b/lnwallet/aux_leaf_store.go index 0a850503780..113e743f8ab 100644 --- a/lnwallet/aux_leaf_store.go +++ b/lnwallet/aux_leaf_store.go @@ -190,8 +190,12 @@ type AuxLeafStore interface { // FetchLeavesFromRevocation attempts to fetch the auxiliary leaves // from a channel revocation that stores balance + blob information. - FetchLeavesFromRevocation( - r *channeldb.RevocationLog) fn.Result[CommitDiffAuxResult] + // The additional parameters (chanState, keys, commitTx) are needed + // to compute second-level HTLC auxiliary leaves at runtime, since + // these are not stored in the commitment blob. + FetchLeavesFromRevocation(r *channeldb.RevocationLog, + chanState AuxChanState, keys CommitmentKeyRing, + commitTx *wire.MsgTx) fn.Result[CommitDiffAuxResult] // ApplyHtlcView serves as the state transition function for the custom // channel's blob. Given the old blob, and an HTLC view, then a new diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 55cc92b170d..def23ff7c19 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2147,7 +2147,10 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, auxResult, err := fn.MapOptionZ( leafStore, func(s AuxLeafStore) fn.Result[CommitDiffAuxResult] { - return s.FetchLeavesFromRevocation(revokedLog) + return s.FetchLeavesFromRevocation( + revokedLog, NewAuxChanState(chanState), + *keyRing, spendTx, + ) }, ).Unpack() if err != nil { diff --git a/lnwallet/mock.go b/lnwallet/mock.go index 39e520d2760..de1a8b292fb 100644 --- a/lnwallet/mock.go +++ b/lnwallet/mock.go @@ -430,7 +430,8 @@ func (*MockAuxLeafStore) FetchLeavesFromCommit(_ AuxChanState, // FetchLeavesFromRevocation attempts to fetch the auxiliary leaves // from a channel revocation that stores balance + blob information. func (*MockAuxLeafStore) FetchLeavesFromRevocation( - _ *channeldb.RevocationLog) fn.Result[CommitDiffAuxResult] { + _ *channeldb.RevocationLog, _ AuxChanState, _ CommitmentKeyRing, + _ *wire.MsgTx) fn.Result[CommitDiffAuxResult] { return fn.Ok(CommitDiffAuxResult{}) } From 4e1981bcce78226e59ed8ae2d4c35f38fbd295dc Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:09 +0300 Subject: [PATCH 06/16] lnwallet: preserve resolution request for HTLC resolutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an HTLC is taken to the second level — whether during a breach or during a regular force close — the aux contract resolver needs to re-resolve the contract with the actual second-level transaction once it confirms. Preserve the original ResolutionReq as a template: - in HtlcRetribution, so the breach arbiter can re-resolve with a different witness type - on IncomingHtlcResolution / OutgoingHtlcResolution, so the htlc success and timeout resolvers can do the same once their pre-signed second-level tx confirms Also add SecondLevelTx and SecondLevelTxBlockHeight fields to ResolutionReq so the resolver can re-anchor proofs to the second-level transaction rather than the commitment transaction. Populate HtlcAmt in the resolution request so the resolver has the HTLC value available. Add a NewSecondLevelResolveReq helper that reconstructs the ResolutionReq at re-resolve time, re-deriving the commitment key ring from RevocationProducer.AtIndex(LocalCommitment.CommitHeight) so the plumbing does not have to round-trip the key ring through the briefcase. --- lnwallet/aux_resolutions.go | 10 ++ lnwallet/channel.go | 265 +++++++++++++++++++++++++++--------- 2 files changed, 213 insertions(+), 62 deletions(-) diff --git a/lnwallet/aux_resolutions.go b/lnwallet/aux_resolutions.go index 14802c57c7b..2ae762afd00 100644 --- a/lnwallet/aux_resolutions.go +++ b/lnwallet/aux_resolutions.go @@ -117,6 +117,16 @@ type ResolutionReq struct { // AuxSigDesc is an optional field that contains additional information // needed to sweep second level HTLCs. AuxSigDesc fn.Option[AuxSigDesc] + + // SecondLevelTx is the second-level HTLC transaction, set only when + // resolving a TaprootHtlcSecondLevelRevoke. This allows the resolver + // to re-anchor proofs to the second-level tx rather than the + // commitment tx. + SecondLevelTx *wire.MsgTx + + // SecondLevelTxBlockHeight is the block height where the second-level + // HTLC transaction was confirmed. + SecondLevelTxBlockHeight uint32 } // AuxContractResolver is an interface that is used to resolve contracts that diff --git a/lnwallet/channel.go b/lnwallet/channel.go index def23ff7c19..c3f3d8d3186 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2007,6 +2007,12 @@ type HtlcRetribution struct { // ResolutionBlob is a blob used for aux channels that permits a // spender of this output to claim all funds. ResolutionBlob fn.Option[tlv.Blob] + + // ResolveReq is the resolution request template used to generate + // the ResolutionBlob. It is preserved so the breach arbiter can + // re-resolve with a different witness type when an HTLC is taken + // to the second level. + ResolveReq *ResolutionReq } // BreachRetribution contains all the data necessary to bring a channel @@ -2533,6 +2539,7 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, // Only taproot channels can be modified by aux channels, so we // only need to resolve the aux blob for taproot channel types. var resolutionBlob fn.Option[tlv.Blob] + var savedResolveReq *ResolutionReq if isTaproot { htlcIDOpt := fn.MapOption( func(v tlv.BigSizeT[uint64]) input.HtlcIndex { @@ -2555,6 +2562,7 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, KeyRing: keyRing, CsvDelay: theirDelay, CommitFee: cs.RemoteCommitment.CommitFee, + HtlcAmt: htlc.Amt.Val.Int(), PayHash: fn.Some( [32]byte(htlc.RHash.Val), ), @@ -2577,6 +2585,15 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, } resolutionBlob = resolveBlob.OkToSome() + + // Save the resolve request template so the breach arbiter + // can re-resolve when an HTLC is taken to second level. + // We deep-copy the key ring to prevent any potential + // mutation from affecting the saved request. + keyRingCopy := *keyRing + resolveReqCopy := resolveReq + resolveReqCopy.KeyRing = &keyRingCopy + savedResolveReq = &resolveReqCopy } return HtlcRetribution{ @@ -2589,6 +2606,7 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, IsIncoming: htlc.Incoming.Val, SecondLevelTapTweak: secondLevelTapTweak, ResolutionBlob: resolutionBlob, + ResolveReq: savedResolveReq, }, nil } @@ -7415,6 +7433,15 @@ type IncomingHtlcResolution struct { // spender of the output to properly resolve it in the case of a force // close. ResolutionBlob fn.Option[tlv.Blob] + + // ResolveReq is the resolution request template used to generate the + // ResolutionBlob. It is preserved so the htlc success resolver can + // re-resolve with a second-level witness type once the pre-signed + // second-level HTLC tx confirms (under DeterministicHTLCs the + // resolver publishes the second-level tx directly via PublishTx, + // bypassing the sweeper's NotifyBroadcast, so tapd must import the + // second-level proof retroactively before the output sweep runs). + ResolveReq *ResolutionReq } // OutgoingHtlcResolution houses the information necessary to sweep any @@ -7469,6 +7496,10 @@ type OutgoingHtlcResolution struct { // spender of the output to properly resolve it in the case of a force // close. ResolutionBlob fn.Option[tlv.Blob] + + // ResolveReq is the resolution request template used to generate the + // ResolutionBlob. See IncomingHtlcResolution.ResolveReq for details. + ResolveReq *ResolutionReq } // HtlcResolutions contains the items necessary to sweep HTLC's on chain @@ -7800,39 +7831,37 @@ func newOutgoingHtlcResolution(signer input.Signer, // This might be an aux channel, so we'll go ahead and attempt to // generate the resolution blob for the channel so we can pass along to // the sweeping sub-system. + resReq := ResolutionReq{ + ChanPoint: chanState.FundingOutpoint, + ChanType: chanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.LocalCommitment.CustomBlob, + FundingBlob: chanState.CustomBlob, + Type: input.TaprootHtlcLocalOfferedTimeout, + CloseType: LocalForceClose, + CommitTx: commitTx, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: op, + SignDesc: sweepSignDesc, + KeyRing: keyRing, + CsvDelay: htlcCsvDelay, + HtlcAmt: btcutil.Amount(txOut.Value), + CommitCsvDelay: csvDelay, + CltvDelay: fn.Some(htlc.RefundTimeout), + CommitFee: chanState.LocalCommitment.CommitFee, + HtlcID: fn.Some(htlc.HtlcIndex), + PayHash: fn.Some(htlc.RHash), + AuxSigDesc: fn.Some(AuxSigDesc{ + SignDetails: *txSignDetails, + AuxSig: func() []byte { + tlvType := htlcCustomSigType.TypeVal() + return htlc.CustomRecords[uint64(tlvType)] + }(), + }), + } resolveRes := fn.MapOptionZ( auxResolver, func(a AuxContractResolver) fn.Result[tlv.Blob] { - //nolint:ll - resReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.LocalCommitment.CustomBlob, - FundingBlob: chanState.CustomBlob, - Type: input.TaprootHtlcLocalOfferedTimeout, - CloseType: LocalForceClose, - CommitTx: commitTx, - CommitTxBlockHeight: commitTxHeight, - ContractPoint: op, - SignDesc: sweepSignDesc, - KeyRing: keyRing, - CsvDelay: htlcCsvDelay, - HtlcAmt: btcutil.Amount(txOut.Value), - CommitCsvDelay: csvDelay, - CltvDelay: fn.Some(htlc.RefundTimeout), - CommitFee: chanState.LocalCommitment.CommitFee, - HtlcID: fn.Some(htlc.HtlcIndex), - PayHash: fn.Some(htlc.RHash), - AuxSigDesc: fn.Some(AuxSigDesc{ - SignDetails: *txSignDetails, - AuxSig: func() []byte { - tlvType := htlcCustomSigType.TypeVal() - return htlc.CustomRecords[uint64(tlvType)] - }(), - }), - } - return a.ResolveContract(resReq) }, ) @@ -7841,12 +7870,15 @@ func newOutgoingHtlcResolution(signer input.Signer, } resolutionBlob := resolveRes.OkToSome() + resReqCopy := resReq + return &OutgoingHtlcResolution{ Expiry: htlc.RefundTimeout, SignedTimeoutTx: timeoutTx, SignDetails: txSignDetails, CsvDelay: csvDelay, ResolutionBlob: resolutionBlob, + ResolveReq: &resReqCopy, ClaimOutpoint: wire.OutPoint{ Hash: timeoutTx.TxHash(), Index: 0, @@ -8164,39 +8196,37 @@ func newIncomingHtlcResolution(signer input.Signer, }, nil } + resReq := ResolutionReq{ + ChanPoint: chanState.FundingOutpoint, + ChanType: chanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.LocalCommitment.CustomBlob, + Type: input.TaprootHtlcAcceptedLocalSuccess, + FundingBlob: chanState.CustomBlob, + CloseType: LocalForceClose, + CommitTx: commitTx, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: op, + SignDesc: sweepSignDesc, + KeyRing: keyRing, + HtlcID: fn.Some(htlc.HtlcIndex), + CsvDelay: htlcCsvDelay, + CommitFee: chanState.LocalCommitment.CommitFee, + PayHash: fn.Some(htlc.RHash), + AuxSigDesc: fn.Some(AuxSigDesc{ + SignDetails: *txSignDetails, + AuxSig: func() []byte { + tlvType := htlcCustomSigType.TypeVal() + return htlc.CustomRecords[uint64(tlvType)] + }(), + }), + CommitCsvDelay: csvDelay, + HtlcAmt: btcutil.Amount(txOut.Value), + CltvDelay: fn.Some(htlc.RefundTimeout), + } resolveRes := fn.MapOptionZ( auxResolver, func(a AuxContractResolver) fn.Result[tlv.Blob] { - //nolint:ll - resReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.LocalCommitment.CustomBlob, - Type: input.TaprootHtlcAcceptedLocalSuccess, - FundingBlob: chanState.CustomBlob, - CloseType: LocalForceClose, - CommitTx: commitTx, - CommitTxBlockHeight: commitTxHeight, - ContractPoint: op, - SignDesc: sweepSignDesc, - KeyRing: keyRing, - HtlcID: fn.Some(htlc.HtlcIndex), - CsvDelay: htlcCsvDelay, - CommitFee: chanState.LocalCommitment.CommitFee, - PayHash: fn.Some(htlc.RHash), - AuxSigDesc: fn.Some(AuxSigDesc{ - SignDetails: *txSignDetails, - AuxSig: func() []byte { - tlvType := htlcCustomSigType.TypeVal() - return htlc.CustomRecords[uint64(tlvType)] - }(), - }), - CommitCsvDelay: csvDelay, - HtlcAmt: btcutil.Amount(txOut.Value), - CltvDelay: fn.Some(htlc.RefundTimeout), - } - return a.ResolveContract(resReq) }, ) @@ -8206,11 +8236,14 @@ func newIncomingHtlcResolution(signer input.Signer, resolutionBlob := resolveRes.OkToSome() + resReqCopy := resReq + return &IncomingHtlcResolution{ SignedSuccessTx: successTx, SignDetails: txSignDetails, CsvDelay: csvDelay, ResolutionBlob: resolutionBlob, + ResolveReq: &resReqCopy, ClaimOutpoint: wire.OutPoint{ Hash: successTx.TxHash(), Index: 0, @@ -8243,6 +8276,114 @@ func (r *OutgoingHtlcResolution) HtlcPoint() wire.OutPoint { return r.ClaimOutpoint } +// NewSecondLevelResolveReq reconstructs a ResolutionReq for re-resolving a +// second-level HTLC sweep after the pre-signed second-level tx has confirmed +// on chain. It is intended to be called from the htlc success / timeout +// resolvers when, under DeterministicHTLCs, the resolver published the +// pre-signed second-level tx directly via PublishTx (bypassing the aux +// sweeper's NotifyBroadcast) and the upcoming second-level output sweep +// would otherwise fail to fetch its input proof from the aux sweeper's +// archive. +// +// The ResolveReq returned mirrors the live request that +// newIncomingHtlcResolution / newOutgoingHtlcResolution built at +// force-close time. We rebuild it here from the persisted channel state +// because IncomingHtlcResolution / OutgoingHtlcResolution don't currently +// round-trip the original ResolveReq through the briefcase log. +// +// chanState must be the historical channel state (e.g. via +// FetchHistoricalChannel). htlc is the HTLC entry whose second-level +// sweep we're about to perform. signDetails is the SignDetails captured +// on the resolution at force-close time. htlcCsvDelay is the CSV delay +// for the second-level output (taken from +// IncomingHtlcResolution.CsvDelay / OutgoingHtlcResolution.CsvDelay). +// commitTxBlockHeight is the height at which the force-close commitment +// confirmed. secondLevelTx and secondLevelTxBlockHeight describe the +// just-confirmed second-level tx. witType is the second-level output +// witness type LND is about to offer to the sweeper +// (TaprootHtlcAcceptedSuccessSecondLevel for the success path or +// TaprootHtlcOfferedTimeoutSecondLevel for the timeout path). +// +// We always treat this as the local commitment because the success / +// timeout resolvers only run for HTLCs on our local force-close +// commitment. +func NewSecondLevelResolveReq(chanState *channeldb.OpenChannel, + htlc *channeldb.HTLC, signDetails *input.SignDetails, + sweepSignDesc input.SignDescriptor, htlcCsvDelay, + commitTxBlockHeight uint32, secondLevelTx *wire.MsgTx, + secondLevelTxBlockHeight uint32, + witType input.WitnessType) (*ResolutionReq, error) { + + if chanState == nil { + return nil, fmt.Errorf("chanState is nil") + } + if secondLevelTx == nil || len(secondLevelTx.TxIn) == 0 { + return nil, fmt.Errorf("invalid second-level tx") + } + + revocation, err := chanState.RevocationProducer.AtIndex( + chanState.LocalCommitment.CommitHeight, + ) + if err != nil { + return nil, fmt.Errorf("derive revocation: %w", err) + } + commitPoint := input.ComputeCommitmentPoint(revocation[:]) + keyRing := DeriveCommitmentKeys( + commitPoint, lntypes.Local, chanState.ChanType, + &chanState.LocalChanCfg, &chanState.RemoteChanCfg, + ) + keyRingCopy := *keyRing + + htlcAmt := htlc.Amt.ToSatoshis() + commitCsvDelay := uint32(chanState.LocalChanCfg.CsvDelay) + contractPoint := secondLevelTx.TxIn[0].PreviousOutPoint + + // AuxSigDesc is populated when we have both the SignDetails captured + // on the resolution and the remote party's aux sig in the HTLC's + // custom records. signSecondLevelImport uses the breach-style + // 2-of-2 witness construction when AuxSigDesc is present, producing + // a fully verifiable proof transition; the placeholder-witness + // fallback is reserved for the breach path where we don't have the + // remote sig. + var auxSigDesc fn.Option[AuxSigDesc] + if signDetails != nil { + sigType := htlcCustomSigType.TypeVal() + auxSig := htlc.CustomRecords[uint64(sigType)] + if len(auxSig) > 0 { + auxSigDesc = fn.Some(AuxSigDesc{ + SignDetails: *signDetails, + AuxSig: auxSig, + }) + } + } + + return &ResolutionReq{ + ChanPoint: chanState.FundingOutpoint, + ChanType: chanState.ChanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.LocalCommitment.CustomBlob, + FundingBlob: chanState.CustomBlob, + HtlcID: fn.Some(htlc.HtlcIndex), + HtlcAmt: htlcAmt, + Type: witType, + CloseType: LocalForceClose, + CommitTx: chanState.LocalCommitment.CommitTx, + CommitTxBlockHeight: commitTxBlockHeight, + CommitFee: chanState.LocalCommitment.CommitFee, + ContractPoint: contractPoint, + SignDesc: sweepSignDesc, + KeyRing: &keyRingCopy, + CsvDelay: htlcCsvDelay, + CommitCsvDelay: commitCsvDelay, + CltvDelay: fn.Some(htlc.RefundTimeout), + PayHash: fn.Some(htlc.RHash), + AuxSigDesc: auxSigDesc, + SecondLevelTx: secondLevelTx, + SecondLevelTxBlockHeight: secondLevelTxBlockHeight, + }, nil +} + // extractHtlcResolutions creates a series of outgoing HTLC resolutions, and // the local key used when generating the HTLC scrips. This function is to be // used in two cases: force close, or a unilateral close. From 63ed6b57016f1cd8ce3a70aa9e33097880e1420a Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:10 +0300 Subject: [PATCH 07/16] contractcourt: enhance breach arbiter for second-level HTLC revocation Support revoking second-level HTLC outputs in custom (asset) channels: - Add findSecondLevelOutputIndex to locate second-level outputs by script match rather than relying on SpenderInputIndex, which can diverge from the output index when the spending tx has additional inputs (e.g., wallet UTXOs for fees). - Re-resolve contract blobs at morph time via AuxContractResolver when an HTLC is taken to the second level, using the preserved ResolutionReq template with an updated witness type and the actual second-level spending transaction. - Handle non-positive BTC sweep amounts when an aux output carries the real value, allowing all input BTC to go toward fees. - Wire AuxResolver into BreachConfig from server.go. --- contractcourt/breach_arbitrator.go | 227 ++++++++++++++- contractcourt/breach_arbitrator_test.go | 354 +++++++++++++++++++++++- server.go | 3 +- 3 files changed, 567 insertions(+), 17 deletions(-) diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index a232039ad61..33f6174f0bc 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -180,6 +180,11 @@ type BreachConfig struct { // AuxSweeper is an optional interface that can be used to modify the // way sweep transaction are generated. AuxSweeper fn.Option[sweep.AuxSweeper] + + // AuxResolver is an optional interface for resolving auxiliary + // contract data. Used to generate resolution blobs for second-level + // HTLC revocations at morph time. + AuxResolver fn.Option[lnwallet.AuxContractResolver] } // BreachArbitrator is a special subsystem which is responsible for watching and @@ -540,13 +545,77 @@ func (b *BreachArbitrator) waitForSpendEvent(breachInfo *retributionInfo, } } +// findSecondLevelOutputIndex searches the spending (second-level) transaction's +// outputs for the one that matches the expected second-level HTLC output +// script. This is necessary because SpenderInputIndex (which identifies the +// input that spent our output) does NOT necessarily correspond to the output +// index — the second-level tx may contain additional inputs (e.g. wallet UTXOs +// for fees) that shift the input indices. +func findSecondLevelOutputIndex(bo *breachedOutput, + spendingTx *wire.MsgTx) (uint32, error) { + + isTaproot := txscript.IsPayToTaproot(bo.signDesc.Output.PkScript) + + // For taproot outputs, we can derive the expected pkScript from the + // revocation key and the second-level tap tweak. + if isTaproot && bo.signDesc.DoubleTweak != nil { + // Derive the revocation public key from the revocation base + // point and the commitment secret (DoubleTweak). + commitPoint := bo.signDesc.DoubleTweak.PubKey() + revokeKey := input.DeriveRevocationPubkey( + bo.signDesc.KeyDesc.PubKey, commitPoint, + ) + + // Compute the expected taproot output key using the revocation + // key as internal key and the second-level tap tweak. + expectedOutputKey := txscript.ComputeTaprootOutputKey( + revokeKey, bo.secondLevelTapTweak[:], + ) + expectedPkScript, err := input.PayToTaprootScript( + expectedOutputKey, + ) + if err != nil { + return 0, fmt.Errorf("unable to compute expected "+ + "pkScript: %w", err) + } + + // Search the spending tx outputs for the matching pkScript. + for i, txOut := range spendingTx.TxOut { + if bytes.Equal(txOut.PkScript, expectedPkScript) { + return uint32(i), nil + } + } + + // Log details to help diagnose why no match was found. + brarLog.Warnf("Expected second-level pkScript %x "+ + "(revokeKey=%x, tapTweak=%x) not found among "+ + "%d outputs of tx %s", + expectedPkScript, + revokeKey.SerializeCompressed(), + bo.secondLevelTapTweak[:], + len(spendingTx.TxOut), spendingTx.TxHash()) + for i, txOut := range spendingTx.TxOut { + brarLog.Warnf(" output %d: pkScript=%x value=%d", + i, txOut.PkScript, txOut.Value) + } + + return 0, fmt.Errorf("no output matching expected "+ + "second-level taproot pkScript found in tx %s", + spendingTx.TxHash()) + } + + return 0, fmt.Errorf("cannot derive expected pkScript: " + + "non-taproot or missing DoubleTweak") +} + // convertToSecondLevelRevoke takes a breached output, and a transaction that // spends it to the second level, and mutates the breach output into one that // is able to properly sweep that second level output. We'll use this function // when we go to sweep a breached commitment transaction, but the cheating // party has already attempted to take it to the second level. func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, - spendDetails *chainntnfs.SpendDetail) { + spendDetails *chainntnfs.SpendDetail, + auxResolver fn.Option[lnwallet.AuxContractResolver]) { // In this case, we'll modify the witness type of this output to // actually prepare for a second level revoke. @@ -557,24 +626,56 @@ func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, bo.witnessType = input.HtlcSecondLevelRevoke } - // We'll also redirect the outpoint to this second level output, so the - // spending transaction updates it inputs accordingly. + // Find the correct output index in the spending (second-level) tx. + // We cannot simply use SpenderInputIndex as the output index because + // the spending tx may have additional inputs (e.g., wallet UTXOs for + // fees in batched second-level txs) that cause input indices to + // diverge from output indices. spendingTx := spendDetails.SpendingTx - spendInputIndex := spendDetails.SpenderInputIndex + outputIndex, err := findSecondLevelOutputIndex(bo, spendingTx) + if err != nil { + // For taproot channels, the script match must succeed — + // the confirmed second-level tx necessarily contains our + // expected output script. A failure here indicates a bug + // in the derivation chain (wrong tap tweak, wrong + // revocation key, aux leaf mismatch). + if txscript.IsPayToTaproot( + bo.signDesc.Output.PkScript, + ) { + + brarLog.Errorf("BUG: cannot locate "+ + "second-level taproot output by "+ + "script match for %v: %v", + bo.outpoint, err) + + return + } + + // For non-taproot (SigHashSingle|AnyoneCanPay), + // input index always equals output index in + // 1-input-1-output second-level txs. + outputIndex = spendDetails.SpenderInputIndex + + brarLog.Warnf("Could not find matching second-"+ + "level output by script, falling back "+ + "to SpenderInputIndex=%d: %v", + outputIndex, err) + } + oldOp := bo.outpoint bo.outpoint = wire.OutPoint{ Hash: spendingTx.TxHash(), - Index: spendInputIndex, + Index: outputIndex, } // Next, we need to update the amount so we can do fee estimation // properly, and also so we can generate a valid signature as we need // to know the new input value (the second level transactions shaves // off some funds to fees). - newAmt := spendingTx.TxOut[spendInputIndex].Value + newAmt := spendingTx.TxOut[outputIndex].Value bo.amt = btcutil.Amount(newAmt) bo.signDesc.Output.Value = newAmt - bo.signDesc.Output.PkScript = spendingTx.TxOut[spendInputIndex].PkScript + bo.signDesc.Output.PkScript = spendingTx.TxOut[outputIndex].PkScript // For taproot outputs, the taptweak also needs to be swapped out. We // do this unconditionally as this field isn't used at all for segwit @@ -585,6 +686,56 @@ func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, // SignDescriptor. bo.signDesc.WitnessScript = bo.secondLevelWitnessScript + // Re-resolve the aux contract blob for the second-level witness + // type. The second-level tx is now known, so we pass it as the + // CommitTx in the resolution request. + if bo.resolveReq != nil && bo.resolveReq.KeyRing != nil { + secondLevelReq := *bo.resolveReq + secondLevelReq.CommitTx = bo.resolveReq.CommitTx.Copy() + secondLevelReq.Type = input.TaprootHtlcSecondLevelRevoke + secondLevelReq.SecondLevelTx = spendingTx + secondLevelReq.SecondLevelTxBlockHeight = uint32( + spendDetails.SpendingHeight, + ) + + brarLog.Infof("Re-resolving HTLC blob for second-level "+ + "revoke, htlcID=%v, htlcAmt=%v, "+ + "keyRing=%v, revokeKey=%v", + secondLevelReq.HtlcID, + secondLevelReq.HtlcAmt, + secondLevelReq.KeyRing != nil, + secondLevelReq.KeyRing != nil && + secondLevelReq.KeyRing.RevocationKey != nil) + + resolveBlob := fn.MapOptionZ( + auxResolver, + func(a lnwallet.AuxContractResolver, + ) fn.Result[tlv.Blob] { + + return a.ResolveContract( + secondLevelReq, + ) + }, + ) + if err := resolveBlob.Err(); err != nil { + brarLog.Errorf("Unable to re-resolve "+ + "second-level HTLC blob for %v: "+ + "%v — output will be skipped", + bo.outpoint, err) + + return + } + + bo.resolutionBlob = resolveBlob.OkToSome() + } else if bo.resolveReq == nil { + brarLog.Warnf("No resolve request template available " + + "for second-level HTLC, skipping blob update") + } + + // Update confHeight to the second-level tx's confirmation height + // so the sweeper computes correct CSV locktime for the justice tx. + bo.confHeight = uint32(spendDetails.SpendingHeight) + brarLog.Warnf("HTLC(%v) for ChannelPoint(%v) has been spent to the "+ "second-level, adjusting -> %v", oldOp, breachInfo.chanPoint, bo.outpoint) @@ -593,7 +744,8 @@ func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, // updateBreachInfo mutates the passed breachInfo by removing or converting any // outputs among the spends. It also counts the total and revoked funds swept // by our justice spends. -func updateBreachInfo(breachInfo *retributionInfo, spends []spend) ( +func updateBreachInfo(breachInfo *retributionInfo, spends []spend, + auxResolver fn.Option[lnwallet.AuxContractResolver]) ( btcutil.Amount, btcutil.Amount) { inputs := breachInfo.breachedOutputs @@ -642,6 +794,7 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend) ( // process. convertToSecondLevelRevoke( breachedOutput, breachInfo, s.detail, + auxResolver, ) continue @@ -728,6 +881,17 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, justiceCtx = justiceTxs.spendHTLCs } + // Also check the individual second-level sweeps. + if justiceCtx == nil { + for _, tx := range justiceTxs.spendSecondLevelHTLCs { + if matchesJusticeTx(tx) { + justiceCtx = tx + + break + } + } + } + // If this is one of our justice txs, notify the aux sweeper. if justiceCtx != nil { bumpReq := sweep.BumpRequest{ @@ -885,7 +1049,9 @@ Loop: ) // Update the breach info with the new spends. - t, r := updateBreachInfo(breachInfo, spends) + t, r := updateBreachInfo( + breachInfo, spends, b.cfg.AuxResolver, + ) totalFunds += t revokedFunds += r @@ -1177,6 +1343,12 @@ type breachedOutput struct { secondLevelWitnessScript []byte secondLevelTapTweak [32]byte + // resolveReq is a template ResolutionReq populated at breach + // detection time. It is used when an HTLC is taken to the second + // level and we need to re-resolve the contract with the updated + // witness type and the actual second-level spending tx. + resolveReq *lnwallet.ResolutionReq + witnessFunc input.WitnessGenerator resolutionBlob fn.Option[tlv.Blob] @@ -1463,9 +1635,10 @@ func newRetributionInfo(chanPoint *wire.OutPoint, ) // For taproot outputs, we also need to hold onto the second - // level tap tweak as well. + // level tap tweak and resolution request template. //nolint:ll htlcOutput.secondLevelTapTweak = breachedHtlc.SecondLevelTapTweak + htlcOutput.resolveReq = breachedHtlc.ResolveReq breachedOutputs = append(breachedOutputs, htlcOutput) } @@ -1582,6 +1755,12 @@ func (b *BreachArbitrator) createJusticeTx( } txs.spendSecondLevelHTLCs = secondLevelSweeps + brarLog.Infof("createJusticeTx: variants summary — spendAll=%v, "+ + "spendCommitOuts=%v, spendHTLCs=%v, "+ + "spendSecondLevelHTLCs=%d", + txs.spendAll != nil, txs.spendCommitOuts != nil, + txs.spendHTLCs != nil, len(txs.spendSecondLevelHTLCs)) + return txs, nil } @@ -1715,19 +1894,37 @@ func (b *BreachArbitrator) sweepSpendableOutputsTxn(txWeight lntypes.WeightUnit, // First, we'll add the extra sweep output if it exists, subtracting the // amount from the sweep amt. + var hasAuxOut bool if b.cfg.AuxSweeper.IsSome() { extraChangeOut.WhenOk(func(o sweep.SweepOutput) { sweepAmt -= o.Value txn.AddTxOut(&o.TxOut) + hasAuxOut = true }) } - // Next, we'll add the output to which our funds will be deposited. - txn.AddTxOut(&wire.TxOut{ - PkScript: pkScript.DeliveryAddress, - Value: sweepAmt, - }) + // If the sweep amount is positive, add the regular sweep output as + // usual. If it's non-positive but we have an aux output, we skip + // the BTC sweep output entirely — for custom (asset) channels the + // real value is carried by the aux output and the remaining BTC + // can all go towards fees. + switch { + case sweepAmt > 0: + txn.AddTxOut(&wire.TxOut{ + PkScript: pkScript.DeliveryAddress, + Value: sweepAmt, + }) + + case hasAuxOut: + brarLog.Infof("Dropping BTC sweep output (amount=%d), "+ + "all input BTC (%v) goes to fee + aux output", + sweepAmt, totalAmt) + + default: + return nil, fmt.Errorf("sweep amount is non-positive "+ + "(%d) and no aux output exists", sweepAmt) + } // TODO(roasbeef): add other output change modify sweep amt diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go index 7f0e56f311d..d7eb38e233d 100644 --- a/contractcourt/breach_arbitrator_test.go +++ b/contractcourt/breach_arbitrator_test.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -2616,7 +2617,7 @@ func TestUpdateBreachInfoCountsFinalTaprootRevokedFunds(t *testing.T) { SpendingTx: &wire.MsgTx{TxIn: []*wire.TxIn{{}}}, SpenderInputIndex: 0, }, - }}) + }}, fn.None[lnwallet.AuxContractResolver]()) // Assert: The amount contributes to both the total and revoked-funds // tallies and is removed from the remaining breach set. @@ -2625,6 +2626,357 @@ func TestUpdateBreachInfoCountsFinalTaprootRevokedFunds(t *testing.T) { require.Empty(t, breachInfo.breachedOutputs) } +// TestFindSecondLevelOutputIndex verifies that findSecondLevelOutputIndex +// correctly identifies the HTLC output in second-level transactions, including +// batched txs with multiple inputs and a wallet change output where input +// indices diverge from output indices. +func TestFindSecondLevelOutputIndex(t *testing.T) { + t.Parallel() + + // Generate a random key pair for the revocation base point. + revokeBasePriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + revokeBasePub := revokeBasePriv.PubKey() + + // Generate a commitment secret (used as DoubleTweak). + commitSecret, err := btcec.NewPrivateKey() + require.NoError(t, err) + commitPoint := commitSecret.PubKey() + + // Derive the revocation public key. + revokeKey := input.DeriveRevocationPubkey( + revokeBasePub, commitPoint, + ) + + // Create a second-level tap tweak (random 32 bytes). + var tapTweak [32]byte + _, err = crand.Read(tapTweak[:]) + require.NoError(t, err) + + // Compute the expected taproot output key and pkScript. + expectedOutputKey := txscript.ComputeTaprootOutputKey( + revokeKey, tapTweak[:], + ) + expectedPkScript, err := input.PayToTaprootScript( + expectedOutputKey, + ) + require.NoError(t, err) + + // Create a random unrelated pkScript for wallet change outputs. + unrelatedKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + unrelatedPkScript, err := input.PayToTaprootScript( + unrelatedKey.PubKey(), + ) + require.NoError(t, err) + + // Build the breachedOutput with taproot signDesc. + bo := &breachedOutput{ + signDesc: input.SignDescriptor{ + KeyDesc: keychain.KeyDescriptor{ + PubKey: revokeBasePub, + }, + DoubleTweak: commitSecret, + Output: &wire.TxOut{ + PkScript: expectedPkScript, + }, + }, + secondLevelTapTweak: tapTweak, + } + + tests := []struct { + name string + tx *wire.MsgTx + expectedIndex uint32 + expectErr bool + }{ + { + // Simple 1-input-1-output second-level tx. + name: "single output matches", + tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{ + { + PkScript: expectedPkScript, + Value: 50_000, + }, + }, + }, + expectedIndex: 0, + }, + { + // Batched tx: wallet UTXO input adds a + // change output before the HTLC output. + name: "batched tx - HTLC at index 1", + tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, // HTLC input + {}, // wallet UTXO + }, + TxOut: []*wire.TxOut{ + { + PkScript: unrelatedPkScript, + Value: 100_000, + }, + { + PkScript: expectedPkScript, + Value: 50_000, + }, + }, + }, + expectedIndex: 1, + }, + { + // Batched tx: HTLC output first, change + // output second. + name: "batched tx - HTLC at index 0", + tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, // wallet UTXO + {}, // HTLC input + }, + TxOut: []*wire.TxOut{ + { + PkScript: expectedPkScript, + Value: 50_000, + }, + { + PkScript: unrelatedPkScript, + Value: 100_000, + }, + }, + }, + expectedIndex: 0, + }, + { + // Batched tx with 3 outputs: HTLC in the + // middle. + name: "batched tx - HTLC at index 1 of 3", + tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, {}, {}, + }, + TxOut: []*wire.TxOut{ + { + PkScript: unrelatedPkScript, + Value: 100_000, + }, + { + PkScript: expectedPkScript, + Value: 50_000, + }, + { + PkScript: unrelatedPkScript, + Value: 200_000, + }, + }, + }, + expectedIndex: 1, + }, + { + // No matching output — should error. + name: "no match", + tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{ + { + PkScript: unrelatedPkScript, + Value: 50_000, + }, + }, + }, + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + idx, err := findSecondLevelOutputIndex( + bo, tc.tx, + ) + if tc.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedIndex, idx) + }) + } +} + +// TestSweepSpendableOutputsAuxOutput tests the three-way output construction +// logic in sweepSpendableOutputsTxn: positive sweep amount, aux-only output +// (BTC sweep dropped), and error when sweep is non-positive without aux. +func TestSweepSpendableOutputsAuxOutput(t *testing.T) { + t.Parallel() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pkScript, err := input.PayToTaprootScript(privKey.PubKey()) + require.NoError(t, err) + + // Build a P2WKH pkScript for the input (WitnessKeyHash needs + // a valid P2WKH output to sign). + p2wkhAddr := btcutil.Hash160( + privKey.PubKey().SerializeCompressed(), + ) + builder := txscript.NewScriptBuilder() + builder.AddOp(txscript.OP_0) + builder.AddData(p2wkhAddr) + p2wkhScript, err := builder.Script() + require.NoError(t, err) + + // Create a breached output with enough value to cover fees. + makeOutput := func(value int64) breachedOutput { + return breachedOutput{ + amt: btcutil.Amount(value), + outpoint: wire.OutPoint{ + Hash: chainhash.Hash{0x01}, + Index: 0, + }, + witnessType: input.WitnessKeyHash, + signDesc: input.SignDescriptor{ + KeyDesc: keychain.KeyDescriptor{ + PubKey: privKey.PubKey(), + }, + Output: &wire.TxOut{ + PkScript: p2wkhScript, + Value: value, + }, + }, + } + } + + sweepAddr := lnwallet.AddrWithKey{ + DeliveryAddress: pkScript, + } + + signer := input.NewMockSigner( + []*btcec.PrivateKey{privKey}, + &chaincfg.RegressionNetParams, + ) + + // Common config for the BreachArbitrator. + makeBrar := func( + auxSweeper fn.Option[sweep.AuxSweeper], + ) *BreachArbitrator { + + genScript := func() fn.Result[lnwallet.AddrWithKey] { + return fn.Ok(sweepAddr) + } + + return &BreachArbitrator{ + cfg: &BreachConfig{ + Signer: signer, + GenSweepScript: genScript, + Estimator: chainfee.NewStaticEstimator( + chainfee.FeePerKwFloor, 0, + ), + AuxSweeper: auxSweeper, + }, + } + } + + t.Run("positive sweep no aux", func(t *testing.T) { + brar := makeBrar(fn.None[sweep.AuxSweeper]()) + bo := makeOutput(1_000_000) + + result, err := brar.sweepSpendableOutputsTxn( + 200, &bo, + ) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have exactly 1 output (the BTC sweep). + require.Len(t, result.justiceTx.TxOut, 1) + }) + + t.Run("positive sweep with aux", func(t *testing.T) { + sweeper := &mockAuxSweeperWithOutput{ + outputValue: 10_000, + pkScript: pkScript, + } + brar := makeBrar(fn.Some[sweep.AuxSweeper](sweeper)) + bo := makeOutput(1_000_000) + + result, err := brar.sweepSpendableOutputsTxn( + 200, &bo, + ) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have 2 outputs: aux + BTC sweep. + require.Len(t, result.justiceTx.TxOut, 2) + }) + + t.Run("non-positive sweep with aux", func(t *testing.T) { + // Value so low that after fee the sweep amount is + // non-positive, but aux output saves it. + sweeper := &mockAuxSweeperWithOutput{ + outputValue: 100, + pkScript: pkScript, + } + brar := makeBrar(fn.Some[sweep.AuxSweeper](sweeper)) + + // Use a very high weight so fee exceeds input value. + bo := makeOutput(500) + + result, err := brar.sweepSpendableOutputsTxn( + 100_000, &bo, + ) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have only 1 output (aux only, BTC sweep + // dropped). + require.Len(t, result.justiceTx.TxOut, 1) + }) + + t.Run("non-positive sweep no aux errors", func(t *testing.T) { + brar := makeBrar(fn.None[sweep.AuxSweeper]()) + bo := makeOutput(500) + + _, err := brar.sweepSpendableOutputsTxn( + 100_000, &bo, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "non-positive") + }) +} + +// mockAuxSweeperWithOutput is a mock AuxSweeper that returns a configurable +// sweep output from DeriveSweepAddr. +type mockAuxSweeperWithOutput struct { + outputValue int64 + pkScript []byte +} + +func (m *mockAuxSweeperWithOutput) DeriveSweepAddr( + _ []input.Input, + _ lnwallet.AddrWithKey) fn.Result[sweep.SweepOutput] { + + return fn.Ok(sweep.SweepOutput{ + TxOut: wire.TxOut{ + PkScript: m.pkScript, + Value: m.outputValue, + }, + }) +} + +func (m *mockAuxSweeperWithOutput) ExtraBudgetForInputs( + _ []input.Input) fn.Result[btcutil.Amount] { + + return fn.Ok(btcutil.Amount(0)) +} + +func (m *mockAuxSweeperWithOutput) NotifyBroadcast( + _ *sweep.BumpRequest, _ *wire.MsgTx, + _ btcutil.Amount, _ map[wire.OutPoint]int, + _ bool) error { + + return nil +} + // mockAuxSweeperNotify is a mock implementation of sweep.AuxSweeper that tracks // calls to NotifyBroadcast for testing purposes. type mockAuxSweeperNotify struct { diff --git a/server.go b/server.go index 83131c6f2b3..1802be84180 100644 --- a/server.go +++ b/server.go @@ -1303,7 +1303,8 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, Store: contractcourt.NewRetributionStore( dbs.ChanStateDB, ), - AuxSweeper: s.implCfg.AuxSweeper, + AuxSweeper: s.implCfg.AuxSweeper, + AuxResolver: s.implCfg.AuxContractResolver, }, ) From e713ca73ba554e10009277fee8ef2f7858831a02 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:11 +0300 Subject: [PATCH 08/16] lnwallet+contractcourt: gate HTLC sighash on negotiated feature bit Replace the backwards-incompatible CustomBlob.IsSome() check for choosing SigHashDefault with a feature-bit negotiation approach via a new AuxSigner.HtlcSigHashType hook. Add ResolveHtlcSigHashType helper that queries the aux signer for a channel-specific sighash override based on negotiated features, falling back to the default HtlcSigHashType when no aux signer is present or the feature isn't negotiated. Thread the auxSigner through all HTLC second-level transaction signing and validation call sites. This ensures both channel parties agree on the sighash type through explicit feature negotiation rather than an implicit check that could cause sighash disagreement and forced channel closure when only one party has upgraded. --- contractcourt/chain_arbitrator.go | 2 ++ contractcourt/chain_watcher.go | 7 +++- input/size_test.go | 4 ++- lnwallet/aux_signer.go | 46 +++++++++++++++++++++++++ lnwallet/channel.go | 56 +++++++++++++++++++++++-------- lnwallet/channel_test.go | 3 ++ lnwallet/commitment.go | 7 ++-- lnwallet/mock.go | 8 +++++ 8 files changed, 115 insertions(+), 18 deletions(-) diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go index eac63cb32d1..701e17457ab 100644 --- a/contractcourt/chain_arbitrator.go +++ b/contractcourt/chain_arbitrator.go @@ -1148,6 +1148,7 @@ func (c *ChainArbitrator) WatchNewChannel(newChan *channeldb.OpenChannel) error extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, + auxSigner: c.cfg.AuxSigner, auxCloser: c.cfg.AuxCloser, chanCloseConfs: c.cfg.ChannelCloseConfs, }, @@ -1327,6 +1328,7 @@ func (c *ChainArbitrator) loadOpenChannels() error { extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, + auxSigner: c.cfg.AuxSigner, auxCloser: c.cfg.AuxCloser, chanCloseConfs: c.cfg.ChannelCloseConfs, }, diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index e45bb3dc99b..d961e65f6cb 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -271,6 +271,10 @@ type chainWatcherConfig struct { // auxResolver is used to supplement contract resolution. auxResolver fn.Option[lnwallet.AuxContractResolver] + // auxSigner is an optional signer that can be used to determine + // channel-specific HTLC sighash types based on negotiated features. + auxSigner fn.Option[lnwallet.AuxSigner] + // auxCloser is used to finalize cooperative closes. auxCloser fn.Option[AuxChanCloser] @@ -1435,7 +1439,7 @@ func (c *chainWatcher) dispatchLocalForceClose( forceClose, err := lnwallet.NewLocalForceCloseSummary( c.cfg.chanState, c.cfg.signer, commitSpend.SpendingTx, uint32(commitSpend.SpendingHeight), stateNum, - c.cfg.auxLeafStore, c.cfg.auxResolver, + c.cfg.auxLeafStore, c.cfg.auxResolver, c.cfg.auxSigner, ) if err != nil { return err @@ -1542,6 +1546,7 @@ func (c *chainWatcher) dispatchRemoteForceClose( uniClose, err := lnwallet.NewUnilateralCloseSummary( c.cfg.chanState, c.cfg.signer, commitSpend, remoteCommit, commitPoint, c.cfg.auxLeafStore, c.cfg.auxResolver, + c.cfg.auxSigner, ) if err != nil { return err diff --git a/input/size_test.go b/input/size_test.go index 88a66c75aef..22d11234ad8 100644 --- a/input/size_test.go +++ b/input/size_test.go @@ -1704,7 +1704,9 @@ func genSuccessTx(t *testing.T, chanType channeldb.ChannelType) *wire.MsgTx { }, } - sigHashType := lnwallet.HtlcSigHashType(channeldb.SingleFunderBit) + sigHashType := lnwallet.HtlcSigHashType( + channeldb.SingleFunderBit, + ) var successWitness [][]byte diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 79a7ca1dc09..14f49bfddd6 100644 --- a/lnwallet/aux_signer.go +++ b/lnwallet/aux_signer.go @@ -1,7 +1,9 @@ package lnwallet import ( + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" @@ -305,4 +307,48 @@ type AuxSigner interface { // sig jobs. VerifySecondLevelSigs(chanState AuxChanState, commitTx *wire.MsgTx, verifyJob []AuxVerifyJob) error + + // HtlcSigHashType returns the sighash type to use for HTLC + // second-level transactions for the given channel. The caller + // populates HtlcSigHashReq with either a ChanID (for live + // feature-negotiation lookups on new commitments) or a CommitBlob + // (for existing commitments where the blob is the source of truth), + // or both. The implementation decides the lookup strategy. + HtlcSigHashType( + req HtlcSigHashReq, + ) fn.Option[txscript.SigHashType] +} + +// HtlcSigHashReq is the request passed to AuxSigner.HtlcSigHashType. +// Callers populate either ChanID (for next-commitment signing/verification +// where live feature negotiation is authoritative) or CommitBlob (for +// existing commitments where the blob records what was actually used), or +// both. +type HtlcSigHashReq struct { + // ChanID identifies the channel for live feature-negotiation lookups. + // Set when determining the sighash for a new commitment being + // signed or verified. + ChanID fn.Option[lnwire.ChannelID] + + // CommitBlob is the commitment custom blob that may contain a cached + // SigHashDefault flag. Set when resolving the sighash for an + // already-persisted commitment (breach, resolution). + CommitBlob fn.Option[tlv.Blob] +} + +// ResolveHtlcSigHashType determines the sighash type to use for HTLC +// second-level transactions. It queries the aux signer (if present) with the +// given request. If the aux signer returns None (or is not present), it falls +// back to the default HtlcSigHashType based on channel type. +func ResolveHtlcSigHashType(chanType channeldb.ChannelType, + auxSigner fn.Option[AuxSigner], + req HtlcSigHashReq) txscript.SigHashType { + + sigHash := fn.FlatMapOption( + func(s AuxSigner) fn.Option[txscript.SigHashType] { + return s.HtlcSigHashType(req) + }, + )(auxSigner) + + return sigHash.UnwrapOr(HtlcSigHashType(chanType)) } diff --git a/lnwallet/channel.go b/lnwallet/channel.go index c3f3d8d3186..3a3408d68cd 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -3438,7 +3438,8 @@ func (lc *LightningChannel) evaluateNoOpHtlc(entry *paymentDescriptor, func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, chanState *channeldb.OpenChannel, leaseExpiry uint32, remoteCommitView *commitment, - leafStore fn.Option[AuxLeafStore]) ([]SignJob, []AuxSigJob, + leafStore fn.Option[AuxLeafStore], + auxSigner fn.Option[AuxSigner]) ([]SignJob, []AuxSigJob, chan struct{}, error) { var ( @@ -3451,7 +3452,14 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, txHash := remoteCommitView.txn.TxHash() dustLimit := remoteChanCfg.DustLimit feePerKw := remoteCommitView.feePerKw - sigHashType := HtlcSigHashType(chanType) + sigHashType := ResolveHtlcSigHashType( + chanType, auxSigner, HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: remoteCommitView.customBlob, + }, + ) // With the keys generated, we'll make a slice with enough capacity to // hold potentially all the HTLCs. The actual slice may be a bit @@ -4314,7 +4322,7 @@ func (lc *LightningChannel) SignNextCommitment( } sigBatch, auxSigBatch, cancelChan, err := genRemoteHtlcSigJobs( keyRing, lc.channelState, leaseExpiry, newCommitView, - lc.leafStore, + lc.leafStore, lc.auxSigner, ) if err != nil { return nil, err @@ -5080,7 +5088,14 @@ func genHtlcSigValidationJobs(chanState *channeldb.OpenChannel, txHash := localCommitmentView.txn.TxHash() feePerKw := localCommitmentView.feePerKw - sigHashType := HtlcSigHashType(chanType) + sigHashType := ResolveHtlcSigHashType( + chanType, auxSigner, HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: localCommitmentView.customBlob, + }, + ) // With the required state generated, we'll create a slice with large // enough capacity to hold verification jobs for all HTLC's in this @@ -7174,7 +7189,8 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen signer input.Signer, commitSpend *chainntnfs.SpendDetail, remoteCommit channeldb.ChannelCommitment, commitPoint *btcec.PublicKey, leafStore fn.Option[AuxLeafStore], - auxResolver fn.Option[AuxContractResolver]) (*UnilateralCloseSummary, + auxResolver fn.Option[AuxContractResolver], + auxSigner fn.Option[AuxSigner]) (*UnilateralCloseSummary, error) { // First, we'll generate the commitment point and the revocation point @@ -7217,7 +7233,7 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen &chanState.RemoteChanCfg, commitSpend.SpendingTx, commitTxHeight, chanState.ChanType, isRemoteInitiator, leaseExpiry, chanState, auxResult.AuxLeaves, - auxResolver, + auxResolver, auxSigner, ) if err != nil { return nil, fmt.Errorf("unable to create htlc resolutions: %w", @@ -7527,6 +7543,7 @@ func newOutgoingHtlcResolution(signer input.Signer, chanType channeldb.ChannelType, chanState *channeldb.OpenChannel, auxLeaves fn.Option[CommitAuxLeaves], auxResolver fn.Option[AuxContractResolver], + auxSigner fn.Option[AuxSigner], ) (*OutgoingHtlcResolution, error) { op := wire.OutPoint{ @@ -7691,7 +7708,11 @@ func newOutgoingHtlcResolution(signer input.Signer, // With the sign desc created, we can now construct the full witness // for the timeout transaction, and populate it as well. - sigHashType := HtlcSigHashType(chanType) + sigHashType := ResolveHtlcSigHashType( + chanType, auxSigner, HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + ) var timeoutWitness wire.TxWitness if scriptTree, ok := htlcScriptInfo.(input.TapscriptDescriptor); ok { timeoutSignDesc.SignMethod = input.TaprootScriptSpendSignMethod @@ -7902,6 +7923,7 @@ func newIncomingHtlcResolution(signer input.Signer, chanType channeldb.ChannelType, chanState *channeldb.OpenChannel, auxLeaves fn.Option[CommitAuxLeaves], auxResolver fn.Option[AuxContractResolver], + auxSigner fn.Option[AuxSigner], ) (*IncomingHtlcResolution, error) { op := wire.OutPoint{ @@ -8064,7 +8086,11 @@ func newIncomingHtlcResolution(signer input.Signer, // will be supplied by the contract resolver, either directly or when it // becomes known. var successWitness wire.TxWitness - sigHashType := HtlcSigHashType(chanType) + sigHashType := ResolveHtlcSigHashType( + chanType, auxSigner, HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + ) if scriptTree, ok := scriptInfo.(input.TapscriptDescriptor); ok { successSignDesc.SignMethod = input.TaprootScriptSpendSignMethod @@ -8395,7 +8421,8 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, chanType channeldb.ChannelType, isCommitFromInitiator bool, leaseExpiry uint32, chanState *channeldb.OpenChannel, auxLeaves fn.Option[CommitAuxLeaves], - auxResolver fn.Option[AuxContractResolver]) (*HtlcResolutions, error) { + auxResolver fn.Option[AuxContractResolver], + auxSigner fn.Option[AuxSigner]) (*HtlcResolutions, error) { // TODO(roasbeef): don't need to swap csv delay? dustLimit := remoteChanCfg.DustLimit @@ -8431,6 +8458,7 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, &htlc, keyRing, feePerKw, uint32(csvDelay), leaseExpiry, whoseCommit, isCommitFromInitiator, chanType, chanState, auxLeaves, auxResolver, + auxSigner, ) if err != nil { return nil, fmt.Errorf("incoming resolution "+ @@ -8445,7 +8473,7 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, signer, localChanCfg, commitTx, commitTxHeight, &htlc, keyRing, feePerKw, uint32(csvDelay), leaseExpiry, whoseCommit, isCommitFromInitiator, chanType, chanState, - auxLeaves, auxResolver, + auxLeaves, auxResolver, auxSigner, ) if err != nil { return nil, fmt.Errorf("outgoing resolution "+ @@ -8592,7 +8620,7 @@ func (lc *LightningChannel) ForceClose(opts ...ForceCloseOpt) ( summary, err := NewLocalForceCloseSummary( lc.channelState, lc.Signer, commitTx, 0, localCommitment.CommitHeight, lc.leafStore, - lc.auxResolver, + lc.auxResolver, lc.auxSigner, ) if err != nil { return nil, fmt.Errorf("unable to gen force close "+ @@ -8611,8 +8639,8 @@ func (lc *LightningChannel) ForceClose(opts ...ForceCloseOpt) ( func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, signer input.Signer, commitTx *wire.MsgTx, commitTxHeight uint32, stateNum uint64, leafStore fn.Option[AuxLeafStore], - auxResolver fn.Option[AuxContractResolver]) (*LocalForceCloseSummary, - error) { + auxResolver fn.Option[AuxContractResolver], + auxSigner fn.Option[AuxSigner]) (*LocalForceCloseSummary, error) { // Re-derive the original pkScript for to-self output within the // commitment transaction. We'll need this to find the corresponding @@ -8781,7 +8809,7 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, signer, localCommit.Htlcs, keyRing, &chanState.LocalChanCfg, &chanState.RemoteChanCfg, commitTx, commitTxHeight, chanState.ChanType, chanState.IsInitiator, leaseExpiry, - chanState, auxResult.AuxLeaves, auxResolver, + chanState, auxResult.AuxLeaves, auxResolver, auxSigner, ) if err != nil { return nil, fmt.Errorf("unable to gen htlc resolution: %w", err) diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index ffc026caccb..62e693d0d8f 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -6485,6 +6485,7 @@ func TestChannelUnilateralCloseHtlcResolution(t *testing.T) { aliceChannel.channelState.RemoteCurrentRevocation, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.NoError(t, err, "unable to create alice close summary") @@ -6631,6 +6632,7 @@ func TestChannelUnilateralClosePendingCommit(t *testing.T) { aliceChannel.channelState.RemoteCurrentRevocation, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.NoError(t, err, "unable to create alice close summary") @@ -6650,6 +6652,7 @@ func TestChannelUnilateralClosePendingCommit(t *testing.T) { aliceChannel.channelState.RemoteNextRevocation, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.NoError(t, err, "unable to create alice close summary") diff --git a/lnwallet/commitment.go b/lnwallet/commitment.go index f715b9336cf..262678e7452 100644 --- a/lnwallet/commitment.go +++ b/lnwallet/commitment.go @@ -375,8 +375,11 @@ func CommitScriptToRemote(chanType channeldb.ChannelType, initiator bool, } } -// HtlcSigHashType returns the sighash type to use for HTLC success and timeout -// transactions given the channel type. +// HtlcSigHashType returns the default sighash type to use for HTLC success +// and timeout transactions given the channel type. For channels where an +// AuxSigner is available, use ResolveHtlcSigHashType instead, which queries +// the aux signer for a channel-specific override based on negotiated feature +// bits. func HtlcSigHashType(chanType channeldb.ChannelType) txscript.SigHashType { if chanType.HasAnchors() { return txscript.SigHashSingle | txscript.SigHashAnyOneCanPay diff --git a/lnwallet/mock.go b/lnwallet/mock.go index de1a8b292fb..c2c0afe890d 100644 --- a/lnwallet/mock.go +++ b/lnwallet/mock.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" base "github.com/btcsuite/btcwallet/wallet" @@ -511,6 +512,13 @@ func (a *MockAuxSigner) VerifySecondLevelSigs(chanState AuxChanState, return args.Error(0) } +// HtlcSigHashType returns None, deferring to the default sighash behavior. +func (a *MockAuxSigner) HtlcSigHashType( + _ HtlcSigHashReq) fn.Option[txscript.SigHashType] { + + return fn.None[txscript.SigHashType]() +} + type MockAuxContractResolver struct{} // ResolveContract is called to resolve a contract that needs From 918440bad1c9f8a5348f1cf3d82f9bd11c6e8ae5 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:12 +0300 Subject: [PATCH 09/16] lnwallet: bake fees into second-level HTLCs under SigHashDefault When the channel uses SigHashDefault for HTLC second-level transactions, the sweeper cannot add wallet inputs to pay fees (that would invalidate the peer's signature). Instead, the fee must be baked into the pre-signed transaction at commitment signing time. Add sigHashDefaultFeeRate (3x FeePerKwFloor = 759 sat/kw) and thread a sigHashDefault bool through HtlcTimeoutFee, HtlcSuccessFee, HtlcIsDust, CommitmentBuilder, and all call sites in channel.go, htlcswitch/link.go, and the HTLC resolution construction paths. This results in: - Timeout tx fee: 759 * 645 / 1000 = 489 sat - Success tx fee: 759 * 705 / 1000 = 535 sat The 3x multiplier ensures the pre-signed tx clears the mempool minimum even when nodes compute fee rate against raw serialized size rather than virtual size. Add an IsSigHashDefault convenience helper and IsChanSigHashDefault / isSigHashDefault methods on LightningChannel. For next-commitment call sites (genRemoteHtlcSigJobs, genHtlcSigValidationJobs), extract the sigHashReq into a local variable so it can be shared between ResolveHtlcSigHashType and IsSigHashDefault. Use chanState.LocalCommitment.CustomBlob as the CommitBlob source (instead of the in-memory commitment view blob) since the persisted commitment already carries the SigHashDefault flag. NewBreachRetribution now takes an AuxSigner option so it can resolve the sighash type when computing dust thresholds for HTLC retribution data. --- contractcourt/breach_arbitrator_test.go | 2 + contractcourt/chain_watcher.go | 1 + htlcswitch/link.go | 7 +- htlcswitch/mailbox_test.go | 2 +- htlcswitch/mock.go | 1 + lnwallet/aux_signer.go | 13 +++ lnwallet/channel.go | 142 +++++++++++++++++++----- lnwallet/channel_test.go | 103 +++++++++++++++-- lnwallet/commitment.go | 57 ++++++++-- server.go | 1 + 10 files changed, 277 insertions(+), 52 deletions(-) diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go index d7eb38e233d..0e33147ff78 100644 --- a/contractcourt/breach_arbitrator_test.go +++ b/contractcourt/breach_arbitrator_test.go @@ -1586,6 +1586,7 @@ func testBreachSpends(t *testing.T, test breachTest) { fn.Some[lnwallet.AuxContractResolver]( &lnwallet.MockAuxContractResolver{}, ), + fn.None[lnwallet.AuxSigner](), ) require.NoError(t, err, "unable to create breach retribution") @@ -1799,6 +1800,7 @@ func TestBreachDelayedJusticeConfirmation(t *testing.T) { fn.Some[lnwallet.AuxContractResolver]( &lnwallet.MockAuxContractResolver{}, ), + fn.None[lnwallet.AuxSigner](), ) require.NoError(t, err, "unable to create breach retribution") diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index d961e65f6cb..cad6ba85013 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -1096,6 +1096,7 @@ func (c *chainWatcher) handlePossibleBreach(commitSpend *chainntnfs.SpendDetail, retribution, err := lnwallet.NewBreachRetribution( c.cfg.chanState, broadcastStateNum, spendHeight, commitSpend.SpendingTx, c.cfg.auxLeafStore, c.cfg.auxResolver, + c.cfg.auxSigner, ) switch { diff --git a/htlcswitch/link.go b/htlcswitch/link.go index dd0f5d37ac2..b1009b6a712 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -2205,7 +2205,8 @@ func (l *channelLink) getDustClosure() dustClosure { remoteDustLimit := l.channel.State().RemoteChanCfg.DustLimit chanType := l.channel.State().ChanType - return dustHelper(chanType, localDustLimit, remoteDustLimit) + return dustHelper(chanType, localDustLimit, remoteDustLimit, + l.channel.IsChanSigHashDefault()) } // getCommitFee returns either the local or remote CommitFee in satoshis. This @@ -2373,7 +2374,7 @@ type dustClosure func(feerate chainfee.SatPerKWeight, incoming bool, // dustHelper is used to construct the dustClosure. func dustHelper(chantype channeldb.ChannelType, localDustLimit, - remoteDustLimit btcutil.Amount) dustClosure { + remoteDustLimit btcutil.Amount, sigHashDefault bool) dustClosure { isDust := func(feerate chainfee.SatPerKWeight, incoming bool, whoseCommit lntypes.ChannelParty, amt btcutil.Amount) bool { @@ -2387,7 +2388,7 @@ func dustHelper(chantype channeldb.ChannelType, localDustLimit, return lnwallet.HtlcIsDust( chantype, incoming, whoseCommit, feerate, amt, - dustLimit, + dustLimit, sigHashDefault, ) } diff --git a/htlcswitch/mailbox_test.go b/htlcswitch/mailbox_test.go index 57a581c4b14..e03b5bf103b 100644 --- a/htlcswitch/mailbox_test.go +++ b/htlcswitch/mailbox_test.go @@ -603,7 +603,7 @@ func testMailBoxDust(t *testing.T, chantype channeldb.ChannelType) { localDustLimit := btcutil.Amount(400) remoteDustLimit := btcutil.Amount(500) - isDust := dustHelper(chantype, localDustLimit, remoteDustLimit) + isDust := dustHelper(chantype, localDustLimit, remoteDustLimit, false) ctx.mailbox.SetDustClosure(isDust) // The first packet will be dust according to the remote dust limit, diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go index 70bd73c37d2..0959cffd09e 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -832,6 +832,7 @@ func (f *mockChannelLink) getDustClosure() dustClosure { dustLimit := btcutil.Amount(400) return dustHelper( channeldb.SingleFunderTweaklessBit, dustLimit, dustLimit, + false, ) } diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 14f49bfddd6..e0560ee3972 100644 --- a/lnwallet/aux_signer.go +++ b/lnwallet/aux_signer.go @@ -352,3 +352,16 @@ func ResolveHtlcSigHashType(chanType channeldb.ChannelType, return sigHash.UnwrapOr(HtlcSigHashType(chanType)) } + +// IsSigHashDefault returns true if the resolved HTLC sighash type for the +// given channel is SigHashDefault. This is used to determine whether +// second-level HTLC transactions must carry their own fee (since the sweeper +// cannot add wallet inputs under SigHashDefault). +func IsSigHashDefault(chanType channeldb.ChannelType, + auxSigner fn.Option[AuxSigner], + req HtlcSigHashReq) bool { + + return ResolveHtlcSigHashType( + chanType, auxSigner, req, + ) == txscript.SigHashDefault +} diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 3a3408d68cd..aa4c953a1f1 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -310,7 +310,7 @@ func locateOutputIndex(p *paymentDescriptor, tx *wire.MsgTx, // the current state to disk, and also to locate the paymentDescriptor // corresponding to HTLC outputs in the commitment transaction. func (c *commitment) populateHtlcIndexes(chanType channeldb.ChannelType, - cltvs []uint32) error { + cltvs []uint32, sigHashDefault bool) error { // First, we'll set up some state to allow us to locate the output // index of the all the HTLCs within the commitment transaction. We @@ -326,6 +326,7 @@ func (c *commitment) populateHtlcIndexes(chanType channeldb.ChannelType, isDust := HtlcIsDust( chanType, incoming, c.whoseCommit, c.feePerKw, htlc.Amount.ToSatoshis(), c.dustLimit, + sigHashDefault, ) var err error @@ -478,6 +479,28 @@ func (c *commitment) toDiskCommit( return commit } +// IsChanSigHashDefault returns whether HTLC second-level transactions for +// this channel use SigHashDefault. +func (lc *LightningChannel) IsChanSigHashDefault() bool { + chanID := lnwire.NewChanIDFromOutPoint( + lc.channelState.FundingOutpoint, + ) + + return IsSigHashDefault( + lc.channelState.ChanType, + lc.auxSigner, + HtlcSigHashReq{ + ChanID: fn.Some(chanID), + CommitBlob: lc.channelState.LocalCommitment.CustomBlob, + }, + ) +} + +// isSigHashDefault is an internal alias for IsChanSigHashDefault. +func (lc *LightningChannel) isSigHashDefault() bool { + return lc.IsChanSigHashDefault() +} + // diskHtlcToPayDesc converts an HTLC previously written to disk within a // commitment state to the form required to manipulate in memory within the // commitment struct and updateLog. This function is used when we need to @@ -506,6 +529,7 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight, isDustLocal := HtlcIsDust( chanType, htlc.Incoming, lntypes.Local, feeRate, htlc.Amt.ToSatoshis(), lc.channelState.LocalChanCfg.DustLimit, + lc.isSigHashDefault(), ) localCommitKeys := commitKeys.GetForParty(lntypes.Local) if !isDustLocal && localCommitKeys != nil { @@ -523,6 +547,7 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight, isDustRemote := HtlcIsDust( chanType, htlc.Incoming, lntypes.Remote, feeRate, htlc.Amt.ToSatoshis(), lc.channelState.RemoteChanCfg.DustLimit, + lc.isSigHashDefault(), ) remoteCommitKeys := commitKeys.GetForParty(lntypes.Remote) if !isDustRemote && remoteCommitKeys != nil { @@ -1001,7 +1026,19 @@ func NewLightningChannel(signer input.Signer, commitChains: commitChains, channelState: state, commitBuilder: NewCommitmentBuilder( - state, opts.leafStore, + state, opts.leafStore, IsSigHashDefault( + state.ChanType, opts.auxSigner, + HtlcSigHashReq{ + ChanID: fn.Some( + lnwire.NewChanIDFromOutPoint( + state.FundingOutpoint, + ), + ), + CommitBlob: state. + LocalCommitment. + CustomBlob, + }, + ), ), updateLogs: updateLogs, Capacity: state.Capacity, @@ -1160,6 +1197,7 @@ func (lc *LightningChannel) logUpdateToPayDesc(logUpdate *channeldb.LogUpdate, isDustRemote := HtlcIsDust( lc.channelState.ChanType, false, lntypes.Remote, feeRate, wireMsg.Amount.ToSatoshis(), remoteDustLimit, + lc.isSigHashDefault(), ) if !isDustRemote { auxLeaf := fn.FlatMapOption( @@ -2107,7 +2145,8 @@ type BreachRetribution struct { func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, breachHeight uint32, spendTx *wire.MsgTx, leafStore fn.Option[AuxLeafStore], - auxResolver fn.Option[AuxContractResolver]) (*BreachRetribution, + auxResolver fn.Option[AuxContractResolver], + auxSigner fn.Option[AuxSigner]) (*BreachRetribution, error) { // Query the on-disk revocation log for the snapshot which was recorded @@ -2222,7 +2261,7 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, br, ourAmt, theirAmt, err = createBreachRetributionLegacy( revokedLogLegacy, chanState, keyRing, commitmentSecret, ourScript, theirScript, leaseExpiry, auxResolver, - breachHeight, + auxSigner, breachHeight, ) if err != nil { return nil, err @@ -2740,6 +2779,7 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, commitmentSecret *btcec.PrivateKey, ourScript, theirScript input.ScriptDescriptor, leaseExpiry uint32, auxResolver fn.Option[AuxContractResolver], + auxSigner fn.Option[AuxSigner], breachHeight uint32) (*BreachRetribution, int64, int64, error) { commitHash := revokedLog.CommitTx.TxHash() @@ -2773,6 +2813,14 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chainfee.SatPerKWeight(revokedLog.FeePerKw), htlc.Amt.ToSatoshis(), chanState.RemoteChanCfg.DustLimit, + IsSigHashDefault( + chanState.ChanType, + auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState. + LocalCommitment.CustomBlob, + }, + ), ) { continue @@ -2819,6 +2867,7 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, func HtlcIsDust(chanType channeldb.ChannelType, incoming bool, whoseCommit lntypes.ChannelParty, feePerKw chainfee.SatPerKWeight, htlcAmt, dustLimit btcutil.Amount, + sigHashDefault bool, ) bool { // First we'll determine the fee required for this HTLC based on if this is @@ -2830,25 +2879,25 @@ func HtlcIsDust(chanType channeldb.ChannelType, // If this is an incoming HTLC on our commitment transaction, then the // second-level transaction will be a success transaction. case incoming && whoseCommit.IsLocal(): - htlcFee = HtlcSuccessFee(chanType, feePerKw) + htlcFee = HtlcSuccessFee(chanType, feePerKw, sigHashDefault) // If this is an incoming HTLC on their commitment transaction, then // we'll be using a second-level timeout transaction as they've added // this HTLC. case incoming && whoseCommit.IsRemote(): - htlcFee = HtlcTimeoutFee(chanType, feePerKw) + htlcFee = HtlcTimeoutFee(chanType, feePerKw, sigHashDefault) // If this is an outgoing HTLC on our commitment transaction, then // we'll be using a timeout transaction as we're the sender of the // HTLC. case !incoming && whoseCommit.IsLocal(): - htlcFee = HtlcTimeoutFee(chanType, feePerKw) + htlcFee = HtlcTimeoutFee(chanType, feePerKw, sigHashDefault) // If this is an outgoing HTLC on their commitment transaction, then // we'll be using an HTLC success transaction as they're the receiver // of this HTLC. case !incoming && whoseCommit.IsRemote(): - htlcFee = HtlcSuccessFee(chanType, feePerKw) + htlcFee = HtlcSuccessFee(chanType, feePerKw, sigHashDefault) } return (htlcAmt - htlcFee) < dustLimit @@ -3080,7 +3129,8 @@ func (lc *LightningChannel) fetchCommitmentView( // locations of each HTLC in the commitment state. We pass in the sorted // slice of CLTV deltas in order to properly locate HTLCs that otherwise // have the same payment hash and amount. - err = c.populateHtlcIndexes(lc.channelState.ChanType, commitTx.cltvs) + err = c.populateHtlcIndexes(lc.channelState.ChanType, commitTx.cltvs, + lc.isSigHashDefault()) if err != nil { return nil, err } @@ -3452,13 +3502,14 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, txHash := remoteCommitView.txn.TxHash() dustLimit := remoteChanCfg.DustLimit feePerKw := remoteCommitView.feePerKw + sigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: chanState.LocalCommitment.CustomBlob, + } sigHashType := ResolveHtlcSigHashType( - chanType, auxSigner, HtlcSigHashReq{ - ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( - chanState.FundingOutpoint, - )), - CommitBlob: remoteCommitView.customBlob, - }, + chanType, auxSigner, sigHashReq, ) // With the keys generated, we'll make a slice with enough capacity to @@ -3489,10 +3540,14 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, // For each outgoing and incoming HTLC, if the HTLC isn't considered a // dust output after taking into account second-level HTLC fees, then a // sigJob will be generated and appended to the current batch. + sigHashDefault := IsSigHashDefault( + chanType, auxSigner, sigHashReq, + ) for _, htlc := range remoteCommitView.incomingHTLCs { if HtlcIsDust( chanType, true, lntypes.Remote, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + sigHashDefault, ) { continue @@ -3509,7 +3564,7 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, // HTLC timeout transaction for them. The output of the timeout // transaction needs to account for fees, so we'll compute the // required fee and output now. - htlcFee := HtlcTimeoutFee(chanType, feePerKw) + htlcFee := HtlcTimeoutFee(chanType, feePerKw, sigHashDefault) outputAmt := htlc.Amount.ToSatoshis() - htlcFee auxLeaf := fn.FlatMapOption( @@ -3576,6 +3631,7 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, if HtlcIsDust( chanType, false, lntypes.Remote, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + sigHashDefault, ) { continue @@ -3590,7 +3646,7 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, // HTLC success transaction for them. The output of the timeout // transaction needs to account for fees, so we'll compute the // required fee and output now. - htlcFee := HtlcSuccessFee(chanType, feePerKw) + htlcFee := HtlcSuccessFee(chanType, feePerKw, sigHashDefault) outputAmt := htlc.Amount.ToSatoshis() - htlcFee auxLeaf := fn.FlatMapOption( @@ -5032,6 +5088,7 @@ func (lc *LightningChannel) computeView(view *HtlcView, if HtlcIsDust( lc.channelState.ChanType, false, whoseCommitChain, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + lc.isSigHashDefault(), ) { continue @@ -5043,6 +5100,7 @@ func (lc *LightningChannel) computeView(view *HtlcView, if HtlcIsDust( lc.channelState.ChanType, true, whoseCommitChain, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + lc.isSigHashDefault(), ) { continue @@ -5088,13 +5146,17 @@ func genHtlcSigValidationJobs(chanState *channeldb.OpenChannel, txHash := localCommitmentView.txn.TxHash() feePerKw := localCommitmentView.feePerKw + sigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: chanState.LocalCommitment.CustomBlob, + } sigHashType := ResolveHtlcSigHashType( - chanType, auxSigner, HtlcSigHashReq{ - ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( - chanState.FundingOutpoint, - )), - CommitBlob: localCommitmentView.customBlob, - }, + chanType, auxSigner, sigHashReq, + ) + sigHashDefault := IsSigHashDefault( + chanType, auxSigner, sigHashReq, ) // With the required state generated, we'll create a slice with large @@ -5167,7 +5229,7 @@ func genHtlcSigValidationJobs(chanState *channeldb.OpenChannel, Index: uint32(htlc.localOutputIndex), } - htlcFee := HtlcSuccessFee(chanType, feePerKw) + htlcFee := HtlcSuccessFee(chanType, feePerKw, sigHashDefault) outputAmt := htlc.Amount.ToSatoshis() - htlcFee auxLeaf := fn.FlatMapOption(func( @@ -5260,7 +5322,7 @@ func genHtlcSigValidationJobs(chanState *channeldb.OpenChannel, Index: uint32(htlc.localOutputIndex), } - htlcFee := HtlcTimeoutFee(chanType, feePerKw) + htlcFee := HtlcTimeoutFee(chanType, feePerKw, sigHashDefault) outputAmt := htlc.Amount.ToSatoshis() - htlcFee auxLeaf := fn.FlatMapOption(func( @@ -6405,6 +6467,7 @@ func (lc *LightningChannel) GetDustSum(whoseCommit lntypes.ChannelParty, // amount to the dust sum. if HtlcIsDust( chanType, false, whoseCommit, feeRate, amt, dustLimit, + lc.isSigHashDefault(), ) { dustSum += pd.Amount @@ -6424,7 +6487,7 @@ func (lc *LightningChannel) GetDustSum(whoseCommit lntypes.ChannelParty, // amount to the dust sum. if HtlcIsDust( chanType, true, whoseCommit, feeRate, - amt, dustLimit, + amt, dustLimit, lc.isSigHashDefault(), ) { dustSum += pd.Amount @@ -7662,7 +7725,12 @@ func newOutgoingHtlcResolution(signer input.Signer, // In order to properly reconstruct the HTLC transaction, we'll need to // re-calculate the fee required at this state, so we can add the // correct output value amount to the transaction. - htlcFee := HtlcTimeoutFee(chanType, feePerKw) + htlcFee := HtlcTimeoutFee(chanType, feePerKw, IsSigHashDefault( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + )) secondLevelOutputAmt := htlc.Amt.ToSatoshis() - htlcFee // With the fee calculated, re-construct the second level timeout @@ -8047,7 +8115,12 @@ func newIncomingHtlcResolution(signer input.Signer, // // First, we'll reconstruct the original HTLC success transaction, // taking into account the fee rate used. - htlcFee := HtlcSuccessFee(chanType, feePerKw) + htlcFee := HtlcSuccessFee(chanType, feePerKw, IsSigHashDefault( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + )) secondLevelOutputAmt := htlc.Amt.ToSatoshis() - htlcFee successTx, err := CreateHtlcSuccessTx( chanType, isCommitFromInitiator, op, secondLevelOutputAmt, @@ -8443,6 +8516,13 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, if HtlcIsDust( chanType, htlc.Incoming, whoseCommit, feePerKw, htlc.Amt.ToSatoshis(), dustLimit, + IsSigHashDefault( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState. + LocalCommitment.CustomBlob, + }, + ), ) { continue @@ -9567,7 +9647,8 @@ func (lc *LightningChannel) availableCommitmentBalance(view *HtlcView, // For an extra HTLC fee to be paid on our commitment, the HTLC must be // large enough to make a non-dust HTLC timeout transaction. htlcFee := lnwire.NewMSatFromSatoshis( - HtlcTimeoutFee(lc.channelState.ChanType, feePerKw), + HtlcTimeoutFee(lc.channelState.ChanType, feePerKw, + lc.isSigHashDefault()), ) // If we are looking at the remote commitment, we must use the remote @@ -9577,7 +9658,8 @@ func (lc *LightningChannel) availableCommitmentBalance(view *HtlcView, lc.channelState.RemoteChanCfg.DustLimit, ) htlcFee = lnwire.NewMSatFromSatoshis( - HtlcSuccessFee(lc.channelState.ChanType, feePerKw), + HtlcSuccessFee(lc.channelState.ChanType, feePerKw, + lc.isSigHashDefault()), ) } diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index 62e693d0d8f..7108b98d099 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -1563,6 +1563,7 @@ func TestHTLCDustLimit(t *testing.T) { chainfee.SatPerKWeight( aliceChannel.channelState.LocalCommitment.FeePerKw, ), + false, )) htlcAmount := lnwire.NewMSatFromSatoshis(htlcSat) @@ -1657,10 +1658,10 @@ func TestHTLCSigNumber(t *testing.T) { require.NoError(t, err, "unable to get fee") belowDust := btcutil.Amount(500) + HtlcTimeoutFee( - channeldb.SingleFunderTweaklessBit, feePerKw, + channeldb.SingleFunderTweaklessBit, feePerKw, false, ) aboveDust := btcutil.Amount(1400) + HtlcSuccessFee( - channeldb.SingleFunderTweaklessBit, feePerKw, + channeldb.SingleFunderTweaklessBit, feePerKw, false, ) // =================================================================== @@ -1808,6 +1809,7 @@ func TestChannelBalanceDustLimit(t *testing.T) { chainfee.SatPerKWeight( aliceChannel.channelState.LocalCommitment.FeePerKw, ), + false, ) htlcAmount := lnwire.NewMSatFromSatoshis(htlcSat) @@ -5782,11 +5784,12 @@ func TestChanAvailableBalanceNearHtlcFee(t *testing.T) { commitFee := lnwire.NewMSatFromSatoshis( aliceChannel.channelState.LocalCommitment.CommitFee, ) + chanType := aliceChannel.channelState.ChanType htlcTimeoutFee := lnwire.NewMSatFromSatoshis( - HtlcTimeoutFee(aliceChannel.channelState.ChanType, feeRate), + HtlcTimeoutFee(chanType, feeRate, false), ) htlcSuccessFee := lnwire.NewMSatFromSatoshis( - HtlcSuccessFee(aliceChannel.channelState.ChanType, feeRate), + HtlcSuccessFee(chanType, feeRate, false), ) // Helper method to check the current reported balance. @@ -5953,11 +5956,12 @@ func TestChanCommitWeightDustHtlcs(t *testing.T) { feeRate := chainfee.SatPerKWeight( aliceChannel.channelState.LocalCommitment.FeePerKw, ) + chanType := aliceChannel.channelState.ChanType htlcTimeoutFee := lnwire.NewMSatFromSatoshis( - HtlcTimeoutFee(aliceChannel.channelState.ChanType, feeRate), + HtlcTimeoutFee(chanType, feeRate, false), ) htlcSuccessFee := lnwire.NewMSatFromSatoshis( - HtlcSuccessFee(aliceChannel.channelState.ChanType, feeRate), + HtlcSuccessFee(chanType, feeRate, false), ) // Helper method to add an HTLC from Alice to Bob. @@ -7306,6 +7310,7 @@ func TestChanReserveLocalInitiatorDustHtlc(t *testing.T) { chainfee.SatPerKWeight( aliceChannel.channelState.LocalCommitment.FeePerKw, ), + false, ) // Set Alice's channel reserve to be low enough to carry the value of @@ -7485,6 +7490,7 @@ func TestNewBreachRetributionSkipsDustHtlcs(t *testing.T) { aliceChannel.channelState, revokedStateNum, 100, breachTx, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.NoError(t, err, "unable to create breach retribution") @@ -10387,7 +10393,7 @@ func TestCreateBreachRetributionLegacy(t *testing.T) { br, ourAmt, theirAmt, err := createBreachRetributionLegacy( &revokedLog, aliceChannel.channelState, keyRing, dummyPrivate, ourScript, theirScript, leaseExpiry, - fn.None[AuxContractResolver](), 0, + fn.None[AuxContractResolver](), fn.None[AuxSigner](), 0, ) require.NoError(t, err) @@ -10450,6 +10456,7 @@ func testNewBreachRetribution(t *testing.T, chanType channeldb.ChannelType) { aliceChannel.channelState, stateNum, breachHeight, breachTx, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.ErrorIs(t, err, channeldb.ErrNoPastDeltas) @@ -10459,6 +10466,7 @@ func testNewBreachRetribution(t *testing.T, chanType channeldb.ChannelType) { aliceChannel.channelState, stateNum, breachHeight, nil, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.ErrorIs(t, err, channeldb.ErrNoPastDeltas) @@ -10506,6 +10514,7 @@ func testNewBreachRetribution(t *testing.T, chanType channeldb.ChannelType) { aliceChannel.channelState, stateNum, breachHeight, breachTx, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.NoError(t, err) @@ -10519,6 +10528,7 @@ func testNewBreachRetribution(t *testing.T, chanType channeldb.ChannelType) { aliceChannel.channelState, stateNum, breachHeight, nil, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.NoError(t, err) assertRetribution(br, 1, 0) @@ -10529,6 +10539,7 @@ func testNewBreachRetribution(t *testing.T, chanType channeldb.ChannelType) { aliceChannel.channelState, stateNum+1, breachHeight, breachTx, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.ErrorIs(t, err, channeldb.ErrLogEntryNotFound) @@ -10538,6 +10549,7 @@ func testNewBreachRetribution(t *testing.T, chanType channeldb.ChannelType) { aliceChannel.channelState, stateNum+1, breachHeight, nil, fn.Some[AuxLeafStore](&MockAuxLeafStore{}), fn.Some[AuxContractResolver](&MockAuxContractResolver{}), + fn.None[AuxSigner](), ) require.ErrorIs(t, err, channeldb.ErrLogEntryNotFound) } @@ -10833,7 +10845,9 @@ func TestAsynchronousSendingContraint(t *testing.T) { // |<----add------- // make sure this htlc is non-dust for alice. - htlcFee := HtlcSuccessFee(channeldb.SingleFunderTweaklessBit, feePerKw) + htlcFee := HtlcSuccessFee( + channeldb.SingleFunderTweaklessBit, feePerKw, false, + ) // We need to take the remote dustlimit amount, because it the greater // one. htlcAmt2 := lnwire.NewMSatFromSatoshis( @@ -10967,7 +10981,9 @@ func TestAsynchronousSendingWithFeeBuffer(t *testing.T) { // commitment as well. // |<----add------- // make sure this htlc is non-dust for alice. - htlcFee := HtlcSuccessFee(channeldb.SingleFunderTweaklessBit, feePerKw) + htlcFee := HtlcSuccessFee( + channeldb.SingleFunderTweaklessBit, feePerKw, false, + ) htlcAmt2 := lnwire.NewMSatFromSatoshis( aliceChannel.channelState.LocalChanCfg.DustLimit + htlcFee, ) @@ -11051,7 +11067,9 @@ func TestAsynchronousSendingWithFeeBuffer(t *testing.T) { // --------------- |-----sig------> // <----rev------- |--------------- // Update the non-dust amount because we updated the fee by 100%. - htlcFee = HtlcSuccessFee(channeldb.SingleFunderTweaklessBit, feePerKw*2) + htlcFee = HtlcSuccessFee( + channeldb.SingleFunderTweaklessBit, feePerKw*2, false, + ) htlcAmt3 := lnwire.NewMSatFromSatoshis( aliceChannel.channelState.LocalChanCfg.DustLimit + htlcFee, ) @@ -11973,3 +11991,68 @@ func TestEvaluateNoOpHtlc(t *testing.T) { require.Equal(t, tc.expectedDeltas, tc.balanceDeltas) } } + +// TestHtlcFeesSigHashDefault verifies that HtlcTimeoutFee and +// HtlcSuccessFee return non-zero baked-in fees for taproot channels +// with sigHashDefault=true, using the fixed sigHashDefaultFeeRate. +func TestHtlcFeesSigHashDefault(t *testing.T) { + t.Parallel() + + taprootChanType := channeldb.SimpleTaprootFeatureBit | + channeldb.AnchorOutputsBit | + channeldb.ZeroHtlcTxFeeBit | + channeldb.SingleFunderTweaklessBit + + // With sigHashDefault=false, taproot channels have zero + // second-level fees (zero-fee HTLC path). + timeoutFeeOff := HtlcTimeoutFee(taprootChanType, 0, false) + successFeeOff := HtlcSuccessFee(taprootChanType, 0, false) + require.Zero(t, timeoutFeeOff, + "taproot timeout fee should be zero without sigHashDefault") + require.Zero(t, successFeeOff, + "taproot success fee should be zero without sigHashDefault") + + // With sigHashDefault=true, taproot channels use baked-in fees + // at sigHashDefaultFeeRate regardless of the passed feePerKw. + timeoutFeeOn := HtlcTimeoutFee(taprootChanType, 0, true) + successFeeOn := HtlcSuccessFee(taprootChanType, 0, true) + require.NotZero(t, timeoutFeeOn, + "taproot timeout fee should be non-zero with sigHashDefault") + require.NotZero(t, successFeeOn, + "taproot success fee should be non-zero with sigHashDefault") + + // The fee should be deterministic and match the expected weight + // calculation. + expectedTimeout := sigHashDefaultFeeRate.FeeForWeight( + input.TaprootHtlcTimeoutWeight, + ) + expectedSuccess := sigHashDefaultFeeRate.FeeForWeight( + input.TaprootHtlcSuccessWeight, + ) + require.Equal(t, expectedTimeout, timeoutFeeOn) + require.Equal(t, expectedSuccess, successFeeOn) + + // The fee should be independent of the passed feePerKw. + highFeeRate := chainfee.SatPerKWeight(50_000) + timeoutFeeHigh := HtlcTimeoutFee( + taprootChanType, highFeeRate, true, + ) + successFeeHigh := HtlcSuccessFee( + taprootChanType, highFeeRate, true, + ) + require.Equal(t, timeoutFeeOn, timeoutFeeHigh, + "sigHashDefault fee should not depend on feePerKw") + require.Equal(t, successFeeOn, successFeeHigh, + "sigHashDefault fee should not depend on feePerKw") + + // Non-taproot channel types should not be affected by + // sigHashDefault flag. + anchorChanType := channeldb.AnchorOutputsBit | + channeldb.ZeroHtlcTxFeeBit | + channeldb.SingleFunderTweaklessBit + anchorTimeoutFee := HtlcTimeoutFee( + anchorChanType, highFeeRate, true, + ) + require.Zero(t, anchorTimeoutFee, + "non-taproot zero-fee channel should still have zero fee") +} diff --git a/lnwallet/commitment.go b/lnwallet/commitment.go index 262678e7452..761543e76ba 100644 --- a/lnwallet/commitment.go +++ b/lnwallet/commitment.go @@ -508,12 +508,31 @@ func CommitWeight(chanType channeldb.ChannelType) lntypes.WeightUnit { } } +// sigHashDefaultFeeRate is the fee rate used for baked-in second-level HTLC +// transaction fees when SigHashDefault is active. We use 3x the floor relay +// rate to ensure the pre-signed transaction clears the mempool minimum fee +// even when nodes compute fee rate using the full serialized size (including +// witness data) rather than the virtual size. +const sigHashDefaultFeeRate = 3 * chainfee.FeePerKwFloor + // HtlcTimeoutFee returns the fee in satoshis required for an HTLC timeout -// transaction based on the current fee rate. +// transaction based on the current fee rate. When sigHashDefault is true and +// the channel is taproot, a fixed fee rate is used because the pre-signed +// second-level tx must carry its own fee (the sweeper cannot add wallet +// inputs under SigHashDefault). func HtlcTimeoutFee(chanType channeldb.ChannelType, - feePerKw chainfee.SatPerKWeight) btcutil.Amount { + feePerKw chainfee.SatPerKWeight, + sigHashDefault bool) btcutil.Amount { switch { + // For taproot channels with SigHashDefault, the second-level tx must + // pay its own fee. We use a rate well above the floor to account for + // nodes that check fee rate against the raw serialized size. + case chanType.IsTaproot() && sigHashDefault: + return sigHashDefaultFeeRate.FeeForWeight( + input.TaprootHtlcTimeoutWeight, + ) + // For zero-fee HTLC channels, this will always be zero, regardless of // feerate. case chanType.ZeroHtlcTxFee() || chanType.IsTaproot(): @@ -528,11 +547,23 @@ func HtlcTimeoutFee(chanType channeldb.ChannelType, } // HtlcSuccessFee returns the fee in satoshis required for an HTLC success -// transaction based on the current fee rate. +// transaction based on the current fee rate. When sigHashDefault is true and +// the channel is taproot, a fixed fee rate is used because the pre-signed +// second-level tx must carry its own fee (the sweeper cannot add wallet +// inputs under SigHashDefault). func HtlcSuccessFee(chanType channeldb.ChannelType, - feePerKw chainfee.SatPerKWeight) btcutil.Amount { + feePerKw chainfee.SatPerKWeight, + sigHashDefault bool) btcutil.Amount { switch { + // For taproot channels with SigHashDefault, the second-level tx must + // pay its own fee. We use a rate well above the floor to account for + // nodes that check fee rate against the raw serialized size. + case chanType.IsTaproot() && sigHashDefault: + return sigHashDefaultFeeRate.FeeForWeight( + input.TaprootHtlcSuccessWeight, + ) + // For zero-fee HTLC channels, this will always be zero, regardless of // feerate. case chanType.ZeroHtlcTxFee() || chanType.IsTaproot(): @@ -647,11 +678,16 @@ type CommitmentBuilder struct { // auxLeafStore is an interface that allows us to fetch auxiliary // tapscript leaves for the commitment output. auxLeafStore fn.Option[AuxLeafStore] + + // sigHashDefault indicates whether HTLC second-level transactions + // for this channel use SigHashDefault. + sigHashDefault bool } // NewCommitmentBuilder creates a new CommitmentBuilder from chanState. func NewCommitmentBuilder(chanState *channeldb.OpenChannel, - leafStore fn.Option[AuxLeafStore]) *CommitmentBuilder { + leafStore fn.Option[AuxLeafStore], + sigHashDefault bool) *CommitmentBuilder { // The anchor channel type MUST be tweakless. if chanState.ChanType.HasAnchors() && !chanState.ChanType.IsTweakless() { @@ -659,9 +695,10 @@ func NewCommitmentBuilder(chanState *channeldb.OpenChannel, } return &CommitmentBuilder{ - chanState: chanState, - obfuscator: createStateHintObfuscator(chanState), - auxLeafStore: leafStore, + chanState: chanState, + obfuscator: createStateHintObfuscator(chanState), + auxLeafStore: leafStore, + sigHashDefault: sigHashDefault, } } @@ -728,6 +765,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, false, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -739,6 +777,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, true, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -853,6 +892,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, false, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -881,6 +921,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, true, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue diff --git a/server.go b/server.go index 1802be84180..ec075ff0ba4 100644 --- a/server.go +++ b/server.go @@ -1772,6 +1772,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, channel, commitHeight, 0, nil, implCfg.AuxLeafStore, implCfg.AuxContractResolver, + implCfg.AuxSigner, ) if err != nil { return nil, 0, err From 8c68a0540bedcf91a1072fda0af6144bf6dfa44e Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:13 +0300 Subject: [PATCH 10/16] contractcourt: publish pre-signed second-level HTLCs under SigHashDefault When the second-level HTLC transaction was signed with SigHashDefault (baked-in fees), the sweeper's normal tx-rebuilding flow would invalidate the peer's signature. Add isSigHashDefault() and publishTimeoutTx() / publishSuccessTx() to both the timeout and success resolvers, which bypass the sweeper and broadcast the pre-signed transaction directly. The Launch() method now checks isSigHashDefault() before falling through to the sweeper-based sweepTimeoutTx / sweepSuccessTx paths. Because the resolver publishes the second-level tx directly via PublishTx, the aux sweeper's NotifyBroadcast hook never fires for it and the aux resolver never gets a chance to import the second-level proof. To compensate, sweepSuccessTxOutput and sweepTimeoutTxOutput now call AuxResolver.ResolveContract once the pre-signed tx confirms (mirroring the breach arbiter's re-resolve pattern), using the new lnwallet.NewSecondLevelResolveReq helper to rebuild the resolution request with the actual second-level transaction. The resulting blob carries the imported 2nd-level proof, so the upcoming output sweep can find its input proof when the sweeper later offers it. --- contractcourt/htlc_success_resolver.go | 98 +++++++++++++++++++++++++- contractcourt/htlc_timeout_resolver.go | 97 ++++++++++++++++++++++++- 2 files changed, 193 insertions(+), 2 deletions(-) diff --git a/contractcourt/htlc_success_resolver.go b/contractcourt/htlc_success_resolver.go index 1770c214a45..8129b1c4aab 100644 --- a/contractcourt/htlc_success_resolver.go +++ b/contractcourt/htlc_success_resolver.go @@ -418,6 +418,39 @@ func (h *htlcSuccessResolver) isZeroFeeOutput() bool { h.htlcResolution.SignDetails != nil } +// isSigHashDefault returns true when the second-level HTLC transaction was +// signed with SigHashDefault. In this case the pre-signed transaction has +// baked-in fees and must be broadcast as-is — the sweeper cannot add wallet +// inputs or change outputs without invalidating the peer's signature. +// +// NOTE: This only applies to taproot-assets (custom channel) HTLCs, so we +// also require a resolution blob to be present (which is only set for aux +// channels). This prevents accidental activation for regular lnd channels +// where the zero-value SigHashType (0x00) would otherwise match. +func (h *htlcSuccessResolver) isSigHashDefault() bool { + sd := h.htlcResolution.SignDetails + + return sd != nil && + sd.SigHashType == txscript.SigHashDefault && + h.htlcResolution.ResolutionBlob.IsSome() +} + +// publishSuccessTx directly broadcasts the pre-signed second-level HTLC +// success transaction. This is used when the transaction was signed with +// SigHashDefault (baked-in fees), where the sweeper's normal tx-rebuilding +// flow would invalidate the peer's signature. +func (h *htlcSuccessResolver) publishSuccessTx() error { + h.log.Infof("publishing pre-signed 2nd-level HTLC success tx=%v "+ + "(SigHashDefault, baked-in fees)", + h.htlcResolution.SignedSuccessTx.TxHash()) + + label := labels.MakeLabel( + labels.LabelTypeChannelClose, &h.ShortChanID, + ) + + return h.PublishTx(h.htlcResolution.SignedSuccessTx, label) +} + // isTaproot returns true if the resolver is for a taproot output. func (h *htlcSuccessResolver) isTaproot() bool { return txscript.IsPayToTaproot( @@ -604,12 +637,67 @@ func (h *htlcSuccessResolver) sweepSuccessTxOutput() error { default: witType = input.HtlcAcceptedSuccessSecondLevel } + + // Under DeterministicHTLCs the pre-signed second-level HTLC tx was + // published directly via PublishTx, bypassing the sweeper's + // NotifyBroadcast hook. As a result the aux sweeper never imported + // the second-level proof transition into its archive, and the + // upcoming output sweep would fail to fetch its input proof. Mirror + // the breach_arbitrator re-resolve pattern (breach_arbitrator.go) to + // give the aux resolver a chance to import the second-level tx into + // its proof archive before we offer the output sweep to the sweeper. + resolutionBlob := h.htlcResolution.ResolutionBlob + h.AuxResolver.WhenSome(func(a lnwallet.AuxContractResolver) { + chanState, err := h.FetchHistoricalChannel() + if err != nil { + h.log.Errorf("Unable to fetch historical channel "+ + "for second-level success re-resolve "+ + "(htlcID=%v): %v — falling back to original "+ + "blob", h.htlc.HtlcIndex, err) + + return + } + + secondLevelReq, err := lnwallet.NewSecondLevelResolveReq( + chanState, &h.htlc, h.htlcResolution.SignDetails, + h.htlcResolution.SweepSignDesc, + h.htlcResolution.CsvDelay, h.broadcastHeight, + h.htlcResolution.SignedSuccessTx, + uint32(commitSpend.SpendingHeight), witType, + ) + if err != nil { + h.log.Errorf("Unable to build second-level success "+ + "ResolveReq (htlcID=%v): %v — falling back "+ + "to original blob", h.htlc.HtlcIndex, err) + + return + } + + resolveBlob := a.ResolveContract(*secondLevelReq) + if err := resolveBlob.Err(); err != nil { + h.log.Errorf("Unable to re-resolve aux blob for "+ + "second-level success output sweep "+ + "(htlcID=%v): %v — falling back to original "+ + "blob; output sweep may fail aux proof "+ + "verification", + h.htlc.HtlcIndex, err) + + return + } + h.log.Infof("sweepSuccessTxOutput: re-resolved aux blob "+ + "for second-level success output sweep "+ + "(htlcID=%v, secondLevelTxid=%v)", + h.htlc.HtlcIndex, + h.htlcResolution.SignedSuccessTx.TxHash()) + resolutionBlob = resolveBlob.OkToSome() + }) + inp := h.makeSweepInput( op, witType, input.LeaseHtlcAcceptedSuccessSecondLevel, &h.htlcResolution.SweepSignDesc, h.htlcResolution.CsvDelay, uint32(commitSpend.SpendingHeight), - h.htlc.RHash, h.htlcResolution.ResolutionBlob, + h.htlc.RHash, resolutionBlob, ) // Calculate the budget for this sweep. @@ -797,6 +885,14 @@ func (h *htlcSuccessResolver) Launch() error { return h.sweepSuccessTxOutput() } + // When the peer signed with SigHashDefault the pre-signed + // second-level tx has baked-in fees and cannot be modified + // (adding wallet inputs would invalidate the signature). + // Publish it directly instead of going through the sweeper. + if h.isSigHashDefault() { + return h.publishSuccessTx() + } + // Otherwise, sweep the second level tx. return h.sweepSuccessTx() diff --git a/contractcourt/htlc_timeout_resolver.go b/contractcourt/htlc_timeout_resolver.go index c2cbb133beb..43dcc6800d1 100644 --- a/contractcourt/htlc_timeout_resolver.go +++ b/contractcourt/htlc_timeout_resolver.go @@ -14,6 +14,7 @@ import ( "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/labels" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" @@ -972,6 +973,39 @@ func (h *htlcTimeoutResolver) isZeroFeeOutput() bool { h.htlcResolution.SignDetails != nil } +// isSigHashDefault returns true when the second-level HTLC transaction was +// signed with SigHashDefault. In this case the pre-signed transaction has +// baked-in fees and must be broadcast as-is — the sweeper cannot add wallet +// inputs or change outputs without invalidating the peer's signature. +// +// NOTE: This only applies to taproot-assets (custom channel) HTLCs, so we +// also require a resolution blob to be present (which is only set for aux +// channels). This prevents accidental activation for regular lnd channels +// where the zero-value SigHashType (0x00) would otherwise match. +func (h *htlcTimeoutResolver) isSigHashDefault() bool { + sd := h.htlcResolution.SignDetails + + return sd != nil && + sd.SigHashType == txscript.SigHashDefault && + h.htlcResolution.ResolutionBlob.IsSome() +} + +// publishTimeoutTx directly broadcasts the pre-signed second-level HTLC +// timeout transaction. This is used when the transaction was signed with +// SigHashDefault (baked-in fees), where the sweeper's normal tx-rebuilding +// flow would invalidate the peer's signature. +func (h *htlcTimeoutResolver) publishTimeoutTx() error { + h.log.Infof("publishing pre-signed 2nd-level HTLC timeout tx=%v "+ + "(SigHashDefault, baked-in fees)", + h.htlcResolution.SignedTimeoutTx.TxHash()) + + label := labels.MakeLabel( + labels.LabelTypeChannelClose, &h.ShortChanID, + ) + + return h.PublishTx(h.htlcResolution.SignedTimeoutTx, label) +} + // waitHtlcSpendAndCheckPreimage waits for the htlc output to be spent and // checks whether the spending reveals the preimage. If the preimage is found, // it will be added to the preimage beacon to settle the incoming link, and a @@ -1064,6 +1098,59 @@ func (h *htlcTimeoutResolver) sweepTimeoutTxOutput() error { witType = input.HtlcOfferedTimeoutSecondLevel } + // Under DeterministicHTLCs the pre-signed second-level HTLC tx was + // published directly via PublishTx, bypassing the sweeper's + // NotifyBroadcast hook. As a result the aux sweeper never imported + // the second-level proof transition into its archive, and the + // upcoming output sweep would fail to fetch its input proof. Mirror + // the breach_arbitrator re-resolve pattern to give the aux resolver + // a chance to import the second-level tx into its proof archive + // before we offer the output sweep to the sweeper. + resolutionBlob := h.htlcResolution.ResolutionBlob + h.AuxResolver.WhenSome(func(a lnwallet.AuxContractResolver) { + chanState, err := h.FetchHistoricalChannel() + if err != nil { + h.log.Errorf("Unable to fetch historical channel "+ + "for second-level timeout re-resolve "+ + "(htlcID=%v): %v — falling back to original "+ + "blob", h.htlc.HtlcIndex, err) + + return + } + + secondLevelReq, err := lnwallet.NewSecondLevelResolveReq( + chanState, &h.htlc, h.htlcResolution.SignDetails, + h.htlcResolution.SweepSignDesc, + h.htlcResolution.CsvDelay, h.broadcastHeight, + h.htlcResolution.SignedTimeoutTx, + uint32(commitSpend.SpendingHeight), witType, + ) + if err != nil { + h.log.Errorf("Unable to build second-level timeout "+ + "ResolveReq (htlcID=%v): %v — falling back "+ + "to original blob", h.htlc.HtlcIndex, err) + + return + } + + resolveBlob := a.ResolveContract(*secondLevelReq) + if err := resolveBlob.Err(); err != nil { + h.log.Errorf("Unable to re-resolve aux blob for "+ + "second-level timeout output sweep "+ + "(htlcID=%v): %v — falling back to original "+ + "blob; output sweep may fail aux proof "+ + "verification", + h.htlc.HtlcIndex, err) + + return + } + h.log.Infof("re-resolved aux blob for second-level timeout "+ + "output sweep (htlcID=%v, secondLevelTxid=%v)", + h.htlc.HtlcIndex, + h.htlcResolution.SignedTimeoutTx.TxHash()) + resolutionBlob = resolveBlob.OkToSome() + }) + // Let the sweeper sweep the second-level output now that the CSV/CLTV // locks have expired. inp := h.makeSweepInput( @@ -1071,7 +1158,7 @@ func (h *htlcTimeoutResolver) sweepTimeoutTxOutput() error { input.LeaseHtlcOfferedTimeoutSecondLevel, &h.htlcResolution.SweepSignDesc, h.htlcResolution.CsvDelay, uint32(commitSpend.SpendingHeight), - h.htlc.RHash, h.htlcResolution.ResolutionBlob, + h.htlc.RHash, resolutionBlob, ) // Calculate the budget for this sweep. @@ -1320,6 +1407,14 @@ func (h *htlcTimeoutResolver) Launch() error { return h.sweepTimeoutTxOutput() } + // When the peer signed with SigHashDefault the pre-signed + // second-level tx has baked-in fees and cannot be modified + // (adding wallet inputs would invalidate the signature). + // Publish it directly instead of going through the sweeper. + if h.isSigHashDefault() { + return h.publishTimeoutTx() + } + // Otherwise, sweep the second level tx. return h.sweepTimeoutTx() From 096742c28b0f1aa00f61727f9d0f8e1f46ef199b Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:13 +0300 Subject: [PATCH 11/16] contractcourt: track historic justice tx variants across rebuild cycles The breach arbiter rebuilds justice tx variants after each spend detection (e.g. when an HTLC transitions to second-level). The tx that ultimately confirms may have been created in an earlier rebuild cycle and is no longer present in the current justiceTxVariants struct. Add a historicJusticeTxs map that records every justice tx variant ever created (keyed by txid) via recordJusticeTxVariants(). The notifyConfirmedJusticeTx function now falls back to this map when the confirmed spend doesn't match any current variant, ensuring the aux sweeper receives NotifyBroadcast for asset proof generation. Also improve the split-broadcast path: rebuild justice tx variants from the updated breach info before splitting, and re-attempt the spendAll variant first (which may now succeed after second-level spends have been incorporated). Add logging to createJusticeTx for input counts and variant creation. --- contractcourt/breach_arbitrator.go | 103 +++++++++++++++++++++++- contractcourt/breach_arbitrator_test.go | 18 ++++- 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index 33f6174f0bc..e819e34b2ee 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -848,7 +848,9 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend, // haven't already notified about it, we call NotifyBroadcast on the aux sweeper // to generate asset-level proofs. func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, - justiceTxs *justiceTxVariants, notifiedTxs map[chainhash.Hash]bool) { + justiceTxs *justiceTxVariants, + historicJusticeTxs map[chainhash.Hash]*justiceTxCtx, + notifiedTxs map[chainhash.Hash]bool) { // Check each spend to see if it's from one of our justice txs. for _, s := range spends { @@ -892,6 +894,14 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, } } + // Check the historic map of all justice tx variants ever + // created. This handles the case where the confirmed tx + // was from a previous rebuild cycle and the current + // justiceTxs has been replaced with newer variants. + if justiceCtx == nil { + justiceCtx = historicJusticeTxs[spendingTxHash] + } + // If this is one of our justice txs, notify the aux sweeper. if justiceCtx != nil { bumpReq := sweep.BumpRequest{ @@ -924,6 +934,28 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, } } +// recordJusticeTxVariants records all non-nil justice tx variants into the +// historic map keyed by their txid. This allows notifyConfirmedJusticeTx to +// match confirmed spends against justice txs from previous rebuild cycles. +func recordJusticeTxVariants(variants *justiceTxVariants, + history map[chainhash.Hash]*justiceTxCtx) { + + record := func(jtx *justiceTxCtx) { + if jtx == nil { + return + } + hash := jtx.justiceTx.TxHash() + history[hash] = jtx + } + + record(variants.spendAll) + record(variants.spendCommitOuts) + record(variants.spendHTLCs) + for _, tx := range variants.spendSecondLevelHTLCs { + record(tx) + } +} + // exactRetribution is a goroutine which is executed once a contract breach has // been detected by a breachObserver. This function is responsible for // punishing a counterparty for violating the channel contract by sweeping ALL @@ -979,6 +1011,13 @@ func (b *BreachArbitrator) exactRetribution( // handle duplicate calls idempotently. notifiedJusticeTxs := make(map[chainhash.Hash]bool) + // Track all historically created justice tx contexts by their txid. + // This is needed because justiceTxs is rebuilt after each spend + // detection, and the tx that actually confirmed may have been from + // an earlier rebuild cycle. Without this history, we can't match + // the confirmed tx to call NotifyBroadcast on the aux sweeper. + historicJusticeTxs := make(map[chainhash.Hash]*justiceTxCtx) + // Compute both the total value of funds being swept and the // amount of funds that were revoked from the counter party. var totalFunds, revokedFunds btcutil.Amount @@ -992,6 +1031,7 @@ justiceTxBroadcast: brarLog.Errorf("Unable to create justice tx: %v", err) return } + recordJusticeTxVariants(justiceTxs, historicJusticeTxs) finalTx := justiceTxs.spendAll brarLog.Debugf("Broadcasting justice tx: %v", lnutils.SpewLogClosure( @@ -1045,7 +1085,9 @@ Loop: // justice transaction, and if so, notify the aux // sweeper. b.notifyConfirmedJusticeTx( - spends, justiceTxs, notifiedJusticeTxs, + spends, justiceTxs, + historicJusticeTxs, + notifiedJusticeTxs, ) // Update the breach info with the new spends. @@ -1113,6 +1155,44 @@ Loop: "height %v), splitting justice tx.", epoch.Height, breachInfo.breachHeight) + // Rebuild justice tx variants from the current + // breach info, which may have been updated by + // spend detection (e.g. second-level HTLC spends + // replacing commit-level outputs). + justiceTxs, err = b.createJusticeTx( + breachInfo.breachedOutputs, + ) + if err != nil { + brarLog.Errorf("Unable to recreate "+ + "justice tx for split: %v", err) + continue Loop + } + recordJusticeTxVariants( + justiceTxs, historicJusticeTxs, + ) + + // Re-attempt the spendAll variant first, in + // case the breach info was updated since the + // initial broadcast. This avoids splitting into + // small txs that can't pay fees when a combined + // tx would work. + if justiceTxs.spendAll != nil { + label := labels.MakeLabel( + labels.LabelTypeJusticeTransaction, + nil, + ) + err = b.cfg.PublishTransaction( + justiceTxs.spendAll.justiceTx, + label, + ) + if err != nil { + brarLog.Warnf("Unable to "+ + "broadcast updated "+ + "spendAll justice "+ + "tx: %v", err) + } + } + // Otherwise we'll attempt to publish the two separate // justice transactions that sweeps the commitment // outputs and the HTLC outputs separately. This is to @@ -1716,6 +1796,10 @@ func (b *BreachArbitrator) createJusticeTx( } } + brarLog.Infof("createJusticeTx: %d total inputs (%d commit, "+ + "%d htlc, %d second-level)", len(allInputs), + len(commitInputs), len(htlcInputs), len(secondLevelInputs)) + var ( txs = &justiceTxVariants{} err error @@ -1726,31 +1810,42 @@ func (b *BreachArbitrator) createJusticeTx( if err != nil { return nil, err } + brarLog.Infof("createJusticeTx: spendAll created successfully "+ + "(%d inputs, txid=%v)", len(allInputs), + txs.spendAll.justiceTx.TxHash()) txs.spendCommitOuts, err = b.createSweepTx(commitInputs...) if err != nil { brarLog.Errorf("could not create sweep tx for commitment "+ "outputs: %v", err) + } else if txs.spendCommitOuts != nil { + brarLog.Infof("createJusticeTx: spendCommitOuts created "+ + "successfully (%d inputs)", len(commitInputs)) } txs.spendHTLCs, err = b.createSweepTx(htlcInputs...) if err != nil { brarLog.Errorf("could not create sweep tx for HTLC outputs: %v", err) + } else if txs.spendHTLCs != nil { + brarLog.Infof("createJusticeTx: spendHTLCs created "+ + "successfully (%d inputs)", len(htlcInputs)) } // TODO(roasbeef): only register one of them? secondLevelSweeps := make([]*justiceTxCtx, 0, len(secondLevelInputs)) - for _, input := range secondLevelInputs { + for i, input := range secondLevelInputs { sweepTx, err := b.createSweepTx(input) if err != nil { brarLog.Errorf("could not create sweep tx for "+ - "second-level HTLC output: %v", err) + "second-level HTLC output %d: %v", i, err) continue } + brarLog.Infof("createJusticeTx: individual second-level "+ + "sweep %d created successfully", i) secondLevelSweeps = append(secondLevelSweeps, sweepTx) } txs.spendSecondLevelHTLCs = secondLevelSweeps diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go index 0e33147ff78..4bf95135412 100644 --- a/contractcourt/breach_arbitrator_test.go +++ b/contractcourt/breach_arbitrator_test.go @@ -1899,12 +1899,19 @@ func TestBreachDelayedJusticeConfirmation(t *testing.T) { } // Now mine another block without the justice tx confirming. This - // should lead to the BreachArbitrator publishing the split justice tx - // variants. + // should lead to the BreachArbitrator re-broadcasting the spendAll + // variant and then publishing the split justice tx variants. notifier.EpochChan <- &chainntnfs.BlockEpoch{ Height: blockHeight + 4, } + // First, drain the re-broadcast of the spendAll variant. + select { + case <-publTx: + case <-time.After(defaultTimeout): + t.Fatalf("spendAll re-broadcast not published") + } + var ( splits []*wire.MsgTx spending = make(map[wire.OutPoint]struct{}) @@ -3209,8 +3216,10 @@ func TestNotifyConfirmedJusticeTx(t *testing.T) { } // Call the function under test. + historicTxs := make(map[chainhash.Hash]*justiceTxCtx) brar.notifyConfirmedJusticeTx( - tc.spends, tc.justiceTxs, tc.notifiedTxs, + tc.spends, tc.justiceTxs, + historicTxs, tc.notifiedTxs, ) // Verify the number of NotifyBroadcast calls. @@ -3279,8 +3288,9 @@ func TestNotifyConfirmedJusticeTxNoAuxSweeper(t *testing.T) { // Should not panic and should not mark as notified since there's no // aux sweeper to notify. + historicTxs := make(map[chainhash.Hash]*justiceTxCtx) brar.notifyConfirmedJusticeTx( - spends, justiceTxs, notifiedTxs, + spends, justiceTxs, historicTxs, notifiedTxs, ) // The tx should still be marked as notified even without an aux From 886dee4fe0f1894c7502fdd99e9f7c511fae86b9 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:14 +0300 Subject: [PATCH 12/16] input: remove ErrTweakOverdose check from SignDescriptor.Decode The ErrTweakOverdose check rejected SignDescriptors with both SingleTweak and DoubleTweak set during deserialization. This was a placeholder guard that no caller ever checked for or handled. With taproot asset channels, the combined tweak path (DoubleTweak for revocation + SingleTweak for HTLC index) is exercised during asset-level signing. While the current code only sets both tweaks on transient copies that are never persisted, removing the check eliminates a latent footgun if a future change were to persist such descriptors. --- input/signdescriptor.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/input/signdescriptor.go b/input/signdescriptor.go index a01c939ae74..abfbf725faa 100644 --- a/input/signdescriptor.go +++ b/input/signdescriptor.go @@ -2,7 +2,6 @@ package input import ( "encoding/binary" - "errors" "fmt" "io" @@ -12,11 +11,6 @@ import ( "github.com/lightningnetwork/lnd/keychain" ) -var ( - // ErrTweakOverdose signals a SignDescriptor is invalid because both of its - // SingleTweak and DoubleTweak are non-nil. - ErrTweakOverdose = errors.New("sign descriptor should only have one tweak") -) // SignDescriptor houses the necessary information required to successfully // sign a given segwit output. This struct is used by the Signer interface in @@ -289,11 +283,6 @@ func ReadSignDescriptor(r io.Reader, sd *SignDescriptor) error { sd.DoubleTweak, _ = btcec.PrivKeyFromBytes(doubleTweakBytes) } - // Only one tweak should ever be set, fail if both are present. - if sd.SingleTweak != nil && sd.DoubleTweak != nil { - return ErrTweakOverdose - } - witnessScript, err := wire.ReadVarBytes(r, 0, 500, "witnessScript") if err != nil { return err From 45372ff5e55155bf5d878e4245067f70f8c97548 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:15 +0300 Subject: [PATCH 13/16] sweep+contractcourt: replace skipBroadcast bool with AuxNotifyOpts Introduce AuxNotifyOpts struct with SkipBroadcast and SkipProofVerify fields, replacing the bare boolean skipBroadcast parameter on the AuxSweeper.NotifyBroadcast interface. This decouples broadcast control (transport) from proof verification skip (security), allowing callers to set each independently. --- contractcourt/breach_arbitrator.go | 11 +++++++--- contractcourt/breach_arbitrator_test.go | 29 ++++++++++++++----------- sweep/fee_bumper.go | 3 +-- sweep/interface.go | 21 ++++++++++++++---- sweep/mock_test.go | 2 +- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index e819e34b2ee..ce7b1d96b1e 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -913,11 +913,16 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, err := fn.MapOptionZ( b.cfg.AuxSweeper, func(aux sweep.AuxSweeper) error { - // The transaction is already confirmed, - // so we pass skipBroadcast=true. + // The tx is already confirmed, so + // skip broadcast and proof verify + // (placeholder witnesses). return aux.NotifyBroadcast( &bumpReq, s.detail.SpendingTx, - justiceCtx.fee, nil, true, + justiceCtx.fee, nil, + sweep.AuxNotifyOpts{ + SkipBroadcast: true, + SkipProofVerify: true, + }, ) }, ) diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go index 4bf95135412..8bb2101721a 100644 --- a/contractcourt/breach_arbitrator_test.go +++ b/contractcourt/breach_arbitrator_test.go @@ -2981,7 +2981,7 @@ func (m *mockAuxSweeperWithOutput) ExtraBudgetForInputs( func (m *mockAuxSweeperWithOutput) NotifyBroadcast( _ *sweep.BumpRequest, _ *wire.MsgTx, _ btcutil.Amount, _ map[wire.OutPoint]int, - _ bool) error { + _ sweep.AuxNotifyOpts) error { return nil } @@ -2995,10 +2995,10 @@ type mockAuxSweeperNotify struct { // notifyCall records the parameters of a NotifyBroadcast call. type notifyCall struct { - req *sweep.BumpRequest - tx *wire.MsgTx - fee btcutil.Amount - skipBroadcast bool + req *sweep.BumpRequest + tx *wire.MsgTx + fee btcutil.Amount + opts sweep.AuxNotifyOpts } // DeriveSweepAddr implements sweep.AuxSweeper. @@ -3018,13 +3018,13 @@ func (m *mockAuxSweeperNotify) ExtraBudgetForInputs( // NotifyBroadcast implements sweep.AuxSweeper and records the call. func (m *mockAuxSweeperNotify) NotifyBroadcast(req *sweep.BumpRequest, tx *wire.MsgTx, fee btcutil.Amount, - _ map[wire.OutPoint]int, skipBroadcast bool) error { + _ map[wire.OutPoint]int, opts sweep.AuxNotifyOpts) error { m.notifyCalls = append(m.notifyCalls, notifyCall{ - req: req, - tx: tx, - fee: fee, - skipBroadcast: skipBroadcast, + req: req, + tx: tx, + fee: fee, + opts: opts, }) return m.notifyErr @@ -3235,11 +3235,14 @@ func TestNotifyConfirmedJusticeTx(t *testing.T) { "unexpected fee for call %d", i) } - // Verify skipBroadcast is always true for + // Verify skip flags are set for // confirmed justice txs. require.Equal(t, tc.expectedSkipFlag, - call.skipBroadcast, - "skipBroadcast should be true") + call.opts.SkipBroadcast, + "SkipBroadcast should be true") + require.Equal(t, tc.expectedSkipFlag, + call.opts.SkipProofVerify, + "SkipProofVerify should be true") } // Verify notifiedTxs map was updated for successful diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index f73a2b0d8a9..d1592bfd323 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -738,10 +738,9 @@ func (t *TxPublisher) broadcast(record *monitorRecord) (*BumpResult, error) { // Before we go to broadcast, we'll notify the aux sweeper, if it's // present of this new broadcast attempt. err := fn.MapOptionZ(t.cfg.AuxSweeper, func(aux AuxSweeper) error { - const skipBroadcast = false return aux.NotifyBroadcast( record.req, tx, record.fee, record.outpointToTxIndex, - skipBroadcast, + AuxNotifyOpts{}, ) }) if err != nil { diff --git a/sweep/interface.go b/sweep/interface.go index 669db5f2a49..f1fe1eb05a2 100644 --- a/sweep/interface.go +++ b/sweep/interface.go @@ -75,6 +75,22 @@ type SweepOutput struct { //nolint:revive InternalKey fn.Option[keychain.KeyDescriptor] } +// AuxNotifyOpts contains options for the NotifyBroadcast call on the +// AuxSweeper interface. +type AuxNotifyOpts struct { + // SkipBroadcast indicates whether the transaction is already + // confirmed on-chain (true for breach sweeps, force close + // commitments) and should not be broadcast again. + SkipBroadcast bool + + // SkipProofVerify indicates whether asset-level proof + // verification should be skipped. This is used when the input + // proofs contain placeholder witnesses (e.g. second-level HTLC + // outputs) that cannot pass VM-level validation, and the + // on-chain confirmation serves as proof of validity instead. + SkipProofVerify bool +} + // AuxSweeper is used to enable a 3rd party to further shape the sweeping // transaction by adding a set of extra outputs to the sweeping transaction. type AuxSweeper interface { @@ -92,11 +108,8 @@ type AuxSweeper interface { // NotifyBroadcast is used to notify external callers of the broadcast // of a sweep transaction, generated by the passed BumpRequest. - // The skipBroadcast parameter indicates whether the transaction is - // already confirmed on-chain (true for breach sweeps) or needs to be - // broadcast (false for normal sweeps). NotifyBroadcast(req *BumpRequest, tx *wire.MsgTx, totalFees btcutil.Amount, outpointToTxIndex map[wire.OutPoint]int, - skipBroadcast bool) error + opts AuxNotifyOpts) error } diff --git a/sweep/mock_test.go b/sweep/mock_test.go index 55d9a4fa31e..f741968b486 100644 --- a/sweep/mock_test.go +++ b/sweep/mock_test.go @@ -359,7 +359,7 @@ func (m *MockAuxSweeper) ExtraBudgetForInputs( // NotifyBroadcast is used to notify external callers of the broadcast // of a sweep transaction, generated by the passed BumpRequest. func (*MockAuxSweeper) NotifyBroadcast(_ *BumpRequest, _ *wire.MsgTx, - _ btcutil.Amount, _ map[wire.OutPoint]int, _ bool) error { + _ btcutil.Amount, _ map[wire.OutPoint]int, _ AuxNotifyOpts) error { return nil } From 47dd67b8c92ce70c10de3707c253232edb89bcd2 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:16 +0300 Subject: [PATCH 14/16] lnwallet: populate AuxSigDesc for breach HTLC retribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When constructing HtlcRetribution for a breached commitment, extract the remote party's asset-level HTLC signature from the revocation log's CustomBlob and populate AuxSigDesc on the ResolutionReq. This allows the aux subsystem to construct valid asset witnesses for the commitment → second-level proof transition, making breach-recovered assets fully spendable. --- lnwallet/channel.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index aa4c953a1f1..33059309c9d 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2608,6 +2608,48 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, HtlcID: htlcIDOpt, CltvDelay: fn.Some(htlc.RefundTimeout.Val), } + + // Populate the AuxSigDesc if the HTLC has custom + // records (containing the remote's asset-level + // sig for the second-level tx). This allows the + // aux subsystem to construct a valid proof with + // real witnesses instead of placeholders. + htlc.CustomBlob.WhenSome( + func(r tlv.RecordT[tlv.TlvType5, tlv.Blob]) { + customRecords, err := lnwire. + ParseCustomRecords(r.Val) + if err != nil { + return + } + + sigType := htlcCustomSigType.TypeVal() + auxSig := customRecords[uint64(sigType)] + if len(auxSig) > 0 { + // Construct the HTLC sign + // descriptor that the aux + // subsystem needs to sign the + // second-level transition proof. + htlcSignDesc := input.SignDescriptor{ + KeyDesc: chanState. + LocalChanCfg. + HtlcBasePoint, + SingleTweak: keyRing. + LocalHtlcKeyTweak, + SignMethod: input. + TaprootScriptSpendSignMethod, + } + + resolveReq.AuxSigDesc = fn.Some( + AuxSigDesc{ + AuxSig: auxSig, + SignDetails: input.SignDetails{ + SignDesc: htlcSignDesc, + }, + }, + ) + } + }, + ) if revokedLog != nil { resolveReq.CommitBlob = revokedLog.CustomBlob.ValOpt() } From 4c54752daf52cfb63c32c53e1ce134d0ac8007d5 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:16 +0300 Subject: [PATCH 15/16] contractcourt: use dust limit for justice tx sweep output check When the justice tx has an aux (asset) output, the remaining BTC change after fees can be below the dust limit but still positive. Check against the dust limit instead of zero to prevent btcd from rejecting the transaction as non-standard. --- contractcourt/breach_arbitrator.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index ce7b1d96b1e..012a2749bf8 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -2004,13 +2004,15 @@ func (b *BreachArbitrator) sweepSpendableOutputsTxn(txWeight lntypes.WeightUnit, }) } - // If the sweep amount is positive, add the regular sweep output as - // usual. If it's non-positive but we have an aux output, we skip - // the BTC sweep output entirely — for custom (asset) channels the - // real value is carried by the aux output and the remaining BTC - // can all go towards fees. + // If the sweep amount exceeds the dust limit, add the regular sweep + // output. If it's at or below dust but we have an aux output, we + // skip the BTC sweep output entirely — for custom (asset) channels + // the real value is carried by the aux output and the remaining BTC + // can all go towards fees. Using the dust limit instead of zero + // prevents the mempool from rejecting the transaction. + dustLimit := int64(lnwallet.DustLimitForSize(input.UnknownWitnessSize)) switch { - case sweepAmt > 0: + case sweepAmt > dustLimit: txn.AddTxOut(&wire.TxOut{ PkScript: pkScript.DeliveryAddress, Value: sweepAmt, From c26a2151a8751ef8a666ce52da577c32c07258e6 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 14 Apr 2026 18:39:17 +0300 Subject: [PATCH 16/16] multi: add revocation AuxSig signing and verification When revoking a commitment via RevokeAndAck, sign both spending paths (success and timeout) for each in-flight HTLC's second-level virtual transaction. The signatures are packed into an HTLC-index-tagged blob that the receiver can match to HTLCs unambiguously, regardless of ordering differences between local and remote commitment views. On the receiving side, verify all signatures against the breach-time key ring before accepting the revocation. Store both primary and alternate path signatures in the revocation log so the honest party can reconstruct valid proofs for whichever spending path the breaching party used on-chain. Both signing and verification are gated by IsDeterministicHTLCs (formerly IsSigHashDefault), ensuring backward compatibility with peers that have not negotiated the feature. Key changes: - Add signLocalHtlcAuxSigs to produce dual-path AuxSigs per HTLC - Add verifyRevocationAuxSigs to validate sigs at ReceiveRevocation - Add injectRevocationAuxSigs to store sigs in the revocation log - Add HTLC-index-tagged pack/unpack format for revocation sig blobs - Add AuxSigAlt field to AuxSigDesc for alternate spending path - Add IncomingHTLCLookup to BaseAuxJob for correct aux output lookup when Incoming is flipped for alternate spending path generation - Add WhoseCommit, HtlcTimeout fields to BaseAuxJob - Add CustomRecords field to RevokeAndAck for carrying aux sig blobs - Rename IsSigHashDefault to IsDeterministicHTLCs - Use ResolveHtlcSigHashType instead of hardcoded SigHashAll - Add ConfirmHeight to AuxNotifyOpts for porter height hints --- contractcourt/breach_arbitrator.go | 150 +++- contractcourt/breach_arbitrator_test.go | 10 +- input/signdescriptor.go | 1 - lnwallet/aux_resolutions.go | 12 +- lnwallet/aux_signer.go | 54 +- lnwallet/channel.go | 1005 ++++++++++++++++++++++- lnwallet/revocation_aux_sig_test.go | 100 +++ lnwire/revoke_and_ack.go | 48 +- sweep/interface.go | 19 +- 9 files changed, 1304 insertions(+), 95 deletions(-) create mode 100644 lnwallet/revocation_aux_sig_test.go diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index 012a2749bf8..f76eb046f57 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -846,11 +846,12 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend, // notifyConfirmedJusticeTx checks if any of the spend details match one of our // justice transactions. If a confirmed justice transaction is detected and we // haven't already notified about it, we call NotifyBroadcast on the aux sweeper -// to generate asset-level proofs. +// to generate aux-level proofs. func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, justiceTxs *justiceTxVariants, historicJusticeTxs map[chainhash.Hash]*justiceTxCtx, - notifiedTxs map[chainhash.Hash]bool) { + notifiedTxs map[chainhash.Hash]bool, + breachedOutputs []breachedOutput) { // Check each spend to see if it's from one of our justice txs. for _, s := range spends { @@ -872,22 +873,29 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, } var justiceCtx *justiceTxCtx + var matchSource string switch { case matchesJusticeTx(justiceTxs.spendAll): justiceCtx = justiceTxs.spendAll + matchSource = "spendAll" case matchesJusticeTx(justiceTxs.spendCommitOuts): justiceCtx = justiceTxs.spendCommitOuts + matchSource = "spendCommitOuts" case matchesJusticeTx(justiceTxs.spendHTLCs): justiceCtx = justiceTxs.spendHTLCs + matchSource = "spendHTLCs" } // Also check the individual second-level sweeps. if justiceCtx == nil { - for _, tx := range justiceTxs.spendSecondLevelHTLCs { + for i, tx := range justiceTxs.spendSecondLevelHTLCs { if matchesJusticeTx(tx) { justiceCtx = tx + matchSource = fmt.Sprintf( + "secondLevel[%d]", i, + ) break } @@ -900,12 +908,81 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, // justiceTxs has been replaced with newer variants. if justiceCtx == nil { justiceCtx = historicJusticeTxs[spendingTxHash] + if justiceCtx != nil { + matchSource = "historic" + } } // If this is one of our justice txs, notify the aux sweeper. if justiceCtx != nil { + brarLog.Infof("[NOTIFY-JUSTICE] matched spend "+ + "txid=%v to justice tx via %s "+ + "(numInputs=%d), notifying aux sweeper", + spendingTxHash, matchSource, + len(justiceCtx.inputs)) + for i, inp := range justiceCtx.inputs { + brarLog.Infof("[NOTIFY-JUSTICE] "+ + "input[%d]: outpoint=%v "+ + "witnessType=%v", + i, inp.OutPoint(), + inp.WitnessType()) + } + } else { + brarLog.Infof("[NOTIFY-JUSTICE] spend txid=%v "+ + "did NOT match any justice tx", + spendingTxHash) + } + if justiceCtx != nil { + // Build a fresh input list by matching the + // confirmed tx's BTC inputs against the + // current breachedOutputs. This avoids using + // stale pointers from historic variants + // whose data may have been mutated by + // second-level morphing or slice compaction. + spendingTx := s.detail.SpendingTx + boByOutpoint := make( + map[wire.OutPoint]*breachedOutput, + ) + for i := range breachedOutputs { + bo := &breachedOutputs[i] + boByOutpoint[bo.outpoint] = bo + } + + var freshInputs []input.Input + hasSecondLevel := false + + // Preserve second-level awareness from the justice + // tx context snapshot. The current breachedOutputs + // view can lag after morphing/rebuilds, and if we + // lose this bit for a mixed justice tx we stop + // looking up refreshed input proofs for the + // second-level spends on aux channels. + hasSecondLevel = justiceCtx.hasSecondLevel + + for _, txIn := range spendingTx.TxIn { + op := txIn.PreviousOutPoint + bo, ok := boByOutpoint[op] + if !ok { + continue + } + freshInputs = append(freshInputs, bo) + + wt := bo.WitnessType() + switch wt { + case input.HtlcSecondLevelRevoke, + input.TaprootHtlcSecondLevelRevoke: + + hasSecondLevel = true + } + } + + brarLog.Infof("[NOTIFY-JUSTICE] built "+ + "fresh input list: %d inputs "+ + "(hasSecondLevel=%v)", + len(freshInputs), hasSecondLevel) + bumpReq := sweep.BumpRequest{ - Inputs: justiceCtx.inputs, + Inputs: freshInputs, DeliveryAddress: justiceCtx.sweepAddr, ExtraTxOut: justiceCtx.extraTxOut, } @@ -914,14 +991,17 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, b.cfg.AuxSweeper, func(aux sweep.AuxSweeper) error { // The tx is already confirmed, so - // skip broadcast and proof verify - // (placeholder witnesses). + // skip broadcast. Proof verification + // must run to ensure valid anchor + // metadata for spending. + h := uint32(s.detail.SpendingHeight) return aux.NotifyBroadcast( &bumpReq, s.detail.SpendingTx, justiceCtx.fee, nil, sweep.AuxNotifyOpts{ - SkipBroadcast: true, - SkipProofVerify: true, + SkipBroadcast: true, + ConfirmHeight: h, + LookupInputProofs: hasSecondLevel, //nolint:ll }, ) }, @@ -949,8 +1029,22 @@ func recordJusticeTxVariants(variants *justiceTxVariants, if jtx == nil { return } - hash := jtx.justiceTx.TxHash() - history[hash] = jtx + + snapshot := *jtx + if jtx.justiceTx != nil { + snapshot.justiceTx = jtx.justiceTx.Copy() + } + + // The historic map is only used to match later confirmed + // spends against the justice variants that were published + // at the time. Store an immutable snapshot so later rebuilds + // can't mutate the metadata associated with an older txid + // and retroactively change whether that tx should be + // treated as carrying second-level inputs. + snapshot.inputs = nil + + hash := snapshot.justiceTx.TxHash() + history[hash] = &snapshot } record(variants.spendAll) @@ -1039,15 +1133,13 @@ justiceTxBroadcast: recordJusticeTxVariants(justiceTxs, historicJusticeTxs) finalTx := justiceTxs.spendAll - brarLog.Debugf("Broadcasting justice tx: %v", lnutils.SpewLogClosure( - finalTx)) - // We'll now attempt to broadcast the transaction which finalized the // channel's retribution against the cheating counter party. label := labels.MakeLabel(labels.LabelTypeJusticeTransaction, nil) err = b.cfg.PublishTransaction(finalTx.justiceTx, label) if err != nil { - brarLog.Errorf("Unable to broadcast justice tx: %v", err) + brarLog.Errorf("Unable to broadcast initial spendAll "+ + "justice tx: %v", err) } // Regardless of publication succeeded or not, we now wait for any of @@ -1093,12 +1185,14 @@ Loop: spends, justiceTxs, historicJusticeTxs, notifiedJusticeTxs, + breachInfo.breachedOutputs, ) // Update the breach info with the new spends. t, r := updateBreachInfo( breachInfo, spends, b.cfg.AuxResolver, ) + totalFunds += t revokedFunds += r @@ -1176,24 +1270,19 @@ Loop: justiceTxs, historicJusticeTxs, ) - // Re-attempt the spendAll variant first, in - // case the breach info was updated since the - // initial broadcast. This avoids splitting into - // small txs that can't pay fees when a combined - // tx would work. + // Re-attempt the spendAll variant first. if justiceTxs.spendAll != nil { + sa := justiceTxs.spendAll label := labels.MakeLabel( labels.LabelTypeJusticeTransaction, nil, ) err = b.cfg.PublishTransaction( - justiceTxs.spendAll.justiceTx, - label, + sa.justiceTx, label, ) if err != nil { - brarLog.Warnf("Unable to "+ - "broadcast updated "+ - "spendAll justice "+ + brarLog.Warnf("Unable to broadcast "+ + "rebuild spendAll justice "+ "tx: %v", err) } } @@ -1875,6 +1964,12 @@ type justiceTxCtx struct { fee btcutil.Amount + // hasSecondLevel records whether this concrete justice tx spends any + // second-level revoke inputs. We keep it on the tx context because the + // live breachedOutputs slice may later be rebuilt into a view that no + // longer faithfully reflects the confirmed tx we need to notify about. + hasSecondLevel bool + inputs []input.Input } @@ -2090,7 +2185,12 @@ func (b *BreachArbitrator) sweepSpendableOutputsTxn(txWeight lntypes.WeightUnit, sweepAddr: pkScript, extraTxOut: extraChangeOut.OkToSome(), fee: txFee, - inputs: inputs, + hasSecondLevel: fn.Any(inputs, func(inp input.Input) bool { + wt := inp.WitnessType() + return wt == input.HtlcSecondLevelRevoke || + wt == input.TaprootHtlcSecondLevelRevoke + }), + inputs: inputs, }, nil } diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go index 8bb2101721a..36d838ae8a6 100644 --- a/contractcourt/breach_arbitrator_test.go +++ b/contractcourt/breach_arbitrator_test.go @@ -3220,6 +3220,7 @@ func TestNotifyConfirmedJusticeTx(t *testing.T) { brar.notifyConfirmedJusticeTx( tc.spends, tc.justiceTxs, historicTxs, tc.notifiedTxs, + nil, ) // Verify the number of NotifyBroadcast calls. @@ -3240,9 +3241,12 @@ func TestNotifyConfirmedJusticeTx(t *testing.T) { require.Equal(t, tc.expectedSkipFlag, call.opts.SkipBroadcast, "SkipBroadcast should be true") - require.Equal(t, tc.expectedSkipFlag, + // SkipProofVerify is NOT set — + // proof verification must run to + // ensure valid anchor metadata. + require.False(t, call.opts.SkipProofVerify, - "SkipProofVerify should be true") + "SkipProofVerify should be false") } // Verify notifiedTxs map was updated for successful @@ -3293,7 +3297,7 @@ func TestNotifyConfirmedJusticeTxNoAuxSweeper(t *testing.T) { // aux sweeper to notify. historicTxs := make(map[chainhash.Hash]*justiceTxCtx) brar.notifyConfirmedJusticeTx( - spends, justiceTxs, historicTxs, notifiedTxs, + spends, justiceTxs, historicTxs, notifiedTxs, nil, ) // The tx should still be marked as notified even without an aux diff --git a/input/signdescriptor.go b/input/signdescriptor.go index abfbf725faa..425ba964588 100644 --- a/input/signdescriptor.go +++ b/input/signdescriptor.go @@ -11,7 +11,6 @@ import ( "github.com/lightningnetwork/lnd/keychain" ) - // SignDescriptor houses the necessary information required to successfully // sign a given segwit output. This struct is used by the Signer interface in // order to gain access to critical data needed to generate a valid signature. diff --git a/lnwallet/aux_resolutions.go b/lnwallet/aux_resolutions.go index 2ae762afd00..3a98668e8aa 100644 --- a/lnwallet/aux_resolutions.go +++ b/lnwallet/aux_resolutions.go @@ -28,11 +28,17 @@ const ( // AuxSigDesc stores optional information related to 2nd level HTLCs for aux // channels. type AuxSigDesc struct { - // AuxSig is the second-level signature for the HTLC that we are trying - // to resolve. This is only present if this is a resolution request for - // an HTLC on our commitment transaction. + // AuxSig is the second-level signature for the HTLC's primary + // spending path (success for incoming, timeout for outgoing). AuxSig []byte + // AuxSigAlt is the second-level signature for the HTLC's + // alternate spending path (timeout for incoming, success for + // outgoing). At breach time, the honest party uses the BTC-level + // witness to determine which path was used and selects the + // matching sig. + AuxSigAlt []byte + // SignDetails is the sign details for the second-level HTLC. This may // be used to generate the second signature needed for broadcast. SignDetails input.SignDetails diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index e0560ee3972..7be0fd891f5 100644 --- a/lnwallet/aux_signer.go +++ b/lnwallet/aux_signer.go @@ -194,9 +194,20 @@ type BaseAuxJob struct { HTLC AuxHtlcDescriptor // Incoming is a boolean that indicates if the HTLC is incoming or - // outgoing. + // outgoing from the LOCAL party's perspective. This is used with + // WhoseCommit to determine the correct HTLC script variant + // (sender vs receiver). Incoming bool + // IncomingHTLCLookup controls which HTLC aux output list in the + // commitment blob the signer uses to find the aux outputs. + // When true, the signer looks in IncomingHtlcAssets; when false, + // in OutgoingHtlcAssets. This is normally the same as Incoming, + // but differs for revocation self-signing where the Incoming + // flag is flipped for script generation but the aux output lookup + // must still use the original direction. + IncomingHTLCLookup bool + // CommitBlob is the commitment transaction blob that contains the aux // information for this channel. CommitBlob fn.Option[tlv.Blob] @@ -204,6 +215,16 @@ type BaseAuxJob struct { // HtlcLeaf is the aux tap leaf that corresponds to the HTLC being // signed/verified. HtlcLeaf input.AuxTapLeaf + + // WhoseCommit indicates which party's commitment transaction the + // second-level HTLC belongs to. + WhoseCommit lntypes.ChannelParty + + // HtlcTimeout, if set, overrides the timeout logic in + // generateHtlcSignature and verifyHtlcSignature. When nil, + // the timeout is derived from Incoming (the normal CommitSig + // convention). When set, it is used directly. + HtlcTimeout fn.Option[uint32] } // AuxSigJob is a struct that contains all the information needed to sign an @@ -224,20 +245,24 @@ type AuxSigJob struct { Cancel <-chan struct{} } -// NewAuxSigJob creates a new AuxSigJob. +// NewAuxSigJob creates a new AuxSigJob. The whoseCommit parameter indicates +// which party's commitment the HTLC belongs to. func NewAuxSigJob(sigJob SignJob, keyRing CommitmentKeyRing, incoming bool, htlc AuxHtlcDescriptor, commitBlob fn.Option[tlv.Blob], - htlcLeaf input.AuxTapLeaf, cancelChan <-chan struct{}) AuxSigJob { + htlcLeaf input.AuxTapLeaf, whoseCommit lntypes.ChannelParty, + cancelChan <-chan struct{}) AuxSigJob { return AuxSigJob{ SignDesc: sigJob.SignDesc, BaseAuxJob: BaseAuxJob{ - OutputIndex: sigJob.OutputIndex, - KeyRing: keyRing, - HTLC: htlc, - Incoming: incoming, - CommitBlob: commitBlob, - HtlcLeaf: htlcLeaf, + OutputIndex: sigJob.OutputIndex, + KeyRing: keyRing, + HTLC: htlc, + Incoming: incoming, + IncomingHTLCLookup: incoming, + CommitBlob: commitBlob, + HtlcLeaf: htlcLeaf, + WhoseCommit: whoseCommit, }, Resp: make(chan AuxSigJobResp, 1), Cancel: cancelChan, @@ -353,11 +378,12 @@ func ResolveHtlcSigHashType(chanType channeldb.ChannelType, return sigHash.UnwrapOr(HtlcSigHashType(chanType)) } -// IsSigHashDefault returns true if the resolved HTLC sighash type for the -// given channel is SigHashDefault. This is used to determine whether -// second-level HTLC transactions must carry their own fee (since the sweeper -// cannot add wallet inputs under SigHashDefault). -func IsSigHashDefault(chanType channeldb.ChannelType, +// IsDeterministicHTLCs returns true if the DeterministicHTLCs feature is +// active for the given channel. When true, second-level HTLC transactions +// use SigHashDefault (making them fully deterministic), must carry their +// own fees, and the revoking party includes dual-path AuxSigs in +// RevokeAndAck for breach proof reconstruction. +func IsDeterministicHTLCs(chanType channeldb.ChannelType, auxSigner fn.Option[AuxSigner], req HtlcSigHashReq) bool { diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 33059309c9d..7c2005c09d6 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -486,7 +486,7 @@ func (lc *LightningChannel) IsChanSigHashDefault() bool { lc.channelState.FundingOutpoint, ) - return IsSigHashDefault( + return IsDeterministicHTLCs( lc.channelState.ChanType, lc.auxSigner, HtlcSigHashReq{ @@ -1026,7 +1026,7 @@ func NewLightningChannel(signer input.Signer, commitChains: commitChains, channelState: state, commitBuilder: NewCommitmentBuilder( - state, opts.leafStore, IsSigHashDefault( + state, opts.leafStore, IsDeterministicHTLCs( state.ChanType, opts.auxSigner, HtlcSigHashReq{ ChanID: fn.Some( @@ -2610,10 +2610,17 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, } // Populate the AuxSigDesc if the HTLC has custom - // records (containing the remote's asset-level - // sig for the second-level tx). This allows the - // aux subsystem to construct a valid proof with - // real witnesses instead of placeholders. + // records containing aux signatures. We look for two + // types: + // + // 1. revocationAuxSigType: the remote party's sig for + // THEIR OWN commitment's second-level HTLCs (sent + // in RevokeAndAck). This is the correct sig for + // reconstructing the breach proof chain. + // + // 2. htlcCustomSigType: the remote party's sig for + // OUR local commitment (sent in CommitSig). This + // is a fallback for backward compatibility. htlc.CustomBlob.WhenSome( func(r tlv.RecordT[tlv.TlvType5, tlv.Blob]) { customRecords, err := lnwire. @@ -2622,29 +2629,39 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, return } - sigType := htlcCustomSigType.TypeVal() - auxSig := customRecords[uint64(sigType)] + // Extract both the primary and alternate + // revocation aux sigs. + revSigType := revocationAuxSigType.TypeVal() + auxSig := customRecords[uint64(revSigType)] + altSigType := revocationAuxSigAltType.TypeVal() + auxSigAlt := customRecords[uint64(altSigType)] + + // Fall back to the CommitSig aux sig. + if len(auxSig) == 0 { + sigType := htlcCustomSigType.TypeVal() + auxSig = customRecords[uint64(sigType)] + } + if len(auxSig) > 0 { - // Construct the HTLC sign - // descriptor that the aux - // subsystem needs to sign the - // second-level transition proof. - htlcSignDesc := input.SignDescriptor{ + // Construct the sign desc for + // the second-level tx proof. + sd := input.SignDescriptor{ KeyDesc: chanState. LocalChanCfg. HtlcBasePoint, SingleTweak: keyRing. LocalHtlcKeyTweak, - SignMethod: input. - TaprootScriptSpendSignMethod, + SignMethod: input.TaprootScriptSpendSignMethod, //nolint:ll } + details := input.SignDetails{ + SignDesc: sd, + } resolveReq.AuxSigDesc = fn.Some( AuxSigDesc{ - AuxSig: auxSig, - SignDetails: input.SignDetails{ - SignDesc: htlcSignDesc, - }, + AuxSig: auxSig, + AuxSigAlt: auxSigAlt, + SignDetails: details, }, ) } @@ -2855,7 +2872,7 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chainfee.SatPerKWeight(revokedLog.FeePerKw), htlc.Amt.ToSatoshis(), chanState.RemoteChanCfg.DustLimit, - IsSigHashDefault( + IsDeterministicHTLCs( chanState.ChanType, auxSigner, HtlcSigHashReq{ @@ -3582,7 +3599,7 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, // For each outgoing and incoming HTLC, if the HTLC isn't considered a // dust output after taking into account second-level HTLC fees, then a // sigJob will be generated and appended to the current batch. - sigHashDefault := IsSigHashDefault( + sigHashDefault := IsDeterministicHTLCs( chanType, auxSigner, sigHashReq, ) for _, htlc := range remoteCommitView.incomingHTLCs { @@ -3666,7 +3683,8 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, auxSigBatch = append(auxSigBatch, NewAuxSigJob( sigJob, *keyRing, true, newAuxHtlcDescriptor(&htlc), - remoteCommitView.customBlob, auxLeaf, cancelChan, + remoteCommitView.customBlob, auxLeaf, + lntypes.Remote, cancelChan, )) } for _, htlc := range remoteCommitView.outgoingHTLCs { @@ -3749,13 +3767,879 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, auxSigBatch = append(auxSigBatch, NewAuxSigJob( sigJob, *keyRing, false, newAuxHtlcDescriptor(&htlc), - remoteCommitView.customBlob, auxLeaf, cancelChan, + remoteCommitView.customBlob, auxLeaf, + lntypes.Remote, cancelChan, )) } return sigBatch, auxSigBatch, cancelChan, nil } +// revocationAuxSigType is the TLV type used to store the remote party's +// aux signatures for their own second-level HTLCs in the HTLC +// CustomRecords within the revocation log. We use a different type than +// htlcCustomSigType to avoid collisions with the sigs the remote sent +// for OUR local commitment (stored during genHtlcSigValidationJobs). +var revocationAuxSigType tlv.TlvType65637 + +// revocationAuxSigAltType stores the alternate spending path's AuxSig. +// For incoming HTLCs, the primary is success and alt is timeout. +// For outgoing HTLCs, the primary is timeout and alt is success. +var revocationAuxSigAltType tlv.TlvType65639 + +// revocationAuxSigEntry holds a single HTLC's revocation AuxSigs tagged with +// its HTLC index, so the receiver can match sigs to HTLCs unambiguously +// regardless of HTLC ordering differences between local and remote views. +type revocationAuxSigEntry struct { + htlcIndex uint64 + primarySig []byte + altSig []byte +} + +// packRevocationAuxSigs encodes a list of HTLC-index-tagged sig pairs into a +// single blob. Format: [count (4 bytes BE)][entries...] where each entry is: +// +// [htlcIndex (8 bytes BE)][primaryLen (2 bytes BE)][primary bytes] +// [altLen (2 bytes BE)][alt bytes] +func packRevocationAuxSigs(entries []revocationAuxSigEntry) []byte { + var buf bytes.Buffer + + // Entry count. + count := uint32(len(entries)) + buf.Write([]byte{byte(count >> 24), byte(count >> 16), + byte(count >> 8), byte(count)}) + + for _, e := range entries { + // HTLC index (8 bytes BE). + idx := e.htlcIndex + buf.Write([]byte{ + byte(idx >> 56), byte(idx >> 48), + byte(idx >> 40), byte(idx >> 32), + byte(idx >> 24), byte(idx >> 16), + byte(idx >> 8), byte(idx), + }) + + // Primary sig. + pLen := uint16(len(e.primarySig)) + buf.Write([]byte{byte(pLen >> 8), byte(pLen)}) + buf.Write(e.primarySig) + + // Alt sig. + aLen := uint16(len(e.altSig)) + buf.Write([]byte{byte(aLen >> 8), byte(aLen)}) + buf.Write(e.altSig) + } + + return buf.Bytes() +} + +// unpackRevocationAuxSigs decodes a blob produced by packRevocationAuxSigs +// into a map of HTLC index → (primarySig, altSig). +func unpackRevocationAuxSigs( + blob []byte) (map[uint64]revocationAuxSigEntry, error) { + + if len(blob) < 4 { + return nil, fmt.Errorf("revocation aux sig blob too short: "+ + "%d bytes", len(blob)) + } + + count := uint32(blob[0])<<24 | uint32(blob[1])<<16 | + uint32(blob[2])<<8 | uint32(blob[3]) + pos := 4 + + result := make(map[uint64]revocationAuxSigEntry, count) + for i := uint32(0); i < count; i++ { + if pos+8 > len(blob) { + return nil, fmt.Errorf("truncated at entry %d "+ + "htlcIndex", i) + } + htlcIdx := uint64(blob[pos])<<56 | uint64(blob[pos+1])<<48 | + uint64(blob[pos+2])<<40 | uint64(blob[pos+3])<<32 | + uint64(blob[pos+4])<<24 | uint64(blob[pos+5])<<16 | + uint64(blob[pos+6])<<8 | uint64(blob[pos+7]) + pos += 8 + + if pos+2 > len(blob) { + return nil, fmt.Errorf("truncated at entry %d "+ + "primaryLen", i) + } + pLen := int(uint16(blob[pos])<<8 | uint16(blob[pos+1])) + pos += 2 + if pos+pLen > len(blob) { + return nil, fmt.Errorf("truncated at entry %d "+ + "primary sig", i) + } + primary := make([]byte, pLen) + copy(primary, blob[pos:pos+pLen]) + pos += pLen + + if pos+2 > len(blob) { + return nil, fmt.Errorf("truncated at entry %d "+ + "altLen", i) + } + aLen := int(uint16(blob[pos])<<8 | uint16(blob[pos+1])) + pos += 2 + if pos+aLen > len(blob) { + return nil, fmt.Errorf("truncated at entry %d "+ + "alt sig", i) + } + alt := make([]byte, aLen) + copy(alt, blob[pos:pos+aLen]) + pos += aLen + + result[htlcIdx] = revocationAuxSigEntry{ + htlcIndex: htlcIdx, + primarySig: primary, + altSig: alt, + } + } + + return result, nil +} + +// injectRevocationAuxSigs takes the packed aux sig blob from a RevokeAndAck +// and injects individual HTLC sigs into the remote commitment's HTLC entries. +// This must be called before AdvanceCommitChainTail so the sigs are persisted +// in the revocation log. +func (lc *LightningChannel) injectRevocationAuxSigs(auxSigBlob []byte) { + sigMap, err := unpackRevocationAuxSigs(auxSigBlob) + if err != nil { + lc.log.Warnf("Unable to unpack revocation aux sigs: %v", err) + return + } + + if len(sigMap) == 0 { + return + } + + primaryType := revocationAuxSigType.TypeVal() + altType := revocationAuxSigAltType.TypeVal() + htlcs := lc.channelState.RemoteCommitment.Htlcs + // Match sigs to HTLCs by HTLC index. + for i := range htlcs { + entry, ok := sigMap[htlcs[i].HtlcIndex] + if !ok { + continue + } + + if htlcs[i].CustomRecords == nil { + htlcs[i].CustomRecords = make(lnwire.CustomRecords) + } + if len(entry.primarySig) > 0 { + htlcs[i].CustomRecords[uint64(primaryType)] = + entry.primarySig + } + if len(entry.altSig) > 0 { + htlcs[i].CustomRecords[uint64(altType)] = + entry.altSig + } + } +} + +// verifyRevocationAuxSigs verifies the aux sigs received in a RevokeAndAck +// message. It derives the same key ring that the breach-time code will use +// (DeriveCommitmentKeys with whoseCommit=Remote and our local configs) and +// verifies the signatures against the remote commitment's HTLCs. If the +// signatures don't verify now, they won't work at breach time either. +// +// The commitPoint must be the RemoteCurrentRevocation BEFORE it is rotated. +func (lc *LightningChannel) verifyRevocationAuxSigs( + auxSigBlob []byte, commitPoint *btcec.PublicKey) error { + + // If there's no aux signer, nothing to verify. + if lc.auxSigner.IsNone() { + return nil + } + + auxSigner, _ := lc.auxSigner.UnwrapOrErr( + fmt.Errorf("no aux signer"), + ) + + // Unpack the HTLC-index-tagged blob. + sigMap, err := unpackRevocationAuxSigs(auxSigBlob) + if err != nil { + return fmt.Errorf("unable to unpack revocation aux sigs "+ + "for verification: %w", err) + } + + if len(sigMap) == 0 { + return nil + } + + // Get the remote commitment view (about to be revoked). + remoteCommitView := lc.commitChains.Remote.tail() + if remoteCommitView == nil { + return nil + } + + numHTLCs := len(remoteCommitView.incomingHTLCs) + + len(remoteCommitView.outgoingHTLCs) + if numHTLCs == 0 { + return nil + } + + // Derive the key ring the same way the breach code does. + keyRing := DeriveCommitmentKeys( + commitPoint, lntypes.Remote, + lc.channelState.ChanType, + &lc.channelState.LocalChanCfg, + &lc.channelState.RemoteChanCfg, + ) + + chanState := lc.channelState + chanType := chanState.ChanType + remoteChanCfg := chanState.RemoteChanCfg + feePerKw := remoteCommitView.feePerKw + dustLimit := remoteChanCfg.DustLimit + + sigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: chanState.RemoteCommitment.CustomBlob, + } + sigHashDefault := IsDeterministicHTLCs( + chanType, lc.auxSigner, sigHashReq, + ) + + // Fetch aux leaves for the remote commitment. + diskCommit := remoteCommitView.toDiskCommit(lntypes.Remote) + auxResult, err := fn.MapOptionZ( + lc.leafStore, + func(s AuxLeafStore) fn.Result[CommitDiffAuxResult] { + return s.FetchLeavesFromCommit( + NewAuxChanState(chanState), *diskCommit, + *keyRing, lntypes.Remote, + ) + }, + ).Unpack() + if err != nil { + return fmt.Errorf("unable to fetch aux leaves for "+ + "revocation verification: %w", err) + } + + // Build verify jobs for both spending paths of each non-dust + // HTLC, matched by HTLC index from the unpacked sig map. + type htlcEntry struct { + htlc *paymentDescriptor + incoming bool + } + var htlcList []htlcEntry + for i := range remoteCommitView.incomingHTLCs { + htlcList = append(htlcList, htlcEntry{ + htlc: &remoteCommitView.incomingHTLCs[i], + incoming: true, + }) + } + for i := range remoteCommitView.outgoingHTLCs { + htlcList = append(htlcList, htlcEntry{ + htlc: &remoteCommitView.outgoingHTLCs[i], + incoming: false, + }) + } + + auxVerifyJobs := make([]AuxVerifyJob, 0, numHTLCs*2) + commitBlob := remoteCommitView.customBlob + + for _, entry := range htlcList { + htlc := entry.htlc + incoming := entry.incoming + + if HtlcIsDust( + chanType, incoming, lntypes.Remote, feePerKw, + htlc.Amount.ToSatoshis(), dustLimit, + sigHashDefault, + ) { + + continue + } + + sigEntry, ok := sigMap[htlc.HtlcIndex] + if !ok { + return fmt.Errorf("no revocation aux sig for "+ + "HTLC index %d", htlc.HtlcIndex) + } + + // Determine the aux leaf for this HTLC. + var auxLeaf input.AuxTapLeaf + if incoming { + auxLeaf = fn.FlatMapOption( + func(l CommitAuxLeaves) input.AuxTapLeaf { + idx := htlc.HtlcIndex + leaves := l.IncomingHtlcLeaves + return leaves[idx].SecondLevelLeaf + }, + )(auxResult.AuxLeaves) + } else { + auxLeaf = fn.FlatMapOption( + func(l CommitAuxLeaves) input.AuxTapLeaf { + idx := htlc.HtlcIndex + leaves := l.OutgoingHtlcLeaves + return leaves[idx].SecondLevelLeaf + }, + )(auxResult.AuxLeaves) + } + + // Verify the PRIMARY sig against its natural spending + // path: incoming→success, outgoing→timeout. From OUR + // perspective, the remote's incoming is our outgoing + // and vice versa. The signer signs from their local + // perspective where their incoming=success. Our + // "incoming" here means "incoming to the remote + // commitment" = the signer's outgoing = timeout. + // + // So: our incoming → signer's outgoing → timeout path + // our outgoing → signer's incoming → success path + if len(sigEntry.primarySig) > 0 { + primaryJob := AuxVerifyJob{ + SigBlob: fn.Some(sigEntry.primarySig), + BaseAuxJob: BaseAuxJob{ + OutputIndex: htlc. + remoteOutputIndex, + KeyRing: *keyRing, + HTLC: newAuxHtlcDescriptor( + htlc, + ), + Incoming: incoming, + IncomingHTLCLookup: incoming, + CommitBlob: commitBlob, + HtlcLeaf: auxLeaf, + WhoseCommit: lntypes.Remote, + }, + } + // The primary path for the signer: + // signer's incoming=success (no timeout needed), + // signer's outgoing=timeout (needs timeout). + // Our incoming = signer's outgoing → timeout. + if incoming { + primaryJob.HtlcTimeout = fn.Some(htlc.Timeout) + } + auxVerifyJobs = append(auxVerifyJobs, primaryJob) + } + + // Verify the ALT sig against the alternate spending + // path: incoming→timeout, outgoing→success. + if len(sigEntry.altSig) > 0 { + altJob := AuxVerifyJob{ + SigBlob: fn.Some(sigEntry.altSig), + BaseAuxJob: BaseAuxJob{ + OutputIndex: htlc.remoteOutputIndex, + KeyRing: *keyRing, + HTLC: newAuxHtlcDescriptor(htlc), + // Flip incoming for the alt + // path script generation. + Incoming: !incoming, + IncomingHTLCLookup: incoming, + CommitBlob: commitBlob, + HtlcLeaf: auxLeaf, + WhoseCommit: lntypes.Remote, + }, + } + // Alt path is the opposite: if our incoming + // (signer's outgoing) primary=timeout, then + // alt=success (no timeout needed). And vice versa. + if !incoming { + altJob.HtlcTimeout = fn.Some(htlc.Timeout) + } + auxVerifyJobs = append(auxVerifyJobs, altJob) + } + } + + if len(auxVerifyJobs) == 0 { + return nil + } + + lc.log.Infof("Verifying %d revocation aux sigs (%d HTLCs, "+ + "2 paths each) against breach-time key ring", + len(auxVerifyJobs), len(sigMap)) + + err = auxSigner.VerifySecondLevelSigs( + NewAuxChanState(lc.channelState), + remoteCommitView.txn, auxVerifyJobs, + ) + if err != nil { + return fmt.Errorf("revocation aux sig verification "+ + "failed: %w", err) + } + + lc.log.Infof("Revocation aux sig verification passed") + + return nil +} + +// htlcAuxSigParams bundles the parameters shared across both the incoming +// and outgoing HTLC aux-signing loops. +type htlcAuxSigParams struct { + chanType channeldb.ChannelType + isRemoteInitiator bool + localChanCfg channeldb.ChannelConfig + localHtlcKeyTweak []byte + feePerKw chainfee.SatPerKWeight + dustLimit btcutil.Amount + txHash chainhash.Hash + ourDelay uint32 + leaseExpiry uint32 + sigHashDefault bool + sigHashType txscript.SigHashType + keyRing *CommitmentKeyRing + auxResult CommitDiffAuxResult + commitView *commitment + cancelChan chan struct{} +} + +// signIncomingHTLCAuxSigs signs both spending paths (success + timeout) +// for each non-dust incoming HTLC on the local commitment. +func (p *htlcAuxSigParams) signIncomingHTLCAuxSigs( + htlcs []paymentDescriptor, +) ([]AuxSigJob, error) { + + var jobs []AuxSigJob + for _, htlc := range htlcs { + if HtlcIsDust( + p.chanType, true, lntypes.Local, + p.feePerKw, htlc.Amount.ToSatoshis(), + p.dustLimit, p.sigHashDefault, + ) { + + continue + } + + primary, alt, err := p.signHTLCBothPaths( + &htlc, true, + ) + if err != nil { + return nil, err + } + + jobs = append(jobs, primary, alt) + } + + return jobs, nil +} + +// signOutgoingHTLCAuxSigs signs both spending paths (timeout + success) +// for each non-dust outgoing HTLC on the local commitment. +func (p *htlcAuxSigParams) signOutgoingHTLCAuxSigs( + htlcs []paymentDescriptor, +) ([]AuxSigJob, error) { + + var jobs []AuxSigJob + for _, htlc := range htlcs { + if HtlcIsDust( + p.chanType, false, lntypes.Local, + p.feePerKw, htlc.Amount.ToSatoshis(), + p.dustLimit, p.sigHashDefault, + ) { + + continue + } + + primary, alt, err := p.signHTLCBothPaths( + &htlc, false, + ) + if err != nil { + return nil, err + } + + jobs = append(jobs, primary, alt) + } + + return jobs, nil +} + +// signHTLCBothPaths creates AuxSigJobs for both the primary and +// alternate spending paths of an HTLC. For incoming HTLCs the +// primary path is success and the alternate is timeout; for +// outgoing HTLCs the primary is timeout and the alternate is +// success. +func (p *htlcAuxSigParams) signHTLCBothPaths( + htlc *paymentDescriptor, incoming bool, +) (AuxSigJob, AuxSigJob, error) { + + auxLeaf := p.getAuxLeaf(htlc, incoming) + op := wire.OutPoint{ + Hash: p.txHash, + Index: uint32(htlc.localOutputIndex), + } + + // Build the primary second-level tx. + primaryTx, _, err := p.createSecondLevelTx( + op, htlc, incoming, auxLeaf, + ) + if err != nil { + return AuxSigJob{}, AuxSigJob{}, err + } + + amt := htlc.Amount.ToSatoshis() + txOut := p.commitView.txn.TxOut[htlc.localOutputIndex] + prevFetcher := txscript.NewCannedPrevOutputFetcher( + txOut.PkScript, int64(amt), + ) + + primaryJob, err := p.buildSigJob( + primaryTx, txOut, prevFetcher, htlc, + incoming, auxLeaf, + ) + if err != nil { + return AuxSigJob{}, AuxSigJob{}, err + } + + // Build the alternate second-level tx (opposite path). + altTx, _, err := p.createSecondLevelTx( + op, htlc, !incoming, auxLeaf, + ) + if err != nil { + return AuxSigJob{}, AuxSigJob{}, err + } + + altJob, err := p.buildSigJob( + altTx, txOut, prevFetcher, htlc, + !incoming, auxLeaf, + ) + if err != nil { + return AuxSigJob{}, AuxSigJob{}, err + } + + // The alt job flips Incoming for script generation, but the + // asset lookup must still use the original HTLC direction. + altJob.IncomingHTLCLookup = incoming + + // Set timeout fields. The primary path for incoming is + // success (no timeout), alt is timeout. Vice versa for + // outgoing. + if incoming { + primaryJob.HtlcTimeout = fn.None[uint32]() + altJob.HtlcTimeout = fn.Some(htlc.Timeout) + } else { + primaryJob.HtlcTimeout = fn.Some(htlc.Timeout) + altJob.HtlcTimeout = fn.None[uint32]() + } + + return primaryJob, altJob, nil +} + +// createSecondLevelTx creates the second-level HTLC transaction for +// the given path. When incoming is true a success-tx is created; +// when false a timeout-tx is created. Returns the tx and its fee. +func (p *htlcAuxSigParams) createSecondLevelTx( + op wire.OutPoint, htlc *paymentDescriptor, + incoming bool, auxLeaf input.AuxTapLeaf, +) (*wire.MsgTx, btcutil.Amount, error) { + + if incoming { + fee := HtlcSuccessFee( + p.chanType, p.feePerKw, p.sigHashDefault, + ) + amt := htlc.Amount.ToSatoshis() - fee + tx, err := CreateHtlcSuccessTx( + p.chanType, p.isRemoteInitiator, op, + amt, p.ourDelay, p.leaseExpiry, + p.keyRing.RevocationKey, + p.keyRing.ToLocalKey, auxLeaf, + ) + + return tx, fee, err + } + + fee := HtlcTimeoutFee( + p.chanType, p.feePerKw, p.sigHashDefault, + ) + amt := htlc.Amount.ToSatoshis() - fee + tx, err := CreateHtlcTimeoutTx( + p.chanType, p.isRemoteInitiator, op, amt, + htlc.Timeout, p.ourDelay, p.leaseExpiry, + p.keyRing.RevocationKey, + p.keyRing.ToLocalKey, auxLeaf, + ) + + return tx, fee, err +} + +// buildSigJob creates an AuxSigJob for a second-level HTLC +// transaction, wrapping the sign descriptor and script generation. +func (p *htlcAuxSigParams) buildSigJob( + tx *wire.MsgTx, txOut *wire.TxOut, + prevFetcher txscript.PrevOutputFetcher, + htlc *paymentDescriptor, incoming bool, + auxLeaf input.AuxTapLeaf, +) (AuxSigJob, error) { + + hashCache := txscript.NewTxSigHashes(tx, prevFetcher) + htlcScript, err := genHtlcScript( + p.chanType, incoming, lntypes.Local, + htlc.Timeout, htlc.RHash, p.keyRing, + fn.None[txscript.TapLeaf](), + ) + if err != nil { + return AuxSigJob{}, err + } + + ws := htlcScript.WitnessScriptToSign() + sigJob := SignJob{ + SignDesc: input.SignDescriptor{ + KeyDesc: p.localChanCfg.HtlcBasePoint, + SingleTweak: p.localHtlcKeyTweak, + WitnessScript: ws, + Output: txOut, + PrevOutputFetcher: prevFetcher, + HashType: p.sigHashType, + SigHashes: hashCache, + InputIndex: 0, + }, + Tx: tx, + OutputIndex: htlc.localOutputIndex, + } + if p.chanType.IsTaproot() { + sigJob.SignDesc.SignMethod = input.TaprootScriptSpendSignMethod + } + + auxJob := NewAuxSigJob( + sigJob, *p.keyRing, incoming, + newAuxHtlcDescriptor(htlc), + p.commitView.customBlob, auxLeaf, + lntypes.Local, p.cancelChan, + ) + + return auxJob, nil +} + +// getAuxLeaf returns the aux tap leaf for the given HTLC. +func (p *htlcAuxSigParams) getAuxLeaf( + htlc *paymentDescriptor, incoming bool, +) input.AuxTapLeaf { + + if incoming { + return fn.FlatMapOption( + func(l CommitAuxLeaves) input.AuxTapLeaf { + idx := htlc.HtlcIndex + leaves := l.IncomingHtlcLeaves + return leaves[idx].SecondLevelLeaf + }, + )(p.auxResult.AuxLeaves) + } + + return fn.FlatMapOption( + func(l CommitAuxLeaves) input.AuxTapLeaf { + idx := htlc.HtlcIndex + leaves := l.OutgoingHtlcLeaves + return leaves[idx].SecondLevelLeaf + }, + )(p.auxResult.AuxLeaves) +} + +// signLocalHtlcAuxSigs signs the second-level HTLC virtual packets for the +// current local commitment (the one being revoked). These signatures allow +// the remote party to reconstruct valid aux proofs for the +// commitment-to-second-level transition if a breach occurs. The returned +// blob is packed in the same format as CommitSig aux sigs and can be +// attached to RevokeAndAck.CustomRecords. +// +// This method must be called BEFORE advanceTail() since it needs access to the +// current local commitment view and the commitment secret at currentHeight. +func (lc *LightningChannel) signLocalHtlcAuxSigs() ([]byte, error) { + // If there's no aux signer, nothing to do. + if lc.auxSigner.IsNone() { + return nil, nil + } + + // Get the current local commitment view (about to be revoked). + localCommitView := lc.commitChains.Local.tail() + if localCommitView == nil { + return nil, nil + } + + // Check if there are any HTLCs to sign. + numHTLCs := len(localCommitView.incomingHTLCs) + + len(localCommitView.outgoingHTLCs) + if numHTLCs == 0 { + return nil, nil + } + + // Derive the key ring for our local commitment using the + // standard perspective (whoseCommit=Local, standard config + // order). This matches how the commitment was originally + // built, so the AuxSig will be over the actual on-chain + // second-level HTLC outputs. + commitSecret, err := lc.channelState.RevocationProducer.AtIndex( + lc.currentHeight, + ) + if err != nil { + return nil, err + } + commitPoint := input.ComputeCommitmentPoint(commitSecret[:]) + keyRing := DeriveCommitmentKeys( + commitPoint, lntypes.Local, + lc.channelState.ChanType, + &lc.channelState.LocalChanCfg, + &lc.channelState.RemoteChanCfg, + ) + + chanState := lc.channelState + chanType := chanState.ChanType + + var leaseExpiry uint32 + if chanType.HasLeaseExpiration() { + leaseExpiry = chanState.ThawHeight + } + + sigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: chanState.LocalCommitment.CustomBlob, + } + sigHashDefault := IsDeterministicHTLCs( + chanType, lc.auxSigner, sigHashReq, + ) + sigHashType := ResolveHtlcSigHashType( + chanType, lc.auxSigner, sigHashReq, + ) + + // Fetch aux leaves for our local commitment, matching how + // the commitment was originally constructed. + diskCommit := localCommitView.toDiskCommit(lntypes.Local) + auxResult, err := fn.MapOptionZ( + lc.leafStore, + func(s AuxLeafStore) fn.Result[CommitDiffAuxResult] { + return s.FetchLeavesFromCommit( + NewAuxChanState(chanState), *diskCommit, + *keyRing, lntypes.Local, + ) + }, + ).Unpack() + if err != nil { + return nil, fmt.Errorf("unable to fetch aux leaves: %w", err) + } + + cancelChan := make(chan struct{}) + + localChanCfg := chanState.LocalChanCfg + params := htlcAuxSigParams{ + chanType: chanType, + isRemoteInitiator: !chanState.IsInitiator, + localChanCfg: localChanCfg, + localHtlcKeyTweak: keyRing.LocalHtlcKeyTweak, + feePerKw: localCommitView.feePerKw, + dustLimit: chanState.LocalChanCfg.DustLimit, + txHash: localCommitView.txn.TxHash(), + ourDelay: uint32(localChanCfg.CsvDelay), + leaseExpiry: leaseExpiry, + sigHashDefault: sigHashDefault, + sigHashType: sigHashType, + keyRing: keyRing, + auxResult: auxResult, + commitView: localCommitView, + cancelChan: cancelChan, + } + + // Sign incoming HTLCs (both success + timeout paths). + inJobs, err := params.signIncomingHTLCAuxSigs( + localCommitView.incomingHTLCs, + ) + if err != nil { + return nil, err + } + + // Sign outgoing HTLCs (both timeout + success paths). + outJobs, err := params.signOutgoingHTLCAuxSigs( + localCommitView.outgoingHTLCs, + ) + if err != nil { + return nil, err + } + + auxSigBatch := append(inJobs, outJobs...) //nolint:gocritic + + if len(auxSigBatch) == 0 { + return nil, nil + } + + // Sort by output index to match the order the verify side and + // inject code expect (same as genRemoteHtlcSigJobs). + slices.SortFunc(auxSigBatch, func(i, j AuxSigJob) int { + return cmp.Compare(i.OutputIndex, j.OutputIndex) + }) + + // Submit the signing batch using the standard chanState + // (matching the commitment construction perspective). + auxSigner, _ := lc.auxSigner.UnwrapOrErr( + fmt.Errorf("no aux signer"), + ) + err = auxSigner.SubmitSecondLevelSigBatch( + NewAuxChanState(lc.channelState), + localCommitView.txn, auxSigBatch, + ) + if err != nil { + return nil, fmt.Errorf("unable to submit aux sig batch: %w", + err) + } + + // Collect the signatures from the batch. + auxSigs := make([]fn.Option[tlv.Blob], len(auxSigBatch)) + for i, sigJob := range auxSigBatch { + resp := <-sigJob.Resp + if resp.Err != nil { + close(cancelChan) + return nil, fmt.Errorf("aux sig job %d "+ + "failed: %w", i, resp.Err) + } + auxSigs[i] = resp.SigBlob + } + + // Pack sigs into a blob tagged by HTLC index. We can't rely on the + // two jobs for a given HTLC remaining adjacent after the global + // output-index sort above. Once multiple HTLCs are present, an + // adjacent-pair assumption can mix primary/alt sigs across HTLCs and + // produce an invalid RevokeAndAck aux-sig blob. Group by HTLC index and + // path type explicitly before packing. + entryByHtlc := make(map[uint64]revocationAuxSigEntry) + for i, sigOpt := range auxSigs { + htlcIdx := auxSigBatch[i].HTLC.HtlcIndex + entry := entryByHtlc[htlcIdx] + entry.htlcIndex = htlcIdx + + // The natural path keeps Incoming equal to the original + // HTLC direction used for aux-output lookup. The alternate + // path flips Incoming for script generation but preserves + // IncomingHTLCLookup. + if auxSigBatch[i].Incoming == + auxSigBatch[i].IncomingHTLCLookup { + + entry.primarySig = sigOpt.UnwrapOr(nil) + } else { + entry.altSig = sigOpt.UnwrapOr(nil) + } + + entryByHtlc[htlcIdx] = entry + } + + entries := make([]revocationAuxSigEntry, 0, len(entryByHtlc)) + for _, entry := range entryByHtlc { + entries = append(entries, entry) + } + slices.SortFunc(entries, func(i, j revocationAuxSigEntry) int { + return cmp.Compare(i.htlcIndex, j.htlcIndex) + }) + + // If no entry has any actual sig data, return nil to avoid + // attaching an empty blob to the RevokeAndAck. + hasSigs := false + for _, e := range entries { + if len(e.primarySig) > 0 || len(e.altSig) > 0 { + hasSigs = true + + break + } + } + if !hasSigs { + return nil, nil + } + + packed := packRevocationAuxSigs(entries) + + return packed, nil +} + // createCommitDiff will create a commit diff given a new pending commitment // for the remote party and the necessary signatures for the remote party to // validate this new state. This function is called right before sending the @@ -5197,7 +6081,7 @@ func genHtlcSigValidationJobs(chanState *channeldb.OpenChannel, sigHashType := ResolveHtlcSigHashType( chanType, auxSigner, sigHashReq, ) - sigHashDefault := IsSigHashDefault( + sigHashDefault := IsDeterministicHTLCs( chanType, auxSigner, sigHashReq, ) @@ -6013,6 +6897,36 @@ func (lc *LightningChannel) RevokeCurrentCommitment() (*lnwire.RevokeAndAck, lc.commitChains.Local.tail().height, lc.currentHeight+1) + // Sign second-level HTLC virtual packets for the commitment + // being revoked, but only if the DeterministicHTLCs feature is + // negotiated. Without the feature the remote party has no use + // for the signatures (and wouldn't know how to parse them). + sigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + lc.channelState.FundingOutpoint, + )), + CommitBlob: lc.channelState.LocalCommitment.CustomBlob, + } + + if IsDeterministicHTLCs( + lc.channelState.ChanType, lc.auxSigner, sigHashReq, + ) { + + auxSigBlob, err := lc.signLocalHtlcAuxSigs() + if err != nil { + lc.log.Errorf("Unable to sign local HTLC aux "+ + "sigs for revocation: %v", err) + } + if auxSigBlob != nil { + revocationMsg.CustomRecords = + make(lnwire.CustomRecords) + + recType := uint64(lnwire.MinCustomRecordsTlvType) + revocationMsg.CustomRecords[recType] = + auxSigBlob + } + } + // Advance our tail, as we've revoked our previous state. lc.commitChains.Local.advanceTail() lc.currentHeight++ @@ -6291,6 +7205,43 @@ func (lc *LightningChannel) ReceiveRevocation(revMsg *lnwire.RevokeAndAck) ( lc.musigSessions.RemoteSession = session } + // When the DeterministicHTLCs feature is negotiated, the + // RevokeAndAck must contain aux sigs for the remote party's + // second-level HTLCs. Verify all signatures against the + // breach-time key ring before accepting the revocation, then + // store them in the revocation log for breach recovery. + // + // When the feature is NOT negotiated, skip verification + // entirely — the remote party does not include aux sigs. + revSigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + lc.channelState.FundingOutpoint, + )), + CommitBlob: lc.channelState.RemoteCommitment. + CustomBlob, + } + deterministicHTLCs := IsDeterministicHTLCs( + lc.channelState.ChanType, lc.auxSigner, + revSigHashReq, + ) + if deterministicHTLCs { + recType := uint64(lnwire.MinCustomRecordsTlvType) + auxSigBlob := revMsg.CustomRecords[recType] + + if len(auxSigBlob) > 0 { + err = lc.verifyRevocationAuxSigs( + auxSigBlob, currentCommitPoint, + ) + if err != nil { + return nil, nil, fmt.Errorf("invalid "+ + "revocation aux sigs: %w", + err) + } + + lc.injectRevocationAuxSigs(auxSigBlob) + } + } + // At this point, the revocation has been accepted, and we've rotated // the current revocation key+hash for the remote party. Therefore we // sync now to ensure the revocation producer state is consistent with @@ -7767,7 +8718,7 @@ func newOutgoingHtlcResolution(signer input.Signer, // In order to properly reconstruct the HTLC transaction, we'll need to // re-calculate the fee required at this state, so we can add the // correct output value amount to the transaction. - htlcFee := HtlcTimeoutFee(chanType, feePerKw, IsSigHashDefault( + htlcFee := HtlcTimeoutFee(chanType, feePerKw, IsDeterministicHTLCs( chanType, auxSigner, HtlcSigHashReq{ CommitBlob: chanState.LocalCommitment.CustomBlob, @@ -8157,7 +9108,7 @@ func newIncomingHtlcResolution(signer input.Signer, // // First, we'll reconstruct the original HTLC success transaction, // taking into account the fee rate used. - htlcFee := HtlcSuccessFee(chanType, feePerKw, IsSigHashDefault( + htlcFee := HtlcSuccessFee(chanType, feePerKw, IsDeterministicHTLCs( chanType, auxSigner, HtlcSigHashReq{ CommitBlob: chanState.LocalCommitment.CustomBlob, @@ -8558,7 +9509,7 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, if HtlcIsDust( chanType, htlc.Incoming, whoseCommit, feePerKw, htlc.Amt.ToSatoshis(), dustLimit, - IsSigHashDefault( + IsDeterministicHTLCs( chanType, auxSigner, HtlcSigHashReq{ CommitBlob: chanState. diff --git a/lnwallet/revocation_aux_sig_test.go b/lnwallet/revocation_aux_sig_test.go new file mode 100644 index 00000000000..357704ed279 --- /dev/null +++ b/lnwallet/revocation_aux_sig_test.go @@ -0,0 +1,100 @@ +package lnwallet + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPackUnpackRevocationAuxSigs tests the round-trip encoding and decoding +// of revocation aux sig entries. +func TestPackUnpackRevocationAuxSigs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + entries []revocationAuxSigEntry + }{ + { + name: "empty entries", + entries: []revocationAuxSigEntry{}, + }, + { + name: "single entry with one HTLC", + entries: []revocationAuxSigEntry{ + { + htlcIndex: 42, + primarySig: []byte{0xaa, 0xbb, 0xcc}, + altSig: []byte{0xdd, 0xee, 0xff}, + }, + }, + }, + { + name: "multiple entries with various HTLC indices", + entries: []revocationAuxSigEntry{ + { + htlcIndex: 0, + primarySig: []byte{0x01}, + altSig: []byte{0x02, 0x03}, + }, + { + htlcIndex: 100, + primarySig: []byte{0x04, 0x05, 0x06}, + altSig: []byte{0x07}, + }, + { + htlcIndex: 999, + primarySig: []byte{0x08, 0x09}, + altSig: []byte{ + 0x0a, 0x0b, 0x0c, 0x0d, + }, + }, + }, + }, + { + name: "entry with empty primary and alt sigs", + entries: []revocationAuxSigEntry{ + { + htlcIndex: 7, + primarySig: []byte{}, + altSig: []byte{}, + }, + }, + }, + { + name: "max uint64 HTLC index", + entries: []revocationAuxSigEntry{ + { + htlcIndex: math.MaxUint64, + primarySig: []byte{0xde, 0xad}, + altSig: []byte{0xbe, 0xef}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + packed := packRevocationAuxSigs(tc.entries) + + sigMap, err := unpackRevocationAuxSigs(packed) + require.NoError(t, err) + + require.Len(t, sigMap, len(tc.entries)) + + for _, entry := range tc.entries { + got, ok := sigMap[entry.htlcIndex] + require.True(t, ok, "missing HTLC index %d", + entry.htlcIndex) + + require.Equal(t, entry.htlcIndex, got.htlcIndex) + require.Equal( + t, entry.primarySig, + got.primarySig, + ) + require.Equal(t, entry.altSig, got.altSig) + } + }) + } +} diff --git a/lnwire/revoke_and_ack.go b/lnwire/revoke_and_ack.go index e2a4c930798..222ef35e13e 100644 --- a/lnwire/revoke_and_ack.go +++ b/lnwire/revoke_and_ack.go @@ -42,6 +42,13 @@ type RevokeAndAck struct { // nonces, keyed by TXID. This is used for splice nonce coordination. LocalNonces OptLocalNonces + // CustomRecords is a set of custom TLV records that can be used to + // attach auxiliary data to the revocation message. For custom + // channels, this carries the revoking party's aux signatures for + // the second-level HTLC transactions on the commitment being + // revoked. + CustomRecords CustomRecords + // ExtraData is the set of data that was appended to this message to // fill out the full maximum transport message size. These fields can // be used to specify optional data such as custom TLV fields. @@ -50,9 +57,7 @@ type RevokeAndAck struct { // NewRevokeAndAck creates a new RevokeAndAck message. func NewRevokeAndAck() *RevokeAndAck { - return &RevokeAndAck{ - ExtraData: make([]byte, 0), - } + return &RevokeAndAck{} } // A compile time check to ensure RevokeAndAck implements the lnwire.Message @@ -68,43 +73,41 @@ var _ SizeableMessage = (*RevokeAndAck)(nil) // // This is part of the lnwire.Message interface. func (c *RevokeAndAck) Decode(r io.Reader, pver uint32) error { + // msgExtraData is a temporary variable used to read the message extra + // data field from the reader. + var msgExtraData ExtraOpaqueData + err := ReadElements(r, &c.ChanID, c.Revocation[:], &c.NextRevocationKey, + &msgExtraData, ) if err != nil { return err } - var tlvRecords ExtraOpaqueData - if err := ReadElements(r, &tlvRecords); err != nil { - return err - } - - var ( - localNonce = c.LocalNonce.Zero() - localNoncesData LocalNoncesData - ) + // Extract TLV records from the extra data field. + localNonce := c.LocalNonce.Zero() + var localNoncesData LocalNoncesData - typeMap, err := tlvRecords.ExtractRecords( - &localNonce, &localNoncesData, + customRecords, parsed, extraData, err := ParseAndExtractCustomRecords( + msgExtraData, &localNonce, &localNoncesData, ) if err != nil { return err } // Set the corresponding TLV types if they were included in the stream. - if val, ok := typeMap[c.LocalNonce.TlvType()]; ok && val == nil { + if _, ok := parsed[localNonce.TlvType()]; ok { c.LocalNonce = tlv.SomeRecordT(localNonce) } - if val, ok := typeMap[(LocalNoncesRecordTypeDef)(nil).TypeVal()]; ok && val == nil { //nolint:ll + if _, ok := parsed[(LocalNoncesRecordTypeDef)(nil).TypeVal()]; ok { c.LocalNonces = SomeLocalNonces(localNoncesData) } - if len(tlvRecords) != 0 { - c.ExtraData = tlvRecords - } + c.CustomRecords = customRecords + c.ExtraData = extraData return nil } @@ -121,7 +124,10 @@ func (c *RevokeAndAck) Encode(w *bytes.Buffer, pver uint32) error { c.LocalNonces.WhenSome(func(ln LocalNoncesData) { recordProducers = append(recordProducers, &ln) }) - err := EncodeMessageExtraData(&c.ExtraData, recordProducers...) + + extraData, err := MergeAndEncode( + recordProducers, c.ExtraData, c.CustomRecords, + ) if err != nil { return err } @@ -138,7 +144,7 @@ func (c *RevokeAndAck) Encode(w *bytes.Buffer, pver uint32) error { return err } - return WriteBytes(w, c.ExtraData) + return WriteBytes(w, extraData) } // MsgType returns the integer uniquely identifying this message type on the diff --git a/sweep/interface.go b/sweep/interface.go index f1fe1eb05a2..b745f86f49d 100644 --- a/sweep/interface.go +++ b/sweep/interface.go @@ -83,12 +83,29 @@ type AuxNotifyOpts struct { // commitments) and should not be broadcast again. SkipBroadcast bool - // SkipProofVerify indicates whether asset-level proof + // SkipProofVerify indicates whether aux-level proof // verification should be skipped. This is used when the input // proofs contain placeholder witnesses (e.g. second-level HTLC // outputs) that cannot pass VM-level validation, and the // on-chain confirmation serves as proof of validity instead. SkipProofVerify bool + + // ConfirmHeight is an optional confirmation height hint for the + // transaction. When set, the porter uses this as the height hint + // when scanning for the on-chain confirmation instead of the + // current chain tip. This is critical for breach justice sweeps + // where NotifyBroadcast is called after the tx has already been + // confirmed and the chain has advanced past the confirmation + // block. + ConfirmHeight uint32 + + // LookupInputProofs indicates that the aux sweeper should look + // up the input proofs from its proof archive rather than using + // the proofs embedded in the resolution blob. This is needed + // when the resolution blob carries a stale proof (e.g. the + // commit-level proof for a second-level HTLC output that has + // since been imported with a proper second-level proof). + LookupInputProofs bool } // AuxSweeper is used to enable a 3rd party to further shape the sweeping