bolt9

Lightning feature flags, per BOLT #9 (docs.ppad.tech/bolt9).
git clone git://git.ppad.tech/bolt9.git
Log | Files | Refs | README | LICENSE

commit 3beb830dccaebfcd243a38189187341717982289
parent b8ce66e205120a75884c8649b924ecc2df05d8b7
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 13:14:01 +0400

Merge fix/validation: remote dependencies, bit wrap, opposite bit

Fixes three validation bugs:

- validateRemote now checks that all known transitive dependencies of
  set features are present, as BOLT 1 (init) and BOLT 11 (invoices)
  require of the receiver.

- Bit positions derived from a vector (setBits, highestSetBit, and the
  bit fields of UnknownRequiredBit and InvalidParity) are now Int
  rather than Word16. A vector can be 65535 bytes long; bits at 65536
  and above previously wrapped onto low bits, so an unknown even bit
  could alias a known feature and pass remote validation.

- setFeature clears the other bit of the feature's pair, so the
  constructors (setFeature, setFeatureWithDeps, setFeatureForContext)
  can no longer produce a vector with both bits set.
  setFeatureWithDeps leaves already-set dependencies at their level.

Adds regression tests for each.

Diffstat:
Mlib/Lightning/Protocol/BOLT9/Codec.hs | 21+++++++++++++--------
Mlib/Lightning/Protocol/BOLT9/Validate.hs | 30+++++++++++++++++-------------
Mtest/Main.hs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 151 insertions(+), 23 deletions(-)

