Skip to content

Commit 1939ae8

Browse files
committed
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
1 parent 159b629 commit 1939ae8

9 files changed

Lines changed: 1244 additions & 92 deletions

File tree

contractcourt/breach_arbitrator.go

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -845,11 +845,12 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend,
845845
// notifyConfirmedJusticeTx checks if any of the spend details match one of our
846846
// justice transactions. If a confirmed justice transaction is detected and we
847847
// haven't already notified about it, we call NotifyBroadcast on the aux sweeper
848-
// to generate asset-level proofs.
848+
// to generate aux-level proofs.
849849
func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend,
850850
justiceTxs *justiceTxVariants,
851851
historicJusticeTxs map[chainhash.Hash]*justiceTxCtx,
852-
notifiedTxs map[chainhash.Hash]bool) {
852+
notifiedTxs map[chainhash.Hash]bool,
853+
breachedOutputs []breachedOutput) {
853854

854855
// Check each spend to see if it's from one of our justice txs.
855856
for _, s := range spends {
@@ -871,22 +872,29 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend,
871872
}
872873

873874
var justiceCtx *justiceTxCtx
875+
var matchSource string
874876
switch {
875877
case matchesJusticeTx(justiceTxs.spendAll):
876878
justiceCtx = justiceTxs.spendAll
879+
matchSource = "spendAll"
877880

878881
case matchesJusticeTx(justiceTxs.spendCommitOuts):
879882
justiceCtx = justiceTxs.spendCommitOuts
883+
matchSource = "spendCommitOuts"
880884

881885
case matchesJusticeTx(justiceTxs.spendHTLCs):
882886
justiceCtx = justiceTxs.spendHTLCs
887+
matchSource = "spendHTLCs"
883888
}
884889

885890
// Also check the individual second-level sweeps.
886891
if justiceCtx == nil {
887-
for _, tx := range justiceTxs.spendSecondLevelHTLCs {
892+
for i, tx := range justiceTxs.spendSecondLevelHTLCs {
888893
if matchesJusticeTx(tx) {
889894
justiceCtx = tx
895+
matchSource = fmt.Sprintf(
896+
"secondLevel[%d]", i,
897+
)
890898

891899
break
892900
}
@@ -899,12 +907,70 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend,
899907
// justiceTxs has been replaced with newer variants.
900908
if justiceCtx == nil {
901909
justiceCtx = historicJusticeTxs[spendingTxHash]
910+
if justiceCtx != nil {
911+
matchSource = "historic"
912+
}
902913
}
903914

904915
// If this is one of our justice txs, notify the aux sweeper.
905916
if justiceCtx != nil {
917+
brarLog.Infof("[NOTIFY-JUSTICE] matched spend "+
918+
"txid=%v to justice tx via %s "+
919+
"(numInputs=%d), notifying aux sweeper",
920+
spendingTxHash, matchSource,
921+
len(justiceCtx.inputs))
922+
for i, inp := range justiceCtx.inputs {
923+
brarLog.Infof("[NOTIFY-JUSTICE] "+
924+
"input[%d]: outpoint=%v "+
925+
"witnessType=%v",
926+
i, inp.OutPoint(),
927+
inp.WitnessType())
928+
}
929+
} else {
930+
brarLog.Infof("[NOTIFY-JUSTICE] spend txid=%v "+
931+
"did NOT match any justice tx",
932+
spendingTxHash)
933+
}
934+
if justiceCtx != nil {
935+
// Build a fresh input list by matching the
936+
// confirmed tx's BTC inputs against the
937+
// current breachedOutputs. This avoids using
938+
// stale pointers from historic variants
939+
// whose data may have been mutated by
940+
// second-level morphing or slice compaction.
941+
spendingTx := s.detail.SpendingTx
942+
boByOutpoint := make(
943+
map[wire.OutPoint]*breachedOutput,
944+
)
945+
for i := range breachedOutputs {
946+
bo := &breachedOutputs[i]
947+
boByOutpoint[bo.outpoint] = bo
948+
}
949+
950+
var freshInputs []input.Input
951+
hasSecondLevel := false
952+
for _, txIn := range spendingTx.TxIn {
953+
op := txIn.PreviousOutPoint
954+
bo, ok := boByOutpoint[op]
955+
if !ok {
956+
continue
957+
}
958+
freshInputs = append(freshInputs, bo)
959+
960+
wt := bo.WitnessType()
961+
//nolint:ll
962+
if wt == input.HtlcSecondLevelRevoke || wt == input.TaprootHtlcSecondLevelRevoke {
963+
hasSecondLevel = true
964+
}
965+
}
966+
967+
brarLog.Infof("[NOTIFY-JUSTICE] built "+
968+
"fresh input list: %d inputs "+
969+
"(hasSecondLevel=%v)",
970+
len(freshInputs), hasSecondLevel)
971+
906972
bumpReq := sweep.BumpRequest{
907-
Inputs: justiceCtx.inputs,
973+
Inputs: freshInputs,
908974
DeliveryAddress: justiceCtx.sweepAddr,
909975
ExtraTxOut: justiceCtx.extraTxOut,
910976
}
@@ -913,14 +979,17 @@ func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend,
913979
b.cfg.AuxSweeper,
914980
func(aux sweep.AuxSweeper) error {
915981
// The tx is already confirmed, so
916-
// skip broadcast and proof verify
917-
// (placeholder witnesses).
982+
// skip broadcast. Proof verification
983+
// must run to ensure valid anchor
984+
// metadata for spending.
985+
h := uint32(s.detail.SpendingHeight)
918986
return aux.NotifyBroadcast(
919987
&bumpReq, s.detail.SpendingTx,
920988
justiceCtx.fee, nil,
921989
sweep.AuxNotifyOpts{
922-
SkipBroadcast: true,
923-
SkipProofVerify: true,
990+
SkipBroadcast: true,
991+
ConfirmHeight: h,
992+
LookupInputProofs: hasSecondLevel, //nolint:ll
924993
},
925994
)
926995
},
@@ -1038,15 +1107,13 @@ justiceTxBroadcast:
10381107
recordJusticeTxVariants(justiceTxs, historicJusticeTxs)
10391108
finalTx := justiceTxs.spendAll
10401109

1041-
brarLog.Debugf("Broadcasting justice tx: %v", lnutils.SpewLogClosure(
1042-
finalTx))
1043-
10441110
// We'll now attempt to broadcast the transaction which finalized the
10451111
// channel's retribution against the cheating counter party.
10461112
label := labels.MakeLabel(labels.LabelTypeJusticeTransaction, nil)
10471113
err = b.cfg.PublishTransaction(finalTx.justiceTx, label)
10481114
if err != nil {
1049-
brarLog.Errorf("Unable to broadcast justice tx: %v", err)
1115+
brarLog.Errorf("Unable to broadcast initial spendAll "+
1116+
"justice tx: %v", err)
10501117
}
10511118

10521119
// Regardless of publication succeeded or not, we now wait for any of
@@ -1092,12 +1159,14 @@ Loop:
10921159
spends, justiceTxs,
10931160
historicJusticeTxs,
10941161
notifiedJusticeTxs,
1162+
breachInfo.breachedOutputs,
10951163
)
10961164

10971165
// Update the breach info with the new spends.
10981166
t, r := updateBreachInfo(
10991167
breachInfo, spends, b.cfg.AuxResolver,
11001168
)
1169+
11011170
totalFunds += t
11021171
revokedFunds += r
11031172

@@ -1175,24 +1244,19 @@ Loop:
11751244
justiceTxs, historicJusticeTxs,
11761245
)
11771246

1178-
// Re-attempt the spendAll variant first, in
1179-
// case the breach info was updated since the
1180-
// initial broadcast. This avoids splitting into
1181-
// small txs that can't pay fees when a combined
1182-
// tx would work.
1247+
// Re-attempt the spendAll variant first.
11831248
if justiceTxs.spendAll != nil {
1249+
sa := justiceTxs.spendAll
11841250
label := labels.MakeLabel(
11851251
labels.LabelTypeJusticeTransaction,
11861252
nil,
11871253
)
11881254
err = b.cfg.PublishTransaction(
1189-
justiceTxs.spendAll.justiceTx,
1190-
label,
1255+
sa.justiceTx, label,
11911256
)
11921257
if err != nil {
1193-
brarLog.Warnf("Unable to "+
1194-
"broadcast updated "+
1195-
"spendAll justice "+
1258+
brarLog.Warnf("Unable to broadcast "+
1259+
"rebuild spendAll justice "+
11961260
"tx: %v", err)
11971261
}
11981262
}

contractcourt/breach_arbitrator_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3039,6 +3039,7 @@ func TestNotifyConfirmedJusticeTx(t *testing.T) {
30393039
brar.notifyConfirmedJusticeTx(
30403040
tc.spends, tc.justiceTxs,
30413041
historicTxs, tc.notifiedTxs,
3042+
nil,
30423043
)
30433044

30443045
// Verify the number of NotifyBroadcast calls.
@@ -3059,9 +3060,12 @@ func TestNotifyConfirmedJusticeTx(t *testing.T) {
30593060
require.Equal(t, tc.expectedSkipFlag,
30603061
call.opts.SkipBroadcast,
30613062
"SkipBroadcast should be true")
3062-
require.Equal(t, tc.expectedSkipFlag,
3063+
// SkipProofVerify is NOT set —
3064+
// proof verification must run to
3065+
// ensure valid anchor metadata.
3066+
require.False(t,
30633067
call.opts.SkipProofVerify,
3064-
"SkipProofVerify should be true")
3068+
"SkipProofVerify should be false")
30653069
}
30663070

30673071
// Verify notifiedTxs map was updated for successful
@@ -3112,7 +3116,7 @@ func TestNotifyConfirmedJusticeTxNoAuxSweeper(t *testing.T) {
31123116
// aux sweeper to notify.
31133117
historicTxs := make(map[chainhash.Hash]*justiceTxCtx)
31143118
brar.notifyConfirmedJusticeTx(
3115-
spends, justiceTxs, historicTxs, notifiedTxs,
3119+
spends, justiceTxs, historicTxs, notifiedTxs, nil,
31163120
)
31173121

31183122
// The tx should still be marked as notified even without an aux

input/signdescriptor.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"github.com/lightningnetwork/lnd/keychain"
1212
)
1313

14-
1514
// SignDescriptor houses the necessary information required to successfully
1615
// sign a given segwit output. This struct is used by the Signer interface in
1716
// order to gain access to critical data needed to generate a valid signature.

lnwallet/aux_resolutions.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,17 @@ const (
2828
// AuxSigDesc stores optional information related to 2nd level HTLCs for aux
2929
// channels.
3030
type AuxSigDesc struct {
31-
// AuxSig is the second-level signature for the HTLC that we are trying
32-
// to resolve. This is only present if this is a resolution request for
33-
// an HTLC on our commitment transaction.
31+
// AuxSig is the second-level signature for the HTLC's primary
32+
// spending path (success for incoming, timeout for outgoing).
3433
AuxSig []byte
3534

35+
// AuxSigAlt is the second-level signature for the HTLC's
36+
// alternate spending path (timeout for incoming, success for
37+
// outgoing). At breach time, the honest party uses the BTC-level
38+
// witness to determine which path was used and selects the
39+
// matching sig.
40+
AuxSigAlt []byte
41+
3642
// SignDetails is the sign details for the second-level HTLC. This may
3743
// be used to generate the second signature needed for broadcast.
3844
SignDetails input.SignDetails

lnwallet/aux_signer.go

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -194,16 +194,37 @@ type BaseAuxJob struct {
194194
HTLC AuxHtlcDescriptor
195195

196196
// Incoming is a boolean that indicates if the HTLC is incoming or
197-
// outgoing.
197+
// outgoing from the LOCAL party's perspective. This is used with
198+
// WhoseCommit to determine the correct HTLC script variant
199+
// (sender vs receiver).
198200
Incoming bool
199201

202+
// IncomingHTLCLookup controls which HTLC aux output list in the
203+
// commitment blob the signer uses to find the aux outputs.
204+
// When true, the signer looks in IncomingHtlcAssets; when false,
205+
// in OutgoingHtlcAssets. This is normally the same as Incoming,
206+
// but differs for revocation self-signing where the Incoming
207+
// flag is flipped for script generation but the aux output lookup
208+
// must still use the original direction.
209+
IncomingHTLCLookup bool
210+
200211
// CommitBlob is the commitment transaction blob that contains the aux
201212
// information for this channel.
202213
CommitBlob fn.Option[tlv.Blob]
203214

204215
// HtlcLeaf is the aux tap leaf that corresponds to the HTLC being
205216
// signed/verified.
206217
HtlcLeaf input.AuxTapLeaf
218+
219+
// WhoseCommit indicates which party's commitment transaction the
220+
// second-level HTLC belongs to.
221+
WhoseCommit lntypes.ChannelParty
222+
223+
// HtlcTimeout, if set, overrides the timeout logic in
224+
// generateHtlcSignature and verifyHtlcSignature. When nil,
225+
// the timeout is derived from Incoming (the normal CommitSig
226+
// convention). When set, it is used directly.
227+
HtlcTimeout fn.Option[uint32]
207228
}
208229

209230
// AuxSigJob is a struct that contains all the information needed to sign an
@@ -224,20 +245,24 @@ type AuxSigJob struct {
224245
Cancel <-chan struct{}
225246
}
226247

227-
// NewAuxSigJob creates a new AuxSigJob.
248+
// NewAuxSigJob creates a new AuxSigJob. The whoseCommit parameter indicates
249+
// which party's commitment the HTLC belongs to.
228250
func NewAuxSigJob(sigJob SignJob, keyRing CommitmentKeyRing, incoming bool,
229251
htlc AuxHtlcDescriptor, commitBlob fn.Option[tlv.Blob],
230-
htlcLeaf input.AuxTapLeaf, cancelChan <-chan struct{}) AuxSigJob {
252+
htlcLeaf input.AuxTapLeaf, whoseCommit lntypes.ChannelParty,
253+
cancelChan <-chan struct{}) AuxSigJob {
231254

232255
return AuxSigJob{
233256
SignDesc: sigJob.SignDesc,
234257
BaseAuxJob: BaseAuxJob{
235-
OutputIndex: sigJob.OutputIndex,
236-
KeyRing: keyRing,
237-
HTLC: htlc,
238-
Incoming: incoming,
239-
CommitBlob: commitBlob,
240-
HtlcLeaf: htlcLeaf,
258+
OutputIndex: sigJob.OutputIndex,
259+
KeyRing: keyRing,
260+
HTLC: htlc,
261+
Incoming: incoming,
262+
IncomingHTLCLookup: incoming,
263+
CommitBlob: commitBlob,
264+
HtlcLeaf: htlcLeaf,
265+
WhoseCommit: whoseCommit,
241266
},
242267
Resp: make(chan AuxSigJobResp, 1),
243268
Cancel: cancelChan,
@@ -353,11 +378,12 @@ func ResolveHtlcSigHashType(chanType channeldb.ChannelType,
353378
return sigHash.UnwrapOr(HtlcSigHashType(chanType))
354379
}
355380

356-
// IsSigHashDefault returns true if the resolved HTLC sighash type for the
357-
// given channel is SigHashDefault. This is used to determine whether
358-
// second-level HTLC transactions must carry their own fee (since the sweeper
359-
// cannot add wallet inputs under SigHashDefault).
360-
func IsSigHashDefault(chanType channeldb.ChannelType,
381+
// IsDeterministicHTLCs returns true if the DeterministicHTLCs feature is
382+
// active for the given channel. When true, second-level HTLC transactions
383+
// use SigHashDefault (making them fully deterministic), must carry their
384+
// own fees, and the revoking party includes dual-path AuxSigs in
385+
// RevokeAndAck for breach proof reconstruction.
386+
func IsDeterministicHTLCs(chanType channeldb.ChannelType,
361387
auxSigner fn.Option[AuxSigner],
362388
req HtlcSigHashReq) bool {
363389

0 commit comments

Comments
 (0)