contractcourt: make breach retribution final-taproot aware
What changed, and why it matters
This commit updates LND's breach-arbitrator (the component that punishes a counterparty for broadcasting an old channel state) so it correctly handles a new 'final taproot' channel type. Previously, the arbitrator likely treated final-taproot outputs as ordinary taproot or non-taproot outputs, which could have caused it to use the wrong witness type, skip control-block handling, miscount revoked funds, or fail to sweep funds after a breach. The patch adds the new witness-type constants everywhere the old taproot variants were used and adds tests to confirm the new paths work.
Treat this as a functional correctness fix that has security implications for final-taproot channels. Operators running final-taproot channels should upgrade before any counterparty breach attempt. Review related witness-type switch statements elsewhere in the codebase for similar omissions of the final-taproot variants.
Security signals we found
New channel type (final taproot) not previously handled by breach retribution logic
Wrong witness type could prevent successful justice transaction construction
Missing control-block persistence could break re-spending after restart
Revoked-funds tally omission could under-report or skip final-taproot penalties
CSV maturity mismatch could cause premature or delayed sweep attempts
Evidence from the diff
The change extends contractcourt/breach_arbitrator.go to recognize the final-taproot witness types: TaprootCommitmentRevokeFinal and TaprootRemoteCommitSpendFinal. It adds them to the revoked-funds tally in updateBreachInfo, sets the one-block CSV maturity for the remote commit spend, selects the correct witness type in newRetributionInfo when ChanType.IsTaprootFinal() is true, and includes them in taprootBriefcaseFromRetInfo/applyTaprootRetInfo so control blocks and resolution blobs are persisted and restored. Tests verify witness-type selection, briefcase round-trip, and revoked-funds accounting for final taproot channels.
Changed components
contractcourt/breach_arbitrator.gocontractcourt/breach_arbitrator_test.golnwallet/input witness typeschanneldb taproot channel type flagsInspect captured patch +210 / −7
diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go
index d11b725..6e12086 100644
--- a/contractcourt/breach_arbitrator.go
+++ b/contractcourt/breach_arbitrator.go
@@ -655,6 +655,7 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend) (
// or an offered HTLC output, its amount contributes to the
// value of funds being revoked from the counter party.
case input.CommitmentRevoke, input.TaprootCommitmentRevoke,
+ input.TaprootCommitmentRevokeFinal,
input.HtlcSecondLevelRevoke,
input.TaprootHtlcSecondLevelRevoke,
input.TaprootHtlcOfferedRevoke, input.HtlcOfferedRevoke:
@@ -1196,7 +1197,10 @@ func (bo *breachedOutput) BlocksToMaturity() uint32 {
// confirmed type (or is a taproot channel that always has the CSV 1),
// we must wait one block before claiming it.
switch bo.witnessType {
- case input.CommitmentToRemoteConfirmed, input.TaprootRemoteCommitSpend:
+ case input.CommitmentToRemoteConfirmed,
+ input.TaprootRemoteCommitSpend,
+ input.TaprootRemoteCommitSpendFinal:
+
return 1
}
@@ -1279,6 +1283,11 @@ func newRetributionInfo(chanPoint *wire.OutPoint,
if breachInfo.LocalOutputSignDesc != nil {
var witnessType input.StandardWitnessType
switch {
+ // Check the final channel type before the generic taproot case,
+ // since the pkScript check below is true for both variants.
+ case breachInfo.ChanType.IsTaprootFinal():
+ witnessType = input.TaprootRemoteCommitSpendFinal
+
case isTaproot:
witnessType = input.TaprootRemoteCommitSpend
@@ -1318,9 +1327,14 @@ func newRetributionInfo(chanPoint *wire.OutPoint,
// the funds from the commitment transaction immediately.
if breachInfo.RemoteOutputSignDesc != nil {
var witType input.StandardWitnessType
- if isTaproot {
+ switch {
+ case breachInfo.ChanType.IsTaprootFinal():
+ witType = input.TaprootCommitmentRevokeFinal
+
+ case isTaproot:
witType = input.TaprootCommitmentRevoke
- } else {
+
+ default:
witType = input.CommitmentRevoke
}
@@ -1728,7 +1742,9 @@ func taprootBriefcaseFromRetInfo(retInfo *retributionInfo) *taprootBriefcase {
switch bo.WitnessType() {
// For spending from our commitment output on the remote
// commitment, we'll need to stash the control block.
- case input.TaprootRemoteCommitSpend:
+ case input.TaprootRemoteCommitSpend,
+ input.TaprootRemoteCommitSpendFinal:
+
//nolint:ll
tapCase.CtrlBlocks.Val.CommitSweepCtrlBlock = bo.signDesc.ControlBlock
@@ -1742,7 +1758,9 @@ func taprootBriefcaseFromRetInfo(retInfo *retributionInfo) *taprootBriefcase {
// To spend the revoked output again, we'll store the same
// control block value as above, but in a different place.
- case input.TaprootCommitmentRevoke:
+ case input.TaprootCommitmentRevoke,
+ input.TaprootCommitmentRevokeFinal:
+
//nolint:ll
tapCase.CtrlBlocks.Val.RevokeSweepCtrlBlock = bo.signDesc.ControlBlock
@@ -1787,7 +1805,9 @@ func applyTaprootRetInfo(tapCase *taprootBriefcase,
switch bo.WitnessType() {
// For spending from our commitment output on the remote
// commitment, we'll apply the control block.
- case input.TaprootRemoteCommitSpend:
+ case input.TaprootRemoteCommitSpend,
+ input.TaprootRemoteCommitSpendFinal:
+
//nolint:ll
bo.signDesc.ControlBlock = tapCase.CtrlBlocks.Val.CommitSweepCtrlBlock
@@ -1799,7 +1819,9 @@ func applyTaprootRetInfo(tapCase *taprootBriefcase,
// To spend the revoked output again, we'll apply the same
// control block value as above, but to a different place.
- case input.TaprootCommitmentRevoke:
+ case input.TaprootCommitmentRevoke,
+ input.TaprootCommitmentRevokeFinal:
+
//nolint:ll
bo.signDesc.ControlBlock = tapCase.CtrlBlocks.Val.RevokeSweepCtrlBlock
diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go
index 40dad40..869a009 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/tlv"
"github.com/stretchr/testify/require"
)
@@ -2442,3 +2443,183 @@ func createHTLC(data int, amount lnwire.MilliSatoshi) (*lnwire.UpdateAddHTLC, [3
Expiry: uint32(5),
}, returnPreimage
}
+
+// testTaprootBreachSignDesc creates a minimal taproot sign descriptor for
+// breach-arbitrator unit tests that only need a taproot output script.
+func testTaprootBreachSignDesc(t *testing.T) *input.SignDescriptor {
+ t.Helper()
+
+ pkScript, err := input.PayToTaprootScript(&input.TaprootNUMSKey)
+ require.NoError(t, err)
+
+ return &input.SignDescriptor{
+ Output: &wire.TxOut{
+ Value: 1000,
+ PkScript: pkScript,
+ },
+ }
+}
+
+// TestNewRetributionInfoTaprootFinalWitnessTypes verifies that final taproot
+// breaches use the final witness enums for the settled commitment outputs and
+// preserve their auxiliary resolution blobs.
+func TestNewRetributionInfoTaprootFinalWitnessTypes(t *testing.T) {
+ t.Parallel()
+
+ // Arrange: Create a final taproot breach with both settled commitment
+ // outputs present and auxiliary blobs attached.
+ settledBlob := tlv.Blob("settled-blob")
+ breachedBlob := tlv.Blob("breached-blob")
+ chanType := channeldb.SimpleTaprootFeatureBit |
+ channeldb.TaprootFinalBit
+
+ breachInfo := &lnwallet.BreachRetribution{
+ LocalOutpoint: wire.OutPoint{Index: 1},
+ RemoteOutpoint: wire.OutPoint{Index: 2},
+ LocalOutputSignDesc: testTaprootBreachSignDesc(t),
+ RemoteOutputSignDesc: testTaprootBreachSignDesc(t),
+ ChanType: chanType,
+ LocalResolutionBlob: fn.Some(settledBlob),
+ RemoteResolutionBlob: fn.Some(breachedBlob),
+ }
+
+ // Act: Convert the wallet retribution into the breach-arbitrator form.
+ retInfo := newRetributionInfo(&wire.OutPoint{}, breachInfo)
+
+ // Assert: The final taproot witness enums, blobs, and CSV maturity are
+ // preserved.
+ require.Len(t, retInfo.breachedOutputs, 2)
+
+ require.Equal(
+ t, input.TaprootRemoteCommitSpendFinal,
+ retInfo.breachedOutputs[0].witnessType,
+ )
+ require.Equal(
+ t, uint32(1), retInfo.breachedOutputs[0].BlocksToMaturity(),
+ )
+ require.Equal(
+ t, input.TaprootCommitmentRevokeFinal,
+ retInfo.breachedOutputs[1].witnessType,
+ )
+ require.Equal(
+ t, settledBlob,
+ retInfo.breachedOutputs[0].resolutionBlob.UnsafeFromSome(),
+ )
+ require.Equal(
+ t, breachedBlob,
+ retInfo.breachedOutputs[1].resolutionBlob.UnsafeFromSome(),
+ )
+}
+
+// TestTaprootBriefcaseRoundTripFinalWitnessTypes verifies that final taproot
+// breach outputs survive taproot briefcase encoding and decoding with their
+// control blocks and auxiliary blobs intact.
+func TestTaprootBriefcaseRoundTripFinalWitnessTypes(t *testing.T) {
+ t.Parallel()
+
+ // Arrange: Build a retribution with final taproot witness enums and the
+ // corresponding control blocks/blobs that must survive persistence.
+ commitCtrlBlock := []byte("commit-ctrl-block")
+ revokeCtrlBlock := []byte("revoke-ctrl-block")
+ settledBlob := tlv.Blob("settled-blob")
+ breachedBlob := tlv.Blob("breached-blob")
+
+ retInfo := &retributionInfo{
+ breachedOutputs: []breachedOutput{
+ {
+ outpoint: wire.OutPoint{Index: 1},
+ witnessType: input.
+ TaprootRemoteCommitSpendFinal,
+ signDesc: input.SignDescriptor{
+ ControlBlock: commitCtrlBlock,
+ },
+ resolutionBlob: fn.Some(settledBlob),
+ },
+ {
+ outpoint: wire.OutPoint{Index: 2},
+ witnessType: input.TaprootCommitmentRevokeFinal,
+ signDesc: input.SignDescriptor{
+ ControlBlock: revokeCtrlBlock,
+ },
+ resolutionBlob: fn.Some(breachedBlob),
+ },
+ },
+ }
+
+ // Act: Persist the taproot briefcase, decode it again, then apply it
+ // back to a fresh retribution shell.
+ tapCase := taprootBriefcaseFromRetInfo(retInfo)
+
+ var b bytes.Buffer
+ require.NoError(t, tapCase.Encode(&b))
+
+ decoded := newTaprootBriefcase()
+ require.NoError(t, decoded.Decode(&b))
+
+ restored := &retributionInfo{
+ breachedOutputs: []breachedOutput{
+ {
+ outpoint: wire.OutPoint{Index: 1},
+ witnessType: input.
+ TaprootRemoteCommitSpendFinal,
+ },
+ {
+ outpoint: wire.OutPoint{Index: 2},
+ witnessType: input.TaprootCommitmentRevokeFinal,
+ },
+ },
+ }
+
+ // Assert: The final taproot control blocks and blobs round-trip intact.
+ require.NoError(t, applyTaprootRetInfo(decoded, restored))
+ require.Equal(
+ t, commitCtrlBlock,
+ restored.breachedOutputs[0].signDesc.ControlBlock,
+ )
+ require.Equal(
+ t, revokeCtrlBlock,
+ restored.breachedOutputs[1].signDesc.ControlBlock,
+ )
+ require.Equal(
+ t, settledBlob,
+ restored.breachedOutputs[0].resolutionBlob.UnsafeFromSome(),
+ )
+ require.Equal(
+ t, breachedBlob,
+ restored.breachedOutputs[1].resolutionBlob.UnsafeFromSome(),
+ )
+}
+
+// TestUpdateBreachInfoCountsFinalTaprootRevokedFunds verifies that final
+// taproot revoked commitment outputs are included in the revoked-funds tally.
+func TestUpdateBreachInfoCountsFinalTaprootRevokedFunds(t *testing.T) {
+ t.Parallel()
+
+ const revokedAmt = btcutil.Amount(1234)
+
+ // Arrange: Create a breach with a single final taproot revoked output.
+ breachInfo := &retributionInfo{
+ breachedOutputs: []breachedOutput{
+ {
+ amt: revokedAmt,
+ outpoint: wire.OutPoint{Index: 1},
+ witnessType: input.TaprootCommitmentRevokeFinal,
+ },
+ },
+ }
+
+ // Act: Process a spend for that revoked output.
+ total, revoked := updateBreachInfo(breachInfo, []spend{{
+ index: 0,
+ detail: &chainntnfs.SpendDetail{
+ SpendingTx: &wire.MsgTx{TxIn: []*wire.TxIn{{}}},
+ SpenderInputIndex: 0,
+ },
+ }})
+
+ // Assert: The amount contributes to both the total and revoked-funds
+ // tallies and is removed from the remaining breach set.
+ require.Equal(t, revokedAmt, total)
+ require.Equal(t, revokedAmt, revoked)
+ require.Empty(t, breachInfo.breachedOutputs)
+}
Why this scored 59/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.