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 698b5a1cb601f52c8668d2e4342459682c5b2db2
parent 56ce6c5246ce245dae96cd3a1d2af8fb538af01d
Author: Jared Tobin <jared@jtobin.io>
Date:   Mon, 20 Apr 2026 15:35:45 +0800

merge: type safety improvements - HTLCOutputType, RevokedOutput, Internal module

Diffstat:
Mbench/Main.hs | 20++++++++++++++++----
Mbench/Weight.hs | 23+++++++++++++++++++----
Mflake.lock | 16++++++++--------
Mlib/Lightning/Protocol/BOLT5.hs | 8++++++++
Mlib/Lightning/Protocol/BOLT5/Detect.hs | 8++++----
Alib/Lightning/Protocol/BOLT5/Internal.hs | 27+++++++++++++++++++++++++++
Mlib/Lightning/Protocol/BOLT5/Spend.hs | 38++++++++++++++++----------------------
Mlib/Lightning/Protocol/BOLT5/Types.hs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mppad-bolt5.cabal | 1+
Mtest/Main.hs | 81++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
10 files changed, 221 insertions(+), 80 deletions(-)

diff --git a/bench/Main.hs b/bench/Main.hs @@ -66,10 +66,22 @@ instance NFData B5.OutputResolution where rnf (B5.Revoke rk) = rnf rk rnf _ = () +instance NFData B5.HTLCOutputType where + rnf (B5.HTLCOfferedOutput e) = rnf e + rnf (B5.HTLCReceivedOutput e) = rnf e + instance NFData B5.UnresolvedOutput where rnf (B5.UnresolvedOutput op v t) = rnf op `seq` rnf v `seq` rnf t +instance NFData B5.RevokedOutputType where + rnf B5.RevokedToLocal = () + rnf (B5.RevokedHTLC h) = rnf h + +instance NFData B5.RevokedOutput where + rnf (B5.RevokedOutput op v t) = + rnf op `seq` rnf v `seq` rnf t + instance NFData B5.SpendingTx where rnf (B5.SpendingTx tx scr val sh) = rnf tx `seq` rnf scr `seq` rnf val `seq` rnf sh @@ -118,13 +130,13 @@ dummyFeerate = FeeratePerKw 253 dummyDelay :: ToSelfDelay dummyDelay = ToSelfDelay 144 -mkRevokedOutputs :: Int -> NE.NonEmpty B5.UnresolvedOutput +mkRevokedOutputs :: Int -> NE.NonEmpty B5.RevokedOutput mkRevokedOutputs n = - let uo i = B5.UnresolvedOutput + let ro i = B5.RevokedOutput (OutPoint dummyTxId (fromIntegral i)) (Satoshi 10000) - (B5.Revoke dummyRevPk) - in uo 0 NE.:| [ uo i | i <- [1..n-1] ] + B5.RevokedToLocal + in ro 0 NE.:| [ ro i | i <- [1..n-1] ] -- benchmarks --------------------------------------------------------- diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -43,6 +43,9 @@ instance NFData FeeratePerKw where instance NFData ToSelfDelay where rnf (ToSelfDelay x) = rnf x +instance NFData CltvExpiry where + rnf (CltvExpiry x) = rnf x + instance NFData SighashType instance NFData B5.OutputResolution where @@ -50,10 +53,22 @@ instance NFData B5.OutputResolution where rnf (B5.Revoke rk) = rnf rk rnf _ = () +instance NFData B5.HTLCOutputType where + rnf (B5.HTLCOfferedOutput e) = rnf e + rnf (B5.HTLCReceivedOutput e) = rnf e + instance NFData B5.UnresolvedOutput where rnf (B5.UnresolvedOutput op v t) = rnf op `seq` rnf v `seq` rnf t +instance NFData B5.RevokedOutputType where + rnf B5.RevokedToLocal = () + rnf (B5.RevokedHTLC h) = rnf h + +instance NFData B5.RevokedOutput where + rnf (B5.RevokedOutput op v t) = + rnf op `seq` rnf v `seq` rnf t + instance NFData B5.SpendingTx where rnf (B5.SpendingTx tx scr val sh) = rnf tx `seq` rnf scr `seq` rnf val `seq` rnf sh @@ -102,13 +117,13 @@ dummyFeerate = FeeratePerKw 253 dummyDelay :: ToSelfDelay dummyDelay = ToSelfDelay 144 -mkRevokedOutputs :: Int -> NE.NonEmpty B5.UnresolvedOutput +mkRevokedOutputs :: Int -> NE.NonEmpty B5.RevokedOutput mkRevokedOutputs n = - let uo i = B5.UnresolvedOutput + let ro i = B5.RevokedOutput (OutPoint dummyTxId (fromIntegral i)) (Satoshi 10000) - (B5.Revoke dummyRevPk) - in uo 0 NE.:| [ uo i | i <- [1..n-1] ] + B5.RevokedToLocal + in ro 0 NE.:| [ ro i | i <- [1..n-1] ] -- weights ------------------------------------------------------------ diff --git a/flake.lock b/flake.lock @@ -228,11 +228,11 @@ ] }, "locked": { - "lastModified": 1776570879, - "narHash": "sha256-XsgGBvYWL+sD7pDZoPPi4l39DE7GH7maNnhm8iUeB/E=", + "lastModified": 1776668614, + "narHash": "sha256-ZckuUOZHrSya8kn7aRizWIQtqTZMrhSoi2NX7BE2s90=", "ref": "master", - "rev": "20ea43188d781368e5e64c7c646285a6b0aaeb94", - "revCount": 27, + "rev": "580036e8f5cb22d423a205abeb15fca33307267c", + "revCount": 29, "type": "git", "url": "git://git.ppad.tech/bolt1.git" }, @@ -272,11 +272,11 @@ ] }, "locked": { - "lastModified": 1776572200, - "narHash": "sha256-d3+A3XTTvKl4McyXkeQT840kpRI+9mtcXWwJQLoEbT0=", + "lastModified": 1776669534, + "narHash": "sha256-clRaTQr9j/fWdK5mAZ0SO1x/8QEto/0ZxRcNcbJNyZ4=", "ref": "master", - "rev": "2113539a278a3b1ab8484a5b205d9388b758ddb6", - "revCount": 39, + "rev": "b930e58309f1afb27a72cb026c40991cfd9687c3", + "revCount": 44, "type": "git", "url": "git://git.ppad.tech/bolt3.git" }, diff --git a/lib/Lightning/Protocol/BOLT5.hs b/lib/Lightning/Protocol/BOLT5.hs @@ -52,11 +52,19 @@ module Lightning.Protocol.BOLT5 ( , UnresolvedOutput(..) , OutputResolution(..) + -- ** HTLC output type + , HTLCOutputType(..) + , htlcOutputType + , htlcOutputTypeWeight + -- ** Spending transactions , SpendingTx(..) -- ** Penalty batching + , RevokedOutput(..) + , RevokedOutputType(..) , PenaltyContext(..) + , revoked_output_weight -- * Weight constants (Appendix A) , to_local_penalty_witness_weight diff --git a/lib/Lightning/Protocol/BOLT5/Detect.hs b/lib/Lightning/Protocol/BOLT5/Detect.hs @@ -251,10 +251,10 @@ classifyRevokedOutput !txid !revpk !idx !out = Resolved -- Can be swept by anyone after 16 blocks OutputRemoteAnchor -> Resolved -- Our anchor - otype@(OutputOfferedHTLC _) -> - RevokeHTLC revpk otype - otype@(OutputReceivedHTLC _) -> - RevokeHTLC revpk otype + OutputOfferedHTLC expiry -> + RevokeHTLC revpk (HTLCOfferedOutput expiry) + OutputReceivedHTLC expiry -> + RevokeHTLC revpk (HTLCReceivedOutput expiry) in UnresolvedOutput op val resolution -- preimage extraction ------------------------------------------------ diff --git a/lib/Lightning/Protocol/BOLT5/Internal.hs b/lib/Lightning/Protocol/BOLT5/Internal.hs @@ -0,0 +1,27 @@ +{-# OPTIONS_HADDOCK hide #-} + +-- | +-- Module: Lightning.Protocol.BOLT5.Internal +-- Copyright: (c) 2025 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Internal definitions for BOLT #5. +-- +-- This module re-exports all constructors from the Types +-- module, including those not exported by the public API. +-- Use only in tests or trusted internal code. + +module Lightning.Protocol.BOLT5.Internal ( + -- * All type constructors (for test use) + CloseType(..) + , UnresolvedOutput(..) + , OutputResolution(..) + , HTLCOutputType(..) + , SpendingTx(..) + , RevokedOutput(..) + , RevokedOutputType(..) + , PenaltyContext(..) + ) where + +import Lightning.Protocol.BOLT5.Types diff --git a/lib/Lightning/Protocol/BOLT5/Spend.hs b/lib/Lightning/Protocol/BOLT5/Spend.hs @@ -306,14 +306,14 @@ spend_revoked_to_local !op !value !revpk !delay -- 'received_htlc_witness_revoke' from bolt3, depending on -- the output type. -- --- Returns 'Nothing' if the output type is not an HTLC, or --- if the fee would exceed the output value. +-- Returns 'Nothing' if the fee would exceed the output +-- value. spend_revoked_htlc :: OutPoint -- ^ Outpoint of the HTLC output. -> Satoshi -- ^ Value of the HTLC output. - -> OutputType + -> HTLCOutputType -- ^ Whether offered or received HTLC. -> RevocationPubkey -> CommitmentKeys @@ -323,10 +323,10 @@ spend_revoked_htlc -- ^ Destination scriptPubKey. -> FeeratePerKw -> Maybe SpendingTx -spend_revoked_htlc !op !value !otype !revpk !keys +spend_revoked_htlc !op !value !htype !revpk !keys !features !ph !destScript !feerate = - case otype of - OutputOfferedHTLC _ -> + case htype of + HTLCOfferedOutput _ -> let !witnessScript = offered_htlc_script revpk (ck_remote_htlc keys) @@ -346,7 +346,7 @@ spend_revoked_htlc !op !value !otype !revpk !keys destScript outputValue 0 in Just (SpendingTx tx witnessScript value SIGHASH_ALL) - OutputReceivedHTLC expiry -> + HTLCReceivedOutput expiry -> let !witnessScript = received_htlc_script revpk (ck_remote_htlc keys) @@ -367,7 +367,6 @@ spend_revoked_htlc !op !value !otype !revpk !keys destScript outputValue 0 in Just (SpendingTx tx witnessScript value SIGHASH_ALL) - _ -> Nothing -- | Spend a revoked second-stage HTLC output (HTLC-timeout or -- HTLC-success output) using the revocation key. @@ -413,8 +412,9 @@ spend_revoked_htlc_output !op !value !revpk !delay -- be resolved in a single penalty transaction (within the -- 400,000 weight limit). The caller signs each input with the -- revocation privkey. --- | Returns 'Nothing' if the total fee would exceed the --- total input value. +-- +-- Returns 'Nothing' if the total fee would exceed the total +-- input value. spend_revoked_batch :: PenaltyContext -> Maybe SpendingTx spend_revoked_batch !ctx = let !outs = pc_outputs ctx @@ -455,22 +455,16 @@ spend_revoked_batch !ctx = 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 + go !totalVal !totalWt (ro:rest) = + let !w = revoked_output_weight ro !v = Satoshi - (unSatoshi totalVal + unSatoshi (uo_value uo)) + (unSatoshi totalVal + + unSatoshi (ro_value ro)) in go v (totalWt + w) rest - mkPenaltyInput !uo = + mkPenaltyInput !ro = TxIn - { txin_prevout = uo_outpoint uo + { txin_prevout = ro_outpoint ro , txin_script_sig = BS.empty , txin_sequence = 0xFFFFFFFF } diff --git a/lib/Lightning/Protocol/BOLT5/Types.hs b/lib/Lightning/Protocol/BOLT5/Types.hs @@ -18,11 +18,19 @@ module Lightning.Protocol.BOLT5.Types ( , UnresolvedOutput(..) , OutputResolution(..) + -- * HTLC output type + , HTLCOutputType(..) + , htlcOutputType + , htlcOutputTypeWeight + -- * Spending transactions , SpendingTx(..) -- * Penalty batching + , RevokedOutput(..) + , RevokedOutputType(..) , PenaltyContext(..) + , revoked_output_weight -- * Weight constants (Appendix A) , to_local_penalty_witness_weight @@ -96,12 +104,50 @@ data OutputResolution -- remote offer). | Revoke !RevocationPubkey -- ^ Spend revoked to_local with revocation key. - | RevokeHTLC !RevocationPubkey !OutputType + | RevokeHTLC !RevocationPubkey !HTLCOutputType -- ^ Spend revoked HTLC output with revocation key. | AnchorSpend !FundingPubkey -- ^ Spend anchor output. deriving (Eq, Show, Generic) +-- HTLC output type ------------------------------------------------- + +-- | Type of HTLC output, restricted to only HTLC variants. +-- +-- Unlike bolt3's 'OutputType' which includes all six output +-- types, this ADT makes it impossible to confuse HTLC outputs +-- with to_local, to_remote, or anchor outputs. +data HTLCOutputType + = HTLCOfferedOutput {-# UNPACK #-} !CltvExpiry + -- ^ Offered HTLC output with CLTV expiry. + | HTLCReceivedOutput {-# UNPACK #-} !CltvExpiry + -- ^ Received HTLC output with CLTV expiry. + deriving (Eq, Show, Generic) + +-- | Extract an 'HTLCOutputType' from a bolt3 'OutputType'. +-- +-- Returns 'Nothing' for non-HTLC output types. +-- +-- >>> htlcOutputType (OutputOfferedHTLC (CltvExpiry 500)) +-- Just (HTLCOfferedOutput (CltvExpiry ...)) +-- >>> htlcOutputType OutputToLocal +-- Nothing +htlcOutputType :: OutputType -> Maybe HTLCOutputType +htlcOutputType (OutputOfferedHTLC e) = + Just (HTLCOfferedOutput e) +htlcOutputType (OutputReceivedHTLC e) = + Just (HTLCReceivedOutput e) +htlcOutputType _ = Nothing +{-# INLINE htlcOutputType #-} + +-- | Penalty input weight for an HTLC output type. +htlcOutputTypeWeight :: HTLCOutputType -> Word64 +htlcOutputTypeWeight (HTLCOfferedOutput _) = + offered_htlc_penalty_input_weight +htlcOutputTypeWeight (HTLCReceivedOutput _) = + accepted_htlc_penalty_input_weight +{-# INLINE htlcOutputTypeWeight #-} + -- spending transactions ---------------------------------------------- -- | Unsigned spending transaction, ready for caller to sign. @@ -121,9 +167,38 @@ data SpendingTx = SpendingTx -- penalty batching --------------------------------------------------- +-- | A revoked output that can be swept with a revocation key. +-- +-- This type restricts penalty transaction inputs to only +-- valid revocation targets: to_local outputs and HTLC outputs. +-- Other output types (to_remote, anchors) cannot appear in +-- penalty transactions. +data RevokedOutput = RevokedOutput + { ro_outpoint :: !OutPoint + , ro_value :: {-# UNPACK #-} !Satoshi + , ro_type :: !RevokedOutputType + } deriving (Eq, Show, Generic) + +-- | What kind of revoked output is being swept. +data RevokedOutputType + = RevokedToLocal + -- ^ Revoked to_local output. + | RevokedHTLC !HTLCOutputType + -- ^ Revoked HTLC output. + deriving (Eq, Show, Generic) + +-- | Penalty input weight for a revoked output. +revoked_output_weight :: RevokedOutput -> Word64 +revoked_output_weight !ro = case ro_type ro of + RevokedToLocal -> + to_local_penalty_input_weight + RevokedHTLC !htype -> + htlcOutputTypeWeight htype +{-# INLINE revoked_output_weight #-} + -- | Context for constructing batched penalty transactions. data PenaltyContext = PenaltyContext - { pc_outputs :: !(NonEmpty UnresolvedOutput) + { pc_outputs :: !(NonEmpty RevokedOutput) -- ^ Revoked outputs to sweep (must be non-empty). , pc_revocation_key :: !RevocationPubkey -- ^ Revocation pubkey for all outputs. diff --git a/ppad-bolt5.cabal b/ppad-bolt5.cabal @@ -26,6 +26,7 @@ library exposed-modules: Lightning.Protocol.BOLT5 Lightning.Protocol.BOLT5.Detect + Lightning.Protocol.BOLT5.Internal Lightning.Protocol.BOLT5.Spend Lightning.Protocol.BOLT5.Types build-depends: diff --git a/test/Main.hs b/test/Main.hs @@ -240,8 +240,8 @@ detect_tests = testGroup "Detect" [ preimage = dummyPreimage wit = Witness [sig, preimage] case B5.extract_preimage_offered wit of - Just (PaymentPreimage bs) -> - bs @?= preimage + Just pp -> + unPaymentPreimage pp @?= preimage Nothing -> assertFailure "expected preimage" @@ -263,8 +263,8 @@ detect_tests = testGroup "Detect" [ preimage = dummyPreimage wit = Witness [zero, remoteSig, localSig, preimage] case B5.extract_preimage_htlc_success wit of - Just (PaymentPreimage bs) -> - bs @?= preimage + Just pp -> + unPaymentPreimage pp @?= preimage Nothing -> assertFailure "expected preimage" @@ -415,13 +415,13 @@ classify_tests = testGroup "Classify" [ @?= B5.Revoke dummyRevocationPubkey B5.uo_type o2 @?= B5.Resolved case B5.uo_type o3 of - B5.RevokeHTLC _ (OutputOfferedHTLC _) -> + B5.RevokeHTLC _ (B5.HTLCOfferedOutput _) -> pure () other -> assertFailure $ "expected RevokeHTLC offered, got " <> show other case B5.uo_type o4 of - B5.RevokeHTLC _ (OutputReceivedHTLC _) -> + B5.RevokeHTLC _ (B5.HTLCReceivedOutput _) -> pure () other -> assertFailure $ "expected RevokeHTLC received, got " @@ -600,12 +600,12 @@ spend_tests = testGroup "Spend" [ , testCase "spend_revoked_batch total value" $ do let op1 = OutPoint dummyTxId 0 op2 = OutPoint dummyTxId 1 - uo1 = B5.UnresolvedOutput op1 (Satoshi 50000) - (B5.Revoke dummyRevocationPubkey) - uo2 = B5.UnresolvedOutput op2 (Satoshi 30000) - (B5.Revoke dummyRevocationPubkey) + ro1 = B5.RevokedOutput op1 (Satoshi 50000) + B5.RevokedToLocal + ro2 = B5.RevokedOutput op2 (Satoshi 30000) + B5.RevokedToLocal pctx = B5.PenaltyContext - { B5.pc_outputs = uo1 :| [uo2] + { B5.pc_outputs = ro1 :| [ro2] , B5.pc_revocation_key = dummyRevocationPubkey , B5.pc_destination = dummyDestScript @@ -623,11 +623,11 @@ spend_tests = testGroup "Spend" [ length (tx_inputs tx) @?= 2 , testCase "spend_revoked_batch single element" $ do - let uo = B5.UnresolvedOutput + let ro = B5.RevokedOutput dummyOutPoint (Satoshi 50000) - (B5.Revoke dummyRevocationPubkey) + B5.RevokedToLocal pctx = B5.PenaltyContext - { B5.pc_outputs = uo :| [] + { B5.pc_outputs = ro :| [] , B5.pc_revocation_key = dummyRevocationPubkey , B5.pc_destination = dummyDestScript @@ -643,19 +643,21 @@ spend_tests = testGroup "Spend" [ let op1 = OutPoint dummyTxId 0 op2 = OutPoint dummyTxId 1 op3 = OutPoint dummyTxId 2 - uo1 = B5.UnresolvedOutput op1 + ro1 = B5.RevokedOutput op1 (Satoshi 50000) - (B5.Revoke dummyRevocationPubkey) - uo2 = B5.UnresolvedOutput op2 + B5.RevokedToLocal + ro2 = B5.RevokedOutput op2 (Satoshi 10000) - (B5.RevokeHTLC dummyRevocationPubkey - (OutputOfferedHTLC (CltvExpiry 500000))) - uo3 = B5.UnresolvedOutput op3 + (B5.RevokedHTLC + (B5.HTLCOfferedOutput + (CltvExpiry 500000))) + ro3 = B5.RevokedOutput op3 (Satoshi 10000) - (B5.RevokeHTLC dummyRevocationPubkey - (OutputReceivedHTLC (CltvExpiry 500000))) + (B5.RevokedHTLC + (B5.HTLCReceivedOutput + (CltvExpiry 500000))) pctx = B5.PenaltyContext - { B5.pc_outputs = uo1 :| [uo2, uo3] + { B5.pc_outputs = ro1 :| [ro2, ro3] , B5.pc_revocation_key = dummyRevocationPubkey , B5.pc_destination = dummyDestScript @@ -675,11 +677,11 @@ spend_tests = testGroup "Spend" [ @?= Nothing , testCase "spend_revoked_batch fee underflow" $ do - let uo = B5.UnresolvedOutput + let ro = B5.RevokedOutput dummyOutPoint (Satoshi 1) - (B5.Revoke dummyRevocationPubkey) + B5.RevokedToLocal pctx = B5.PenaltyContext - { B5.pc_outputs = uo :| [] + { B5.pc_outputs = ro :| [] , B5.pc_revocation_key = dummyRevocationPubkey , B5.pc_destination = dummyDestScript @@ -810,7 +812,7 @@ revoked_htlc_spend_tests = testGroup "Revoked HTLC Spend" [ testCase "spend_revoked_htlc - offered" $ do case B5.spend_revoked_htlc dummyOutPoint (Satoshi 50000) - (OutputOfferedHTLC (CltvExpiry 500000)) + (B5.HTLCOfferedOutput (CltvExpiry 500000)) dummyRevocationPubkey dummyKeys dummyFeatures dummyPaymentHash dummyDestScript dummyFeerate of @@ -825,7 +827,8 @@ revoked_htlc_spend_tests = testGroup "Revoked HTLC Spend" [ , testCase "spend_revoked_htlc - received" $ do case B5.spend_revoked_htlc dummyOutPoint (Satoshi 50000) - (OutputReceivedHTLC (CltvExpiry 500000)) + (B5.HTLCReceivedOutput + (CltvExpiry 500000)) dummyRevocationPubkey dummyKeys dummyFeatures dummyPaymentHash dummyDestScript dummyFeerate of @@ -838,14 +841,20 @@ revoked_htlc_spend_tests = testGroup "Revoked HTLC Spend" [ assertBool "output < input" (outVal < 50000) - , testCase "spend_revoked_htlc - invalid type" $ do - B5.spend_revoked_htlc - dummyOutPoint (Satoshi 50000) - OutputToLocal - dummyRevocationPubkey dummyKeys - dummyFeatures dummyPaymentHash - dummyDestScript dummyFeerate - @?= Nothing + , testCase "htlcOutputType - valid HTLC types" $ do + B5.htlcOutputType + (OutputOfferedHTLC (CltvExpiry 500)) + @?= Just (B5.HTLCOfferedOutput (CltvExpiry 500)) + B5.htlcOutputType + (OutputReceivedHTLC (CltvExpiry 600)) + @?= Just (B5.HTLCReceivedOutput + (CltvExpiry 600)) + + , testCase "htlcOutputType - non-HTLC types" $ do + B5.htlcOutputType OutputToLocal @?= Nothing + B5.htlcOutputType OutputToRemote @?= Nothing + B5.htlcOutputType OutputLocalAnchor @?= Nothing + B5.htlcOutputType OutputRemoteAnchor @?= Nothing , testCase "spend_revoked_htlc_output structure" $ do stx <- assertJust "spend_revoked_htlc_output" $