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 5e927388a31a1ee047259c12fc5ce4984ecf83cc
parent 39bd0dcf9fe8bd5afba7a873e6f450245a69fe2d
Author: Jared Tobin <jared@jtobin.io>
Date:   Mon, 20 Apr 2026 14:56:42 +0800

Merge impl/type-safety: type system improvements

Add validated alternatives alongside the existing unvalidated API:

- KnownFeature newtype: wraps Feature with proof of membership
  in the BOLT #9 known features table. Smart constructors
  knownFeatureByBit and knownFeatureByName validate lookup.

- setFeatureWithDeps: sets a feature and all its transitive
  dependencies at the same level. Silently skips unknown deps.

- setFeatureForContext: validates context allowance and parity
  (ChanAnnOdd forces odd/Optional, ChanAnnEven forces
  even/Required) before setting. Returns Either ValidationError.

- validateNoBothBits: checks that no known feature has both its
  required and optional bits set. Returns first violation found.

All existing API is unchanged. 20 new test cases (59 total).

Diffstat:
Mlib/Lightning/Protocol/BOLT9.hs | 6++++++
Mlib/Lightning/Protocol/BOLT9/Codec.hs | 25+++++++++++++++++++++++++
Mlib/Lightning/Protocol/BOLT9/Features.hs | 41+++++++++++++++++++++++++++++++++++++++++
Mlib/Lightning/Protocol/BOLT9/Validate.hs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtest/Main.hs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 328 insertions(+), 1 deletion(-)

