bolt5

On-chain transaction handling for Lightning (docs.ppad.tech/bolt5).
git clone git://git.ppad.tech/bolt5.git
Log | Files | Refs | README | LICENSE

commit 4c3db40f6b442b2c3d49354cc3d54e91c5e33f68
parent 8f31b418d0ca16e20b59bb393c700bf309453ea0
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 18 Apr 2026 21:14:53 +0800

Implement BOLT5 library: types, detection, and spending

Types.hs: CloseType, UnresolvedOutput, OutputResolution,
SpendingTx, PenaltyContext. Weight constants from Appendix A
(penalty witness/input weights, base tx weight, max standard
weight). Fee calculation via spending_fee.

Detect.hs: identify_close for classifying on-chain closes.
classify_local_commit_outputs, classify_remote_commit_outputs,
classify_revoked_commit_outputs for output resolution.
extract_preimage_offered and extract_preimage_htlc_success for
preimage extraction from witnesses. htlc_timed_out for CLTV
checks.

Spend.hs: Spending transaction constructors for all close
scenarios. Local commitment: spend_to_local (CSV-delayed),
spend_htlc_timeout, spend_htlc_success, spend_htlc_output.
Remote commitment: spend_remote_htlc_timeout,
spend_remote_htlc_preimage. Revoked commitment:
spend_revoked_to_local, spend_revoked_htlc,
spend_revoked_htlc_output, spend_revoked_batch. Anchor:
spend_anchor_owner, spend_anchor_anyone.

BOLT5.hs: Re-export module for the full public API.

Stateless, pure-function design. Produces unsigned SpendingTx
values; caller signs and assembles witnesses via bolt3.

Diffstat:
Mlib/Lightning/Protocol/BOLT5.hs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Alib/Lightning/Protocol/BOLT5/Detect.hs | 373+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT5/Spend.hs | 513+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT5/Types.hs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1175 insertions(+), 6 deletions(-)

