Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a9882e6
bolt: add tx_add_output codec
erickcestari Aug 27, 2026
01f0a05
bolt: add tx_signatures codec
erickcestari Aug 27, 2026
81a5826
bolt: derive v2 channel ids from revocation basepoints
erickcestari Aug 28, 2026
b123898
bitcoin: add raw transaction and sign-only RPCs
erickcestari Aug 28, 2026
cb65e9d
channel_tx: add interactive transaction construction
erickcestari Aug 28, 2026
61207a3
smite-scenarios: negotiate option_dual_fund
erickcestari Aug 28, 2026
98caa8b
smite-ir: add open_channel2 operations
erickcestari Aug 28, 2026
efde91d
channel_tx: compute the initiator's collaborative transaction fee
erickcestari Aug 28, 2026
9985c06
smite-ir: add interactive transaction construction operations
erickcestari Aug 28, 2026
082d4b0
smite-ir: add v2 commitment and tx_signatures operations
erickcestari Aug 28, 2026
e8141cd
smite-ir: add the dual funding flow generator
erickcestari Aug 28, 2026
9567feb
smite-ir-mutator: select the generator set per campaign
erickcestari Aug 28, 2026
8c3eccf
smite-scenarios: stop receiving once the tx exchange concludes
erickcestari Aug 28, 2026
b1eb5a7
smite-scenarios: give the wallet more than one spendable UTXO
erickcestari Aug 28, 2026
727f414
smite-scenarios: count the interactive tx replies the peer owes
erickcestari Aug 28, 2026
829a24b
smite: stop panicking on funding transactions the fuzzer negotiated
erickcestari Sep 9, 2026
8a1f8e6
smite: own the interactive tx exchange in one type
erickcestari Sep 10, 2026
dbe61fc
smite: keep our entries when the peer removes them
erickcestari Sep 10, 2026
ba1b21d
smite: check a policy-rejected transaction fits in a block before min…
erickcestari Sep 10, 2026
8f4fb9a
smite-scenarios: settle the negotiation before building from it
erickcestari Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions smite-ir-mutator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
//! bypasses our custom mutators. This was an AFL++ bug fixed upstream in
//! commit eddb2701b022351fb34b696ccf923bb856e9d953.
//!
//! Optionally:
//! - `SMITE_IR_GENERATORS=v1|v2|all` -- which generators to draw from. BOLT 2
//! makes the two channel establishment flows mutually exclusive on one
//! connection, so a campaign against an `ir` scenario wants `v1` and one
//! against `ir_v2` wants `v2`; the other flow's programs would only ever be
//! rejected. Defaults to `all`, which draws from both.
//!
//! # Logging
//!
//! Logging is opt-in: [`afl_custom_init`] installs a logger only when
Expand Down Expand Up @@ -60,6 +67,24 @@ struct MutatorState {
/// Sequence of actions taken in the last [`afl_custom_fuzz`] call, used by
/// [`afl_custom_describe`] to name queue entries.
last_sequence: Vec<&'static str>,
/// Generators this campaign draws from, selected by `SMITE_IR_GENERATORS`.
generators: &'static [AnyGenerator],
}

/// Reads `SMITE_IR_GENERATORS` and returns the generator set it names,
/// defaulting to all of them.
fn generators_from_env() -> &'static [AnyGenerator] {
match std::env::var("SMITE_IR_GENERATORS").as_deref() {
Ok("v1") => AnyGenerator::V1,
Ok("v2") => AnyGenerator::V2,
Ok("all") | Err(_) => AnyGenerator::ALL,
Ok(other) => {
eprintln!(
"[smite-ir-mutator] WARNING: unknown SMITE_IR_GENERATORS={other:?}, using all",
);
AnyGenerator::ALL
}
}
}

impl MutatorState {
Expand All @@ -69,17 +94,18 @@ impl MutatorState {
out_buf: Vec::new(),
description: Vec::new(),
last_sequence: vec!["init"],
generators: generators_from_env(),
}
}

