Skip to content

[2/3] aux revocation: exchange revocation AuxSigs in RevokeAndAck - #11111

Open
GeorgeTsagk wants to merge 8 commits into
lightningnetwork:masterfrom
GeorgeTsagk:revocation-aux-sigs
Open

[2/3] aux revocation: exchange revocation AuxSigs in RevokeAndAck#11111
GeorgeTsagk wants to merge 8 commits into
lightningnetwork:masterfrom
GeorgeTsagk:revocation-aux-sigs

Conversation

@GeorgeTsagk

@GeorgeTsagk GeorgeTsagk commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

This is part 2 of the HTLC revocation series for aux/custom (taproot asset) channels, stacked on #11094. Only the last three commits are new here.

When a custom channel with deterministic HTLCs revokes a commitment, the revoking party signs second-level transactions for both spending paths of every non-dust HTLC on the revoked commitment and ships the signatures in the RevokeAndAck. This hands the honest party the asset-level material it needs at breach time: whichever path a cheater later uses to take an HTLC to the second level, a valid asset witness is already in hand.

Scope:

  • lnwire: RevokeAndAck gains CustomRecords. Additive and backward compatible, a message without records is byte-identical to today (pinned by test).
  • Sender: signs both spend paths per non-dust HTLC and packs the sigs per HTLC index into the outgoing RevokeAndAck, one entry per non-dust HTLC in lockstep with the BTC-level sigs (BTC-only HTLCs get a sig-less entry). A signing failure fails the revocation before any state advances.
  • Receiver: verifies every sig against the revoked commitment's key ring before the revocation is accepted, then persists them in the revocation log for breach-time consumption. Presence is enforced, mirroring the aux sig count check on CommitSig: entries are required for every non-dust HTLC, and unknown-index, dust-index, or one-sided entries are rejected.
  • Retransmission: the packed blob is persisted atomically with the commitment advance and re-attached when the RevokeAndAck is retransmitted on reestablish, surviving restarts.
  • Isolation: everything is gated behind a single predicate requiring a tapscript root, an aux signer, and negotiated SigHashDefault. Vanilla channels never sign, attach, read, or verify these records.

No lnd-native channel can enter these paths, so end-to-end coverage lives in the taproot-assets custom channel itest suite; unit tests here cover the happy path, dust exclusion, all rejection cases, retransmission across a restart, and codec round trips.

Previous PR: #11094

Next PR: #11112

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant