From 97ac82822b9c66eccff3139666b4f194c82714ca Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 27 Aug 2026 17:02:50 -0300 Subject: [PATCH 1/6] bolt: add tx_add_output codec --- smite/src/bolt.rs | 34 ++++++ smite/src/bolt/tx_add_output.rs | 203 ++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 smite/src/bolt/tx_add_output.rs diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index 5572124f..c3353d8e 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -30,6 +30,7 @@ mod tlv; mod tx_abort; mod tx_ack_rbf; mod tx_add_input; +mod tx_add_output; mod tx_complete; mod tx_init_rbf; mod tx_remove_input; @@ -69,6 +70,7 @@ pub use tlv::{TlvRecord, TlvStream}; pub use tx_abort::TxAbort; pub use tx_ack_rbf::{TxAckRbf, TxAckRbfTlvs}; pub use tx_add_input::{TxAddInput, TxAddInputTlvs}; +pub use tx_add_output::TxAddOutput; pub use tx_complete::TxComplete; pub use tx_init_rbf::{TxInitRbf, TxInitRbfTlvs}; pub use tx_remove_input::TxRemoveInput; @@ -175,6 +177,8 @@ impl MessageType { pub const ACCEPT_CHANNEL2: MessageType = MessageType(65); /// `tx_add_input` message (BOLT 2). pub const TX_ADD_INPUT: MessageType = MessageType(66); + /// `tx_add_output` message (BOLT 2). + pub const TX_ADD_OUTPUT: MessageType = MessageType(67); /// `tx_remove_input` message (BOLT 2). pub const TX_REMOVE_INPUT: MessageType = MessageType(68); /// `tx_remove_output` message (BOLT 2). @@ -243,6 +247,7 @@ impl MessageType { Self::OPEN_CHANNEL2 => "open_channel2", Self::ACCEPT_CHANNEL2 => "accept_channel2", Self::TX_ADD_INPUT => "tx_add_input", + Self::TX_ADD_OUTPUT => "tx_add_output", Self::TX_REMOVE_INPUT => "tx_remove_input", Self::TX_REMOVE_OUTPUT => "tx_remove_output", Self::TX_COMPLETE => "tx_complete", @@ -307,6 +312,8 @@ pub enum Message { AcceptChannel2(AcceptChannel2), /// `tx_add_input` message (type 66). TxAddInput(TxAddInput), + /// `tx_add_output` message (type 67). + TxAddOutput(TxAddOutput), /// `tx_remove_input` message (type 68). TxRemoveInput(TxRemoveInput), /// `tx_remove_output` message (type 69). @@ -380,6 +387,7 @@ impl Message { Self::OpenChannel2(_) => MessageType::OPEN_CHANNEL2, Self::AcceptChannel2(_) => MessageType::ACCEPT_CHANNEL2, Self::TxAddInput(_) => MessageType::TX_ADD_INPUT, + Self::TxAddOutput(_) => MessageType::TX_ADD_OUTPUT, Self::TxRemoveInput(_) => MessageType::TX_REMOVE_INPUT, Self::TxRemoveOutput(_) => MessageType::TX_REMOVE_OUTPUT, Self::TxComplete(_) => MessageType::TX_COMPLETE, @@ -423,6 +431,7 @@ impl Message { Self::OpenChannel2(m) => out.extend(m.encode()), Self::AcceptChannel2(m) => out.extend(m.encode()), Self::TxAddInput(m) => out.extend(m.encode()), + Self::TxAddOutput(m) => out.extend(m.encode()), Self::TxRemoveInput(m) => out.extend(m.encode()), Self::TxRemoveOutput(m) => out.extend(m.encode()), Self::TxComplete(m) => out.extend(m.encode()), @@ -479,6 +488,7 @@ impl Message { Ok(Self::AcceptChannel2(AcceptChannel2::decode(cursor)?)) } MessageType::TX_ADD_INPUT => Ok(Self::TxAddInput(TxAddInput::decode(cursor)?)), + MessageType::TX_ADD_OUTPUT => Ok(Self::TxAddOutput(TxAddOutput::decode(cursor)?)), MessageType::TX_REMOVE_INPUT => Ok(Self::TxRemoveInput(TxRemoveInput::decode(cursor)?)), MessageType::TX_REMOVE_OUTPUT => { Ok(Self::TxRemoveOutput(TxRemoveOutput::decode(cursor)?)) @@ -900,6 +910,25 @@ mod tests { assert_eq!(decoded, Message::TxAddInput(tx_add_input)); } + /// Valid `TxAddOutput` message for testing. + fn sample_tx_add_output() -> TxAddOutput { + TxAddOutput { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + serial_id: 30, + sats: 49_999_845, + script: vec![0x00, 0x14, 0x1c, 0xa1], + } + } + + #[test] + fn message_tx_add_output_roundtrip() { + let tx_add_output = sample_tx_add_output(); + let msg = Message::TxAddOutput(tx_add_output.clone()); + let encoded = msg.encode(); + let decoded = Message::decode(&encoded).unwrap(); + assert_eq!(decoded, Message::TxAddOutput(tx_add_output)); + } + #[test] fn message_tx_remove_input_roundtrip() { let tx_remove_input = TxRemoveInput { @@ -1288,6 +1317,11 @@ mod tests { "tx_add_input", MessageType::TX_ADD_INPUT, ), + ( + Message::TxAddOutput(sample_tx_add_output()), + "tx_add_output", + MessageType::TX_ADD_OUTPUT, + ), ( Message::TxRemoveInput(TxRemoveInput { channel_id: ChannelId::new([0; CHANNEL_ID_SIZE]), diff --git a/smite/src/bolt/tx_add_output.rs b/smite/src/bolt/tx_add_output.rs new file mode 100644 index 00000000..3b96950b --- /dev/null +++ b/smite/src/bolt/tx_add_output.rs @@ -0,0 +1,203 @@ +//! BOLT 2 `tx_add_output` message. + +use super::BoltError; +use super::types::ChannelId; +use super::wire::WireFormat; + +/// BOLT 2 `tx_add_output` message (type 67). +/// +/// Sent during interactive transaction construction to propose adding an +/// output to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TxAddOutput { + /// The channel ID. + pub channel_id: ChannelId, + /// Serial ID for this output. Must be even if sent by the initiator, + /// odd if sent by the non-initiator (BOLT 2 parity rule). + pub serial_id: u64, + /// The value of this output in satoshis. + pub sats: u64, + /// The `scriptPubKey` for the output. + pub script: Vec, +} + +impl TxAddOutput { + /// Encodes to wire format (without message type prefix). + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + self.channel_id.write(&mut out); + self.serial_id.write(&mut out); + self.sats.write(&mut out); + self.script.write(&mut out); + out + } + + /// Decodes from wire format (without message type prefix). + /// + /// # Errors + /// + /// Returns `Truncated` if the payload is too short for any field. + pub fn decode(payload: &[u8]) -> Result { + let mut cursor = payload; + let channel_id = ChannelId::read(&mut cursor)?; + let serial_id = u64::read(&mut cursor)?; + let sats = u64::read(&mut cursor)?; + let script = Vec::::read(&mut cursor)?; + + Ok(Self { + channel_id, + serial_id, + sats, + script, + }) + } +} + +#[cfg(test)] +mod tests { + use super::super::CHANNEL_ID_SIZE; + use super::*; + + /// P2WPKH `scriptPubKey` from the BOLT 3 dual-funding test vectors. + const P2WPKH_SCRIPT: [u8; 22] = [ + 0x00, 0x14, 0x1c, 0xa1, 0xcc, 0xa8, 0x85, 0x5b, 0xad, 0x6b, 0xc1, 0xea, 0x54, 0x36, 0xed, + 0xd8, 0xcf, 0xf1, 0x0b, 0x7e, 0x44, 0x8b, + ]; + + fn sample_msg() -> TxAddOutput { + TxAddOutput { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + serial_id: 30, + sats: 49_999_845, + script: P2WPKH_SCRIPT.to_vec(), + } + } + + #[test] + fn encode_field_sizes() { + let encoded = sample_msg().encode(); + // channel_id(32) + serial_id(8) + sats(8) + scriptlen(2) + script(22) + assert_eq!(encoded.len(), CHANNEL_ID_SIZE + 8 + 8 + 2 + 22); + assert_eq!( + &encoded[CHANNEL_ID_SIZE + 16..CHANNEL_ID_SIZE + 18], + &[0x00, 0x16] + ); + } + + #[test] + fn roundtrip() { + let original = sample_msg(); + let encoded = original.encode(); + let decoded = TxAddOutput::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn roundtrip_p2wsh_script() { + // 34-byte P2WSH funding output from the BOLT 3 dual-funding vectors. + let mut script = vec![0x00, 0x20]; + script.extend_from_slice(&[0x29; 32]); + let original = TxAddOutput { + serial_id: 44, + sats: 400_000_000, + script, + ..sample_msg() + }; + let encoded = original.encode(); + let decoded = TxAddOutput::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + /// A zero-length script and an out-of-range `sats` are negotiation + /// failures, not decode failures: the codec must round-trip both so that + /// they stay reachable as fuzzing inputs. + #[test] + fn roundtrip_empty_script_and_max_sats() { + let original = TxAddOutput { + sats: u64::MAX, + script: Vec::new(), + ..sample_msg() + }; + let encoded = original.encode(); + let decoded = TxAddOutput::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn decode_ignores_trailing_bytes() { + let original = sample_msg(); + let mut encoded = original.encode(); + encoded.extend_from_slice(&[0xff; 4]); + assert_eq!(TxAddOutput::decode(&encoded).unwrap(), original); + } + + #[test] + fn decode_truncated_channel_id() { + assert_eq!( + TxAddOutput::decode(&[0x00; 20]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 20 + }) + ); + } + + #[test] + fn decode_truncated_serial_id() { + assert_eq!( + TxAddOutput::decode(&[0x00; CHANNEL_ID_SIZE + 4]), + Err(BoltError::Truncated { + expected: 8, + actual: 4 + }) + ); + } + + #[test] + fn decode_truncated_sats() { + assert_eq!( + TxAddOutput::decode(&[0x00; CHANNEL_ID_SIZE + 8 + 3]), + Err(BoltError::Truncated { + expected: 8, + actual: 3 + }) + ); + } + + #[test] + fn decode_truncated_scriptlen() { + assert_eq!( + TxAddOutput::decode(&[0x00; CHANNEL_ID_SIZE + 8 + 8 + 1]), + Err(BoltError::Truncated { + expected: 2, + actual: 1 + }) + ); + } + + #[test] + fn decode_truncated_script() { + let mut payload = vec![0x00u8; CHANNEL_ID_SIZE + 8 + 8]; + payload.extend_from_slice(&[0x00, 0x16]); // declare 22 bytes + payload.extend_from_slice(&[0x00; 5]); // only 5 provided + assert_eq!( + TxAddOutput::decode(&payload), + Err(BoltError::Truncated { + expected: 22, + actual: 5 + }) + ); + } + + #[test] + fn decode_empty() { + assert_eq!( + TxAddOutput::decode(&[]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 0 + }) + ); + } +} From c897613dd0a73dd25cf867dd3efe3bfc7931cff7 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 27 Aug 2026 17:44:51 -0300 Subject: [PATCH 2/6] bolt: add tx_signatures codec --- smite/src/bolt.rs | 34 +++ smite/src/bolt/tx_signatures.rs | 390 ++++++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 smite/src/bolt/tx_signatures.rs diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index c3353d8e..c3e15514 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -35,6 +35,7 @@ mod tx_complete; mod tx_init_rbf; mod tx_remove_input; mod tx_remove_output; +mod tx_signatures; mod types; mod update_add_htlc; mod update_fail_htlc; @@ -75,6 +76,7 @@ pub use tx_complete::TxComplete; pub use tx_init_rbf::{TxInitRbf, TxInitRbfTlvs}; pub use tx_remove_input::TxRemoveInput; pub use tx_remove_output::TxRemoveOutput; +pub use tx_signatures::{TxSignatures, TxSignaturesTlvs}; pub use types::{ BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, MAX_MESSAGE_SIZE, PAYMENT_ONION_PACKET_SIZE, PER_COMMITMENT_SECRET_SIZE, PUBLIC_KEY_SIZE, SHA256_HASH_SIZE, @@ -185,6 +187,8 @@ impl MessageType { pub const TX_REMOVE_OUTPUT: MessageType = MessageType(69); /// `tx_complete` message (BOLT 2). pub const TX_COMPLETE: MessageType = MessageType(70); + /// `tx_signatures` message (BOLT 2). + pub const TX_SIGNATURES: MessageType = MessageType(71); /// `tx_init_rbf` message (BOLT 2). pub const TX_INIT_RBF: MessageType = MessageType(72); /// `tx_ack_rbf` message (BOLT 2). @@ -251,6 +255,7 @@ impl MessageType { Self::TX_REMOVE_INPUT => "tx_remove_input", Self::TX_REMOVE_OUTPUT => "tx_remove_output", Self::TX_COMPLETE => "tx_complete", + Self::TX_SIGNATURES => "tx_signatures", Self::TX_INIT_RBF => "tx_init_rbf", Self::TX_ACK_RBF => "tx_ack_rbf", Self::TX_ABORT => "tx_abort", @@ -320,6 +325,8 @@ pub enum Message { TxRemoveOutput(TxRemoveOutput), /// `tx_complete` message (type 70). TxComplete(TxComplete), + /// `tx_signatures` message (type 71). + TxSignatures(TxSignatures), /// `tx_init_rbf` message (type 72). TxInitRbf(TxInitRbf), /// `tx_ack_rbf` message (type 73). @@ -391,6 +398,7 @@ impl Message { Self::TxRemoveInput(_) => MessageType::TX_REMOVE_INPUT, Self::TxRemoveOutput(_) => MessageType::TX_REMOVE_OUTPUT, Self::TxComplete(_) => MessageType::TX_COMPLETE, + Self::TxSignatures(_) => MessageType::TX_SIGNATURES, Self::TxInitRbf(_) => MessageType::TX_INIT_RBF, Self::TxAckRbf(_) => MessageType::TX_ACK_RBF, Self::TxAbort(_) => MessageType::TX_ABORT, @@ -435,6 +443,7 @@ impl Message { Self::TxRemoveInput(m) => out.extend(m.encode()), Self::TxRemoveOutput(m) => out.extend(m.encode()), Self::TxComplete(m) => out.extend(m.encode()), + Self::TxSignatures(m) => out.extend(m.encode()), Self::TxInitRbf(m) => out.extend(m.encode()), Self::TxAckRbf(m) => out.extend(m.encode()), Self::TxAbort(m) => out.extend(m.encode()), @@ -494,6 +503,7 @@ impl Message { Ok(Self::TxRemoveOutput(TxRemoveOutput::decode(cursor)?)) } MessageType::TX_COMPLETE => Ok(Self::TxComplete(TxComplete::decode(cursor)?)), + MessageType::TX_SIGNATURES => Ok(Self::TxSignatures(TxSignatures::decode(cursor)?)), MessageType::TX_INIT_RBF => Ok(Self::TxInitRbf(TxInitRbf::decode(cursor)?)), MessageType::TX_ACK_RBF => Ok(Self::TxAckRbf(TxAckRbf::decode(cursor)?)), MessageType::TX_ABORT => Ok(Self::TxAbort(TxAbort::decode(cursor)?)), @@ -964,6 +974,25 @@ mod tests { assert_eq!(decoded, Message::TxComplete(tx_complete)); } + /// Valid `TxSignatures` message for testing. + fn sample_tx_signatures() -> TxSignatures { + TxSignatures { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + txid: Txid::from_byte_array([0xcd; TXID_SIZE]), + witnesses: vec![vec![0xde, 0xad, 0xbe, 0xef], vec![0x01, 0x02]], + tlvs: TxSignaturesTlvs::default(), + } + } + + #[test] + fn message_tx_signatures_roundtrip() { + let tx_signatures = sample_tx_signatures(); + let msg = Message::TxSignatures(tx_signatures.clone()); + let encoded = msg.encode(); + let decoded = Message::decode(&encoded).unwrap(); + assert_eq!(decoded, Message::TxSignatures(tx_signatures)); + } + #[test] fn message_tx_init_rbf_roundtrip() { let tx_init_rbf = TxInitRbf { @@ -1345,6 +1374,11 @@ mod tests { "tx_complete", MessageType::TX_COMPLETE, ), + ( + Message::TxSignatures(sample_tx_signatures()), + "tx_signatures", + MessageType::TX_SIGNATURES, + ), ( Message::TxInitRbf(TxInitRbf { channel_id: ChannelId::new([0; CHANNEL_ID_SIZE]), diff --git a/smite/src/bolt/tx_signatures.rs b/smite/src/bolt/tx_signatures.rs new file mode 100644 index 00000000..287401d3 --- /dev/null +++ b/smite/src/bolt/tx_signatures.rs @@ -0,0 +1,390 @@ +//! BOLT 2 `tx_signatures` message. + +use bitcoin::Txid; +use bitcoin::secp256k1::ecdsa::Signature; + +use super::BoltError; +use super::tlv::TlvStream; +use super::types::ChannelId; +use super::wire::WireFormat; + +/// TLV type for the shared input signature. +const TLV_SHARED_INPUT_SIGNATURE: u64 = 0; + +/// BOLT 2 `tx_signatures` message (type 71). +/// +/// Sent once interactive transaction construction has completed, carrying the +/// sender's witnesses for the inputs it contributed to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TxSignatures { + /// The channel this message pertains to. + pub channel_id: ChannelId, + /// Transaction ID of the shared transaction being signed. + pub txid: Txid, + /// One entry per input added by the sender, ordered by that input's + /// `serial_id`. + /// + /// Each entry is bitcoin-wire-encoded witness data: a `CompactSize` + /// element count, then each element as a `CompactSize` length followed by + /// that many bytes. + pub witnesses: Vec>, + /// Optional TLV extensions. + pub tlvs: TxSignaturesTlvs, +} + +/// TLV extensions for the `tx_signatures` message. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TxSignaturesTlvs { + /// Signature for the shared input, when one is being spent (splicing). + pub shared_input_signature: Option, +} + +impl TxSignaturesTlvs { + /// Extracts TLVs from a parsed TLV stream. + /// + /// # Errors + /// + /// Returns a `BoltError` if `shared_input_signature` has invalid length or + /// is not a canonical compact ECDSA signature. + fn from_stream(stream: &TlvStream) -> Result { + let shared_input_signature = stream.get_as::(TLV_SHARED_INPUT_SIGNATURE)?; + Ok(Self { + shared_input_signature, + }) + } +} + +impl TxSignatures { + /// Encodes to wire format (without message type prefix). + /// + /// # Panics + /// + /// Panics if the number of witnesses exceeds `u16::MAX`. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + self.channel_id.write(&mut out); + self.txid.write(&mut out); + u16::try_from(self.witnesses.len()) + .expect("number of witnesses must not exceed u16::MAX") + .write(&mut out); + for witness in &self.witnesses { + witness.write(&mut out); + } + + let mut tlv_stream = TlvStream::new(); + if let Some(signature) = &self.tlvs.shared_input_signature { + tlv_stream.add( + TLV_SHARED_INPUT_SIGNATURE, + signature.serialize_compact().to_vec(), + ); + } + out.extend(tlv_stream.encode()); + + out + } + + /// Decodes from wire format (without message type prefix). + /// + /// # Errors + /// + /// Returns `Truncated` if the payload is too short for any field, + /// `InvalidSignature` if `shared_input_signature` is not a valid compact + /// ECDSA signature, or a TLV error if the TLV stream is malformed. + pub fn decode(payload: &[u8]) -> Result { + let mut cursor = payload; + + let channel_id = WireFormat::read(&mut cursor)?; + let txid = WireFormat::read(&mut cursor)?; + let num_witnesses = u16::read(&mut cursor)?; + let mut witnesses = Vec::with_capacity(num_witnesses.into()); + for _ in 0..num_witnesses { + witnesses.push(Vec::::read(&mut cursor)?); + } + + let tlv_stream = TlvStream::decode_with_known(cursor, &[TLV_SHARED_INPUT_SIGNATURE])?; + let tlvs = TxSignaturesTlvs::from_stream(&tlv_stream)?; + + Ok(Self { + channel_id, + txid, + witnesses, + tlvs, + }) + } +} + +#[cfg(test)] +mod tests { + use super::super::{CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, TXID_SIZE}; + use super::*; + use bitcoin::secp256k1::hashes::Hash; + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + + /// Offset of `num_witnesses` within the encoded payload. + const NUM_WITNESSES_OFFSET: usize = CHANNEL_ID_SIZE + TXID_SIZE; + + fn sample_msg() -> TxSignatures { + TxSignatures { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + txid: Txid::from_byte_array([0xcd; TXID_SIZE]), + witnesses: vec![vec![0xde, 0xad, 0xbe, 0xef], vec![0x01, 0x02]], + tlvs: TxSignaturesTlvs::default(), + } + } + + fn sample_signature() -> Signature { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x11; 32]).unwrap(); + let msg = Message::from_digest([0xaa; 32]); + secp.sign_ecdsa(&msg, &sk) + } + + /// Dual-funding test vectors from BOLT 3, "Appendix G: Dual Funded + /// Transaction Test Vectors". `channel_id` is unspecified there, so the + /// sample value is used. `txid` is given in display order; the wire + /// encoding is its reverse, which is what `Txid` writes. + #[test] + fn encode_bolt3_dual_funding_vectors() { + const TXID_DISPLAY: &str = + "5ca4e657c1aa9d069ea4a5d712045d233a7d7c52738cb02993637289e6386057"; + let opener_witness = "022068656c6c6f2074686572652c2074686973206973206120626974636f6e21212127\ + 82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87"; + let accepter_witness = "0247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e90220\ + 69e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b786\ + 4c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"; + + let mut txid_bytes: [u8; TXID_SIZE] = + hex::decode(TXID_DISPLAY).unwrap().try_into().unwrap(); + txid_bytes.reverse(); + let txid = Txid::from_byte_array(txid_bytes); + assert_eq!(txid.to_string(), TXID_DISPLAY); + + // (witness hex, declared `len`, expected payload hex) + let cases = [ + ( + opener_witness, + 74, + "abababababababababababababababababababababababababababababababab\ + 576038e68972639329b08c73527c7d3a235d0412d7a5a49e069daac157e6a45c0001004a", + ), + ( + accepter_witness, + 107, + "abababababababababababababababababababababababababababababababab\ + 576038e68972639329b08c73527c7d3a235d0412d7a5a49e069daac157e6a45c0001006b", + ), + ]; + + for (witness_hex, len, prefix_hex) in cases { + let witness = hex::decode(witness_hex).unwrap(); + assert_eq!(witness.len(), len, "witness `len` field"); + + let msg = TxSignatures { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + txid, + witnesses: vec![witness], + tlvs: TxSignaturesTlvs::default(), + }; + + let encoded = msg.encode(); + assert_eq!(hex::encode(&encoded), prefix_hex.to_owned() + witness_hex); + assert_eq!(TxSignatures::decode(&encoded).unwrap(), msg); + } + } + + #[test] + fn roundtrip() { + let original = sample_msg(); + let encoded = original.encode(); + // channel_id(32) + txid(32) + num_witnesses(2) + (2+4) + (2+2) = 76 + assert_eq!(encoded.len(), 76); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn roundtrip_with_shared_input_signature() { + let mut original = sample_msg(); + original.tlvs.shared_input_signature = Some(sample_signature()); + let encoded = original.encode(); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + /// A witness count of zero is a negotiation failure, not a parse failure. + #[test] + fn roundtrip_zero_witnesses() { + let original = TxSignatures { + witnesses: vec![], + ..sample_msg() + }; + let encoded = original.encode(); + assert_eq!(encoded.len(), NUM_WITNESSES_OFFSET + 2); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + /// An empty witness is a negotiation failure, not a parse failure. + #[test] + fn roundtrip_empty_witness() { + let original = TxSignatures { + witnesses: vec![vec![]], + ..sample_msg() + }; + let encoded = original.encode(); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(decoded.witnesses, vec![Vec::::new()]); + assert_eq!(original, decoded); + } + + #[test] + #[should_panic(expected = "number of witnesses must not exceed u16::MAX")] + fn encode_panics_on_oversized_witnesses() { + let msg = TxSignatures { + witnesses: vec![vec![0x00]; usize::from(u16::MAX) + 1], + ..sample_msg() + }; + let _ = msg.encode(); + } + + #[test] + fn decode_empty() { + assert_eq!( + TxSignatures::decode(&[]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 0, + }) + ); + } + + #[test] + fn decode_truncated_channel_id() { + assert_eq!( + TxSignatures::decode(&[0x00; 5]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 5, + }) + ); + } + + #[test] + fn decode_truncated_txid() { + assert_eq!( + TxSignatures::decode(&[0x00; CHANNEL_ID_SIZE + 20]), + Err(BoltError::Truncated { + expected: TXID_SIZE, + actual: 20, + }) + ); + } + + #[test] + fn decode_truncated_num_witnesses() { + assert_eq!( + TxSignatures::decode(&[0x00; NUM_WITNESSES_OFFSET + 1]), + Err(BoltError::Truncated { + expected: 2, + actual: 1, + }) + ); + } + + #[test] + fn decode_truncated_witness_len() { + // num_witnesses = 1, then a single byte of the 2-byte witness length. + let mut payload = vec![0x00u8; NUM_WITNESSES_OFFSET]; + payload.extend_from_slice(&[0x00, 0x01]); + payload.push(0x00); + assert_eq!( + TxSignatures::decode(&payload), + Err(BoltError::Truncated { + expected: 2, + actual: 1, + }) + ); + } + + #[test] + fn decode_truncated_witness_data() { + // num_witnesses = 1, witness declares 10 bytes but only 3 are present. + let mut payload = vec![0x00u8; NUM_WITNESSES_OFFSET]; + payload.extend_from_slice(&[0x00, 0x01]); + payload.extend_from_slice(&[0x00, 0x0a]); + payload.extend_from_slice(&[0x00; 3]); + assert_eq!( + TxSignatures::decode(&payload), + Err(BoltError::Truncated { + expected: 10, + actual: 3, + }) + ); + } + + #[test] + fn decode_missing_witness() { + // num_witnesses claims 2, but only the first witness is present. + let encoded = sample_msg().encode(); + let cutoff = NUM_WITNESSES_OFFSET + 2 + 2 + 4; + assert_eq!( + TxSignatures::decode(&encoded[..cutoff]), + Err(BoltError::Truncated { + expected: 2, + actual: 0, + }) + ); + } + + #[test] + fn decode_unknown_odd_tlv_ignored() { + let original = sample_msg(); + let mut encoded = original.encode(); + // Append unknown odd TLV: type 3, length 2, value [0xaa, 0xbb] + encoded.extend_from_slice(&[0x03, 0x02, 0xaa, 0xbb]); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn decode_unknown_even_tlv_rejected() { + let mut encoded = sample_msg().encode(); + // Append unknown even TLV: type 2, length 1, value [0xff] + encoded.extend_from_slice(&[0x02, 0x01, 0xff]); + assert_eq!( + TxSignatures::decode(&encoded), + Err(BoltError::TlvUnknownEvenType(2)) + ); + } + + #[test] + fn decode_wrong_length_shared_input_signature() { + let mut encoded = sample_msg().encode(); + // Append TLV type 0 with only 32 bytes instead of 64. + encoded.push(0x00); // type 0 + encoded.push(0x20); // length 32 + encoded.extend_from_slice(&[0xaa; 32]); + assert_eq!( + TxSignatures::decode(&encoded), + Err(BoltError::Truncated { + expected: COMPACT_SIGNATURE_SIZE, + actual: 32, + }) + ); + } + + #[test] + fn decode_invalid_shared_input_signature() { + let mut encoded = sample_msg().encode(); + // r and s are both above the curve order. + let bad_sig = [0xff; COMPACT_SIGNATURE_SIZE]; + encoded.push(0x00); // type 0 + encoded.push(0x40); // length 64 + encoded.extend_from_slice(&bad_sig); + assert_eq!( + TxSignatures::decode(&encoded), + Err(BoltError::InvalidSignature(bad_sig)) + ); + } +} From 25245d1935fbd3d61b79e72611be6dfaa32e5cee Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Fri, 28 Aug 2026 11:08:05 -0300 Subject: [PATCH 3/6] bolt: derive v2 channel ids from revocation basepoints Channel establishment v2 identifies a channel by SHA256(lesser-revocation-basepoint || greater-revocation-basepoint) rather than by the funding outpoint, so the id is stable across the interactive transaction negotiation. Before accept_channel2 reveals the peer's basepoint, a zeroed basepoint stands in for the non-initiator. BOLT 2 gives no test vector for either derivation, so the expected digests were computed independently and are pinned as regression vectors. --- smite/src/bolt/types.rs | 101 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/smite/src/bolt/types.rs b/smite/src/bolt/types.rs index c13925fc..34273ece 100644 --- a/smite/src/bolt/types.rs +++ b/smite/src/bolt/types.rs @@ -1,8 +1,9 @@ //! Fundamental types for BOLT message encoding. use bitcoin::OutPoint; -use bitcoin::hashes::Hash; +use bitcoin::hashes::{Hash, sha256}; use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::PublicKey; use std::fmt; /// Maximum Lightning message size (2-byte length prefix limit). @@ -71,6 +72,37 @@ impl ChannelId { res[31] ^= (outpoint.vout & 0xff) as u8; Self(res) } + + /// Creates a _v2_ channel ID from both peers' revocation basepoints. + #[must_use] + pub fn v2_from_revocation_basepoints(basepoint1: &PublicKey, basepoint2: &PublicKey) -> Self { + Self::v2_from_serialized_basepoints(basepoint1.serialize(), basepoint2.serialize()) + } + + /// Creates a _v2_ `temporary_channel_id` from the opener's revocation + /// basepoint. + #[must_use] + pub fn v2_temporary_from_revocation_basepoint( + opener_basepoint: &PublicKey, + ) -> TemporaryChannelId { + Self::v2_from_serialized_basepoints([0u8; PUBLIC_KEY_SIZE], opener_basepoint.serialize()) + } + + /// Hashes two already-serialized basepoints in BOLT 2's canonical order. + fn v2_from_serialized_basepoints( + basepoint1: [u8; PUBLIC_KEY_SIZE], + basepoint2: [u8; PUBLIC_KEY_SIZE], + ) -> Self { + let (lesser, greater) = if basepoint1 <= basepoint2 { + (basepoint1, basepoint2) + } else { + (basepoint2, basepoint1) + }; + let mut preimage = [0u8; PUBLIC_KEY_SIZE * 2]; + preimage[..PUBLIC_KEY_SIZE].copy_from_slice(&lesser); + preimage[PUBLIC_KEY_SIZE..].copy_from_slice(&greater); + Self(sha256::Hash::hash(&preimage).to_byte_array()) + } } impl fmt::Display for ChannelId { @@ -218,6 +250,17 @@ mod tests { use super::*; use bitcoin::{OutPoint, Txid}; + /// The two `funding_pubkey`s of the 2-of-2 output in the BOLT 3 + /// "Appendix G: Dual Funded Transaction Test Vectors". Used here only as a + /// convenient pair of known-valid compressed points. + const BASEPOINT_1: &str = "0292edb5f7bbf9e900f7e024be1c1339c6d149c11930e613af3a983d2565f4e41e"; + const BASEPOINT_2: &str = "02e16172a41e928cbd78f761bd1c657c4afc7495a1244f7f30166b654fbf7661e3"; + + fn pubkey(hex_str: &str) -> PublicKey { + let bytes = hex::decode(hex_str).expect("valid hex"); + PublicKey::from_slice(&bytes).expect("valid pubkey") + } + #[test] fn bigsize_new() { let bs = BigSize::new(42); @@ -308,6 +351,62 @@ mod tests { } } + #[test] + fn channel_id_v2_from_revocation_basepoints_matches_vector() { + let channel_id = + ChannelId::v2_from_revocation_basepoints(&pubkey(BASEPOINT_1), &pubkey(BASEPOINT_2)); + + assert_eq!( + channel_id.to_string(), + "59bc22f722836ce5095a37504f8ab87b1b2dbdc8aad638b77da3f5f3e8330edd", + ); + } + + #[test] + fn channel_id_v2_from_revocation_basepoints_is_order_independent() { + let basepoint_1 = pubkey(BASEPOINT_1); + let basepoint_2 = pubkey(BASEPOINT_2); + + assert_eq!( + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_2), + ChannelId::v2_from_revocation_basepoints(&basepoint_2, &basepoint_1), + ); + } + + #[test] + fn channel_id_v2_temporary_matches_vector() { + assert_eq!( + ChannelId::v2_temporary_from_revocation_basepoint(&pubkey(BASEPOINT_1)).to_string(), + "90fc2d0fcef3376e4c26de47e9c86a7362adecf87c8c1a01cdaa3263abd74c5a", + ); + assert_eq!( + ChannelId::v2_temporary_from_revocation_basepoint(&pubkey(BASEPOINT_2)).to_string(), + "270d4e8fa33e0c15f46c4978186d02c51a5307229ba5689c9f600c68360e25e3", + ); + } + + #[test] + fn channel_id_v2_temporary_differs_from_final() { + let basepoint_1 = pubkey(BASEPOINT_1); + let basepoint_2 = pubkey(BASEPOINT_2); + + assert_ne!( + ChannelId::v2_temporary_from_revocation_basepoint(&basepoint_1), + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_2), + ); + } + + #[test] + fn channel_id_v2_distinguishes_basepoints() { + let basepoint_1 = pubkey(BASEPOINT_1); + let basepoint_2 = pubkey(BASEPOINT_2); + + assert_ne!( + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_1), + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_2), + ); + } + #[test] fn short_channel_id_ord_matches_packed_u64() { let a = ShortChannelId::new(100, 0, 0); From aa6e176f7377283fec02b16c231e15d93bb71791 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Fri, 28 Aug 2026 11:10:45 -0300 Subject: [PATCH 4/6] bitcoin: add raw transaction and sign-only RPCs Channel establishment v2 needs two things the bitcoin-cli wrapper could not do. tx_add_input carries the serialized previous transaction, so add get_raw_transaction. tx_signatures carries our witnesses for a transaction the peer broadcasts, so add sign_tx, which signs without broadcasting and, unlike sign_and_broadcast_tx, does not require signing to be complete: a dual-funded transaction also carries the peer's inputs, which our wallet cannot sign, and the partially signed result still holds our own witnesses. Both share a signrawtransactionwithwallet helper with sign_and_broadcast_tx, whose complete=false assertion stays as-is since a v1 funding transaction spends only our own inputs. Also tolerate an already-broadcast transaction. In v2 the peer broadcasts the funding transaction too, so it can already be in the mempool by the time we get there. Only a confirmed transaction was handled before, which left the mempool case panicking. --- smite/src/bitcoin.rs | 136 +++++++++++++++++++++++++++++++++---------- 1 file changed, 104 insertions(+), 32 deletions(-) diff --git a/smite/src/bitcoin.rs b/smite/src/bitcoin.rs index 250409aa..926b08c1 100644 --- a/smite/src/bitcoin.rs +++ b/smite/src/bitcoin.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::process::Command; use std::str::FromStr; -use bitcoin::consensus::encode::serialize_hex; +use bitcoin::consensus::encode::{deserialize, serialize_hex}; use bitcoin::{Address, Amount, Network, OutPoint, ScriptBuf, Transaction, Txid}; use serde::{Deserialize, Serialize}; @@ -49,6 +49,15 @@ pub struct TxBlockPosition { pub tx_index: u32, } +/// Parsed response from `signrawtransactionwithwallet `. +#[derive(Deserialize)] +struct SignRawTransactionResponse { + /// Consensus-serialized transaction with every signable input signed. + hex: String, + /// Whether every input now has a complete signature set. + complete: bool, +} + /// Parsed response from `getrawtransaction 1`. #[derive(Deserialize)] struct RawTransactionInfo { @@ -58,6 +67,8 @@ struct RawTransactionInfo { confirmations: u32, /// Omitted while the transaction is unconfirmed (in the mempool). blockhash: Option, + /// Consensus-serialized transaction, always present. + hex: String, } /// Connection info for invoking `bitcoin-cli` against the regtest `bitcoind` @@ -271,6 +282,50 @@ impl BitcoinCli { .expect("getnewaddress should return a valid address") } + /// Signs the wallet-owned inputs of `tx`, returning the partially or fully + /// signed transaction, or `None` if the node does not know how to sign any + /// of them. + /// + /// # Panics + /// + /// - If `bitcoin-cli signrawtransactionwithwallet` fails to execute. + /// - If the command succeeds but its output is not valid JSON, or its `hex` + /// field does not decode as a transaction. + #[must_use] + pub fn sign_tx(&self, tx: &Transaction) -> Option { + let signed = self + .sign_raw_transaction_with_wallet(tx) + .inspect_err(|stderr| { + log::debug!("bitcoin-cli signrawtransactionwithwallet failed: {stderr}"); + }) + .ok()?; + Some( + deserialize(&hex::decode(&signed.hex).expect("signing should return valid hex")) + .expect("signing should return a valid transaction"), + ) + } + + /// Runs `signrawtransactionwithwallet`, returning the raw response, or the + /// command's stderr if it exits non-zero. + fn sign_raw_transaction_with_wallet( + &self, + tx: &Transaction, + ) -> Result { + let signed_out = self + .run() + .arg("signrawtransactionwithwallet") + .arg(serialize_hex(tx)) + .output() + .expect("bitcoin-cli signrawtransactionwithwallet should not fail"); + + if !signed_out.status.success() { + return Err(String::from_utf8_lossy(&signed_out.stderr).into_owned()); + } + + Ok(serde_json::from_slice(&signed_out.stdout) + .expect("signrawtransactionwithwallet should return valid JSON")) + } + /// Signs and broadcasts a transaction, unless it is already confirmed. /// /// If the signed transaction is accepted by the mempool, it is broadcast @@ -278,29 +333,22 @@ impl BitcoinCli { /// the minimum relay feerate or creates a dust output), it is returned /// instead so the caller can mine it later, bypassing mempool policy. /// - /// Returns `None` if the transaction was already confirmed or was broadcast - /// successfully, or hex-encoded raw transaction if it was rejected by the - /// mempool. + /// Returns `None` if the transaction was already confirmed, could not be + /// fully signed, or was broadcast successfully; or the hex-encoded raw + /// transaction if it was rejected by the mempool. /// /// # Panics /// /// - If `bitcoin-cli signrawtransactionwithwallet` fails to execute or /// exits non-zero. /// - If the sign output is not valid JSON. - /// - If signing returns `complete=false`. /// - If `bitcoin-cli sendrawtransaction` fails to execute. - /// - If the broadcast is rejected for any reason other than a below-dust - /// output or a below-minimum relay feerate. + /// - If the broadcast is rejected for a reason other than a known mempool + /// policy rule or the transaction already being known to the node. /// - If a successful broadcast does not return a valid UTF-8 txid. /// - If the broadcasted txid does not match the given transaction's txid. #[must_use] pub fn sign_and_broadcast_tx(&self, tx: &Transaction) -> Option { - #[derive(Deserialize)] - struct SignRawTransactionResponse { - hex: String, - complete: bool, - } - // A confirmed transaction may be broadcast again by the fuzzer. Its // inputs are spent, so the wallet can no longer fully sign it, skip // signing and broadcasting it again. @@ -309,26 +357,18 @@ impl BitcoinCli { return None; } - let tx_hex = serialize_hex(tx); + let signed_tx = self + .sign_raw_transaction_with_wallet(tx) + .unwrap_or_else(|stderr| { + panic!("bitcoin-cli signrawtransactionwithwallet failed: {stderr}") + }); - let signed_out = self - .run() - .arg("signrawtransactionwithwallet") - .arg(&tx_hex) - .output() - .expect("bitcoin-cli signrawtransactionwithwallet should not fail"); - assert!( - signed_out.status.success(), - "bitcoin-cli signrawtransactionwithwallet failed: {}", - String::from_utf8_lossy(&signed_out.stderr) - ); - - let signed_tx: SignRawTransactionResponse = serde_json::from_slice(&signed_out.stdout) - .expect("signrawtransactionwithwallet should return valid JSON"); - assert!( - signed_tx.complete, - "signrawtransactionwithwallet returned complete=false" - ); + if !signed_tx.complete { + log::debug!( + "signrawtransactionwithwallet could not fully sign {txid}, not broadcasting" + ); + return None; + } let broadcast_out = self .run() @@ -347,6 +387,24 @@ impl BitcoinCli { if stderr.contains("tx with dust output") || stderr.contains("min relay fee not met") { return Some(signed_tx.hex); } + // The transaction may already be known to the node: the fuzzer can + // broadcast the same transaction twice before mining, and in + // channel establishment v2 the peer broadcasts the funding + // transaction as well. Either way it is already where we want it, + // nothing to mine privately. + if stderr.contains("txn-already-in-mempool") + || stderr.contains("txn-already-known") + || stderr.contains("Transaction already in block chain") + { + return None; + } + // A channel establishment v2 funding transaction takes its + // `nLockTime` from `open_channel2.locktime`, which the fuzzer picks + // freely, so it is routinely in the future. + if stderr.contains("non-final") { + log::debug!("{txid} is not final yet, not broadcasting"); + return None; + } panic!("bitcoin-cli sendrawtransaction failed: {stderr}"); } @@ -453,6 +511,20 @@ impl BitcoinCli { .map_or(0, |info| info.confirmations) } + /// Returns the consensus-serialized transaction with the given txid, or + /// `None` if it is unknown to the node. + /// + /// # Panics + /// + /// - If the `bitcoin-cli getrawtransaction` command fails to execute. + /// - If the command succeeds but its output is not valid JSON or its `hex` + /// field is not valid hex. + #[must_use] + pub fn get_raw_transaction(&self, txid: Txid) -> Option> { + let info = self.get_raw_transaction_info(txid)?; + Some(hex::decode(&info.hex).expect("getrawtransaction should return valid hex")) + } + /// Returns the position of the confirmed transaction with the given txid, /// or `None` if it is unconfirmed (in the mempool) or unknown to the node /// (e.g. not broadcast yet). From b6cea2a61dad1db189dd8dc7ccf5474808d4f89a Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Fri, 28 Aug 2026 11:14:42 -0300 Subject: [PATCH 5/6] channel_tx: add interactive transaction construction BOLT 2 interactive transaction construction has both peers contribute inputs and outputs to one shared transaction, each tagged with a serial_id. SharedTransaction accumulates those contributions and assembles the transaction both peers must agree on: inputs and outputs sorted by ascending serial_id, nVersion 2, and nLockTime from open_channel2. The reconstruction is checked against the BOLT 3 Appendix G dual-funding vectors, rebuilding the spec's unsigned funding transaction byte for byte from the tx_add_input and tx_add_output messages it says each peer sends. Contributions from a peer are total by construction: a prevtx that does not parse, or a prevtx_vout past the end of it, yields an unknown prevout rather than an error, since a peer is free to send nonsense and it is then the peer that must fail the negotiation. Input and output counts are capped at the BOLT limit of 252 so a mutated program cannot grow the session without bound. Also add signs_first, the BOLT 2 rule deciding who sends tx_signatures first: lowest total contributed input value, with the lexicographically lower node_id breaking a tie. --- smite/src/channel_tx.rs | 12 +- smite/src/channel_tx/funding.rs | 1 + smite/src/channel_tx/interactive_tx.rs | 621 +++++++++++++++++++++++++ 3 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 smite/src/channel_tx/interactive_tx.rs diff --git a/smite/src/channel_tx.rs b/smite/src/channel_tx.rs index 3e646154..45829a2d 100644 --- a/smite/src/channel_tx.rs +++ b/smite/src/channel_tx.rs @@ -1,13 +1,21 @@ //! BOLT 3 channel transaction construction. //! //! This module builds Lightning channel on-chain transactions: the funding -//! transaction and the commitment transaction. +//! transaction, the commitment transaction, and the shared transaction +//! negotiated by BOLT 2 interactive transaction construction. mod commitment; mod funding; +mod interactive_tx; pub use commitment::{ ChannelConfig, ChannelPartyConfig, ChannelState, CommitmentCost, CommitmentError, CommitmentPartyState, CommitmentState, HolderIdentity, Side, }; -pub use funding::{FundingTransaction, InsufficientFunds, build_funding_transaction}; +pub use funding::{ + FundingTransaction, InsufficientFunds, build_funding_transaction, build_funding_witness_script, +}; +pub use interactive_tx::{ + Contributor, MAX_INPUTS, MAX_OUTPUTS, MAX_SEQUENCE, SharedInput, SharedOutput, + SharedTransaction, signs_first, +}; diff --git a/smite/src/channel_tx/funding.rs b/smite/src/channel_tx/funding.rs index 3fb89429..2b5bd9ad 100644 --- a/smite/src/channel_tx/funding.rs +++ b/smite/src/channel_tx/funding.rs @@ -187,6 +187,7 @@ impl FundingTransaction { } /// Builds the funding output witness script per BOLT 3. +#[must_use] pub fn build_funding_witness_script(pubkey1: &PublicKey, pubkey2: &PublicKey) -> ScriptBuf { let key1_bytes = pubkey1.serialize(); let key2_bytes = pubkey2.serialize(); diff --git a/smite/src/channel_tx/interactive_tx.rs b/smite/src/channel_tx/interactive_tx.rs new file mode 100644 index 00000000..11b91229 --- /dev/null +++ b/smite/src/channel_tx/interactive_tx.rs @@ -0,0 +1,621 @@ +//! BOLT 2 interactive transaction construction. +//! +//! Two peers collaboratively build one transaction by exchanging `tx_add_input` +//! / `tx_add_output` / `tx_remove_input` / `tx_remove_output` messages, each +//! carrying a `serial_id`. [`SharedTransaction`] accumulates those +//! contributions and assembles the transaction both peers must agree on. + +use std::collections::BTreeMap; + +use bitcoin::absolute::LockTime; +use bitcoin::consensus::encode::deserialize; +use bitcoin::hashes::Hash; +use bitcoin::transaction::Version; +use bitcoin::{Amount, OutPoint, Script, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid}; +use bitcoin::{Witness, secp256k1::PublicKey}; + +use super::funding::FundingTransaction; + +/// Maximum inputs in the constructed transaction (BOLT 2). +pub const MAX_INPUTS: usize = 252; + +/// Maximum outputs in the constructed transaction (BOLT 2). +pub const MAX_OUTPUTS: usize = 252; + +/// Largest `sequence` a `tx_add_input` may carry (BOLT 2): every input must +/// signal replaceability. +pub const MAX_SEQUENCE: u32 = 0xffff_fffd; + +/// Which peer contributed an input or output to the shared transaction. +/// +/// Only [`Contributor::Local`] contributions are ours to sign and to remove. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Contributor { + /// We contributed it. + Local, + /// The peer contributed it. + Remote, +} + +/// An input contributed to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedInput { + /// The outpoint being spent. + pub outpoint: OutPoint, + /// `nSequence` for this input. + pub sequence: u32, + /// Which peer contributed it. + pub contributor: Contributor, + /// The output being spent, when known. Always known for our own inputs; + /// known for the peer's only when its `prevtx` parsed and `prevtx_vout` was + /// within range. + pub prevout: Option, +} + +impl SharedInput { + /// Builds an input from a `tx_add_input`'s serialized previous transaction. + /// + /// A `prevtx` that does not parse, or a `prevtx_vout` past the end of it, + /// yields an all-zero txid and an unknown `prevout` rather than an error: + /// the peer is free to send nonsense, and it is the peer that must then + /// fail the negotiation. + #[must_use] + pub fn from_prevtx( + prevtx: &[u8], + prevtx_vout: u32, + sequence: u32, + contributor: Contributor, + ) -> Self { + let prev: Option = deserialize(prevtx).ok(); + let prevout = prev + .as_ref() + .and_then(|tx| tx.output.get(prevtx_vout as usize)) + .cloned(); + let txid = prev.as_ref().map_or_else( + || Txid::from_byte_array([0u8; 32]), + Transaction::compute_txid, + ); + + Self { + outpoint: OutPoint { + txid, + vout: prevtx_vout, + }, + sequence, + contributor, + prevout, + } + } + + /// Value of the output being spent, or `0` when it is unknown. + #[must_use] + pub fn value(&self) -> u64 { + self.prevout.as_ref().map_or(0, |o| o.value.to_sat()) + } +} + +/// An output contributed to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedOutput { + /// Output value in satoshis. + pub value: u64, + /// Output `scriptPubKey`. + pub script_pubkey: ScriptBuf, + /// Which peer contributed it. + pub contributor: Contributor, +} + +/// The transaction being built by an interactive construction session. +/// +/// Contributions are keyed by `serial_id`, so iteration is already in the +/// ascending order BOLT 2 requires for the assembled transaction. A repeated +/// `serial_id` replaces the previous entry, mirroring what a peer that failed +/// to enforce uniqueness would end up with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedTransaction { + /// `nLockTime` of the transaction, from `open_channel2`. + pub locktime: u32, + inputs: BTreeMap, + outputs: BTreeMap, +} + +impl SharedTransaction { + /// Creates an empty session for a transaction with the given `nLockTime`. + #[must_use] + pub fn new(locktime: u32) -> Self { + Self { + locktime, + inputs: BTreeMap::new(), + outputs: BTreeMap::new(), + } + } + + /// Adds or replaces the input with `serial_id`. + /// + /// Returns `false` when the transaction already holds [`MAX_INPUTS`] + /// distinct serial ids and the input was dropped. + pub fn add_input(&mut self, serial_id: u64, input: SharedInput) -> bool { + if !self.inputs.contains_key(&serial_id) && self.inputs.len() >= MAX_INPUTS { + return false; + } + self.inputs.insert(serial_id, input); + true + } + + /// Adds or replaces the output with `serial_id`. + /// + /// Returns `false` when the transaction already holds [`MAX_OUTPUTS`] + /// distinct serial ids and the output was dropped. + pub fn add_output(&mut self, serial_id: u64, output: SharedOutput) -> bool { + if !self.outputs.contains_key(&serial_id) && self.outputs.len() >= MAX_OUTPUTS { + return false; + } + self.outputs.insert(serial_id, output); + true + } + + /// Removes the input with `serial_id`, returning it when it was present. + pub fn remove_input(&mut self, serial_id: u64) -> Option { + self.inputs.remove(&serial_id) + } + + /// Removes the output with `serial_id`, returning it when it was present. + pub fn remove_output(&mut self, serial_id: u64) -> Option { + self.outputs.remove(&serial_id) + } + + /// Inputs in ascending `serial_id` order. + pub fn inputs(&self) -> impl Iterator { + self.inputs.iter().map(|(id, input)| (*id, input)) + } + + /// Outputs in ascending `serial_id` order. + pub fn outputs(&self) -> impl Iterator { + self.outputs.iter().map(|(id, output)| (*id, output)) + } + + /// Positions in the assembled transaction of the inputs `contributor` + /// contributed. + /// + /// [`Self::build`] emits inputs in ascending `serial_id` order, so these + /// are also the positions BOLT 2's "order the `witnesses` by the + /// `serial_id` of the input they correspond to" maps a `tx_signatures`'s + /// witnesses onto, in either direction. + #[must_use] + pub fn input_positions(&self, contributor: Contributor) -> Vec { + self.inputs + .values() + .enumerate() + .filter(|(_, input)| input.contributor == contributor) + .map(|(position, _)| position) + .collect() + } + + /// Total value of the inputs contributed by `contributor`, saturating. + /// + /// Inputs whose `prevout` is unknown count as zero. + #[must_use] + pub fn contributed_input_value(&self, contributor: Contributor) -> u64 { + self.inputs + .values() + .filter(|i| i.contributor == contributor) + .fold(0u64, |acc, i| acc.saturating_add(i.value())) + } + + /// Assembles the transaction both peers must agree on. + /// + /// Per BOLT 2 the inputs and outputs are sorted by ascending `serial_id`; + /// `nVersion` is 2 and `nLockTime` comes from `open_channel2`. + #[must_use] + pub fn build(&self) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(self.locktime), + input: self + .inputs + .values() + .map(|i| TxIn { + previous_output: i.outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence(i.sequence), + witness: Witness::new(), + }) + .collect(), + output: self + .outputs + .values() + .map(|o| TxOut { + value: Amount::from_sat(o.value), + script_pubkey: o.script_pubkey.clone(), + }) + .collect(), + } + } + + /// Index of the channel funding output, identified by its script and value. + #[must_use] + pub fn funding_vout(&self, funding_script: &Script, funding_satoshis: u64) -> Option { + let index = self + .outputs + .values() + .position(|o| o.script_pubkey == *funding_script && o.value == funding_satoshis)?; + u32::try_from(index).ok() + } + + /// Assembles the transaction and locates its funding output. + #[must_use] + pub fn build_funding( + &self, + funding_script: &Script, + funding_satoshis: u64, + ) -> FundingTransaction { + FundingTransaction { + tx: self.build(), + vout: self + .funding_vout(funding_script, funding_satoshis) + .unwrap_or(0), + } + } +} + +/// Returns whether we must send `tx_signatures` first. +/// +/// Per BOLT 2 the peer contributing the lowest total input value signs first, +/// with the lexicographically lower `node_id` breaking a tie. The strict +/// ordering is what stops both peers waiting on each other. +#[must_use] +pub fn signs_first( + local_input_value: u64, + remote_input_value: u64, + local_node_id: &PublicKey, + remote_node_id: &PublicKey, +) -> bool { + match local_input_value.cmp(&remote_input_value) { + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Greater => false, + std::cmp::Ordering::Equal => local_node_id.serialize() < remote_node_id.serialize(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The transaction whose outputs both peers spend in the BOLT 3 + /// "Appendix G: Dual Funded Transaction Test Vectors". + const APPENDIX_G_PREVTX: &str = "02000000000101f86fd1d0db3ac5a72df968622f31e6b5e6566a09e2920\ +6d7c7a55df90e181de800000000171600141fb9623ffd0d422eacc450fd1e967efc477b83ccffffffff0580b2e60e0000\ +0000220020fd89acf65485df89797d9ba7ba7a33624ac4452f00db08107f34257d33e5b94680b2e60e0000000017a9146\ +a235d064786b49e7043e4a042d4cc429f7eb6948780b2e60e00000000160014fbb4db9d85fba5e301f4399e3038928e44\ +e37d3280b2e60e0000000017a9147ecd1b519326bc13b0ec716e469b58ed02b112a087f0006bee0000000017a914f856a\ +70093da3a5b5c4302ade033d4c2171705d387024730440220696f6cee2929f1feb3fd6adf024ca0f9aa2f4920ed6d35fb\ +9ec5b78c8408475302201641afae11242160101c6f9932aeb4fcd1f13a9c6df5d1386def000ea259a35001210381d7d5b\ +1bc0d7600565d827242576d9cb793bfe0754334af82289ee8b65d137600000000"; + + /// The `Unsigned Funding Transaction` of BOLT 3 Appendix G. + const APPENDIX_G_UNSIGNED_TX: &str = "0200000002b932b0669cd0394d0d5bcc27e01ab8c511f1662a679992\ +5b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca0\ +3430000000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000\ +000016001444cb0c39f93ecc372b5851725bd29d865d333b100084d71700000000220020297b92c238163e820b8248608\ +4634b4846b86a3c658d87b9384192e6bea98ec578000000"; + + /// The 2-of-2 funding `scriptPubKey` of Appendix G. + const APPENDIX_G_FUNDING_SPK: &str = + "0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5"; + /// Appendix G's opener change `scriptPubKey`. + const APPENDIX_G_OPENER_CHANGE_SPK: &str = "00141ca1cca8855bad6bc1ea5436edd8cff10b7e448b"; + /// Appendix G's accepter change `scriptPubKey`. + const APPENDIX_G_ACCEPTER_CHANGE_SPK: &str = "001444cb0c39f93ecc372b5851725bd29d865d333b10"; + + /// Appendix G's `nLockTime`. + const APPENDIX_G_LOCKTIME: u32 = 120; + /// Appendix G's funding output value: 2 x 2,000,000,000 sat. + const APPENDIX_G_FUNDING_SATS: u64 = 400_000_000; + + fn script(hex_str: &str) -> ScriptBuf { + ScriptBuf::from(hex::decode(hex_str).expect("valid hex")) + } + + fn pubkey(hex_str: &str) -> PublicKey { + PublicKey::from_slice(&hex::decode(hex_str).expect("valid hex")).expect("valid pubkey") + } + + /// Rebuilds Appendix G's funding transaction from the `tx_add_input` and + /// `tx_add_output` messages the appendix says each peer sends. Note that + /// the contributions are added out of serial order on purpose. + fn appendix_g() -> SharedTransaction { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(APPENDIX_G_LOCKTIME); + + // Opener's input, serial_id 20, spending the parent's output 0. + assert!(shared.add_input( + 20, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + )); + // Accepter's input, serial_id 11, spending the parent's output 2. + assert!(shared.add_input( + 11, + SharedInput::from_prevtx(&prevtx, 2, MAX_SEQUENCE, Contributor::Remote), + )); + + // Opener's change, serial_id 30. + assert!(shared.add_output( + 30, + SharedOutput { + value: 49_999_845, + script_pubkey: script(APPENDIX_G_OPENER_CHANGE_SPK), + contributor: Contributor::Local, + }, + )); + // Opener's funding output, serial_id 44. + assert!(shared.add_output( + 44, + SharedOutput { + value: APPENDIX_G_FUNDING_SATS, + script_pubkey: script(APPENDIX_G_FUNDING_SPK), + contributor: Contributor::Local, + }, + )); + // Accepter's change, serial_id 33. + assert!(shared.add_output( + 33, + SharedOutput { + value: 49_999_900, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }, + )); + + shared + } + + #[test] + fn build_matches_bolt3_appendix_g() { + let tx = appendix_g().build(); + + assert_eq!( + bitcoin::consensus::encode::serialize_hex(&tx), + APPENDIX_G_UNSIGNED_TX, + ); + } + + #[test] + fn build_sorts_by_serial_id_not_insertion_order() { + let tx = appendix_g().build(); + + // Inputs: serial 11 (parent vout 2) before serial 20 (parent vout 0), + // even though serial 20 was added first. + assert_eq!( + tx.input + .iter() + .map(|i| i.previous_output.vout) + .collect::>(), + vec![2, 0], + ); + // Outputs: serials 30, 33, 44, even though 44 was added before 33. + assert_eq!( + tx.output + .iter() + .map(|o| o.value.to_sat()) + .collect::>(), + vec![49_999_845, 49_999_900, APPENDIX_G_FUNDING_SATS], + ); + } + + #[test] + fn build_uses_version_two_and_negotiated_locktime() { + let tx = appendix_g().build(); + + assert_eq!(tx.version, Version::TWO); + assert_eq!(tx.lock_time, LockTime::from_consensus(APPENDIX_G_LOCKTIME)); + assert!( + tx.input + .iter() + .all(|i| i.sequence == Sequence(MAX_SEQUENCE)) + ); + } + + #[test] + fn funding_vout_locates_the_two_of_two_output() { + let shared = appendix_g(); + + assert_eq!( + shared.funding_vout(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS), + Some(2), + ); + assert_eq!( + shared + .build_funding(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS) + .vout, + 2, + ); + } + + #[test] + fn funding_vout_rejects_a_wrong_value_or_script() { + let shared = appendix_g(); + + assert_eq!( + shared.funding_vout(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS - 1), + None, + ); + assert_eq!( + shared.funding_vout( + &script(APPENDIX_G_OPENER_CHANGE_SPK), + APPENDIX_G_FUNDING_SATS + ), + None, + ); + } + + #[test] + fn build_funding_falls_back_to_vout_zero_without_a_funding_output() { + let mut shared = appendix_g(); + shared.remove_output(44); + + let funding = + shared.build_funding(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS); + + assert_eq!(funding.vout, 0); + } + + #[test] + fn contributed_input_value_splits_by_contributor() { + let shared = appendix_g(); + + // Each peer spends one 2.5 BTC output of the parent transaction. + assert_eq!( + shared.contributed_input_value(Contributor::Local), + 250_000_000 + ); + assert_eq!( + shared.contributed_input_value(Contributor::Remote), + 250_000_000 + ); + } + + #[test] + fn input_positions_follow_serial_order_not_insertion_order() { + let shared = appendix_g(); + + // Serial 11 is the accepter's and was added second, but sorts first. + assert_eq!(shared.input_positions(Contributor::Remote), vec![0]); + assert_eq!(shared.input_positions(Contributor::Local), vec![1]); + } + + #[test] + fn input_positions_is_empty_without_contributions() { + assert!( + SharedTransaction::new(0) + .input_positions(Contributor::Local) + .is_empty() + ); + } + + #[test] + fn from_prevtx_with_unparsable_prevtx_is_not_an_error() { + let input = SharedInput::from_prevtx(&[0xde, 0xad], 0, MAX_SEQUENCE, Contributor::Remote); + + assert_eq!(input.outpoint.txid, Txid::from_byte_array([0u8; 32])); + assert_eq!(input.prevout, None); + assert_eq!(input.value(), 0); + } + + #[test] + fn from_prevtx_with_out_of_range_vout_has_no_prevout() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + + let input = SharedInput::from_prevtx(&prevtx, 99, MAX_SEQUENCE, Contributor::Remote); + + // The txid is still known, so the outpoint is well-formed and the peer + // is the one that must fail the negotiation. + assert_ne!(input.outpoint.txid, Txid::from_byte_array([0u8; 32])); + assert_eq!(input.outpoint.vout, 99); + assert_eq!(input.prevout, None); + assert_eq!(input.value(), 0); + } + + #[test] + fn add_replaces_a_duplicate_serial_id() { + let mut shared = appendix_g(); + let outputs_before = shared.outputs().count(); + + assert!(shared.add_output( + 30, + SharedOutput { + value: 1, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }, + )); + + assert_eq!(shared.outputs().count(), outputs_before); + assert_eq!(shared.build().output[0].value.to_sat(), 1); + } + + #[test] + fn add_input_stops_at_the_maximum() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + for serial_id in 0..MAX_INPUTS as u64 { + assert!(shared.add_input( + serial_id, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Remote), + )); + } + + // A new serial id is dropped, but replacing an existing one still works. + assert!(!shared.add_input( + MAX_INPUTS as u64, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Remote), + )); + assert!(shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 1, MAX_SEQUENCE, Contributor::Remote), + )); + assert_eq!(shared.inputs().count(), MAX_INPUTS); + } + + #[test] + fn add_output_stops_at_the_maximum() { + let mut shared = SharedTransaction::new(0); + let output = SharedOutput { + value: 1000, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }; + for serial_id in 0..MAX_OUTPUTS as u64 { + assert!(shared.add_output(serial_id, output.clone())); + } + + assert!(!shared.add_output(MAX_OUTPUTS as u64, output.clone())); + assert!(shared.add_output(0, output)); + assert_eq!(shared.outputs().count(), MAX_OUTPUTS); + } + + #[test] + fn remove_reports_whether_the_serial_id_was_present() { + let mut shared = appendix_g(); + + assert!(shared.remove_input(20).is_some()); + assert!(shared.remove_input(20).is_none()); + assert!(shared.remove_output(44).is_some()); + assert!(shared.remove_output(9999).is_none()); + } + + // -- tx_signatures ordering -- + + /// Two valid compressed points, ordered so that `LOW` sorts first. + const NODE_ID_LOW: &str = "0292edb5f7bbf9e900f7e024be1c1339c6d149c11930e613af3a983d2565f4e41e"; + const NODE_ID_HIGH: &str = "02e16172a41e928cbd78f761bd1c657c4afc7495a1244f7f30166b654fbf7661e3"; + + #[test] + fn signs_first_follows_the_lowest_contribution() { + let low = pubkey(NODE_ID_LOW); + let high = pubkey(NODE_ID_HIGH); + + // We contributed less, so we sign first regardless of node id. + assert!(signs_first(1, 2, &high, &low)); + // We contributed more, so the peer signs first. + assert!(!signs_first(2, 1, &low, &high)); + } + + #[test] + fn signs_first_breaks_an_equal_contribution_by_node_id() { + let low = pubkey(NODE_ID_LOW); + let high = pubkey(NODE_ID_HIGH); + + assert!(signs_first(5, 5, &low, &high)); + assert!(!signs_first(5, 5, &high, &low)); + } + + #[test] + fn signs_first_when_the_peer_contributes_nothing_is_the_peer() { + let low = pubkey(NODE_ID_LOW); + let high = pubkey(NODE_ID_HIGH); + + // The opener-funds-everything case: the peer's total is 0, so the peer + // signs first and we must receive tx_signatures before sending ours. + assert!(!signs_first(250_000_000, 0, &low, &high)); + } +} From 2a4e71a65907bdf392b9e06daf2d713ea71e5433 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Fri, 28 Aug 2026 11:26:28 -0300 Subject: [PATCH 6/6] channel_tx: compute the initiator's collaborative transaction fee BOLT 2 splits fee responsibility for the shared transaction: the initiator pays for the common fields, and each peer pays for the inputs and outputs it contributed. SharedTransaction::local_fee_sat computes our share, which is what makes a change output's value computable before the output exists. The result is rounded up. BOLT 3 Appendix G's worked example has weight 609 at 253 sat/kw and states a fee of 155, not the 154 that truncating gives; underpaying by a single satoshi makes the peer fail the negotiation at tx_complete, which would silently kill every generated program. Witness weight is charged at 108 per input rather than Appendix G's minimum of 107, since our wallet inputs are P2WPKH and the appendix charges the maximum of the two. Overestimating is the safe direction: the peer fails the negotiation when our feerate falls short, never when it exceeds. --- smite/src/channel_tx/interactive_tx.rs | 171 +++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/smite/src/channel_tx/interactive_tx.rs b/smite/src/channel_tx/interactive_tx.rs index 11b91229..ddab042b 100644 --- a/smite/src/channel_tx/interactive_tx.rs +++ b/smite/src/channel_tx/interactive_tx.rs @@ -19,6 +19,32 @@ use super::funding::FundingTransaction; /// Maximum inputs in the constructed transaction (BOLT 2). pub const MAX_INPUTS: usize = 252; +/// Weight of the transaction fields the initiator alone pays for (BOLT 2): +/// `(input_count + output_count + version + locktime) * 4 + segwit marker and +/// flag`. +const COMMON_FIELDS_WEIGHT: u64 = (1 + 1 + 4 + 4) * 4 + 2; + +/// Weight of one input's non-witness fields: `txid + vout + scriptSig length + +/// sequence`, all outside the witness and so multiplied by four. +const INPUT_WEIGHT: u64 = (32 + 4 + 1 + 4) * 4; + +/// Weight of one output's fixed fields: `value + script length`. +const OUTPUT_BASE_WEIGHT: u64 = (8 + 1) * 4; + +/// Witness weight charged per input we contribute. +/// +/// BOLT 3 Appendix G charges `max(num_inputs * 107, actual witness weight)`, +/// where 107 is the minimum witness weight. Our wallet inputs are P2WPKH, whose +/// witness is `1` element count `+ 1 + sig + 1 + 33` pubkey; Bitcoin Core +/// grinds for a low-R signature, so `sig` is 71 bytes and the actual weight is +/// the same 107 the floor already charges. +/// +/// Charging 108 is a deliberate one-unit overpay, sized for the 72-byte +/// signature Core does not normally produce. It keeps the estimate on the +/// paying side of the requirement: the peer fails the negotiation when our +/// feerate falls short, never when it exceeds. +const WITNESS_WEIGHT_PER_INPUT: u64 = 108; + /// Maximum outputs in the constructed transaction (BOLT 2). pub const MAX_OUTPUTS: usize = 252; @@ -202,6 +228,52 @@ impl SharedTransaction { .fold(0u64, |acc, i| acc.saturating_add(i.value())) } + /// Fee we are responsible for at `feerate_per_kw`, in satoshis, **as the + /// initiator**. + /// + /// BOLT 2 splits fee responsibility: the initiator pays for the common + /// transaction fields, and each peer pays for the inputs and outputs it + /// contributed. This unconditionally charges both halves, which is correct + /// only while we are the initiator -- true for every caller today, since we + /// reach interactive construction by sending `open_channel2`. It stops + /// being true if `tx_init_rbf` is ever implemented, since an accepter that + /// initiates an RBF attempt becomes the initiator and takes the common + /// fields with it; splitting the two halves is the change to make then. + /// + /// `pending_output_script_lens` covers outputs we are about to add but have + /// not added yet, which is what makes a change output's value computable + /// before it exists. + /// + /// Rounds up. BOLT 3 Appendix G's worked example has weight 609 at 253 + /// sat/kw and states a fee of 155, not the 154 that truncating would give; + /// underpaying by a single satoshi makes the peer fail the negotiation. + #[must_use] + pub fn local_fee_sat(&self, feerate_per_kw: u32, pending_output_script_lens: &[usize]) -> u64 { + let local_inputs = self + .inputs + .values() + .filter(|i| i.contributor == Contributor::Local) + .count() as u64; + + let output_weight = self + .outputs + .values() + .filter(|o| o.contributor == Contributor::Local) + .map(|o| o.script_pubkey.len() as u64) + .chain(pending_output_script_lens.iter().map(|len| *len as u64)) + .map(|script_len| OUTPUT_BASE_WEIGHT + script_len * 4) + .sum::(); + + let weight = COMMON_FIELDS_WEIGHT + + local_inputs * INPUT_WEIGHT + + output_weight + + local_inputs * WITNESS_WEIGHT_PER_INPUT; + + weight + .saturating_mul(u64::from(feerate_per_kw)) + .div_ceil(1000) + } + /// Assembles the transaction both peers must agree on. /// /// Per BOLT 2 the inputs and outputs are sorted by ascending `serial_id`; @@ -583,6 +655,105 @@ e37d3280b2e60e0000000017a9147ecd1b519326bc13b0ec716e469b58ed02b112a087f0006bee00 assert!(shared.remove_output(9999).is_none()); } + // -- Fee responsibility -- + + #[test] + fn local_fee_matches_bolt3_appendix_g_opener() { + // Appendix G's opener contributes one input, the funding output and a + // change output, at 253 sat/kw, and owes 155 sat. + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(APPENDIX_G_LOCKTIME); + shared.add_input( + 20, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + shared.add_output( + 44, + SharedOutput { + value: APPENDIX_G_FUNDING_SATS, + script_pubkey: script(APPENDIX_G_FUNDING_SPK), + contributor: Contributor::Local, + }, + ); + + // The change output is not added yet; its script length is what makes + // its own value computable. + let change_script = script(APPENDIX_G_OPENER_CHANGE_SPK); + assert_eq!(shared.local_fee_sat(253, &[change_script.len()]), 155); + } + + #[test] + fn local_fee_rounds_up() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + + // Weight 42 + 164 + 108 = 314. At 1 sat/kw that is 0.314 sat, which + // must round up to 1 rather than down to 0. + assert_eq!(shared.local_fee_sat(1, &[]), 1); + // And 314 * 1000 / 1000 divides exactly. + assert_eq!(shared.local_fee_sat(1000, &[]), 314); + } + + #[test] + fn local_fee_ignores_the_peers_contributions() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + let ours = shared.local_fee_sat(253, &[]); + + // Each peer pays for what it contributed, so adding theirs must not + // change what we owe. + shared.add_input( + 1, + SharedInput::from_prevtx(&prevtx, 1, MAX_SEQUENCE, Contributor::Remote), + ); + shared.add_output( + 3, + SharedOutput { + value: 10_000, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }, + ); + + assert_eq!(shared.local_fee_sat(253, &[]), ours); + } + + #[test] + fn local_fee_covers_the_common_fields_with_no_contributions() { + // The initiator pays for version, locktime and the two counts even + // when it contributes nothing else: weight 42 at 1000 sat/kw. + assert_eq!(SharedTransaction::new(0).local_fee_sat(1000, &[]), 42); + } + + #[test] + fn local_fee_grows_with_each_input_and_output() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + let base = shared.local_fee_sat(1000, &[]); + + shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + let with_input = shared.local_fee_sat(1000, &[]); + assert_eq!(with_input - base, INPUT_WEIGHT + WITNESS_WEIGHT_PER_INPUT); + + let change_script = script(APPENDIX_G_OPENER_CHANGE_SPK); + let with_output = shared.local_fee_sat(1000, &[change_script.len()]); + assert_eq!( + with_output - with_input, + OUTPUT_BASE_WEIGHT + change_script.len() as u64 * 4, + ); + } + // -- tx_signatures ordering -- /// Two valid compressed points, ordered so that `LOW` sorts first.