Skip to content

[3/3] aux revocation: breach-to-confirmation revocation flow - #11112

Open
GeorgeTsagk wants to merge 22 commits into
lightningnetwork:masterfrom
GeorgeTsagk:htlc-revocation-flow
Open

[3/3] aux revocation: breach-to-confirmation revocation flow#11112
GeorgeTsagk wants to merge 22 commits into
lightningnetwork:masterfrom
GeorgeTsagk:htlc-revocation-flow

Conversation

@GeorgeTsagk

@GeorgeTsagk GeorgeTsagk commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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):

  • HTLC retributions carry their resolution blobs, the breach height, and a preserved resolution request template, so the aux resolver can be re-consulted later with full context.
  • FetchLeavesFromRevocation gains the channel state parameters needed to derive the revoked commitment's leaves.
  • Breached HTLCs get an AuxSigDesc populated from the revocation log, carrying the dual-path sigs from part 2.

Confirmed-only aux notification (sweep + contractcourt):

  • The aux sweeper is notified only once a justice transaction actually confirms, via 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):

  • The breach arbiter handles second-level morphing: it locates the punishable output by script match (aux second-level txs bundle multiple HTLCs plus an anchor, so input index does not equal output index) and rebuilds justice inputs against current state.
  • Justice tx variants are tracked across rebuild cycles so a confirmed historic variant is still recognized.
  • Justice sweep outputs respect the dust limit when the asset output carries the real value.

Convergence:

  • Breach-time consumption of the revocation AuxSigs: whichever second-level path the cheater used, the stored signature for that path is applied.
  • Second-level output sweeps re-resolve the aux blob against the confirmed second-level transaction so proof chains stay intact (gated to custom channels).

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

@GeorgeTsagk GeorgeTsagk self-assigned this Aug 21, 2026
@GeorgeTsagk
GeorgeTsagk force-pushed the htlc-revocation-flow branch from db471fa to af50f4a Compare August 24, 2026 14:56
GeorgeTsagk added a commit to lightninglabs/taproot-assets that referenced this pull request Aug 24, 2026
Points the lnd module and its submodules at the companion series on the
GeorgeTsagk/lnd fork:

  1. lightningnetwork/lnd#11094, deterministic second-level HTLCs
  2. lightningnetwork/lnd#11111, revocation AuxSigs in RevokeAndAck
  3. lightningnetwork/lnd#11112, breach-to-confirmation revocation flow

The pin is at the tip of part 3, which contains all three. It also adapts
the call sites that the new aux interfaces touch.

This is temporary and has to be dropped once the lnd series lands.
@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Aug 24, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

file classification | 39 files | 5928 lines changed (5604 additions, 324 deletions)

🔴 Critical (17 files)
  • channeldb/channel.go - channel state persistence
  • chanstate/open_channel.go - open channel state (closely tied to channeldb)
  • contractcourt/breach_arbitrator.go - breach handling, largest diff (+514/-40)
  • contractcourt/chain_arbitrator.go - on-chain dispute coordination
  • contractcourt/chain_watcher.go - on-chain dispute resolution
  • contractcourt/contract_resolver.go - new contract resolver logic (+312)
  • contractcourt/htlc_success_resolver.go - HTLC resolution on-chain
  • contractcourt/htlc_timeout_resolver.go - HTLC resolution on-chain
  • htlcswitch/link.go - HTLC forwarding state machine
  • lnwallet/aux_leaf_store.go - custom channel commitment auxiliary data
  • lnwallet/aux_resolutions.go - commitment tx signing/resolution
  • lnwallet/aux_signer.go - custom channel signing
  • lnwallet/chanfunding/coin_select.go - channel funding coin selection
  • lnwallet/channel.go - core channel/commitment logic, largest diff (+1694/-202)
  • lnwallet/commitment.go - commitment transaction construction
  • lnwallet/transactions.go - transaction signing/construction
  • lnwire/revoke_and_ack.go - wire protocol message (RevokeAndAck)
  • server.go - core server coordination
  • sweep/fee_bumper.go - fund recovery/fee bumping
  • sweep/interface.go - fund recovery interfaces
🟠 High (1 file)
  • lnrpc/walletrpc/walletkit_server.go - RPC/API surface
🟢 Low (test-only / docs, excluded from counts)
  • channeldb/channel_test.go, channeldb/db_test.go
  • contractcourt/breach_arbitrator_test.go, contractcourt/commit_sweep_resolver_test.go, contractcourt/contract_resolver_test.go
  • htlcswitch/mailbox_test.go, htlcswitch/mock.go
  • input/size_test.go
  • lnrpc/walletrpc/walletkit_server_test.go
  • lnwallet/aux_signer_test.go, lnwallet/chanfunding/coin_select_test.go, lnwallet/channel_test.go, lnwallet/mock.go, lnwallet/revocation_aux_sig_test.go, lnwallet/transactions_test.go
  • lnwire/revoke_and_ack_test.go
  • sweep/mock_test.go
  • docs/release-notes/release-notes-0.22.0.md

Analysis

