diff --git a/contractcourt/breach_arbitrator.go b/contractcourt/breach_arbitrator.go index d11b725edf..f335dd70dd 100644 --- a/contractcourt/breach_arbitrator.go +++ b/contractcourt/breach_arbitrator.go @@ -180,6 +180,11 @@ type BreachConfig struct { // AuxSweeper is an optional interface that can be used to modify the // way sweep transaction are generated. AuxSweeper fn.Option[sweep.AuxSweeper] + + // AuxResolver is an optional interface for resolving auxiliary + // contract data. Used to generate resolution blobs for second-level + // HTLC revocations at morph time. + AuxResolver fn.Option[lnwallet.AuxContractResolver] } // BreachArbitrator is a special subsystem which is responsible for watching and @@ -540,13 +545,77 @@ func (b *BreachArbitrator) waitForSpendEvent(breachInfo *retributionInfo, } } +// findSecondLevelOutputIndex searches the spending (second-level) transaction's +// outputs for the one that matches the expected second-level HTLC output +// script. This is necessary because SpenderInputIndex (which identifies the +// input that spent our output) does NOT necessarily correspond to the output +// index — the second-level tx may contain additional inputs (e.g. wallet UTXOs +// for fees) that shift the input indices. +func findSecondLevelOutputIndex(bo *breachedOutput, + spendingTx *wire.MsgTx) (uint32, error) { + + isTaproot := txscript.IsPayToTaproot(bo.signDesc.Output.PkScript) + + // For taproot outputs, we can derive the expected pkScript from the + // revocation key and the second-level tap tweak. + if isTaproot && bo.signDesc.DoubleTweak != nil { + // Derive the revocation public key from the revocation base + // point and the commitment secret (DoubleTweak). + commitPoint := bo.signDesc.DoubleTweak.PubKey() + revokeKey := input.DeriveRevocationPubkey( + bo.signDesc.KeyDesc.PubKey, commitPoint, + ) + + // Compute the expected taproot output key using the revocation + // key as internal key and the second-level tap tweak. + expectedOutputKey := txscript.ComputeTaprootOutputKey( + revokeKey, bo.secondLevelTapTweak[:], + ) + expectedPkScript, err := input.PayToTaprootScript( + expectedOutputKey, + ) + if err != nil { + return 0, fmt.Errorf("unable to compute expected "+ + "pkScript: %w", err) + } + + // Search the spending tx outputs for the matching pkScript. + for i, txOut := range spendingTx.TxOut { + if bytes.Equal(txOut.PkScript, expectedPkScript) { + return uint32(i), nil + } + } + + // Log details to help diagnose why no match was found. + brarLog.Warnf("Expected second-level pkScript %x "+ + "(revokeKey=%x, tapTweak=%x) not found among "+ + "%d outputs of tx %s", + expectedPkScript, + revokeKey.SerializeCompressed(), + bo.secondLevelTapTweak[:], + len(spendingTx.TxOut), spendingTx.TxHash()) + for i, txOut := range spendingTx.TxOut { + brarLog.Warnf(" output %d: pkScript=%x value=%d", + i, txOut.PkScript, txOut.Value) + } + + return 0, fmt.Errorf("no output matching expected "+ + "second-level taproot pkScript found in tx %s", + spendingTx.TxHash()) + } + + return 0, fmt.Errorf("cannot derive expected pkScript: " + + "non-taproot or missing DoubleTweak") +} + // convertToSecondLevelRevoke takes a breached output, and a transaction that // spends it to the second level, and mutates the breach output into one that // is able to properly sweep that second level output. We'll use this function // when we go to sweep a breached commitment transaction, but the cheating // party has already attempted to take it to the second level. func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, - spendDetails *chainntnfs.SpendDetail) { + spendDetails *chainntnfs.SpendDetail, + auxResolver fn.Option[lnwallet.AuxContractResolver]) { // In this case, we'll modify the witness type of this output to // actually prepare for a second level revoke. @@ -557,24 +626,56 @@ func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, bo.witnessType = input.HtlcSecondLevelRevoke } - // We'll also redirect the outpoint to this second level output, so the - // spending transaction updates it inputs accordingly. + // Find the correct output index in the spending (second-level) tx. + // We cannot simply use SpenderInputIndex as the output index because + // the spending tx may have additional inputs (e.g., wallet UTXOs for + // fees in batched second-level txs) that cause input indices to + // diverge from output indices. spendingTx := spendDetails.SpendingTx - spendInputIndex := spendDetails.SpenderInputIndex + outputIndex, err := findSecondLevelOutputIndex(bo, spendingTx) + if err != nil { + // For taproot channels, the script match must succeed — + // the confirmed second-level tx necessarily contains our + // expected output script. A failure here indicates a bug + // in the derivation chain (wrong tap tweak, wrong + // revocation key, aux leaf mismatch). + if txscript.IsPayToTaproot( + bo.signDesc.Output.PkScript, + ) { + + brarLog.Errorf("BUG: cannot locate "+ + "second-level taproot output by "+ + "script match for %v: %v", + bo.outpoint, err) + + return + } + + // For non-taproot (SigHashSingle|AnyoneCanPay), + // input index always equals output index in + // 1-input-1-output second-level txs. + outputIndex = spendDetails.SpenderInputIndex + + brarLog.Warnf("Could not find matching second-"+ + "level output by script, falling back "+ + "to SpenderInputIndex=%d: %v", + outputIndex, err) + } + oldOp := bo.outpoint bo.outpoint = wire.OutPoint{ Hash: spendingTx.TxHash(), - Index: spendInputIndex, + Index: outputIndex, } // Next, we need to update the amount so we can do fee estimation // properly, and also so we can generate a valid signature as we need // to know the new input value (the second level transactions shaves // off some funds to fees). - newAmt := spendingTx.TxOut[spendInputIndex].Value + newAmt := spendingTx.TxOut[outputIndex].Value bo.amt = btcutil.Amount(newAmt) bo.signDesc.Output.Value = newAmt - bo.signDesc.Output.PkScript = spendingTx.TxOut[spendInputIndex].PkScript + bo.signDesc.Output.PkScript = spendingTx.TxOut[outputIndex].PkScript // For taproot outputs, the taptweak also needs to be swapped out. We // do this unconditionally as this field isn't used at all for segwit @@ -585,6 +686,56 @@ func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, // SignDescriptor. bo.signDesc.WitnessScript = bo.secondLevelWitnessScript + // Re-resolve the aux contract blob for the second-level witness + // type. The second-level tx is now known, so we pass it as the + // CommitTx in the resolution request. + if bo.resolveReq != nil && bo.resolveReq.KeyRing != nil { + secondLevelReq := *bo.resolveReq + secondLevelReq.CommitTx = bo.resolveReq.CommitTx.Copy() + secondLevelReq.Type = input.TaprootHtlcSecondLevelRevoke + secondLevelReq.SecondLevelTx = spendingTx + secondLevelReq.SecondLevelTxBlockHeight = uint32( + spendDetails.SpendingHeight, + ) + + brarLog.Infof("Re-resolving HTLC blob for second-level "+ + "revoke, htlcID=%v, htlcAmt=%v, "+ + "keyRing=%v, revokeKey=%v", + secondLevelReq.HtlcID, + secondLevelReq.HtlcAmt, + secondLevelReq.KeyRing != nil, + secondLevelReq.KeyRing != nil && + secondLevelReq.KeyRing.RevocationKey != nil) + + resolveBlob := fn.MapOptionZ( + auxResolver, + func(a lnwallet.AuxContractResolver, + ) fn.Result[tlv.Blob] { + + return a.ResolveContract( + secondLevelReq, + ) + }, + ) + if err := resolveBlob.Err(); err != nil { + brarLog.Errorf("Unable to re-resolve "+ + "second-level HTLC blob for %v: "+ + "%v — output will be skipped", + bo.outpoint, err) + + return + } + + bo.resolutionBlob = resolveBlob.OkToSome() + } else if bo.resolveReq == nil { + brarLog.Warnf("No resolve request template available " + + "for second-level HTLC, skipping blob update") + } + + // Update confHeight to the second-level tx's confirmation height + // so the sweeper computes correct CSV locktime for the justice tx. + bo.confHeight = uint32(spendDetails.SpendingHeight) + brarLog.Warnf("HTLC(%v) for ChannelPoint(%v) has been spent to the "+ "second-level, adjusting -> %v", oldOp, breachInfo.chanPoint, bo.outpoint) @@ -593,7 +744,8 @@ func convertToSecondLevelRevoke(bo *breachedOutput, breachInfo *retributionInfo, // updateBreachInfo mutates the passed breachInfo by removing or converting any // outputs among the spends. It also counts the total and revoked funds swept // by our justice spends. -func updateBreachInfo(breachInfo *retributionInfo, spends []spend) ( +func updateBreachInfo(breachInfo *retributionInfo, spends []spend, + auxResolver fn.Option[lnwallet.AuxContractResolver]) ( btcutil.Amount, btcutil.Amount) { inputs := breachInfo.breachedOutputs @@ -642,6 +794,7 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend) ( // process. convertToSecondLevelRevoke( breachedOutput, breachInfo, s.detail, + auxResolver, ) continue @@ -689,6 +842,119 @@ func updateBreachInfo(breachInfo *retributionInfo, spends []spend) ( return totalFunds, revokedFunds } +// notifyConfirmedJusticeTx checks if any of the spend details match one of our +// justice transactions. If a confirmed justice transaction is detected and we +// haven't already notified about it, we call NotifyBroadcast on the aux sweeper +// to generate asset-level proofs. +func (b *BreachArbitrator) notifyConfirmedJusticeTx(spends []spend, + justiceTxs *justiceTxVariants, + historicJusticeTxs map[chainhash.Hash]*justiceTxCtx, + notifiedTxs map[chainhash.Hash]bool) { + + // Check each spend to see if it's from one of our justice txs. + for _, s := range spends { + spendingTxHash := *s.detail.SpenderTxHash + + // Skip if we've already notified about this transaction. + if notifiedTxs[spendingTxHash] { + continue + } + + // Helper to check if a justice tx matches the spending tx. + matchesJusticeTx := func(jtx *justiceTxCtx) bool { + if jtx == nil { + return false + } + hash := jtx.justiceTx.TxHash() + + return spendingTxHash.IsEqual(&hash) + } + + var justiceCtx *justiceTxCtx + switch { + case matchesJusticeTx(justiceTxs.spendAll): + justiceCtx = justiceTxs.spendAll + + case matchesJusticeTx(justiceTxs.spendCommitOuts): + justiceCtx = justiceTxs.spendCommitOuts + + case matchesJusticeTx(justiceTxs.spendHTLCs): + justiceCtx = justiceTxs.spendHTLCs + } + + // Also check the individual second-level sweeps. + if justiceCtx == nil { + for _, tx := range justiceTxs.spendSecondLevelHTLCs { + if matchesJusticeTx(tx) { + justiceCtx = tx + + 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 this is one of our justice txs, notify the aux sweeper. + if justiceCtx != nil { + bumpReq := sweep.BumpRequest{ + Inputs: justiceCtx.inputs, + DeliveryAddress: justiceCtx.sweepAddr, + ExtraTxOut: justiceCtx.extraTxOut, + } + + err := fn.MapOptionZ( + b.cfg.AuxSweeper, + func(aux sweep.AuxSweeper) error { + // The transaction is already confirmed, + // so we pass skipBroadcast=true. + return aux.NotifyBroadcast( + &bumpReq, s.detail.SpendingTx, + justiceCtx.fee, nil, true, + ) + }, + ) + if err != nil { + brarLog.Errorf("Failed to notify aux sweeper "+ + "of confirmed justice tx %v: %v", + spendingTxHash, err) + } else { + // Mark this transaction as notified to avoid + // duplicate calls. + notifiedTxs[spendingTxHash] = true + } + } + } +} + +// recordJusticeTxVariants records all non-nil justice tx variants into the +// historic map keyed by their txid. This allows notifyConfirmedJusticeTx to +// match confirmed spends against justice txs from previous rebuild cycles. +func recordJusticeTxVariants(variants *justiceTxVariants, + history map[chainhash.Hash]*justiceTxCtx) { + + record := func(jtx *justiceTxCtx) { + if jtx == nil { + return + } + hash := jtx.justiceTx.TxHash() + history[hash] = jtx + } + + record(variants.spendAll) + record(variants.spendCommitOuts) + record(variants.spendHTLCs) + for _, tx := range variants.spendSecondLevelHTLCs { + record(tx) + } +} + // exactRetribution is a goroutine which is executed once a contract breach has // been detected by a breachObserver. This function is responsible for // punishing a counterparty for violating the channel contract by sweeping ALL @@ -725,6 +991,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,29 +1030,12 @@ 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) @@ -805,8 +1080,19 @@ 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, + ) + // 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 +1154,44 @@ Loop: "height %v), splitting justice tx.", epoch.Height, breachInfo.breachHeight) + // Rebuild justice tx variants from the current + // breach info, which may have been updated by + // spend detection (e.g. second-level HTLC spends + // replacing commit-level outputs). + justiceTxs, err = b.createJusticeTx( + breachInfo.breachedOutputs, + ) + if err != nil { + brarLog.Errorf("Unable to recreate "+ + "justice tx for split: %v", err) + continue Loop + } + recordJusticeTxVariants( + justiceTxs, historicJusticeTxs, + ) + + // Re-attempt the spendAll variant first, in + // case the breach info was updated since the + // initial broadcast. This avoids splitting into + // small txs that can't pay fees when a combined + // tx would work. + if justiceTxs.spendAll != nil { + label := labels.MakeLabel( + labels.LabelTypeJusticeTransaction, + nil, + ) + err = b.cfg.PublishTransaction( + justiceTxs.spendAll.justiceTx, + label, + ) + if err != nil { + brarLog.Warnf("Unable to "+ + "broadcast updated "+ + "spendAll justice "+ + "tx: %v", err) + } + } + // Otherwise we'll attempt to publish the two separate // justice transactions that sweeps the commitment // outputs and the HTLC outputs separately. This is to @@ -1098,6 +1422,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] @@ -1371,9 +1701,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) } @@ -1451,6 +1782,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 @@ -1461,35 +1796,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 } @@ -1623,19 +1975,37 @@ func (b *BreachArbitrator) sweepSpendableOutputsTxn(txWeight lntypes.WeightUnit, // First, we'll add the extra sweep output if it exists, subtracting the // amount from the sweep amt. + var hasAuxOut bool if b.cfg.AuxSweeper.IsSome() { extraChangeOut.WhenOk(func(o sweep.SweepOutput) { sweepAmt -= o.Value txn.AddTxOut(&o.TxOut) + hasAuxOut = true }) } - // Next, we'll add the output to which our funds will be deposited. - txn.AddTxOut(&wire.TxOut{ - PkScript: pkScript.DeliveryAddress, - Value: sweepAmt, - }) + // If the sweep amount is positive, add the regular sweep output as + // usual. If it's non-positive but we have an aux output, we skip + // the BTC sweep output entirely — for custom (asset) channels the + // real value is carried by the aux output and the remaining BTC + // can all go towards fees. + switch { + case sweepAmt > 0: + txn.AddTxOut(&wire.TxOut{ + PkScript: pkScript.DeliveryAddress, + Value: sweepAmt, + }) + + case hasAuxOut: + brarLog.Infof("Dropping BTC sweep output (amount=%d), "+ + "all input BTC (%v) goes to fee + aux output", + sweepAmt, totalAmt) + + default: + return nil, fmt.Errorf("sweep amount is non-positive "+ + "(%d) and no aux output exists", sweepAmt) + } // TODO(roasbeef): add other output change modify sweep amt diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go index 40dad40f48..af3b01fd45 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/stretchr/testify/require" ) @@ -1583,6 +1585,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") @@ -1796,6 +1799,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") @@ -1894,12 +1898,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{}) @@ -2442,3 +2453,667 @@ func createHTLC(data int, amount lnwire.MilliSatoshi) (*lnwire.UpdateAddHTLC, [3 Expiry: uint32(5), }, returnPreimage } + +// TestFindSecondLevelOutputIndex verifies that findSecondLevelOutputIndex +// correctly identifies the HTLC output in second-level transactions, including +// batched txs with multiple inputs and a wallet change output where input +// indices diverge from output indices. +func TestFindSecondLevelOutputIndex(t *testing.T) { + t.Parallel() + + // Generate a random key pair for the revocation base point. + revokeBasePriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + revokeBasePub := revokeBasePriv.PubKey() + + // Generate a commitment secret (used as DoubleTweak). + commitSecret, err := btcec.NewPrivateKey() + require.NoError(t, err) + commitPoint := commitSecret.PubKey() + + // Derive the revocation public key. + revokeKey := input.DeriveRevocationPubkey( + revokeBasePub, commitPoint, + ) + + // Create a second-level tap tweak (random 32 bytes). + var tapTweak [32]byte + _, err = crand.Read(tapTweak[:]) + require.NoError(t, err) + + // Compute the expected taproot output key and pkScript. + expectedOutputKey := txscript.ComputeTaprootOutputKey( + revokeKey, tapTweak[:], + ) + expectedPkScript, err := input.PayToTaprootScript( + expectedOutputKey, + ) + require.NoError(t, err) + + // Create a random unrelated pkScript for wallet change outputs. + unrelatedKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + unrelatedPkScript, err := input.PayToTaprootScript( + unrelatedKey.PubKey(), + ) + require.NoError(t, err) + + // Build the breachedOutput with taproot signDesc. + bo := &breachedOutput{ + signDesc: input.SignDescriptor{ + KeyDesc: keychain.KeyDescriptor{ + PubKey: revokeBasePub, + }, + DoubleTweak: commitSecret, + Output: &wire.TxOut{ + PkScript: expectedPkScript, + }, + }, + secondLevelTapTweak: tapTweak, + } + + tests := []struct { + name string + tx *wire.MsgTx + expectedIndex uint32 + expectErr bool + }{ + { + // Simple 1-input-1-output second-level tx. + name: "single output matches", + tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{ + { + PkScript: expectedPkScript, + Value: 50_000, + }, + }, + }, + expectedIndex: 0, + }, + { + // Batched tx: wallet UTXO input adds a + // change output before the HTLC output. + name: "batched tx - HTLC at index 1", + tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, // HTLC input + {}, // wallet UTXO + }, + TxOut: []*wire.TxOut{ + { + PkScript: unrelatedPkScript, + Value: 100_000, + }, + { + PkScript: expectedPkScript, + Value: 50_000, + }, + }, + }, + expectedIndex: 1, + }, + { + // Batched tx: HTLC output first, change + // output second. + name: "batched tx - HTLC at index 0", + tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, // wallet UTXO + {}, // HTLC input + }, + TxOut: []*wire.TxOut{ + { + PkScript: expectedPkScript, + Value: 50_000, + }, + { + PkScript: unrelatedPkScript, + Value: 100_000, + }, + }, + }, + expectedIndex: 0, + }, + { + // Batched tx with 3 outputs: HTLC in the + // middle. + name: "batched tx - HTLC at index 1 of 3", + tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, {}, {}, + }, + TxOut: []*wire.TxOut{ + { + PkScript: unrelatedPkScript, + Value: 100_000, + }, + { + PkScript: expectedPkScript, + Value: 50_000, + }, + { + PkScript: unrelatedPkScript, + Value: 200_000, + }, + }, + }, + expectedIndex: 1, + }, + { + // No matching output — should error. + name: "no match", + tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{ + { + PkScript: unrelatedPkScript, + Value: 50_000, + }, + }, + }, + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + idx, err := findSecondLevelOutputIndex( + bo, tc.tx, + ) + if tc.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedIndex, idx) + }) + } +} + +// TestSweepSpendableOutputsAuxOutput tests the three-way output construction +// logic in sweepSpendableOutputsTxn: positive sweep amount, aux-only output +// (BTC sweep dropped), and error when sweep is non-positive without aux. +func TestSweepSpendableOutputsAuxOutput(t *testing.T) { + t.Parallel() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pkScript, err := input.PayToTaprootScript(privKey.PubKey()) + require.NoError(t, err) + + // Build a P2WKH pkScript for the input (WitnessKeyHash needs + // a valid P2WKH output to sign). + p2wkhAddr := btcutil.Hash160( + privKey.PubKey().SerializeCompressed(), + ) + builder := txscript.NewScriptBuilder() + builder.AddOp(txscript.OP_0) + builder.AddData(p2wkhAddr) + p2wkhScript, err := builder.Script() + require.NoError(t, err) + + // Create a breached output with enough value to cover fees. + makeOutput := func(value int64) breachedOutput { + return breachedOutput{ + amt: btcutil.Amount(value), + outpoint: wire.OutPoint{ + Hash: chainhash.Hash{0x01}, + Index: 0, + }, + witnessType: input.WitnessKeyHash, + signDesc: input.SignDescriptor{ + KeyDesc: keychain.KeyDescriptor{ + PubKey: privKey.PubKey(), + }, + Output: &wire.TxOut{ + PkScript: p2wkhScript, + Value: value, + }, + }, + } + } + + sweepAddr := lnwallet.AddrWithKey{ + DeliveryAddress: pkScript, + } + + signer := input.NewMockSigner( + []*btcec.PrivateKey{privKey}, + &chaincfg.RegressionNetParams, + ) + + // Common config for the BreachArbitrator. + makeBrar := func( + auxSweeper fn.Option[sweep.AuxSweeper], + ) *BreachArbitrator { + + genScript := func() fn.Result[lnwallet.AddrWithKey] { + return fn.Ok(sweepAddr) + } + + return &BreachArbitrator{ + cfg: &BreachConfig{ + Signer: signer, + GenSweepScript: genScript, + Estimator: chainfee.NewStaticEstimator( + chainfee.FeePerKwFloor, 0, + ), + AuxSweeper: auxSweeper, + }, + } + } + + t.Run("positive sweep no aux", func(t *testing.T) { + brar := makeBrar(fn.None[sweep.AuxSweeper]()) + bo := makeOutput(1_000_000) + + result, err := brar.sweepSpendableOutputsTxn( + 200, &bo, + ) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have exactly 1 output (the BTC sweep). + require.Len(t, result.justiceTx.TxOut, 1) + }) + + t.Run("positive sweep with aux", func(t *testing.T) { + sweeper := &mockAuxSweeperWithOutput{ + outputValue: 10_000, + pkScript: pkScript, + } + brar := makeBrar(fn.Some[sweep.AuxSweeper](sweeper)) + bo := makeOutput(1_000_000) + + result, err := brar.sweepSpendableOutputsTxn( + 200, &bo, + ) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have 2 outputs: aux + BTC sweep. + require.Len(t, result.justiceTx.TxOut, 2) + }) + + t.Run("non-positive sweep with aux", func(t *testing.T) { + // Value so low that after fee the sweep amount is + // non-positive, but aux output saves it. + sweeper := &mockAuxSweeperWithOutput{ + outputValue: 100, + pkScript: pkScript, + } + brar := makeBrar(fn.Some[sweep.AuxSweeper](sweeper)) + + // Use a very high weight so fee exceeds input value. + bo := makeOutput(500) + + result, err := brar.sweepSpendableOutputsTxn( + 100_000, &bo, + ) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have only 1 output (aux only, BTC sweep + // dropped). + require.Len(t, result.justiceTx.TxOut, 1) + }) + + t.Run("non-positive sweep no aux errors", func(t *testing.T) { + brar := makeBrar(fn.None[sweep.AuxSweeper]()) + bo := makeOutput(500) + + _, err := brar.sweepSpendableOutputsTxn( + 100_000, &bo, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "non-positive") + }) +} + +// mockAuxSweeperWithOutput is a mock AuxSweeper that returns a configurable +// sweep output from DeriveSweepAddr. +type mockAuxSweeperWithOutput struct { + outputValue int64 + pkScript []byte +} + +func (m *mockAuxSweeperWithOutput) DeriveSweepAddr( + _ []input.Input, + _ lnwallet.AddrWithKey) fn.Result[sweep.SweepOutput] { + + return fn.Ok(sweep.SweepOutput{ + TxOut: wire.TxOut{ + PkScript: m.pkScript, + Value: m.outputValue, + }, + }) +} + +func (m *mockAuxSweeperWithOutput) ExtraBudgetForInputs( + _ []input.Input) fn.Result[btcutil.Amount] { + + return fn.Ok(btcutil.Amount(0)) +} + +func (m *mockAuxSweeperWithOutput) NotifyBroadcast( + _ *sweep.BumpRequest, _ *wire.MsgTx, + _ btcutil.Amount, _ map[wire.OutPoint]int, + _ bool) error { + + return nil +} + +// mockAuxSweeperNotify is a mock implementation of sweep.AuxSweeper that tracks +// calls to NotifyBroadcast for testing purposes. +type mockAuxSweeperNotify struct { + notifyCalls []notifyCall + notifyErr error +} + +// notifyCall records the parameters of a NotifyBroadcast call. +type notifyCall struct { + req *sweep.BumpRequest + tx *wire.MsgTx + fee btcutil.Amount + skipBroadcast bool +} + +// DeriveSweepAddr implements sweep.AuxSweeper. +func (m *mockAuxSweeperNotify) DeriveSweepAddr(_ []input.Input, + _ lnwallet.AddrWithKey) fn.Result[sweep.SweepOutput] { + + return fn.Ok(sweep.SweepOutput{}) +} + +// ExtraBudgetForInputs implements sweep.AuxSweeper. +func (m *mockAuxSweeperNotify) ExtraBudgetForInputs( + _ []input.Input) fn.Result[btcutil.Amount] { + + return fn.Ok(btcutil.Amount(0)) +} + +// NotifyBroadcast implements sweep.AuxSweeper and records the call. +func (m *mockAuxSweeperNotify) NotifyBroadcast(req *sweep.BumpRequest, + tx *wire.MsgTx, fee btcutil.Amount, + _ map[wire.OutPoint]int, skipBroadcast bool) error { + + m.notifyCalls = append(m.notifyCalls, notifyCall{ + req: req, + tx: tx, + fee: fee, + skipBroadcast: skipBroadcast, + }) + + return m.notifyErr +} + +// TestNotifyConfirmedJusticeTx tests that notifyConfirmedJusticeTx correctly +// identifies confirmed justice transactions and notifies the aux sweeper. +func TestNotifyConfirmedJusticeTx(t *testing.T) { + t.Parallel() + + // Create test transactions for each justice tx variant. + spendAllTx := &wire.MsgTx{Version: 1} + spendCommitOutsTx := &wire.MsgTx{Version: 2} + spendHTLCsTx := &wire.MsgTx{Version: 3} + unrelatedTx := &wire.MsgTx{Version: 4} + + spendAllHash := spendAllTx.TxHash() + spendCommitOutsHash := spendCommitOutsTx.TxHash() + spendHTLCsHash := spendHTLCsTx.TxHash() + unrelatedHash := unrelatedTx.TxHash() + + // Create justice tx contexts. + spendAllCtx := &justiceTxCtx{ + justiceTx: spendAllTx, + fee: btcutil.Amount(1000), + } + spendCommitOutsCtx := &justiceTxCtx{ + justiceTx: spendCommitOutsTx, + fee: btcutil.Amount(2000), + } + spendHTLCsCtx := &justiceTxCtx{ + justiceTx: spendHTLCsTx, + fee: btcutil.Amount(3000), + } + + tests := []struct { + name string + spends []spend + justiceTxs *justiceTxVariants + notifiedTxs map[chainhash.Hash]bool + expectedCalls int + expectedFees []btcutil.Amount + expectedSkipFlag bool + }{ + { + name: "spendAll variant detected", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 1, + expectedFees: []btcutil.Amount{1000}, + expectedSkipFlag: true, + }, + { + name: "spendCommitOuts variant detected", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendCommitOutsHash, + SpendingTx: spendCommitOutsTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendCommitOuts: spendCommitOutsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 1, + expectedFees: []btcutil.Amount{2000}, + expectedSkipFlag: true, + }, + { + name: "spendHTLCs variant detected", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendHTLCsHash, + SpendingTx: spendHTLCsTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendHTLCs: spendHTLCsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 1, + expectedFees: []btcutil.Amount{3000}, + expectedSkipFlag: true, + }, + { + name: "skip already notified transaction", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + }, + notifiedTxs: map[chainhash.Hash]bool{ + spendAllHash: true, + }, + expectedCalls: 0, + }, + { + name: "no match - unrelated transaction", + spends: []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &unrelatedHash, + SpendingTx: unrelatedTx, + SpendingHeight: 100, + }, + }}, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + spendCommitOuts: spendCommitOutsCtx, + spendHTLCs: spendHTLCsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 0, + }, + { + name: "multiple spends - only matching ones notified", + spends: []spend{ + { + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }, + { + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &unrelatedHash, + SpendingTx: unrelatedTx, + SpendingHeight: 100, + }, + }, + { + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendHTLCsHash, + SpendingTx: spendHTLCsTx, + SpendingHeight: 100, + }, + }, + }, + justiceTxs: &justiceTxVariants{ + spendAll: spendAllCtx, + spendHTLCs: spendHTLCsCtx, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 2, + expectedFees: []btcutil.Amount{1000, 3000}, + expectedSkipFlag: true, + }, + { + name: "nil justice txs - no panic", + spends: []spend{}, + justiceTxs: &justiceTxVariants{ + spendAll: nil, + spendCommitOuts: nil, + spendHTLCs: nil, + }, + notifiedTxs: make(map[chainhash.Hash]bool), + expectedCalls: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create mock aux sweeper. + mockSweeper := &mockAuxSweeperNotify{} + + // Create a minimal BreachArbitrator with the mock. + brar := &BreachArbitrator{ + cfg: &BreachConfig{ + AuxSweeper: fn.Some[sweep.AuxSweeper]( + mockSweeper, + ), + }, + } + + // Call the function under test. + historicTxs := make(map[chainhash.Hash]*justiceTxCtx) + brar.notifyConfirmedJusticeTx( + tc.spends, tc.justiceTxs, + historicTxs, tc.notifiedTxs, + ) + + // Verify the number of NotifyBroadcast calls. + require.Len(t, mockSweeper.notifyCalls, + tc.expectedCalls, "unexpected number of "+ + "NotifyBroadcast calls") + + // Verify the fees if we expected calls. + for i, call := range mockSweeper.notifyCalls { + if i < len(tc.expectedFees) { + require.Equal(t, tc.expectedFees[i], + call.fee, + "unexpected fee for call %d", i) + } + + // Verify skipBroadcast is always true for + // confirmed justice txs. + require.Equal(t, tc.expectedSkipFlag, + call.skipBroadcast, + "skipBroadcast should be true") + } + + // Verify notifiedTxs map was updated for successful + // notifications. + for _, call := range mockSweeper.notifyCalls { + txHash := call.tx.TxHash() + require.True(t, tc.notifiedTxs[txHash], + "tx %v should be marked as notified", + txHash) + } + }) + } +} + +// TestNotifyConfirmedJusticeTxNoAuxSweeper verifies that the function handles +// the case where no aux sweeper is configured. +func TestNotifyConfirmedJusticeTxNoAuxSweeper(t *testing.T) { + t.Parallel() + + spendAllTx := &wire.MsgTx{Version: 1} + spendAllHash := spendAllTx.TxHash() + + spends := []spend{{ + detail: &chainntnfs.SpendDetail{ + SpenderTxHash: &spendAllHash, + SpendingTx: spendAllTx, + SpendingHeight: 100, + }, + }} + + justiceTxs := &justiceTxVariants{ + spendAll: &justiceTxCtx{ + justiceTx: spendAllTx, + fee: btcutil.Amount(1000), + }, + } + + // Create BreachArbitrator with no aux sweeper. + brar := &BreachArbitrator{ + cfg: &BreachConfig{ + AuxSweeper: fn.None[sweep.AuxSweeper](), + }, + } + + notifiedTxs := make(map[chainhash.Hash]bool) + + // Should not panic and should not mark as notified since there's no + // aux sweeper to notify. + historicTxs := make(map[chainhash.Hash]*justiceTxCtx) + brar.notifyConfirmedJusticeTx( + spends, justiceTxs, historicTxs, notifiedTxs, + ) + + // The tx should still be marked as notified even without an aux + // sweeper, to avoid repeated processing. + require.True(t, notifiedTxs[spendAllHash], + "tx should be marked as notified") +} diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go index 287c871cd2..f162d57c24 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 c802fc41a5..a5ef9cf154 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] @@ -1072,6 +1076,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 { @@ -1415,7 +1420,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 @@ -1522,6 +1527,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 a4d27ba4e8..8b6f84c261 100644 --- a/contractcourt/htlc_success_resolver.go +++ b/contractcourt/htlc_success_resolver.go @@ -402,6 +402,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( @@ -759,6 +792,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 6beafc3990..7ae4edeb10 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" @@ -946,6 +947,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 @@ -1291,6 +1325,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 1db005bf82..aed57a78a1 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -2184,7 +2184,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 @@ -2352,7 +2353,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 { @@ -2366,7 +2367,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 57a581c4b1..e03b5bf103 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 70bd73c37d..0959cffd09 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 a01c939ae7..abfbf725fa 100644 --- a/input/signdescriptor.go +++ b/input/signdescriptor.go @@ -2,7 +2,6 @@ package input import ( "encoding/binary" - "errors" "fmt" "io" @@ -12,11 +11,6 @@ import ( "github.com/lightningnetwork/lnd/keychain" ) -var ( - // ErrTweakOverdose signals a SignDescriptor is invalid because both of its - // SingleTweak and DoubleTweak are non-nil. - ErrTweakOverdose = errors.New("sign descriptor should only have one tweak") -) // SignDescriptor houses the necessary information required to successfully // sign a given segwit output. This struct is used by the Signer interface in @@ -289,11 +283,6 @@ func ReadSignDescriptor(r io.Reader, sd *SignDescriptor) error { sd.DoubleTweak, _ = btcec.PrivKeyFromBytes(doubleTweakBytes) } - // Only one tweak should ever be set, fail if both are present. - if sd.SingleTweak != nil && sd.DoubleTweak != nil { - return ErrTweakOverdose - } - witnessScript, err := wire.ReadVarBytes(r, 0, 500, "witnessScript") if err != nil { return err diff --git a/input/size_test.go b/input/size_test.go index 2fba2c7b2e..1f31a2ce05 100644 --- a/input/size_test.go +++ b/input/size_test.go @@ -1490,7 +1490,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 0a85050378..113e743f8a 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 14802c57c7..2ae762afd0 100644 --- a/lnwallet/aux_resolutions.go +++ b/lnwallet/aux_resolutions.go @@ -117,6 +117,16 @@ type ResolutionReq struct { // AuxSigDesc is an optional field that contains additional information // needed to sweep second level HTLCs. AuxSigDesc fn.Option[AuxSigDesc] + + // SecondLevelTx is the second-level HTLC transaction, set only when + // resolving a TaprootHtlcSecondLevelRevoke. This allows the resolver + // to re-anchor proofs to the second-level tx rather than the + // commitment tx. + SecondLevelTx *wire.MsgTx + + // SecondLevelTxBlockHeight is the block height where the second-level + // HTLC transaction was confirmed. + SecondLevelTxBlockHeight uint32 } // AuxContractResolver is an interface that is used to resolve contracts that diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 79a7ca1dc0..e0560ee397 100644 --- a/lnwallet/aux_signer.go +++ b/lnwallet/aux_signer.go @@ -1,7 +1,9 @@ package lnwallet import ( + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" @@ -305,4 +307,61 @@ 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)) +} + +// IsSigHashDefault returns true if the resolved HTLC sighash type for the +// given channel is SigHashDefault. This is used to determine whether +// second-level HTLC transactions must carry their own fee (since the sweeper +// cannot add wallet inputs under SigHashDefault). +func IsSigHashDefault(chanType channeldb.ChannelType, + auxSigner fn.Option[AuxSigner], + req HtlcSigHashReq) bool { + + return ResolveHtlcSigHashType( + chanType, auxSigner, req, + ) == txscript.SigHashDefault } diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 02f5f9ccff..1f1a8abeab 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -309,7 +309,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 @@ -325,6 +325,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 @@ -477,6 +478,28 @@ func (c *commitment) toDiskCommit( return commit } +// IsChanSigHashDefault returns whether HTLC second-level transactions for +// this channel use SigHashDefault. +func (lc *LightningChannel) IsChanSigHashDefault() bool { + chanID := lnwire.NewChanIDFromOutPoint( + lc.channelState.FundingOutpoint, + ) + + return IsSigHashDefault( + lc.channelState.ChanType, + lc.auxSigner, + HtlcSigHashReq{ + ChanID: fn.Some(chanID), + CommitBlob: lc.channelState.LocalCommitment.CustomBlob, + }, + ) +} + +// isSigHashDefault is an internal alias for IsChanSigHashDefault. +func (lc *LightningChannel) isSigHashDefault() bool { + return lc.IsChanSigHashDefault() +} + // diskHtlcToPayDesc converts an HTLC previously written to disk within a // commitment state to the form required to manipulate in memory within the // commitment struct and updateLog. This function is used when we need to @@ -505,6 +528,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 { @@ -522,6 +546,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 { @@ -980,7 +1005,19 @@ func NewLightningChannel(signer input.Signer, commitChains: commitChains, channelState: state, commitBuilder: NewCommitmentBuilder( - state, opts.leafStore, + state, opts.leafStore, IsSigHashDefault( + state.ChanType, opts.auxSigner, + HtlcSigHashReq{ + ChanID: fn.Some( + lnwire.NewChanIDFromOutPoint( + state.FundingOutpoint, + ), + ), + CommitBlob: state. + LocalCommitment. + CustomBlob, + }, + ), ), updateLogs: updateLogs, Capacity: state.Capacity, @@ -1139,6 +1176,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( @@ -1986,6 +2024,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 @@ -2075,7 +2119,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 @@ -2121,7 +2166,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 { @@ -2171,6 +2219,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 @@ -2185,7 +2234,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 @@ -2375,8 +2425,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 @@ -2479,6 +2531,84 @@ 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), + } + 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{ @@ -2488,6 +2618,8 @@ func createHtlcRetribution(chanState *channeldb.OpenChannel, SecondLevelWitnessScript: secondLevelWitnessScript, IsIncoming: htlc.Incoming.Val, SecondLevelTapTweak: secondLevelTapTweak, + ResolutionBlob: resolutionBlob, + ResolveReq: savedResolveReq, }, nil } @@ -2501,9 +2633,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 @@ -2512,7 +2644,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 @@ -2617,8 +2750,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{ @@ -2651,6 +2786,14 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, chainfee.SatPerKWeight(revokedLog.FeePerKw), htlc.Amt.ToSatoshis(), chanState.RemoteChanCfg.DustLimit, + IsSigHashDefault( + chanState.ChanType, + auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState. + LocalCommitment.CustomBlob, + }, + ), ) { continue @@ -2665,6 +2808,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 @@ -2695,6 +2839,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 @@ -2706,25 +2851,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 @@ -2956,7 +3101,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 } @@ -3314,7 +3460,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 ( @@ -3327,7 +3474,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 @@ -3357,10 +3512,14 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, // For each outgoing and incoming HTLC, if the HTLC isn't considered a // dust output after taking into account second-level HTLC fees, then a // sigJob will be generated and appended to the current batch. + sigHashDefault := IsSigHashDefault( + chanType, auxSigner, sigHashReq, + ) for _, htlc := range remoteCommitView.incomingHTLCs { if HtlcIsDust( chanType, true, lntypes.Remote, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + sigHashDefault, ) { continue @@ -3377,7 +3536,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( @@ -3444,6 +3603,7 @@ func genRemoteHtlcSigJobs(keyRing *CommitmentKeyRing, if HtlcIsDust( chanType, false, lntypes.Remote, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + sigHashDefault, ) { continue @@ -3458,7 +3618,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( @@ -4190,7 +4350,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 @@ -4871,6 +5031,7 @@ func (lc *LightningChannel) computeView(view *HtlcView, if HtlcIsDust( lc.channelState.ChanType, false, whoseCommitChain, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + lc.isSigHashDefault(), ) { continue @@ -4882,6 +5043,7 @@ func (lc *LightningChannel) computeView(view *HtlcView, if HtlcIsDust( lc.channelState.ChanType, true, whoseCommitChain, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + lc.isSigHashDefault(), ) { continue @@ -4927,7 +5089,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 := IsSigHashDefault( + 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 @@ -4999,7 +5172,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( @@ -5092,7 +5265,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( @@ -6195,6 +6368,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 @@ -6214,7 +6388,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 @@ -6979,7 +7153,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 @@ -7022,7 +7197,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", @@ -7319,6 +7494,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{ @@ -7437,7 +7613,12 @@ func newOutgoingHtlcResolution(signer input.Signer, // In order to properly reconstruct the HTLC transaction, we'll need to // re-calculate the fee required at this state, so we can add the // correct output value amount to the transaction. - htlcFee := HtlcTimeoutFee(chanType, feePerKw) + htlcFee := HtlcTimeoutFee(chanType, feePerKw, IsSigHashDefault( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + )) secondLevelOutputAmt := htlc.Amt.ToSatoshis() - htlcFee // With the fee calculated, re-construct the second level timeout @@ -7483,7 +7664,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 @@ -7687,6 +7872,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{ @@ -7810,7 +7996,12 @@ func newIncomingHtlcResolution(signer input.Signer, // // First, we'll reconstruct the original HTLC success transaction, // taking into account the fee rate used. - htlcFee := HtlcSuccessFee(chanType, feePerKw) + htlcFee := HtlcSuccessFee(chanType, feePerKw, IsSigHashDefault( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState.LocalCommitment.CustomBlob, + }, + )) secondLevelOutputAmt := htlc.Amt.ToSatoshis() - htlcFee successTx, err := CreateHtlcSuccessTx( chanType, isCommitFromInitiator, op, secondLevelOutputAmt, @@ -7849,7 +8040,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 @@ -8065,7 +8260,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 @@ -8086,6 +8282,13 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, if HtlcIsDust( chanType, htlc.Incoming, whoseCommit, feePerKw, htlc.Amt.ToSatoshis(), dustLimit, + IsSigHashDefault( + chanType, auxSigner, + HtlcSigHashReq{ + CommitBlob: chanState. + LocalCommitment.CustomBlob, + }, + ), ) { continue @@ -8101,6 +8304,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 "+ @@ -8115,7 +8319,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 "+ @@ -8262,7 +8466,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 "+ @@ -8281,8 +8485,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 @@ -8451,7 +8655,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) @@ -9209,7 +9413,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 @@ -9219,7 +9424,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 6e175ba739..995c7557d8 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) @@ -5572,11 +5574,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. @@ -5743,11 +5746,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. @@ -6275,6 +6279,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") @@ -6421,6 +6426,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") @@ -6440,6 +6446,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") @@ -7093,6 +7100,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 @@ -7272,6 +7280,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") @@ -9908,6 +9917,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) @@ -10114,6 +10124,7 @@ func TestCreateBreachRetribution(t *testing.T) { aliceChannel.channelState, keyRing, dummyPrivate, leaseExpiry, fn.None[CommitAuxLeaves](), + fn.None[AuxContractResolver](), 0, ) // Check the error if expected. @@ -10172,6 +10183,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) @@ -10234,6 +10246,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) @@ -10243,6 +10256,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) @@ -10290,6 +10304,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) @@ -10303,6 +10318,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) @@ -10313,6 +10329,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) @@ -10322,6 +10339,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) } @@ -10617,7 +10635,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( @@ -10751,7 +10771,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, ) @@ -10835,7 +10857,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, ) @@ -11757,3 +11781,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 ab20d9afaa..82aa9edca1 100644 --- a/lnwallet/commitment.go +++ b/lnwallet/commitment.go @@ -363,8 +363,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 @@ -486,12 +489,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(): @@ -506,11 +528,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(): @@ -625,11 +659,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() { @@ -637,9 +676,10 @@ func NewCommitmentBuilder(chanState *channeldb.OpenChannel, } return &CommitmentBuilder{ - chanState: chanState, - obfuscator: createStateHintObfuscator(chanState), - auxLeafStore: leafStore, + chanState: chanState, + obfuscator: createStateHintObfuscator(chanState), + auxLeafStore: leafStore, + sigHashDefault: sigHashDefault, } } @@ -706,6 +746,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, false, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -717,6 +758,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, true, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -831,6 +873,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance, if HtlcIsDust( cb.chanState.ChanType, false, whoseCommit, feePerKw, htlc.Amount.ToSatoshis(), dustLimit, + cb.sigHashDefault, ) { continue @@ -859,6 +902,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 39e520d276..c2c0afe890 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/server.go b/server.go index 0e7fe48966..0cc4460558 100644 --- a/server.go +++ b/server.go @@ -1270,7 +1270,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, }, ) @@ -1736,6 +1737,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 e0d5d75161..f73a2b0d8a 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -738,8 +738,10 @@ func (t *TxPublisher) broadcast(record *monitorRecord) (*BumpResult, error) { // Before we go to broadcast, we'll notify the aux sweeper, if it's // present of this new broadcast attempt. err := fn.MapOptionZ(t.cfg.AuxSweeper, func(aux AuxSweeper) error { + const skipBroadcast = false return aux.NotifyBroadcast( record.req, tx, record.fee, record.outpointToTxIndex, + skipBroadcast, ) }) if err != nil { diff --git a/sweep/interface.go b/sweep/interface.go index 6c8c2cfad2..669db5f2a4 100644 --- a/sweep/interface.go +++ b/sweep/interface.go @@ -92,7 +92,11 @@ type AuxSweeper interface { // NotifyBroadcast is used to notify external callers of the broadcast // of a sweep transaction, generated by the passed BumpRequest. + // The skipBroadcast parameter indicates whether the transaction is + // already confirmed on-chain (true for breach sweeps) or needs to be + // broadcast (false for normal sweeps). NotifyBroadcast(req *BumpRequest, tx *wire.MsgTx, totalFees btcutil.Amount, - outpointToTxIndex map[wire.OutPoint]int) error + outpointToTxIndex map[wire.OutPoint]int, + skipBroadcast bool) error } diff --git a/sweep/mock_test.go b/sweep/mock_test.go index e6e254e8e1..55d9a4fa31 100644 --- a/sweep/mock_test.go +++ b/sweep/mock_test.go @@ -359,7 +359,7 @@ func (m *MockAuxSweeper) ExtraBudgetForInputs( // NotifyBroadcast is used to notify external callers of the broadcast // of a sweep transaction, generated by the passed BumpRequest. func (*MockAuxSweeper) NotifyBroadcast(_ *BumpRequest, _ *wire.MsgTx, - _ btcutil.Amount, _ map[wire.OutPoint]int) error { + _ btcutil.Amount, _ map[wire.OutPoint]int, _ bool) error { return nil }