Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3207e8c
lnwallet: populate resolution blob for htlc retribution
GeorgeTsagk Apr 14, 2026
fd5100d
sweep+contractcourt: notify aux sweeper only on confirmed sweeps
GeorgeTsagk Apr 14, 2026
c6f3f85
lnwallet: pass breach height to HTLC resolution requests
GeorgeTsagk Apr 14, 2026
5bbe7d9
contractcourt: add tests for notifyConfirmedJusticeTx
GeorgeTsagk Apr 14, 2026
57c345a
lnwallet: extend FetchLeavesFromRevocation with channel state params
GeorgeTsagk Apr 14, 2026
f1dc9ab
lnwallet: preserve resolution request in HTLC retribution data
GeorgeTsagk Apr 14, 2026
e7def50
contractcourt: enhance breach arbiter for second-level HTLC revocation
GeorgeTsagk Apr 14, 2026
25a6ebe
lnwallet+contractcourt: gate HTLC sighash on negotiated feature bit
GeorgeTsagk Apr 14, 2026
4a08dd7
lnwallet: bake fees into second-level HTLCs under SigHashDefault
GeorgeTsagk Apr 14, 2026
05ca8b5
contractcourt: publish pre-signed second-level HTLCs under SigHashDef…
GeorgeTsagk Apr 14, 2026
98b9975
contractcourt: track historic justice tx variants across rebuild cycles
GeorgeTsagk Apr 14, 2026
3e4359c
input: remove ErrTweakOverdose check from SignDescriptor.Decode
GeorgeTsagk Apr 14, 2026
71279a0
sweep+contractcourt: replace skipBroadcast bool with AuxNotifyOpts
GeorgeTsagk Apr 14, 2026
58494f2
lnwallet: populate AuxSigDesc for breach HTLC retribution
GeorgeTsagk Apr 14, 2026
8f0104f
contractcourt: use dust limit for justice tx sweep output check
GeorgeTsagk Apr 14, 2026
c025901
multi: add revocation AuxSig signing and verification
GeorgeTsagk Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
557 changes: 517 additions & 40 deletions contractcourt/breach_arbitrator.go

Large diffs are not rendered by default.

688 changes: 685 additions & 3 deletions contractcourt/breach_arbitrator_test.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions contractcourt/chain_arbitrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down
8 changes: 7 additions & 1 deletion contractcourt/chain_watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -1092,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 {
Expand Down Expand Up @@ -1435,7 +1440,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
Expand Down Expand Up @@ -1542,6 +1547,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
Expand Down
41 changes: 41 additions & 0 deletions contractcourt/htlc_success_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -797,6 +830,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()

Expand Down
42 changes: 42 additions & 0 deletions contractcourt/htlc_timeout_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1320,6 +1354,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()

Expand Down
7 changes: 4 additions & 3 deletions htlcswitch/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -2387,7 +2388,7 @@ func dustHelper(chantype channeldb.ChannelType, localDustLimit,

return lnwallet.HtlcIsDust(
chantype, incoming, whoseCommit, feerate, amt,
dustLimit,
dustLimit, sigHashDefault,
)
}

Expand Down
2 changes: 1 addition & 1 deletion htlcswitch/mailbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions htlcswitch/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ func (f *mockChannelLink) getDustClosure() dustClosure {
dustLimit := btcutil.Amount(400)
return dustHelper(
channeldb.SingleFunderTweaklessBit, dustLimit, dustLimit,
false,
)
}

Expand Down
12 changes: 0 additions & 12 deletions input/signdescriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package input

import (
"encoding/binary"
"errors"
"fmt"
"io"

Expand All @@ -12,12 +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
// order to gain access to critical data needed to generate a valid signature.
Expand Down Expand Up @@ -289,11 +282,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
Expand Down
4 changes: 3 additions & 1 deletion input/size_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions lnwallet/aux_leaf_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions lnwallet/aux_resolutions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,6 +123,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
Expand Down
Loading
Loading