From e4cf3724492f7feea94c67610534c502a6d313e3 Mon Sep 17 00:00:00 2001 From: Matt Morehouse Date: Fri, 15 May 2026 14:47:39 -0500 Subject: [PATCH] smite-ir: implement BuildNodeAnnouncement operation Enables programs that build signed node_announcement messages and send them to the target. The rgb_color and alias fields are fixed-length literal params instead of Bytes variables so that they are always the correct size. This means we lose the ability to cross-pollinate these two fields with other messages, but since these fields serve a purely cosmetic purpose the tradeoff is good. --- smite-ir/src/mutators/operation_param.rs | 10 ++ smite-ir/src/operation.rs | 39 +++++++- smite-ir/src/tests.rs | 88 +++++++++++++++++ smite-scenarios/src/executor.rs | 116 ++++++++++++++++++++++- 4 files changed, 249 insertions(+), 4 deletions(-) diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index 6ce1306..504947c 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -78,6 +78,16 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { true } Operation::ExtractAcceptChannel(field) => mutate_extract_field(field, rng), + Operation::BuildNodeAnnouncement { rgb_color, alias } => { + // Randomly mutate rgb_color or alias bytes in place; never change + // their lengths (array types prevent it). + if rng.random() { + mutate_fixed_bytes(rgb_color, rng); + } else { + mutate_fixed_bytes(alias, rng); + } + true + } // Non-mutable variants. Reaching here means `is_param_mutable` and this // match have drifted out of sync. diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 7e56ed9..ed5984a 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -92,6 +92,22 @@ pub enum Operation { /// 18: `upfront_shutdown_script` (`Bytes`, empty = omit TLV) /// 19: `channel_type` (`Features`, empty = omit TLV) BuildOpenChannel, + /// Build a `node_announcement` message (BOLT 7, type 257). + /// + /// `rgb_color` and `alias` are op-level params (not variable inputs) so the + /// mutator can flip bits inside them but cannot change their lengths. + /// + /// Inputs (4): + /// 0: `node_sk` (`PrivateKey`) -- derives `node_id` and signs the body + /// 1: `features` (`Features`) + /// 2: `timestamp` (`Timestamp`) + /// 3: `addresses` (`Bytes`, raw u16-length-prefixed address descriptors) + BuildNodeAnnouncement { + /// 3-byte RGB color for UI display. + rgb_color: [u8; 3], + /// 32-byte node alias, zero-padded. + alias: [u8; 32], + }, // -- Act: side effects against the target -- /// Send an encoded message over the connection. @@ -533,6 +549,12 @@ impl fmt::Display for Operation { Self::DerivePoint => write!(f, "DerivePoint"), Self::ExtractAcceptChannel(field) => write!(f, "Extract{field}"), Self::BuildOpenChannel => write!(f, "BuildOpenChannel"), + Self::BuildNodeAnnouncement { rgb_color, alias } => write!( + f, + "BuildNodeAnnouncement{{rgb={}, alias={}}}", + format_hex(rgb_color), + format_hex(alias), + ), Self::SendMessage => write!(f, "SendMessage"), } } @@ -557,7 +579,9 @@ impl Operation { Self::LoadTargetPubkeyFromContext | Self::DerivePoint => Some(VariableType::Point), Self::LoadChainHashFromContext => Some(VariableType::ChainHash), Self::ExtractAcceptChannel(field) => Some(field.output_type()), - Self::BuildOpenChannel => Some(VariableType::Message), + Self::BuildOpenChannel | Self::BuildNodeAnnouncement { .. } => { + Some(VariableType::Message) + } Self::SendMessage | Self::MineBlocks(_) => None, Self::RecvAcceptChannel => Some(VariableType::AcceptChannel), } @@ -610,6 +634,13 @@ impl Operation { VariableType::Bytes, // upfront_shutdown_script VariableType::Features, // channel_type ], + + Self::BuildNodeAnnouncement { .. } => vec![ + VariableType::PrivateKey, // node_sk + VariableType::Features, // features + VariableType::Timestamp, // timestamp + VariableType::Bytes, // addresses + ], } } @@ -638,6 +669,7 @@ impl Operation { | Self::DerivePoint | Self::ExtractAcceptChannel(_) | Self::BuildOpenChannel + | Self::BuildNodeAnnouncement { .. } | Self::SendMessage | Self::MineBlocks(_) => vec![], @@ -665,8 +697,9 @@ impl Operation { | Self::LoadChannelId(_) | Self::LoadShutdownScript(_) | Self::LoadChannelType(_) - | Self::MineBlocks(_) - | Self::ExtractAcceptChannel(_) => true, + | Self::ExtractAcceptChannel(_) + | Self::BuildNodeAnnouncement { .. } + | Self::MineBlocks(_) => true, Self::LoadTargetPubkeyFromContext | Self::LoadChainHashFromContext diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index 9e907b3..ba0530a 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -202,6 +202,60 @@ fn display_open_channel_program() { } } +#[test] +fn display_build_node_announcement_program() { + let mut alias = [0u8; 32]; + alias[..5].copy_from_slice(b"smite"); + let instructions = vec![ + Instruction { + operation: Operation::LoadPrivateKey(key(1)), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeatures(vec![]), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadTimestamp(1_700_000_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadBytes(vec![]), + inputs: vec![], + }, + Instruction { + operation: Operation::BuildNodeAnnouncement { + rgb_color: [0x11, 0x22, 0x33], + alias, + }, + inputs: vec![0, 1, 2, 3], + }, + Instruction { + operation: Operation::SendMessage, + inputs: vec![4], + }, + ]; + + let program = Program { instructions }; + let text = program.to_string(); + let lines: Vec<&str> = text.lines().collect(); + + let z31 = "00".repeat(31); + let alias_hex = format!("0x736d697465{}", "00".repeat(27)); + let expected: Vec = vec![ + format!("v0 = LoadPrivateKey(0x{z31}01)"), + "v1 = LoadFeatures()".into(), + "v2 = LoadTimestamp(1700000000)".into(), + "v3 = LoadBytes()".into(), + format!("v4 = BuildNodeAnnouncement{{rgb=0x112233, alias={alias_hex}}}(v0, v1, v2, v3)"), + "SendMessage(v4)".into(), + ]; + assert_eq!(lines.len(), expected.len(), "line count mismatch"); + for (i, (got, want)) in lines.iter().zip(expected.iter()).enumerate() { + assert_eq!(got, want, "line {i} mismatch"); + } +} + #[test] fn postcard_roundtrip() { let program = Program { @@ -1043,6 +1097,40 @@ fn param_mutator_caps_byte_length() { } } +#[test] +fn param_mutator_modifies_node_announcement_params() { + let original_rgb = [0x11, 0x22, 0x33]; + let original_alias = [0xaa; 32]; + let mut builder = ProgramBuilder::new(); + let sk = builder.append(Operation::LoadPrivateKey(key(1)), &[]); + let features = builder.append(Operation::LoadFeatures(vec![]), &[]); + let timestamp = builder.append(Operation::LoadTimestamp(0), &[]); + let addresses = builder.append(Operation::LoadBytes(vec![]), &[]); + let build_idx = builder.append( + Operation::BuildNodeAnnouncement { + rgb_color: original_rgb, + alias: original_alias, + }, + &[sk, features, timestamp, addresses], + ); + let mut program = builder.build(); + + let mutator = OperationParamMutator; + let mut rng = SmallRng::seed_from_u64(0); + + for _ in 0..100 { + mutator.mutate(&mut program, &mut rng); + } + + let Operation::BuildNodeAnnouncement { rgb_color, alias } = + &program.instructions[build_idx].operation + else { + panic!("operation type changed"); + }; + assert_ne!(*rgb_color, original_rgb, "rgb_color never mutated"); + assert_ne!(*alias, original_alias, "alias never mutated"); +} + #[test] fn param_mutator_preserves_extract_field_type() { // One ExtractAcceptChannel instruction per field variant. diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 83f98a6..c8efbcc 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -3,10 +3,12 @@ //! Executes an IR program against a target node over an established connection, //! producing side effects (sending/receiving messages). +use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use smite::bitcoin::BitcoinCli; use smite::bolt::{ - AcceptChannel, ChannelId, Message, OpenChannel, OpenChannelTlvs, Pong, msg_type, + AcceptChannel, ChannelId, Message, NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, + msg_type, }; use smite::noise::{ConnectionError, NoiseConnection}; use smite_ir::operation::AcceptChannelField; @@ -169,6 +171,12 @@ pub fn execute( Some(Variable::Message(encoded)) } + Operation::BuildNodeAnnouncement { rgb_color, alias } => { + let na = build_node_announcement(&variables, &instr.inputs, *rgb_color, *alias)?; + let encoded = Message::NodeAnnouncement(na).encode(); + Some(Variable::Message(encoded)) + } + // -- Act operations -- Operation::SendMessage => { let bytes = resolve_message(&variables, instr.inputs[0])?; @@ -240,6 +248,14 @@ fn resolve_feerate(variables: &[Option], index: usize) -> Result], index: usize) -> Result { + let var = resolve(variables, index)?; + match var { + Variable::Timestamp(v) => Ok(*v), + _ => Err(type_err(VariableType::Timestamp, var)), + } +} + fn resolve_u16(variables: &[Option], index: usize) -> Result { let var = resolve(variables, index)?; match var { @@ -369,6 +385,36 @@ fn build_open_channel( }) } +/// Builds a signed `NodeAnnouncement` from 4 input variables. +fn build_node_announcement( + variables: &[Option], + inputs: &[usize], + rgb_color: [u8; 3], + alias: [u8; 32], +) -> Result { + let sk_bytes = resolve_private_key(variables, inputs[0])?; + let features = resolve_features(variables, inputs[1])?.to_vec(); + let timestamp = resolve_timestamp(variables, inputs[2])?; + let addresses = resolve_bytes(variables, inputs[3])?.to_vec(); + + let sk = SecretKey::from_slice(&sk_bytes).map_err(|_| ExecuteError::InvalidPrivateKey)?; + let secp = Secp256k1::new(); + let node_id = PublicKey::from_secret_key(&secp, &sk); + + let mut na = NodeAnnouncement { + signature: Signature::from_compact(&[0u8; 64]).expect("zero bytes parse as a signature"), + features, + timestamp, + node_id, + rgb_color, + alias, + addresses, + extra: Vec::new(), + }; + na.sign(&sk); + Ok(na) +} + /// Receives the next message of interest, auto-responding to pings and silently /// skipping unknown odd-type messages. #[allow(clippy::similar_names)] // ping and pong are canonical names @@ -683,6 +729,74 @@ mod tests { assert!(oc.tlvs.channel_type.is_none()); } + #[test] + fn execute_build_node_announcement() { + let mut sk_bytes = [0u8; 32]; + sk_bytes[31] = 0x42; + let rgb_color = [0x11, 0x22, 0x33]; + let mut alias = [0u8; 32]; + alias[..5].copy_from_slice(b"smite"); + let addresses = vec![0xaa, 0xbb, 0xcc]; + + let instrs = vec![ + Instruction { + operation: Operation::LoadPrivateKey(sk_bytes), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeatures(vec![0x01, 0x02]), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadTimestamp(1_700_000_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadBytes(addresses.clone()), + inputs: vec![], + }, + Instruction { + operation: Operation::BuildNodeAnnouncement { rgb_color, alias }, + inputs: vec![0, 1, 2, 3], + }, + Instruction { + operation: Operation::SendMessage, + inputs: vec![4], + }, + ]; + + let program = Program { + instructions: instrs, + }; + let mut conn = MockConnection::new(); + execute( + &program, + &sample_context(), + &mut conn, + &mut MockBitcoinCli::default(), + std::time::Instant::now(), + ) + .unwrap(); + + assert_eq!(conn.sent.len(), 1); + let na = match Message::decode(&conn.sent[0]).expect("valid message") { + Message::NodeAnnouncement(na) => na, + other => panic!("expected NodeAnnouncement, got type {}", other.msg_type()), + }; + + let secp = Secp256k1::new(); + let expected_node_id = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&sk_bytes).unwrap()); + assert_eq!(na.node_id, expected_node_id); + assert_eq!(na.features, vec![0x01, 0x02]); + assert_eq!(na.timestamp, 1_700_000_000); + assert_eq!(na.rgb_color, rgb_color); + assert_eq!(na.alias, alias); + assert_eq!(na.addresses, addresses); + assert!(na.extra.is_empty()); + assert!(na.verify()); + } + #[test] fn execute_build_open_channel_with_tlvs() { let mut instrs = open_channel_instructions();