commit b93640a812a1f57867b89299ce9c08179804a20b
Author: Jared Tobin <jared@jtobin.io>
Date: Sun, 25 Jan 2026 14:37:02 +0400
Initial project skeleton for BOLT #7 implementation
Add library structure for Lightning Network routing gossip protocol:
- Types: ChainHash, ShortChannelId, NodeId, Signature, Point,
RgbColor, Alias, FeatureBits, Address types
- Messages: All 9 BOLT #7 message types (channel_announcement,
node_announcement, channel_update, announcement_signatures,
query_short_channel_ids, reply_short_channel_ids_end,
query_channel_range, reply_channel_range, gossip_timestamp_filter)
- Codec: Encode/decode functions for all message types
- Test skeleton with roundtrip tests
- Benchmark skeletons (criterion + weigh)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
15 files changed, 2616 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+dist-newstyle/
diff --git a/AGENTS.md b/AGENTS.md
@@ -0,0 +1,139 @@
+# ppad-bolt7
+
+Haskell implementation of BOLT #7 (Lightning Network routing gossip).
+
+Specification: https://github.com/lightning/bolts/blob/master/07-routing-gossip.md
+
+## Project Structure
+
+- `lib/` - library source (Lightning.Protocol.BOLT7)
+- `test/` - tests (tasty + tasty-hunit)
+- `bench/` - benchmarks (criterion for timing, weigh for allocations)
+- `etc/` - reference materials (BOLT spec)
+- `flake.nix` - nix flake for dependency and build management
+- `ppad-bolt7.cabal` - cabal package definition
+- `CLAUDE.md` / `AGENTS.md` - keep these in sync
+
+## Build and Test
+
+Enter devshell and use cabal:
+
+```
+nix develop
+cabal build
+cabal test
+cabal bench
+```
+
+Do not use stack. All dependency and build management via nix.
+
+## Dependencies
+
+### ppad libraries (use freely)
+
+Use ppad libraries (github.com/ppad-tech, git.ppad.tech) liberally.
+
+### External libraries
+
+Use only minimal external dependencies. Prefer GHC's core/boot libraries
+(base, bytestring, primitive, etc.).
+
+**Ask for explicit confirmation before adding any library outside of:**
+- GHC boot/core libraries
+- ppad-* libraries
+- Test dependencies (tasty, QuickCheck, etc. for test-suite only)
+- Benchmark dependencies (criterion, weigh for benchmark only)
+
+## Code Style
+
+### Performance
+
+- Use strictness annotations (BangPatterns) liberally
+- Prefer UNPACK for strict record fields
+- Use MagicHash, UnboxedTuples, GHC.Exts for hot paths
+- Do not rely on UNBOX pragmas; implement primitives directly with
+ MagicHash and GHC.Exts when needed
+- Use INLINE pragmas for small functions
+- Refer to ppad-sha256 and ppad-fixed for low-level patterns
+
+### Type safety
+
+- Encode invariants into the type system
+- Use newtypes liberally (e.g., Sec, Pub, Session)
+- Use ADTs to make illegal states unrepresentable
+- Prefer smart constructors that validate inputs
+
+### Safety
+
+- Never use partial Prelude functions (head, tail, !!, etc.)
+- Use Maybe/Either for fallible operations
+- Validate all inputs at system boundaries
+
+### Formatting
+
+- Keep lines under 80 characters
+- Use Haskell2010
+- Module header with copyright, license, maintainer
+- OPTIONS_HADDOCK prune for public modules
+- Haddock examples for exported functions
+
+## Testing
+
+Use tasty to wrap all tests:
+- tasty-hunit for unit tests with known vectors
+- tasty-quickcheck for property-based tests
+- Source test vectors from specifications (RFC, BOLT spec, Wycheproof, etc.)
+
+Property tests should enforce invariants that can't be encoded in types.
+
+## Benchmarking
+
+Always maintain benchmark suites:
+- `bench/Main.hs` - criterion for wall-time benchmarks
+- `bench/Weight.hs` - weigh for allocation tracking
+
+Define NFData instances for types that need benchmarking.
+
+## Git Workflow
+
+- Feature branches for development; commit freely there
+- Logical, atomic commits on feature branches
+- Master should be mostly merge commits
+- Merge to master with `--no-ff` after validation
+- Always build and test before creating a merge commit
+- Write detailed merge commit messages summarising changes
+
+### Worktree flow (for planned work)
+
+When starting work on an implementation plan:
+
+```
+git worktree add ./impl-<desc> -b impl/<desc> master
+# work in that worktree
+# merge to master when complete
+git worktree remove ./impl-<desc>
+```
+
+### Commits
+
+- Higher-level descriptions in merge commits
+- Never update git config
+- Never use destructive git commands (push --force, hard reset) without
+ explicit request
+- Never skip hooks unless explicitly requested
+
+## Planning
+
+When planning work:
+- Highlight which steps can be done independently
+- Consider forking subagents for concurrent work on independent steps
+- Write implementation plans to `plans/IMPL<n>.md` if the project uses
+ this convention
+
+## Flake Structure
+
+The flake.nix follows ppad conventions:
+- Uses ppad-nixpkgs as base
+- Follows references to avoid duplication
+- Supports LLVM backend via cabal flag
+- Provides devShell with ghc, cabal, cc, llvm
diff --git a/CHANGELOG b/CHANGELOG
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -0,0 +1,139 @@
+# ppad-bolt7
+
+Haskell implementation of BOLT #7 (Lightning Network routing gossip).
+
+Specification: https://github.com/lightning/bolts/blob/master/07-routing-gossip.md
+
+## Project Structure
+
+- `lib/` - library source (Lightning.Protocol.BOLT7)
+- `test/` - tests (tasty + tasty-hunit)
+- `bench/` - benchmarks (criterion for timing, weigh for allocations)
+- `etc/` - reference materials (BOLT spec)
+- `flake.nix` - nix flake for dependency and build management
+- `ppad-bolt7.cabal` - cabal package definition
+- `CLAUDE.md` / `AGENTS.md` - keep these in sync
+
+## Build and Test
+
+Enter devshell and use cabal:
+
+```
+nix develop
+cabal build
+cabal test
+cabal bench
+```
+
+Do not use stack. All dependency and build management via nix.
+
+## Dependencies
+
+### ppad libraries (use freely)
+
+Use ppad libraries (github.com/ppad-tech, git.ppad.tech) liberally.
+
+### External libraries
+
+Use only minimal external dependencies. Prefer GHC's core/boot libraries
+(base, bytestring, primitive, etc.).
+
+**Ask for explicit confirmation before adding any library outside of:**
+- GHC boot/core libraries
+- ppad-* libraries
+- Test dependencies (tasty, QuickCheck, etc. for test-suite only)
+- Benchmark dependencies (criterion, weigh for benchmark only)
+
+## Code Style
+
+### Performance
+
+- Use strictness annotations (BangPatterns) liberally
+- Prefer UNPACK for strict record fields
+- Use MagicHash, UnboxedTuples, GHC.Exts for hot paths
+- Do not rely on UNBOX pragmas; implement primitives directly with
+ MagicHash and GHC.Exts when needed
+- Use INLINE pragmas for small functions
+- Refer to ppad-sha256 and ppad-fixed for low-level patterns
+
+### Type safety
+
+- Encode invariants into the type system
+- Use newtypes liberally (e.g., Sec, Pub, Session)
+- Use ADTs to make illegal states unrepresentable
+- Prefer smart constructors that validate inputs
+
+### Safety
+
+- Never use partial Prelude functions (head, tail, !!, etc.)
+- Use Maybe/Either for fallible operations
+- Validate all inputs at system boundaries
+
+### Formatting
+
+- Keep lines under 80 characters
+- Use Haskell2010
+- Module header with copyright, license, maintainer
+- OPTIONS_HADDOCK prune for public modules
+- Haddock examples for exported functions
+
+## Testing
+
+Use tasty to wrap all tests:
+- tasty-hunit for unit tests with known vectors
+- tasty-quickcheck for property-based tests
+- Source test vectors from specifications (RFC, BOLT spec, Wycheproof, etc.)
+
+Property tests should enforce invariants that can't be encoded in types.
+
+## Benchmarking
+
+Always maintain benchmark suites:
+- `bench/Main.hs` - criterion for wall-time benchmarks
+- `bench/Weight.hs` - weigh for allocation tracking
+
+Define NFData instances for types that need benchmarking.
+
+## Git Workflow
+
+- Feature branches for development; commit freely there
+- Logical, atomic commits on feature branches
+- Master should be mostly merge commits
+- Merge to master with `--no-ff` after validation
+- Always build and test before creating a merge commit
+- Write detailed merge commit messages summarising changes
+
+### Worktree flow (for planned work)
+
+When starting work on an implementation plan:
+
+```
+git worktree add ./impl-<desc> -b impl/<desc> master
+# work in that worktree
+# merge to master when complete
+git worktree remove ./impl-<desc>
+```
+
+### Commits
+
+- Higher-level descriptions in merge commits
+- Never update git config
+- Never use destructive git commands (push --force, hard reset) without
+ explicit request
+- Never skip hooks unless explicitly requested
+
+## Planning
+
+When planning work:
+- Highlight which steps can be done independently
+- Consider forking subagents for concurrent work on independent steps
+- Write implementation plans to `plans/IMPL<n>.md` if the project uses
+ this convention
+
+## Flake Structure
+
+The flake.nix follows ppad conventions:
+- Uses ppad-nixpkgs as base
+- Follows references to avoid duplication
+- Supports LLVM backend via cabal flag
+- Provides devShell with ghc, cabal, cc, llvm
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2025 Jared Tobin
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
@@ -0,0 +1,44 @@
+# ppad-bolt7
+
+A pure Haskell implementation of
+[BOLT #7](https://github.com/lightning/bolts/blob/master/07-routing-gossip.md)
+(Lightning Network routing gossip protocol).
+
+## Synopsis
+
+ppad-bolt7 provides types and codecs for BOLT #7 gossip messages:
+
+* `channel_announcement` (256)
+* `node_announcement` (257)
+* `channel_update` (258)
+* `announcement_signatures` (259)
+* `query_short_channel_ids` (261)
+* `reply_short_channel_ids_end` (262)
+* `query_channel_range` (263)
+* `reply_channel_range` (264)
+* `gossip_timestamp_filter` (265)
+
+## Documentation
+
+Haddock documentation is available at
+[docs.ppad.tech/bolt7](https://docs.ppad.tech/bolt7).
+
+## Development
+
+A Nix development shell is provided via flake. Enter it with:
+
+```
+$ nix develop
+```
+
+Then use `cabal` as usual:
+
+```
+$ cabal build
+$ cabal test
+$ cabal bench
+```
+
+## Security
+
+This library has received no security audit. Use at your own risk.
diff --git a/bench/Main.hs b/bench/Main.hs
@@ -0,0 +1,205 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module: Main
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Criterion timing benchmarks for BOLT #7 gossip codecs.
+
+module Main where
+
+import Criterion.Main
+import qualified Data.ByteString as BS
+import Lightning.Protocol.BOLT1 (TlvStream(..))
+import Lightning.Protocol.BOLT7
+
+-- Test data construction ------------------------------------------------------
+
+-- | 32 zero bytes for chain hashes, etc.
+zeroBytes32 :: BS.ByteString
+zeroBytes32 = BS.replicate 32 0x00
+{-# NOINLINE zeroBytes32 #-}
+
+-- | 8 zero bytes for short channel IDs.
+zeroBytes8 :: BS.ByteString
+zeroBytes8 = BS.replicate 8 0x00
+{-# NOINLINE zeroBytes8 #-}
+
+-- | 64-byte signature.
+testSignature :: Signature
+testSignature = case signature (BS.replicate 64 0x01) of
+ Just s -> s
+ Nothing -> error "testSignature: invalid"
+{-# NOINLINE testSignature #-}
+
+-- | 33-byte compressed public key (02 prefix + 32 zero bytes).
+testPoint :: Point
+testPoint = case point (BS.cons 0x02 zeroBytes32) of
+ Just p -> p
+ Nothing -> error "testPoint: invalid"
+{-# NOINLINE testPoint #-}
+
+-- | 32-byte chain hash.
+testChainHash :: ChainHash
+testChainHash = case chainHash zeroBytes32 of
+ Just h -> h
+ Nothing -> error "testChainHash: invalid"
+{-# NOINLINE testChainHash #-}
+
+-- | 8-byte short channel ID.
+testShortChannelId :: ShortChannelId
+testShortChannelId = case shortChannelId zeroBytes8 of
+ Just s -> s
+ Nothing -> error "testShortChannelId: invalid"
+{-# NOINLINE testShortChannelId #-}
+
+-- | 32-byte channel ID.
+testChannelId :: ChannelId
+testChannelId = case channelId zeroBytes32 of
+ Just c -> c
+ Nothing -> error "testChannelId: invalid"
+{-# NOINLINE testChannelId #-}
+
+-- | 33-byte node ID.
+testNodeId :: NodeId
+testNodeId = case nodeId (BS.cons 0x03 zeroBytes32) of
+ Just n -> n
+ Nothing -> error "testNodeId: invalid"
+{-# NOINLINE testNodeId #-}
+
+-- | Second node ID.
+testNodeId2 :: NodeId
+testNodeId2 = case nodeId (BS.cons 0x02 zeroBytes32) of
+ Just n -> n
+ Nothing -> error "testNodeId2: invalid"
+{-# NOINLINE testNodeId2 #-}
+
+-- | RGB color.
+testRgbColor :: RgbColor
+testRgbColor = case rgbColor (BS.pack [0xff, 0x00, 0x00]) of
+ Just c -> c
+ Nothing -> error "testRgbColor: invalid"
+{-# NOINLINE testRgbColor #-}
+
+-- | 32-byte alias.
+testAlias :: Alias
+testAlias = case alias zeroBytes32 of
+ Just a -> a
+ Nothing -> error "testAlias: invalid"
+{-# NOINLINE testAlias #-}
+
+-- | Empty TLV stream.
+emptyTlvs :: TlvStream
+emptyTlvs = TlvStream []
+{-# NOINLINE emptyTlvs #-}
+
+-- | Empty feature bits.
+emptyFeatures :: FeatureBits
+emptyFeatures = featureBits BS.empty
+{-# NOINLINE emptyFeatures #-}
+
+-- Test messages ---------------------------------------------------------------
+
+-- | Test ChannelAnnouncement message.
+testChannelAnnouncement :: ChannelAnnouncement
+testChannelAnnouncement = ChannelAnnouncement
+ { channelAnnNodeSig1 = testSignature
+ , channelAnnNodeSig2 = testSignature
+ , channelAnnBitcoinSig1 = testSignature
+ , channelAnnBitcoinSig2 = testSignature
+ , channelAnnFeatures = emptyFeatures
+ , channelAnnChainHash = testChainHash
+ , channelAnnShortChanId = testShortChannelId
+ , channelAnnNodeId1 = testNodeId
+ , channelAnnNodeId2 = testNodeId2
+ , channelAnnBitcoinKey1 = testPoint
+ , channelAnnBitcoinKey2 = testPoint
+ }
+{-# NOINLINE testChannelAnnouncement #-}
+
+-- | Encoded ChannelAnnouncement for decode benchmarks.
+encodedChannelAnnouncement :: BS.ByteString
+encodedChannelAnnouncement = encodeChannelAnnouncement testChannelAnnouncement
+{-# NOINLINE encodedChannelAnnouncement #-}
+
+-- | Test ChannelUpdate message.
+testChannelUpdate :: ChannelUpdate
+testChannelUpdate = ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = 1234567890
+ , chanUpdateMsgFlags = 0x01
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = 144
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Just 1000000000
+ }
+{-# NOINLINE testChannelUpdate #-}
+
+-- | Encoded ChannelUpdate for decode benchmarks.
+encodedChannelUpdate :: BS.ByteString
+encodedChannelUpdate = encodeChannelUpdate testChannelUpdate
+{-# NOINLINE encodedChannelUpdate #-}
+
+-- | Test AnnouncementSignatures message.
+testAnnouncementSignatures :: AnnouncementSignatures
+testAnnouncementSignatures = AnnouncementSignatures
+ { annSigChannelId = testChannelId
+ , annSigShortChanId = testShortChannelId
+ , annSigNodeSig = testSignature
+ , annSigBitcoinSig = testSignature
+ }
+{-# NOINLINE testAnnouncementSignatures #-}
+
+-- | Encoded AnnouncementSignatures for decode benchmarks.
+encodedAnnouncementSignatures :: BS.ByteString
+encodedAnnouncementSignatures =
+ encodeAnnouncementSignatures testAnnouncementSignatures
+{-# NOINLINE encodedAnnouncementSignatures #-}
+
+-- | Test GossipTimestampFilter message.
+testGossipTimestampFilter :: GossipTimestampFilter
+testGossipTimestampFilter = GossipTimestampFilter
+ { gossipFilterChainHash = testChainHash
+ , gossipFilterFirstTimestamp = 1609459200
+ , gossipFilterTimestampRange = 86400
+ }
+{-# NOINLINE testGossipTimestampFilter #-}
+
+-- | Encoded GossipTimestampFilter for decode benchmarks.
+encodedGossipTimestampFilter :: BS.ByteString
+encodedGossipTimestampFilter =
+ encodeGossipTimestampFilter testGossipTimestampFilter
+{-# NOINLINE encodedGossipTimestampFilter #-}
+
+-- Benchmark groups ------------------------------------------------------------
+
+main :: IO ()
+main = defaultMain
+ [ bgroup "channel_announcement"
+ [ bench "encode" $ nf encodeChannelAnnouncement testChannelAnnouncement
+ , bench "decode" $ nf decodeChannelAnnouncement encodedChannelAnnouncement
+ ]
+ , bgroup "channel_update"
+ [ bench "encode" $ nf encodeChannelUpdate testChannelUpdate
+ , bench "decode" $ nf decodeChannelUpdate encodedChannelUpdate
+ ]
+ , bgroup "announcement_signatures"
+ [ bench "encode" $
+ nf encodeAnnouncementSignatures testAnnouncementSignatures
+ , bench "decode" $
+ nf decodeAnnouncementSignatures encodedAnnouncementSignatures
+ ]
+ , bgroup "gossip_timestamp_filter"
+ [ bench "encode" $
+ nf encodeGossipTimestampFilter testGossipTimestampFilter
+ , bench "decode" $
+ nf decodeGossipTimestampFilter encodedGossipTimestampFilter
+ ]
+ ]
diff --git a/bench/Weight.hs b/bench/Weight.hs
@@ -0,0 +1,214 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module: Main
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Weigh allocation benchmarks for BOLT #7 gossip codecs.
+
+module Main where
+
+import qualified Data.ByteString as BS
+import Lightning.Protocol.BOLT1 (TlvStream(..))
+import Lightning.Protocol.BOLT7
+import Weigh
+
+-- Test data construction ------------------------------------------------------
+
+-- | 32 zero bytes for chain hashes, etc.
+zeroBytes32 :: BS.ByteString
+zeroBytes32 = BS.replicate 32 0x00
+{-# NOINLINE zeroBytes32 #-}
+
+-- | 8 zero bytes for short channel IDs.
+zeroBytes8 :: BS.ByteString
+zeroBytes8 = BS.replicate 8 0x00
+{-# NOINLINE zeroBytes8 #-}
+
+-- | 64-byte signature.
+testSignature :: Signature
+testSignature = case signature (BS.replicate 64 0x01) of
+ Just s -> s
+ Nothing -> error "testSignature: invalid"
+{-# NOINLINE testSignature #-}
+
+-- | 33-byte compressed public key (02 prefix + 32 zero bytes).
+testPoint :: Point
+testPoint = case point (BS.cons 0x02 zeroBytes32) of
+ Just p -> p
+ Nothing -> error "testPoint: invalid"
+{-# NOINLINE testPoint #-}
+
+-- | 32-byte chain hash.
+testChainHash :: ChainHash
+testChainHash = case chainHash zeroBytes32 of
+ Just h -> h
+ Nothing -> error "testChainHash: invalid"
+{-# NOINLINE testChainHash #-}
+
+-- | 8-byte short channel ID.
+testShortChannelId :: ShortChannelId
+testShortChannelId = case shortChannelId zeroBytes8 of
+ Just s -> s
+ Nothing -> error "testShortChannelId: invalid"
+{-# NOINLINE testShortChannelId #-}
+
+-- | 32-byte channel ID.
+testChannelId :: ChannelId
+testChannelId = case channelId zeroBytes32 of
+ Just c -> c
+ Nothing -> error "testChannelId: invalid"
+{-# NOINLINE testChannelId #-}
+
+-- | 33-byte node ID.
+testNodeId :: NodeId
+testNodeId = case nodeId (BS.cons 0x03 zeroBytes32) of
+ Just n -> n
+ Nothing -> error "testNodeId: invalid"
+{-# NOINLINE testNodeId #-}
+
+-- | Second node ID.
+testNodeId2 :: NodeId
+testNodeId2 = case nodeId (BS.cons 0x02 zeroBytes32) of
+ Just n -> n
+ Nothing -> error "testNodeId2: invalid"
+{-# NOINLINE testNodeId2 #-}
+
+-- | Empty feature bits.
+emptyFeatures :: FeatureBits
+emptyFeatures = featureBits BS.empty
+{-# NOINLINE emptyFeatures #-}
+
+-- Message constructors --------------------------------------------------------
+
+-- | Construct ChannelAnnouncement message.
+mkChannelAnnouncement
+ :: Signature -> Signature -> ChainHash -> ShortChannelId
+ -> NodeId -> NodeId -> Point -> Point -> FeatureBits
+ -> ChannelAnnouncement
+mkChannelAnnouncement !ns1 !ns2 !ch !scid !nid1 !nid2 !bk1 !bk2 !feat =
+ ChannelAnnouncement
+ { channelAnnNodeSig1 = ns1
+ , channelAnnNodeSig2 = ns2
+ , channelAnnBitcoinSig1 = ns1
+ , channelAnnBitcoinSig2 = ns2
+ , channelAnnFeatures = feat
+ , channelAnnChainHash = ch
+ , channelAnnShortChanId = scid
+ , channelAnnNodeId1 = nid1
+ , channelAnnNodeId2 = nid2
+ , channelAnnBitcoinKey1 = bk1
+ , channelAnnBitcoinKey2 = bk2
+ }
+
+-- | Construct ChannelUpdate message.
+mkChannelUpdate :: Signature -> ChainHash -> ShortChannelId -> ChannelUpdate
+mkChannelUpdate !sig !ch !scid = ChannelUpdate
+ { chanUpdateSignature = sig
+ , chanUpdateChainHash = ch
+ , chanUpdateShortChanId = scid
+ , chanUpdateTimestamp = 1234567890
+ , chanUpdateMsgFlags = 0x01
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = 144
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Just 1000000000
+ }
+
+-- | Construct AnnouncementSignatures message.
+mkAnnouncementSignatures
+ :: ChannelId -> ShortChannelId -> Signature -> AnnouncementSignatures
+mkAnnouncementSignatures !cid !scid !sig = AnnouncementSignatures
+ { annSigChannelId = cid
+ , annSigShortChanId = scid
+ , annSigNodeSig = sig
+ , annSigBitcoinSig = sig
+ }
+
+-- | Construct GossipTimestampFilter message.
+mkGossipTimestampFilter :: ChainHash -> GossipTimestampFilter
+mkGossipTimestampFilter !ch = GossipTimestampFilter
+ { gossipFilterChainHash = ch
+ , gossipFilterFirstTimestamp = 1609459200
+ , gossipFilterTimestampRange = 86400
+ }
+
+-- Pre-constructed messages ----------------------------------------------------
+
+-- | Test ChannelAnnouncement message.
+testChannelAnnouncement :: ChannelAnnouncement
+testChannelAnnouncement = mkChannelAnnouncement
+ testSignature testSignature testChainHash testShortChannelId
+ testNodeId testNodeId2 testPoint testPoint emptyFeatures
+{-# NOINLINE testChannelAnnouncement #-}
+
+-- | Encoded ChannelAnnouncement for decode benchmarks.
+encodedChannelAnnouncement :: BS.ByteString
+encodedChannelAnnouncement = encodeChannelAnnouncement testChannelAnnouncement
+{-# NOINLINE encodedChannelAnnouncement #-}
+
+-- | Test ChannelUpdate message.
+testChannelUpdate :: ChannelUpdate
+testChannelUpdate = mkChannelUpdate testSignature testChainHash testShortChannelId
+{-# NOINLINE testChannelUpdate #-}
+
+-- | Encoded ChannelUpdate for decode benchmarks.
+encodedChannelUpdate :: BS.ByteString
+encodedChannelUpdate = encodeChannelUpdate testChannelUpdate
+{-# NOINLINE encodedChannelUpdate #-}
+
+-- | Test AnnouncementSignatures message.
+testAnnouncementSignatures :: AnnouncementSignatures
+testAnnouncementSignatures =
+ mkAnnouncementSignatures testChannelId testShortChannelId testSignature
+{-# NOINLINE testAnnouncementSignatures #-}
+
+-- | Encoded AnnouncementSignatures for decode benchmarks.
+encodedAnnouncementSignatures :: BS.ByteString
+encodedAnnouncementSignatures =
+ encodeAnnouncementSignatures testAnnouncementSignatures
+{-# NOINLINE encodedAnnouncementSignatures #-}
+
+-- | Test GossipTimestampFilter message.
+testGossipTimestampFilter :: GossipTimestampFilter
+testGossipTimestampFilter = mkGossipTimestampFilter testChainHash
+{-# NOINLINE testGossipTimestampFilter #-}
+
+-- | Encoded GossipTimestampFilter for decode benchmarks.
+encodedGossipTimestampFilter :: BS.ByteString
+encodedGossipTimestampFilter =
+ encodeGossipTimestampFilter testGossipTimestampFilter
+{-# NOINLINE encodedGossipTimestampFilter #-}
+
+-- Weigh benchmarks ------------------------------------------------------------
+
+main :: IO ()
+main = mainWith $ do
+ wgroup "channel_announcement" $ do
+ func "construct" (mkChannelAnnouncement
+ testSignature testSignature testChainHash testShortChannelId
+ testNodeId testNodeId2 testPoint testPoint) emptyFeatures
+ func "encode" encodeChannelAnnouncement testChannelAnnouncement
+ func "decode" decodeChannelAnnouncement encodedChannelAnnouncement
+
+ wgroup "channel_update" $ do
+ func "construct" (mkChannelUpdate testSignature testChainHash)
+ testShortChannelId
+ func "encode" encodeChannelUpdate testChannelUpdate
+ func "decode" decodeChannelUpdate encodedChannelUpdate
+
+ wgroup "announcement_signatures" $ do
+ func "construct" (mkAnnouncementSignatures testChannelId testShortChannelId)
+ testSignature
+ func "encode" encodeAnnouncementSignatures testAnnouncementSignatures
+ func "decode" decodeAnnouncementSignatures encodedAnnouncementSignatures
+
+ wgroup "gossip_timestamp_filter" $ do
+ func "construct" mkGossipTimestampFilter testChainHash
+ func "encode" encodeGossipTimestampFilter testGossipTimestampFilter
+ func "decode" decodeGossipTimestampFilter encodedGossipTimestampFilter
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,72 @@
+{
+ description = "A Haskell implementation of BOLT #7.";
+
+ inputs = {
+ ppad-bolt1 = {
+ type = "path";
+ path = "/Users/jtobin/src/ppad/bolt1";
+ inputs.ppad-nixpkgs.follows = "ppad-nixpkgs";
+ };
+ ppad-nixpkgs = {
+ type = "git";
+ url = "git://git.ppad.tech/nixpkgs.git";
+ ref = "master";
+ };
+ flake-utils.follows = "ppad-nixpkgs/flake-utils";
+ nixpkgs.follows = "ppad-nixpkgs/nixpkgs";
+ };
+
+ outputs = { self, nixpkgs, flake-utils, ppad-nixpkgs, ppad-bolt1 }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ lib = "ppad-bolt7";
+
+ pkgs = import nixpkgs { inherit system; };
+ hlib = pkgs.haskell.lib;
+ llvm = pkgs.llvmPackages_19.llvm;
+ clang = pkgs.llvmPackages_19.clang;
+
+ bolt1 = ppad-bolt1.packages.${system}.default;
+ bolt1-llvm =
+ hlib.addBuildTools
+ (hlib.enableCabalFlag bolt1 "llvm")
+ [ llvm clang ];
+
+ hpkgs = pkgs.haskell.packages.ghc910.extend (new: old: {
+ ppad-bolt1 = bolt1-llvm;
+ ${lib} = new.callCabal2nix lib ./. {
+ ppad-bolt1 = new.ppad-bolt1;
+ };
+ });
+
+ cc = pkgs.stdenv.cc;
+ ghc = hpkgs.ghc;
+ cabal = hpkgs.cabal-install;
+ in
+ {
+ packages.default = hpkgs.${lib};
+
+ packages.haddock = hpkgs.${lib}.doc;
+
+ devShells.default = hpkgs.shellFor {
+ packages = p: [
+ (hlib.doBenchmark p.${lib})
+ ];
+
+ buildInputs = [
+ cabal
+ cc
+ llvm
+ ];
+
+ shellHook = ''
+ PS1="[${lib}] \w$ "
+ echo "entering ${system} shell, using"
+ echo "cc: $(${cc}/bin/cc --version)"
+ echo "ghc: $(${ghc}/bin/ghc --version)"
+ echo "cabal: $(${cabal}/bin/cabal --version)"
+ '';
+ };
+ }
+ );
+}
diff --git a/lib/Lightning/Protocol/BOLT7.hs b/lib/Lightning/Protocol/BOLT7.hs
@@ -0,0 +1,85 @@
+{-# OPTIONS_HADDOCK prune #-}
+
+-- |
+-- Module: Lightning.Protocol.BOLT7
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Routing gossip protocol for the Lightning Network, per
+-- [BOLT #7](https://github.com/lightning/bolts/blob/master/07-routing-gossip.md).
+
+module Lightning.Protocol.BOLT7 (
+ -- * Core types
+ -- | Re-exported from "Lightning.Protocol.BOLT7.Types".
+ module Lightning.Protocol.BOLT7.Types
+
+ -- * Message types
+ -- | Re-exported from "Lightning.Protocol.BOLT7.Messages".
+ , module Lightning.Protocol.BOLT7.Messages
+
+ -- * Codec functions
+ -- | Re-exported from "Lightning.Protocol.BOLT7.Codec".
+ , module Lightning.Protocol.BOLT7.Codec
+
+ -- $messagetypes
+
+ -- ** Channel announcement
+ -- $announcement
+
+ -- ** Node announcement
+ -- $nodeannouncement
+
+ -- ** Channel updates
+ -- $updates
+
+ -- ** Gossip queries
+ -- $queries
+ ) where
+
+import Lightning.Protocol.BOLT7.Codec
+import Lightning.Protocol.BOLT7.Messages
+import Lightning.Protocol.BOLT7.Types
+
+-- $messagetypes
+--
+-- BOLT #7 defines the following message types:
+--
+-- * 256: channel_announcement
+-- * 257: node_announcement
+-- * 258: channel_update
+-- * 259: announcement_signatures
+-- * 261: query_short_channel_ids
+-- * 262: reply_short_channel_ids_end
+-- * 263: query_channel_range
+-- * 264: reply_channel_range
+-- * 265: gossip_timestamp_filter
+
+-- $announcement
+--
+-- Channel announcement messages:
+--
+-- * channel_announcement (256) - public channel announcement
+-- * announcement_signatures (259) - signatures enabling announcement
+
+-- $nodeannouncement
+--
+-- Node announcement message:
+--
+-- * node_announcement (257) - advertises node metadata
+
+-- $updates
+--
+-- Channel update message:
+--
+-- * channel_update (258) - per-direction routing parameters
+
+-- $queries
+--
+-- Gossip query messages:
+--
+-- * query_short_channel_ids (261) - request specific channel info
+-- * reply_short_channel_ids_end (262) - concludes query response
+-- * query_channel_range (263) - query channels in block range
+-- * reply_channel_range (264) - response with channel IDs
+-- * gossip_timestamp_filter (265) - constrain relayed gossip
diff --git a/lib/Lightning/Protocol/BOLT7/Codec.hs b/lib/Lightning/Protocol/BOLT7/Codec.hs
@@ -0,0 +1,600 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module: Lightning.Protocol.BOLT7.Codec
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Encoding and decoding for BOLT #7 gossip messages.
+
+module Lightning.Protocol.BOLT7.Codec (
+ -- * Error types
+ EncodeError(..)
+ , DecodeError(..)
+
+ -- * Channel announcement
+ , encodeChannelAnnouncement
+ , decodeChannelAnnouncement
+
+ -- * Node announcement
+ , encodeNodeAnnouncement
+ , decodeNodeAnnouncement
+
+ -- * Channel update
+ , encodeChannelUpdate
+ , decodeChannelUpdate
+
+ -- * Announcement signatures
+ , encodeAnnouncementSignatures
+ , decodeAnnouncementSignatures
+
+ -- * Query messages
+ , encodeQueryShortChannelIds
+ , decodeQueryShortChannelIds
+ , encodeReplyShortChannelIdsEnd
+ , decodeReplyShortChannelIdsEnd
+ , encodeQueryChannelRange
+ , decodeQueryChannelRange
+ , encodeReplyChannelRange
+ , decodeReplyChannelRange
+ , encodeGossipTimestampFilter
+ , decodeGossipTimestampFilter
+ ) where
+
+import Data.Bits ((.&.))
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.Word (Word8, Word16, Word32, Word64)
+import Lightning.Protocol.BOLT1 (TlvStream(..))
+import qualified Lightning.Protocol.BOLT1.Prim as Prim
+import qualified Lightning.Protocol.BOLT1.TLV as TLV
+import Lightning.Protocol.BOLT7.Messages
+import Lightning.Protocol.BOLT7.Types
+
+-- Error types -----------------------------------------------------------------
+
+-- | Encoding errors.
+data EncodeError
+ = EncodeLengthOverflow -- ^ Field too large for u16 length prefix
+ deriving (Eq, Show)
+
+-- | Decoding errors.
+data DecodeError
+ = DecodeInsufficientBytes -- ^ Not enough bytes
+ | DecodeInvalidSignature -- ^ Invalid signature field
+ | DecodeInvalidChainHash -- ^ Invalid chain hash field
+ | DecodeInvalidShortChannelId -- ^ Invalid short channel ID field
+ | DecodeInvalidChannelId -- ^ Invalid channel ID field
+ | DecodeInvalidNodeId -- ^ Invalid node ID field
+ | DecodeInvalidPoint -- ^ Invalid point field
+ | DecodeInvalidRgbColor -- ^ Invalid RGB color field
+ | DecodeInvalidAlias -- ^ Invalid alias field
+ | DecodeInvalidAddress -- ^ Invalid address encoding
+ | DecodeTlvError -- ^ TLV decoding error
+ deriving (Eq, Show)
+
+-- Primitive helpers -----------------------------------------------------------
+
+-- | Decode u8.
+decodeU8 :: ByteString -> Either DecodeError (Word8, ByteString)
+decodeU8 bs
+ | BS.null bs = Left DecodeInsufficientBytes
+ | otherwise = Right (BS.index bs 0, BS.drop 1 bs)
+{-# INLINE decodeU8 #-}
+
+-- | Decode u16 (big-endian).
+decodeU16 :: ByteString -> Either DecodeError (Word16, ByteString)
+decodeU16 bs
+ | BS.length bs < 2 = Left DecodeInsufficientBytes
+ | otherwise =
+ let (bytes, rest) = BS.splitAt 2 bs
+ in Right (Prim.word16 bytes, rest)
+{-# INLINE decodeU16 #-}
+
+-- | Decode u32 (big-endian).
+decodeU32 :: ByteString -> Either DecodeError (Word32, ByteString)
+decodeU32 bs
+ | BS.length bs < 4 = Left DecodeInsufficientBytes
+ | otherwise =
+ let (bytes, rest) = BS.splitAt 4 bs
+ in Right (Prim.word32 bytes, rest)
+{-# INLINE decodeU32 #-}
+
+-- | Decode u64 (big-endian).
+decodeU64 :: ByteString -> Either DecodeError (Word64, ByteString)
+decodeU64 bs
+ | BS.length bs < 8 = Left DecodeInsufficientBytes
+ | otherwise =
+ let (bytes, rest) = BS.splitAt 8 bs
+ in Right (Prim.word64 bytes, rest)
+{-# INLINE decodeU64 #-}
+
+-- | Decode fixed-length bytes.
+decodeBytes :: Int -> ByteString -> Either DecodeError (ByteString, ByteString)
+decodeBytes n bs
+ | BS.length bs < n = Left DecodeInsufficientBytes
+ | otherwise = Right (BS.splitAt n bs)
+{-# INLINE decodeBytes #-}
+
+-- | Decode length-prefixed bytes (u16 prefix).
+decodeLenPrefixed :: ByteString
+ -> Either DecodeError (ByteString, ByteString)
+decodeLenPrefixed bs = do
+ (len, rest) <- decodeU16 bs
+ let n = fromIntegral len
+ if BS.length rest < n
+ then Left DecodeInsufficientBytes
+ else Right (BS.splitAt n rest)
+{-# INLINE decodeLenPrefixed #-}
+
+-- Type-specific decoders ------------------------------------------------------
+
+-- | Decode Signature (64 bytes).
+decodeSignature :: ByteString -> Either DecodeError (Signature, ByteString)
+decodeSignature bs = do
+ (bytes, rest) <- decodeBytes signatureLen bs
+ case signature bytes of
+ Nothing -> Left DecodeInvalidSignature
+ Just s -> Right (s, rest)
+{-# INLINE decodeSignature #-}
+
+-- | Decode ChainHash (32 bytes).
+decodeChainHash :: ByteString -> Either DecodeError (ChainHash, ByteString)
+decodeChainHash bs = do
+ (bytes, rest) <- decodeBytes chainHashLen bs
+ case chainHash bytes of
+ Nothing -> Left DecodeInvalidChainHash
+ Just h -> Right (h, rest)
+{-# INLINE decodeChainHash #-}
+
+-- | Decode ShortChannelId (8 bytes).
+decodeShortChannelId :: ByteString
+ -> Either DecodeError (ShortChannelId, ByteString)
+decodeShortChannelId bs = do
+ (bytes, rest) <- decodeBytes shortChannelIdLen bs
+ case shortChannelId bytes of
+ Nothing -> Left DecodeInvalidShortChannelId
+ Just s -> Right (s, rest)
+{-# INLINE decodeShortChannelId #-}
+
+-- | Decode ChannelId (32 bytes).
+decodeChannelId :: ByteString -> Either DecodeError (ChannelId, ByteString)
+decodeChannelId bs = do
+ (bytes, rest) <- decodeBytes channelIdLen bs
+ case channelId bytes of
+ Nothing -> Left DecodeInvalidChannelId
+ Just c -> Right (c, rest)
+{-# INLINE decodeChannelId #-}
+
+-- | Decode NodeId (33 bytes).
+decodeNodeId :: ByteString -> Either DecodeError (NodeId, ByteString)
+decodeNodeId bs = do
+ (bytes, rest) <- decodeBytes nodeIdLen bs
+ case nodeId bytes of
+ Nothing -> Left DecodeInvalidNodeId
+ Just n -> Right (n, rest)
+{-# INLINE decodeNodeId #-}
+
+-- | Decode Point (33 bytes).
+decodePoint :: ByteString -> Either DecodeError (Point, ByteString)
+decodePoint bs = do
+ (bytes, rest) <- decodeBytes pointLen bs
+ case point bytes of
+ Nothing -> Left DecodeInvalidPoint
+ Just p -> Right (p, rest)
+{-# INLINE decodePoint #-}
+
+-- | Decode RgbColor (3 bytes).
+decodeRgbColor :: ByteString -> Either DecodeError (RgbColor, ByteString)
+decodeRgbColor bs = do
+ (bytes, rest) <- decodeBytes rgbColorLen bs
+ case rgbColor bytes of
+ Nothing -> Left DecodeInvalidRgbColor
+ Just c -> Right (c, rest)
+{-# INLINE decodeRgbColor #-}
+
+-- | Decode Alias (32 bytes).
+decodeAlias :: ByteString -> Either DecodeError (Alias, ByteString)
+decodeAlias bs = do
+ (bytes, rest) <- decodeBytes aliasLen bs
+ case alias bytes of
+ Nothing -> Left DecodeInvalidAlias
+ Just a -> Right (a, rest)
+{-# INLINE decodeAlias #-}
+
+-- | Decode FeatureBits (length-prefixed).
+decodeFeatureBits :: ByteString -> Either DecodeError (FeatureBits, ByteString)
+decodeFeatureBits bs = do
+ (bytes, rest) <- decodeLenPrefixed bs
+ Right (featureBits bytes, rest)
+{-# INLINE decodeFeatureBits #-}
+
+-- | Decode addresses list (length-prefixed).
+decodeAddresses :: ByteString -> Either DecodeError ([Address], ByteString)
+decodeAddresses bs = do
+ (addrData, rest) <- decodeLenPrefixed bs
+ addrs <- parseAddrs addrData
+ Right (addrs, rest)
+ where
+ parseAddrs :: ByteString -> Either DecodeError [Address]
+ parseAddrs !d
+ | BS.null d = Right []
+ | otherwise = do
+ (addr, d') <- parseOneAddr d
+ rest <- parseAddrs d'
+ Right (addr : rest)
+
+ parseOneAddr :: ByteString -> Either DecodeError (Address, ByteString)
+ parseOneAddr d = do
+ (typ, d1) <- decodeU8 d
+ case typ of
+ 1 -> do -- IPv4
+ (addrBytes, d2) <- decodeBytes ipv4AddrLen d1
+ (port, d3) <- decodeU16 d2
+ case ipv4Addr addrBytes of
+ Nothing -> Left DecodeInvalidAddress
+ Just a -> Right (AddrIPv4 a port, d3)
+ 2 -> do -- IPv6
+ (addrBytes, d2) <- decodeBytes ipv6AddrLen d1
+ (port, d3) <- decodeU16 d2
+ case ipv6Addr addrBytes of
+ Nothing -> Left DecodeInvalidAddress
+ Just a -> Right (AddrIPv6 a port, d3)
+ 4 -> do -- Tor v3
+ (addrBytes, d2) <- decodeBytes torV3AddrLen d1
+ (port, d3) <- decodeU16 d2
+ case torV3Addr addrBytes of
+ Nothing -> Left DecodeInvalidAddress
+ Just a -> Right (AddrTorV3 a port, d3)
+ 5 -> do -- DNS hostname
+ (hostLen, d2) <- decodeU8 d1
+ (hostBytes, d3) <- decodeBytes (fromIntegral hostLen) d2
+ (port, d4) <- decodeU16 d3
+ Right (AddrDNS hostBytes port, d4)
+ _ -> Left DecodeInvalidAddress -- Unknown address type
+
+-- Channel announcement --------------------------------------------------------
+
+-- | Encode channel_announcement message.
+encodeChannelAnnouncement :: ChannelAnnouncement -> ByteString
+encodeChannelAnnouncement msg = mconcat
+ [ getSignature (channelAnnNodeSig1 msg)
+ , getSignature (channelAnnNodeSig2 msg)
+ , getSignature (channelAnnBitcoinSig1 msg)
+ , getSignature (channelAnnBitcoinSig2 msg)
+ , Prim.u16 (fromIntegral $ BS.length features)
+ , features
+ , getChainHash (channelAnnChainHash msg)
+ , getShortChannelId (channelAnnShortChanId msg)
+ , getNodeId (channelAnnNodeId1 msg)
+ , getNodeId (channelAnnNodeId2 msg)
+ , getPoint (channelAnnBitcoinKey1 msg)
+ , getPoint (channelAnnBitcoinKey2 msg)
+ ]
+ where
+ features = getFeatureBits (channelAnnFeatures msg)
+
+-- | Decode channel_announcement message.
+decodeChannelAnnouncement :: ByteString
+ -> Either DecodeError (ChannelAnnouncement, ByteString)
+decodeChannelAnnouncement bs = do
+ (nodeSig1, bs1) <- decodeSignature bs
+ (nodeSig2, bs2) <- decodeSignature bs1
+ (btcSig1, bs3) <- decodeSignature bs2
+ (btcSig2, bs4) <- decodeSignature bs3
+ (features, bs5) <- decodeFeatureBits bs4
+ (chainH, bs6) <- decodeChainHash bs5
+ (scid, bs7) <- decodeShortChannelId bs6
+ (nodeId1, bs8) <- decodeNodeId bs7
+ (nodeId2, bs9) <- decodeNodeId bs8
+ (btcKey1, bs10) <- decodePoint bs9
+ (btcKey2, rest) <- decodePoint bs10
+ let msg = ChannelAnnouncement
+ { channelAnnNodeSig1 = nodeSig1
+ , channelAnnNodeSig2 = nodeSig2
+ , channelAnnBitcoinSig1 = btcSig1
+ , channelAnnBitcoinSig2 = btcSig2
+ , channelAnnFeatures = features
+ , channelAnnChainHash = chainH
+ , channelAnnShortChanId = scid
+ , channelAnnNodeId1 = nodeId1
+ , channelAnnNodeId2 = nodeId2
+ , channelAnnBitcoinKey1 = btcKey1
+ , channelAnnBitcoinKey2 = btcKey2
+ }
+ Right (msg, rest)
+
+-- Node announcement -----------------------------------------------------------
+
+-- | Encode node_announcement message.
+encodeNodeAnnouncement :: NodeAnnouncement -> Either EncodeError ByteString
+encodeNodeAnnouncement msg = do
+ addrData <- encodeAddresses (nodeAnnAddresses msg)
+ let features = getFeatureBits (nodeAnnFeatures msg)
+ if BS.length features > 65535
+ then Left EncodeLengthOverflow
+ else Right $ mconcat
+ [ getSignature (nodeAnnSignature msg)
+ , Prim.u16 (fromIntegral $ BS.length features)
+ , features
+ , Prim.u32 (nodeAnnTimestamp msg)
+ , getNodeId (nodeAnnNodeId msg)
+ , getRgbColor (nodeAnnRgbColor msg)
+ , getAlias (nodeAnnAlias msg)
+ , Prim.u16 (fromIntegral $ BS.length addrData)
+ , addrData
+ ]
+
+-- | Encode address list.
+encodeAddresses :: [Address] -> Either EncodeError ByteString
+encodeAddresses addrs = Right $ mconcat (map encodeAddress addrs)
+ where
+ encodeAddress :: Address -> ByteString
+ encodeAddress (AddrIPv4 a port) = mconcat
+ [ BS.singleton 1
+ , getIPv4Addr a
+ , Prim.u16 port
+ ]
+ encodeAddress (AddrIPv6 a port) = mconcat
+ [ BS.singleton 2
+ , getIPv6Addr a
+ , Prim.u16 port
+ ]
+ encodeAddress (AddrTorV3 a port) = mconcat
+ [ BS.singleton 4
+ , getTorV3Addr a
+ , Prim.u16 port
+ ]
+ encodeAddress (AddrDNS host port) = mconcat
+ [ BS.singleton 5
+ , BS.singleton (fromIntegral $ BS.length host)
+ , host
+ , Prim.u16 port
+ ]
+
+-- | Decode node_announcement message.
+decodeNodeAnnouncement :: ByteString
+ -> Either DecodeError (NodeAnnouncement, ByteString)
+decodeNodeAnnouncement bs = do
+ (sig, bs1) <- decodeSignature bs
+ (features, bs2) <- decodeFeatureBits bs1
+ (timestamp, bs3) <- decodeU32 bs2
+ (nid, bs4) <- decodeNodeId bs3
+ (color, bs5) <- decodeRgbColor bs4
+ (al, bs6) <- decodeAlias bs5
+ (addrs, rest) <- decodeAddresses bs6
+ let msg = NodeAnnouncement
+ { nodeAnnSignature = sig
+ , nodeAnnFeatures = features
+ , nodeAnnTimestamp = timestamp
+ , nodeAnnNodeId = nid
+ , nodeAnnRgbColor = color
+ , nodeAnnAlias = al
+ , nodeAnnAddresses = addrs
+ }
+ Right (msg, rest)
+
+-- Channel update --------------------------------------------------------------
+
+-- | Encode channel_update message.
+encodeChannelUpdate :: ChannelUpdate -> ByteString
+encodeChannelUpdate msg = mconcat
+ [ getSignature (chanUpdateSignature msg)
+ , getChainHash (chanUpdateChainHash msg)
+ , getShortChannelId (chanUpdateShortChanId msg)
+ , Prim.u32 (chanUpdateTimestamp msg)
+ , BS.singleton (chanUpdateMsgFlags msg)
+ , BS.singleton (chanUpdateChanFlags msg)
+ , Prim.u16 (chanUpdateCltvExpDelta msg)
+ , Prim.u64 (chanUpdateHtlcMinMsat msg)
+ , Prim.u32 (chanUpdateFeeBaseMsat msg)
+ , Prim.u32 (chanUpdateFeeProportional msg)
+ , case chanUpdateHtlcMaxMsat msg of
+ Nothing -> BS.empty
+ Just m -> Prim.u64 m
+ ]
+
+-- | Decode channel_update message.
+decodeChannelUpdate :: ByteString
+ -> Either DecodeError (ChannelUpdate, ByteString)
+decodeChannelUpdate bs = do
+ (sig, bs1) <- decodeSignature bs
+ (chainH, bs2) <- decodeChainHash bs1
+ (scid, bs3) <- decodeShortChannelId bs2
+ (timestamp, bs4) <- decodeU32 bs3
+ (msgFlags, bs5) <- decodeU8 bs4
+ (chanFlags, bs6) <- decodeU8 bs5
+ (cltvDelta, bs7) <- decodeU16 bs6
+ (htlcMin, bs8) <- decodeU64 bs7
+ (feeBase, bs9) <- decodeU32 bs8
+ (feeProp, bs10) <- decodeU32 bs9
+ -- htlc_maximum_msat is present if message_flags bit 0 is set
+ (htlcMax, rest) <- if msgFlags .&. 0x01 /= 0
+ then do
+ (m, r) <- decodeU64 bs10
+ Right (Just m, r)
+ else Right (Nothing, bs10)
+ let msg = ChannelUpdate
+ { chanUpdateSignature = sig
+ , chanUpdateChainHash = chainH
+ , chanUpdateShortChanId = scid
+ , chanUpdateTimestamp = timestamp
+ , chanUpdateMsgFlags = msgFlags
+ , chanUpdateChanFlags = chanFlags
+ , chanUpdateCltvExpDelta = cltvDelta
+ , chanUpdateHtlcMinMsat = htlcMin
+ , chanUpdateFeeBaseMsat = feeBase
+ , chanUpdateFeeProportional = feeProp
+ , chanUpdateHtlcMaxMsat = htlcMax
+ }
+ Right (msg, rest)
+
+-- Announcement signatures -----------------------------------------------------
+
+-- | Encode announcement_signatures message.
+encodeAnnouncementSignatures :: AnnouncementSignatures -> ByteString
+encodeAnnouncementSignatures msg = mconcat
+ [ getChannelId (annSigChannelId msg)
+ , getShortChannelId (annSigShortChanId msg)
+ , getSignature (annSigNodeSig msg)
+ , getSignature (annSigBitcoinSig msg)
+ ]
+
+-- | Decode announcement_signatures message.
+decodeAnnouncementSignatures :: ByteString
+ -> Either DecodeError
+ (AnnouncementSignatures, ByteString)
+decodeAnnouncementSignatures bs = do
+ (cid, bs1) <- decodeChannelId bs
+ (scid, bs2) <- decodeShortChannelId bs1
+ (nodeSig, bs3) <- decodeSignature bs2
+ (btcSig, rest) <- decodeSignature bs3
+ let msg = AnnouncementSignatures
+ { annSigChannelId = cid
+ , annSigShortChanId = scid
+ , annSigNodeSig = nodeSig
+ , annSigBitcoinSig = btcSig
+ }
+ Right (msg, rest)
+
+-- Query messages --------------------------------------------------------------
+
+-- | Encode query_short_channel_ids message.
+encodeQueryShortChannelIds :: QueryShortChannelIds
+ -> Either EncodeError ByteString
+encodeQueryShortChannelIds msg = do
+ let scidData = queryScidsData msg
+ if BS.length scidData > 65535
+ then Left EncodeLengthOverflow
+ else Right $ mconcat
+ [ getChainHash (queryScidsChainHash msg)
+ , Prim.u16 (fromIntegral $ BS.length scidData)
+ , scidData
+ , TLV.encodeTlvStream (queryScidsTlvs msg)
+ ]
+
+-- | Decode query_short_channel_ids message.
+decodeQueryShortChannelIds :: ByteString
+ -> Either DecodeError
+ (QueryShortChannelIds, ByteString)
+decodeQueryShortChannelIds bs = do
+ (chainH, bs1) <- decodeChainHash bs
+ (scidData, bs2) <- decodeLenPrefixed bs1
+ let tlvs = case TLV.decodeTlvStreamRaw bs2 of
+ Left _ -> TlvStream []
+ Right (t, _) -> t
+ let msg = QueryShortChannelIds
+ { queryScidsChainHash = chainH
+ , queryScidsData = scidData
+ , queryScidsTlvs = tlvs
+ }
+ Right (msg, BS.empty)
+
+-- | Encode reply_short_channel_ids_end message.
+encodeReplyShortChannelIdsEnd :: ReplyShortChannelIdsEnd -> ByteString
+encodeReplyShortChannelIdsEnd msg = mconcat
+ [ getChainHash (replyScidsChainHash msg)
+ , BS.singleton (replyScidsFullInfo msg)
+ ]
+
+-- | Decode reply_short_channel_ids_end message.
+decodeReplyShortChannelIdsEnd :: ByteString
+ -> Either DecodeError
+ (ReplyShortChannelIdsEnd, ByteString)
+decodeReplyShortChannelIdsEnd bs = do
+ (chainH, bs1) <- decodeChainHash bs
+ (fullInfo, rest) <- decodeU8 bs1
+ let msg = ReplyShortChannelIdsEnd
+ { replyScidsChainHash = chainH
+ , replyScidsFullInfo = fullInfo
+ }
+ Right (msg, rest)
+
+-- | Encode query_channel_range message.
+encodeQueryChannelRange :: QueryChannelRange -> ByteString
+encodeQueryChannelRange msg = mconcat
+ [ getChainHash (queryRangeChainHash msg)
+ , Prim.u32 (queryRangeFirstBlock msg)
+ , Prim.u32 (queryRangeNumBlocks msg)
+ , TLV.encodeTlvStream (queryRangeTlvs msg)
+ ]
+
+-- | Decode query_channel_range message.
+decodeQueryChannelRange :: ByteString
+ -> Either DecodeError (QueryChannelRange, ByteString)
+decodeQueryChannelRange bs = do
+ (chainH, bs1) <- decodeChainHash bs
+ (firstBlock, bs2) <- decodeU32 bs1
+ (numBlocks, bs3) <- decodeU32 bs2
+ let tlvs = case TLV.decodeTlvStreamRaw bs3 of
+ Left _ -> TlvStream []
+ Right (t, _) -> t
+ let msg = QueryChannelRange
+ { queryRangeChainHash = chainH
+ , queryRangeFirstBlock = firstBlock
+ , queryRangeNumBlocks = numBlocks
+ , queryRangeTlvs = tlvs
+ }
+ Right (msg, BS.empty)
+
+-- | Encode reply_channel_range message.
+encodeReplyChannelRange :: ReplyChannelRange -> Either EncodeError ByteString
+encodeReplyChannelRange msg = do
+ let rangeData = replyRangeData msg
+ if BS.length rangeData > 65535
+ then Left EncodeLengthOverflow
+ else Right $ mconcat
+ [ getChainHash (replyRangeChainHash msg)
+ , Prim.u32 (replyRangeFirstBlock msg)
+ , Prim.u32 (replyRangeNumBlocks msg)
+ , BS.singleton (replyRangeSyncComplete msg)
+ , Prim.u16 (fromIntegral $ BS.length rangeData)
+ , rangeData
+ , TLV.encodeTlvStream (replyRangeTlvs msg)
+ ]
+
+-- | Decode reply_channel_range message.
+decodeReplyChannelRange :: ByteString
+ -> Either DecodeError (ReplyChannelRange, ByteString)
+decodeReplyChannelRange bs = do
+ (chainH, bs1) <- decodeChainHash bs
+ (firstBlock, bs2) <- decodeU32 bs1
+ (numBlocks, bs3) <- decodeU32 bs2
+ (syncComplete, bs4) <- decodeU8 bs3
+ (rangeData, bs5) <- decodeLenPrefixed bs4
+ let tlvs = case TLV.decodeTlvStreamRaw bs5 of
+ Left _ -> TlvStream []
+ Right (t, _) -> t
+ let msg = ReplyChannelRange
+ { replyRangeChainHash = chainH
+ , replyRangeFirstBlock = firstBlock
+ , replyRangeNumBlocks = numBlocks
+ , replyRangeSyncComplete = syncComplete
+ , replyRangeData = rangeData
+ , replyRangeTlvs = tlvs
+ }
+ Right (msg, BS.empty)
+
+-- | Encode gossip_timestamp_filter message.
+encodeGossipTimestampFilter :: GossipTimestampFilter -> ByteString
+encodeGossipTimestampFilter msg = mconcat
+ [ getChainHash (gossipFilterChainHash msg)
+ , Prim.u32 (gossipFilterFirstTimestamp msg)
+ , Prim.u32 (gossipFilterTimestampRange msg)
+ ]
+
+-- | Decode gossip_timestamp_filter message.
+decodeGossipTimestampFilter :: ByteString
+ -> Either DecodeError
+ (GossipTimestampFilter, ByteString)
+decodeGossipTimestampFilter bs = do
+ (chainH, bs1) <- decodeChainHash bs
+ (firstTs, bs2) <- decodeU32 bs1
+ (tsRange, rest) <- decodeU32 bs2
+ let msg = GossipTimestampFilter
+ { gossipFilterChainHash = chainH
+ , gossipFilterFirstTimestamp = firstTs
+ , gossipFilterTimestampRange = tsRange
+ }
+ Right (msg, rest)
diff --git a/lib/Lightning/Protocol/BOLT7/Messages.hs b/lib/Lightning/Protocol/BOLT7/Messages.hs
@@ -0,0 +1,234 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE DeriveGeneric #-}
+
+-- |
+-- Module: Lightning.Protocol.BOLT7.Messages
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- BOLT #7 gossip message type definitions.
+
+module Lightning.Protocol.BOLT7.Messages (
+ -- * Message types
+ MsgType(..)
+ , msgTypeCode
+
+ -- * Channel announcement
+ , ChannelAnnouncement(..)
+
+ -- * Node announcement
+ , NodeAnnouncement(..)
+
+ -- * Channel update
+ , ChannelUpdate(..)
+
+ -- * Announcement signatures
+ , AnnouncementSignatures(..)
+
+ -- * Query messages
+ , QueryShortChannelIds(..)
+ , ReplyShortChannelIdsEnd(..)
+ , QueryChannelRange(..)
+ , ReplyChannelRange(..)
+ , GossipTimestampFilter(..)
+
+ -- * Union type
+ , Message(..)
+ ) where
+
+import Control.DeepSeq (NFData)
+import Data.ByteString (ByteString)
+import Data.Word (Word8, Word16, Word32, Word64)
+import GHC.Generics (Generic)
+import Lightning.Protocol.BOLT1 (TlvStream)
+import Lightning.Protocol.BOLT7.Types
+
+-- Message type codes ----------------------------------------------------------
+
+-- | BOLT #7 message type codes.
+data MsgType
+ = MsgChannelAnnouncement -- ^ 256
+ | MsgNodeAnnouncement -- ^ 257
+ | MsgChannelUpdate -- ^ 258
+ | MsgAnnouncementSignatures -- ^ 259
+ | MsgQueryShortChannelIds -- ^ 261
+ | MsgReplyShortChannelIdsEnd -- ^ 262
+ | MsgQueryChannelRange -- ^ 263
+ | MsgReplyChannelRange -- ^ 264
+ | MsgGossipTimestampFilter -- ^ 265
+ deriving (Eq, Show, Generic)
+
+instance NFData MsgType
+
+-- | Get numeric code for message type.
+msgTypeCode :: MsgType -> Word16
+msgTypeCode MsgChannelAnnouncement = 256
+msgTypeCode MsgNodeAnnouncement = 257
+msgTypeCode MsgChannelUpdate = 258
+msgTypeCode MsgAnnouncementSignatures = 259
+msgTypeCode MsgQueryShortChannelIds = 261
+msgTypeCode MsgReplyShortChannelIdsEnd = 262
+msgTypeCode MsgQueryChannelRange = 263
+msgTypeCode MsgReplyChannelRange = 264
+msgTypeCode MsgGossipTimestampFilter = 265
+{-# INLINE msgTypeCode #-}
+
+-- Channel announcement --------------------------------------------------------
+
+-- | channel_announcement message (type 256).
+--
+-- Announces a public channel to the network.
+data ChannelAnnouncement = ChannelAnnouncement
+ { channelAnnNodeSig1 :: !Signature -- ^ Signature from node_id_1
+ , channelAnnNodeSig2 :: !Signature -- ^ Signature from node_id_2
+ , channelAnnBitcoinSig1 :: !Signature -- ^ Signature from bitcoin_key_1
+ , channelAnnBitcoinSig2 :: !Signature -- ^ Signature from bitcoin_key_2
+ , channelAnnFeatures :: !FeatureBits -- ^ Feature bits
+ , channelAnnChainHash :: !ChainHash -- ^ Chain identifier
+ , channelAnnShortChanId :: !ShortChannelId -- ^ Short channel ID
+ , channelAnnNodeId1 :: !NodeId -- ^ First node (lexicographically)
+ , channelAnnNodeId2 :: !NodeId -- ^ Second node
+ , channelAnnBitcoinKey1 :: !Point -- ^ Bitcoin key for node_id_1
+ , channelAnnBitcoinKey2 :: !Point -- ^ Bitcoin key for node_id_2
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData ChannelAnnouncement
+
+-- Node announcement -----------------------------------------------------------
+
+-- | node_announcement message (type 257).
+--
+-- Advertises node metadata to the network.
+data NodeAnnouncement = NodeAnnouncement
+ { nodeAnnSignature :: !Signature -- ^ Signature of message
+ , nodeAnnFeatures :: !FeatureBits -- ^ Feature bits
+ , nodeAnnTimestamp :: !Timestamp -- ^ Unix timestamp
+ , nodeAnnNodeId :: !NodeId -- ^ Node public key
+ , nodeAnnRgbColor :: !RgbColor -- ^ RGB color
+ , nodeAnnAlias :: !Alias -- ^ Node alias (32 bytes UTF-8)
+ , nodeAnnAddresses :: ![Address] -- ^ List of addresses
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData NodeAnnouncement
+
+-- Channel update --------------------------------------------------------------
+
+-- | channel_update message (type 258).
+--
+-- Communicates per-direction routing parameters.
+data ChannelUpdate = ChannelUpdate
+ { chanUpdateSignature :: !Signature -- ^ Signature of message
+ , chanUpdateChainHash :: !ChainHash -- ^ Chain identifier
+ , chanUpdateShortChanId :: !ShortChannelId -- ^ Short channel ID
+ , chanUpdateTimestamp :: !Timestamp -- ^ Unix timestamp
+ , chanUpdateMsgFlags :: !Word8 -- ^ Message flags
+ , chanUpdateChanFlags :: !Word8 -- ^ Channel flags
+ , chanUpdateCltvExpDelta :: !CltvExpiryDelta -- ^ CLTV expiry delta
+ , chanUpdateHtlcMinMsat :: !HtlcMinimumMsat -- ^ Minimum HTLC msat
+ , chanUpdateFeeBaseMsat :: !FeeBaseMsat -- ^ Base fee msat
+ , chanUpdateFeeProportional :: !FeeProportionalMillionths -- ^ Prop fee
+ , chanUpdateHtlcMaxMsat :: !(Maybe HtlcMaximumMsat) -- ^ Max HTLC (optional)
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData ChannelUpdate
+
+-- Announcement signatures -----------------------------------------------------
+
+-- | announcement_signatures message (type 259).
+--
+-- Sent between channel peers to enable channel announcement.
+data AnnouncementSignatures = AnnouncementSignatures
+ { annSigChannelId :: !ChannelId -- ^ Channel ID
+ , annSigShortChanId :: !ShortChannelId -- ^ Short channel ID
+ , annSigNodeSig :: !Signature -- ^ Node signature
+ , annSigBitcoinSig :: !Signature -- ^ Bitcoin signature
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData AnnouncementSignatures
+
+-- Query messages --------------------------------------------------------------
+
+-- | query_short_channel_ids message (type 261).
+--
+-- Requests information about specific channels.
+data QueryShortChannelIds = QueryShortChannelIds
+ { queryScidsChainHash :: !ChainHash -- ^ Chain identifier
+ , queryScidsData :: !ByteString -- ^ Encoded short_channel_ids
+ , queryScidsTlvs :: !TlvStream -- ^ Optional TLV (query_flags)
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData QueryShortChannelIds
+
+-- | reply_short_channel_ids_end message (type 262).
+--
+-- Concludes response to query_short_channel_ids.
+data ReplyShortChannelIdsEnd = ReplyShortChannelIdsEnd
+ { replyScidsChainHash :: !ChainHash -- ^ Chain identifier
+ , replyScidsFullInfo :: !Word8 -- ^ 1 if complete, 0 otherwise
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData ReplyShortChannelIdsEnd
+
+-- | query_channel_range message (type 263).
+--
+-- Queries channels within a block range.
+data QueryChannelRange = QueryChannelRange
+ { queryRangeChainHash :: !ChainHash -- ^ Chain identifier
+ , queryRangeFirstBlock :: !Word32 -- ^ First block number
+ , queryRangeNumBlocks :: !Word32 -- ^ Number of blocks
+ , queryRangeTlvs :: !TlvStream -- ^ Optional TLV (query_option)
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData QueryChannelRange
+
+-- | reply_channel_range message (type 264).
+--
+-- Responds to query_channel_range with channel IDs.
+data ReplyChannelRange = ReplyChannelRange
+ { replyRangeChainHash :: !ChainHash -- ^ Chain identifier
+ , replyRangeFirstBlock :: !Word32 -- ^ First block number
+ , replyRangeNumBlocks :: !Word32 -- ^ Number of blocks
+ , replyRangeSyncComplete :: !Word8 -- ^ 1 if sync complete
+ , replyRangeData :: !ByteString -- ^ Encoded short_channel_ids
+ , replyRangeTlvs :: !TlvStream -- ^ Optional TLVs
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData ReplyChannelRange
+
+-- | gossip_timestamp_filter message (type 265).
+--
+-- Constrains which gossip messages are relayed.
+data GossipTimestampFilter = GossipTimestampFilter
+ { gossipFilterChainHash :: !ChainHash -- ^ Chain identifier
+ , gossipFilterFirstTimestamp :: !Word32 -- ^ First timestamp
+ , gossipFilterTimestampRange :: !Word32 -- ^ Timestamp range
+ }
+ deriving (Eq, Show, Generic)
+
+instance NFData GossipTimestampFilter
+
+-- Union type ------------------------------------------------------------------
+
+-- | Union of all BOLT #7 message types.
+data Message
+ = MsgChanAnn !ChannelAnnouncement
+ | MsgNodeAnn !NodeAnnouncement
+ | MsgChanUpd !ChannelUpdate
+ | MsgAnnSig !AnnouncementSignatures
+ | MsgQueryScids !QueryShortChannelIds
+ | MsgReplyScids !ReplyShortChannelIdsEnd
+ | MsgQueryRange !QueryChannelRange
+ | MsgReplyRange !ReplyChannelRange
+ | MsgGossipFilter !GossipTimestampFilter
+ deriving (Eq, Show, Generic)
+
+instance NFData Message
diff --git a/lib/Lightning/Protocol/BOLT7/Types.hs b/lib/Lightning/Protocol/BOLT7/Types.hs
@@ -0,0 +1,363 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE DeriveGeneric #-}
+
+-- |
+-- Module: Lightning.Protocol.BOLT7.Types
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Core types for BOLT #7 routing gossip.
+
+module Lightning.Protocol.BOLT7.Types (
+ -- * Identifiers
+ ChainHash
+ , chainHash
+ , getChainHash
+ , ShortChannelId
+ , shortChannelId
+ , getShortChannelId
+ , scidBlockHeight
+ , scidTxIndex
+ , scidOutputIndex
+ , ChannelId
+ , channelId
+ , getChannelId
+
+ -- * Cryptographic types
+ , Signature
+ , signature
+ , getSignature
+ , Point
+ , point
+ , getPoint
+ , NodeId
+ , nodeId
+ , getNodeId
+
+ -- * Node metadata
+ , RgbColor
+ , rgbColor
+ , getRgbColor
+ , Alias
+ , alias
+ , getAlias
+ , Timestamp
+ , FeatureBits
+ , featureBits
+ , getFeatureBits
+
+ -- * Address types
+ , Address(..)
+ , IPv4Addr
+ , ipv4Addr
+ , getIPv4Addr
+ , IPv6Addr
+ , ipv6Addr
+ , getIPv6Addr
+ , TorV3Addr
+ , torV3Addr
+ , getTorV3Addr
+
+ -- * Routing parameters
+ , CltvExpiryDelta
+ , FeeBaseMsat
+ , FeeProportionalMillionths
+ , HtlcMinimumMsat
+ , HtlcMaximumMsat
+
+ -- * Constants
+ , chainHashLen
+ , shortChannelIdLen
+ , channelIdLen
+ , signatureLen
+ , pointLen
+ , nodeIdLen
+ , rgbColorLen
+ , aliasLen
+ , ipv4AddrLen
+ , ipv6AddrLen
+ , torV3AddrLen
+ ) where
+
+import Control.DeepSeq (NFData)
+import Data.Bits (shiftL, (.|.))
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.Word (Word16, Word32, Word64)
+import GHC.Generics (Generic)
+
+-- Constants -------------------------------------------------------------------
+
+-- | Length of a chain hash (32 bytes).
+chainHashLen :: Int
+chainHashLen = 32
+{-# INLINE chainHashLen #-}
+
+-- | Length of a short channel ID (8 bytes).
+shortChannelIdLen :: Int
+shortChannelIdLen = 8
+{-# INLINE shortChannelIdLen #-}
+
+-- | Length of a channel ID (32 bytes).
+channelIdLen :: Int
+channelIdLen = 32
+{-# INLINE channelIdLen #-}
+
+-- | Length of a signature (64 bytes).
+signatureLen :: Int
+signatureLen = 64
+{-# INLINE signatureLen #-}
+
+-- | Length of a compressed public key (33 bytes).
+pointLen :: Int
+pointLen = 33
+{-# INLINE pointLen #-}
+
+-- | Length of a node ID (33 bytes, same as compressed public key).
+nodeIdLen :: Int
+nodeIdLen = 33
+{-# INLINE nodeIdLen #-}
+
+-- | Length of RGB color (3 bytes).
+rgbColorLen :: Int
+rgbColorLen = 3
+{-# INLINE rgbColorLen #-}
+
+-- | Length of node alias (32 bytes).
+aliasLen :: Int
+aliasLen = 32
+{-# INLINE aliasLen #-}
+
+-- | Length of IPv4 address (4 bytes).
+ipv4AddrLen :: Int
+ipv4AddrLen = 4
+{-# INLINE ipv4AddrLen #-}
+
+-- | Length of IPv6 address (16 bytes).
+ipv6AddrLen :: Int
+ipv6AddrLen = 16
+{-# INLINE ipv6AddrLen #-}
+
+-- | Length of Tor v3 address (35 bytes).
+torV3AddrLen :: Int
+torV3AddrLen = 35
+{-# INLINE torV3AddrLen #-}
+
+-- Identifiers -----------------------------------------------------------------
+
+-- | Chain hash identifying the blockchain (32 bytes).
+newtype ChainHash = ChainHash { getChainHash :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData ChainHash
+
+-- | Smart constructor for ChainHash. Returns Nothing if not 32 bytes.
+chainHash :: ByteString -> Maybe ChainHash
+chainHash !bs
+ | BS.length bs == chainHashLen = Just (ChainHash bs)
+ | otherwise = Nothing
+{-# INLINE chainHash #-}
+
+-- | Short channel ID (8 bytes): block height (3) + tx index (3) + output (2).
+newtype ShortChannelId = ShortChannelId { getShortChannelId :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData ShortChannelId
+
+-- | Smart constructor for ShortChannelId. Returns Nothing if not 8 bytes.
+shortChannelId :: ByteString -> Maybe ShortChannelId
+shortChannelId !bs
+ | BS.length bs == shortChannelIdLen = Just (ShortChannelId bs)
+ | otherwise = Nothing
+{-# INLINE shortChannelId #-}
+
+-- | Extract block height from short channel ID (first 3 bytes, big-endian).
+scidBlockHeight :: ShortChannelId -> Word32
+scidBlockHeight (ShortChannelId bs) =
+ let b0 = fromIntegral (BS.index bs 0)
+ b1 = fromIntegral (BS.index bs 1)
+ b2 = fromIntegral (BS.index bs 2)
+ in (b0 `shiftL` 16) .|. (b1 `shiftL` 8) .|. b2
+{-# INLINE scidBlockHeight #-}
+
+-- | Extract transaction index from short channel ID (bytes 3-5, big-endian).
+scidTxIndex :: ShortChannelId -> Word32
+scidTxIndex (ShortChannelId bs) =
+ let b3 = fromIntegral (BS.index bs 3)
+ b4 = fromIntegral (BS.index bs 4)
+ b5 = fromIntegral (BS.index bs 5)
+ in (b3 `shiftL` 16) .|. (b4 `shiftL` 8) .|. b5
+{-# INLINE scidTxIndex #-}
+
+-- | Extract output index from short channel ID (last 2 bytes, big-endian).
+scidOutputIndex :: ShortChannelId -> Word16
+scidOutputIndex (ShortChannelId bs) =
+ let b6 = fromIntegral (BS.index bs 6)
+ b7 = fromIntegral (BS.index bs 7)
+ in (b6 `shiftL` 8) .|. b7
+{-# INLINE scidOutputIndex #-}
+
+-- | Channel ID (32 bytes).
+newtype ChannelId = ChannelId { getChannelId :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData ChannelId
+
+-- | Smart constructor for ChannelId. Returns Nothing if not 32 bytes.
+channelId :: ByteString -> Maybe ChannelId
+channelId !bs
+ | BS.length bs == channelIdLen = Just (ChannelId bs)
+ | otherwise = Nothing
+{-# INLINE channelId #-}
+
+-- Cryptographic types ---------------------------------------------------------
+
+-- | Signature (64 bytes).
+newtype Signature = Signature { getSignature :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData Signature
+
+-- | Smart constructor for Signature. Returns Nothing if not 64 bytes.
+signature :: ByteString -> Maybe Signature
+signature !bs
+ | BS.length bs == signatureLen = Just (Signature bs)
+ | otherwise = Nothing
+{-# INLINE signature #-}
+
+-- | Compressed public key (33 bytes).
+newtype Point = Point { getPoint :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData Point
+
+-- | Smart constructor for Point. Returns Nothing if not 33 bytes.
+point :: ByteString -> Maybe Point
+point !bs
+ | BS.length bs == pointLen = Just (Point bs)
+ | otherwise = Nothing
+{-# INLINE point #-}
+
+-- | Node ID (33 bytes, same as compressed public key).
+newtype NodeId = NodeId { getNodeId :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData NodeId
+
+-- | Smart constructor for NodeId. Returns Nothing if not 33 bytes.
+nodeId :: ByteString -> Maybe NodeId
+nodeId !bs
+ | BS.length bs == nodeIdLen = Just (NodeId bs)
+ | otherwise = Nothing
+{-# INLINE nodeId #-}
+
+-- Node metadata ---------------------------------------------------------------
+
+-- | RGB color (3 bytes).
+newtype RgbColor = RgbColor { getRgbColor :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData RgbColor
+
+-- | Smart constructor for RgbColor. Returns Nothing if not 3 bytes.
+rgbColor :: ByteString -> Maybe RgbColor
+rgbColor !bs
+ | BS.length bs == rgbColorLen = Just (RgbColor bs)
+ | otherwise = Nothing
+{-# INLINE rgbColor #-}
+
+-- | Node alias (32 bytes, UTF-8 padded with zero bytes).
+newtype Alias = Alias { getAlias :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData Alias
+
+-- | Smart constructor for Alias. Returns Nothing if not 32 bytes.
+alias :: ByteString -> Maybe Alias
+alias !bs
+ | BS.length bs == aliasLen = Just (Alias bs)
+ | otherwise = Nothing
+{-# INLINE alias #-}
+
+-- | Timestamp (Unix epoch seconds).
+type Timestamp = Word32
+
+-- | Feature bits (variable length).
+newtype FeatureBits = FeatureBits { getFeatureBits :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData FeatureBits
+
+-- | Smart constructor for FeatureBits (any length).
+featureBits :: ByteString -> FeatureBits
+featureBits = FeatureBits
+{-# INLINE featureBits #-}
+
+-- Address types ---------------------------------------------------------------
+
+-- | IPv4 address (4 bytes).
+newtype IPv4Addr = IPv4Addr { getIPv4Addr :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData IPv4Addr
+
+-- | Smart constructor for IPv4Addr. Returns Nothing if not 4 bytes.
+ipv4Addr :: ByteString -> Maybe IPv4Addr
+ipv4Addr !bs
+ | BS.length bs == ipv4AddrLen = Just (IPv4Addr bs)
+ | otherwise = Nothing
+{-# INLINE ipv4Addr #-}
+
+-- | IPv6 address (16 bytes).
+newtype IPv6Addr = IPv6Addr { getIPv6Addr :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData IPv6Addr
+
+-- | Smart constructor for IPv6Addr. Returns Nothing if not 16 bytes.
+ipv6Addr :: ByteString -> Maybe IPv6Addr
+ipv6Addr !bs
+ | BS.length bs == ipv6AddrLen = Just (IPv6Addr bs)
+ | otherwise = Nothing
+{-# INLINE ipv6Addr #-}
+
+-- | Tor v3 onion address (35 bytes: 32 pubkey + 2 checksum + 1 version).
+newtype TorV3Addr = TorV3Addr { getTorV3Addr :: ByteString }
+ deriving (Eq, Show, Generic)
+
+instance NFData TorV3Addr
+
+-- | Smart constructor for TorV3Addr. Returns Nothing if not 35 bytes.
+torV3Addr :: ByteString -> Maybe TorV3Addr
+torV3Addr !bs
+ | BS.length bs == torV3AddrLen = Just (TorV3Addr bs)
+ | otherwise = Nothing
+{-# INLINE torV3Addr #-}
+
+-- | Network address with port.
+data Address
+ = AddrIPv4 !IPv4Addr !Word16 -- ^ IPv4 address + port
+ | AddrIPv6 !IPv6Addr !Word16 -- ^ IPv6 address + port
+ | AddrTorV3 !TorV3Addr !Word16 -- ^ Tor v3 address + port
+ | AddrDNS !ByteString !Word16 -- ^ DNS hostname + port
+ deriving (Eq, Show, Generic)
+
+instance NFData Address
+
+-- Routing parameters ----------------------------------------------------------
+
+-- | CLTV expiry delta.
+type CltvExpiryDelta = Word16
+
+-- | Base fee in millisatoshis.
+type FeeBaseMsat = Word32
+
+-- | Proportional fee in millionths.
+type FeeProportionalMillionths = Word32
+
+-- | Minimum HTLC value in millisatoshis.
+type HtlcMinimumMsat = Word64
+
+-- | Maximum HTLC value in millisatoshis.
+type HtlcMaximumMsat = Word64
diff --git a/ppad-bolt7.cabal b/ppad-bolt7.cabal
@@ -0,0 +1,88 @@
+cabal-version: 3.0
+name: ppad-bolt7
+version: 0.0.1
+synopsis: Routing gossip per BOLT #7
+license: MIT
+license-file: LICENSE
+author: Jared Tobin
+maintainer: jared@ppad.tech
+category: Cryptography
+build-type: Simple
+tested-with: GHC == 9.10.3
+extra-doc-files: CHANGELOG
+description:
+ Routing gossip protocol, per
+ [BOLT #7](https://github.com/lightning/bolts/blob/master/07-routing-gossip.md).
+
+source-repository head
+ type: git
+ location: git.ppad.tech/bolt7.git
+
+library
+ default-language: Haskell2010
+ hs-source-dirs: lib
+ ghc-options:
+ -Wall
+ exposed-modules:
+ Lightning.Protocol.BOLT7
+ Lightning.Protocol.BOLT7.Codec
+ Lightning.Protocol.BOLT7.Messages
+ Lightning.Protocol.BOLT7.Types
+ build-depends:
+ base >= 4.9 && < 5
+ , bytestring >= 0.9 && < 0.13
+ , deepseq >= 1.4 && < 1.6
+ , ppad-bolt1
+
+test-suite bolt7-tests
+ type: exitcode-stdio-1.0
+ default-language: Haskell2010
+ hs-source-dirs: test
+ main-is: Main.hs
+
+ ghc-options:
+ -rtsopts -Wall -O2
+
+ build-depends:
+ base
+ , bytestring
+ , ppad-base16
+ , ppad-bolt1
+ , ppad-bolt7
+ , tasty
+ , tasty-hunit
+ , tasty-quickcheck
+
+benchmark bolt7-bench
+ type: exitcode-stdio-1.0
+ default-language: Haskell2010
+ hs-source-dirs: bench
+ main-is: Main.hs
+
+ ghc-options:
+ -rtsopts -O2 -Wall -fno-warn-orphans
+
+ build-depends:
+ base
+ , bytestring
+ , criterion
+ , deepseq
+ , ppad-bolt1
+ , ppad-bolt7
+
+benchmark bolt7-weigh
+ type: exitcode-stdio-1.0
+ default-language: Haskell2010
+ hs-source-dirs: bench
+ main-is: Weight.hs
+
+ ghc-options:
+ -rtsopts -O2 -Wall -fno-warn-orphans
+
+ build-depends:
+ base
+ , bytestring
+ , deepseq
+ , ppad-bolt1
+ , ppad-bolt7
+ , weigh
diff --git a/test/Main.hs b/test/Main.hs
@@ -0,0 +1,412 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base16 as B16
+import Data.Maybe (fromJust)
+import Data.Word (Word8, Word16, Word32)
+import Lightning.Protocol.BOLT1 (TlvStream(..))
+import Lightning.Protocol.BOLT7
+import Test.Tasty
+import Test.Tasty.HUnit
+import Test.Tasty.QuickCheck
+
+main :: IO ()
+main = defaultMain $ testGroup "ppad-bolt7" [
+ type_tests
+ , channel_announcement_tests
+ , node_announcement_tests
+ , channel_update_tests
+ , announcement_signatures_tests
+ , query_tests
+ , error_tests
+ , property_tests
+ ]
+
+-- Test data helpers -----------------------------------------------------------
+
+-- | Create a valid ChainHash (32 bytes).
+testChainHash :: ChainHash
+testChainHash = fromJust $ chainHash (BS.replicate 32 0x01)
+
+-- | Create a valid ShortChannelId (8 bytes).
+testShortChannelId :: ShortChannelId
+testShortChannelId = fromJust $ shortChannelId (BS.replicate 8 0xab)
+
+-- | Create a valid ChannelId (32 bytes).
+testChannelId :: ChannelId
+testChannelId = fromJust $ channelId (BS.replicate 32 0xcd)
+
+-- | Create a valid Signature (64 bytes).
+testSignature :: Signature
+testSignature = fromJust $ signature (BS.replicate 64 0xee)
+
+-- | Create a valid Point (33 bytes).
+testPoint :: Point
+testPoint = fromJust $ point (BS.pack $ 0x02 : replicate 32 0xff)
+
+-- | Create a valid NodeId (33 bytes).
+testNodeId :: NodeId
+testNodeId = fromJust $ nodeId (BS.pack $ 0x03 : replicate 32 0xaa)
+
+-- | Create a second valid NodeId (33 bytes).
+testNodeId2 :: NodeId
+testNodeId2 = fromJust $ nodeId (BS.pack $ 0x02 : replicate 32 0xbb)
+
+-- | Create a valid RgbColor (3 bytes).
+testRgbColor :: RgbColor
+testRgbColor = fromJust $ rgbColor (BS.pack [0xff, 0x00, 0x00])
+
+-- | Create a valid Alias (32 bytes).
+testAlias :: Alias
+testAlias = fromJust $ alias (BS.pack $ replicate 32 0x00)
+
+-- | Empty TLV stream for messages.
+emptyTlvs :: TlvStream
+emptyTlvs = TlvStream []
+
+-- | Empty feature bits.
+emptyFeatures :: FeatureBits
+emptyFeatures = featureBits BS.empty
+
+-- Type Tests ------------------------------------------------------------------
+
+type_tests :: TestTree
+type_tests = testGroup "Types" [
+ testGroup "ShortChannelId" [
+ testCase "scidBlockHeight" $ do
+ -- 8 bytes: block=0x123456, tx=0x789abc, output=0xdef0
+ let scid = fromJust $ shortChannelId (BS.pack
+ [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])
+ scidBlockHeight scid @?= 0x123456
+ , testCase "scidTxIndex" $ do
+ let scid = fromJust $ shortChannelId (BS.pack
+ [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])
+ scidTxIndex scid @?= 0x789abc
+ , testCase "scidOutputIndex" $ do
+ let scid = fromJust $ shortChannelId (BS.pack
+ [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])
+ scidOutputIndex scid @?= 0xdef0
+ ]
+ , testGroup "Smart constructors" [
+ testCase "chainHash rejects wrong length" $ do
+ chainHash (BS.replicate 31 0x00) @?= Nothing
+ chainHash (BS.replicate 33 0x00) @?= Nothing
+ , testCase "shortChannelId rejects wrong length" $ do
+ shortChannelId (BS.replicate 7 0x00) @?= Nothing
+ shortChannelId (BS.replicate 9 0x00) @?= Nothing
+ , testCase "signature rejects wrong length" $ do
+ signature (BS.replicate 63 0x00) @?= Nothing
+ signature (BS.replicate 65 0x00) @?= Nothing
+ , testCase "point rejects wrong length" $ do
+ point (BS.replicate 32 0x00) @?= Nothing
+ point (BS.replicate 34 0x00) @?= Nothing
+ ]
+ ]
+
+-- Channel Announcement Tests --------------------------------------------------
+
+channel_announcement_tests :: TestTree
+channel_announcement_tests = testGroup "ChannelAnnouncement" [
+ testCase "encode/decode roundtrip" $ do
+ let msg = ChannelAnnouncement
+ { channelAnnNodeSig1 = testSignature
+ , channelAnnNodeSig2 = testSignature
+ , channelAnnBitcoinSig1 = testSignature
+ , channelAnnBitcoinSig2 = testSignature
+ , channelAnnFeatures = emptyFeatures
+ , channelAnnChainHash = testChainHash
+ , channelAnnShortChanId = testShortChannelId
+ , channelAnnNodeId1 = testNodeId
+ , channelAnnNodeId2 = testNodeId2
+ , channelAnnBitcoinKey1 = testPoint
+ , channelAnnBitcoinKey2 = testPoint
+ }
+ encoded = encodeChannelAnnouncement msg
+ case decodeChannelAnnouncement encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+
+-- Node Announcement Tests -----------------------------------------------------
+
+node_announcement_tests :: TestTree
+node_announcement_tests = testGroup "NodeAnnouncement" [
+ testCase "encode/decode roundtrip with no addresses" $ do
+ let msg = NodeAnnouncement
+ { nodeAnnSignature = testSignature
+ , nodeAnnFeatures = emptyFeatures
+ , nodeAnnTimestamp = 1234567890
+ , nodeAnnNodeId = testNodeId
+ , nodeAnnRgbColor = testRgbColor
+ , nodeAnnAlias = testAlias
+ , nodeAnnAddresses = []
+ }
+ case encodeNodeAnnouncement msg of
+ Left e -> assertFailure $ "encode failed: " ++ show e
+ Right encoded -> case decodeNodeAnnouncement encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ , testCase "encode/decode roundtrip with IPv4 address" $ do
+ let ipv4 = fromJust $ ipv4Addr (BS.pack [127, 0, 0, 1])
+ msg = NodeAnnouncement
+ { nodeAnnSignature = testSignature
+ , nodeAnnFeatures = emptyFeatures
+ , nodeAnnTimestamp = 1234567890
+ , nodeAnnNodeId = testNodeId
+ , nodeAnnRgbColor = testRgbColor
+ , nodeAnnAlias = testAlias
+ , nodeAnnAddresses = [AddrIPv4 ipv4 9735]
+ }
+ case encodeNodeAnnouncement msg of
+ Left e -> assertFailure $ "encode failed: " ++ show e
+ Right encoded -> case decodeNodeAnnouncement encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+
+-- Channel Update Tests --------------------------------------------------------
+
+channel_update_tests :: TestTree
+channel_update_tests = testGroup "ChannelUpdate" [
+ testCase "encode/decode roundtrip without htlc_maximum_msat" $ do
+ let msg = ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = 1234567890
+ , chanUpdateMsgFlags = 0x00
+ , chanUpdateChanFlags = 0x01
+ , chanUpdateCltvExpDelta = 144
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Nothing
+ }
+ encoded = encodeChannelUpdate msg
+ case decodeChannelUpdate encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ , testCase "encode/decode roundtrip with htlc_maximum_msat" $ do
+ let msg = ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = 1234567890
+ , chanUpdateMsgFlags = 0x01 -- bit 0 set
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = 40
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 500
+ , chanUpdateFeeProportional = 50
+ , chanUpdateHtlcMaxMsat = Just 1000000000
+ }
+ encoded = encodeChannelUpdate msg
+ case decodeChannelUpdate encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+
+-- Announcement Signatures Tests -----------------------------------------------
+
+announcement_signatures_tests :: TestTree
+announcement_signatures_tests = testGroup "AnnouncementSignatures" [
+ testCase "encode/decode roundtrip" $ do
+ let msg = AnnouncementSignatures
+ { annSigChannelId = testChannelId
+ , annSigShortChanId = testShortChannelId
+ , annSigNodeSig = testSignature
+ , annSigBitcoinSig = testSignature
+ }
+ encoded = encodeAnnouncementSignatures msg
+ case decodeAnnouncementSignatures encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+
+-- Query Tests -----------------------------------------------------------------
+
+query_tests :: TestTree
+query_tests = testGroup "Query Messages" [
+ testGroup "QueryShortChannelIds" [
+ testCase "encode/decode roundtrip" $ do
+ let msg = QueryShortChannelIds
+ { queryScidsChainHash = testChainHash
+ , queryScidsData = BS.replicate 24 0xab -- 3 SCIDs
+ , queryScidsTlvs = emptyTlvs
+ }
+ case encodeQueryShortChannelIds msg of
+ Left e -> assertFailure $ "encode failed: " ++ show e
+ Right encoded -> case decodeQueryShortChannelIds encoded of
+ Right (decoded, _) -> do
+ queryScidsChainHash decoded @?= queryScidsChainHash msg
+ queryScidsData decoded @?= queryScidsData msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+ , testGroup "ReplyShortChannelIdsEnd" [
+ testCase "encode/decode roundtrip" $ do
+ let msg = ReplyShortChannelIdsEnd
+ { replyScidsChainHash = testChainHash
+ , replyScidsFullInfo = 1
+ }
+ encoded = encodeReplyShortChannelIdsEnd msg
+ case decodeReplyShortChannelIdsEnd encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+ , testGroup "QueryChannelRange" [
+ testCase "encode/decode roundtrip" $ do
+ let msg = QueryChannelRange
+ { queryRangeChainHash = testChainHash
+ , queryRangeFirstBlock = 600000
+ , queryRangeNumBlocks = 10000
+ , queryRangeTlvs = emptyTlvs
+ }
+ encoded = encodeQueryChannelRange msg
+ case decodeQueryChannelRange encoded of
+ Right (decoded, _) -> do
+ queryRangeChainHash decoded @?= queryRangeChainHash msg
+ queryRangeFirstBlock decoded @?= queryRangeFirstBlock msg
+ queryRangeNumBlocks decoded @?= queryRangeNumBlocks msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+ , testGroup "ReplyChannelRange" [
+ testCase "encode/decode roundtrip" $ do
+ let msg = ReplyChannelRange
+ { replyRangeChainHash = testChainHash
+ , replyRangeFirstBlock = 600000
+ , replyRangeNumBlocks = 10000
+ , replyRangeSyncComplete = 1
+ , replyRangeData = BS.replicate 16 0xcd
+ , replyRangeTlvs = emptyTlvs
+ }
+ case encodeReplyChannelRange msg of
+ Left e -> assertFailure $ "encode failed: " ++ show e
+ Right encoded -> case decodeReplyChannelRange encoded of
+ Right (decoded, _) -> do
+ replyRangeChainHash decoded @?= replyRangeChainHash msg
+ replyRangeFirstBlock decoded @?= replyRangeFirstBlock msg
+ replyRangeNumBlocks decoded @?= replyRangeNumBlocks msg
+ replyRangeSyncComplete decoded @?= replyRangeSyncComplete msg
+ replyRangeData decoded @?= replyRangeData msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+ , testGroup "GossipTimestampFilter" [
+ testCase "encode/decode roundtrip" $ do
+ let msg = GossipTimestampFilter
+ { gossipFilterChainHash = testChainHash
+ , gossipFilterFirstTimestamp = 1609459200
+ , gossipFilterTimestampRange = 86400
+ }
+ encoded = encodeGossipTimestampFilter msg
+ case decodeGossipTimestampFilter encoded of
+ Right (decoded, _) -> decoded @?= msg
+ Left e -> assertFailure $ "decode failed: " ++ show e
+ ]
+ ]
+
+-- Error Tests -----------------------------------------------------------------
+
+error_tests :: TestTree
+error_tests = testGroup "Error Conditions" [
+ testGroup "Insufficient Bytes" [
+ testCase "decodeChannelAnnouncement empty" $ do
+ case decodeChannelAnnouncement BS.empty of
+ Left DecodeInsufficientBytes -> pure ()
+ other -> assertFailure $ "expected insufficient: " ++ show other
+ , testCase "decodeChannelUpdate too short" $ do
+ case decodeChannelUpdate (BS.replicate 50 0x00) of
+ Left DecodeInsufficientBytes -> pure ()
+ other -> assertFailure $ "expected insufficient: " ++ show other
+ , testCase "decodeAnnouncementSignatures too short" $ do
+ case decodeAnnouncementSignatures (BS.replicate 50 0x00) of
+ Left DecodeInsufficientBytes -> pure ()
+ other -> assertFailure $ "expected insufficient: " ++ show other
+ , testCase "decodeGossipTimestampFilter too short" $ do
+ case decodeGossipTimestampFilter (BS.replicate 30 0x00) of
+ Left DecodeInsufficientBytes -> pure ()
+ other -> assertFailure $ "expected insufficient: " ++ show other
+ ]
+ ]
+
+-- Property Tests --------------------------------------------------------------
+
+property_tests :: TestTree
+property_tests = testGroup "Properties" [
+ testProperty "ChannelAnnouncement roundtrip" propChannelAnnouncementRoundtrip
+ , testProperty "ChannelUpdate roundtrip" propChannelUpdateRoundtrip
+ , testProperty "AnnouncementSignatures roundtrip"
+ propAnnouncementSignaturesRoundtrip
+ , testProperty "GossipTimestampFilter roundtrip"
+ propGossipTimestampFilterRoundtrip
+ ]
+
+-- Property: ChannelAnnouncement roundtrip
+propChannelAnnouncementRoundtrip :: Property
+propChannelAnnouncementRoundtrip = property $ do
+ let msg = ChannelAnnouncement
+ { channelAnnNodeSig1 = testSignature
+ , channelAnnNodeSig2 = testSignature
+ , channelAnnBitcoinSig1 = testSignature
+ , channelAnnBitcoinSig2 = testSignature
+ , channelAnnFeatures = emptyFeatures
+ , channelAnnChainHash = testChainHash
+ , channelAnnShortChanId = testShortChannelId
+ , channelAnnNodeId1 = testNodeId
+ , channelAnnNodeId2 = testNodeId2
+ , channelAnnBitcoinKey1 = testPoint
+ , channelAnnBitcoinKey2 = testPoint
+ }
+ encoded = encodeChannelAnnouncement msg
+ case decodeChannelAnnouncement encoded of
+ Right (decoded, _) -> decoded == msg
+ Left _ -> False
+
+-- Property: ChannelUpdate roundtrip
+propChannelUpdateRoundtrip :: Word32 -> Word16 -> Property
+propChannelUpdateRoundtrip timestamp cltvDelta = property $ do
+ let msg = ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = timestamp
+ , chanUpdateMsgFlags = 0x00
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = cltvDelta
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Nothing
+ }
+ encoded = encodeChannelUpdate msg
+ case decodeChannelUpdate encoded of
+ Right (decoded, _) -> decoded == msg
+ Left _ -> False
+
+-- Property: AnnouncementSignatures roundtrip
+propAnnouncementSignaturesRoundtrip :: Property
+propAnnouncementSignaturesRoundtrip = property $ do
+ let msg = AnnouncementSignatures
+ { annSigChannelId = testChannelId
+ , annSigShortChanId = testShortChannelId
+ , annSigNodeSig = testSignature
+ , annSigBitcoinSig = testSignature
+ }
+ encoded = encodeAnnouncementSignatures msg
+ case decodeAnnouncementSignatures encoded of
+ Right (decoded, _) -> decoded == msg
+ Left _ -> False
+
+-- Property: GossipTimestampFilter roundtrip
+propGossipTimestampFilterRoundtrip :: Word32 -> Word32 -> Property
+propGossipTimestampFilterRoundtrip firstTs tsRange = property $ do
+ let msg = GossipTimestampFilter
+ { gossipFilterChainHash = testChainHash
+ , gossipFilterFirstTimestamp = firstTs
+ , gossipFilterTimestampRange = tsRange
+ }
+ encoded = encodeGossipTimestampFilter msg
+ case decodeGossipTimestampFilter encoded of
+ Right (decoded, _) -> decoded == msg
+ Left _ -> False