packages feed

ppad-bolt1-0.0.1: lib/Lightning/Protocol/BOLT1/TLV.hs

{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}

-- |
-- Module: Lightning.Protocol.BOLT1.TLV
-- Copyright: (c) 2025 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- TLV (Type-Length-Value) format for BOLT #1.

module Lightning.Protocol.BOLT1.TLV (
  -- * TLV types
    TlvRecord(..)
  , TlvStream
  , unTlvStream
  , tlvStream
  , unsafeTlvStream
  , TlvError(..)

  -- * TLV encoding
  , encodeTlvRecord
  , encodeTlvStream

  -- * TLV decoding
  , decodeTlvStream
  , decodeTlvStreamWith
  , decodeTlvStreamRaw

  -- * Init TLV types
  , InitTlv(..)
  , parseInitTlvs
  , encodeInitTlvs

  -- * Re-exports
  , ChainHash
  , chainHash
  , unChainHash
  ) where

import Control.DeepSeq (NFData)
import Control.Monad (when)
import qualified Data.ByteString as BS
import Data.Word (Word64)
import GHC.Generics (Generic)
import Lightning.Protocol.BOLT1.Prim

-- TLV types -------------------------------------------------------------------

-- | A single TLV record.
data TlvRecord = TlvRecord
  { tlvType   :: {-# UNPACK #-} !Word64
  , tlvValue  :: !BS.ByteString
  } deriving stock (Eq, Show, Generic)

instance NFData TlvRecord

-- | A TLV stream (series of TLV records).
newtype TlvStream = TlvStream { unTlvStream :: [TlvRecord] }
  deriving stock (Eq, Show, Generic)

instance NFData TlvStream

-- | Smart constructor for 'TlvStream' that validates records are
-- strictly increasing by type.
--
-- Returns 'Nothing' if types are not strictly increasing.
tlvStream :: [TlvRecord] -> Maybe TlvStream
tlvStream recs
  | isStrictlyIncreasing (map tlvType recs) = Just (TlvStream recs)
  | otherwise = Nothing
  where
    isStrictlyIncreasing :: [Word64] -> Bool
    isStrictlyIncreasing [] = True
    isStrictlyIncreasing [_] = True
    isStrictlyIncreasing (x:y:rest) = x < y && isStrictlyIncreasing (y:rest)

-- | Unsafe constructor for 'TlvStream' that skips validation.
--
-- Use only when ordering is already guaranteed (e.g., in decode functions).
unsafeTlvStream :: [TlvRecord] -> TlvStream
unsafeTlvStream = TlvStream

-- | TLV decoding errors.
data TlvError
  = TlvNonMinimalEncoding
  | TlvNotStrictlyIncreasing
  | TlvLengthExceedsBounds
  | TlvUnknownEvenType !Word64
  | TlvInvalidKnownType !Word64
  deriving stock (Eq, Show, Generic)

instance NFData TlvError

-- TLV encoding ----------------------------------------------------------------

-- | Encode a TLV record.
encodeTlvRecord :: TlvRecord -> BS.ByteString
encodeTlvRecord (TlvRecord typ val) = mconcat
  [ encodeBigSize typ
  , encodeBigSize (fromIntegral (BS.length val))
  , val
  ]

-- | Encode a TLV stream.
encodeTlvStream :: TlvStream -> BS.ByteString
encodeTlvStream (TlvStream recs) = mconcat (map encodeTlvRecord recs)

-- TLV decoding ----------------------------------------------------------------

-- | Decode a TLV stream without any known-type validation.
--
-- This decoder only enforces structural validity:
-- - Types must be strictly increasing
-- - Lengths must not exceed bounds
--
-- All records are returned regardless of type. Note: this does NOT
-- enforce the BOLT #1 unknown-even-type rule. Use 'decodeTlvStreamWith'
-- with an appropriate predicate for spec-compliant parsing.
decodeTlvStreamRaw :: BS.ByteString -> Either TlvError TlvStream
decodeTlvStreamRaw = go Nothing []
  where
    go :: Maybe Word64 -> [TlvRecord] -> BS.ByteString
       -> Either TlvError TlvStream
    go !_ !acc !bs
      | BS.null bs = Right (unsafeTlvStream (reverse acc))
    go !mPrevType !acc !bs = do
      (typ, rest1) <- maybe (Left TlvNonMinimalEncoding) Right
                        (decodeBigSize bs)
      -- Strictly increasing check
      case mPrevType of
        Just prevType -> when (typ <= prevType) $
          Left TlvNotStrictlyIncreasing
        Nothing -> pure ()
      (len, rest2) <- maybe (Left TlvNonMinimalEncoding) Right
                        (decodeBigSize rest1)
      -- Length bounds check
      when (fromIntegral len > BS.length rest2) $
        Left TlvLengthExceedsBounds
      let !val = BS.take (fromIntegral len) rest2
          !rest3 = BS.drop (fromIntegral len) rest2
          !rec = TlvRecord typ val
      go (Just typ) (rec : acc) rest3

-- | Decode a TLV stream with configurable known-type predicate.
--
-- Per BOLT #1:
-- - Types must be strictly increasing
-- - Unknown even types cause failure
-- - Unknown odd types are skipped
--
-- The predicate determines which types are "known" for the context.
decodeTlvStreamWith
  :: (Word64 -> Bool)  -- ^ Predicate: is this type known?
  -> BS.ByteString
  -> Either TlvError TlvStream
decodeTlvStreamWith isKnown = go Nothing []
  where
    go :: Maybe Word64 -> [TlvRecord] -> BS.ByteString
       -> Either TlvError TlvStream
    go !_ !acc !bs
      | BS.null bs = Right (unsafeTlvStream (reverse acc))
    go !mPrevType !acc !bs = do
      (typ, rest1) <- maybe (Left TlvNonMinimalEncoding) Right
                        (decodeBigSize bs)
      -- Strictly increasing check
      case mPrevType of
        Just prevType -> when (typ <= prevType) $
          Left TlvNotStrictlyIncreasing
        Nothing -> pure ()
      (len, rest2) <- maybe (Left TlvNonMinimalEncoding) Right
                        (decodeBigSize rest1)
      -- Length bounds check
      when (fromIntegral len > BS.length rest2) $
        Left TlvLengthExceedsBounds
      let !val = BS.take (fromIntegral len) rest2
          !rest3 = BS.drop (fromIntegral len) rest2
          !rec = TlvRecord typ val
      -- Unknown type handling: even = fail, odd = skip
      if isKnown typ
        then go (Just typ) (rec : acc) rest3
        else if even typ
          then Left (TlvUnknownEvenType typ)
          else go (Just typ) acc rest3  -- skip unknown odd

-- | Decode a TLV stream with BOLT #1 init_tlvs validation.
--
-- This uses the default known types for init messages (1 and 3).
-- For other contexts, use 'decodeTlvStreamWith' with an appropriate
-- predicate.
decodeTlvStream :: BS.ByteString -> Either TlvError TlvStream
decodeTlvStream = decodeTlvStreamWith isInitTlvType
  where
    isInitTlvType :: Word64 -> Bool
    isInitTlvType 1 = True  -- networks
    isInitTlvType 3 = True  -- remote_addr
    isInitTlvType _ = False

-- Init TLV types --------------------------------------------------------------

-- | TLV records for init message.
data InitTlv
  = InitNetworks ![ChainHash]      -- ^ Type 1: chain hashes (32 bytes each)
  | InitRemoteAddr !BS.ByteString  -- ^ Type 3: remote address
  deriving stock (Eq, Show, Generic)

instance NFData InitTlv

-- | Parse init TLVs from a TLV stream.
parseInitTlvs :: TlvStream -> Either TlvError [InitTlv]
parseInitTlvs (TlvStream recs) = traverse parseOne recs
  where
    parseOne (TlvRecord 1 val)
      | BS.length val `mod` 32 == 0 =
          Right (InitNetworks (map mkChainHash (chunksOf 32 val)))
      | otherwise = Left (TlvInvalidKnownType 1)
    parseOne (TlvRecord 3 val) = Right (InitRemoteAddr val)
    parseOne (TlvRecord t _) = Left (TlvUnknownEvenType t)

    -- Each chunk is exactly 32 bytes from chunksOf, so chainHash always
    -- succeeds. We use a partial pattern match as the Nothing case is
    -- unreachable given our chunksOf guarantee.
    mkChainHash bs = case chainHash bs of
      Just ch -> ch
      Nothing -> error "parseInitTlvs: impossible - chunk is not 32 bytes"

-- | Split bytestring into chunks of given size.
chunksOf :: Int -> BS.ByteString -> [BS.ByteString]
chunksOf !n !bs
  | BS.null bs = []
  | otherwise =
      let (!chunk, !rest) = BS.splitAt n bs
      in  chunk : chunksOf n rest

-- | Encode init TLVs to a TLV stream.
encodeInitTlvs :: [InitTlv] -> TlvStream
encodeInitTlvs = unsafeTlvStream . map toRecord
  where
    toRecord (InitNetworks chains) =
      TlvRecord 1 (mconcat (map unChainHash chains))
    toRecord (InitRemoteAddr addr) =
      TlvRecord 3 addr