bolt7

Routing gossip protocol, per BOLT #7.
git clone git://git.ppad.tech/bolt7.git
Log | Files | Refs | README | LICENSE

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:
A.gitignore | 1+
AAGENTS.md | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACHANGELOG | 0
ACLAUDE.md | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 20++++++++++++++++++++
AREADME.md | 44++++++++++++++++++++++++++++++++++++++++++++
Abench/Main.hs | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abench/Weight.hs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflake.nix | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT7.hs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT7/Codec.hs | 600+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT7/Messages.hs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT7/Types.hs | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Appad-bolt7.cabal | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/Main.hs | 412+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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