diff --git a/lib/Lightning/Protocol/BOLT9/Codec.hs b/lib/Lightning/Protocol/BOLT9/Codec.hs @@ -89,7 +89,8 @@ testBit !idx = member (bitIndex idx) -- | Set a feature's bit at the given level. -- --- 'Required' sets the even bit, 'Optional' sets the odd bit. +-- 'Required' sets the even bit, 'Optional' sets the odd bit. The +-- other bit of the pair is cleared, so both are never set. -- -- >>> import Data.Maybe (fromJust) -- >>> let mpp = fromJust (featureByName "basic_mpp") @@ -98,17 +99,17 @@ testBit !idx = member (bitIndex idx) -- >>> setFeature mpp Required empty -- set required bit (16) -- FeatureVector {unFeatureVector = "\SOH"} setFeature :: Feature -> FeatureLevel -> FeatureVector -> FeatureVector -setFeature !f !level = setBit targetBit +setFeature !f !level = case level of + Required -> setBit baseBit . clearBit (baseBit + 1) + Optional -> setBit (baseBit + 1) . clearBit baseBit where - !baseBit = featureBaseBit f - !targetBit = case level of - Required -> baseBit - Optional -> baseBit + 1 + !baseBit = featureBaseBit f {-# INLINE setFeature #-} -- | Set a feature and all its transitive dependencies. -- --- Dependencies are set at the same level as the feature itself. +-- Dependencies that are not yet set are set at the same level as +-- the feature itself; dependencies already set keep their level. -- Unknown dependencies (not in the known features table) are -- silently skipped. -- @@ -128,7 +129,11 @@ setFeatureWithDeps !f !level !fv = where setDep !depName !acc = case featureByName depName of Nothing -> acc - Just dep -> setFeatureWithDeps dep level acc + Just dep + | isFeatureSet dep acc -> + foldr setDep acc (featureDependencies dep) + | otherwise -> + setFeatureWithDeps dep level acc -- | Check if a feature is set in the vector. -- diff --git a/lib/Lightning/Protocol/BOLT9/Validate.hs b/lib/Lightning/Protocol/BOLT9/Validate.hs @@ -51,10 +51,10 @@ data ValidationError | ContextNotAllowed !String !Context -- ^ A feature is not allowed in the given context. -- Arguments: feature name, context. - | UnknownRequiredBit {-# UNPACK #-} !Word16 + | UnknownRequiredBit {-# UNPACK #-} !Int -- ^ An unknown required (even) bit is set (remote validation only). -- Argument: bit index. - | InvalidParity {-# UNPACK #-} !Word16 !Context + | InvalidParity {-# UNPACK #-} !Int !Context -- ^ A bit has invalid parity for a channel context. -- Arguments: bit index, context (ChanAnnOdd or ChanAnnEven). deriving (Eq, Show, Generic) @@ -180,9 +180,9 @@ setFeatureForContext !ctx !f !level !fv | otherwise = case channelParity ctx of Just True | level == Optional -> - Left (InvalidParity targetBit ctx) + Left (InvalidParity (fromIntegral targetBit) ctx) Just False | level == Required -> - Left (InvalidParity targetBit ctx) + Left (InvalidParity (fromIntegral targetBit) ctx) _ -> Right (setFeature f level fv) where !contexts = featureContexts f @@ -220,6 +220,7 @@ validateNoBothBits !fv = go knownFeatures -- * Unknown even (required) bits are errors -- * If both bits of a pair are set, treat as required (not an error) -- * Context restrictions still apply for known features +-- * All dependencies of set features are also set -- -- >>> import Lightning.Protocol.BOLT9.Codec (setBit) -- >>> validateRemote Init (setBit 999 empty) -- unknown odd bit: ok @@ -230,6 +231,7 @@ validateRemote :: Context -> FeatureVector -> Either [ValidationError] () validateRemote !ctx !fv = let errs = unknownRequiredErrors fv ++ contextErrors ctx fv + ++ dependencyErrors fv ++ parityErrors ctx fv in if null errs then Right () @@ -241,7 +243,9 @@ unknownRequiredErrors !fv = foldr check [] (setBits fv) where check !bit !acc | bit `mod` 2 == 1 = acc -- odd bit, optional, ignore - | otherwise = case featureByBit bit of + | bit > fromIntegral (maxBound :: Word16) = + UnknownRequiredBit bit : acc + | otherwise = case featureByBit (fromIntegral bit) of Just _ -> acc -- known feature Nothing -> UnknownRequiredBit bit : acc @@ -250,7 +254,7 @@ unknownRequiredErrors !fv = foldr check [] (setBits fv) -- | Find the highest set bit in a feature vector. -- -- Returns 'Nothing' if the vector is empty or has no bits set. -highestSetBit :: FeatureVector -> Maybe Word16 +highestSetBit :: FeatureVector -> Maybe Int highestSetBit !fv = let !bs = unFeatureVector fv in if BS.null bs @@ -258,7 +262,7 @@ highestSetBit !fv = else findHighestBit bs -- | Find the highest set bit in a non-empty ByteString. -findHighestBit :: ByteString -> Maybe Word16 +findHighestBit :: ByteString -> Maybe Int findHighestBit !bs = go 0 where !len = BS.length bs @@ -272,33 +276,33 @@ findHighestBit !bs = go 0 else let !bytePos = len - 1 - i !highBit = 7 - B.countLeadingZeros byte - !bitIdx = fromIntegral bytePos * 8 + fromIntegral highBit + !bitIdx = bytePos * 8 + highBit in Just bitIdx -- | Collect all set bits in a feature vector. -- -- Returns a list of bit indices in ascending order. -setBits :: FeatureVector -> [Word16] +setBits :: FeatureVector -> [Int] setBits !fv = let !bs = unFeatureVector fv !len = BS.length bs in collectBits bs len 0 [] -- | Collect bits from a ByteString into a list. -collectBits :: ByteString -> Int -> Int -> [Word16] -> [Word16] +collectBits :: ByteString -> Int -> Int -> [Int] -> [Int] collectBits !bs !len !i !acc | i >= len = acc | otherwise = let !byte = BS.index bs (len - 1 - i) - !baseIdx = fromIntegral i * 8 + !baseIdx = i * 8 !acc' = collectByteBits byte baseIdx acc in collectBits bs len (i + 1) acc' -- | Collect set bits from a single byte. -collectByteBits :: B.Bits a => a -> Word16 -> [Word16] -> [Word16] +collectByteBits :: B.Bits a => a -> Int -> [Int] -> [Int] collectByteBits !byte !baseIdx = go 7 where go !bit !acc | bit < 0 = acc - | B.testBit byte bit = go (bit - 1) ((baseIdx + fromIntegral bit) : acc) + | B.testBit byte bit = go (bit - 1) ((baseIdx + bit) : acc) | otherwise = go (bit - 1) acc diff --git a/test/Main.hs b/test/Main.hs @@ -6,7 +6,7 @@ module Main where import qualified Data.ByteString as BS import Data.Either (isLeft, isRight) import Data.Maybe (isJust, isNothing) -import Data.Word (Word16) +import Data.Word (Word8, Word16) import Lightning.Protocol.BOLT9 import Test.Tasty import Test.Tasty.HUnit @@ -25,6 +25,9 @@ tests = testGroup "ppad-bolt9" [ , setFeatureWithDepsTests , setFeatureForContextTests , validateNoBothBitsTests + , remoteDependencyTests + , longVectorTests + , oppositeBitTests , propertyTests ] @@ -417,6 +420,121 @@ validateNoBothBitsTests = testGroup "validateNoBothBits" [ isLeft (validateNoBothBits fv) @?= True ] +-- Remote dependency tests ----------------------------------------------------- + +remoteDependencyTests :: TestTree +remoteDependencyTests = testGroup "validateRemote dependencies" [ + testCase "rejects option_zeroconf without option_scid_alias" $ + validateRemote Init (setBit 51 empty) @?= + Left [MissingDependency "option_zeroconf" "option_scid_alias"] + + , testCase "rejects basic_mpp without payment_secret (init)" $ + validateRemote Init (setBit 17 empty) @?= + Left [MissingDependency "basic_mpp" "payment_secret"] + + , testCase "rejects basic_mpp without payment_secret (invoice)" $ + validateRemote Invoice (setBit 17 empty) @?= + Left [MissingDependency "basic_mpp" "payment_secret"] + + , testCase "rejects option_simple_close without anysegwit" $ + validateRemote Init (setBit 60 empty) @?= + Left [ MissingDependency + "option_simple_close" "option_shutdown_anysegwit" ] + + , testCase "accepts features with dependencies set" $ do + validateRemote Init (setBit 51 (setBit 47 empty)) @?= Right () + validateRemote Invoice (setBit 17 (setBit 14 empty)) @?= Right () + ] + +-- Long vector tests ----------------------------------------------------------- + +-- | A vector of (n + 1) bytes whose most significant byte is b. +longVector :: Int -> Word8 -> FeatureVector +longVector n b = parse (BS.cons b (BS.replicate n 0)) + +longVectorTests :: TestTree +longVectorTests = testGroup "Long vectors" [ + testCase "bit 65536 is reported without wrapping" $ do + let fv = longVector 8192 0x01 + setBits fv @?= [65536] + highestSetBit fv @?= Just 65536 + validateRemote Init fv @?= Left [UnknownRequiredBit 65536] + + , testCase "bit 65538 is reported as itself" $ + validateRemote Init (longVector 8192 0x04) @?= + Left [UnknownRequiredBit 65538] + + , testCase "bit 65552 does not alias basic_mpp" $ + validateRemote Init (longVector 8194 0x01) @?= + Left [UnknownRequiredBit 65552] + + , testCase "max-length vector, top odd bit accepted" $ do + let fv = longVector 65534 0x80 + setBits fv @?= [524279] + highestSetBit fv @?= Just 524279 + validateRemote Init fv @?= Right () + + , testCase "max-length vector, top even bit rejected" $ + validateRemote Init (longVector 65534 0x40) @?= + Left [UnknownRequiredBit 524278] + ] + +-- Opposite bit tests ---------------------------------------------------------- + +-- | Look up a known feature by name, failing the test if absent. +knownFeature :: String -> IO Feature +knownFeature n = case featureByName n of + Nothing -> assertFailure ("feature not found: " ++ n) + Just f -> pure f + +oppositeBitTests :: TestTree +oppositeBitTests = testGroup "Opposite bit" [ + testCase "setFeature Optional clears the required bit" $ do + ps <- knownFeature "payment_secret" + let fv = setFeature ps Optional (setFeature ps Required empty) + setBits fv @?= [15] + hasFeature ps fv @?= Just Optional + + , testCase "setFeature Required clears the optional bit" $ do + ps <- knownFeature "payment_secret" + let fv = setFeature ps Required (setBit 15 empty) + setBits fv @?= [14] + hasFeature ps fv @?= Just Required + + , testCase "setFeatureWithDeps keeps a set dependency's level" $ do + ps <- knownFeature "payment_secret" + mpp <- knownFeature "basic_mpp" + let fv = setFeatureWithDeps mpp Optional + (setFeature ps Required empty) + hasFeature ps fv @?= Just Required + hasFeature mpp fv @?= Just Optional + validateLocal Init fv @?= Right () + + , testCase "setFeatureForContext clears the opposite bit" $ do + anc <- knownFeature "option_anchors" + let start = setFeature anc Required empty + case setFeatureForContext Init anc Optional start of + Left e -> assertFailure (show e) + Right fv -> do + hasFeature anc fv @?= Just Optional + validateNoBothBits fv @?= Right fv + + , testProperty "setFeature never sets both bits (any start)" $ + forAll genFeatureAndLevel $ \(f, lvl) bs -> + let fv = setFeature f lvl (parse (BS.pack bs)) + b = featureBaseBit f + in not (testBit b fv && testBit (b + 1) fv) + && hasFeature f fv == Just lvl + + , testProperty "setFeature leaves other bits alone" $ + forAll genFeatureAndLevel $ \(f, lvl) bs -> + let fv0 = parse (BS.pack bs) + fv = setFeature f lvl fv0 + b = fromIntegral (featureBaseBit f) + other = filter (\i -> i /= b && i /= b + 1) + in other (setBits fv) === other (setBits fv0) + ] + isInvalidParity :: ValidationError -> Bool isInvalidParity (InvalidParity _ _) = True isInvalidParity _ = False @@ -461,7 +579,8 @@ propertyTests = testGroup "Properties" [ , testProperty "member consistent with setBits" $ \bs -> let fv = parse (BS.pack bs) bits = setBits fv - in all (\b -> member (bitIndex b) fv) bits === True + in all (\b -> member (bitIndex (fromIntegral b)) fv) bits + === True , testProperty "requiredBit only succeeds for even" $ \(n :: Word16) -> isJust (requiredBit n) === (n `mod` 2 == 0)