This PR is a large, security-sensitive change touching several CRITICAL-tier subsystems simultaneously: contractcourt/* (breach arbitration and on-chain HTLC resolution), lnwallet/* (core commitment transaction and channel state logic, +1896 lines in lnwallet/channel.go alone), lnwire/* (RevokeAndAck wire message), htlcswitch/*, sweep/*, channeldb/*, and server.go. Even ignoring the file-count/line-count bump thresholds, the presence of multiple distinct critical packages (contractcourt, lnwallet, htlcswitch, sweep, channeldb) already places this at the highest severity tier. Excluding tests, mocks, and docs, the PR still touches ~21 non-test files and ~3.5k non-test lines, well past the >20 files / >500 lines bump thresholds — reinforcing the CRITICAL classification.

Given the scope (breach arbitration, commitment/resolution logic, and revocation wire messages), this warrants careful expert review of the channel state machine and on-chain resolution paths, particularly around the new/modified aux-signer and contract-resolver logic.


To override, add a severity-override-{critical,high,medium,low} label.

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 1.1x the relay floor over the tx weight (including
  the anchor output), which is enough for the pre-signed tx to clear
  min-relay on its own. It deliberately does not track the negotiated
  commitment fee rate: the whole on-chain BTC value of an aux-channel
  HTLC is a small fixed budget that already pays this fee, funds the
  anchor, and must still leave a second-level output that is non-dust
  AND able to fund the third-level sweep claiming it. At a realistic
  commitment fee rate the fee alone exceeds that budget, and capping it
  at what keeps the output non-dust leaves the output unable to pay for
  its own sweep, stranding the asset value it carries. Adapting to the
  fee market is the CPFP child's job; where a mempool minimum sits above
  the parent's own rate, package relay (walletrpc.SubmitPackage) is the
  answer, since no child can rescue a parent the mempool never accepted.
- 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 baked-in 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.
@GeorgeTsagk
GeorgeTsagk force-pushed the htlc-revocation-flow branch from af50f4a to 707dfdb Compare August 25, 2026 18:26
GeorgeTsagk added a commit to GeorgeTsagk/taproot-assets that referenced this pull request Aug 25, 2026
Points the lnd module and its submodules at the companion series on the
GeorgeTsagk/lnd fork:

  1. lightningnetwork/lnd#11094, deterministic second-level HTLCs
  2. lightningnetwork/lnd#11111, revocation AuxSigs in RevokeAndAck
  3. lightningnetwork/lnd#11112, breach-to-confirmation revocation flow

The pin is at the tip of part 3, which contains all three. It also adapts
the call sites that the new aux interfaces touch.

This is temporary and has to be dropped once the lnd series lands.
@GeorgeTsagk
GeorgeTsagk force-pushed the htlc-revocation-flow branch from 707dfdb to c16f334 Compare August 26, 2026 14:18
GeorgeTsagk added a commit to GeorgeTsagk/taproot-assets that referenced this pull request Aug 26, 2026
Points the lnd module and its submodules at the companion series on the
GeorgeTsagk/lnd fork:

  1. lightningnetwork/lnd#11094, deterministic second-level HTLCs
  2. lightningnetwork/lnd#11111, revocation AuxSigs in RevokeAndAck
  3. lightningnetwork/lnd#11112, breach-to-confirmation revocation flow

The pin is at the tip of part 3, which contains all three. It also adapts
the call sites that the new aux interfaces touch.

This is temporary and has to be dropped once the lnd series lands.
…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, so that an RPC caller can opt in to a fee that
exceeds the transaction's total output value.

The concrete caller is the taproot-assets daemon, which funds PSBTs
that anchor asset outputs through walletrpc.FundPsbt with a ratio of
5.0 (see lndservices.DefaultPsbtMaxFeeRatio). Those transactions spend
asset-bearing UTXOs whose BTC value sits near dust because the value
they carry lives at the asset level, so in a busy fee market the fee
legitimately dwarfs the BTC output value and the 1.0 bound would fail
the funding outright.

Note that this is independent of the deterministic second-level HTLC
work in the rest of this series: the CPFP anchor sweep goes through the
sweeper (Sweeper.SweepInput with a budget), not through FundPsbt, so
nothing in this PR exercises the relaxed bound. It is carried here
because the companion taproot-assets series depends on it, and it can
be split into its own PR on request.

Every internal funding flow keeps passing DefaultMaxFeeRatio, ratios
above 1.0 are logged by FundPsbt so the opt-in is auditable, and the
new ceiling still rejects 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.
The direct signing path was taught to apply the revocation (double) and
HTLC-index (single) tweaks together in 8125286, but the PSBT path used
by remote signers still returned after the first tweak unknown it
encountered, silently dropping the other one. A watch-only node with a
remote signer would then derive a wrong signing key for second-level
HTLC signing on aux channels, where both tweaks are attached. Mirror the
combined derivation (double first, then single) and cover all tweak
combinations and unknown orderings with a test.
@GeorgeTsagk
GeorgeTsagk force-pushed the htlc-revocation-flow branch from c16f334 to e8d8da9 Compare August 28, 2026 10:00
GeorgeTsagk added a commit to GeorgeTsagk/taproot-assets that referenced this pull request Aug 28, 2026
Points the lnd module and its submodules at the companion series on the
GeorgeTsagk/lnd fork:

  1. lightningnetwork/lnd#11094, deterministic second-level HTLCs
  2. lightningnetwork/lnd#11111, revocation AuxSigs in RevokeAndAck
  3. lightningnetwork/lnd#11112, breach-to-confirmation revocation flow

The pin is at the tip of part 3, which contains all three. It also adapts
the call sites that the new aux interfaces touch.

This is temporary and has to be dropped once the lnd series lands.
@litbot-9000

Copy link
Copy Markdown
Collaborator

@gijswijs: review reminder
@ziggie1984: review reminder

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

severity-critical Requires expert review - security/consensus critical

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants