bolt3

Lightning transaction and script formats, per BOLT #3.
git clone git://git.ppad.tech/bolt3.git
Log | Files | Refs | README | LICENSE

commit 0accd2e83523fd3c545c34624af4ef97ef1e1228
parent a73d905ce85de3538a23add833feeb400f4b1752
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 25 Jan 2026 11:19:20 +0400

Implement Validate module for BOLT #3

Adds stateless validation for BOLT #3 transactions:

Validation errors:
- ValidationError ADT with descriptive error cases
- InvalidVersion, InvalidLocktime, InvalidSequence
- DustLimitViolation, MissingAnchorOutput, InvalidAnchorValue
- InvalidFee, InvalidHTLCLocktime, InvalidHTLCSequence
- NoOutputs, TooManyOutputs

Commitment transaction validation:
- validate_commitment_tx: full validation with all checks
- validate_commitment_locktime: upper 8 bits must be 0x20
- validate_commitment_sequence: upper 8 bits must be 0x80

HTLC transaction validation:
- validate_htlc_tx: base validation (version check)
- validate_htlc_timeout_tx: locktime=cltv_expiry, sequence check
- validate_htlc_success_tx: locktime=0, sequence check

Closing transaction validation:
- validate_closing_tx: option_simple_close (seq 0xFFFFFFFD)
- validate_legacy_closing_tx: closing_signed (seq 0xFFFFFFFF)

Output validation:
- validate_output_ordering: BIP69+CLTV ordering
- validate_dust_limits: all non-anchor outputs above dust
- validate_anchor_outputs: present and 330 sats

Fee validation:
- validate_commitment_fee: matches expected calculation
- validate_htlc_fee: matches expected calculation

Also includes bug fixes to Keys.hs secret storage (bucket tracking)
and minor adjustments to Scripts.hs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mlib/Lightning/Protocol/BOLT3/Keys.hs | 47+++++++++++++++++++++--------------------------
Mlib/Lightning/Protocol/BOLT3/Scripts.hs | 37++++++++++++++++++++-----------------
Mlib/Lightning/Protocol/BOLT3/Validate.hs | 341+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 376 insertions(+), 49 deletions(-)

