Most Lightning Network tutorials explain what an HTLC is. Almost none explain what happens when one expires. That gap is where real money is lost.
This post is about the specific block heights, the fee races, and the protocol rules that govern the most operationally dangerous scenarios in a Lightning node. Understanding this is not optional if you are running a node in production.
What an HTLC Actually Is
A Hash Time-Locked Contract is a conditional payment output sitting inside a commitment transaction. It says: pay this amount to whoever can present the preimage of hash H, but only until block N. After block N, the funds return to the party that offered the HTLC.
In Lightning, HTLCs live in an off-chain commitment transaction that both parties hold. That transaction is valid and can be broadcast to the Bitcoin chain at any time. The critical property is that the time-lock (cltv_expiry) in the HTLC is a real Bitcoin block height, enforced by the chain if the commitment transaction is ever published.
The HTLC output in a commitment transaction looks like this:
OP_DUP OP_HASH160 <RIPEMD160(SHA256(payment_hash))> OP_EQUALVERIFY OP_CHECKSIG
But the full script is more complex. An offered HTLC output, as defined in BOLT 3, has two spending paths:
- The recipient presents the preimage before
cltv_expiry— payment succeeds - The offerer reclaims after
cltv_expiryand a relative delay — payment fails
The relative delay (to_self_delay) is a separate CSV constraint that prevents the offerer from immediately reclaiming. It gives the counterparty time to contest a revoked commitment transaction.
The Expiry Problem
In a routed payment, you are both an HTLC offerer (to the next hop) and an HTLC recipient (from the previous hop). These two positions have different expiry heights, and the difference between them is the CLTV delta.
Alice --[expiry: 800]-- Bob --[expiry: 780]-- Carol
Bob's safety window is 20 blocks. If Carol does not reveal the preimage by block 780, Bob must act before block 800 or he cannot reclaim the funds he forwarded.
This is the expiry problem. Bob has a deadline. If Carol settles off-chain, Bob receives the preimage and can settle upstream with Alice. But if Carol goes offline or becomes unresponsive, Bob must go on-chain before his own expiry.
BOLT 5 is explicit about this obligation:
A node MUST fail the channel if an outgoing HTLC's cltv_expiry is not met by the fulfillment deadline.
The fulfillment deadline is not the expiry block itself. BOLT 2 defines cltv_expiry_delta minimums and BOLT 11 defines default values (40 blocks for most implementations), but the safe window for on-chain action is considerably tighter than the raw delta suggests, because Bitcoin block times are probabilistic and fees can delay confirmation.
When On-Chain Action Is Required
Your node must go on-chain in two situations:
Outgoing HTLC timeout: You offered an HTLC to the next hop and that HTLC is approaching its expiry. The counterparty has not provided the preimage. You must broadcast your local commitment transaction and then claim the HTLC output via the timeout path.
Incoming HTLC with a revealed preimage: You received the preimage from downstream but the upstream peer is unresponsive. You must claim the HTLC output on-chain to receive the funds.
Both require broadcasting a commitment transaction, which triggers a force close.
Force Close: What Actually Happens
A force close is the unilateral publication of your local commitment transaction. There is no negotiation. You broadcast the transaction and let the chain settle the state.
Here is what happens step by step:
Step 1: Broadcast the commitment transaction
Your local commitment transaction has two types of outputs:
-
to_local: your balance, encumbered byto_self_delay(you cannot spend it immediately) -
to_remote: the counterparty's balance, spendable immediately by them - Zero or more HTLC outputs
Commitment Transaction
├── to_local (your balance, CSV-locked)
├── to_remote (counterparty balance, immediately spendable)
├── offered HTLC 1 (you offered, counterparty can claim with preimage)
└── received HTLC 2 (counterparty offered, you can claim with preimage)
Step 2: Handle each HTLC output
Each HTLC output requires a second-stage transaction. You cannot spend HTLC outputs directly from the commitment transaction in a single step (with anchor channels there are further complications — see below).
For an offered HTLC that is timing out:
# HTLC-Timeout transaction
# Input: offered HTLC output from commitment transaction
# Sequence: 0 (no relative delay on input)
# Output: HTLC-delayed output, encumbered by to_self_delay
# This transaction requires:
# - A valid signature from your HTLC key
# - The cltv_expiry block to have passed (absolute lock time)
# - Confirmation before your upstream expiry
For a received HTLC where you have the preimage:
# HTLC-Success transaction
# Input: received HTLC output from commitment transaction
# Output: HTLC-delayed output, encumbered by to_self_delay
# This transaction requires:
# - A valid signature from your HTLC key
# - The payment preimage
# - No absolute lock time constraint (can confirm any time)
Step 3: Claim delayed outputs
After to_self_delay blocks have passed since the commitment transaction confirmed, you can claim your to_local output and the outputs from your HTLC second-stage transactions.
The Fee Race
Here is where many operators underestimate the risk. HTLC-Timeout transactions have an absolute lock time (nLockTime) set to cltv_expiry. They cannot be mined before that block. But once that block passes, you are racing to get the transaction confirmed before your upstream expiry.
In a fee market with high mempool pressure, this is a real race. A 20-block CLTV delta provides only 200 minutes of slack on average, and Bitcoin block times are not uniform. During periods of fee volatility, a 20-block window can mean you are competing against other transactions with significantly higher fees.
The implication for node operators: CLTV deltas that seemed generous in a low-fee environment become dangerous in a high-fee environment. The Lightning specification allows for this with min_final_cltv_expiry_delta, but many implementations historically used values that provide insufficient safety margin under adversarial or fee-spike conditions.
Practical recommendations:
- Use at minimum a 40-block
cltv_expiry_deltafor routing nodes - Configure your fee estimation to use a 6-block target for HTLC-Timeout and HTLC-Success transactions
- Monitor your mempool. A full mempool means your fee estimate from last hour may be stale
- Automate alerts for HTLCs within 50 blocks of expiry
Anchor Outputs: How They Changed This
In 2020, Lightning implementations adopted anchor outputs (BOLT 3 update). The key change for force close is that HTLC transactions are no longer required to carry pre-committed fee rates. Instead:
- The commitment transaction has two anchor outputs (one per party), each 330 satoshis
- HTLC second-stage transactions have
SIGHASH_SINGLE | SIGHASH_ANYONECANPAYsignatures - This allows fee bumping via CPFP (child-pays-for-parent) at broadcast time
Before anchors, fees on HTLC transactions were negotiated at channel open and locked in at signing. If the fee market moved significantly, you might broadcast a transaction that would never confirm. Anchors solve this by deferring fee decisions to broadcast time.
Here is how CPFP works in this context:
# Your HTLC-Timeout transaction is in the mempool with a low fee rate
# You create a child transaction spending your anchor output
# The child carries enough fee to pull the parent into the next block
def calculate_cpfp_fee(
parent_vsize: int,
parent_fee: int,
child_vsize: int,
target_fee_rate: float # sat/vbyte
) -> int:
# Total fee needed for the package
total_package_fee = (parent_vsize + child_vsize) * target_fee_rate
# Child must cover the deficit
child_fee = total_package_fee - parent_fee
return max(0, int(child_fee))
# Example
parent_vsize = 663 # typical HTLC-Timeout
parent_fee = 1500 # low fee from old estimate
child_vsize = 150 # anchor spend
target_rate = 25.0 # sat/vbyte, current mempool
child_fee = calculate_cpfp_fee(parent_vsize, parent_fee, child_vsize, target_rate)
# child_fee = (663 + 150) * 25 - 1500 = 20325 - 1500 = 18825 sat
The tradeoff is complexity. Anchor channels require watching for both commitment transactions and HTLC outputs, and your wallet must maintain UTXOs suitable for anchor spending.
Justice Transactions: The Other Side of This
When your counterparty broadcasts a revoked commitment transaction, one that represents an older state, you have a limited window to claim all their funds as a penalty. This is the justice transaction, sometimes called the breach remedy.
The window is exactly to_self_delay blocks. Your counterparty's to_local output is CSV-locked for that many blocks. If you detect the revoked transaction and broadcast a justice transaction within that window, you take everything: their balance, your balance, and all HTLC outputs.
This is why channel watchtowers exist. If your node is offline when your counterparty broadcasts a revoked state, you lose the justice window.
LND exposes this via the watchtower client:
# Register an external watchtower
lncli wtclient add --uri <pubkey>@<host>:<port>
# Check registered towers
lncli wtclient towers
# The tower watches your channels and broadcasts justice transactions
# if a breach is detected while your node is offline
The justice transaction spends from the same commitment transaction:
Justice Transaction
├── Input: to_local (with revocation key, no CSV delay required)
├── Input: HTLC 1 output (with revocation key)
├── Input: HTLC 2 output (with revocation key)
└── Output: to your address (all funds)
The revocation key is computed from the revocation base point and the per-commitment secret, which your counterparty revealed when they moved to the next commitment state. BOLT 3 specifies the exact derivation.
Monitoring What Matters
An implementation that does not monitor block height against HTLC expiry deadlines will lose money. The minimum viable monitoring loop:
import asyncio
from dataclasses import dataclass
@dataclass
class PendingHTLC:
payment_hash: str
direction: str # 'offered' or 'received'
cltv_expiry: int # absolute block height
amount_msat: int
channel_id: str
async def monitor_htlc_expiry(
get_block_height,
get_pending_htlcs,
trigger_force_close,
safety_buffer: int = 50 # blocks before expiry to act
):
while True:
current_height = await get_block_height()
pending = await get_pending_htlcs()
for htlc in pending:
if htlc.direction == 'offered':
blocks_remaining = htlc.cltv_expiry - current_height
if blocks_remaining <= safety_buffer:
print(
f"WARNING: Offered HTLC {htlc.payment_hash[:16]} "
f"expires in {blocks_remaining} blocks. "
f"Amount: {htlc.amount_msat} msat. "
f"Channel: {htlc.channel_id}"
)
if blocks_remaining <= 20:
# Critical: initiate force close
await trigger_force_close(htlc.channel_id)
await asyncio.sleep(30) # check every ~30 seconds
A 50-block buffer before acting is conservative but defensible. The exact value depends on your CLTV delta configuration and your confidence in fee estimation.
What Implementations Do
LND has an on-chain watcher (chainwatcher.go) that subscribes to new blocks and evaluates HTLC states against current height. It initiates force close when an outgoing HTLC is within OutgoingBroadcastDelta blocks of expiry. The default value is 10 blocks which is lower than many operators realize.
Core Lightning uses a plugin hook system where the onchaind daemon manages all on-chain state, including HTLC resolution. CLN was earlier to adopt anchor outputs and has separate handling for pre-anchor and anchor-style commitments.
LDK takes a different approach. It is a library rather than a daemon, so HTLC expiry handling is the responsibility of the consuming application. LDK provides ChannelMonitor objects that must be fed new blocks by the application, and it generates Events that the application must handle, including HTLCHandlingFailed and SpendableOutputs. If you are building on LDK, you own the loop.
What to Take Away
The HTLC expiry mechanism is what makes Lightning secure. It is also what makes Lightning operationally demanding. The key points:
- Every pending HTLC has a deadline. The deadline is a real Bitcoin block height.
- When the deadline approaches and the payment is unresolved, you must go on-chain.
- Going on-chain triggers a force close and a sequence of second-stage transactions.
- Fee estimation at broadcast time matters. Anchor outputs give you flexibility; you must use it.
- The justice window is real. Watchtowers are not optional if your node has downtime.
- LDK puts the responsibility on you explicitly. Other implementations handle it internally, but you should understand what they are doing.
The block height is the ground truth. Everything else is coordination on top of it.
Further Reading
- BOLT 3: Transaction formats — https://github.com/lightning/bolts/blob/master/03-transactions.md
- BOLT 5: On-chain transaction handling — https://github.com/lightning/bolts/blob/master/05-onchain.md
- LND chainwatcher implementation — https://github.com/lightningnetwork/lnd/blob/master/lnwallet/channel.go
- Anchor outputs explanation — https://bitcoinops.org/en/topics/anchor-outputs/
Top comments (0)