[3/3] aux revocation: breach-to-confirmation revocation flow - #11112
Open
GeorgeTsagk wants to merge 21 commits into
Open
[3/3] aux revocation: breach-to-confirmation revocation flow#11112GeorgeTsagk wants to merge 21 commits into
GeorgeTsagk wants to merge 21 commits into
Conversation
Introduce a feature-bit negotiation approach for choosing the sighash type of second-level HTLC transactions, via a new AuxSigner.HtlcSigHashType hook. Add a ResolveHtlcSigHashType helper that queries the aux signer for a channel-specific sighash override based on negotiated features, falling back to the default HtlcSigHashType when no aux signer is present or the feature isn't negotiated. Thread the auxSigner through all HTLC second-level transaction signing and validation call sites. An earlier draft of this feature selected the sighash based on the presence of the commitment's custom blob alone, which is backwards incompatible: a peer that has not upgraded would disagree on the sighash and force close the channel. Explicit feature negotiation ensures both channel parties agree on the sighash type before it is ever used.
When DeterministicHTLCs is negotiated (SigHashDefault second-level HTLCs), the peer's signature commits to the entire transaction, so the sweeper can neither add fee inputs nor RBF the pre-signed tx. Instead of baking a large fixed fee into the transaction, give it a minimal baked-in fee plus a CPFP anchor: - The pre-signed second-level HTLC tx gets a second output: a taproot anchor (AnchorSize sats, keyed to the broadcaster's delay key) that the local party can sweep to CPFP-bump the package fee rate. - The baked-in fee is computed at 1.1x the relay floor over the tx weight including the anchor output, just enough to clear min-relay even for nodes computing fee rate over raw serialized size. - HtlcIsDust and the second-level fee/output-amount calculations are threaded with the sigHashDefault flag so dust decisions and the HTLC output value account for both the floor fee and the AnchorSize reduction. - The sweep sign descriptor for the second-level output is reduced by AnchorSize accordingly, so the sweeper signs for the correct value. Only aux/custom (taproot asset) channels can negotiate SigHashDefault (see ResolveHtlcSigHashType); all other channel types keep their existing fee and transaction form.
…ault When a second-level HTLC transaction was signed with SigHashDefault, the peer's signature commits to the entire transaction: the sweeper's usual flow of rebuilding the tx with extra fee inputs or batched outputs would invalidate it. Add isSigHashDefault() and publishTimeoutTx() / publishSuccessTx() to the timeout and success resolvers, which broadcast each pre-signed transaction directly and individually via PublishTx. The second-level output itself is still swept through the sweeper after confirmation, like today. The gate lives in the shared isSecondLevelSigHashDefault helper and requires the channel type to carry a tapscript root: SigHashDefault is the zero value of SigHashType, so without the explicit channel-type check any channel that never populates SignDetails.SigHashType would false-positively match. Only aux/custom (taproot asset) channels carry a tapscript root, so non-custom channels provably keep using the sweeper flow.
After publishSuccessTx / publishTimeoutTx broadcasts the pre-signed second-level HTLC tx, offer the anchor output at index 1 to the sweeper so the local party can CPFP-bump the parent's effective fee rate. The pre-signed tx itself cannot be RBF'd under SigHashDefault (the peer's signature commits to the full tx), so CPFP via this anchor is the only fee-bumping path. The sweeper handles fee estimation and package math; we hand it the anchor outpoint, a key-path sign descriptor, and the parent tx info needed for package fee-rate calculation: the exact baked-in parent fee (spent commitment output value minus the parent's outputs) and the parent weight. The fee budget for the CPFP child is derived from the value under protection (the second-level HTLC output) via the same sweeper.budget.anchorcpfp(ratio) configuration used for commitment anchor CPFP, plus the anchor value itself. The child is funded from wallet inputs, so the budget can and usually must exceed the anchor's own 330 sats. The sweep deadline is the incoming HTLC's expiry on the timeout path (matching the other timeout-path sweeps) and the HTLC's own expiry on the success path. The sweep result is consumed in a tracked goroutine so terminal failures (budget exhausted, deadline blown, persistent estimation failure) are logged rather than silently dropped. The anchor is keyed to the broadcaster's to-local delay key, which is derived directly from the second-level output's sweep sign descriptor (delay base point + single tweak), keeping this path self-contained. A no-op when the parent tx has fewer than two outputs, i.e. the channel did not use DeterministicHTLCs. The publish itself happens asynchronously via publishPreSignedHtlcTx: a pre-signed timeout tx carries an absolute locktime and is only final once the chain reaches that height, so the broadcast waits on block epochs for the locktime to become satisfiable and retries on every new block until it succeeds. This also heals transient mempool rejections; without it, a "non final" rejection at resolver launch would permanently strand the resolver, since Launch() only runs once. The anchor is offered to the sweeper only after the parent broadcast succeeded, and all background goroutines are tracked by a wait group the resolver's Stop drains.
Raise the upper bound on maxFeeRatio in sanityCheckFee from 1.0 to a hard ceiling of 100.0. CPFP sweeps of second-level HTLC anchors (AnchorSize sats) legitimately spend more in fees than the swept output value: the anchor exists purely to pay fees for its parent package, so a fee-to-output ratio above 100% is expected there rather than a bug. Ratios above 1.0 only ever reach this code when an RPC caller explicitly requests one via FundPsbt (every internal funding flow passes DefaultMaxFeeRatio), and such requests are logged so the opt-in is auditable. The ceiling still catches nonsensical values.
The RevokeAndAck message gains a CustomRecords field, encoded and decoded with the standard ParseAndExtractCustomRecords/MergeAndEncode helpers. The field is additive and backward-compatible on the wire: peers that send no records produce byte-identical encodings to before this change, and unknown odd records are tolerated on receive. The following commits use this field to carry revocation AuxSigs for aux/custom (taproot asset) channels; a vanilla lnd channel never populates it.
When an aux/custom (taproot asset) channel with DeterministicHTLCs revokes a commitment, the revoking party now signs second-level HTLC virtual transactions for BOTH spending paths of every non-dust HTLC on the commitment being revoked, and ships the signatures to the peer inside the RevokeAndAck message's custom records. This hands the honest party the asset-level puzzle pieces it needs at breach time: if the cheater later broadcasts the revoked commitment and takes an HTLC to the second level, the breached party holds a valid asset witness for either path the cheater may use. Changes: - Sender: signLocalHtlcAuxSigs derives, for each non-dust HTLC on the revoked commitment, both the primary path (success for incoming, timeout for outgoing) and the alternate path second-level txs, and submits sign jobs to the aux signer. The resulting signatures are packed per HTLC index as a TLV stream (revocationAuxSigType / revocationAuxSigAltType records). One entry is packed per non-dust HTLC even when the signer produces no signature for it (a BTC-only HTLC), in lockstep with the BTC-level signatures. A signing failure fails the revocation as a whole, before any state is advanced: the peer requires the sigs, so a sig-less RevokeAndAck would just fail the channel on their side. - Receiver: ReceiveRevocation verifies every received aux sig against the revoked commitment's key ring BEFORE the revocation is accepted and the tail advances, then injects the sigs into the remote commitment's HTLC custom records so they are persisted in the revocation log for breach-time consumption. Presence is enforced, mirroring the aux sig count check on CommitSig: an entry is required for every non-dust HTLC on the revoked commitment (a peer cannot strip our breach protection by withholding the records), entries for unknown or dust HTLC indexes are rejected, and an entry carrying exactly one spend path is rejected as a withheld signature. - Retransmission: the packed sig blob is persisted in a new optional TLV field on the channel info, written atomically with the commitment advance in UpdateCommitment, and re-attached when the RevokeAndAck is retransmitted on channel reestablish. Only the latest revocation can ever be owed, so a single overwritten slot suffices. - BaseAuxJob gains WhoseCommit, HtlcTimeout and IncomingHTLCLookup fields needed to describe both-path sign jobs to the aux signer. The gate is factored into a single predicate, exchangesRevocationAuxSigs, used by both hooks and (defense in depth) inside the sign and verify functions themselves. It requires an explicit HasTapscriptRoot() on the channel type in addition to the negotiated SigHashDefault, making the custom-channel-only isolation locally auditable at every call site. A vanilla lnd channel never signs, attaches, reads, or verifies these records and its RevokeAndAck is byte-identical to before this change.
Coverage for the revocation AuxSig exchange introduced in the previous commits: - lnwire codec: encode/decode round trips for RevokeAndAck with and without custom records, including a byte-for-byte assertion that the no-records encoding is identical to the legacy wire format. - TLV blob: pack/unpack round trips and corrupt-blob rejection (truncation, garbage). - Custom-channel-only gating: a non-custom taproot channel with an aux signer that pushes SigHashDefault attaches nothing and ignores stray records. - Happy path over a funded tapscript-root channel pair: both-path sigs for every non-dust HTLC, dust excluded, distinct primary/alt blobs matched to the right verify jobs, and the verified sigs persisted into the revocation log entry of the revoked commitment. - BTC-only HTLCs: sig-less lockstep entries are attached and accepted without verification jobs. - Rejection before the remote chain tail advances: unverifiable sigs, malformed blob, missing sig for a non-dust HTLC, withheld blob, extra entry for an unknown HTLC index, entry for a dust HTLC, and a one-sided entry carrying only one spend path. - Sign-time failure: an aux signer error fails RevokeCurrentCommitment before any state advances, for both batch-level and per-job errors. - Retransmission: a retransmitted RevokeAndAck carries the original aux sigs across a simulated restart (persisted blob reloaded from disk).
Pass the breach height through the call chain from NewBreachRetribution to createHtlcRetribution, and include it in the ResolutionReq for HTLC outputs via CommitTxBlockHeight. This is required for taproot-assets to properly reanchor asset proofs when sweeping revoked HTLCs. The reanchorAssetOutputs function needs the block height to fetch the block containing the breach commitment transaction and construct valid proofs for the sweep. Without this, the CommitTxBlockHeight defaults to 0, causing tapd to look for the commitment tx in the genesis block and fail with: "commit tx not found in block". See: lightninglabs/taproot-assets@815021fd (tapchannel: add reanchorAssetOutputs for proof reanchoring)
Second-level HTLC auxiliary leaves need to be computed at runtime from the channel state, key ring, and commitment transaction, rather than being stored in the commitment blob. Extend the FetchLeavesFromRevocation interface method to accept these additional parameters so that implementers can derive the leaves on the fly.
When an HTLC is taken to the second level — whether during a breach or during a regular force close — the aux contract resolver needs to re-resolve the contract with the actual second-level transaction once it confirms. Preserve the original ResolutionReq as a template: - in HtlcRetribution, so the breach arbiter can re-resolve with a different witness type - on IncomingHtlcResolution / OutgoingHtlcResolution, so the htlc success and timeout resolvers can do the same once their pre-signed second-level tx confirms Also add SecondLevelTx and SecondLevelTxBlockHeight fields to ResolutionReq so the resolver can re-anchor proofs to the second-level transaction rather than the commitment transaction. Populate HtlcAmt in the resolution request so the resolver has the HTLC value available. Add a NewSecondLevelResolveReq helper that reconstructs the ResolutionReq at re-resolve time, re-deriving the commitment key ring from RevocationProducer.AtIndex(LocalCommitment.CommitHeight) so the plumbing does not have to round-trip the key ring through the briefcase.
When constructing HtlcRetribution for a breached commitment, extract the remote party's asset-level HTLC signature from the revocation log's CustomBlob and populate AuxSigDesc on the ResolutionReq. This allows the aux subsystem to construct valid asset witnesses for the commitment → second-level proof transition, making breach-recovered assets fully spendable.
Previously the breach arbiter called NotifyBroadcast on the aux sweeper when each justice tx was first broadcast. That fires too early: the aux sweeper imports proofs on broadcast, but a re-org or pinned-by-low-fee HTLC scenario can prevent that broadcast from ever confirming, leaving the aux sweeper with stale proofs for txids that never made it on-chain. Move the NotifyBroadcast call into a new notifyConfirmedJusticeTx helper that runs from the spend-detection loop and only fires once a confirmed spending tx matches one of our justice tx variants (current or historic across rebuild cycles). The same dedup state is now batch-local to the helper since within a single batch waitForSpendEvent can produce multiple spend entries for the same SpenderTxHash; across loop iterations each outpoint fires exactly once. The notification flags are carried by a new AuxNotifyOpts struct on the AuxSweeper.NotifyBroadcast interface with independent SkipBroadcast and SkipProofVerify fields, decoupling broadcast control (transport) from proof verification skip (security) so callers can set each on its own. This also lets us drop the long-lived notifiedJusticeTxs map from exactRetribution.
Add unit tests for the notifyConfirmedJusticeTx function to verify correct behavior when detecting confirmed justice transactions and notifying the aux sweeper. Test cases cover: - Detection of each justice tx variant (spendAll, spendCommitOuts, spendHTLCs) - Skipping already-notified transactions via notifiedTxs map - No notification for unrelated transactions - Multiple spends with mixed matching/non-matching - Graceful handling of nil justice tx contexts - Operation without an aux sweeper configured
Support revoking second-level HTLC outputs in custom (asset) channels: - Add findSecondLevelOutputIndex to locate second-level outputs by script match rather than relying on SpenderInputIndex, which can diverge from the output index when the spending tx has additional inputs (e.g., wallet UTXOs for fees). - Re-resolve contract blobs at morph time via AuxContractResolver when an HTLC is taken to the second level, using the preserved ResolutionReq template with an updated witness type and the actual second-level spending transaction. - Handle non-positive BTC sweep amounts when an aux output carries the real value, allowing all input BTC to go toward fees. - Wire AuxResolver into BreachConfig from server.go.
The breach arbiter rebuilds justice tx variants after each spend detection (e.g. when an HTLC transitions to second-level). The tx that ultimately confirms may have been created in an earlier rebuild cycle and is no longer present in the current justiceTxVariants struct. Add a historicJusticeTxs map that records every justice tx variant ever created (keyed by txid) via recordJusticeTxVariants(). The notifyConfirmedJusticeTx function now falls back to this map when the confirmed spend doesn't match any current variant, ensuring the aux sweeper receives NotifyBroadcast for asset proof generation. Also improve the split-broadcast path: rebuild justice tx variants from the updated breach info before splitting, and re-attempt the spendAll variant first (which may now succeed after second-level spends have been incorporated). Add logging to createJusticeTx for input counts and variant creation.
When the justice tx has an aux (asset) output, the remaining BTC change after fees can be below the dust limit but still positive. Check against the dust limit instead of zero to prevent btcd from rejecting the transaction as non-standard.
Wire the dual-path revocation AuxSigs (delivered via RevokeAndAck and persisted in the revocation log) into breach handling: - createHtlcRetribution extracts both the primary and alternate revocation aux sigs from the breached HTLC's custom-records blob (auxSigDescFromHTLCBlob), preferring the revocation-path sigs over the CommitSig-time fallback, and threads them via AuxSigDesc (which gains an AuxSigAlt field) so the aux resolver holds a valid asset witness for whichever second-level path the cheater used. - notifyConfirmedJusticeTx rebuilds the confirmed justice tx's input set against the CURRENT breachedOutputs by outpoint before notifying the aux sweeper. Historic justiceTxCtx snapshots can hold pointers to slots that were later mutated by second-level morphing or slice compaction; matching by outpoint avoids acting on stale data. The justiceTxCtx records whether it spends any second-level outputs (hasSecondLevel) so the aux notification can request refreshed input proofs. - AuxNotifyOpts gains ConfirmHeight and LookupInputProofs so the aux sweeper receives the confirmation height for porter height hints and knows to look up refreshed second-level input proofs instead of relying on cached ones. All of this runs under the existing aux gates: breached outputs only carry a resolveReq/AuxSigDesc on aux/custom channels, and the sweeper notification is a no-op unless an AuxSweeper is configured.
Under DeterministicHTLCs the pre-signed second-level HTLC tx is published directly via PublishTx, bypassing the sweeper's NotifyBroadcast hook, so the aux sweeper never imports the second-level proof transition into its archive and the upcoming output sweep would fail to fetch its input proof. Before offering the second-level output to the sweeper, give the aux resolver a chance to import the confirmed second-level tx into its proof archive by re-resolving the contract against it. The fast path clones the ResolveReq preserved on the htlcResolution at force-close time and only swaps the second-level context (SecondLevelInfo); the slow path (resolvers recovered from a briefcase checkpoint, where the in-memory ResolveReq is gone) reconstructs the request from historical channel state via NewSecondLevelResolveReq. The whole path is explicitly gated on isAuxChannel(): the AuxResolver is a daemon-global option (present on every resolver whenever tapd is attached, regardless of channel type), so the channel-type check guarantees this never executes for a non-custom channel.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is the final part of the HTLC revocation series for aux/custom (taproot asset) channels, stacked on #11094 and #11111. Only the last twelve commits are new here.
It connects the pieces from the previous two PRs into the end-to-end breach lifecycle: with deterministic second-level HTLCs (part 1) and the revocation AuxSigs handed over in RevokeAndAck (part 2), the breach machinery can now punish a revoked commitment on an asset channel no matter which path the cheater takes, while keeping the asset proof chain intact.
The commits are grouped into four arcs:
Breach context (lnwallet):
FetchLeavesFromRevocationgains the channel state parameters needed to derive the revoked commitment's leaves.AuxSigDescpopulated from the revocation log, carrying the dual-path sigs from part 2.Confirmed-only aux notification (sweep + contractcourt):
AuxNotifyOpts(confirm height, input-proof lookup, independent skip flags), so the asset side never finalizes proofs for transactions that lost the pinning race. Tests included.Justice machinery (contractcourt):
Convergence:
As with the previous parts, every aux path is gated on the channel type carrying a tapscript root; lnd's own breach-retribution and force-close itests pass unchanged, and the full custom channel itest suite (force close, HTLC force close, MPP, group tranches, breach, coop close) passes against this tip together with the companion taproot-assets branch.
Previous PR: #11111