Implements structure-aware fuzzing of BOLT 7 gossip messages — channel_announcement,
node_announcement, channel_update, and announcement_signatures — with valid
secp256k1 signatures, so the fuzzer can penetrate gossip validation logic that
raw-bytes fuzzing cannot reach.
Scope: implements Milestone 6 from the IR Design and Implementation Plan (#5).
Non-goals: routing-table semantics, onion routing, BOLT 11 invoices.
1. Motivation
Every Lightning node accepts gossip from untrusted peers. Bugs in gossip parsing, signature verification, or storage have led to memory exhaustion, CPU DoS, and corrupted routing tables across implementations. The current
EncryptedBytesScenario only stresses early parsing, because raw bytes never satisfy:
- four chained
secp256k1 signatures over a double-SHA256 of the message tail
(channel_announcement),
- the lexicographic
node_id_1 < node_id_2 ordering rule,
chain_hash and short_channel_id consistency between
channel_announcement → channel_update → announcement_signatures,
- the
must_be_one flag and htlc_minimum_msat ≤ htlc_maximum_msat ≤ capacity
invariants in channel_update.
On Extending the IR with gossip operations lets the fuzzer construct structurally valid messages whose mutations exercise the deep code paths behind these checks.
2. Current State (baseline)
| Area |
Status |
| BOLT 7 codecs |
only gossip_timestamp_filter exists |
IR Operations |
BuildOpenChannel + load/compute primitives only |
IR VariableTypes |
no Signature, ShortChannelId, or Timestamp types |
| Generators / executor |
not yet upstream (Milestones 1–5) |
msg_type constants |
gossip types 256–259 missing from bolt.rs |
This milestone assumes Milestone 1 (executor + ProgramBuilder + at least one Generator) has landed. It does not depend on Milestones 2–5.
3. High-Level Architecture
4. Deliverables
4.1 BOLT 7 Codecs — smite/smite/src/bolt/
New modules, each exporting an encode / decode pair plus a struct that mirrors the wire layout. All re-exported from bolt.rs
and registered in msg_type:
| File |
Type |
msg_type |
channel_announcement.rs |
ChannelAnnouncement |
256 |
node_announcement.rs |
NodeAnnouncement (+ Address enum: ipv4/ipv6/tor‑v3/dns) |
257 |
channel_update.rs |
ChannelUpdate (+ MessageFlags, ChannelFlags bitfields) |
258 |
announcement_signatures.rs |
AnnouncementSignatures |
259 |
short_channel_id.rs |
ShortChannelId (3+3+2 byte packed u64) |
— |
Implementation notes:
- Reuse the existing
WireFormat trait in wire.rs
(it already supports Signature, PublicKey, and [u8; N]).
- For
ChannelAnnouncement, expose a helper
signing_hash(&self) -> [u8; 32] that returns the double‑SHA256 of the
message starting at byte offset 256 (i.e. features onwards). The hash skips
the four signature slots but covers any trailing bytes, per BOLT 7.
- For
NodeAnnouncement and ChannelUpdate, expose
signing_hash(&self) -> [u8; 32] covering everything after the leading
signature field.
- Round-trip property tests + at least one BOLT‑7 spec vector per message.
4.2 IR Extensions — smite/smite-ir/src/
New VariableTypes (in variable.rs):
Signature, ShortChannelId, Timestamp, plus three compounds for parsed gossip if/when we add Recv* ops (ChannelAnnouncementData, NodeAnnouncementData, ChannelUpdateData) gated behind Extract*
operations following the existing AcceptChannel pattern.
New Operations (in operation.rs):
Load:
LoadTimestamp(u32)
LoadShortChannelId(u64) # packed: (block << 40) | (tx << 16) | out
LoadRgbColor([u8;3])
LoadAlias([u8;32])
LoadAddresses(Vec<u8>) # already-encoded address descriptor list
Compute (reusable beyond gossip):
BuildShortChannelId # inputs: BlockHeight, U16 (tx_index), U16 (out)
# ergonomic alternative to LoadShortChannelId
DoubleSha256 # input: Bytes -> Bytes(32)
Sign # inputs: PrivateKey, Bytes(32) -> Signature
# secp256k1 ECDSA, deterministic (RFC 6979)
Build (unsigned + signed pairs, see §4.3):
BuildChannelAnnouncementUnsigned / BuildChannelAnnouncement
BuildNodeAnnouncementUnsigned / BuildNodeAnnouncement
BuildChannelUpdateUnsigned / BuildChannelUpdate
BuildAnnouncementSignatures # unsigned form not needed (sigs are inputs)
The Sign and DoubleSha256 ops are deliberately generic primitives, they are also prerequisites for future commitment-signature work in Milestones 2–3, so this milestone pays down shared infrastructure debt.
Lexicographic node ordering. BOLT 7 requires node_id_1 < node_id_2. The generator computes both pubkeys at IR-emit time and orders the four LoadPrivateKey instructions accordingly. Subsequent OperationParamMutator
or InputSwapMutator mutations may break the order — that is desirable, since it exercises the receiver's node_id_1 ≥ node_id_2 warning path. No runtime sort op is needed.
4.3 Build Operation Wiring (example — channel_announcement)
Why split unsigned + signed? All four BOLT 7 signed messages sign
the message bytes themselves, with the signature(s) prepended. Encoding the
unsigned form first lets us:
- feed it directly into a generic
DoubleSha256(Bytes) -> Bytes op (no
message-type-specific hashing logic in the executor);
- emit each
Sign as an independent SSA instruction that any mutator can
delete, swap, or replace;
- let
OperationParamMutator mutate the unsigned body without invalidating
signatures the fuzzer wants to keep — and conversely, mutate signatures
without re-encoding the body. Both halves of that split reach distinct
validation paths.
Hash regions. The signing region differs per message; the executor stays generic by exposing only DoubleSha256(Bytes), while each codec exposes a signing_region(&self) -> &[u8] helper that the unsigned-Build op uses internally:
| Message |
Bytes hashed |
channel_announcement |
bytes [256..] (skips 4× 64‑byte sig slots) |
node_announcement |
bytes [64..] (skips 1× 64‑byte sig slot) |
channel_update |
bytes [64..] (skips 1× 64‑byte sig slot) |
announcement_signatures |
not signed; signatures come from the chan_ann hash |
Concretely each Build*Unsigned op produces a Message variable whose bytes are already in the signing region (i.e. the leading sig slots are zero-filled and stripped before being passed to DoubleSha256). The matching
Build* op accepts the unsigned Message plus the freshly-produced Signature(s) and patches them into the leading slots before SendMessage.
4.4 Generators — smite/smite-ir/src/generators/gossip.rs
Three message generators plus one composing flow generator:
| Generator |
Type |
Emits |
ChannelAnnouncementMsg |
message |
4 keypairs, sort, build unsigned, hash, 4× sign, build signed, SendMessage |
NodeAnnouncementMsg |
message |
1 keypair, timestamp, alias/color/addresses, build unsigned, hash, sign, send |
ChannelUpdateMsg |
message |
scid + chain_hash + flags + fees, build unsigned, hash, sign, send |
GossipFlow |
flow |
ChannelAnnouncementMsg → 2× ChannelUpdateMsg (one per direction) → 2× NodeAnnouncementMsg. All share the same scid, node_keys, and chain_hash via ProgramBuilder::pick_variable. |
Each generator follows the existing pattern: ask ProgramBuilder for typed variables (75 % reuse / 15 % cross-pollinate / 10 % fresh) and emit instructions via builder.append. No generator manipulates indices directly.
AnnouncementSignaturesMsg is gated on the PostChannelOpenSetup snapshot (reuses local_keys / funding_outpoint from ProgramContext) and is
optional for this milestone added only if Milestone 2 has landed.
4.5 Snapshot Setup
Reuse the existing PostInitSetup from Milestone 1. The init message we send
during setup must:
- advertise
gossip_queries (so the target accepts our query stretches if the
stretch goal is implemented), and
- immediately follow init with
GossipTimestampFilter::no_gossip(chain_hash)
to silence inbound gossip and keep the executor's receive loop predictable.
A new binary entry point, <target>_ir_gossip, is added under
smite-scenarios/src/bin/
wiring IrScenario<T, PostInitSetup> for each of LND / LDK / CLN / Eclair.
Per-target gossip entry symbols the coverage comparison should target
(to be confirmed during implementation):
| Target |
Suspected entry symbols |
| LND |
discovery.(*AuthenticatedGossiper).processNetworkAnnouncement, routing.ValidateChannelAnn |
| LDK |
lightning::routing::gossip::NetworkGraph::update_channel_from_announcement, update_node_from_announcement |
| CLN |
gossipd/queries.c:handle_channel_announcement, routing.c:routing_add_channel_announcement |
| Eclair |
fr.acinq.eclair.router.Validation.handleChannelAnnouncement, handleChannelUpdate |
4.6 Mutator Interaction
No new mutators required. Existing planned mutators already cover gossip:
| Mutator |
Effect on gossip programs |
OperationParamMutator |
flips bits in LoadFeatures, LoadAlias, LoadTimestamp, LoadShortChannelId |
InputSwapMutator |
swaps node_1 ↔ node_2 keys (breaks ordering → exercises the node_id_1 ≥ node_id_2 warning path) |
InstructionDeleteMutator |
drops the Sign step → exercises bad-signature path |
GeneratorInsertionMutator |
inserts a duplicate BuildChannelUpdate with same timestamp but different fees → exercises the blacklist path |
5. Sample Generated Program
v0 = LoadPrivateKey(0x01..) # node_1 secret
v1 = DerivePoint(v0)
v2 = LoadPrivateKey(0x02..) # node_2 secret
v3 = DerivePoint(v2)
v4 = LoadPrivateKey(0x03..) # bitcoin_key_1 secret
v5 = DerivePoint(v4)
v6 = LoadPrivateKey(0x04..) # bitcoin_key_2 secret
v7 = DerivePoint(v6)
v8 = LoadShortChannelId(0x0837A4_00034D_0001)
v9 = LoadChainHashFromContext()
v10 = LoadFeatures(0x)
v11 = BuildChannelAnnouncementUnsigned(v9, v8, v1, v3, v5, v7, v10)
v12 = DoubleSha256(v11) # hashes bytes [256..]
v13 = Sign(v0, v12)
v14 = Sign(v2, v12)
v15 = Sign(v4, v12)
v16 = Sign(v6, v12)
v17 = BuildChannelAnnouncement(v11, v13, v14, v15, v16)
SendMessage(v17)
v18 = LoadTimestamp(1_715_000_000)
v19 = LoadU8(0x01) # message_flags: must_be_one
v20 = LoadU8(0x00) # channel_flags: direction=node_1, enabled
v21 = LoadU16(144) # cltv_expiry_delta
v22 = LoadAmount(1_000) # htlc_minimum_msat
v23 = LoadAmount(1_000) # fee_base_msat
v24 = LoadAmount(100) # fee_proportional_millionths
v25 = LoadAmount(99_000_000) # htlc_maximum_msat
v26 = BuildChannelUpdateUnsigned(v9, v8, v18, v19, v20, v21, v22, v23, v24, v25)
v27 = DoubleSha256(v26)
v28 = Sign(v0, v27)
v29 = BuildChannelUpdate(v26, v28)
SendMessage(v29)
6. Validation & Coverage Analysis
Unit tests
- Round-trip codec tests for all four messages (encode → decode → encode).
- BOLT 7 spec vectors where available (mainnet
chain_hash, ordering rule).
- Generator tests asserting
node_id_1 < node_id_2 and matching scid /
chain_hash between flow-generated channel_announcement and its two
channel_updates.
End-to-end fuzzing
- 24 h fuzzing runs on all four supported targets using the existing
coverage-report.sh workflow.
- Acceptance criterion:
ir_gossip corpus must hit functions named
*verify_channel_announcement*, *process_channel_update*,
*store_node_announcement* (or per-target equivalents) that the
encrypted_bytes corpus does not reach. Comparison published as a small
table in the PR description.
7. Stretch Goals
- Gossip queries — add codecs +
Build* ops for query_channel_range,
reply_channel_range (incl. timestamps_tlv / checksums_tlv with
CRC32C), query_short_channel_ids, and reply_short_channel_ids_end.
These are unsigned, so they reuse Sign infrastructure trivially.
- Plausible funding outpoints — add a
MineFundingTx action generator
that, before emitting a channel_announcement, drives bitcoind (already
available to the executor) to mine a transaction with a P2WSH output spending
to 2-of-2 multisig(bitcoin_key_1, bitcoin_key_2). The resulting
(block, tx_index, vout) is fed into BuildShortChannelId, allowing the
message to pass on-chain validation in targets that perform UTXO lookup
(e.g. CLN with --funding-confirms-required).
- Regtest splice announcements — once Milestone 5 lands, exercise the
splice-driven channel_announcement regeneration path.
8. Risks & Open Questions
secp256k1 signing on the VM hot path. Each Sign op costs ~80 µs on
modern x86. A GossipFlow runs ≤ 8 signatures (4 for chan_ann +
2× chan_update + 2× node_ann) ≈ 0.6 ms — well below the dominant Nyx
snapshot-restore cost. The unsigned/signed split (§4.3) means mutators
don't need to re-sign on every byte flip, so the steady-state cost
stays low.
- Compound
Recv* types deferred. Gossip is largely a one-way ingress
surface; the target rarely replies synchronously. The executor's existing
auto-pong loop already drains anything inbound. If/when a gossip oracle is
added (e.g. "target must not re-broadcast a chan_ann with bad sigs"), we
can add RecvChannelAnnouncement + Extract* following the
AcceptChannel pattern.
short_channel_id realism. Without the §8 funding-outpoint stretch
goal, generated SCIDs reference non-existent UTXOs. CLN and LND will reject
channel_announcement early on UTXO lookup; LDK and Eclair are less strict
in default config. To guarantee all four targets reach signature
verification, the ir_gossip workload Dockerfiles disable on-chain UTXO
validation where possible (e.g. CLN --dev-fast-gossip --dev-allow-localhost,
LND regtest with funding-validation off). Each per-target tweak is documented
in the corresponding workloads/<target>/Dockerfile change.
- Spec ambiguity around
must_be_one. Receivers are now required to
ignore it, but several implementations historically rejected
message_flags & 0x01 == 0. Exposed as a plain LoadU8 so the fuzzer can
flip it.
1. Motivation
Every Lightning node accepts gossip from untrusted peers. Bugs in gossip parsing, signature verification, or storage have led to memory exhaustion, CPU DoS, and corrupted routing tables across implementations. The current
EncryptedBytesScenarioonly stresses early parsing, because raw bytes never satisfy:secp256k1signatures over a double-SHA256 of the message tail(
channel_announcement),node_id_1 < node_id_2ordering rule,chain_hashandshort_channel_idconsistency betweenchannel_announcement→channel_update→announcement_signatures,must_be_oneflag andhtlc_minimum_msat ≤ htlc_maximum_msat ≤ capacityinvariants in
channel_update.On Extending the IR with gossip operations lets the fuzzer construct structurally valid messages whose mutations exercise the deep code paths behind these checks.
2. Current State (baseline)
gossip_timestamp_filterexistsOperationsBuildOpenChannel+ load/compute primitives onlyVariableTypesSignature,ShortChannelId, orTimestamptypesmsg_typeconstantsbolt.rsThis milestone assumes Milestone 1 (executor +
ProgramBuilder+ at least oneGenerator) has landed. It does not depend on Milestones 2–5.3. High-Level Architecture
4. Deliverables
4.1 BOLT 7 Codecs —
smite/smite/src/bolt/New modules, each exporting an
encode/decodepair plus a struct that mirrors the wire layout. All re-exported frombolt.rsand registered in
msg_type:msg_typechannel_announcement.rsChannelAnnouncement256node_announcement.rsNodeAnnouncement(+Addressenum: ipv4/ipv6/tor‑v3/dns)257channel_update.rsChannelUpdate(+MessageFlags,ChannelFlagsbitfields)258announcement_signatures.rsAnnouncementSignatures259short_channel_id.rsShortChannelId(3+3+2 byte packedu64)Implementation notes:
WireFormattrait inwire.rs(it already supports
Signature,PublicKey, and[u8; N]).ChannelAnnouncement, expose a helpersigning_hash(&self) -> [u8; 32]that returns the double‑SHA256 of themessage starting at byte offset 256 (i.e.
featuresonwards). The hash skipsthe four signature slots but covers any trailing bytes, per BOLT 7.
NodeAnnouncementandChannelUpdate, exposesigning_hash(&self) -> [u8; 32]covering everything after the leadingsignaturefield.4.2 IR Extensions —
smite/smite-ir/src/New
VariableTypes (invariable.rs):Signature,ShortChannelId,Timestamp, plus three compounds for parsed gossip if/when we addRecv*ops (ChannelAnnouncementData,NodeAnnouncementData,ChannelUpdateData) gated behindExtract*operations following the existing
AcceptChannelpattern.New
Operations (inoperation.rs):The
SignandDoubleSha256ops are deliberately generic primitives, they are also prerequisites for future commitment-signature work in Milestones 2–3, so this milestone pays down shared infrastructure debt.Lexicographic node ordering. BOLT 7 requires
node_id_1 < node_id_2. The generator computes both pubkeys at IR-emit time and orders the fourLoadPrivateKeyinstructions accordingly. SubsequentOperationParamMutatoror
InputSwapMutatormutations may break the order — that is desirable, since it exercises the receiver'snode_id_1 ≥ node_id_2warning path. No runtime sort op is needed.4.3 Build Operation Wiring (example —
channel_announcement)Why split unsigned + signed? All four BOLT 7 signed messages sign
the message bytes themselves, with the signature(s) prepended. Encoding the
unsigned form first lets us:
DoubleSha256(Bytes) -> Bytesop (nomessage-type-specific hashing logic in the executor);
Signas an independent SSA instruction that any mutator candelete, swap, or replace;
OperationParamMutatormutate the unsigned body without invalidatingsignatures the fuzzer wants to keep — and conversely, mutate signatures
without re-encoding the body. Both halves of that split reach distinct
validation paths.
Hash regions. The signing region differs per message; the executor stays generic by exposing only
DoubleSha256(Bytes), while each codec exposes asigning_region(&self) -> &[u8]helper that the unsigned-Build op uses internally:channel_announcementnode_announcementchannel_updateannouncement_signaturesConcretely each
Build*Unsignedop produces aMessagevariable whose bytes are already in the signing region (i.e. the leading sig slots are zero-filled and stripped before being passed toDoubleSha256). The matchingBuild*op accepts the unsignedMessageplus the freshly-producedSignature(s) and patches them into the leading slots beforeSendMessage.4.4 Generators —
smite/smite-ir/src/generators/gossip.rsThree message generators plus one composing flow generator:
ChannelAnnouncementMsgSendMessageNodeAnnouncementMsgChannelUpdateMsgGossipFlowChannelAnnouncementMsg→ 2×ChannelUpdateMsg(one per direction) → 2×NodeAnnouncementMsg. All share the samescid,node_keys, andchain_hashviaProgramBuilder::pick_variable.Each generator follows the existing pattern: ask
ProgramBuilderfor typed variables (75 % reuse / 15 % cross-pollinate / 10 % fresh) and emit instructions viabuilder.append. No generator manipulates indices directly.AnnouncementSignaturesMsgis gated on thePostChannelOpenSetupsnapshot (reuseslocal_keys/funding_outpointfromProgramContext) and isoptional for this milestone added only if Milestone 2 has landed.
4.5 Snapshot Setup
Reuse the existing
PostInitSetupfrom Milestone 1. The init message we sendduring setup must:
gossip_queries(so the target accepts our query stretches if thestretch goal is implemented), and
GossipTimestampFilter::no_gossip(chain_hash)to silence inbound gossip and keep the executor's receive loop predictable.
A new binary entry point,
<target>_ir_gossip, is added undersmite-scenarios/src/bin/wiring
IrScenario<T, PostInitSetup>for each of LND / LDK / CLN / Eclair.Per-target gossip entry symbols the coverage comparison should target
(to be confirmed during implementation):
discovery.(*AuthenticatedGossiper).processNetworkAnnouncement,routing.ValidateChannelAnnlightning::routing::gossip::NetworkGraph::update_channel_from_announcement,update_node_from_announcementgossipd/queries.c:handle_channel_announcement,routing.c:routing_add_channel_announcementfr.acinq.eclair.router.Validation.handleChannelAnnouncement,handleChannelUpdate4.6 Mutator Interaction
No new mutators required. Existing planned mutators already cover gossip:
OperationParamMutatorLoadFeatures,LoadAlias,LoadTimestamp,LoadShortChannelIdInputSwapMutatornode_1↔node_2keys (breaks ordering → exercises thenode_id_1 ≥ node_id_2warning path)InstructionDeleteMutatorSignstep → exercises bad-signature pathGeneratorInsertionMutatorBuildChannelUpdatewith same timestamp but different fees → exercises the blacklist path5. Sample Generated Program
6. Validation & Coverage Analysis
Unit tests
chain_hash, ordering rule).node_id_1 < node_id_2and matchingscid/chain_hashbetween flow-generatedchannel_announcementand its twochannel_updates.End-to-end fuzzing
coverage-report.shworkflow.ir_gossipcorpus must hit functions named*verify_channel_announcement*,*process_channel_update*,*store_node_announcement*(or per-target equivalents) that theencrypted_bytescorpus does not reach. Comparison published as a smalltable in the PR description.
7. Stretch Goals
Build*ops forquery_channel_range,reply_channel_range(incl.timestamps_tlv/checksums_tlvwithCRC32C),
query_short_channel_ids, andreply_short_channel_ids_end.These are unsigned, so they reuse
Signinfrastructure trivially.MineFundingTxaction generatorthat, before emitting a
channel_announcement, drivesbitcoind(alreadyavailable to the executor) to mine a transaction with a P2WSH output spending
to
2-of-2 multisig(bitcoin_key_1, bitcoin_key_2). The resulting(block, tx_index, vout)is fed intoBuildShortChannelId, allowing themessage to pass on-chain validation in targets that perform UTXO lookup
(e.g. CLN with
--funding-confirms-required).splice-driven
channel_announcementregeneration path.8. Risks & Open Questions
secp256k1signing on the VM hot path. EachSignop costs ~80 µs onmodern x86. A
GossipFlowruns ≤ 8 signatures (4 for chan_ann +2× chan_update + 2× node_ann) ≈ 0.6 ms — well below the dominant Nyx
snapshot-restore cost. The unsigned/signed split (§4.3) means mutators
don't need to re-sign on every byte flip, so the steady-state cost
stays low.
Recv*types deferred. Gossip is largely a one-way ingresssurface; the target rarely replies synchronously. The executor's existing
auto-pong loop already drains anything inbound. If/when a gossip oracle is
added (e.g. "target must not re-broadcast a chan_ann with bad sigs"), we
can add
RecvChannelAnnouncement+Extract*following theAcceptChannelpattern.short_channel_idrealism. Without the §8 funding-outpoint stretchgoal, generated SCIDs reference non-existent UTXOs. CLN and LND will reject
channel_announcementearly on UTXO lookup; LDK and Eclair are less strictin default config. To guarantee all four targets reach signature
verification, the
ir_gossipworkload Dockerfiles disable on-chain UTXOvalidation where possible (e.g. CLN
--dev-fast-gossip --dev-allow-localhost,LND regtest with funding-validation off). Each per-target tweak is documented
in the corresponding
workloads/<target>/Dockerfilechange.must_be_one. Receivers are now required toignore it, but several implementations historically rejected
message_flags & 0x01 == 0. Exposed as a plainLoadU8so the fuzzer canflip it.