/// Generates a fresh program from scratch by randomly delegating to one of
/// the registered generators.
fn generate_fresh(&mut self) -> Program {
let mut builder = ProgramBuilder::new();
AnyGenerator::ALL
self.generators
.iter()
.choose(&mut self.rng)
.expect("AnyGenerator::ALL is non-empty")
.expect("the generator set is non-empty")
.generate(&mut builder, &mut self.rng);
self.last_sequence.clear();
self.last_sequence.push("fresh");
Expand Down Expand Up @@ -114,10 +140,11 @@ impl MutatorState {
"instr-reorder"
}
4 => {
let generator = *AnyGenerator::ALL
let generator = *self
.generators
.iter()
.choose(&mut self.rng)
.expect("AnyGenerator::ALL is non-empty");
.expect("the generator set is non-empty");
let mutator = GeneratorInsertionMutator::new(generator);
mutator.mutate(program, &mut self.rng);
"gen-insert"
Expand Down
15 changes: 15 additions & 0 deletions smite-ir/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,27 @@ impl ProgramBuilder {
VariableType::AcceptChannel => {
panic!("cannot generate fresh AcceptChannel: requires protocol interaction")
}
VariableType::OpenChannel2Message => {
panic!("cannot generate fresh OpenChannel2Message: requires composed inputs")
}
VariableType::AcceptChannel2 => {
panic!("cannot generate fresh AcceptChannel2: requires protocol interaction")
}
VariableType::FundingTransaction => {
panic!("cannot generate fresh FundingTransaction: requires composed inputs")
}
VariableType::SentOpenChannel => {
panic!("cannot generate fresh SentOpenChannel: affine type")
}
VariableType::SentOpenChannel2 => {
panic!("cannot generate fresh SentOpenChannel2: affine type")
}
VariableType::SentInteractiveTx => {
panic!("cannot generate fresh SentInteractiveTx: affine type")
}
VariableType::SentCommitmentSigned => {
panic!("cannot generate fresh SentCommitmentSigned: affine type")
}
VariableType::SentFundingCreated => {
panic!("cannot generate fresh SentFundingCreated: affine type")
}
Expand Down
32 changes: 32 additions & 0 deletions smite-ir/src/generators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
mod channel_announcement;
mod channel_ready;
mod channel_update;
mod dual_funding_flow;
mod funding_created;
mod funding_flow;
mod node_announcement;
Expand All @@ -16,6 +17,7 @@ mod open_channel;
pub use channel_announcement::ChannelAnnouncementGenerator;
pub use channel_ready::ChannelReadyGenerator;
pub use channel_update::ChannelUpdateGenerator;
pub use dual_funding_flow::DualFundingFlowGenerator;
pub use funding_created::FundingCreatedGenerator;
pub use funding_flow::FundingFlowGenerator;
pub use node_announcement::NodeAnnouncementGenerator;
Expand All @@ -42,9 +44,37 @@ pub enum AnyGenerator {
FundingCreated(FundingCreatedGenerator),
ChannelReady(ChannelReadyGenerator),
FundingFlow(FundingFlowGenerator),
DualFundingFlow(DualFundingFlowGenerator),
}

impl AnyGenerator {
/// Generators for the v1 (single-funded) channel establishment flow, plus
/// the gossip generators, which are flow-independent.
///
/// BOLT 2 makes the two establishment flows mutually exclusive on one
/// connection, so a campaign negotiating `option_dual_fund` can only ever
/// have the v1 generators rejected, and vice versa. Splitting them lets a
/// campaign spend its executions on programs its target can act on.
pub const V1: &[Self] = &[
Self::ChannelAnnouncement(ChannelAnnouncementGenerator),
Self::ChannelUpdate(ChannelUpdateGenerator),
Self::NodeAnnouncement(NodeAnnouncementGenerator),
Self::OpenChannel(OpenChannelGenerator),
Self::FundingCreated(FundingCreatedGenerator),
Self::ChannelReady(ChannelReadyGenerator),
Self::FundingFlow(FundingFlowGenerator),
];

/// Generators for the v2 (dual-funded) channel establishment flow, plus the
/// gossip generators. See [`Self::V1`].
pub const V2: &[Self] = &[
Self::ChannelAnnouncement(ChannelAnnouncementGenerator),
Self::ChannelUpdate(ChannelUpdateGenerator),
Self::NodeAnnouncement(NodeAnnouncementGenerator),
Self::ChannelReady(ChannelReadyGenerator),
Self::DualFundingFlow(DualFundingFlowGenerator),
];

/// All variants. Keep in sync with the enum definition.
pub const ALL: &[Self] = &[
Self::ChannelAnnouncement(ChannelAnnouncementGenerator),
Expand All @@ -54,6 +84,7 @@ impl AnyGenerator {
Self::FundingCreated(FundingCreatedGenerator),
Self::ChannelReady(ChannelReadyGenerator),
Self::FundingFlow(FundingFlowGenerator),
Self::DualFundingFlow(DualFundingFlowGenerator),
];
}

Expand All @@ -67,6 +98,7 @@ impl Generator for AnyGenerator {
Self::FundingCreated(generator) => generator.generate(builder, rng),
Self::ChannelReady(generator) => generator.generate(builder, rng),
Self::FundingFlow(generator) => generator.generate(builder, rng),
Self::DualFundingFlow(generator) => generator.generate(builder, rng),
}
}
}
223 changes: 223 additions & 0 deletions smite-ir/src/generators/dual_funding_flow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
//! Generator for the complete channel establishment v2 (dual-funded) flow.

use rand::seq::IndexedRandom;
use rand::{Rng, RngExt};

use super::Generator;
use crate::builder::ProgramBuilder;
use crate::operation::{
AcceptChannel2Field, ChannelTypeVariant, ShutdownScriptVariant, TxOutputRole,
};
use crate::{Operation, VariableType};

/// `serial_id` of the funding output we contribute. BOLT 2 requires the
/// initiator to use even ids; picking these from a high range keeps them clear
/// of the ones assigned to inputs.
const FUNDING_OUTPUT_SERIAL_ID: u64 = 2000;

/// `serial_id` of our change output.
const CHANGE_OUTPUT_SERIAL_ID: u64 = 2002;

/// `nSequence` for the inputs we contribute. BOLT 2 caps it at `0xfffffffd` so
/// every input signals replaceability, and recommends one shared value across
/// implementations to avoid fingerprinting.
const SEQUENCE: u32 = 0xffff_fffd;

/// Channel types most likely to be accepted, so the flow reaches its later
/// steps often enough to cover them. `LoadChannelType` is mutable, so the
/// mutator still reaches the rest.
const LIKELY_CHANNEL_TYPES: &[ChannelTypeVariant] = &[
ChannelTypeVariant::Anchors,
ChannelTypeVariant::StaticRemoteKey,
];

/// Generates the complete channel establishment v2 flow.
///
/// Emits instructions to:
/// 1. Build and send `open_channel2`, then receive `accept_channel2`
/// 2. Contribute inputs, the funding output and a change output through
/// interactive transaction construction, concluding with `tx_complete`
/// 3. Exchange `commitment_signed`, then `tx_signatures`
/// 4. Broadcast and confirm the funding transaction
/// 5. Complete the `channel_ready` exchange
#[derive(Clone, Copy)]
pub struct DualFundingFlowGenerator;

impl Generator for DualFundingFlowGenerator {
// One linear protocol script, from open_channel2 through channel_ready.
// Splitting it would scatter a sequence that reads best in wire order.
#[allow(clippy::too_many_lines)]
fn generate(&self, builder: &mut ProgramBuilder, rng: &mut impl Rng) {
// Keys are generated fresh to ensure they're distinct.
let funding_privkey = builder.generate_fresh(VariableType::PrivateKey, rng);
let funding_pubkey = builder.append(Operation::DerivePoint, &[funding_privkey]);
let revocation_basepoint = builder.generate_fresh(VariableType::Point, rng);
let payment_basepoint = builder.generate_fresh(VariableType::Point, rng);
let delayed_payment_basepoint = builder.generate_fresh(VariableType::Point, rng);
let htlc_basepoint = builder.generate_fresh(VariableType::Point, rng);
let first_per_commitment_point = builder.generate_fresh(VariableType::Point, rng);
let second_per_commitment_point = builder.generate_fresh(VariableType::Point, rng);

// BOLT 2 derives the v2 temporary_channel_id from our revocation
// basepoint with a zeroed one standing in for the peer.
let temporary_channel_id = builder.append(
Operation::DeriveTemporaryChannelIdV2,
&[revocation_basepoint],
);

let chain_hash = builder.pick_variable(VariableType::ChainHash, rng);
let funding_satoshis = builder.append(
Operation::LoadAmount(rng.random_range(100_000..=1_000_000)),
&[],
);
let funding_feerate_perkw = builder.append(
Operation::LoadFeeratePerKw(rng.random_range(253..=2_000)),
&[],
);
let commitment_feerate_perkw = builder.append(
Operation::LoadFeeratePerKw(rng.random_range(253..=5_000)),
&[],
);
let dust_limit_satoshis = builder.append(Operation::LoadAmount(546), &[]);
let max_htlc_value_in_flight_msat = builder.append(Operation::LoadAmount(100_000_000), &[]);
let htlc_minimum_msat = builder.append(Operation::LoadAmount(1), &[]);
let to_self_delay = builder.append(Operation::LoadU16(144), &[]);
let max_accepted_htlcs = builder.append(Operation::LoadU16(483), &[]);
let locktime = builder.append(Operation::LoadBlockHeight(0), &[]);
let channel_flags = builder.append(Operation::LoadU8(u8::from(rng.random::<bool>())), &[]);
let upfront_shutdown_script = builder.append(
Operation::LoadShutdownScript(ShutdownScriptVariant::Empty),
&[],
);
let channel_type_variant = if rng.random_range(0..4) == 0 {
*ChannelTypeVariant::ALL
.choose(rng)
.expect("ChannelTypeVariant::ALL is non-empty")
} else {
*LIKELY_CHANNEL_TYPES
.choose(rng)
.expect("LIKELY_CHANNEL_TYPES is non-empty")
};
let channel_type = builder.append(Operation::LoadChannelType(channel_type_variant), &[]);

// Build and send open_channel2.
let open_channel2_msg = builder.append(
Operation::BuildOpenChannel2 {
require_confirmed_inputs: rng.random_range(0..8) == 0,
},
&[
chain_hash,
temporary_channel_id,
funding_feerate_perkw,
commitment_feerate_perkw,
funding_satoshis,
dust_limit_satoshis,
max_htlc_value_in_flight_msat,
htlc_minimum_msat,
to_self_delay,
max_accepted_htlcs,
locktime,
funding_pubkey,
revocation_basepoint,
payment_basepoint,
delayed_payment_basepoint,
htlc_basepoint,
first_per_commitment_point,
second_per_commitment_point,
channel_flags,
upfront_shutdown_script,
channel_type,
],
);
let sent_open_channel2 = builder.append(Operation::SendOpenChannel2, &[open_channel2_msg]);

// Receive accept_channel2, which reveals the peer's revocation
// basepoint and so the channel_id every later message carries.
let accept_channel2 = builder.append(Operation::RecvAcceptChannel2, &[sent_open_channel2]);
let peer_revocation_basepoint = builder.append(
Operation::ExtractAcceptChannel2(AcceptChannel2Field::RevocationBasepoint),
&[accept_channel2],
);
let channel_id = builder.append(
Operation::DeriveChannelIdV2,
&[revocation_basepoint, peer_revocation_basepoint],
);

// Interactive transaction construction. The protocol is turn-based, so
// every contribution we send is followed by the peer's reply.
for i in 0..rng.random_range(1u8..=3) {
let sent = builder.append(
Operation::SendTxAddInput {
// Even ids, as BOLT 2 requires of the initiator.
serial_id: 2 * (u64::from(i) + 1),
utxo_index: i,
sequence: SEQUENCE,
},
&[channel_id],
);
builder.append(Operation::RecvInteractiveTx, &[sent]);
}

// The opener must contribute the funding output, and pays its fees.
for (serial_id, role) in [
(FUNDING_OUTPUT_SERIAL_ID, TxOutputRole::Funding),
(CHANGE_OUTPUT_SERIAL_ID, TxOutputRole::Change),
] {
let sent = builder.append(
Operation::SendTxAddOutput { serial_id, role },
// The value and script are derived from the negotiation for
// both roles here; they matter only once a mutator switches
// the role to `Explicit`.
&[channel_id, funding_satoshis, upfront_shutdown_script],
);
builder.append(Operation::RecvInteractiveTx, &[sent]);
}

// The exchange ends once both sides have sent `tx_complete` back to
// back. If the peer already sent one, ours ends it and nothing more
// arrives. If the peer contributed instead, it still has to answer
// ours with its own `tx_complete`. The executor tells the two cases
// apart at runtime, so this receive reads only when a reply is owed.
let sent_tx_complete = builder.append(Operation::SendTxComplete, &[channel_id]);
builder.append(Operation::RecvInteractiveTx, &[sent_tx_complete]);

// Exchange commitment signatures over the negotiated transaction.
let funding_transaction =
builder.append(Operation::BuildFundingTransactionV2, &[channel_id]);
let sent_commitment_signed = builder.append(
Operation::SendCommitmentSigned,
&[funding_transaction, funding_privkey, channel_id],
);
let funded_channel_id =
builder.append(Operation::RecvCommitmentSigned, &[sent_commitment_signed]);

// We contribute every input, so BOLT 2 has the peer send its
// tx_signatures first.
builder.append(Operation::RecvTxSignatures, &[channel_id]);
builder.append(
Operation::SendTxSignatures,
&[channel_id, funding_transaction],
);
builder.append(Operation::RecvTxSignatures, &[channel_id]);

builder.append(Operation::BroadcastTransaction, &[funding_transaction]);
builder.append(Operation::MineBlocks(rng.random_range(1..=16)), &[]);

// Reuse the second_per_commitment_point already committed to in
// open_channel2: implementations may cross-check the two, and feeding
// an unrelated point would fail channel_ready for a reason that has
// nothing to do with the flow under test.
let short_channel_id = builder.generate_fresh(VariableType::ShortChannelId, rng);
builder.append(
Operation::SendChannelReady {
include_alias: rng.random(),
},
&[
funded_channel_id,
second_per_commitment_point,
short_channel_id,
],
);
builder.append(Operation::RecvChannelReady, &[]);
}
}
Loading