A pure Haskell implementation of Lightning Network onion routing, BOLT #4: onion construction and processing, returning errors, and route blinding.
A sample GHCi session:
> :set -XOverloadedStrings
> import qualified Data.ByteString as BS
> import qualified Lightning.Protocol.BOLT1 as BOLT1
> import Lightning.Protocol.BOLT4
>
> -- keys (use a fresh random session key for every onion)
> let Just session = secret_key (BS.replicate 32 0x41)
> let Just alice = secret_key (BS.replicate 32 0x42)
> let Just bob = secret_key (BS.replicate 32 0x43)
>
> -- a two-hop route, paying bob through alice
> let Just amt = BOLT1.milli_satoshi 100000
> let Just scid = BOLT1.short_channel_id 800000 1 0
> let pay = empty_hop_payload { hp_amt_to_forward = Just amt }
> :{
ghci| let to_alice = Hop (public_key alice) pay {
ghci| hp_outgoing_cltv_value = Just 800040
ghci| , hp_short_channel_id = Just scid
ghci| }
ghci| to_bob = Hop (public_key bob) pay {
ghci| hp_outgoing_cltv_value = Just 800000
ghci| }
ghci| :}
> let hash = BS.replicate 32 0xff
> let Right (onion, secrets) = construct session [to_alice, to_bob] hash
> BS.length (encode_onion_packet onion)
1366
>
> -- each hop peels off its layer
> let Right (Forward fwd) = process alice onion hash Nothing
> hp_short_channel_id (fwd_payload fwd)
Just (ShortChannelId 879609302220865536)
> let next = fwd_next_packet fwd
> let Right (Receive rcv) = process bob next hash Nothing
> hp_amt_to_forward (rcv_payload rcv)
Just (MilliSatoshi 100000)
>
> -- bob fails the payment, alice wraps the error, and the sender
> -- attributes it to bob
> let failure = FailureMessage MppTimeout BOLT1.empty_tlv_stream
> let Right err = construct_error (rcv_shared_secret rcv) failure
> unwrap_error secrets (wrap_error (fwd_shared_secret fwd) err)
Attributed 1 (FailureMessage {fm_failure = MppTimeout,
fm_tlvs = TlvStream []})
>
> -- bob hands out a blinded route to himself
> let Just seed = secret_key (BS.replicate 32 0x01)
> let mine = empty_blinded_hop_data { bhd_path_id = Just "invoice 1" }
> let Right path = create_blinded_path seed [(public_key bob, mine)]
> let [hop] = bp_hops path
>
> -- the sender reaches the route's introduction node through alice
> :{
ghci| let blinded = Hop (bp_first_node_id path) pay {
ghci| hp_outgoing_cltv_value = Just 800000
ghci| , hp_total_amount_msat = Just amt
ghci| , hp_encrypted_data = Just (bh_encrypted_data hop)
ghci| , hp_current_path_key = Just (bp_first_path_key path)
ghci| }
ghci| :}
> let Right (onion', _) = construct session [to_alice, blinded] hash
> let Right (Forward fwd') = process alice onion' hash Nothing
> let next' = fwd_next_packet fwd'
> let Right (Receive rcv') = process bob next' hash Nothing
> fmap (bhd_path_id . bi_data) (rcv_blinded rcv')
Just (Just "invoice 1")
Nodes further along a blinded route pass the path_key they received
(bi_next_path_key of the previous hop) as the last argument of
process, which then derives their blinded private key.
Haddocks (API documentation, etc.) are hosted at docs.ppad.tech/bolt4.
This library implements BOLT #4 as of lightning/bolts commit
1aadb719 (2026-09-21); a copy lives in etc/. It is tested
against the onion-test.json, onion-error-test.json,
route-blinding-test.json and blinded-payment-onion-test.json
vectors, and the 'Returning Errors' trace.
Fundamental types and the TLV format come from ppad-bolt1;
allowed_features is a ppad-bolt9 feature vector.
Not implemented:
- Attributable failures (
option_attribution_data): theattribution_dataofupdate_fail_htlcandupdate_fulfill_htlc, and HTLC hold times. - Onion messages (
onion_message_packet,onionmsg_tlv). - The 32 KiB limit on return packets:
construct_errordoesn't enforce it, andwrap_errordoesn't truncate longer packets. - The origin node's dummy decryption rounds:
unwrap_errorstops at the hop whose HMAC matches. - The
blinded_pathwire encoding, and the computation of a blinded route's total fees and CLTV delta.
process enforces the onion decryption requirements and the path key
rules of route blinding; the reader requirements that depend on the
HTLC or the node's channels (required fields, amount and expiry checks,
payment_relay and payment_constraints, allowed_features, replayed
HMACs) are left to the caller.
The aim is best-in-class performance for pure Haskell code. Run the benchmarks with:
$ cabal bench
This library has received no security audit. Secret keys, shared
secrets and payment secrets are abstract and kept out of Show output;
shared and payment secrets compare in constant time, and process
checks onion HMACs in constant time. Scalar multiplications involving
secrets use ppad-secp256k1's constant-time implementation.
You'll require Nix with flake support enabled. Enter a development shell with:
$ nix develop
Then do e.g.:
$ cabal repl ppad-bolt4
to get a REPL for the main library.