Menu
BOLT-bolt-04 Final

BOLT-04: Onion Routing

Lightning onion routing protocol. Layered encryption, hop data, and payment privacy.

Type Basis of Lightning Technology
Number bolt-04
Status Final
Authors Lightning Network Developers
Original https://github.com/lightning/bolts/blob/master/04-onion-routing.md
Requires

BOLT-04: Onion Routing

BOLT-04 defines the onion routing protocol that provides payment privacy in Lightning. Each hop only learns the minimum information needed to forward the payment.

Specification Summary

AspectValue
StatusFinal
LayerRouting
PurposePayment privacy and routing
DependenciesBOLT-01, BOLT-02

Privacy Guarantees

What Each Node SeesWhat They Don’t See
Previous hopOriginal sender
Next hopFinal destination
Forwarding amount/feeTotal payment amount
Their CLTV deltaFull route length

Onion Packet Structure

Total size: 1366 bytes (fixed, regardless of route length)

┌─────────────────────────────────────────┐
│ version (1 byte): 0x00                  │
├─────────────────────────────────────────┤
│ public_key (33 bytes): ephemeral key    │
├─────────────────────────────────────────┤
│ hop_payloads (1300 bytes): encrypted    │
├─────────────────────────────────────────┤
│ hmac (32 bytes): authentication         │
└─────────────────────────────────────────┘

Sphinx Construction

Key Derivation

For each hop, derive shared secret:

def derive_shared_secret(ephemeral_key, node_pubkey):
    """ECDH to derive shared secret."""
    shared_point = ephemeral_key.private_key * node_pubkey
    return sha256(shared_point.serialize())

def derive_keys(shared_secret):
    """Derive encryption keys from shared secret."""
    return {
        'rho': hmac_sha256(shared_secret, b'rho'),    # Payload encryption
        'mu': hmac_sha256(shared_secret, b'mu'),      # HMAC key
        'um': hmac_sha256(shared_secret, b'um'),      # Error encryption
        'pad': hmac_sha256(shared_secret, b'pad'),    # Padding
    }

Building Onion (Reverse Order)

def build_onion(hops, session_key, associated_data):
    """Construct onion packet for payment route."""
    # Start with random filler
    filler = generate_filler(hops, session_key)

    # Build from last hop backwards
    onion = bytes(1300)  # Start with zeros

    for i in range(len(hops) - 1, -1, -1):
        hop = hops[i]

        # Derive keys for this hop
        ephemeral = derive_ephemeral_key(session_key, hops[:i])
        shared = derive_shared_secret(ephemeral, hop.pubkey)
        keys = derive_keys(shared)

        # Encrypt payload
        hop_payload = encode_hop_payload(hop)
        stream = generate_stream(keys['rho'], 1300)

        # Shift and encrypt
        onion = shift_and_encrypt(onion, hop_payload, stream)

        # Calculate HMAC
        hmac = calculate_hmac(keys['mu'], onion, associated_data)

    return OnionPacket(
        version=0,
        public_key=ephemeral.public_key,
        hop_payloads=onion,
        hmac=hmac
    )

Hop Payload Format

Legacy Format (Deprecated)

Fixed 65 bytes per hop:

┌─────────────────────────────────────────┐
│ short_channel_id (8 bytes)              │
│ amt_to_forward (8 bytes)                │
│ outgoing_cltv_value (4 bytes)           │
│ padding (12 bytes)                      │
│ hmac (32 bytes)                         │
└─────────────────────────────────────────┘

TLV Format (Current)

Variable length with BigSize encoding:

┌─────────────────────────────────────────┐
│ length (BigSize)                        │
├─────────────────────────────────────────┤
│ amt_to_forward (type 2)                 │
│ outgoing_cltv_value (type 4)            │
│ short_channel_id (type 6)               │
│ payment_data (type 8)                   │
│ [additional TLVs]                       │
├─────────────────────────────────────────┤
│ hmac (32 bytes)                         │
└─────────────────────────────────────────┘

Payment Data (Final Hop)

payment_data:
  payment_secret (32 bytes)
  total_msat (8 bytes)

Processing at Each Hop

Unwrapping

