packages feed

ppad-bolt4-0.1.0: lib/Lightning/Protocol/BOLT4/Types.hs

{-# OPTIONS_HADDOCK hide #-}
{-# LANGUAGE DeriveGeneric #-}

-- |
-- Module: Lightning.Protocol.BOLT4.Types
-- Copyright: (c) 2025 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Data types for BOLT #4.

module Lightning.Protocol.BOLT4.Types (
  -- * Onion packets
    OnionPacket(..)
  , HopPayloads(..)
  , hop_payloads
  , un_hop_payloads
  , Hmac32(..)
  , hmac32
  , un_hmac32

  -- * Hop payloads
  , HopPayload(..)
  , empty_hop_payload
  , PaymentData(..)
  , PaymentSecret(..)
  , payment_secret
  , un_payment_secret

  -- * Route blinding
  , BlindedPath(..)
  , BlindedHop(..)
  , BlindedHopData(..)
  , empty_blinded_hop_data
  , PaymentRelay(..)
  , PaymentConstraints(..)
  , BlindedInfo(..)

  -- * Failure messages
  , FailureMessage(..)
  , Failure(..)
  , OnionHash(..)
  , onion_hash
  , un_onion_hash
  , failure_code
  , is_badonion
  , is_perm
  , is_node
  , is_update

  -- * Errors
  , DecodeError(..)
  , EncodeError(..)
  , ProcessError(..)
  ) where

import Control.DeepSeq (NFData(..))
import Data.Bits ((.&.))
import qualified Data.ByteString as BS
import Data.Word (Word8, Word16, Word32, Word64)
import GHC.Generics (Generic)
import qualified Lightning.Protocol.BOLT1 as BOLT1
import qualified Lightning.Protocol.BOLT4.Prim as P
import qualified Lightning.Protocol.BOLT9 as BOLT9

-- onion packets --------------------------------------------------------------

-- | An onion packet (@onion_packet@): 1366 bytes on the wire.
data OnionPacket = OnionPacket
  { onion_version      :: {-# UNPACK #-} !Word8
    -- ^ the version byte; 0 in this version of the protocol
  , onion_public_key   :: !BOLT1.Point
    -- ^ the ephemeral public key
  , onion_hop_payloads :: !HopPayloads
    -- ^ the obfuscated hop payloads
  , onion_hmac         :: !Hmac32
    -- ^ the HMAC authenticating the packet
  } deriving (Eq, Show, Generic)

instance NFData OnionPacket

-- | The 1300-byte @hop_payloads@ field of an onion packet.
newtype HopPayloads = HopPayloads BS.ByteString
  deriving (Eq, Show, Generic)

instance NFData HopPayloads

-- | Construct t'HopPayloads' from exactly 1300 bytes.
--
--   >>> let Just hp = hop_payloads (BS.replicate 1300 0)
--   >>> BS.length (un_hop_payloads hp)
--   1300
--   >>> hop_payloads (BS.replicate 1299 0)
--   Nothing
hop_payloads :: BS.ByteString -> Maybe HopPayloads
hop_payloads bs
  | BS.length bs == 1300 = Just (HopPayloads bs)
  | otherwise            = Nothing
{-# INLINE hop_payloads #-}

-- | The bytes of t'HopPayloads'.
un_hop_payloads :: HopPayloads -> BS.ByteString
un_hop_payloads (HopPayloads bs) = bs
{-# INLINE un_hop_payloads #-}

-- | A 32-byte HMAC.
newtype Hmac32 = Hmac32 BS.ByteString
  deriving (Eq, Show, Generic)

instance NFData Hmac32

-- | Construct an t'Hmac32' from exactly 32 bytes.
--
--   >>> fmap (BS.length . un_hmac32) (hmac32 (BS.replicate 32 0))
--   Just 32
--   >>> hmac32 "too short"
--   Nothing
hmac32 :: BS.ByteString -> Maybe Hmac32
hmac32 bs
  | BS.length bs == 32 = Just (Hmac32 bs)
  | otherwise          = Nothing
{-# INLINE hmac32 #-}

-- | The bytes of an t'Hmac32'.
un_hmac32 :: Hmac32 -> BS.ByteString
un_hmac32 (Hmac32 bs) = bs
{-# INLINE un_hmac32 #-}

-- hop payloads ---------------------------------------------------------------

-- | A hop's @payload@ TLV stream.
--
--   Each known record has a typed field. 'hp_extra' holds the remaining
--   records: decoding fills it with the unknown odd records (unknown even
--   records are rejected), and encoding accepts any record in it whose
--   type isn't one of the known ones.
data HopPayload = HopPayload
  { hp_amt_to_forward      :: !(Maybe BOLT1.MilliSatoshi)
    -- ^ type 2: @amt_to_forward@
  , hp_outgoing_cltv_value :: !(Maybe Word32)
    -- ^ type 4: @outgoing_cltv_value@
  , hp_short_channel_id    :: !(Maybe BOLT1.ShortChannelId)
    -- ^ type 6: @short_channel_id@
  , hp_payment_data        :: !(Maybe PaymentData)
    -- ^ type 8: @payment_data@
  , hp_encrypted_data      :: !(Maybe BS.ByteString)
    -- ^ type 10: @encrypted_recipient_data@
  , hp_current_path_key    :: !(Maybe BOLT1.Point)
    -- ^ type 12: @current_path_key@
  , hp_payment_metadata    :: !(Maybe BS.ByteString)
    -- ^ type 16: @payment_metadata@
  , hp_total_amount_msat   :: !(Maybe BOLT1.MilliSatoshi)
    -- ^ type 18: @total_amount_msat@
  , hp_extra               :: !BOLT1.TlvStream
    -- ^ other records
  } deriving (Eq, Show, Generic)

instance NFData HopPayload

-- | The empty t'HopPayload', to be updated with record syntax.
--
--   >>> let Just amt = BOLT1.milli_satoshi 1000
--   >>> let hp = empty_hop_payload { hp_amt_to_forward = Just amt }
--   >>> encode_hop_payload hp
--   Right "\STX\STX\ETX\232"
empty_hop_payload :: HopPayload
empty_hop_payload = HopPayload
  Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing
  BOLT1.empty_tlv_stream

-- | The @payment_data@ record of a final hop's payload.
data PaymentData = PaymentData
  { pd_payment_secret :: !PaymentSecret
  , pd_total_msat     :: !BOLT1.MilliSatoshi
  } deriving (Eq, Show, Generic)

instance NFData PaymentData

-- | A 32-byte payment secret.
--
--   Its 'Show' instance is redacted and its 'Eq' instance runs in
--   constant time.
newtype PaymentSecret = PaymentSecret BS.ByteString

instance Eq PaymentSecret where
  PaymentSecret a == PaymentSecret b = P.ct_eq a b

instance Show PaymentSecret where
  showsPrec d _ = showParen (d > 10) $
    showString "PaymentSecret <redacted>"

instance NFData PaymentSecret where
  rnf (PaymentSecret bs) = rnf bs

-- | Construct a t'PaymentSecret' from exactly 32 bytes.
--
--   >>> payment_secret (BS.replicate 32 0x01)
--   Just (PaymentSecret <redacted>)
--   >>> payment_secret (BS.replicate 16 0x01)
--   Nothing
payment_secret :: BS.ByteString -> Maybe PaymentSecret
payment_secret bs
  | BS.length bs == 32 = Just (PaymentSecret bs)
  | otherwise          = Nothing
{-# INLINE payment_secret #-}

-- | The bytes of a t'PaymentSecret'.
un_payment_secret :: PaymentSecret -> BS.ByteString
un_payment_secret (PaymentSecret bs) = bs
{-# INLINE un_payment_secret #-}

-- route blinding -------------------------------------------------------------

-- | A blinded route (@blinded_path@), as created by a recipient.
data BlindedPath = BlindedPath
  { bp_first_node_id  :: !BOLT1.Point
    -- ^ the (unblinded) introduction node
  , bp_first_path_key :: !BOLT1.Point
    -- ^ the path key for the introduction node
  , bp_hops           :: ![BlindedHop]
    -- ^ the blinded hops, starting with the introduction node
  } deriving (Eq, Show, Generic)

instance NFData BlindedPath

-- | A hop of a blinded route (@blinded_path_hop@).
data BlindedHop = BlindedHop
  { bh_blinded_node_id :: !BOLT1.Point
    -- ^ the hop's blinded node id
  , bh_encrypted_data  :: !BS.ByteString
    -- ^ the hop's @encrypted_recipient_data@
  } deriving (Eq, Show, Generic)

instance NFData BlindedHop

-- | The @encrypted_data_tlv@ stream carried, encrypted, in a hop's
--   @encrypted_recipient_data@.
--
--   'bhd_extra' plays the same role as 'hp_extra' in t'HopPayload'.
data BlindedHopData = BlindedHopData
  { bhd_padding                :: !(Maybe BS.ByteString)
    -- ^ type 1: @padding@
  , bhd_short_channel_id       :: !(Maybe BOLT1.ShortChannelId)
    -- ^ type 2: @short_channel_id@
  , bhd_next_node_id           :: !(Maybe BOLT1.Point)
    -- ^ type 4: @next_node_id@
  , bhd_path_id                :: !(Maybe BS.ByteString)
    -- ^ type 6: @path_id@
  , bhd_next_path_key_override :: !(Maybe BOLT1.Point)
    -- ^ type 8: @next_path_key_override@
  , bhd_payment_relay          :: !(Maybe PaymentRelay)
    -- ^ type 10: @payment_relay@
  , bhd_payment_constraints    :: !(Maybe PaymentConstraints)
    -- ^ type 12: @payment_constraints@
  , bhd_allowed_features       :: !(Maybe BOLT9.FeatureVector)
    -- ^ type 14: @allowed_features@
  , bhd_extra                  :: !BOLT1.TlvStream
    -- ^ other records
  } deriving (Eq, Show, Generic)

instance NFData BlindedHopData

-- | The empty t'BlindedHopData', to be updated with record syntax.
--
--   >>> let bhd = empty_blinded_hop_data { bhd_path_id = Just "path id" }
--   >>> encode_blinded_hop_data bhd
--   Right "\ACK\apath id"
empty_blinded_hop_data :: BlindedHopData
empty_blinded_hop_data = BlindedHopData
  Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing
  BOLT1.empty_tlv_stream

-- | The @payment_relay@ record of t'BlindedHopData'.
data PaymentRelay = PaymentRelay
  { pr_cltv_expiry_delta           :: {-# UNPACK #-} !Word16
  , pr_fee_proportional_millionths :: {-# UNPACK #-} !Word32
  , pr_fee_base_msat               :: {-# UNPACK #-} !Word32
  } deriving (Eq, Show, Generic)

instance NFData PaymentRelay

-- | The @payment_constraints@ record of t'BlindedHopData'.
data PaymentConstraints = PaymentConstraints
  { pc_max_cltv_expiry   :: {-# UNPACK #-} !Word32
  , pc_htlc_minimum_msat :: !BOLT1.MilliSatoshi
  } deriving (Eq, Show, Generic)

instance NFData PaymentConstraints

-- | What a node in a blinded route learns from its
--   @encrypted_recipient_data@.
data BlindedInfo = BlindedInfo
  { bi_data          :: !BlindedHopData
    -- ^ the decrypted @encrypted_data_tlv@
  , bi_next_path_key :: !BOLT1.Point
    -- ^ the @path_key@ to send to the next node
  } deriving (Eq, Show, Generic)

instance NFData BlindedInfo

-- failure messages -----------------------------------------------------------

-- | A failure message (@failuremsg@): a failure, followed by an optional
--   TLV stream.
data FailureMessage = FailureMessage
  { fm_failure :: !Failure
  , fm_tlvs    :: !BOLT1.TlvStream
  } deriving (Eq, Show, Generic)

instance NFData FailureMessage

-- | A failure: a failure code and its data.
--
--   A @channel_update@ field holds the raw bytes of the update; it is
--   empty when the update is omitted.
data Failure
  = TemporaryNodeFailure
    -- ^ NODE|2
  | PermanentNodeFailure
    -- ^ PERM|NODE|2
  | RequiredNodeFeatureMissing
    -- ^ PERM|NODE|3
  | InvalidOnionVersion !OnionHash
    -- ^ BADONION|PERM|4: @sha256_of_onion@
  | InvalidOnionHmac !OnionHash
    -- ^ BADONION|PERM|5: @sha256_of_onion@
  | InvalidOnionKey !OnionHash
    -- ^ BADONION|PERM|6: @sha256_of_onion@
  | TemporaryChannelFailure !BS.ByteString
    -- ^ UPDATE|7: @channel_update@
  | PermanentChannelFailure
    -- ^ PERM|8
  | RequiredChannelFeatureMissing
    -- ^ PERM|9
  | UnknownNextPeer
    -- ^ PERM|10
  | AmountBelowMinimum !BOLT1.MilliSatoshi !BS.ByteString
    -- ^ UPDATE|11: @htlc_msat@, @channel_update@
  | FeeInsufficient !BOLT1.MilliSatoshi !BS.ByteString
    -- ^ UPDATE|12: @htlc_msat@, @channel_update@
  | IncorrectCltvExpiry {-# UNPACK #-} !Word32 !BS.ByteString
    -- ^ UPDATE|13: @cltv_expiry@, @channel_update@
  | ExpiryTooSoon !BS.ByteString
    -- ^ UPDATE|14: @channel_update@
  | IncorrectOrUnknownPaymentDetails !BOLT1.MilliSatoshi
                                     {-# UNPACK #-} !Word32
    -- ^ PERM|15: @htlc_msat@, @height@
  | FinalIncorrectCltvExpiry {-# UNPACK #-} !Word32
    -- ^ 18: @cltv_expiry@
  | FinalIncorrectHtlcAmount !BOLT1.MilliSatoshi
    -- ^ 19: @incoming_htlc_amt@
  | ChannelDisabled {-# UNPACK #-} !Word16 !BS.ByteString
    -- ^ UPDATE|20: @disabled_flags@, @channel_update@
  | ExpiryTooFar
    -- ^ 21
  | InvalidOnionPayload !(Maybe (Word64, Word16))
    -- ^ PERM|22: the optional @type@ and @offset@
  | MppTimeout
    -- ^ 23
  | InvalidOnionBlinding !OnionHash
    -- ^ BADONION|PERM|24: @sha256_of_onion@
  | UnknownFailure {-# UNPACK #-} !Word16 !BS.ByteString
    -- ^ a failure code not defined above, and the bytes following it
  deriving (Eq, Show, Generic)

instance NFData Failure

-- | A 32-byte @sha256_of_onion@.
newtype OnionHash = OnionHash BS.ByteString
  deriving (Eq, Show, Generic)

instance NFData OnionHash

-- | Construct an t'OnionHash' from exactly 32 bytes.
--
--   >>> fmap (BS.length . un_onion_hash) (onion_hash (BS.replicate 32 0))
--   Just 32
onion_hash :: BS.ByteString -> Maybe OnionHash
onion_hash bs
  | BS.length bs == 32 = Just (OnionHash bs)
  | otherwise          = Nothing
{-# INLINE onion_hash #-}

-- | The bytes of an t'OnionHash'.
un_onion_hash :: OnionHash -> BS.ByteString
un_onion_hash (OnionHash bs) = bs
{-# INLINE un_onion_hash #-}

-- | The @failure_code@ of a 'Failure'.
--
--   >>> failure_code TemporaryNodeFailure
--   8194
--   >>> failure_code (UnknownFailure 0x1234 mempty)
--   4660
failure_code :: Failure -> Word16
failure_code f = case f of
  TemporaryNodeFailure                 -> 0x2002
  PermanentNodeFailure                 -> 0x6002
  RequiredNodeFeatureMissing           -> 0x6003
  InvalidOnionVersion _                -> 0xc004
  InvalidOnionHmac _                   -> 0xc005
  InvalidOnionKey _                    -> 0xc006
  TemporaryChannelFailure _            -> 0x1007
  PermanentChannelFailure              -> 0x4008
  RequiredChannelFeatureMissing        -> 0x4009
  UnknownNextPeer                      -> 0x400a
  AmountBelowMinimum _ _               -> 0x100b
  FeeInsufficient _ _                  -> 0x100c
  IncorrectCltvExpiry _ _              -> 0x100d
  ExpiryTooSoon _                      -> 0x100e
  IncorrectOrUnknownPaymentDetails _ _ -> 0x400f
  FinalIncorrectCltvExpiry _           -> 0x0012
  FinalIncorrectHtlcAmount _           -> 0x0013
  ChannelDisabled _ _                  -> 0x1014
  ExpiryTooFar                         -> 0x0015
  InvalidOnionPayload _                -> 0x4016
  MppTimeout                           -> 0x0017
  InvalidOnionBlinding _               -> 0xc018
  UnknownFailure c _                   -> c

-- | Is the BADONION flag (0x8000) set: was the onion unparsable?
--
--   >>> is_badonion TemporaryNodeFailure
--   False
is_badonion :: Failure -> Bool
is_badonion f = failure_code f .&. 0x8000 /= 0
{-# INLINE is_badonion #-}

-- | Is the PERM flag (0x4000) set: is the failure permanent?
--
--   >>> is_perm PermanentNodeFailure
--   True
is_perm :: Failure -> Bool
is_perm f = failure_code f .&. 0x4000 /= 0
{-# INLINE is_perm #-}

-- | Is the NODE flag (0x2000) set: is it a node (not channel) failure?
--
--   >>> is_node TemporaryNodeFailure
--   True
is_node :: Failure -> Bool
is_node f = failure_code f .&. 0x2000 /= 0
{-# INLINE is_node #-}

-- | Is the UPDATE flag (0x1000) set: was a channel forwarding parameter
--   violated?
--
--   >>> is_update (ExpiryTooSoon mempty)
--   True
is_update :: Failure -> Bool
is_update f = failure_code f .&. 0x1000 /= 0
{-# INLINE is_update #-}

-- errors ---------------------------------------------------------------------

-- | Why a decoder failed.
data DecodeError
  = InvalidLength
    -- ^ the input has the wrong length
  | InvalidPoint
    -- ^ a point lacks a compressed-encoding prefix
  | InvalidTlvStream !BOLT1.TlvError
    -- ^ the TLV stream is malformed, or has an unknown even type
  | InvalidTlvValue {-# UNPACK #-} !Word64
    -- ^ the value of the record of this type is malformed
  | InvalidFailureData {-# UNPACK #-} !Word16
    -- ^ the data of a failure with this code is malformed
  deriving (Eq, Show, Generic)

instance NFData DecodeError

-- | Why an encoder failed.
data EncodeError
  = ConflictingTlv
    -- ^ an extra TLV record has the type of a known record
  | FieldTooLong
    -- ^ a length-prefixed field exceeds its maximum length
  deriving (Eq, Show, Generic)

instance NFData EncodeError

-- | Why an onion packet was rejected.
data ProcessError
  = InvalidVersion {-# UNPACK #-} !Word8
    -- ^ the version byte is not 0
  | InvalidPublicKey
    -- ^ the ephemeral public key is not a valid point
  | HmacMismatch
    -- ^ the packet's HMAC is wrong
  | InvalidPayloadLength
    -- ^ the payload length is malformed, below 2, or past the end of
    --   @hop_payloads@
  | InvalidPayload !DecodeError
    -- ^ the payload is not a valid @payload@ TLV stream
  | InvalidPathKey
    -- ^ the path key is not a valid point
  | UnexpectedPathKey
    -- ^ a path key was given with no @encrypted_recipient_data@, or
    --   both a @path_key@ and a @current_path_key@ were given
  | MissingPathKey
    -- ^ @encrypted_recipient_data@ was given with no path key
  | InvalidRecipientData
    -- ^ the @encrypted_recipient_data@ doesn't decrypt to a valid
    --   @encrypted_data_tlv@ stream
  deriving (Eq, Show, Generic)

instance NFData ProcessError