packages feed

ppad-bolt4-0.0.1: lib/Lightning/Protocol/BOLT4/Codec.hs

{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module: Lightning.Protocol.BOLT4.Codec
-- Copyright: (c) 2025 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Serialization and deserialization for BOLT4 types.

module Lightning.Protocol.BOLT4.Codec (
    -- * BigSize encoding
    encodeBigSize
  , decodeBigSize
  , bigSizeLen

    -- * TLV encoding
  , encodeTlv
  , decodeTlv
  , decodeTlvStream
  , encodeTlvStream

    -- * Packet serialization
  , encodeOnionPacket
  , decodeOnionPacket
  , encodeHopPayload
  , decodeHopPayload

    -- * ShortChannelId
  , encodeShortChannelId
  , decodeShortChannelId

    -- * Failure messages
  , encodeFailureMessage
  , decodeFailureMessage

    -- * Internal helpers (for Blinding)
  , toStrict
  , word16BE
  , word32BE
  , encodeWord64TU
  , decodeWord64TU
  , encodeWord32TU
  , decodeWord32TU
  ) where

import Data.Bits (shiftL, shiftR, (.&.))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Lazy as BL
import Data.Word (Word16, Word32, Word64)
import Lightning.Protocol.BOLT4.Types

-- BigSize encoding ---------------------------------------------------------

-- | Encode integer as BigSize.
--
-- * 0-0xFC: 1 byte
-- * 0xFD-0xFFFF: 0xFD ++ 2 bytes BE
-- * 0x10000-0xFFFFFFFF: 0xFE ++ 4 bytes BE
-- * larger: 0xFF ++ 8 bytes BE
encodeBigSize :: Word64 -> BS.ByteString
encodeBigSize !n
  | n < 0xFD = BS.singleton (fromIntegral n)
  | n <= 0xFFFF = toStrict $
      B.word8 0xFD <> B.word16BE (fromIntegral n)
  | n <= 0xFFFFFFFF = toStrict $
      B.word8 0xFE <> B.word32BE (fromIntegral n)
  | otherwise = toStrict $
      B.word8 0xFF <> B.word64BE n
{-# INLINE encodeBigSize #-}

-- | Decode BigSize, returning (value, remaining bytes).
decodeBigSize :: BS.ByteString -> Maybe (Word64, BS.ByteString)
decodeBigSize !bs = case BS.uncons bs of
  Nothing -> Nothing
  Just (b, rest)
    | b < 0xFD -> Just (fromIntegral b, rest)
    | b == 0xFD -> do
        (hi, r1) <- BS.uncons rest
        (lo, r2) <- BS.uncons r1
        let !val = fromIntegral hi `shiftL` 8 + fromIntegral lo
        -- Canonical: must be >= 0xFD
        if val < 0xFD then Nothing else Just (val, r2)
    | b == 0xFE -> do
        if BS.length rest < 4 then Nothing else do
          let !bytes = BS.take 4 rest
              !r = BS.drop 4 rest
              !val = word32BE bytes
          -- Canonical: must be > 0xFFFF
          if val <= 0xFFFF then Nothing else Just (fromIntegral val, r)
    | otherwise -> do  -- b == 0xFF
        if BS.length rest < 8 then Nothing else do
          let !bytes = BS.take 8 rest
              !r = BS.drop 8 rest
              !val = word64BE bytes
          -- Canonical: must be > 0xFFFFFFFF
          if val <= 0xFFFFFFFF then Nothing else Just (val, r)
{-# INLINE decodeBigSize #-}

-- | Get encoded size of a BigSize value without encoding.
bigSizeLen :: Word64 -> Int
bigSizeLen !n
  | n < 0xFD       = 1
  | n <= 0xFFFF    = 3
  | n <= 0xFFFFFFFF = 5
  | otherwise      = 9
{-# INLINE bigSizeLen #-}

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

-- | Encode a TLV record.
encodeTlv :: TlvRecord -> BS.ByteString
encodeTlv (TlvRecord !typ !val) = toStrict $
  B.byteString (encodeBigSize typ) <>
  B.byteString (encodeBigSize (fromIntegral (BS.length val))) <>
  B.byteString val
{-# INLINE encodeTlv #-}

-- | Decode a single TLV record.
decodeTlv :: BS.ByteString -> Maybe (TlvRecord, BS.ByteString)
decodeTlv !bs = do
  (typ, r1) <- decodeBigSize bs
  (len, r2) <- decodeBigSize r1
  let !len' = fromIntegral len
  if BS.length r2 < len'
    then Nothing
    else do
      let !val = BS.take len' r2
          !rest = BS.drop len' r2
      Just (TlvRecord typ val, rest)
{-# INLINE decodeTlv #-}

-- | Decode a TLV stream (sequence of records).
-- Validates strictly increasing type order.
decodeTlvStream :: BS.ByteString -> Maybe [TlvRecord]
decodeTlvStream = go Nothing
  where
    go :: Maybe Word64 -> BS.ByteString -> Maybe [TlvRecord]
    go _ !bs | BS.null bs = Just []
    go !mPrev !bs = do
      (rec@(TlvRecord typ _), rest) <- decodeTlv bs
      -- Check strictly increasing order
      case mPrev of
        Just prev | typ <= prev -> Nothing
        _ -> do
          recs <- go (Just typ) rest
          Just (rec : recs)

-- | Encode a TLV stream from records.
-- Records must be sorted by type, no duplicates.
encodeTlvStream :: [TlvRecord] -> BS.ByteString
encodeTlvStream !recs = toStrict $ foldMap (B.byteString . encodeTlv) recs
{-# INLINE encodeTlvStream #-}

-- Packet serialization -----------------------------------------------------

-- | Serialize OnionPacket to 1366 bytes.
encodeOnionPacket :: OnionPacket -> BS.ByteString
encodeOnionPacket (OnionPacket !ver !eph !payloads !mac) = toStrict $
  B.word8 ver <>
  B.byteString eph <>
  B.byteString payloads <>
  B.byteString mac
{-# INLINE encodeOnionPacket #-}

-- | Parse OnionPacket from 1366 bytes.
decodeOnionPacket :: BS.ByteString -> Maybe OnionPacket
decodeOnionPacket !bs
  | BS.length bs /= onionPacketSize = Nothing
  | otherwise =
      let !ver = BS.index bs 0
          !eph = BS.take pubkeySize (BS.drop 1 bs)
          !payloads = BS.take hopPayloadsSize (BS.drop (1 + pubkeySize) bs)
          !mac = BS.drop (1 + pubkeySize + hopPayloadsSize) bs
      in  Just (OnionPacket ver eph payloads mac)
{-# INLINE decodeOnionPacket #-}

-- | Encode HopPayload to bytes (without length prefix).
encodeHopPayload :: HopPayload -> BS.ByteString
encodeHopPayload !hp = encodeTlvStream (buildTlvs hp)
  where
    buildTlvs :: HopPayload -> [TlvRecord]
    buildTlvs (HopPayload amt cltv sci pd ed cpk unk) =
      let amt' = maybe [] (\a -> [TlvRecord 2 (encodeWord64TU a)]) amt
          cltv' = maybe [] (\c -> [TlvRecord 4 (encodeWord32TU c)]) cltv
          sci' = maybe [] (\s -> [TlvRecord 6 (encodeShortChannelId s)]) sci
          pd' = maybe [] (\p -> [TlvRecord 8 (encodePaymentData p)]) pd
          ed' = maybe [] (\e -> [TlvRecord 10 e]) ed
          cpk' = maybe [] (\k -> [TlvRecord 12 k]) cpk
      in  amt' ++ cltv' ++ sci' ++ pd' ++ ed' ++ cpk' ++ unk

-- | Decode HopPayload from bytes.
decodeHopPayload :: BS.ByteString -> Maybe HopPayload
decodeHopPayload !bs = do
  tlvs <- decodeTlvStream bs
  parseHopPayload tlvs

parseHopPayload :: [TlvRecord] -> Maybe HopPayload
parseHopPayload = go emptyHop
  where
    emptyHop :: HopPayload
    emptyHop = HopPayload Nothing Nothing Nothing Nothing Nothing Nothing []

    go :: HopPayload -> [TlvRecord] -> Maybe HopPayload
    go !hp [] = Just hp { hpUnknownTlvs = reverse (hpUnknownTlvs hp) }
    go !hp (TlvRecord typ val : rest) = case typ of
      2  -> do
        amt <- decodeWord64TU val
        go hp { hpAmtToForward = Just amt } rest
      4  -> do
        cltv <- decodeWord32TU val
        go hp { hpOutgoingCltv = Just cltv } rest
      6  -> do
        sci <- decodeShortChannelId val
        go hp { hpShortChannelId = Just sci } rest
      8  -> do
        pd <- decodePaymentData val
        go hp { hpPaymentData = Just pd } rest
      10 -> go hp { hpEncryptedData = Just val } rest
      12 -> go hp { hpCurrentPathKey = Just val } rest
      _  -> go hp { hpUnknownTlvs = TlvRecord typ val : hpUnknownTlvs hp } rest

-- ShortChannelId -----------------------------------------------------------

-- | Encode ShortChannelId to 8 bytes.
-- Format: 3 bytes block || 3 bytes tx || 2 bytes output (all BE)
encodeShortChannelId :: ShortChannelId -> BS.ByteString
encodeShortChannelId (ShortChannelId !blk !tx !out) = toStrict $
  -- Block height: 3 bytes
  B.word8 (fromIntegral (blk `shiftR` 16) .&. 0xFF) <>
  B.word8 (fromIntegral (blk `shiftR` 8) .&. 0xFF) <>
  B.word8 (fromIntegral blk .&. 0xFF) <>
  -- Tx index: 3 bytes
  B.word8 (fromIntegral (tx `shiftR` 16) .&. 0xFF) <>
  B.word8 (fromIntegral (tx `shiftR` 8) .&. 0xFF) <>
  B.word8 (fromIntegral tx .&. 0xFF) <>
  -- Output index: 2 bytes
  B.word16BE out
{-# INLINE encodeShortChannelId #-}

-- | Decode ShortChannelId from 8 bytes.
decodeShortChannelId :: BS.ByteString -> Maybe ShortChannelId
decodeShortChannelId !bs
  | BS.length bs /= 8 = Nothing
  | otherwise =
      let !b0 = fromIntegral (BS.index bs 0) :: Word32
          !b1 = fromIntegral (BS.index bs 1) :: Word32
          !b2 = fromIntegral (BS.index bs 2) :: Word32
          !blk = (b0 `shiftL` 16) + (b1 `shiftL` 8) + b2
          !t0 = fromIntegral (BS.index bs 3) :: Word32
          !t1 = fromIntegral (BS.index bs 4) :: Word32
          !t2 = fromIntegral (BS.index bs 5) :: Word32
          !tx = (t0 `shiftL` 16) + (t1 `shiftL` 8) + t2
          !o0 = fromIntegral (BS.index bs 6) :: Word16
          !o1 = fromIntegral (BS.index bs 7) :: Word16
          !out = (o0 `shiftL` 8) + o1
      in  Just (ShortChannelId blk tx out)
{-# INLINE decodeShortChannelId #-}

-- Failure messages ---------------------------------------------------------

-- | Encode failure message.
encodeFailureMessage :: FailureMessage -> BS.ByteString
encodeFailureMessage (FailureMessage (FailureCode !code) !dat !tlvs) =
  toStrict $
    B.word16BE code <>
    B.word16BE (fromIntegral (BS.length dat)) <>
    B.byteString dat <>
    B.byteString (encodeTlvStream tlvs)
{-# INLINE encodeFailureMessage #-}

-- | Decode failure message.
decodeFailureMessage :: BS.ByteString -> Maybe FailureMessage
decodeFailureMessage !bs = do
  if BS.length bs < 4 then Nothing else do
    let !code = word16BE (BS.take 2 bs)
        !dlen = fromIntegral (word16BE (BS.take 2 (BS.drop 2 bs)))
    if BS.length bs < 4 + dlen then Nothing else do
      let !dat = BS.take dlen (BS.drop 4 bs)
          !tlvBytes = BS.drop (4 + dlen) bs
      tlvs <- if BS.null tlvBytes
                then Just []
                else decodeTlvStream tlvBytes
      Just (FailureMessage (FailureCode code) dat tlvs)

-- Helper functions ---------------------------------------------------------

-- | Convert Builder to strict ByteString.
toStrict :: B.Builder -> BS.ByteString
toStrict = BL.toStrict . B.toLazyByteString
{-# INLINE toStrict #-}

-- | Decode big-endian Word16.
word16BE :: BS.ByteString -> Word16
word16BE !bs =
  let !b0 = fromIntegral (BS.index bs 0) :: Word16
      !b1 = fromIntegral (BS.index bs 1) :: Word16
  in  (b0 `shiftL` 8) + b1
{-# INLINE word16BE #-}

-- | Decode big-endian Word32.
word32BE :: BS.ByteString -> Word32
word32BE !bs =
  let !b0 = fromIntegral (BS.index bs 0) :: Word32
      !b1 = fromIntegral (BS.index bs 1) :: Word32
      !b2 = fromIntegral (BS.index bs 2) :: Word32
      !b3 = fromIntegral (BS.index bs 3) :: Word32
  in  (b0 `shiftL` 24) + (b1 `shiftL` 16) + (b2 `shiftL` 8) + b3
{-# INLINE word32BE #-}

-- | Decode big-endian Word64.
word64BE :: BS.ByteString -> Word64
word64BE !bs =
  let !b0 = fromIntegral (BS.index bs 0) :: Word64
      !b1 = fromIntegral (BS.index bs 1) :: Word64
      !b2 = fromIntegral (BS.index bs 2) :: Word64
      !b3 = fromIntegral (BS.index bs 3) :: Word64
      !b4 = fromIntegral (BS.index bs 4) :: Word64
      !b5 = fromIntegral (BS.index bs 5) :: Word64
      !b6 = fromIntegral (BS.index bs 6) :: Word64
      !b7 = fromIntegral (BS.index bs 7) :: Word64
  in  (b0 `shiftL` 56) + (b1 `shiftL` 48) + (b2 `shiftL` 40) +
      (b3 `shiftL` 32) + (b4 `shiftL` 24) + (b5 `shiftL` 16) +
      (b6 `shiftL` 8) + b7
{-# INLINE word64BE #-}

-- | Encode Word64 as truncated unsigned (minimal bytes).
encodeWord64TU :: Word64 -> BS.ByteString
encodeWord64TU !n
  | n == 0 = BS.empty
  | otherwise = BS.dropWhile (== 0) (toStrict (B.word64BE n))
{-# INLINE encodeWord64TU #-}

-- | Decode truncated unsigned to Word64.
decodeWord64TU :: BS.ByteString -> Maybe Word64
decodeWord64TU !bs
  | BS.null bs = Just 0
  | BS.length bs > 8 = Nothing
  | not (BS.null bs) && BS.index bs 0 == 0 = Nothing  -- Non-canonical
  | otherwise = Just (go 0 bs)
  where
    go :: Word64 -> BS.ByteString -> Word64
    go !acc !b = case BS.uncons b of
      Nothing -> acc
      Just (x, rest) -> go ((acc `shiftL` 8) + fromIntegral x) rest
{-# INLINE decodeWord64TU #-}

-- | Encode Word32 as truncated unsigned.
encodeWord32TU :: Word32 -> BS.ByteString
encodeWord32TU !n
  | n == 0 = BS.empty
  | otherwise = BS.dropWhile (== 0) (toStrict (B.word32BE n))
{-# INLINE encodeWord32TU #-}

-- | Decode truncated unsigned to Word32.
decodeWord32TU :: BS.ByteString -> Maybe Word32
decodeWord32TU !bs
  | BS.null bs = Just 0
  | BS.length bs > 4 = Nothing
  | not (BS.null bs) && BS.index bs 0 == 0 = Nothing  -- Non-canonical
  | otherwise = Just (go 0 bs)
  where
    go :: Word32 -> BS.ByteString -> Word32
    go !acc !b = case BS.uncons b of
      Nothing -> acc
      Just (x, rest) -> go ((acc `shiftL` 8) + fromIntegral x) rest
{-# INLINE decodeWord32TU #-}

-- | Encode PaymentData.
encodePaymentData :: PaymentData -> BS.ByteString
encodePaymentData (PaymentData !secret !total) =
  secret <> encodeWord64TU total
{-# INLINE encodePaymentData #-}

-- | Decode PaymentData.
decodePaymentData :: BS.ByteString -> Maybe PaymentData
decodePaymentData !bs
  | BS.length bs < 32 = Nothing
  | otherwise = do
      let !secret = BS.take 32 bs
          !rest = BS.drop 32 bs
      total <- decodeWord64TU rest
      Just (PaymentData secret total)
{-# INLINE decodePaymentData #-}