diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index 2c12f25598b..dc2278202df 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -179,6 +179,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 @@ -539,13 +544,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. @@ -556,24 +625,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 @@ -584,6 +685,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) @@ -592,7 +743,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 @@ -641,6 +793,7 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend) ( // process. convertToSecondLevelRevoke( breachedOutput, breachInfo, s.detail, + auxResolver, ) continue @@ -689,6 +842,218 @@ 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 aux-level proofs. +func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, + justiceTxs *justiceTxVariants, + historicJusticeTxs map[chainhash.Hash]*justiceTxCtx, + 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 { + 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 + 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 i, tx := range justiceTxs.spendSecondLevelHTLCs { + if matchesJusticeTx(tx) { + justiceCtx = tx + matchSource = fmt.Sprintf( + "secondLevel[%d]", i, + ) + + break + } + } + } + + // 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 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: freshInputs, + DeliveryAddress: justiceCtx.sweepAddr, + ExtraTxOut: justiceCtx.extraTxOut, + } + + err := fn.MapOptionZ( + b.cfg.AuxSweeper, + func(aux sweep.AuxSweeper) error { + // The tx is already confirmed, so + // 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, + ConfirmHeight: h, + LookupInputProofs: hasSecondLevel, //nolint:ll + }, + ) + }, + ) + 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 + } + } + } +} + +// 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 + } + + 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) + 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 @@ -725,6 +1090,32 @@ 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) + + // 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 @@ -738,35 +1129,16 @@ 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( - 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) 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 @@ -805,8 +1177,21 @@ 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, + historicJusticeTxs, + notifiedJusticeTxs, + breachInfo.breachedOutputs, + ) + // 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 @@ -868,6 +1253,39 @@ 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. + if justiceTxs.spendAll != nil { + sa := justiceTxs.spendAll + label := labels.MakeLabel( + labels.LabelTypeJusticeTransaction, + nil, + ) + err = b.cfg.PublishTransaction( + sa.justiceTx, label, + ) + if err != nil { + brarLog.Warnf("Unable to broadcast "+ + "rebuild 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 @@ -1098,6 +1516,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] @@ -1384,9 +1808,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) } @@ -1464,6 +1889,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 @@ -1474,35 +1903,52 @@ 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 + 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 } @@ -1517,6 +1963,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 } @@ -1636,19 +2088,39 @@ 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 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 > dustLimit: + 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 @@ -1712,7 +2184,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 869a0093e01..36d838ae8a6 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" @@ -32,6 +33,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" ) @@ -1584,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") @@ -1797,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") @@ -1895,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{}) @@ -2615,7 +2626,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. @@ -2623,3 +2634,674 @@ func TestUpdateBreachInfoCountsFinalTaprootRevokedFunds(t *testing.T) { require.Equal(t, revokedAmt, revoked) 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, + _ sweep.AuxNotifyOpts) error { + + return nil +} + +// 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 + opts sweep.AuxNotifyOpts +} + +// 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, opts sweep.AuxNotifyOpts) error { + + m.notifyCalls = append(m.notifyCalls, notifyCall{ + req: req, + tx: tx, + fee: fee, + opts: opts, + }) + + 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. + historicTxs := make(map[chainhash.Hash]*justiceTxCtx) + brar.notifyConfirmedJusticeTx( + tc.spends, tc.justiceTxs, + historicTxs, tc.notifiedTxs, + nil, + ) + + // 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 skip flags are set for + // confirmed justice txs. + require.Equal(t, tc.expectedSkipFlag, + call.opts.SkipBroadcast, + "SkipBroadcast should be true") + // SkipProofVerify is NOT set — + // proof verification must run to + // ensure valid anchor metadata. + require.False(t, + call.opts.SkipProofVerify, + "SkipProofVerify should be false") + } + + // 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. + historicTxs := make(map[chainhash.Hash]*justiceTxCtx) + brar.notifyConfirmedJusticeTx( + spends, justiceTxs, historicTxs, notifiedTxs, nil, + ) + + // 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") +} 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..cad6ba85013 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] @@ -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 { @@ -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 @@ -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 diff --git a/contractcourt/htlc_success_resolver.go b/contractcourt/htlc_success_resolver.go index 1770c214a45..411971acc50 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( @@ -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() diff --git a/contractcourt/htlc_timeout_resolver.go b/contractcourt/htlc_timeout_resolver.go index c2cbb133beb..d3580f4afb4 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 @@ -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() 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/input/signdescriptor.go b/input/signdescriptor.go index a01c939ae74..425ba964588 100644 --- a/input/signdescriptor.go +++ b/input/signdescriptor.go @@ -2,7 +2,6 @@ package input import ( "encoding/binary" - "errors" "fmt" "io" @@ -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. @@ -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 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_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/aux_resolutions.go b/lnwallet/aux_resolutions.go index 14802c57c7b..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 @@ -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 diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 79a7ca1dc09..7be0fd891f5 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" @@ -192,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] @@ -202,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 @@ -222,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, @@ -305,4 +332,62 @@ 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)) +} + +// 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 { + + return ResolveHtlcSigHashType( + chanType, auxSigner, req, + ) == txscript.SigHashDefault } diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 78ca895655a..6281144f817 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 IsDeterministicHTLCs( + 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, IsDeterministicHTLCs( + 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( @@ -2007,6 +2045,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 @@ -2101,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 @@ -2147,7 +2192,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 { @@ -2197,6 +2245,7 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, br, ourAmt, theirAmt, err = createBreachRetribution( revokedLog, spendTx, chanState, keyRing, commitmentSecret, leaseExpiry, auxResult.AuxLeaves, + auxResolver, breachHeight, ) if err != nil { return nil, err @@ -2211,7 +2260,8 @@ 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, + auxSigner, breachHeight, ) if err != nil { return nil, err @@ -2401,8 +2451,10 @@ 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, + breachHeight uint32) (HtlcRetribution, error) { var emptyRetribution HtlcRetribution @@ -2505,6 +2557,143 @@ 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] + var savedResolveReq *ResolutionReq + if isTaproot { + htlcIDOpt := fn.MapOption( + func(v tlv.BigSizeT[uint64]) input.HtlcIndex { + return v.Int() + }, + )(htlc.HtlcIndex.ValOpt()) + + cs := chanState + resolveReq := ResolutionReq{ + 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, + HtlcAmt: htlc.Amt.Val.Int(), + PayHash: fn.Some( + [32]byte(htlc.RHash.Val), + ), + HtlcID: htlcIDOpt, + CltvDelay: fn.Some(htlc.RefundTimeout.Val), + } + + // Populate the AuxSigDesc if the HTLC has custom + // 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. + ParseCustomRecords(r.Val) + if err != nil { + return + } + + // 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 sign desc for + // the second-level tx proof. + sd := input.SignDescriptor{ + KeyDesc: chanState. + LocalChanCfg. + HtlcBasePoint, + SingleTweak: keyRing. + LocalHtlcKeyTweak, + SignMethod: input.TaprootScriptSpendSignMethod, //nolint:ll + } + + details := input.SignDetails{ + SignDesc: sd, + } + resolveReq.AuxSigDesc = fn.Some( + AuxSigDesc{ + AuxSig: auxSig, + AuxSigAlt: auxSigAlt, + SignDetails: details, + }, + ) + } + }, + ) + 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() + + // 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{ SignDesc: signDesc, OutPoint: wire.OutPoint{ @@ -2514,6 +2703,8 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, SecondLevelWitnessScript: secondLevelWitnessScript, IsIncoming: htlc.Incoming.Val, SecondLevelTapTweak: secondLevelTapTweak, + ResolutionBlob: resolutionBlob, + ResolveReq: savedResolveReq, }, nil } @@ -2527,9 +2718,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], + breachHeight uint32) (*BreachRetribution, int64, int64, error) { commitHash := revokedLog.CommitTxHash @@ -2538,7 +2729,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, breachHeight, ) if err != nil { return nil, 0, 0, err @@ -2644,8 +2836,10 @@ 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], + auxSigner fn.Option[AuxSigner], + breachHeight uint32) (*BreachRetribution, int64, int64, error) { commitHash := revokedLog.CommitTx.TxHash() ourOutpoint := wire.OutPoint{ @@ -2678,6 +2872,14 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chainfee.SatPerKWeight(revokedLog.FeePerKw), htlc.Amt.ToSatoshis(), chanState.RemoteChanCfg.DustLimit, + IsDeterministicHTLCs( + chanState.ChanType, + auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState. + LocalCommitment.CustomBlob, + }, + ), ) { continue @@ -2692,6 +2894,7 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chanState, keyRing, commitHash, commitmentSecret, leaseExpiry, entry, fn.None[CommitAuxLeaves](), + revokedLog.CommitTx, auxResolver, nil, breachHeight, ) if err != nil { return nil, 0, 0, err @@ -2723,6 +2926,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 @@ -2734,25 +2938,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 @@ -2984,7 +3188,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 } @@ -3342,7 +3547,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 ( @@ -3355,7 +3561,15 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, txHash := remoteCommitView.txn.TxHash() dustLimit := remoteChanCfg.DustLimit feePerKw := remoteCommitView.feePerKw - sigHashType := HtlcSigHashType(chanType) + sigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: chanState.LocalCommitment.CustomBlob, + } + sigHashType := ResolveHtlcSigHashType( + chanType, auxSigner, sigHashReq, + ) // 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 @@ -3385,10 +3599,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 := IsDeterministicHTLCs( + chanType, auxSigner, sigHashReq, + ) for _, htlc := range remoteCommitView.incomingHTLCs { if HtlcIsDust( chanType, true, lntypes.Remote, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + sigHashDefault, ) { continue @@ -3405,7 +3623,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( @@ -3465,13 +3683,15 @@ 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 { if HtlcIsDust( chanType, false, lntypes.Remote, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + sigHashDefault, ) { continue @@ -3486,7 +3706,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( @@ -3547,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 @@ -4218,7 +5304,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 @@ -4928,6 +6014,7 @@ func (lc *LightningChannel) computeView(view *HtlcView, if HtlcIsDust( lc.channelState.ChanType, false, whoseCommitChain, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + lc.isSigHashDefault(), ) { continue @@ -4939,6 +6026,7 @@ func (lc *LightningChannel) computeView(view *HtlcView, if HtlcIsDust( lc.channelState.ChanType, true, whoseCommitChain, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + lc.isSigHashDefault(), ) { continue @@ -4984,7 +6072,18 @@ func genHtlcSigValidationJobs(chanState *channeldb.OpenChannel, txHash := localCommitmentView.txn.TxHash() feePerKw := localCommitmentView.feePerKw - sigHashType := HtlcSigHashType(chanType) + sigHashReq := HtlcSigHashReq{ + ChanID: fn.Some(lnwire.NewChanIDFromOutPoint( + chanState.FundingOutpoint, + )), + CommitBlob: chanState.LocalCommitment.CustomBlob, + } + sigHashType := ResolveHtlcSigHashType( + chanType, auxSigner, sigHashReq, + ) + sigHashDefault := IsDeterministicHTLCs( + chanType, auxSigner, sigHashReq, + ) // With the required state generated, we'll create a slice with large // enough capacity to hold verification jobs for all HTLC's in this @@ -5056,7 +6155,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( @@ -5149,7 +6248,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( @@ -5798,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++ @@ -6076,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 @@ -6294,6 +7460,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 @@ -6313,7 +7480,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 @@ -7078,7 +8245,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 @@ -7121,7 +8289,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", @@ -7418,6 +8586,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{ @@ -7536,7 +8705,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, IsDeterministicHTLCs( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + )) secondLevelOutputAmt := htlc.Amt.ToSatoshis() - htlcFee // With the fee calculated, re-construct the second level timeout @@ -7582,7 +8756,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 @@ -7792,6 +8970,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{ @@ -7915,7 +9094,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, IsDeterministicHTLCs( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + )) secondLevelOutputAmt := htlc.Amt.ToSatoshis() - htlcFee successTx, err := CreateHtlcSuccessTx( chanType, isCommitFromInitiator, op, secondLevelOutputAmt, @@ -7954,7 +9138,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 @@ -8176,7 +9364,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 @@ -8197,6 +9386,13 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, if HtlcIsDust( chanType, htlc.Incoming, whoseCommit, feePerKw, htlc.Amt.ToSatoshis(), dustLimit, + IsDeterministicHTLCs( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState. + LocalCommitment.CustomBlob, + }, + ), ) { continue @@ -8212,6 +9408,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 "+ @@ -8226,7 +9423,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 "+ @@ -8373,7 +9570,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 "+ @@ -8392,8 +9589,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 @@ -8562,7 +9759,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) @@ -9320,7 +10517,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 @@ -9330,7 +10528,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 ab96d339749..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. @@ -6485,6 +6489,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 +6636,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 +6656,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") @@ -7303,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 @@ -7482,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") @@ -10118,6 +10127,7 @@ func TestCreateHtlcRetribution(t *testing.T) { hr, err := createHtlcRetribution( aliceChannel.channelState, keyRing, commitHash, dummyPrivate, leaseExpiry, htlc, fn.None[CommitAuxLeaves](), + nil, fn.None[AuxContractResolver](), nil, 0, ) // Expect no error. require.NoError(t, err) @@ -10324,6 +10334,7 @@ func TestCreateBreachRetribution(t *testing.T) { aliceChannel.channelState, keyRing, dummyPrivate, leaseExpiry, fn.None[CommitAuxLeaves](), + fn.None[AuxContractResolver](), 0, ) // Check the error if expected. @@ -10382,6 +10393,7 @@ func TestCreateBreachRetributionLegacy(t *testing.T) { br, ourAmt, theirAmt, err := createBreachRetributionLegacy( &revokedLog, aliceChannel.channelState, keyRing, dummyPrivate, ourScript, theirScript, leaseExpiry, + fn.None[AuxContractResolver](), fn.None[AuxSigner](), 0, ) require.NoError(t, err) @@ -10444,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) @@ -10453,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) @@ -10500,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) @@ -10513,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) @@ -10523,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) @@ -10532,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) } @@ -10827,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( @@ -10961,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, ) @@ -11045,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, ) @@ -11967,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 f715b9336cf..761543e76ba 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 @@ -505,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(): @@ -525,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(): @@ -644,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() { @@ -656,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, } } @@ -725,6 +765,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, false, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -736,6 +777,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, true, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -850,6 +892,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, false, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -878,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/lnwallet/mock.go b/lnwallet/mock.go index 39e520d2760..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" @@ -430,7 +431,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{}) } @@ -510,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 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/server.go b/server.go index 45992c464cb..dc694979e81 100644 --- a/server.go +++ b/server.go @@ -1305,7 +1305,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, }, ) @@ -1771,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 diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index e0d5d751616..d1592bfd323 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -740,6 +740,7 @@ func (t *TxPublisher) broadcast(record *monitorRecord) (*BumpResult, error) { err := fn.MapOptionZ(t.cfg.AuxSweeper, func(aux AuxSweeper) error { return aux.NotifyBroadcast( record.req, tx, record.fee, record.outpointToTxIndex, + AuxNotifyOpts{}, ) }) if err != nil { diff --git a/sweep/interface.go b/sweep/interface.go index 6c8c2cfad28..b745f86f49d 100644 --- a/sweep/interface.go +++ b/sweep/interface.go @@ -75,6 +75,39 @@ 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 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 // transaction by adding a set of extra outputs to the sweeping transaction. type AuxSweeper interface { @@ -94,5 +127,6 @@ type AuxSweeper interface { // of a sweep transaction, generated by the passed BumpRequest. NotifyBroadcast(req *BumpRequest, tx *wire.MsgTx, totalFees btcutil.Amount, - outpointToTxIndex map[wire.OutPoint]int) error + outpointToTxIndex map[wire.OutPoint]int, + opts AuxNotifyOpts) error } diff --git a/sweep/mock_test.go b/sweep/mock_test.go index e6e254e8e11..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) error { + _ btcutil.Amount, _ map[wire.OutPoint]int, _ AuxNotifyOpts) error { return nil }