def process_onion(onion, node_private_key, associated_data):
    """Process onion at forwarding node."""
    # Derive shared secret
    shared = ecdh(node_private_key, onion.public_key)
    keys = derive_keys(shared)

    # Verify HMAC
    expected_hmac = calculate_hmac(keys['mu'], onion.hop_payloads, associated_data)
    if expected_hmac != onion.hmac:
        raise OnionError("HMAC verification failed")

    # Decrypt one layer
    stream = generate_stream(keys['rho'], 1300)
    decrypted = xor(onion.hop_payloads, stream)

    # Parse hop payload
    hop_payload, next_payload = parse_hop_payload(decrypted)

    # Check if final hop
    if hop_payload.short_channel_id == 0:
        return FinalHop(hop_payload)

    # Calculate next ephemeral key
    blinding = sha256(onion.public_key || shared)
    next_pubkey = onion.public_key * blinding

    # Build next onion
    next_onion = OnionPacket(
        version=0,
        public_key=next_pubkey,
        hop_payloads=next_payload + zeros(len(hop_payload)),
        hmac=hop_payload.hmac
    )

    return ForwardingHop(hop_payload, next_onion)

Error Handling

Error Packet

When payment fails, error propagates backward:

┌─────────────────────────────────────────┐
│ hmac (32 bytes)                         │
│ failuremsg_len (2 bytes)                │
│ failuremsg (failuremsg_len bytes)       │
│ pad_len (2 bytes)                       │
│ pad (pad_len bytes)                     │
└─────────────────────────────────────────┘

Error Encryption

Each hop encrypts the error with their um key:

def wrap_error(error, shared_secret):
    """Encrypt error for return path."""
    keys = derive_keys(shared_secret)
    ammag = hmac_sha256(keys['um'], b'ammag')

    stream = generate_stream(ammag, len(error))
    return xor(error, stream)

Error Decryption (Sender)

Sender decrypts layer by layer:

def unwrap_error(error, hops, session_key):
    """Decrypt error to find failure point."""
    for i, hop in enumerate(hops):
        shared = derive_shared_secret_for_hop(session_key, hops[:i+1])
        error = wrap_error(error, shared)  # Same operation

        # Check HMAC
        if verify_error_hmac(error, shared):
            return (i, parse_error(error))

    raise Exception("Could not decrypt error")

Failure Messages

Format

┌─────────────────────────────────────────┐
│ failure_code (2 bytes)                  │
│ [data]: failure-specific                │
└─────────────────────────────────────────┘

Common Failure Codes

CodeNameMeaning
0x4000PERMPermanent failure
0x2000NODENode failure
0x1000UPDATEChannel update included
0x400Fincorrect_or_unknown_payment_detailsUnknown hash
0x400Dfinal_expiry_too_soonCLTV too close
0x1007temporary_channel_failureNo liquidity
0x100Cfee_insufficientNeed higher fee

Failure with Update

┌─────────────────────────────────────────┐
│ failure_code (2 bytes): 0x1XXX          │
│ len (2 bytes)                           │
│ channel_update (len bytes)              │
└─────────────────────────────────────────┘

Multi-Path Payments (MPP)

Shared Payment Secret

All parts use same payment_secret:

# Sender creates invoice
payment_secret = random_32_bytes()
payment_hash = sha256(preimage)

# Each part includes in final hop
part1_tlv = {
    'payment_data': {
        'payment_secret': payment_secret,
        'total_msat': 100000000  # Total, not part amount
    }
}

Part Identification

Recipient waits for all parts:

  • Same payment_hash
  • Same payment_secret
  • Sum of amounts = total_msat

Agent Implementation

Creating Route Payload

def create_route_payload(route, payment_hash, payment_secret, total_msat):
    """Create TLV payloads for each hop."""
    payloads = []

    for i, hop in enumerate(route):
        is_final = (i == len(route) - 1)

        payload = {
            2: hop.amount_msat,  # amt_to_forward
            4: hop.cltv_expiry,  # outgoing_cltv
        }

        if is_final:
            # Final hop gets payment data
            payload[8] = {
                'payment_secret': payment_secret,
                'total_msat': total_msat
            }
        else:
            # Intermediate hop gets next channel
            payload[6] = hop.short_channel_id

        payloads.append(encode_tlv(payload))

    return payloads

Machine-Readable Summary

{
  "bolt": "04",
  "title": "Onion Routing",
  "status": "final",
  "packet_size": 1366,
  "key_concepts": [
    "sphinx-construction",
    "layered-encryption",
    "error-propagation",
    "tlv-payloads"
  ],
  "privacy": {
    "sender_hidden": true,
    "receiver_hidden": true,
    "amount_hidden": true,
    "route_hidden": true
  }
}