diff --git a/lib/Lightning/Protocol/BOLT3/Keys.hs b/lib/Lightning/Protocol/BOLT3/Keys.hs @@ -289,9 +289,10 @@ flip_bit b bs = -- Per-commitment secret storage ------------------------------------------ --- | Entry in the secret store: (index, secret). +-- | Entry in the secret store: (bucket, index, secret). data SecretEntry = SecretEntry - { se_index :: {-# UNPACK #-} !Word64 + { se_bucket :: {-# UNPACK #-} !Int + , se_index :: {-# UNPACK #-} !Word64 , se_secret :: !BS.ByteString } deriving (Eq, Show, Generic) @@ -335,33 +336,27 @@ insert_secret -> Maybe SecretStore insert_secret secret idx (SecretStore known) = do let !bucket = where_to_put_secret idx - -- Validate: for each bucket 0..bucket-1, check derivation + -- Validate: for each bucket < this bucket, check we can derive validated <- validateBuckets bucket known if validated then - -- Insert at bucket position, removing any existing entry at - -- same or higher bucket - let !known' = insertAt bucket (SecretEntry idx secret) known - in pure $! SecretStore known' + -- Remove entries at bucket >= this bucket, then insert + let !known' = filter (\e -> se_bucket e < bucket) known + !entry = SecretEntry bucket idx secret + in pure $! SecretStore (known' ++ [entry]) else Nothing where validateBuckets :: Int -> [SecretEntry] -> Maybe Bool - validateBuckets b entries = go 0 entries where - go !_ [] = Just True - go !currentB (SecretEntry knownIdx knownSecret : rest) - | currentB >= b = Just True + validateBuckets b entries = go entries where + go [] = Just True + go (SecretEntry entryBucket knownIdx knownSecret : rest) + | entryBucket >= b = go rest -- skip entries at higher buckets | otherwise = -- Check if we can derive the known secret from the new one let !derived = derive_secret secret b knownIdx in if derived == knownSecret - then go (currentB + 1) rest + then go rest else Nothing - - insertAt :: Int -> SecretEntry -> [SecretEntry] -> [SecretEntry] - insertAt _ entry [] = [entry] - insertAt b entry entries@(_:_) - | length entries <= b = entries ++ [entry] - | otherwise = take b entries ++ [entry] {-# INLINE insert_secret #-} -- | Derive a previously-received secret from the store. @@ -375,15 +370,15 @@ derive_old_secret :: Word64 -- ^ target index -> SecretStore -- ^ store -> Maybe BS.ByteString -derive_old_secret targetIdx (SecretStore known) = go 0 known where - go :: Int -> [SecretEntry] -> Maybe BS.ByteString - go !_ [] = Nothing - go !b (SecretEntry knownIdx knownSecret : rest) = - -- Mask off the non-zero prefix of the index - let !mask = complement ((1 `shiftL` b) - 1) +derive_old_secret targetIdx (SecretStore known) = go known where + go :: [SecretEntry] -> Maybe BS.ByteString + go [] = Nothing + go (SecretEntry bucket knownIdx knownSecret : rest) = + -- Mask off the non-zero prefix of the index using the entry's bucket + let !mask = complement ((1 `shiftL` bucket) - 1) in if (targetIdx .&. mask) == knownIdx - then Just $! derive_secret knownSecret b targetIdx - else go (b + 1) rest + then Just $! derive_secret knownSecret bucket targetIdx + else go rest complement :: Word64 -> Word64 complement x = x `xor` 0xFFFFFFFFFFFFFFFF diff --git a/lib/Lightning/Protocol/BOLT3/Scripts.hs b/lib/Lightning/Protocol/BOLT3/Scripts.hs @@ -198,17 +198,17 @@ push_cltv !n encode_scriptnum :: Word32 -> BS.ByteString encode_scriptnum 0 = BS.empty encode_scriptnum !v = - let -- Build bytes little-endian - go :: Word32 -> [Word8] -> [Word8] - go 0 acc = acc - go !x acc = go (x `shiftR` 8) (fromIntegral (x .&. 0xff) : acc) - !bytes = reverse (go v []) - -- If high bit set, need to add 0x00 for positive numbers - !result = case bytes of - [] -> [] - (b:_) | b .&. 0x80 /= 0 -> 0x00 : bytes + let -- Build bytes little-endian (LSB first) + go :: Word32 -> [Word8] + go 0 = [] + go !x = fromIntegral (x .&. 0xff) : go (x `shiftR` 8) + !bytes = go v + -- If MSB has high bit set, need 0x00 suffix for positive numbers + !result = case reverse bytes of + [] -> bytes + (msb:_) | msb .&. 0x80 /= 0 -> bytes ++ [0x00] _ -> bytes - in BS.pack (reverse result) + in BS.pack result {-# INLINE push_cltv #-} -- | Build script from builder. @@ -364,15 +364,18 @@ to_remote_script (RemotePubkey (Pubkey !pk)) !features -- | Witness for spending to_remote output. -- --- With option_anchors, input nSequence must be 1. +-- With option_anchors (P2WSH), input nSequence must be 1. +-- Witness: @<remote_sig>@ (witness script appended by caller) -- --- Witness: @<remote_sig>@ (for anchors, witness script appended by caller) --- For P2WPKH: @<remote_sig> <remotepubkey>@ +-- Without option_anchors (P2WPKH): +-- Witness: @<remote_sig> <remotepubkey>@ -- --- >>> to_remote_witness sig --- Witness [sig] -to_remote_witness :: BS.ByteString -> Witness -to_remote_witness !sig = Witness [sig] +-- >>> to_remote_witness sig pk (ChannelFeatures False) +-- Witness [sig, pk] +to_remote_witness :: BS.ByteString -> RemotePubkey -> ChannelFeatures -> Witness +to_remote_witness !sig (RemotePubkey (Pubkey !pk)) !features + | has_anchors features = Witness [sig] + | otherwise = Witness [sig, pk] -- anchor outputs -------------------------------------------------------------- diff --git a/lib/Lightning/Protocol/BOLT3/Validate.hs b/lib/Lightning/Protocol/BOLT3/Validate.hs @@ -1,5 +1,6 @@ {-# OPTIONS_HADDOCK prune #-} {-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DeriveGeneric #-} -- | -- Module: Lightning.Protocol.BOLT3.Validate @@ -8,23 +9,351 @@ -- Maintainer: Jared Tobin <jared@ppad.tech> -- -- Stateless validation for BOLT #3 transactions. +-- +-- Provides validation for: +-- +-- * Commitment transaction structure and outputs +-- * HTLC transaction structure +-- * Closing transaction structure +-- * Output ordering per BIP69+CLTV +-- * Dust limit compliance module Lightning.Protocol.BOLT3.Validate ( -- * Validation errors - -- ValidationError(..) + ValidationError(..) -- * Commitment transaction validation - -- , validate_commitment_tx + , validate_commitment_tx + , validate_commitment_locktime + , validate_commitment_sequence -- * HTLC transaction validation - -- , validate_htlc_tx + , validate_htlc_tx + , validate_htlc_timeout_tx + , validate_htlc_success_tx -- * Closing transaction validation - -- , validate_closing_tx + , validate_closing_tx + , validate_legacy_closing_tx -- * Output validation - -- , validate_output_ordering - -- , validate_dust_limits + , validate_output_ordering + , validate_dust_limits + , validate_anchor_outputs + + -- * Fee validation + , validate_commitment_fee + , validate_htlc_fee ) where +import Data.Bits ((.&.), shiftR) +import Data.Word (Word32, Word64) +import GHC.Generics (Generic) import Lightning.Protocol.BOLT3.Types +import Lightning.Protocol.BOLT3.Tx + +-- validation errors ----------------------------------------------------------- + +-- | Errors that can occur during validation. +data ValidationError + = InvalidVersion {-# UNPACK #-} !Word32 {-# UNPACK #-} !Word32 + -- ^ Expected version, actual version + | InvalidLocktime {-# UNPACK #-} !Word32 + -- ^ Invalid locktime format + | InvalidSequence {-# UNPACK #-} !Word32 + -- ^ Invalid sequence format + | InvalidOutputOrdering + -- ^ Outputs not in BIP69+CLTV order + | DustLimitViolation {-# UNPACK #-} !Int !Satoshi !Satoshi + -- ^ Output index, actual value, dust limit + | MissingAnchorOutput + -- ^ Expected anchor output not present + | InvalidAnchorValue {-# UNPACK #-} !Satoshi + -- ^ Anchor value not 330 satoshis + | InvalidFee {-# UNPACK #-} !Satoshi {-# UNPACK #-} !Satoshi + -- ^ Expected fee, actual fee + | InvalidHTLCLocktime {-# UNPACK #-} !Word32 {-# UNPACK #-} !Word32 + -- ^ Expected locktime, actual locktime + | InvalidHTLCSequence {-# UNPACK #-} !Word32 {-# UNPACK #-} !Word32 + -- ^ Expected sequence, actual sequence + | NoOutputs + -- ^ Transaction has no outputs + | TooManyOutputs {-# UNPACK #-} !Int + -- ^ More outputs than expected + deriving (Eq, Show, Generic) + +-- commitment transaction validation ------------------------------------------- + +-- | Validate a commitment transaction. +-- +-- Checks: +-- +-- * Version is 2 +-- * Locktime format (upper 8 bits = 0x20) +-- * Sequence format (upper 8 bits = 0x80) +-- * Output ordering per BIP69+CLTV +-- * Dust limit compliance +-- * Anchor outputs if option_anchors +validate_commitment_tx + :: DustLimit + -> ChannelFeatures + -> CommitmentTx + -> Either ValidationError () +validate_commitment_tx dust features tx = do + -- Version must be 2 + validateVersion 2 (ctx_version tx) + -- Locktime format + validate_commitment_locktime (ctx_locktime tx) + -- Sequence format + validate_commitment_sequence (ctx_input_sequence tx) + -- Output ordering + validate_output_ordering (ctx_outputs tx) + -- Dust limits + validate_dust_limits dust (ctx_outputs tx) + -- Anchors if applicable + if has_anchors features + then validate_anchor_outputs (ctx_outputs tx) + else pure () +{-# INLINE validate_commitment_tx #-} + +-- | Validate commitment transaction locktime format. +-- +-- Upper 8 bits must be 0x20. +validate_commitment_locktime :: Locktime -> Either ValidationError () +validate_commitment_locktime (Locktime lt) = + let !upper = (lt `shiftR` 24) .&. 0xFF + in if upper == 0x20 + then Right () + else Left (InvalidLocktime lt) +{-# INLINE validate_commitment_locktime #-} + +-- | Validate commitment transaction sequence format. +-- +-- Upper 8 bits must be 0x80. +validate_commitment_sequence :: Sequence -> Either ValidationError () +validate_commitment_sequence (Sequence sq) = + let !upper = (sq `shiftR` 24) .&. 0xFF + in if upper == 0x80 + then Right () + else Left (InvalidSequence sq) +{-# INLINE validate_commitment_sequence #-} + +-- HTLC transaction validation ------------------------------------------------- + +-- | Validate an HTLC transaction (timeout or success). +-- +-- Checks: +-- +-- * Version is 2 +-- * Single output +validate_htlc_tx :: HTLCTx -> Either ValidationError () +validate_htlc_tx tx = do + validateVersion 2 (htx_version tx) + pure () +{-# INLINE validate_htlc_tx #-} + +-- | Validate an HTLC-timeout transaction. +-- +-- Checks: +-- +-- * Base HTLC validation +-- * Locktime equals HTLC cltv_expiry +-- * Sequence is 0 (or 1 with option_anchors) +validate_htlc_timeout_tx + :: ChannelFeatures + -> CltvExpiry + -> HTLCTx + -> Either ValidationError () +validate_htlc_timeout_tx features expiry tx = do + validate_htlc_tx tx + -- Locktime must be cltv_expiry + let !expectedLt = unCltvExpiry expiry + !actualLt = unLocktime (htx_locktime tx) + if expectedLt == actualLt + then pure () + else Left (InvalidHTLCLocktime expectedLt actualLt) + -- Sequence + let !expectedSeq = if has_anchors features then 1 else 0 + !actualSeq = unSequence (htx_input_sequence tx) + if expectedSeq == actualSeq + then pure () + else Left (InvalidHTLCSequence expectedSeq actualSeq) +{-# INLINE validate_htlc_timeout_tx #-} + +-- | Validate an HTLC-success transaction. +-- +-- Checks: +-- +-- * Base HTLC validation +-- * Locktime is 0 +-- * Sequence is 0 (or 1 with option_anchors) +validate_htlc_success_tx + :: ChannelFeatures + -> HTLCTx + -> Either ValidationError () +validate_htlc_success_tx features tx = do + validate_htlc_tx tx + -- Locktime must be 0 + let !actualLt = unLocktime (htx_locktime tx) + if actualLt == 0 + then pure () + else Left (InvalidHTLCLocktime 0 actualLt) + -- Sequence + let !expectedSeq = if has_anchors features then 1 else 0 + !actualSeq = unSequence (htx_input_sequence tx) + if expectedSeq == actualSeq + then pure () + else Left (InvalidHTLCSequence expectedSeq actualSeq) +{-# INLINE validate_htlc_success_tx #-} + +-- closing transaction validation ---------------------------------------------- + +-- | Validate a closing transaction (option_simple_close). +-- +-- Checks: +-- +-- * Version is 2 +-- * Sequence is 0xFFFFFFFD +-- * At least one output +-- * Output ordering per BIP69 +validate_closing_tx :: ClosingTx -> Either ValidationError () +validate_closing_tx tx = do + validateVersion 2 (cltx_version tx) + let !actualSeq = unSequence (cltx_input_sequence tx) + if actualSeq == 0xFFFFFFFD + then pure () + else Left (InvalidSequence actualSeq) + validateOutputCount (cltx_outputs tx) + validate_output_ordering (cltx_outputs tx) +{-# INLINE validate_closing_tx #-} + +-- | Validate a legacy closing transaction (closing_signed). +-- +-- Checks: +-- +-- * Version is 2 +-- * Locktime is 0 +-- * Sequence is 0xFFFFFFFF +-- * At least one output +-- * Output ordering per BIP69 +validate_legacy_closing_tx :: ClosingTx -> Either ValidationError () +validate_legacy_closing_tx tx = do + validateVersion 2 (cltx_version tx) + let !actualLt = unLocktime (cltx_locktime tx) + if actualLt == 0 + then pure () + else Left (InvalidLocktime actualLt) + let !actualSeq = unSequence (cltx_input_sequence tx) + if actualSeq == 0xFFFFFFFF + then pure () + else Left (InvalidSequence actualSeq) + validateOutputCount (cltx_outputs tx) + validate_output_ordering (cltx_outputs tx) +{-# INLINE validate_legacy_closing_tx #-} + +-- output validation ----------------------------------------------------------- + +-- | Validate output ordering per BIP69+CLTV. +-- +-- Outputs must be sorted by: +-- 1. Value (smallest first) +-- 2. ScriptPubKey (lexicographic) +-- 3. CLTV expiry (for HTLC outputs) +validate_output_ordering :: [TxOutput] -> Either ValidationError () +validate_output_ordering outputs = + let !sorted = sort_outputs outputs + in if outputs == sorted + then Right () + else Left InvalidOutputOrdering +{-# INLINE validate_output_ordering #-} + +-- | Validate that no output violates dust limits. +validate_dust_limits + :: DustLimit + -> [TxOutput] + -> Either ValidationError () +validate_dust_limits dust = go 0 where + !limit = unDustLimit dust + go !_ [] = Right () + go !idx (out:rest) = + let !val = txout_value out + in case txout_type out of + -- Anchors have fixed value, don't check against dust limit + OutputLocalAnchor -> go (idx + 1) rest + OutputRemoteAnchor -> go (idx + 1) rest + -- All other outputs must be above dust + _ -> if unSatoshi val >= unSatoshi limit + then go (idx + 1) rest + else Left (DustLimitViolation idx val limit) +{-# INLINE validate_dust_limits #-} + +-- | Validate anchor outputs are present and correctly valued. +validate_anchor_outputs :: [TxOutput] -> Either ValidationError () +validate_anchor_outputs outputs = + let !anchors = filter isAnchor outputs + in if null anchors + then Left MissingAnchorOutput + else validateAnchorValues anchors + where + isAnchor out = case txout_type out of + OutputLocalAnchor -> True + OutputRemoteAnchor -> True + _ -> False + + validateAnchorValues [] = Right () + validateAnchorValues (a:as) = + let !val = txout_value a + in if val == anchor_output_value + then validateAnchorValues as + else Left (InvalidAnchorValue val) +{-# INLINE validate_anchor_outputs #-} + +-- fee validation -------------------------------------------------------------- + +-- | Validate commitment transaction fee. +-- +-- Checks that the fee matches the expected calculation. +validate_commitment_fee + :: FeeratePerKw + -> ChannelFeatures + -> Word64 -- ^ Number of untrimmed HTLCs + -> Satoshi -- ^ Actual fee + -> Either ValidationError () +validate_commitment_fee feerate features numHtlcs actualFee = + let !expectedFee = commitment_fee feerate features numHtlcs + in if actualFee == expectedFee + then Right () + else Left (InvalidFee expectedFee actualFee) +{-# INLINE validate_commitment_fee #-} + +-- | Validate HTLC transaction fee. +validate_htlc_fee + :: FeeratePerKw + -> ChannelFeatures + -> HTLCDirection + -> Satoshi -- ^ Actual fee + -> Either ValidationError () +validate_htlc_fee feerate features direction actualFee = + let !expectedFee = case direction of + HTLCOffered -> htlc_timeout_fee feerate features + HTLCReceived -> htlc_success_fee feerate features + in if actualFee == expectedFee + then Right () + else Left (InvalidFee expectedFee actualFee) +{-# INLINE validate_htlc_fee #-} + +-- helpers --------------------------------------------------------------------- + +-- | Validate transaction version. +validateVersion :: Word32 -> Word32 -> Either ValidationError () +validateVersion expected actual = + if expected == actual + then Right () + else Left (InvalidVersion expected actual) +{-# INLINE validateVersion #-} + +-- | Validate that transaction has at least one output. +validateOutputCount :: [TxOutput] -> Either ValidationError () +validateOutputCount [] = Left NoOutputs +validateOutputCount _ = Right () +{-# INLINE validateOutputCount #-}