From 3cb041957151ffeb88e51914a27b3b13264f14e2 Mon Sep 17 00:00:00 2001 From: Devansh Vashisht Date: Wed, 20 May 2026 00:52:49 +0530 Subject: [PATCH] smite: implement BOLT 7 short_channel_id and channel_update codecs Adds wire codecs for the BOLT 7 short_channel_id packed type and the channel_update gossip message (type 258), including sign/verify helpers backed by secp256k1 ECDSA, and wires ChannelUpdate into the central Message enum so it can be dispatched off the wire. short_channel_id: * Packed u64 representation per BOLT 7 (3 bytes block || 3 bytes tx || 2 bytes output). new() panics on out-of-range components (24-bit block / tx index), which would be a programmer error in every realistic caller. from_u64 is the infallible inverse of as_u64. channel_update: * Preserves any trailing unknown bytes via a pub extra: Vec field so that re-encoding is byte-identical and the signature still verifies. Per BOLT 7 the signature covers everything after the leading signature field, including unknown fields following fee_proportional_millionths. * sign(&mut self, sk) writes the body via write_body (which includes extra) and stores the resulting ECDSA signature. * verify(&self, pk) -> bool recomputes the digest and returns whether the stored signature matches the supplied pubkey. Unlike node_announcement, channel_update does not embed node_id on the wire, so the receiver must look the key up from the previously-seen channel_announcement for short_channel_id and pass it explicitly. * The decoder is intentionally lenient about flag bits (it preserves message_flags / channel_flags verbatim) and leaves policy decisions such as enforcement of must_be_one to the caller -- this matches how we want to fuzz divergent BOLT 7 implementations. --- smite/src/bolt.rs | 49 ++++- smite/src/bolt/channel_update.rs | 318 +++++++++++++++++++++++++++++++ smite/src/bolt/types.rs | 139 ++++++++++++++ smite/src/bolt/wire.rs | 52 ++++- 4 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 smite/src/bolt/channel_update.rs diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index 5e75286..58fc50a 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -7,6 +7,7 @@ mod accept_channel; mod accept_channel2; mod attribution_data; mod channel_ready; +mod channel_update; mod commitment; mod error; mod funding; @@ -38,6 +39,7 @@ pub use accept_channel::{AcceptChannel, AcceptChannelTlvs}; pub use accept_channel2::{AcceptChannel2, AcceptChannel2Tlvs}; pub use attribution_data::{AttributionData, TruncatedHmac}; pub use channel_ready::{ChannelReady, ChannelReadyTlvs}; +pub use channel_update::ChannelUpdate; pub use commitment::{ ChannelConfig, ChannelPartyConfig, CommitmentError, CommitmentPartyState, CommitmentState, HolderIdentity, Side, @@ -63,7 +65,7 @@ pub use tx_remove_input::TxRemoveInput; pub use tx_remove_output::TxRemoveOutput; pub use types::{ BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, MAX_MESSAGE_SIZE, PUBLIC_KEY_SIZE, - SHA256_HASH_SIZE, TXID_SIZE, + SHA256_HASH_SIZE, SHORT_CHANNEL_ID_SIZE, ShortChannelId, TXID_SIZE, }; pub use update_fail_htlc::{UpdateFailHtlc, UpdateFailHtlcTlvs}; pub use update_fail_malformed_htlc::UpdateFailMalformedHtlc; @@ -156,6 +158,8 @@ pub mod msg_type { pub const UPDATE_FAIL_HTLC: u16 = 131; /// `update_fail_malformed_htlc` message (BOLT 2). pub const UPDATE_FAIL_MALFORMED_HTLC: u16 = 135; + /// `channel_update` message (BOLT 7). + pub const CHANNEL_UPDATE: u16 = 258; /// Gossip timestamp filter message (BOLT 7). pub const GOSSIP_TIMESTAMP_FILTER: u16 = 265; } @@ -210,6 +214,8 @@ pub enum Message { UpdateFailHtlc(UpdateFailHtlc), /// `update_fail_malformed_htlc` message (type 135). UpdateFailMalformedHtlc(UpdateFailMalformedHtlc), + /// `channel_update` message (type 258). + ChannelUpdate(ChannelUpdate), /// Gossip timestamp filter message (type 265). GossipTimestampFilter(GossipTimestampFilter), /// Unknown message type. @@ -252,6 +258,7 @@ impl Message { Self::UpdateFulfillHtlc(_) => msg_type::UPDATE_FULFILL_HTLC, Self::UpdateFailHtlc(_) => msg_type::UPDATE_FAIL_HTLC, Self::UpdateFailMalformedHtlc(_) => msg_type::UPDATE_FAIL_MALFORMED_HTLC, + Self::ChannelUpdate(_) => msg_type::CHANNEL_UPDATE, Self::GossipTimestampFilter(_) => msg_type::GOSSIP_TIMESTAMP_FILTER, Self::Unknown { msg_type, .. } => *msg_type, } @@ -286,6 +293,7 @@ impl Message { Self::UpdateFulfillHtlc(m) => out.extend(m.encode()), Self::UpdateFailHtlc(m) => out.extend(m.encode()), Self::UpdateFailMalformedHtlc(m) => out.extend(m.encode()), + Self::ChannelUpdate(m) => out.extend(m.encode()), Self::GossipTimestampFilter(m) => out.extend(m.encode()), Self::Unknown { payload, .. } => out.extend(payload), } @@ -331,6 +339,7 @@ impl Message { msg_type::UPDATE_FAIL_MALFORMED_HTLC => Ok(Self::UpdateFailMalformedHtlc( UpdateFailMalformedHtlc::decode(cursor)?, )), + msg_type::CHANNEL_UPDATE => Ok(Self::ChannelUpdate(ChannelUpdate::decode(cursor)?)), msg_type::GOSSIP_TIMESTAMP_FILTER => Ok(Self::GossipTimestampFilter( GossipTimestampFilter::decode(cursor)?, )), @@ -777,6 +786,40 @@ mod tests { assert_eq!(decoded, Message::UpdateFailMalformedHtlc(msg)); } + fn sample_channel_update() -> ChannelUpdate { + let sk = SecretKey::from_slice(&[0x22; 32]).expect("valid secret"); + + let mut cu = ChannelUpdate { + // Placeholder; overwritten by `sign` below. + signature: bitcoin::secp256k1::ecdsa::Signature::from_compact( + &[0; COMPACT_SIGNATURE_SIZE], + ) + .expect("zero signature parses"), + chain_hash: [0x6f; CHAIN_HASH_SIZE], + short_channel_id: ShortChannelId::from_u64(0x0000_0824_0000_0035), + timestamp: 1_715_000_000, + message_flags: 1, // must_be_one + channel_flags: 0, + cltv_expiry_delta: 144, + htlc_minimum_msat: 1_000, + fee_base_msat: 1_000, + fee_proportional_millionths: 100, + htlc_maximum_msat: 99_000_000, + extra: Vec::new(), + }; + cu.sign(&sk); + cu + } + + #[test] + fn message_channel_update_roundtrip() { + let cu = sample_channel_update(); + let msg = Message::ChannelUpdate(cu.clone()); + let encoded = msg.encode(); + let decoded = Message::decode(&encoded).unwrap(); + assert_eq!(decoded, Message::ChannelUpdate(cu)); + } + #[test] fn message_gossip_timestamp_filter_roundtrip() { let chain_hash = [0x6f; 32]; @@ -923,6 +966,10 @@ mod tests { .msg_type(), msg_type::UPDATE_FAIL_MALFORMED_HTLC ); + assert_eq!( + Message::ChannelUpdate(sample_channel_update()).msg_type(), + msg_type::CHANNEL_UPDATE + ); assert_eq!( Message::GossipTimestampFilter(GossipTimestampFilter::no_gossip([0u8; 32])).msg_type(), msg_type::GOSSIP_TIMESTAMP_FILTER diff --git a/smite/src/bolt/channel_update.rs b/smite/src/bolt/channel_update.rs new file mode 100644 index 0000000..7310709 --- /dev/null +++ b/smite/src/bolt/channel_update.rs @@ -0,0 +1,318 @@ +//! BOLT 7 `channel_update` message. + +use bitcoin::hashes::{Hash, sha256d}; +use bitcoin::secp256k1::ecdsa::Signature; +use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; + +use super::BoltError; +use super::types::{CHAIN_HASH_SIZE, ShortChannelId}; +use super::wire::WireFormat; + +/// BOLT 7 `channel_update` message (type 258). +/// +/// Each side of a channel independently announces its forwarding parameters +/// using `channel_update`. The message is signed by the originating node. +/// +/// Wire layout (per [BOLT 7]): +/// +/// ```text +/// [signature:64] +/// [chain_hash:32] +/// [short_channel_id:8] +/// [u32:timestamp] +/// [byte:message_flags] +/// [byte:channel_flags] +/// [u16:cltv_expiry_delta] +/// [u64:htlc_minimum_msat] +/// [u32:fee_base_msat] +/// [u32:fee_proportional_millionths] +/// [u64:htlc_maximum_msat] +/// ``` +/// +/// [BOLT 7]: https://github.com/lightning/bolts/blob/master/07-routing-gossip.md#the-channel_update-message +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelUpdate { + /// Signature of `node_id` over the double-SHA256 of the message body + /// following this signature field (see [`Self::write_body`]). + pub signature: Signature, + /// 32-byte hash that uniquely identifies the chain the channel was opened on. + pub chain_hash: [u8; CHAIN_HASH_SIZE], + /// Reference to the funding transaction. + pub short_channel_id: ShortChannelId, + /// Update timestamp; intended to be a UNIX timestamp. + pub timestamp: u32, + /// `message_flags` bitfield (`must_be_one`, `dont_forward`). + pub message_flags: u8, + /// `channel_flags` bitfield (`direction`, `disable`). + pub channel_flags: u8, + /// Number of blocks to subtract from an incoming HTLC's `cltv_expiry`. + pub cltv_expiry_delta: u16, + /// Minimum HTLC value (millisatoshi) the channel peer will accept. + pub htlc_minimum_msat: u64, + /// Base fee charged per HTLC (millisatoshi). + pub fee_base_msat: u32, + /// Proportional fee per transferred satoshi (millionths). + pub fee_proportional_millionths: u32, + /// Maximum HTLC value (millisatoshi) the channel peer will route. + pub htlc_maximum_msat: u64, + /// Any trailing bytes that followed the known fields on the wire. + /// + /// Per BOLT 7, the signature covers "the entire message following the + /// signature field (including unknown fields following + /// `fee_proportional_millionths`)", so we preserve them verbatim to keep + /// the signature verifiable. + pub extra: Vec, +} + +impl ChannelUpdate { + /// Encodes to wire format (without message type prefix). + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + self.signature.write(&mut out); + self.write_body(&mut out); + out + } + + /// Decodes from wire format (without message type prefix). + /// + /// Any bytes that follow the last known field are captured into + /// [`Self::extra`] so that re-encoding preserves them and the signature + /// remains verifiable. + /// + /// # Errors + /// + /// Returns `Truncated` if the payload is too short for any fixed field, or + /// `InvalidSignature` if the signature bytes do not form a valid compact + /// ECDSA signature. + pub fn decode(payload: &[u8]) -> Result { + let mut cursor = payload; + + let signature = WireFormat::read(&mut cursor)?; + let chain_hash = WireFormat::read(&mut cursor)?; + let short_channel_id = WireFormat::read(&mut cursor)?; + let timestamp = WireFormat::read(&mut cursor)?; + let message_flags = WireFormat::read(&mut cursor)?; + let channel_flags = WireFormat::read(&mut cursor)?; + let cltv_expiry_delta = WireFormat::read(&mut cursor)?; + let htlc_minimum_msat = WireFormat::read(&mut cursor)?; + let fee_base_msat = WireFormat::read(&mut cursor)?; + let fee_proportional_millionths = WireFormat::read(&mut cursor)?; + let htlc_maximum_msat = WireFormat::read(&mut cursor)?; + let extra = cursor.to_vec(); + + Ok(Self { + signature, + chain_hash, + short_channel_id, + timestamp, + message_flags, + channel_flags, + cltv_expiry_delta, + htlc_minimum_msat, + fee_base_msat, + fee_proportional_millionths, + htlc_maximum_msat, + extra, + }) + } + + /// Signs the message body with `sk`, storing the resulting signature in + /// `self.signature`. + /// + /// The signature covers the double-SHA256 of [`Self::write_body`], i.e. + /// everything after the leading `signature` field (including + /// [`Self::extra`]), per BOLT 7. + pub fn sign(&mut self, sk: &SecretKey) { + let secp = Secp256k1::new(); + let mut body = Vec::new(); + self.write_body(&mut body); + let digest = secp256k1::Message::from_digest(sha256d::Hash::hash(&body).to_byte_array()); + self.signature = secp.sign_ecdsa(&digest, sk); + } + + /// Verifies `self.signature` against the expected node `PublicKey`. + /// + /// Unlike `node_announcement`, `channel_update` does not embed the signing + /// node's public key on the wire: the receiver looks it up from the + /// previously-seen `channel_announcement` for `short_channel_id`. Callers + /// must therefore pass the expected key explicitly. + #[must_use] + pub fn verify(&self, pk: &PublicKey) -> bool { + let secp = Secp256k1::new(); + let mut body = Vec::new(); + self.write_body(&mut body); + let digest = secp256k1::Message::from_digest(sha256d::Hash::hash(&body).to_byte_array()); + secp.verify_ecdsa(&digest, &self.signature, pk).is_ok() + } + + fn write_body(&self, out: &mut Vec) { + self.chain_hash.write(out); + self.short_channel_id.write(out); + self.timestamp.write(out); + self.message_flags.write(out); + self.channel_flags.write(out); + self.cltv_expiry_delta.write(out); + self.htlc_minimum_msat.write(out); + self.fee_base_msat.write(out); + self.fee_proportional_millionths.write(out); + self.htlc_maximum_msat.write(out); + out.extend_from_slice(&self.extra); + } +} + +#[cfg(test)] +mod tests { + use super::super::COMPACT_SIGNATURE_SIZE; + use super::*; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + + /// Bitcoin mainnet genesis block hash. + const BITCOIN_MAINNET: [u8; CHAIN_HASH_SIZE] = [ + 0x6f, 0xe2, 0x8c, 0x0a, 0xb6, 0xf1, 0xb3, 0x72, 0xc1, 0xa6, 0xa2, 0x46, 0xae, 0x63, 0xf7, + 0x4f, 0x93, 0x1e, 0x83, 0x65, 0xe1, 0x5a, 0x08, 0x9c, 0x68, 0xd6, 0x19, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]; + + /// Secret key used to sign sample messages in tests. + const SAMPLE_SK_BYTES: [u8; 32] = [0x42; 32]; + + /// Returns `(signed_message, signing_pubkey)` for the given trailing bytes. + fn sample(extra: &[u8]) -> (ChannelUpdate, PublicKey) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&SAMPLE_SK_BYTES).expect("valid secret"); + let pk = sk.public_key(&secp); + + let mut cu = ChannelUpdate { + // Placeholder; overwritten by `sign` below. + signature: Signature::from_compact(&[0; COMPACT_SIGNATURE_SIZE]) + .expect("zero signature parses"), + chain_hash: BITCOIN_MAINNET, + short_channel_id: ShortChannelId::new(539_268, 845, 1), + timestamp: 1_715_000_000, + message_flags: 1, // must_be_one + channel_flags: 0, + cltv_expiry_delta: 144, + htlc_minimum_msat: 1_000, + fee_base_msat: 1_000, + fee_proportional_millionths: 100, + htlc_maximum_msat: 99_000_000, + extra: extra.to_vec(), + }; + cu.sign(&sk); + (cu, pk) + } + + #[test] + fn roundtrip() { + let (original, _) = sample(&[]); + let encoded = original.encode(); + let decoded = ChannelUpdate::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn roundtrip_with_extra_bytes() { + let (original, _) = sample(&[0xde, 0xad, 0xbe, 0xef]); + let encoded = original.encode(); + let decoded = ChannelUpdate::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + assert_eq!(decoded.extra, vec![0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn decode_captures_trailing_bytes() { + let (msg, _) = sample(&[]); + let mut encoded = msg.encode(); + encoded.extend_from_slice(&[0x01, 0x02, 0x03]); + let decoded = ChannelUpdate::decode(&encoded).unwrap(); + assert_eq!(decoded.extra, vec![0x01, 0x02, 0x03]); + // Re-encoding preserves the trailing bytes verbatim. + assert_eq!(decoded.encode(), encoded); + } + + #[test] + fn verify_succeeds_after_sign() { + let (msg, pk) = sample(&[]); + assert!(msg.verify(&pk)); + } + + #[test] + fn verify_fails_on_tampered_body() { + let (mut msg, pk) = sample(&[]); + msg.timestamp = msg.timestamp.wrapping_add(1); + assert!(!msg.verify(&pk)); + } + + #[test] + fn verify_fails_on_wrong_pubkey() { + let secp = Secp256k1::new(); + let other = SecretKey::from_slice(&[0x43; 32]) + .unwrap() + .public_key(&secp); + let (msg, _) = sample(&[]); + assert!(!msg.verify(&other)); + } + + #[test] + fn verify_covers_extra() { + // The signature must commit to trailing unknown bytes, per BOLT 7. + let (mut msg, pk) = sample(&[0xde, 0xad, 0xbe, 0xef]); + assert!(msg.verify(&pk)); + msg.extra[0] ^= 0x01; + assert!(!msg.verify(&pk)); + } + + #[test] + fn verify_roundtrips_through_encode_decode() { + let (msg, pk) = sample(&[]); + let decoded = ChannelUpdate::decode(&msg.encode()).unwrap(); + assert!(decoded.verify(&pk)); + } + + #[test] + fn decode_truncated_signature() { + assert_eq!( + ChannelUpdate::decode(&[0u8; 30]), + Err(BoltError::Truncated { + expected: COMPACT_SIGNATURE_SIZE, + actual: 30 + }) + ); + } + + #[test] + fn decode_truncated_body() { + let (msg, _) = sample(&[]); + let encoded = msg.encode(); + // Drop the last byte of htlc_maximum_msat. + let truncated = &encoded[..encoded.len() - 1]; + assert!(matches!( + ChannelUpdate::decode(truncated), + Err(BoltError::Truncated { .. }) + )); + } + + #[test] + fn decode_invalid_signature() { + let (msg, _) = sample(&[]); + let mut encoded = msg.encode(); + encoded[..COMPACT_SIGNATURE_SIZE].copy_from_slice(&[0xff; COMPACT_SIGNATURE_SIZE]); + assert!(matches!( + ChannelUpdate::decode(&encoded), + Err(BoltError::InvalidSignature(_)) + )); + } + + #[test] + fn decode_preserves_unknown_flag_bits() { + // The codec is intentionally lenient: it preserves all flag bits and + // leaves policy decisions (e.g. `must_be_one` enforcement) to the caller. + let (mut msg, _) = sample(&[]); + msg.message_flags = 0xff; + msg.channel_flags = 0xff; + let decoded = ChannelUpdate::decode(&msg.encode()).unwrap(); + assert_eq!(decoded.message_flags, 0xff); + assert_eq!(decoded.channel_flags, 0xff); + } +} diff --git a/smite/src/bolt/types.rs b/smite/src/bolt/types.rs index 4f5841e..b489a38 100644 --- a/smite/src/bolt/types.rs +++ b/smite/src/bolt/types.rs @@ -1,5 +1,7 @@ //! Fundamental types for BOLT message encoding. +use std::fmt; + /// Maximum Lightning message size (2-byte length prefix limit). pub const MAX_MESSAGE_SIZE: usize = 65535; @@ -21,6 +23,14 @@ pub const COMPACT_SIGNATURE_SIZE: usize = 64; /// Size of a compressed secp256k1 public key. pub const PUBLIC_KEY_SIZE: usize = 33; +/// Size of an encoded `short_channel_id` in bytes. +pub const SHORT_CHANNEL_ID_SIZE: usize = 8; + +/// Maximum representable block height (3 bytes, big-endian). +const MAX_BLOCK: u32 = 0x00ff_ffff; +/// Maximum representable transaction index (3 bytes, big-endian). +const MAX_TX_INDEX: u32 = 0x00ff_ffff; + /// A 32-byte channel identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct ChannelId(pub [u8; CHANNEL_ID_SIZE]); @@ -43,6 +53,82 @@ impl ChannelId { } } +/// A BOLT 7 `short_channel_id`. +/// +/// Per [BOLT 7]: +/// * the most significant 3 bytes encode the block height, +/// * the next 3 bytes encode the transaction index within the block, +/// * the least significant 2 bytes encode the output index of the funding +/// transaction. +/// +/// Internally stored as the packed 8-byte big-endian representation: +/// `(block << 40) | (tx_index << 16) | output_index`. +/// +/// [BOLT 7]: https://github.com/lightning/bolts/blob/master/07-routing-gossip.md#definition-of-short_channel_id +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct ShortChannelId(u64); + +impl ShortChannelId { + /// Constructs a `short_channel_id` from its components. + /// + /// # Panics + /// + /// Panics if `block` or `tx_index` exceed their 24-bit field. + #[must_use] + pub const fn new(block: u32, tx_index: u32, output_index: u16) -> Self { + assert!(block <= MAX_BLOCK, "block is out-of-range"); + assert!(tx_index <= MAX_TX_INDEX, "tx_index is out-of-range"); + let packed = ((block as u64) << 40) | ((tx_index as u64) << 16) | (output_index as u64); + Self(packed) + } + + /// Constructs a `short_channel_id` from its raw packed form. + #[must_use] + pub const fn from_u64(packed: u64) -> Self { + Self(packed) + } + + /// Returns the packed `u64` representation. + #[must_use] + pub const fn as_u64(self) -> u64 { + self.0 + } + + /// Returns the block height component. + #[must_use] + #[allow(clippy::cast_possible_truncation)] // value is masked to 24 bits + pub const fn block(self) -> u32 { + ((self.0 >> 40) & 0x00ff_ffff) as u32 + } + + /// Returns the transaction index component. + #[must_use] + #[allow(clippy::cast_possible_truncation)] // value is masked to 24 bits + pub const fn tx_index(self) -> u32 { + ((self.0 >> 16) & 0x00ff_ffff) as u32 + } + + /// Returns the output index component. + #[must_use] + #[allow(clippy::cast_possible_truncation)] // low 16 bits + pub const fn output_index(self) -> u16 { + (self.0 & 0xffff) as u16 + } +} + +impl fmt::Display for ShortChannelId { + /// Formats as the BOLT 7 human-readable form `xx`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}x{}x{}", + self.block(), + self.tx_index(), + self.output_index() + ) + } +} + /// A variable-length unsigned integer similar to Bitcoin's `CompactSize` /// encoding, but big-endian. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -112,4 +198,57 @@ mod tests { fn channel_id_default_is_all() { assert_eq!(ChannelId::default(), ChannelId::ALL); } + + #[test] + fn short_channel_id_new_roundtrips_components() { + let scid = ShortChannelId::new(539_268, 845, 1); + assert_eq!(scid.block(), 539_268); + assert_eq!(scid.tx_index(), 845); + assert_eq!(scid.output_index(), 1); + } + + #[test] + fn short_channel_id_new_accepts_max_components() { + let scid = ShortChannelId::new(MAX_BLOCK, MAX_TX_INDEX, u16::MAX); + assert_eq!(scid.block(), MAX_BLOCK); + assert_eq!(scid.tx_index(), MAX_TX_INDEX); + assert_eq!(scid.output_index(), u16::MAX); + } + + #[test] + #[should_panic(expected = "block is out-of-range")] + fn short_channel_id_new_panics_on_block_overflow() { + let _ = ShortChannelId::new(MAX_BLOCK + 1, 0, 0); + } + + #[test] + #[should_panic(expected = "tx_index is out-of-range")] + fn short_channel_id_new_panics_on_tx_index_overflow() { + let _ = ShortChannelId::new(0, MAX_TX_INDEX + 1, 0); + } + + #[test] + fn short_channel_id_display_uses_bolt7_format() { + let scid = ShortChannelId::new(539_268, 845, 1); + assert_eq!(format!("{scid}"), "539268x845x1"); + } + + #[test] + fn short_channel_id_from_u64_inverse_of_as_u64() { + for packed in [0u64, 1, 0x1234_5678_9abc_def0, u64::MAX] { + let scid = ShortChannelId::from_u64(packed); + assert_eq!(scid.as_u64(), packed); + } + } + + #[test] + fn short_channel_id_ord_matches_packed_u64() { + let a = ShortChannelId::new(100, 0, 0); + let b = ShortChannelId::new(100, 0, 1); + let c = ShortChannelId::new(100, 1, 0); + let d = ShortChannelId::new(101, 0, 0); + assert!(a < b); + assert!(b < c); + assert!(c < d); + } } diff --git a/smite/src/bolt/wire.rs b/smite/src/bolt/wire.rs index 2eb2537..78e6f4f 100644 --- a/smite/src/bolt/wire.rs +++ b/smite/src/bolt/wire.rs @@ -3,7 +3,7 @@ use crate::bolt::BoltError; use crate::bolt::types::{ BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, PUBLIC_KEY_SIZE, SHA256_HASH_SIZE, - TXID_SIZE, + ShortChannelId, TXID_SIZE, }; use bitcoin::Txid; use bitcoin::hashes::{Hash, sha256}; @@ -87,6 +87,22 @@ impl WireFormat for ChannelId { } } +impl WireFormat for ShortChannelId { + /// Reads a packed 8-byte big-endian `short_channel_id`. + /// + /// # Errors + /// + /// Returns `Truncated` if fewer than 8 bytes remain. + fn read(data: &mut &[u8]) -> Result { + let scid = u64::read(data)?; + Ok(Self::from_u64(scid)) + } + + fn write(&self, out: &mut Vec) { + self.as_u64().write(out); + } +} + impl WireFormat for BigSize { /// Reads a `BigSize` value from the byte slice, advancing past the consumed bytes. /// @@ -216,6 +232,7 @@ impl WireFormat for sha256::Hash { #[cfg(test)] mod tests { use super::*; + use crate::bolt::SHORT_CHANNEL_ID_SIZE; use bitcoin::secp256k1::{self, Secp256k1, SecretKey}; #[test] @@ -569,6 +586,39 @@ mod tests { assert_eq!(data.len(), 8); // 8 bytes remaining } + #[test] + fn short_channel_id_write_roundtrip() { + let scid = ShortChannelId::new(539_268, 845, 1); + let mut buf = Vec::new(); + scid.write(&mut buf); + assert_eq!(buf.len(), SHORT_CHANNEL_ID_SIZE); + + let mut cursor: &[u8] = &buf; + let decoded = ShortChannelId::read(&mut cursor).unwrap(); + assert_eq!(decoded, scid); + assert!(cursor.is_empty()); + } + + #[test] + fn short_channel_id_read_truncated() { + let mut empty: &[u8] = &[]; + assert_eq!( + ShortChannelId::read(&mut empty), + Err(BoltError::Truncated { + expected: SHORT_CHANNEL_ID_SIZE, + actual: 0 + }) + ); + } + + #[test] + fn short_channel_id_wire_layout_is_big_endian_packed() { + let scid = ShortChannelId::new(0x00_08_37_a4, 0x00_00_03_4d, 0x0001); + let mut buf = Vec::new(); + scid.write(&mut buf); + assert_eq!(buf, [0x08, 0x37, 0xa4, 0x00, 0x03, 0x4d, 0x00, 0x01]); + } + #[test] fn vec_u8_read_empty_field() { let mut data: &[u8] = &[0x00, 0x00];