diff --git a/lib/Lightning/Protocol/BOLT9.hs b/lib/Lightning/Protocol/BOLT9.hs @@ -99,6 +99,9 @@ module Lightning.Protocol.BOLT9 ( -- * Known features -- | The BOLT #9 feature table and lookup functions. , Feature(..) + , KnownFeature(..) + , knownFeatureByBit + , knownFeatureByName , featureByBit , featureByName , knownFeatures @@ -117,6 +120,7 @@ module Lightning.Protocol.BOLT9 ( -- * Feature operations -- | High-level operations using 'Feature' values. , setFeature + , setFeatureWithDeps , hasFeature , isFeatureSet , listFeatures @@ -126,6 +130,8 @@ module Lightning.Protocol.BOLT9 ( , ValidationError(..) , validateLocal , validateRemote + , setFeatureForContext + , validateNoBothBits , highestSetBit , Validate.setBits ) where diff --git a/lib/Lightning/Protocol/BOLT9/Codec.hs b/lib/Lightning/Protocol/BOLT9/Codec.hs @@ -21,6 +21,7 @@ module Lightning.Protocol.BOLT9.Codec ( -- * Feature operations , setFeature + , setFeatureWithDeps , hasFeature , isFeatureSet , listFeatures @@ -105,6 +106,30 @@ setFeature !f !level = setBit targetBit Optional -> baseBit + 1 {-# INLINE setFeature #-} +-- | Set a feature and all its transitive dependencies. +-- +-- Dependencies are set at the same level as the feature itself. +-- Unknown dependencies (not in the known features table) are +-- silently skipped. +-- +-- >>> import Data.Maybe (fromJust) +-- >>> let mpp = fromJust (featureByName "basic_mpp") +-- >>> let fv = setFeatureWithDeps mpp Optional empty +-- >>> isFeatureSet mpp fv +-- True +-- >>> let Just ps = featureByName "payment_secret" +-- >>> isFeatureSet ps fv -- dependency auto-set +-- True +setFeatureWithDeps + :: Feature -> FeatureLevel -> FeatureVector -> FeatureVector +setFeatureWithDeps !f !level !fv = + let !fv' = setFeature f level fv + in foldr setDep fv' (featureDependencies f) + where + setDep !depName !acc = case featureByName depName of + Nothing -> acc + Just dep -> setFeatureWithDeps dep level acc + -- | Check if a feature is set in the vector. -- -- Returns: diff --git a/lib/Lightning/Protocol/BOLT9/Features.hs b/lib/Lightning/Protocol/BOLT9/Features.hs @@ -15,6 +15,11 @@ module Lightning.Protocol.BOLT9.Features ( -- * Feature Feature(..) + -- * Known feature + , KnownFeature(..) + , knownFeatureByBit + , knownFeatureByName + -- * Lookup , featureByBit , featureByName @@ -50,6 +55,42 @@ data Feature = Feature { instance NFData Feature +-- | A feature that is known to be in the BOLT #9 specification. +-- +-- Constructed via 'knownFeatureByBit' or 'knownFeatureByName', +-- which validate that the feature exists in the known table. +newtype KnownFeature = KnownFeature { + unKnownFeature :: Feature + -- ^ Extract the underlying 'Feature'. + } + deriving (Eq, Show, Generic) + +instance NFData KnownFeature + +-- | Look up a known feature by bit number. +-- +-- Accepts either the even (compulsory) or odd (optional) bit. +-- +-- >>> fmap (featureName . unKnownFeature) (knownFeatureByBit 16) +-- Just "basic_mpp" +-- >>> knownFeatureByBit 999 +-- Nothing +knownFeatureByBit :: Word16 -> Maybe KnownFeature +knownFeatureByBit !bit = fmap KnownFeature (featureByBit bit) +{-# INLINE knownFeatureByBit #-} + +-- | Look up a known feature by its canonical name. +-- +-- >>> fmap (featureName . unKnownFeature) +-- ... (knownFeatureByName "basic_mpp") +-- Just "basic_mpp" +-- >>> knownFeatureByName "nonexistent" +-- Nothing +knownFeatureByName :: String -> Maybe KnownFeature +knownFeatureByName !name = + fmap KnownFeature (featureByName name) +{-# INLINE knownFeatureByName #-} + -- | The complete table of known features from BOLT #9. knownFeatures :: [Feature] knownFeatures = [ diff --git a/lib/Lightning/Protocol/BOLT9/Validate.hs b/lib/Lightning/Protocol/BOLT9/Validate.hs @@ -20,6 +20,10 @@ module Lightning.Protocol.BOLT9.Validate ( -- * Remote validation , validateRemote + -- * Validated construction + , setFeatureForContext + , validateNoBothBits + -- * Helpers , highestSetBit , setBits @@ -31,7 +35,8 @@ import qualified Data.ByteString as BS import qualified Data.Bits as B import Data.Word (Word16) import GHC.Generics (Generic) -import Lightning.Protocol.BOLT9.Codec (isFeatureSet, testBit) +import Lightning.Protocol.BOLT9.Codec + (isFeatureSet, setFeature, testBit) import Lightning.Protocol.BOLT9.Features import Lightning.Protocol.BOLT9.Types @@ -144,6 +149,67 @@ parityErrors !ctx !fv = case channelParity ctx of then InvalidParity bit ctx : acc else acc +-- Validated construction ------------------------------------------------------- + +-- | Set a feature in a vector, validating that the feature is +-- allowed in the given context and has correct parity. +-- +-- Checks: +-- +-- * The feature's context list includes the given context +-- (or is empty, meaning all contexts are allowed) +-- * For 'ChanAnnOdd', only 'Optional' (odd bit) is allowed +-- * For 'ChanAnnEven', only 'Required' (even bit) is allowed +-- +-- >>> import Data.Maybe (fromJust) +-- >>> let pm = fromJust (featureByName "option_payment_metadata") +-- >>> setFeatureForContext Invoice pm Optional empty +-- Right ... +-- >>> setFeatureForContext Init pm Optional empty +-- Left (ContextNotAllowed "option_payment_metadata" Init) +setFeatureForContext + :: Context + -> Feature + -> FeatureLevel + -> FeatureVector + -> Either ValidationError FeatureVector +setFeatureForContext !ctx !f !level !fv + | not (null contexts) + , not (contextAllowed ctx contexts) + = Left (ContextNotAllowed (featureName f) ctx) + | otherwise + = case channelParity ctx of + Just True | level == Optional -> + Left (InvalidParity targetBit ctx) + Just False | level == Required -> + Left (InvalidParity targetBit ctx) + _ -> Right (setFeature f level fv) + where + !contexts = featureContexts f + !baseBit = featureBaseBit f + !targetBit = case level of + Required -> baseBit + Optional -> baseBit + 1 + +-- | Validate that no feature has both its required and optional +-- bits set simultaneously. +-- +-- Returns the input vector unchanged on success. +-- +-- >>> validateNoBothBits empty +-- Right ... +validateNoBothBits + :: FeatureVector -> Either ValidationError FeatureVector +validateNoBothBits !fv = go knownFeatures + where + go [] = Right fv + go (f:fs) = + let !baseBit = featureBaseBit f + in if testBit baseBit fv + && testBit (baseBit + 1) fv + then Left (BothBitsSet baseBit (featureName f)) + else go fs + -- Remote validation ---------------------------------------------------------- -- | Validate a feature vector received from a remote peer. diff --git a/test/Main.hs b/test/Main.hs @@ -4,6 +4,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 Lightning.Protocol.BOLT9 @@ -20,6 +21,10 @@ tests = testGroup "ppad-bolt9" [ , bitParityTests , featureVectorTests , validationTests + , knownFeatureTests + , setFeatureWithDepsTests + , setFeatureForContextTests + , validateNoBothBitsTests , propertyTests ] @@ -232,6 +237,190 @@ isUnknownRequiredBit :: ValidationError -> Bool isUnknownRequiredBit (UnknownRequiredBit _) = True isUnknownRequiredBit _ = False +-- KnownFeature tests ----------------------------------------------------------- + +knownFeatureTests :: TestTree +knownFeatureTests = testGroup "KnownFeature" [ + testCase "knownFeatureByBit finds known feature" $ + case knownFeatureByBit 16 of + Nothing -> assertFailure "expected basic_mpp" + Just kf -> + featureName (unKnownFeature kf) @?= "basic_mpp" + + , testCase "knownFeatureByBit works for odd bit" $ + case knownFeatureByBit 17 of + Nothing -> assertFailure "expected basic_mpp" + Just kf -> + featureName (unKnownFeature kf) @?= "basic_mpp" + + , testCase "knownFeatureByBit returns Nothing for unknown" $ + knownFeatureByBit 999 @?= Nothing + + , testCase "knownFeatureByName finds known feature" $ + case knownFeatureByName "payment_secret" of + Nothing -> assertFailure "expected payment_secret" + Just kf -> + featureBaseBit (unKnownFeature kf) @?= 14 + + , testCase "knownFeatureByName returns Nothing for unknown" $ + knownFeatureByName "nonexistent" @?= Nothing + ] + +-- setFeatureWithDeps tests ----------------------------------------------------- + +setFeatureWithDepsTests :: TestTree +setFeatureWithDepsTests = testGroup "setFeatureWithDeps" [ + testCase "sets feature and its dependency" $ do + case featureByName "basic_mpp" of + Nothing -> assertFailure "basic_mpp not found" + Just mpp -> do + let fv = setFeatureWithDeps mpp Optional empty + isFeatureSet mpp fv @?= True + -- payment_secret should also be set + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> isFeatureSet ps fv @?= True + + , testCase "sets transitive dependencies" $ do + -- option_zeroconf depends on option_scid_alias + case featureByName "option_zeroconf" of + Nothing -> + assertFailure "option_zeroconf not found" + Just zc -> do + let fv = setFeatureWithDeps zc Required empty + isFeatureSet zc fv @?= True + case featureByName "option_scid_alias" of + Nothing -> + assertFailure "option_scid_alias not found" + Just sa -> isFeatureSet sa fv @?= True + + , testCase "feature without deps sets only itself" $ do + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> do + let fv = setFeatureWithDeps ps Optional empty + isFeatureSet ps fv @?= True + -- no other features should be set + let others = filter + (\(f, _) -> featureName f /= "payment_secret") + (listFeatures fv) + null others @?= True + + , testCase "passes validateLocal after setFeatureWithDeps" $ do + case featureByName "basic_mpp" of + Nothing -> assertFailure "basic_mpp not found" + Just mpp -> do + let fv = setFeatureWithDeps mpp Optional empty + validateLocal Init fv @?= Right () + ] + +-- setFeatureForContext tests --------------------------------------------------- + +setFeatureForContextTests :: TestTree +setFeatureForContextTests = testGroup "setFeatureForContext" [ + testCase "allows feature in valid context" $ do + case featureByName "option_payment_metadata" of + Nothing -> + assertFailure "option_payment_metadata not found" + Just pm -> + isRight + (setFeatureForContext Invoice pm Optional empty) + @?= True + + , testCase "rejects feature in wrong context" $ do + case featureByName "option_payment_metadata" of + Nothing -> + assertFailure "option_payment_metadata not found" + Just pm -> + case setFeatureForContext Init pm Optional empty of + Right _ -> assertFailure "expected error" + Left err -> isContextNotAllowed err @?= True + + , testCase "allows feature with empty context list" $ do + -- payment_secret has empty context list (all allowed) + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> + isRight + (setFeatureForContext Init ps Optional empty) + @?= True + + , testCase "rejects Required in ChanAnnOdd context" $ do + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> + case setFeatureForContext + ChanAnnOdd ps Required empty of + Right _ -> assertFailure "expected parity error" + Left err -> isInvalidParity err @?= True + + , testCase "allows Optional in ChanAnnOdd context" $ do + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> + isRight + (setFeatureForContext + ChanAnnOdd ps Optional empty) + @?= True + + , testCase "rejects Optional in ChanAnnEven context" $ do + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> + case setFeatureForContext + ChanAnnEven ps Optional empty of + Right _ -> assertFailure "expected parity error" + Left err -> isInvalidParity err @?= True + + , testCase "allows Required in ChanAnnEven context" $ do + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> + isRight + (setFeatureForContext + ChanAnnEven ps Required empty) + @?= True + ] + +-- validateNoBothBits tests ----------------------------------------------------- + +validateNoBothBitsTests :: TestTree +validateNoBothBitsTests = testGroup "validateNoBothBits" [ + testCase "passes for empty vector" $ + isRight (validateNoBothBits empty) @?= True + + , testCase "passes when only one bit of pair is set" $ do + case featureByName "payment_secret" of + Nothing -> + assertFailure "payment_secret not found" + Just ps -> do + let fv = setFeature ps Optional empty + isRight (validateNoBothBits fv) @?= True + + , testCase "fails when both bits of pair are set" $ do + let fv = setBit 15 (setBit 14 empty) + case validateNoBothBits fv of + Right _ -> assertFailure "expected BothBitsSet" + Left err -> isBothBitsSet err @?= True + + , testCase "returns first error found" $ do + -- set both bits for two features + let fv = setBit 15 (setBit 14 + (setBit 17 (setBit 16 empty))) + isLeft (validateNoBothBits fv) @?= True + ] + +isInvalidParity :: ValidationError -> Bool +isInvalidParity (InvalidParity _ _) = True +isInvalidParity _ = False + -- Property tests -------------------------------------------------------------- propertyTests :: TestTree