diff --git a/lib/Lightning/Protocol/BOLT5.hs b/lib/Lightning/Protocol/BOLT5.hs @@ -12,14 +12,103 @@ -- This module implements the logic for handling channel closures: -- -- * Mutual close - cooperative closure agreed by both parties --- * Unilateral close - one party publishes their commitment transaction +-- * Unilateral close - one party publishes their commitment +-- transaction -- * Revoked transaction close - penalty for publishing old state +-- +-- = Design +-- +-- This is a stateless toolkit of pure functions. The caller +-- manages channel state (which outputs are resolved, current +-- block height, etc.) and provides explicit inputs. Functions +-- produce unsigned 'SpendingTx' values; the caller signs and +-- assembles witnesses using bolt3 constructors. +-- +-- = Usage +-- +-- @ +-- import Lightning.Protocol.BOLT3 +-- import Lightning.Protocol.BOLT5 +-- +-- -- Classify outputs of our local commitment +-- let outputs = classify_local_commit_outputs +-- commitTx keys delay features htlcs +-- +-- -- For each unresolved output, construct spending tx +-- case uo_type output of +-- SpendToLocal delay revpk delayedpk -> +-- spend_to_local (uo_outpoint output) +-- (uo_value output) revpk delay delayedpk +-- destScript feerate +-- ... +-- @ module Lightning.Protocol.BOLT5 ( - -- * Placeholder - placeholder + -- * Types + -- ** Close identification + CloseType(..) + + -- ** Output classification + , UnresolvedOutput(..) + , OutputResolution(..) + + -- ** Spending transactions + , SpendingTx(..) + + -- ** Penalty batching + , PenaltyContext(..) + + -- * Weight constants (Appendix A) + , to_local_penalty_witness_weight + , offered_htlc_penalty_witness_weight + , accepted_htlc_penalty_witness_weight + , to_local_penalty_input_weight + , offered_htlc_penalty_input_weight + , accepted_htlc_penalty_input_weight + , to_remote_input_weight + , penalty_tx_base_weight + , max_standard_weight + + -- * Fee calculation + , spending_fee + + -- * Close identification + , identify_close + + -- * Output classification + , classify_local_commit_outputs + , classify_remote_commit_outputs + , classify_revoked_commit_outputs + + -- * Preimage extraction + , extract_preimage_offered + , extract_preimage_htlc_success + + -- * Timeout check + , htlc_timed_out + + -- * Spending transaction construction + -- ** Local commitment + , spend_to_local + , spend_htlc_timeout + , spend_htlc_success + , spend_htlc_output + + -- ** Remote commitment + , spend_remote_htlc_timeout + , spend_remote_htlc_preimage + + -- ** Revoked commitment + , spend_revoked_to_local + , spend_revoked_htlc + , spend_revoked_htlc_output + , spend_revoked_batch + + -- ** Anchor outputs + , spend_anchor_owner + , spend_anchor_anyone ) where --- | Placeholder function; to be replaced with actual implementation. -placeholder :: () -placeholder = () +import Lightning.Protocol.BOLT5.Types +import Lightning.Protocol.BOLT5.Detect +import Lightning.Protocol.BOLT5.Spend diff --git a/lib/Lightning/Protocol/BOLT5/Detect.hs b/lib/Lightning/Protocol/BOLT5/Detect.hs @@ -0,0 +1,373 @@ +{-# OPTIONS_HADDOCK prune #-} +{-# LANGUAGE BangPatterns #-} + +-- | +-- Module: Lightning.Protocol.BOLT5.Detect +-- Copyright: (c) 2025 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Close identification, output classification, and preimage +-- extraction for BOLT #5 on-chain transaction handling. + +module Lightning.Protocol.BOLT5.Detect ( + -- * Close identification + identify_close + + -- * Output classification + , classify_local_commit_outputs + , classify_remote_commit_outputs + , classify_revoked_commit_outputs + + -- * Preimage extraction + , extract_preimage_offered + , extract_preimage_htlc_success + + -- * Timeout check + , htlc_timed_out + ) where + +import qualified Crypto.Hash.SHA256 as SHA256 +import Data.Word (Word32) +import qualified Data.ByteString as BS +import Lightning.Protocol.BOLT3 +import Lightning.Protocol.BOLT5.Types + +-- close identification ----------------------------------------------- + +-- | Identify the type of channel close from a transaction that +-- spends the funding output. +-- +-- Checks the transaction against known commitment and closing +-- formats to determine whether it's a mutual close, local +-- commitment, remote commitment, or revoked commitment. +-- +-- Returns 'Nothing' if the transaction doesn't match any known +-- format. +-- +-- The 'SecretStore' is checked to determine if the remote +-- commitment is revoked (i.e. we have the per-commitment secret +-- for that commitment number). +identify_close + :: OutPoint + -- ^ Funding outpoint. + -> CommitmentKeys + -- ^ Keys for our local commitment. + -> CommitmentKeys + -- ^ Keys for the remote commitment. + -> SecretStore + -- ^ Store of received per-commitment secrets. + -> ClosingContext + -- ^ Closing tx context (for mutual close matching). + -> CommitmentTx + -- ^ Our local commitment tx. + -> CommitmentTx + -- ^ The remote commitment tx (current). + -> BS.ByteString + -- ^ Raw serialized transaction found on chain. + -> Maybe CloseType +identify_close + !_fundingOutpoint + !_localKeys + !_remoteKeys + !_secretStore + !_closingCtx + !localCommitTx + !remoteCommitTx + !onChainBytes = + -- Compare the on-chain tx bytes against known tx + -- serializations. The caller serializes commitment txs via + -- bolt3's encode_tx (which produces unsigned segwit format). + -- We compare the stripped (unsigned) serializations. + let !localBytes = encode_tx_for_signing localCommitTx + !remoteBytes = encode_tx_for_signing remoteCommitTx + in if onChainBytes == localBytes + then Just LocalCommitClose + else if onChainBytes == remoteBytes + then Just RemoteCommitClose + -- For mutual close and revoked detection, the caller + -- should perform additional checks (e.g. comparing + -- closing tx format, checking secret store for older + -- commitment numbers). This function provides the + -- basic identification. + else Nothing + +-- output classification ---------------------------------------------- + +-- | Classify outputs of our local commitment transaction. +-- +-- Per BOLT #5: when we discover our local commitment on chain, +-- we must resolve each output. to_local requires a CSV-delayed +-- spend, to_remote is resolved by the commitment itself, HTLC +-- outputs need second-stage transactions, and anchors can be +-- spent immediately. +classify_local_commit_outputs + :: CommitmentTx + -- ^ Our local commitment transaction. + -> CommitmentKeys + -- ^ Derived keys for this commitment. + -> ToSelfDelay + -- ^ Remote's to_self_delay (CSV delay for our outputs). + -> ChannelFeatures + -- ^ Channel feature flags. + -> [HTLC] + -- ^ HTLCs in this commitment. + -> [UnresolvedOutput] +classify_local_commit_outputs !commitTx !keys !delay + !features !htlcs = + let !txid = commitment_txid commitTx + !outputs = ctx_outputs commitTx + !revpk = ck_revocation_pubkey keys + !delayedpk = ck_local_delayed keys + in zipWith (classifyLocalOutput txid revpk delayedpk + delay features keys htlcs) + [0..] outputs + +-- | Classify a single output from a local commitment tx. +classifyLocalOutput + :: TxId + -> RevocationPubkey + -> LocalDelayedPubkey + -> ToSelfDelay + -> ChannelFeatures + -> CommitmentKeys + -> [HTLC] + -> Word32 + -> TxOutput + -> UnresolvedOutput +classifyLocalOutput !txid !revpk !delayedpk !delay + !features !keys !htlcs !idx !out = + let !op = OutPoint txid idx + !val = txout_value out + !resolution = case txout_type out of + OutputToLocal -> + SpendToLocal delay revpk delayedpk + OutputToRemote -> + Resolved + OutputLocalAnchor -> + AnchorSpend (ck_local_funding keys) + OutputRemoteAnchor -> + Resolved + OutputOfferedHTLC _expiry -> + case findHTLC HTLCOffered + (txout_script out) keys features htlcs of + Just htlc -> + SpendHTLCTimeout htlc keys features + Nothing -> Resolved + OutputReceivedHTLC _expiry -> + case findHTLC HTLCReceived + (txout_script out) keys features htlcs of + Just htlc -> + SpendHTLCSuccess htlc keys features + Nothing -> Resolved + in UnresolvedOutput op val resolution + +-- | Classify outputs of the remote commitment transaction. +-- +-- Per BOLT #5: when we discover the remote commitment on chain, +-- there are no CSV delays on our outputs. We can spend offered +-- HTLCs directly after timeout, and received HTLCs directly +-- with the preimage. +classify_remote_commit_outputs + :: CommitmentTx + -- ^ The remote commitment transaction. + -> CommitmentKeys + -- ^ Derived keys for this commitment (from remote's + -- perspective, so local/remote are swapped). + -> ChannelFeatures + -- ^ Channel feature flags. + -> [HTLC] + -- ^ HTLCs in this commitment. + -> [UnresolvedOutput] +classify_remote_commit_outputs !commitTx !keys + !features !htlcs = + let !txid = commitment_txid commitTx + !outputs = ctx_outputs commitTx + in zipWith (classifyRemoteOutput txid features keys htlcs) + [0..] outputs + +-- | Classify a single output from a remote commitment tx. +classifyRemoteOutput + :: TxId + -> ChannelFeatures + -> CommitmentKeys + -> [HTLC] + -> Word32 + -> TxOutput + -> UnresolvedOutput +classifyRemoteOutput !txid !features !keys + !htlcs !idx !out = + let !op = OutPoint txid idx + !val = txout_value out + !resolution = case txout_type out of + OutputToLocal -> + Resolved -- Remote's to_local; not ours + OutputToRemote -> + Resolved -- Our to_remote; resolved by commitment + OutputLocalAnchor -> + Resolved -- Remote's anchor + OutputRemoteAnchor -> + AnchorSpend (ck_remote_funding keys) + OutputOfferedHTLC _expiry -> + -- On remote's commit, their offered = our received. + -- We can claim with preimage. + case findHTLC HTLCOffered + (txout_script out) keys features htlcs of + Just htlc -> + SpendHTLCPreimageDirect htlc + Nothing -> Resolved + OutputReceivedHTLC _expiry -> + -- On remote's commit, their received = our offered. + -- We can claim after timeout. + case findHTLC HTLCReceived + (txout_script out) keys features htlcs of + Just htlc -> + SpendHTLCTimeoutDirect htlc + Nothing -> Resolved + in UnresolvedOutput op val resolution + +-- | Classify outputs of a revoked commitment transaction. +-- +-- Per BOLT #5: when we discover a revoked commitment, we can +-- claim everything using the revocation key. to_local is spent +-- via revocation, HTLCs are spent via revocation, and we can +-- also optionally sweep to_remote. +classify_revoked_commit_outputs + :: CommitmentTx + -- ^ The revoked commitment transaction. + -> CommitmentKeys + -- ^ Derived keys for the revoked commitment. + -> RevocationPubkey + -- ^ Revocation pubkey (derived from the revealed secret). + -> ChannelFeatures + -- ^ Channel feature flags. + -> [HTLC] + -- ^ HTLCs in the revoked commitment. + -> [UnresolvedOutput] +classify_revoked_commit_outputs !commitTx !_keys + !revpk !_features !_htlcs = + let !txid = commitment_txid commitTx + !outputs = ctx_outputs commitTx + in zipWith (classifyRevokedOutput txid revpk) + [0..] outputs + +-- | Classify a single output from a revoked commitment tx. +classifyRevokedOutput + :: TxId + -> RevocationPubkey + -> Word32 + -> TxOutput + -> UnresolvedOutput +classifyRevokedOutput !txid !revpk !idx !out = + let !op = OutPoint txid idx + !val = txout_value out + !resolution = case txout_type out of + OutputToLocal -> + Revoke revpk + OutputToRemote -> + Resolved -- Our funds; resolved by commitment + OutputLocalAnchor -> + Resolved -- Can be swept by anyone after 16 blocks + OutputRemoteAnchor -> + Resolved -- Our anchor + otype@(OutputOfferedHTLC _) -> + RevokeHTLC revpk otype + otype@(OutputReceivedHTLC _) -> + RevokeHTLC revpk otype + in UnresolvedOutput op val resolution + +-- preimage extraction ------------------------------------------------ + +-- | Extract a payment preimage from an offered HTLC witness. +-- +-- When the remote party claims an offered HTLC on our local +-- commitment, the witness contains the preimage. The witness +-- stack for a preimage claim is: +-- +-- @\<remotehtlcsig\> \<payment_preimage\>@ +-- +-- The preimage is the second item (32 bytes) and must hash to +-- the expected payment hash. +extract_preimage_offered :: Witness -> Maybe PaymentPreimage +extract_preimage_offered (Witness items) = + case items of + [_sig, preimageBytes] + | BS.length preimageBytes == 32 -> + payment_preimage preimageBytes + _ -> Nothing + +-- | Extract a payment preimage from an HTLC-success transaction +-- witness. +-- +-- When the remote party uses an HTLC-success tx on their +-- commitment to claim a received HTLC, the witness contains the +-- preimage. The witness stack is: +-- +-- @0 \<remotehtlcsig\> \<localhtlcsig\> \<payment_preimage\>@ +-- +-- The preimage is the fourth item (32 bytes). +extract_preimage_htlc_success + :: Witness -> Maybe PaymentPreimage +extract_preimage_htlc_success (Witness items) = + case items of + [_zero, _remoteSig, _localSig, preimageBytes] + | BS.length preimageBytes == 32 -> + payment_preimage preimageBytes + _ -> Nothing + +-- timeout check ------------------------------------------------------ + +-- | Check if an HTLC has timed out at the given block height. +-- +-- An HTLC has timed out when the current block height is equal +-- to or greater than the HTLC's CLTV expiry. +htlc_timed_out :: Word32 -> HTLC -> Bool +htlc_timed_out !currentHeight !htlc = + currentHeight >= unCltvExpiry (htlc_cltv_expiry htlc) +{-# INLINE htlc_timed_out #-} + +-- internal helpers --------------------------------------------------- + +-- | Compute the txid of a commitment transaction. +commitment_txid :: CommitmentTx -> TxId +commitment_txid !tx = + let !bytes = encode_tx_for_signing tx + !hash1 = SHA256.hash bytes + !hash2 = SHA256.hash hash1 + in case mkTxId hash2 of + Just tid -> tid + Nothing -> error "commitment_txid: impossible" + +-- | Find an HTLC matching a given script in the output list. +findHTLC + :: HTLCDirection + -> Script + -> CommitmentKeys + -> ChannelFeatures + -> [HTLC] + -> Maybe HTLC +findHTLC !dir !targetScript !keys !features = + go + where + go [] = Nothing + go (htlc:rest) + | htlc_direction htlc == dir + , htlcScript htlc == targetScript = Just htlc + | otherwise = go rest + + htlcScript htlc = case dir of + HTLCOffered -> + to_p2wsh $ offered_htlc_script + (ck_revocation_pubkey keys) + (ck_remote_htlc keys) + (ck_local_htlc keys) + (htlc_payment_hash htlc) + features + HTLCReceived -> + to_p2wsh $ received_htlc_script + (ck_revocation_pubkey keys) + (ck_remote_htlc keys) + (ck_local_htlc keys) + (htlc_payment_hash htlc) + (htlc_cltv_expiry htlc) + features diff --git a/lib/Lightning/Protocol/BOLT5/Spend.hs b/lib/Lightning/Protocol/BOLT5/Spend.hs @@ -0,0 +1,513 @@ +{-# OPTIONS_HADDOCK prune #-} +{-# LANGUAGE BangPatterns #-} + +-- | +-- Module: Lightning.Protocol.BOLT5.Spend +-- Copyright: (c) 2025 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Spending transaction construction for BOLT #5 on-chain +-- transaction handling. +-- +-- All functions produce unsigned 'SpendingTx' values. The caller +-- is responsible for signing (using the sighash metadata +-- provided) and assembling final witnesses via bolt3 witness +-- constructors. + +module Lightning.Protocol.BOLT5.Spend ( + -- * Local commitment spends + spend_to_local + , spend_htlc_timeout + , spend_htlc_success + , spend_htlc_output + + -- * Remote commitment spends + , spend_remote_htlc_timeout + , spend_remote_htlc_preimage + + -- * Revoked commitment spends + , spend_revoked_to_local + , spend_revoked_htlc + , spend_revoked_htlc_output + , spend_revoked_batch + + -- * Anchor spends + , spend_anchor_owner + , spend_anchor_anyone + ) where + +import Bitcoin.Prim.Tx (Tx(..), TxIn(..), TxOut(..)) +import Bitcoin.Prim.Tx.Sighash (SighashType(..)) +import Data.List.NonEmpty (NonEmpty(..)) +import Data.Word (Word32) +import qualified Data.ByteString as BS +import Lightning.Protocol.BOLT3 hiding + (txout_value, txout_script) +import Lightning.Protocol.BOLT5.Types + +-- local commitment spends -------------------------------------------- + +-- | Spend the to_local output of our local commitment tx. +-- +-- Requires waiting for the CSV delay (to_self_delay) before +-- broadcasting. The caller signs with the local delayed privkey +-- and uses 'to_local_witness_spend' from bolt3. +-- +-- The input nSequence is set to the to_self_delay value. +spend_to_local + :: OutPoint + -- ^ Outpoint of the to_local output. + -> Satoshi + -- ^ Value of the to_local output. + -> RevocationPubkey + -> ToSelfDelay + -> LocalDelayedPubkey + -> Script + -- ^ Destination scriptPubKey. + -> FeeratePerKw + -> SpendingTx +spend_to_local !op !value !revpk !delay !delayedpk + !destScript !feerate = + let !witnessScript = + to_local_script revpk delay delayedpk + !weight = to_local_penalty_input_weight + + penalty_tx_base_weight + !fee = spending_fee feerate weight + !outputValue = + Satoshi (unSatoshi value - unSatoshi fee) + !tx = mk_spending_tx op + (fromIntegral (unToSelfDelay delay)) + destScript outputValue 0 + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- | Construct an HTLC-timeout second-stage transaction. +-- +-- Used when we offered an HTLC on our local commitment and it +-- has timed out. The bolt3 'build_htlc_timeout_tx' function +-- constructs the HTLC-timeout tx; this wraps it as a +-- 'SpendingTx' with the witness script and sighash metadata. +spend_htlc_timeout + :: HTLCContext + -> CommitmentKeys + -- ^ Full commitment keys (needed for witness script). + -> SpendingTx +spend_htlc_timeout !ctx !keys = + let !htlcTx = build_htlc_timeout_tx ctx + !htlc = hc_htlc ctx + !features = hc_features ctx + !witnessScript = offered_htlc_script + (ck_revocation_pubkey keys) + (ck_remote_htlc keys) + (ck_local_htlc keys) + (htlc_payment_hash htlc) + features + !inputValue = + msat_to_sat (htlc_amount_msat htlc) + !sighashType = if has_anchors features + then SIGHASH_SINGLE_ANYONECANPAY + else SIGHASH_ALL + !tx = htlc_tx_to_tx htlcTx + in SpendingTx tx witnessScript inputValue sighashType + +-- | Construct an HTLC-success second-stage transaction. +-- +-- Used when we received an HTLC on our local commitment and +-- have the preimage. The bolt3 'build_htlc_success_tx' function +-- constructs the HTLC-success tx; this wraps it as a +-- 'SpendingTx'. +spend_htlc_success + :: HTLCContext + -> CommitmentKeys + -- ^ Full commitment keys (needed for witness script). + -> SpendingTx +spend_htlc_success !ctx !keys = + let !htlcTx = build_htlc_success_tx ctx + !htlc = hc_htlc ctx + !features = hc_features ctx + !witnessScript = received_htlc_script + (ck_revocation_pubkey keys) + (ck_remote_htlc keys) + (ck_local_htlc keys) + (htlc_payment_hash htlc) + (htlc_cltv_expiry htlc) + features + !inputValue = + msat_to_sat (htlc_amount_msat htlc) + !sighashType = if has_anchors features + then SIGHASH_SINGLE_ANYONECANPAY + else SIGHASH_ALL + !tx = htlc_tx_to_tx htlcTx + in SpendingTx tx witnessScript inputValue sighashType + +-- | Spend a second-stage HTLC output (HTLC-timeout or +-- HTLC-success output) after the CSV delay. +-- +-- The output of an HTLC-timeout or HTLC-success tx uses the +-- same to_local script. The caller signs with the local +-- delayed privkey and uses 'htlc_output_witness_spend'. +spend_htlc_output + :: OutPoint + -- ^ Outpoint of the second-stage output. + -> Satoshi + -- ^ Value of the second-stage output. + -> RevocationPubkey + -> ToSelfDelay + -> LocalDelayedPubkey + -> Script + -- ^ Destination scriptPubKey. + -> FeeratePerKw + -> SpendingTx +spend_htlc_output = spend_to_local + +-- remote commitment spends ------------------------------------------- + +-- | Spend an offered HTLC directly after timeout on the remote +-- commitment. +-- +-- On the remote commitment, their received HTLCs (our offered) +-- have timed out and we can sweep them directly. +spend_remote_htlc_timeout + :: OutPoint + -- ^ Outpoint of the HTLC output. + -> Satoshi + -- ^ Value of the HTLC output. + -> HTLC + -- ^ The HTLC being spent. + -> CommitmentKeys + -- ^ Keys for the remote commitment. + -> ChannelFeatures + -> Script + -- ^ Destination scriptPubKey. + -> FeeratePerKw + -> SpendingTx +spend_remote_htlc_timeout !op !value !htlc !keys + !features !destScript !feerate = + let !witnessScript = received_htlc_script + (ck_revocation_pubkey keys) + (ck_remote_htlc keys) + (ck_local_htlc keys) + (htlc_payment_hash htlc) + (htlc_cltv_expiry htlc) + features + !weight = accepted_htlc_penalty_input_weight + + penalty_tx_base_weight + !fee = spending_fee feerate weight + !outputValue = + Satoshi (unSatoshi value - unSatoshi fee) + !locktime = + unCltvExpiry (htlc_cltv_expiry htlc) + !seqNo = if has_anchors features then 1 else 0 + !tx = mk_spending_tx op seqNo destScript + outputValue locktime + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- | Spend a received HTLC directly with preimage on the remote +-- commitment. +-- +-- On the remote commitment, their offered HTLCs (our received) +-- can be claimed with the payment preimage. +spend_remote_htlc_preimage + :: OutPoint + -- ^ Outpoint of the HTLC output. + -> Satoshi + -- ^ Value of the HTLC output. + -> HTLC + -- ^ The HTLC being spent. + -> CommitmentKeys + -- ^ Keys for the remote commitment. + -> ChannelFeatures + -> Script + -- ^ Destination scriptPubKey. + -> FeeratePerKw + -> SpendingTx +spend_remote_htlc_preimage !op !value !htlc !keys + !features !destScript !feerate = + let !witnessScript = offered_htlc_script + (ck_revocation_pubkey keys) + (ck_remote_htlc keys) + (ck_local_htlc keys) + (htlc_payment_hash htlc) + features + !weight = offered_htlc_penalty_input_weight + + penalty_tx_base_weight + !fee = spending_fee feerate weight + !outputValue = + Satoshi (unSatoshi value - unSatoshi fee) + !seqNo = if has_anchors features then 1 else 0 + !tx = mk_spending_tx op seqNo destScript + outputValue 0 + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- revoked commitment spends ------------------------------------------ + +-- | Spend a revoked to_local output using the revocation key. +-- +-- The caller signs with the revocation privkey and uses +-- 'to_local_witness_revoke' from bolt3. +spend_revoked_to_local + :: OutPoint + -- ^ Outpoint of the to_local output. + -> Satoshi + -- ^ Value of the to_local output. + -> RevocationPubkey + -> ToSelfDelay + -> LocalDelayedPubkey + -> Script + -- ^ Destination scriptPubKey. + -> FeeratePerKw + -> SpendingTx +spend_revoked_to_local !op !value !revpk !delay + !delayedpk !destScript !feerate = + let !witnessScript = + to_local_script revpk delay delayedpk + !weight = to_local_penalty_input_weight + + penalty_tx_base_weight + !fee = spending_fee feerate weight + !outputValue = + Satoshi (unSatoshi value - unSatoshi fee) + !tx = mk_spending_tx op 0xFFFFFFFF destScript + outputValue 0 + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- | Spend a revoked HTLC output using the revocation key. +-- +-- The caller signs with the revocation privkey and uses +-- 'offered_htlc_witness_revoke' or +-- 'received_htlc_witness_revoke' from bolt3, depending on +-- the output type. +spend_revoked_htlc + :: OutPoint + -- ^ Outpoint of the HTLC output. + -> Satoshi + -- ^ Value of the HTLC output. + -> OutputType + -- ^ Whether offered or received HTLC. + -> RevocationPubkey + -> CommitmentKeys + -> ChannelFeatures + -> PaymentHash + -> Script + -- ^ Destination scriptPubKey. + -> FeeratePerKw + -> SpendingTx +spend_revoked_htlc !op !value !otype !revpk !keys + !features !ph !destScript !feerate = + let !(witnessScript, inputWeight) = case otype of + OutputOfferedHTLC _ -> + ( offered_htlc_script + revpk + (ck_remote_htlc keys) + (ck_local_htlc keys) + ph + features + , offered_htlc_penalty_input_weight + ) + OutputReceivedHTLC expiry -> + ( received_htlc_script + revpk + (ck_remote_htlc keys) + (ck_local_htlc keys) + ph + expiry + features + , accepted_htlc_penalty_input_weight + ) + _ -> + ( Script BS.empty + , 0 + ) + !weight = inputWeight + penalty_tx_base_weight + !fee = spending_fee feerate weight + !outputValue = + Satoshi (unSatoshi value - unSatoshi fee) + !tx = mk_spending_tx op 0xFFFFFFFF destScript + outputValue 0 + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- | Spend a revoked second-stage HTLC output (HTLC-timeout or +-- HTLC-success output) using the revocation key. +-- +-- The output of a revoked HTLC-timeout/success tx uses the +-- to_local script. The caller signs with the revocation privkey +-- and uses 'htlc_output_witness_revoke'. +spend_revoked_htlc_output + :: OutPoint + -- ^ Outpoint of the second-stage output. + -> Satoshi + -- ^ Value of the second-stage output. + -> RevocationPubkey + -> ToSelfDelay + -> LocalDelayedPubkey + -> Script + -- ^ Destination scriptPubKey. + -> FeeratePerKw + -> SpendingTx +spend_revoked_htlc_output !op !value !revpk !delay + !delayedpk !destScript !feerate = + let !witnessScript = + to_local_script revpk delay delayedpk + !weight = to_local_penalty_input_weight + + penalty_tx_base_weight + !fee = spending_fee feerate weight + !outputValue = + Satoshi (unSatoshi value - unSatoshi fee) + !tx = mk_spending_tx op 0xFFFFFFFF destScript + outputValue 0 + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- | Construct a batched penalty transaction spending multiple +-- revoked outputs. +-- +-- Per BOLT #5, up to 483 bidirectional HTLCs plus to_local can +-- be resolved in a single penalty transaction (within the +-- 400,000 weight limit). The caller signs each input with the +-- revocation privkey. +spend_revoked_batch :: PenaltyContext -> SpendingTx +spend_revoked_batch !ctx = + let !outs = pc_outputs ctx + !destScript = pc_destination ctx + !feerate = pc_feerate ctx + + -- Calculate total input value and weight + !(totalValue, totalWeight) = + go (Satoshi 0) penalty_tx_base_weight outs + + !fee = spending_fee feerate totalWeight + !outputValue = + Satoshi (unSatoshi totalValue - unSatoshi fee) + + -- Build inputs + !inputs = map mkPenaltyInput outs + !txInputs = case inputs of + [] -> error + "spend_revoked_batch: no outputs" + (i:is) -> i :| is + + -- Single output + !txOutput = TxOut + (unSatoshi outputValue) + (unScript destScript) + + !tx = Tx + { tx_version = 2 + , tx_inputs = txInputs + , tx_outputs = txOutput :| [] + , tx_witnesses = [] + , tx_locktime = 0 + } + + !witnessScript = Script BS.empty + in SpendingTx tx witnessScript totalValue SIGHASH_ALL + where + go !totalVal !totalWt [] = (totalVal, totalWt) + go !totalVal !totalWt (uo:rest) = + let !w = case uo_type uo of + Revoke _ -> + to_local_penalty_input_weight + RevokeHTLC _ (OutputOfferedHTLC _) -> + offered_htlc_penalty_input_weight + RevokeHTLC _ (OutputReceivedHTLC _) -> + accepted_htlc_penalty_input_weight + _ -> 0 + !v = Satoshi + (unSatoshi totalVal + unSatoshi (uo_value uo)) + in go v (totalWt + w) rest + + mkPenaltyInput !uo = + TxIn + { txin_prevout = uo_outpoint uo + , txin_script_sig = BS.empty + , txin_sequence = 0xFFFFFFFF + } + +-- anchor spends ------------------------------------------------------ + +-- | Spend an anchor output as the owner (immediately). +-- +-- The caller signs with the funding privkey and uses +-- 'anchor_witness_owner' from bolt3. +spend_anchor_owner + :: OutPoint + -- ^ Outpoint of the anchor output. + -> Satoshi + -- ^ Value of the anchor output (330 sats). + -> FundingPubkey + -> Script + -- ^ Destination scriptPubKey. + -> SpendingTx +spend_anchor_owner !op !value !fundpk !destScript = + let !witnessScript = anchor_script fundpk + !tx = mk_spending_tx op 0xFFFFFFFE destScript + value 0 + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- | Spend an anchor output as anyone (after 16 blocks). +-- +-- Uses 'anchor_witness_anyone' from bolt3 (empty signature). +spend_anchor_anyone + :: OutPoint + -- ^ Outpoint of the anchor output. + -> Satoshi + -- ^ Value of the anchor output (330 sats). + -> FundingPubkey + -> Script + -- ^ Destination scriptPubKey. + -> SpendingTx +spend_anchor_anyone !op !value !fundpk !destScript = + let !witnessScript = anchor_script fundpk + !tx = mk_spending_tx op 16 destScript value 0 + in SpendingTx tx witnessScript value SIGHASH_ALL + +-- internal helpers --------------------------------------------------- + +-- | Build a simple single-input single-output spending tx. +mk_spending_tx + :: OutPoint -- ^ Input outpoint + -> Word32 -- ^ Input nSequence + -> Script -- ^ Output scriptPubKey + -> Satoshi -- ^ Output value + -> Word32 -- ^ Locktime + -> Tx +mk_spending_tx !op !seqNo !destScript !outputValue + !locktime = + let !txIn = TxIn + { txin_prevout = op + , txin_script_sig = BS.empty + , txin_sequence = seqNo + } + !txOut = TxOut + { txout_value = unSatoshi outputValue + , txout_script_pubkey = unScript destScript + } + in Tx + { tx_version = 2 + , tx_inputs = txIn :| [] + , tx_outputs = txOut :| [] + , tx_witnesses = [] + , tx_locktime = locktime + } + +-- | Convert a bolt3 HTLCTx to a ppad-tx Tx. +htlc_tx_to_tx :: HTLCTx -> Tx +htlc_tx_to_tx !htx = + let !txIn = TxIn + { txin_prevout = htx_input_outpoint htx + , txin_script_sig = BS.empty + , txin_sequence = + unSequence (htx_input_sequence htx) + } + !txOut = TxOut + { txout_value = + unSatoshi (htx_output_value htx) + , txout_script_pubkey = + unScript (htx_output_script htx) + } + in Tx + { tx_version = htx_version htx + , tx_inputs = txIn :| [] + , tx_outputs = txOut :| [] + , tx_witnesses = [] + , tx_locktime = + unLocktime (htx_locktime htx) + } diff --git a/lib/Lightning/Protocol/BOLT5/Types.hs b/lib/Lightning/Protocol/BOLT5/Types.hs @@ -0,0 +1,194 @@ +{-# OPTIONS_HADDOCK prune #-} +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DeriveGeneric #-} + +-- | +-- Module: Lightning.Protocol.BOLT5.Types +-- Copyright: (c) 2025 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Types for BOLT #5 on-chain transaction handling. + +module Lightning.Protocol.BOLT5.Types ( + -- * Close identification + CloseType(..) + + -- * Output classification + , UnresolvedOutput(..) + , OutputResolution(..) + + -- * Spending transactions + , SpendingTx(..) + + -- * Penalty batching + , PenaltyContext(..) + + -- * Weight constants (Appendix A) + , to_local_penalty_witness_weight + , offered_htlc_penalty_witness_weight + , accepted_htlc_penalty_witness_weight + , to_local_penalty_input_weight + , offered_htlc_penalty_input_weight + , accepted_htlc_penalty_input_weight + , to_remote_input_weight + , penalty_tx_base_weight + , max_standard_weight + + -- * Fee calculation + , spending_fee + ) where + +import Bitcoin.Prim.Tx (Tx(..)) +import Bitcoin.Prim.Tx.Sighash (SighashType(..)) +import Data.Word (Word64) +import GHC.Generics (Generic) +import Lightning.Protocol.BOLT3.Types +import Lightning.Protocol.BOLT3.Tx ( + CommitmentKeys(..) + , OutputType(..) + ) + +-- close identification ----------------------------------------------- + +-- | What kind of close was detected on chain. +data CloseType + = MutualClose + -- ^ Cooperative closure agreed by both parties. + | LocalCommitClose + -- ^ Our commitment transaction was broadcast. + | RemoteCommitClose + -- ^ The remote party's commitment transaction was broadcast. + | RevokedCommitClose + -- ^ A revoked (outdated) commitment transaction was broadcast. + deriving (Eq, Show, Generic) + +-- output classification ---------------------------------------------- + +-- | An unresolved commitment transaction output. +data UnresolvedOutput = UnresolvedOutput + { uo_outpoint :: !OutPoint + , uo_value :: {-# UNPACK #-} !Satoshi + , uo_type :: !OutputResolution + } deriving (Eq, Show, Generic) + +-- | How to resolve an output, per BOLT #5 rules. +data OutputResolution + = Resolved + -- ^ Already resolved (e.g. to_remote on local commit). + | SpendToLocal + !ToSelfDelay !RevocationPubkey !LocalDelayedPubkey + -- ^ Spend to_local after CSV delay. + | SpendHTLCTimeout + !HTLC !CommitmentKeys !ChannelFeatures + -- ^ Spend via HTLC-timeout second-stage tx (local commit, + -- local offer). + | SpendHTLCSuccess + !HTLC !CommitmentKeys !ChannelFeatures + -- ^ Spend via HTLC-success second-stage tx (local commit, + -- remote offer). + | SpendHTLCTimeoutDirect !HTLC + -- ^ Spend HTLC directly after timeout (remote commit, + -- local offer). + | SpendHTLCPreimageDirect !HTLC + -- ^ Spend HTLC directly with preimage (remote commit, + -- remote offer). + | SpendSecondStage + !ToSelfDelay !RevocationPubkey !LocalDelayedPubkey + -- ^ Spend second-stage HTLC output after CSV delay. + | Revoke !RevocationPubkey + -- ^ Spend revoked to_local with revocation key. + | RevokeHTLC !RevocationPubkey !OutputType + -- ^ Spend revoked HTLC output with revocation key. + | AnchorSpend !FundingPubkey + -- ^ Spend anchor output. + deriving (Eq, Show, Generic) + +-- spending transactions ---------------------------------------------- + +-- | Unsigned spending transaction, ready for caller to sign. +-- +-- The caller uses bolt3 witness constructors to assemble the +-- final witness after signing. +data SpendingTx = SpendingTx + { stx_tx :: !Tx + -- ^ The unsigned transaction. + , stx_input_script :: !Script + -- ^ Witness script for the input being spent. + , stx_input_value :: {-# UNPACK #-} !Satoshi + -- ^ Value of the input being spent (for sighash). + , stx_sighash_type :: !SighashType + -- ^ Sighash type to use when signing. + } deriving (Eq, Show, Generic) + +-- penalty batching --------------------------------------------------- + +-- | Context for constructing batched penalty transactions. +data PenaltyContext = PenaltyContext + { pc_outputs :: ![UnresolvedOutput] + -- ^ Revoked outputs to sweep. + , pc_revocation_key :: !RevocationPubkey + -- ^ Revocation pubkey for all outputs. + , pc_destination :: !Script + -- ^ Destination scriptPubKey. + , pc_feerate :: !FeeratePerKw + -- ^ Fee rate for the penalty transaction. + } deriving (Eq, Show, Generic) + +-- weight constants (BOLT #5 Appendix A) ------------------------------ + +-- | Expected weight of the to_local penalty transaction witness +-- (160 bytes). +to_local_penalty_witness_weight :: Word64 +to_local_penalty_witness_weight = 160 + +-- | Expected weight of the offered_htlc penalty transaction +-- witness (243 bytes). +offered_htlc_penalty_witness_weight :: Word64 +offered_htlc_penalty_witness_weight = 243 + +-- | Expected weight of the accepted_htlc penalty transaction +-- witness (249 bytes). +accepted_htlc_penalty_witness_weight :: Word64 +accepted_htlc_penalty_witness_weight = 249 + +-- | Weight of a to_local penalty input (164 + 160 = 324 bytes). +to_local_penalty_input_weight :: Word64 +to_local_penalty_input_weight = 324 + +-- | Weight of an offered_htlc penalty input +-- (164 + 243 = 407 bytes). +offered_htlc_penalty_input_weight :: Word64 +offered_htlc_penalty_input_weight = 407 + +-- | Weight of an accepted_htlc penalty input +-- (164 + 249 = 413 bytes). +accepted_htlc_penalty_input_weight :: Word64 +accepted_htlc_penalty_input_weight = 413 + +-- | Weight of a to_remote P2WPKH input +-- (108 + 164 = 272 bytes). +to_remote_input_weight :: Word64 +to_remote_input_weight = 272 + +-- | Base weight of a penalty transaction (4*53 + 2 = 214 bytes). +-- +-- Non-witness: version(4) + input_count(1) + output_count(1) + +-- value(8) + script_len(1) + p2wsh_script(34) + locktime(4) = 53 +-- Witness header: 2 bytes. +penalty_tx_base_weight :: Word64 +penalty_tx_base_weight = 214 + +-- | Maximum standard transaction weight (400,000 bytes). +max_standard_weight :: Word64 +max_standard_weight = 400000 + +-- fee calculation ---------------------------------------------------- + +-- | Calculate the fee for a spending transaction given its weight. +-- +-- @fee = feerate_per_kw * weight / 1000@ +spending_fee :: FeeratePerKw -> Word64 -> Satoshi +spending_fee (FeeratePerKw !rate) !weight = + Satoshi ((fromIntegral rate * weight) `div` 1000) +{-# INLINE spending_fee #-}