From 7eb73fe82530292efa76c91084bd0ffc6912c9f3 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 4 Dec 2025 16:40:56 -0500 Subject: [PATCH 01/20] doc: Module description --- crates/tx-graph2/src/connectors/claim_contest_connector.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tx-graph2/src/connectors/claim_contest_connector.rs b/crates/tx-graph2/src/connectors/claim_contest_connector.rs index 8e4ff34ba..c55cc1ba3 100644 --- a/crates/tx-graph2/src/connectors/claim_contest_connector.rs +++ b/crates/tx-graph2/src/connectors/claim_contest_connector.rs @@ -1,4 +1,4 @@ -//! This module contains the connector between `Claim`, `UncontestedPayout`, and `Contest`. +//! This module contains the claim contest connector. use bitcoin::{ opcodes, From 5835d6beb18ea13631b9f3ef8a499968a670b88b Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 4 Dec 2025 15:22:07 -0500 Subject: [PATCH 02/20] feat: add prelude --- crates/tx-graph2/src/connectors/mod.rs | 1 + crates/tx-graph2/src/connectors/prelude.rs | 3 +++ crates/tx-graph2/src/transactions/claim.rs | 4 ++-- crates/tx-graph2/src/transactions/mod.rs | 1 + crates/tx-graph2/src/transactions/prelude.rs | 3 +++ 5 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 crates/tx-graph2/src/connectors/prelude.rs create mode 100644 crates/tx-graph2/src/transactions/prelude.rs diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index c98e7decf..b4ee23416 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -1,3 +1,4 @@ //! This module contains connectors for the Glock transaction graph. pub mod claim_contest_connector; +pub mod prelude; diff --git a/crates/tx-graph2/src/connectors/prelude.rs b/crates/tx-graph2/src/connectors/prelude.rs new file mode 100644 index 000000000..691e1ce60 --- /dev/null +++ b/crates/tx-graph2/src/connectors/prelude.rs @@ -0,0 +1,3 @@ +//! This module exports all connectors in this crate for convenience. + +pub use super::claim_contest_connector::*; diff --git a/crates/tx-graph2/src/transactions/claim.rs b/crates/tx-graph2/src/transactions/claim.rs index 8b2df6e7c..0a01b6263 100644 --- a/crates/tx-graph2/src/transactions/claim.rs +++ b/crates/tx-graph2/src/transactions/claim.rs @@ -1,10 +1,10 @@ //! This module contains the claim transaction. use bitcoin::{transaction, Amount, OutPoint, Transaction, TxOut}; -use strata_bridge_connectors::connector_cpfp::ConnectorCpfp; +use strata_bridge_connectors::prelude::ConnectorCpfp; use strata_bridge_primitives::scripts::prelude::{create_tx, create_tx_ins, create_tx_outs}; -use crate::connectors::claim_contest_connector::ClaimContestConnector; +use crate::connectors::prelude::ClaimContestConnector; /// Data that is needed to construct a [`ClaimTx`]. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] diff --git a/crates/tx-graph2/src/transactions/mod.rs b/crates/tx-graph2/src/transactions/mod.rs index 459b5a9f8..51246af98 100644 --- a/crates/tx-graph2/src/transactions/mod.rs +++ b/crates/tx-graph2/src/transactions/mod.rs @@ -1,3 +1,4 @@ //! This module contains the individual transactions of the Glock transaction graph. pub mod claim; +pub mod prelude; diff --git a/crates/tx-graph2/src/transactions/prelude.rs b/crates/tx-graph2/src/transactions/prelude.rs new file mode 100644 index 000000000..e4b95a962 --- /dev/null +++ b/crates/tx-graph2/src/transactions/prelude.rs @@ -0,0 +1,3 @@ +//! This module exports all transactions in this crate for convenience. + +pub use super::claim::*; From 7e1ee664381e3238c59ecf18995e6d2a090658de Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Fri, 5 Dec 2025 10:45:58 -0500 Subject: [PATCH 03/20] feat: Connector trait All connector outputs implement this trait. Most of the taproot internals are automatically implemented. The TaprootWitness struct represents a generic Taproot witness. I realize that this clashes with the TaprootWitness from primitives/scripts/taproot.rs. I feel the latter is ill-named or should be removed, since the "witness" doesn't contain any signature data. I plan to make a follow-up PR that revises the struct and that updates the affected Musig2 signing machinery. Let's discuss this. --- crates/tx-graph2/src/connectors/mod.rs | 133 +++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index b4ee23416..2a02d545a 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -2,3 +2,136 @@ pub mod claim_contest_connector; pub mod prelude; + +use bitcoin::{ + psbt::Input, + taproot::{LeafVersion, TaprootSpendInfo}, + Address, Amount, Network, ScriptBuf, TxOut, +}; +use secp256k1::{schnorr, XOnlyPublicKey}; +use strata_bridge_primitives::scripts::prelude::{create_taproot_addr, finalize_input, SpendPath}; +use strata_primitives::constants::UNSPENDABLE_PUBLIC_KEY; + +/// A connector output. +pub trait Connector { + /// Witness data that is required to spend the connector. + type Witness; + + /// Returns the network of the connector. + fn network(&self) -> Network; + + /// Returns the internal key of the connector. + /// + /// The key will be unspendable for connectors without a key path spend. + fn internal_key(&self) -> XOnlyPublicKey { + *UNSPENDABLE_PUBLIC_KEY + } + + /// Generates the vector of leaf scripts of the connector. + /// + /// The vector will be empty for connectors without script path spends. + fn leaf_scripts(&self) -> Vec { + Vec::new() + } + + /// Returns the value of the connector. + fn value(&self) -> Amount; + + /// Generates the address of the connector. + fn address(&self) -> Address { + create_taproot_addr( + &self.network(), + SpendPath::Both { + internal_key: self.internal_key(), + scripts: self.leaf_scripts().as_slice(), + }, + ) + .expect("tap tree is valid") + .0 + } + + /// Generates the transaction output of the connector. + fn output(&self) -> TxOut { + TxOut { + value: self.value(), + script_pubkey: self.address().script_pubkey(), + } + } + + /// Generates the taproot spend info of the connector. + fn spend_info(&self) -> TaprootSpendInfo { + // It seems wasteful to have almost the same function body as [`Connector::address`], + // but in practice we only ever need one of the two: the address or the spend info. + // We may want to reimplement `create_taproot_addr` to reduce code duplication. + create_taproot_addr( + &self.network(), + SpendPath::Both { + internal_key: self.internal_key(), + scripts: self.leaf_scripts().as_slice(), + }, + ) + .expect("tap tree is valid") + .1 + } + + /// Converts the witness into a generic taproot witness. + fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness; + + /// Finalizes the PSBT `input` where the connector is used, using the provided `witness`. + /// + /// # Warning + /// + /// If the connector uses relative timelocks, + /// then the **sequence** field of the transaction input + /// and the **locktime** field of the transaction must be updated accordingly. + fn finalize_input(&self, input: &mut Input, witness: &Self::Witness) { + match self.get_taproot_witness(witness) { + TaprootWitness::Key { + output_key_signature, + } => { + finalize_input(input, [output_key_signature.serialize().to_vec()]); + } + TaprootWitness::Script { + leaf_index, + script_inputs, + } => { + let mut leaf_scripts = self.leaf_scripts(); + assert!( + leaf_index < leaf_scripts.len(), + "leaf index should be within bounds" + ); + let leaf_script = leaf_scripts.swap_remove(leaf_index); + let script_ver = (leaf_script, LeafVersion::TapScript); + let taproot_spend_info = self.spend_info(); + let control_block = taproot_spend_info + .control_block(&script_ver) + .expect("leaf script exists"); + let leaf_script = script_ver.0; + + let mut witness = script_inputs; + witness.push(leaf_script.to_bytes()); + witness.push(control_block.serialize()); + finalize_input(input, witness); + } + } + } +} + +/// Generic Taproot witness data. +/// +/// The leaf script and control block are supplied by the connector. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TaprootWitness { + /// Key-path spend. + Key { + /// Signature of the output key. + output_key_signature: schnorr::Signature, + }, + /// Script-path spend + Script { + /// Leaf index. + leaf_index: usize, + /// Inputs to the leaf script. + script_inputs: Vec>, + }, +} From 414625ab25ebf3f3bd8a2e4095ed6ae738f39ccb Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Fri, 5 Dec 2025 10:57:41 -0500 Subject: [PATCH 04/20] refactor: Implement Connector for claim contest --- .../src/connectors/claim_contest_connector.rs | 111 +++++------------- crates/tx-graph2/src/connectors/mod.rs | 7 +- crates/tx-graph2/src/transactions/claim.rs | 2 +- 3 files changed, 37 insertions(+), 83 deletions(-) diff --git a/crates/tx-graph2/src/connectors/claim_contest_connector.rs b/crates/tx-graph2/src/connectors/claim_contest_connector.rs index c55cc1ba3..5ccb758b7 100644 --- a/crates/tx-graph2/src/connectors/claim_contest_connector.rs +++ b/crates/tx-graph2/src/connectors/claim_contest_connector.rs @@ -1,15 +1,9 @@ //! This module contains the claim contest connector. -use bitcoin::{ - opcodes, - psbt::Input, - relative, script, - taproot::{LeafVersion, TaprootSpendInfo}, - Network, ScriptBuf, TxOut, -}; +use bitcoin::{opcodes, relative, script, Amount, Network, ScriptBuf}; use secp256k1::{schnorr, XOnlyPublicKey}; -use strata_bridge_primitives::scripts::prelude::{create_taproot_addr, finalize_input, SpendPath}; -use strata_primitives::constants::UNSPENDABLE_PUBLIC_KEY; + +use crate::connectors::{Connector, TaprootWitness}; /// Connector output between `Claim` and: /// 1. `UncontestedPayout`, and @@ -50,9 +44,16 @@ impl ClaimContestConnector { pub const fn contest_timelock(&self) -> relative::LockTime { self.contest_timelock } +} + +impl Connector for ClaimContestConnector { + type Witness = ClaimContestWitness; + + fn network(&self) -> Network { + self.network + } - /// Generates a vector of all leaf scripts of the connector. - pub fn leaf_scripts(&self) -> Vec { + fn leaf_scripts(&self) -> Vec { let mut scripts = Vec::new(); for watchtower_pubkey in &self.watchtower_pubkeys { @@ -76,81 +77,29 @@ impl ClaimContestConnector { scripts } - /// Generates the bitcoin address and taproot spending info of the connector. - pub fn address_and_spend_info(&self) -> (bitcoin::Address, TaprootSpendInfo) { - let internal_key = *UNSPENDABLE_PUBLIC_KEY; - let scripts = self.leaf_scripts(); - create_taproot_addr( - &self.network, - SpendPath::Both { - internal_key, - scripts: scripts.as_slice(), - }, - ) - .expect("tap tree is valid") - } - - /// Generates the txout of the connector. - pub fn tx_out(&self) -> TxOut { - let script_pubkey = self.address_and_spend_info().0.script_pubkey(); - let minimal_non_dust = script_pubkey.minimal_non_dust(); + fn value(&self) -> Amount { + let minimal_non_dust = self.script_pubkey().minimal_non_dust(); // TODO (@uncomputable): Replace magic number 3 with constant from contest transaction, // once the code exists - let value = minimal_non_dust * (3 * self.n_watchtowers() as u64); - - TxOut { - value, - script_pubkey, - } + minimal_non_dust * (3 * self.n_watchtowers() as u64) } - /// Finalizes the PSBT `input` where the connector is used, using the provided `witness`. - /// - /// # Warning - /// - /// If [`ClaimContestSpendPath::Uncontested`] is used, then the sequence of the transaction - /// input must be set accordingly. Also, the global locktime has to be set accordingly. - pub fn finalize_input(&self, input: &mut Input, witness: ClaimContestWitness) { - let ClaimContestWitness { - n_of_n_signature, - spend_path, - } = witness; - let taproot_spend_info = self.address_and_spend_info().1; - let leaf_index = match spend_path { + fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness { + match witness.spend_path { ClaimContestSpendPath::Contested { watchtower_index, - watchtower_signature: _, - } => watchtower_index as usize, - ClaimContestSpendPath::Uncontested => self.n_watchtowers(), - }; - let leaf_script = self.leaf_scripts().remove(leaf_index); - let script_ver = (leaf_script, LeafVersion::TapScript); - let control_block = taproot_spend_info - .control_block(&script_ver) - .expect("leaf script exists"); - let leaf_script = script_ver.0; - - match spend_path { - ClaimContestSpendPath::Contested { - watchtower_index: _, watchtower_signature, - } => { - let witness = [ + } => TaprootWitness::Script { + leaf_index: watchtower_index as usize, + script_inputs: vec![ watchtower_signature.serialize().to_vec(), - n_of_n_signature.serialize().to_vec(), - leaf_script.to_bytes(), - control_block.serialize(), - ]; - finalize_input(input, witness); - } - ClaimContestSpendPath::Uncontested => { - let witness = [ - n_of_n_signature.serialize().to_vec(), - leaf_script.to_bytes(), - control_block.serialize(), - ]; - finalize_input(input, witness); - } + witness.n_of_n_signature.serialize().to_vec(), + ], + }, + ClaimContestSpendPath::Uncontested => TaprootWitness::Script { + leaf_index: self.n_watchtowers(), + script_inputs: vec![witness.n_of_n_signature.serialize().to_vec()], + }, } } } @@ -302,7 +251,7 @@ mod tests { let output = vec![ TxOut { value: funding_amount, - script_pubkey: connector.tx_out().script_pubkey, + script_pubkey: connector.script_pubkey(), }, TxOut { value: coinbase_amount - funding_amount - fees, @@ -390,11 +339,11 @@ mod tests { let mut psbt = Psbt::from_unsigned_tx(spending_tx).unwrap(); psbt.inputs[0].witness_utxo = Some(TxOut { value: funding_amount, - script_pubkey: connector.tx_out().script_pubkey, + script_pubkey: connector.script_pubkey(), }); let witness = signer.sign_leaf(leaf_index, sighash); - connector.finalize_input(&mut psbt.inputs[0], witness); + connector.finalize_input(&mut psbt.inputs[0], &witness); let spending_tx = psbt.extract_tx().expect("must be signed"); // Broadcast spending transaction diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 2a02d545a..8131954a2 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -50,8 +50,13 @@ pub trait Connector { .0 } + /// Generates the script pubkey of the connector. + fn script_pubkey(&self) -> ScriptBuf { + self.address().script_pubkey() + } + /// Generates the transaction output of the connector. - fn output(&self) -> TxOut { + fn tx_out(&self) -> TxOut { TxOut { value: self.value(), script_pubkey: self.address().script_pubkey(), diff --git a/crates/tx-graph2/src/transactions/claim.rs b/crates/tx-graph2/src/transactions/claim.rs index 0a01b6263..7c69574aa 100644 --- a/crates/tx-graph2/src/transactions/claim.rs +++ b/crates/tx-graph2/src/transactions/claim.rs @@ -4,7 +4,7 @@ use bitcoin::{transaction, Amount, OutPoint, Transaction, TxOut}; use strata_bridge_connectors::prelude::ConnectorCpfp; use strata_bridge_primitives::scripts::prelude::{create_tx, create_tx_ins, create_tx_outs}; -use crate::connectors::prelude::ClaimContestConnector; +use crate::connectors::{prelude::ClaimContestConnector, Connector}; /// Data that is needed to construct a [`ClaimTx`]. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] From f35060cfdad635dd5461f359c4ac01d48190ab89 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Fri, 5 Dec 2025 13:50:22 -0500 Subject: [PATCH 05/20] refactor: Generalize connector test utils This commit moves the connector testing code to test_utils.rs. The code is generic, using the new Signer trait. --- .../src/connectors/claim_contest_connector.rs | 235 +++--------------- crates/tx-graph2/src/connectors/mod.rs | 3 + crates/tx-graph2/src/connectors/test_utils.rs | 220 ++++++++++++++++ 3 files changed, 263 insertions(+), 195 deletions(-) create mode 100644 crates/tx-graph2/src/connectors/test_utils.rs diff --git a/crates/tx-graph2/src/connectors/claim_contest_connector.rs b/crates/tx-graph2/src/connectors/claim_contest_connector.rs index 5ccb758b7..e2e42b31c 100644 --- a/crates/tx-graph2/src/connectors/claim_contest_connector.rs +++ b/crates/tx-graph2/src/connectors/claim_contest_connector.rs @@ -38,9 +38,6 @@ impl ClaimContestConnector { } /// Returns the relative contest timelock of the connector. - /// - /// The sequence of the input and the global locktime must be - /// large enough to cover this value. pub const fn contest_timelock(&self) -> relative::LockTime { self.contest_timelock } @@ -124,6 +121,11 @@ pub enum ClaimContestSpendPath { watchtower_signature: schnorr::Signature, }, /// The connector is spent in the `UncontestedPayout` transaction. + /// + /// # Warning + /// + /// The sequence number of the transaction input needs to be large enough to cover + /// [`ClaimContestConnector::contest_timelock()`]. Uncontested, } @@ -131,57 +133,57 @@ pub enum ClaimContestSpendPath { mod tests { use std::cmp::Ordering; - use bitcoin::{ - absolute, consensus, - sighash::{Prevouts, SighashCache}, - transaction, Amount, BlockHash, OutPoint, Psbt, TapSighashType, Transaction, TxIn, TxOut, - }; - use bitcoind_async_client::types::SignRawTransactionWithWallet; - use corepc_node::{serde_json::json, Conf, Node}; use secp256k1::{Keypair, Message}; - use strata_bridge_common::logging::{self, LoggerConfig}; - use strata_bridge_primitives::scripts::taproot::create_script_spend_hash; use strata_bridge_test_utils::prelude::generate_keypair; - use tracing::info; use super::*; + use crate::connectors::test_utils::Signer; const N_WATCHTOWERS: usize = 10; const DELTA_CONTEST: relative::LockTime = relative::LockTime::from_height(10); - struct Signer { + struct ClaimContestSigner { n_of_n_keypair: Keypair, watchtower_keypairs: Vec, } - impl Signer { - fn generate() -> Self { - let n_of_n_keypair = generate_keypair(); - let watchtower_keypairs = (0..N_WATCHTOWERS).map(|_| generate_keypair()).collect(); + impl Signer for ClaimContestSigner { + type Connector = ClaimContestConnector; + fn generate() -> Self { Self { - n_of_n_keypair, - watchtower_keypairs, + n_of_n_keypair: generate_keypair(), + watchtower_keypairs: (0..N_WATCHTOWERS).map(|_| generate_keypair()).collect(), } } - fn get_connector(&self) -> ClaimContestConnector { - let n_of_n_pubkey = self.n_of_n_keypair.x_only_public_key().0; - let watchtower_pubkeys: Vec<_> = self - .watchtower_keypairs - .iter() - .map(|key| key.x_only_public_key().0) - .collect(); + fn get_connector(&self) -> Self::Connector { + ClaimContestConnector { + network: Network::Regtest, + n_of_n_pubkey: self.n_of_n_keypair.x_only_public_key().0, + watchtower_pubkeys: self + .watchtower_keypairs + .iter() + .map(|key| key.x_only_public_key().0) + .collect(), + contest_timelock: DELTA_CONTEST, + } + } + + fn get_connector_name(&self) -> &'static str { + "claim-contest" + } - ClaimContestConnector::new( - Network::Regtest, - n_of_n_pubkey, - watchtower_pubkeys, - DELTA_CONTEST, - ) + fn get_relative_timelock(&self, leaf_index: usize) -> Option { + (leaf_index == self.watchtower_keypairs.len()).then_some(DELTA_CONTEST) } - fn sign_leaf(&self, leaf_index: usize, sighash: Message) -> ClaimContestWitness { + fn sign_leaf( + &self, + leaf_index: Option, + sighash: Message, + ) -> ::Witness { + let leaf_index = leaf_index.expect("connector has no key-path spend"); let n_of_n_signature = self.n_of_n_keypair.sign_schnorr(sighash); match leaf_index.cmp(&self.watchtower_keypairs.len()) { @@ -206,172 +208,15 @@ mod tests { } } - fn spend_connector(connector: ClaimContestConnector, signer: Signer, leaf_index: usize) { - logging::init(LoggerConfig::new("claim-contest-connector".to_string())); - - // Setup Bitcoin node - let mut conf = Conf::default(); - conf.args.push("-txindex=1"); - let bitcoind = Node::with_conf("bitcoind", &conf).unwrap(); - let btc_client = &bitcoind.client; - - // Mine until maturity - let funded_address = btc_client.new_address().unwrap(); - let change_address = btc_client.new_address().unwrap(); - let coinbase_block = btc_client - .generate_to_address(101, &funded_address) - .expect("must be able to generate blocks") - .0 - .first() - .expect("must be able to get the blocks") - .parse::() - .expect("must parse"); - let coinbase_txid = btc_client - .get_block(coinbase_block) - .expect("must be able to get coinbase block") - .coinbase() - .expect("must be able to get the coinbase transaction") - .compute_txid(); - - // Create funding transaction - let funding_input = OutPoint { - txid: coinbase_txid, - vout: 0, - }; - - let coinbase_amount = Amount::from_btc(50.0).expect("must be valid amount"); - let funding_amount = Amount::from_sat(50_000); - let fees = Amount::from_sat(1_000); - - let input = vec![TxIn { - previous_output: funding_input, - ..Default::default() - }]; - - let output = vec![ - TxOut { - value: funding_amount, - script_pubkey: connector.script_pubkey(), - }, - TxOut { - value: coinbase_amount - funding_amount - fees, - script_pubkey: change_address.script_pubkey(), - }, - ]; - - let funding_tx = Transaction { - version: transaction::Version(2), - lock_time: absolute::LockTime::ZERO, - input, - output, - }; - - // Sign and broadcast funding transaction - let signed_funding_tx = btc_client - .call::( - "signrawtransactionwithwallet", - &[json!(consensus::encode::serialize_hex(&&funding_tx))], - ) - .expect("must be able to sign transaction"); - - assert!( - signed_funding_tx.complete, - "funding transaction should be complete" - ); - let signed_funding_tx = - consensus::encode::deserialize_hex(&signed_funding_tx.hex).expect("must deserialize"); - - let funding_txid = btc_client - .send_raw_transaction(&signed_funding_tx) - .expect("must be able to broadcast transaction") - .txid() - .expect("must have txid"); - - info!(%funding_txid, "Funding transaction broadcasted"); - - // Mine the funding transaction - let _ = btc_client - .generate_to_address(10, &funded_address) - .expect("must be able to generate blocks"); - - // Create spending transaction that spends the connector p - let spending_input = OutPoint { - txid: funding_txid, - vout: 0, - }; - - let spending_output = TxOut { - value: funding_amount - fees, - script_pubkey: change_address.script_pubkey(), - }; - - let mut spending_tx = Transaction { - version: transaction::Version(2), - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn { - previous_output: spending_input, - ..Default::default() - }], - output: vec![spending_output], - }; - - // Update locktime and sequence for uncontested spend - // This influences the sighash! - if leaf_index == connector.n_watchtowers() { - spending_tx.lock_time = - absolute::LockTime::from_consensus(connector.contest_timelock().to_consensus_u32()); - spending_tx.input[0].sequence = connector.contest_timelock().to_sequence(); - } - - let mut sighash_cache = SighashCache::new(&spending_tx); - let prevouts = [funding_tx.output[0].clone()]; - let leaf_scripts = connector.leaf_scripts(); - let sighash = create_script_spend_hash( - &mut sighash_cache, - &leaf_scripts[leaf_index], - Prevouts::All(&prevouts), - TapSighashType::Default, - 0, - ) - .expect("should be able to compute sighash"); - - // Set the witness in the transaction - let mut psbt = Psbt::from_unsigned_tx(spending_tx).unwrap(); - psbt.inputs[0].witness_utxo = Some(TxOut { - value: funding_amount, - script_pubkey: connector.script_pubkey(), - }); - - let witness = signer.sign_leaf(leaf_index, sighash); - connector.finalize_input(&mut psbt.inputs[0], &witness); - let spending_tx = psbt.extract_tx().expect("must be signed"); - - // Broadcast spending transaction - let spending_txid = btc_client - .send_raw_transaction(&spending_tx) - .expect("must be able to broadcast spending transaction") - .txid() - .expect("must have txid"); - - info!(%spending_txid, "Spending transaction broadcasted"); - - // Verify the transaction was mined - btc_client - .generate_to_address(1, &funded_address) - .expect("must be able to generate block"); - } - #[test] fn contested_spend() { - let signer = Signer::generate(); - let connector = signer.get_connector(); - spend_connector(connector, signer, 0); + let leaf_index = Some(0); + ClaimContestSigner::assert_connector_is_spendable(leaf_index); } #[test] fn uncontested_spend() { - let signer = Signer::generate(); - let connector = signer.get_connector(); - spend_connector(connector, signer, N_WATCHTOWERS); + let leaf_index = Some(N_WATCHTOWERS); + ClaimContestSigner::assert_connector_is_spendable(leaf_index); } } diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 8131954a2..ce565055b 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -3,6 +3,9 @@ pub mod claim_contest_connector; pub mod prelude; +#[cfg(test)] +pub mod test_utils; + use bitcoin::{ psbt::Input, taproot::{LeafVersion, TaprootSpendInfo}, diff --git a/crates/tx-graph2/src/connectors/test_utils.rs b/crates/tx-graph2/src/connectors/test_utils.rs new file mode 100644 index 000000000..30fe2ddd7 --- /dev/null +++ b/crates/tx-graph2/src/connectors/test_utils.rs @@ -0,0 +1,220 @@ +//! Utilities to test connectors. + +use bitcoin::{ + absolute, consensus, relative, + sighash::{Prevouts, SighashCache}, + transaction, Amount, BlockHash, OutPoint, Psbt, TapSighashType, Transaction, TxIn, TxOut, +}; +use bitcoind_async_client::types::SignRawTransactionWithWallet; +use corepc_node::{serde_json::json, Conf, Node}; +use strata_bridge_common::logging::{self, LoggerConfig}; +use strata_bridge_primitives::scripts::taproot::{create_key_spend_hash, create_script_spend_hash}; +use tracing::info; + +use crate::connectors::Connector; + +/// Generator of witness data for a given [`Connector`]. +pub trait Signer: Sized { + /// Connector of the signer. + type Connector: Connector; + + // TODO (@uncomputable) Replace with arbitrary::Arbitrary + /// Generates a random signer instance. + fn generate() -> Self; + + /// Generates the connector that corresponds to the signer. + fn get_connector(&self) -> Self::Connector; + + /// Returns the name of the connector. + fn get_connector_name(&self) -> &'static str; + + /// Returns the relative timelock for the given `leaf_index`, + /// if there is a timelock. + fn get_relative_timelock(&self, _leaf_index: usize) -> Option { + None + } + + /// Generates a witness for the given `leaf_index` using the provided `sighash`. + /// + /// # Warning + /// + /// The sighash has to be computed based on the chosen key-path or script-path spend. + fn sign_leaf( + &self, + leaf_index: Option, + sighash: secp256k1::Message, + ) -> ::Witness; + + /// Asserts that the connector is spendable at the given `leaf_index`. + /// + /// A random signer is generated using [`Signer::generate`]. + /// The signer generates the connector and a witness automatically. + /// Bitcoin Core is used to check transaction validity. + fn assert_connector_is_spendable(leaf_index: Option) { + let signer = Self::generate(); + + logging::init(LoggerConfig::new(format!( + "{}-connector", + signer.get_connector_name() + ))); + + let connector = signer.get_connector(); + + // Setup Bitcoin node + let mut conf = Conf::default(); + conf.args.push("-txindex=1"); + let bitcoind = Node::with_conf("bitcoind", &conf).unwrap(); + let btc_client = &bitcoind.client; + + // Mine until maturity + let funded_address = btc_client.new_address().unwrap(); + let change_address = btc_client.new_address().unwrap(); + let coinbase_block = btc_client + .generate_to_address(101, &funded_address) + .expect("must be able to generate blocks") + .0 + .first() + .expect("must be able to get the blocks") + .parse::() + .expect("must parse"); + let coinbase_txid = btc_client + .get_block(coinbase_block) + .expect("must be able to get coinbase block") + .coinbase() + .expect("must be able to get the coinbase transaction") + .compute_txid(); + + // Create funding transaction + let funding_input = OutPoint { + txid: coinbase_txid, + vout: 0, + }; + + let coinbase_amount = Amount::from_btc(50.0).expect("must be valid amount"); + let funding_amount = Amount::from_sat(50_000); + let fees = Amount::from_sat(1_000); + + let input = vec![TxIn { + previous_output: funding_input, + ..Default::default() + }]; + + let output = vec![ + TxOut { + value: funding_amount, + script_pubkey: connector.script_pubkey(), + }, + TxOut { + value: coinbase_amount - funding_amount - fees, + script_pubkey: change_address.script_pubkey(), + }, + ]; + + let funding_tx = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input, + output, + }; + + // Sign and broadcast funding transaction + let signed_funding_tx = btc_client + .call::( + "signrawtransactionwithwallet", + &[json!(consensus::encode::serialize_hex(&&funding_tx))], + ) + .expect("must be able to sign transaction"); + + assert!( + signed_funding_tx.complete, + "funding transaction should be complete" + ); + let signed_funding_tx = + consensus::encode::deserialize_hex(&signed_funding_tx.hex).expect("must deserialize"); + + let funding_txid = btc_client + .send_raw_transaction(&signed_funding_tx) + .expect("must be able to broadcast transaction") + .txid() + .expect("must have txid"); + + info!(%funding_txid, "Funding transaction broadcasted"); + + // Mine the funding transaction + let _ = btc_client + .generate_to_address(10, &funded_address) + .expect("must be able to generate blocks"); + + // Create spending transaction that spends the connector p + let spending_input = OutPoint { + txid: funding_txid, + vout: 0, + }; + + let spending_output = TxOut { + value: funding_amount - fees, + script_pubkey: change_address.script_pubkey(), + }; + + let mut spending_tx = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: spending_input, + ..Default::default() + }], + output: vec![spending_output], + }; + + // Update locktime and sequence + // This influences the sighash! + if let Some(timelock) = leaf_index.and_then(|i| signer.get_relative_timelock(i)) { + spending_tx.input[0].sequence = timelock.to_sequence(); + } + + let mut sighash_cache = SighashCache::new(&spending_tx); + let utxos = [funding_tx.output[0].clone()]; + let prevouts = Prevouts::All(&utxos); + let sighash_type = TapSighashType::Default; + let input_index = 0; + + let sighash = if let Some(leaf_index) = leaf_index { + let leaf_scripts = connector.leaf_scripts(); + create_script_spend_hash( + &mut sighash_cache, + &leaf_scripts[leaf_index], + prevouts, + sighash_type, + input_index, + ) + } else { + create_key_spend_hash(&mut sighash_cache, prevouts, sighash_type, input_index) + } + .expect("should be able to compute sighash"); + + // Set the witness in the transaction + let mut psbt = Psbt::from_unsigned_tx(spending_tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: funding_amount, + script_pubkey: connector.script_pubkey(), + }); + + let witness = signer.sign_leaf(leaf_index, sighash); + connector.finalize_input(&mut psbt.inputs[0], &witness); + let spending_tx = psbt.extract_tx().expect("must be signed"); + + // Broadcast spending transaction + let spending_txid = btc_client + .send_raw_transaction(&spending_tx) + .expect("must be able to broadcast spending transaction") + .txid() + .expect("must have txid"); + + info!(%spending_txid, "Spending transaction broadcasted"); + + // Verify the transaction was mined + btc_client + .generate_to_address(1, &funded_address) + .expect("must be able to generate block"); + } +} From 34a7d2fe50d76bf1737b698b066c0e4da304cc58 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 4 Dec 2025 16:41:10 -0500 Subject: [PATCH 06/20] feat: claim payout connector --- .../src/connectors/claim_payout_connector.rs | 196 ++++++++++++++++++ crates/tx-graph2/src/connectors/mod.rs | 1 + crates/tx-graph2/src/connectors/prelude.rs | 2 +- crates/tx-graph2/src/transactions/claim.rs | 19 +- 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 crates/tx-graph2/src/connectors/claim_payout_connector.rs diff --git a/crates/tx-graph2/src/connectors/claim_payout_connector.rs b/crates/tx-graph2/src/connectors/claim_payout_connector.rs new file mode 100644 index 000000000..c31d868b6 --- /dev/null +++ b/crates/tx-graph2/src/connectors/claim_payout_connector.rs @@ -0,0 +1,196 @@ +//! This module contains the claim payout connector. + +use bitcoin::{ + hashes::{sha256, Hash}, + opcodes, Amount, Network, ScriptBuf, +}; +use secp256k1::{schnorr, XOnlyPublicKey}; + +use crate::connectors::{Connector, TaprootWitness}; + +/// Connector output between `Claim` and the payouts. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct ClaimPayoutConnector { + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + admin_pubkey: XOnlyPublicKey, + unstaking_image: sha256::Hash, +} + +impl ClaimPayoutConnector { + /// Creates a new connector. + pub const fn new( + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + admin_pubkey: XOnlyPublicKey, + unstaking_image: sha256::Hash, + ) -> Self { + Self { + network, + n_of_n_pubkey, + admin_pubkey, + unstaking_image, + } + } +} + +impl Connector for ClaimPayoutConnector { + type Witness = ClaimPayoutWitness; + + fn network(&self) -> Network { + self.network + } + + fn internal_key(&self) -> XOnlyPublicKey { + self.n_of_n_pubkey + } + + fn leaf_scripts(&self) -> Vec { + let mut scripts = Vec::new(); + + let admin_burn_script = ScriptBuf::builder() + .push_slice(self.admin_pubkey.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIG) + .into_script(); + scripts.push(admin_burn_script); + + let unstaking_burn_script = ScriptBuf::builder() + .push_opcode(opcodes::all::OP_SIZE) + .push_int(0x20) + .push_opcode(opcodes::all::OP_EQUALVERIFY) + .push_opcode(opcodes::all::OP_SHA256) + .push_slice(self.unstaking_image.to_byte_array()) + .push_opcode(opcodes::all::OP_EQUAL) + .into_script(); + scripts.push(unstaking_burn_script); + + scripts + } + + fn value(&self) -> Amount { + self.script_pubkey().minimal_non_dust() + } + + fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness { + match witness { + ClaimPayoutWitness::Payout { + output_key_signature, + } => TaprootWitness::Key { + output_key_signature: *output_key_signature, + }, + ClaimPayoutWitness::AdminBurn { admin_signature } => TaprootWitness::Script { + leaf_index: 0, + script_inputs: vec![admin_signature.serialize().to_vec()], + }, + ClaimPayoutWitness::UnstakingBurn { unstaking_preimage } => TaprootWitness::Script { + leaf_index: 1, + script_inputs: vec![unstaking_preimage.to_vec()], + }, + } + } +} + +/// Witness data to spend a [`ClaimPayoutConnector`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum ClaimPayoutWitness { + /// The connector is spent in a payout transaction. + Payout { + /// Output key signature (key-path spend). + /// + /// The output key is the N/N key tweaked with the tap tree merkle root. + output_key_signature: schnorr::Signature, + }, + /// The connector is spent in the admin burn transaction. + AdminBurn { + /// Admin signature. + admin_signature: schnorr::Signature, + }, + /// The connector is spent in the unstaking burn transaction. + UnstakingBurn { + /// Preimage that is revealed when the operator posts the unstaking intent transaction. + unstaking_preimage: [u8; 32], + }, +} + +#[cfg(test)] +mod tests { + use bitcoin::key::TapTweak; + use secp256k1::{rand::random, Keypair, SECP256K1}; + use strata_bridge_test_utils::prelude::generate_keypair; + + use super::*; + use crate::connectors::test_utils::Signer; + + struct ClaimPayoutSigner { + n_of_n_keypair: Keypair, + admin_keypair: Keypair, + unstaking_preimage: [u8; 32], + } + + impl Signer for ClaimPayoutSigner { + type Connector = ClaimPayoutConnector; + + fn generate() -> Self { + Self { + n_of_n_keypair: generate_keypair(), + admin_keypair: generate_keypair(), + unstaking_preimage: random::<[u8; 32]>(), + } + } + + fn get_connector(&self) -> Self::Connector { + ClaimPayoutConnector::new( + Network::Regtest, + self.n_of_n_keypair.x_only_public_key().0, + self.admin_keypair.x_only_public_key().0, + sha256::Hash::hash(&self.unstaking_preimage), + ) + } + + fn get_connector_name(&self) -> &'static str { + "claim-payout" + } + + fn sign_leaf( + &self, + leaf_index: Option, + sighash: secp256k1::Message, + ) -> ::Witness { + match leaf_index { + None => { + let connector = self.get_connector(); + let merkle_root = connector.spend_info().merkle_root(); + let output_keypair = self + .n_of_n_keypair + .tap_tweak(SECP256K1, merkle_root) + .to_keypair(); + ClaimPayoutWitness::Payout { + output_key_signature: output_keypair.sign_schnorr(sighash), + } + } + Some(0) => ClaimPayoutWitness::AdminBurn { + admin_signature: self.admin_keypair.sign_schnorr(sighash), + }, + Some(1) => ClaimPayoutWitness::UnstakingBurn { + unstaking_preimage: self.unstaking_preimage, + }, + _ => panic!("Invalid tap leaf"), + } + } + } + + #[test] + fn payout_spend() { + ClaimPayoutSigner::assert_connector_is_spendable(None); + } + + #[test] + fn admin_burn_spend() { + ClaimPayoutSigner::assert_connector_is_spendable(Some(0)); + } + + #[test] + fn unstaking_burn_spend() { + ClaimPayoutSigner::assert_connector_is_spendable(Some(1)); + } +} diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index ce565055b..54835ce88 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -1,6 +1,7 @@ //! This module contains connectors for the Glock transaction graph. pub mod claim_contest_connector; +pub mod claim_payout_connector; pub mod prelude; #[cfg(test)] diff --git a/crates/tx-graph2/src/connectors/prelude.rs b/crates/tx-graph2/src/connectors/prelude.rs index 691e1ce60..8fd1a7d49 100644 --- a/crates/tx-graph2/src/connectors/prelude.rs +++ b/crates/tx-graph2/src/connectors/prelude.rs @@ -1,3 +1,3 @@ //! This module exports all connectors in this crate for convenience. -pub use super::claim_contest_connector::*; +pub use super::{claim_contest_connector::*, claim_payout_connector::*}; diff --git a/crates/tx-graph2/src/transactions/claim.rs b/crates/tx-graph2/src/transactions/claim.rs index 7c69574aa..1e90652ff 100644 --- a/crates/tx-graph2/src/transactions/claim.rs +++ b/crates/tx-graph2/src/transactions/claim.rs @@ -4,7 +4,10 @@ use bitcoin::{transaction, Amount, OutPoint, Transaction, TxOut}; use strata_bridge_connectors::prelude::ConnectorCpfp; use strata_bridge_primitives::scripts::prelude::{create_tx, create_tx_ins, create_tx_outs}; -use crate::connectors::{prelude::ClaimContestConnector, Connector}; +use crate::connectors::{ + prelude::{ClaimContestConnector, ClaimPayoutConnector}, + Connector, +}; /// Data that is needed to construct a [`ClaimTx`]. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] @@ -18,17 +21,20 @@ pub struct ClaimData { pub struct ClaimTx(Transaction); const CLAIM_CONTEST_VOUT: usize = 0; -const CLAIM_CPFP_VOUT: usize = 1; +const CLAIM_PAYOUT_VOUT: usize = 1; +const CLAIM_CPFP_VOUT: usize = 2; impl ClaimTx { /// Creates a claim transaction. pub fn new( data: ClaimData, claim_contest_connector: ClaimContestConnector, + claim_payout_connector: ClaimPayoutConnector, cpfp_connector: ConnectorCpfp, ) -> Self { let claim_funds = data.claim_funds; let claim_contest_tx_out = claim_contest_connector.tx_out(); + let claim_payout_tx_out = claim_payout_connector.tx_out(); let tx_ins = create_tx_ins([claim_funds]); let scripts_and_amounts = [ @@ -36,6 +42,10 @@ impl ClaimTx { claim_contest_tx_out.script_pubkey, claim_contest_tx_out.value, ), + ( + claim_payout_tx_out.script_pubkey, + claim_contest_tx_out.value, + ), (cpfp_connector.locking_script(), Amount::ZERO), ]; let tx_outs = create_tx_outs(scripts_and_amounts); @@ -56,6 +66,11 @@ impl ClaimTx { &self.0.output[CLAIM_CONTEST_VOUT] } + /// Accesses the payout transaction output. + pub fn payout_tx_out(&self) -> &TxOut { + &self.0.output[CLAIM_PAYOUT_VOUT] + } + /// Accesses the CPFP transaction output. pub fn cpfp_tx_out(&self) -> &TxOut { &self.0.output[CLAIM_CPFP_VOUT] From aa9a8b76031805a35518766523310b8441f3ed78 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Fri, 5 Dec 2025 14:41:49 -0500 Subject: [PATCH 07/20] feat: n-of-n connector --- crates/tx-graph2/src/connectors/mod.rs | 1 + .../src/connectors/n_of_n_connector.rs | 101 ++++++++++++++++++ crates/tx-graph2/src/connectors/prelude.rs | 2 +- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 crates/tx-graph2/src/connectors/n_of_n_connector.rs diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 54835ce88..da8064c47 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -2,6 +2,7 @@ pub mod claim_contest_connector; pub mod claim_payout_connector; +pub mod n_of_n_connector; pub mod prelude; #[cfg(test)] diff --git a/crates/tx-graph2/src/connectors/n_of_n_connector.rs b/crates/tx-graph2/src/connectors/n_of_n_connector.rs new file mode 100644 index 000000000..b94efbf9a --- /dev/null +++ b/crates/tx-graph2/src/connectors/n_of_n_connector.rs @@ -0,0 +1,101 @@ +//! This module contains a generic N/N connector. + +use bitcoin::{Amount, Network}; +use secp256k1::{schnorr, XOnlyPublicKey}; + +use crate::connectors::{Connector, TaprootWitness}; + +/// Generic N/N connector. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct NOfNConnector { + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + value: Amount, +} + +impl NOfNConnector { + /// Creates a new connector. + pub const fn new(network: Network, n_of_n_pubkey: XOnlyPublicKey, value: Amount) -> Self { + Self { + network, + n_of_n_pubkey, + value, + } + } +} + +impl Connector for NOfNConnector { + type Witness = schnorr::Signature; + + fn network(&self) -> Network { + self.network + } + + fn internal_key(&self) -> XOnlyPublicKey { + self.n_of_n_pubkey + } + + fn value(&self) -> Amount { + self.value + } + + fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness { + TaprootWitness::Key { + output_key_signature: *witness, + } + } +} + +#[cfg(test)] +mod tests { + use bitcoin::key::TapTweak; + use secp256k1::{Keypair, Message, SECP256K1}; + use strata_bridge_test_utils::prelude::generate_keypair; + + use super::*; + use crate::connectors::test_utils::{self, Signer}; + + const CONNECTOR_VALUE: bitcoin::Amount = bitcoin::Amount::from_sat(330); + + struct NOfNSigner(Keypair); + + impl test_utils::Signer for NOfNSigner { + type Connector = NOfNConnector; + + fn generate() -> Self { + Self(generate_keypair()) + } + + fn get_connector(&self) -> Self::Connector { + NOfNConnector { + network: Network::Regtest, + n_of_n_pubkey: self.0.x_only_public_key().0, + value: CONNECTOR_VALUE, + } + } + + fn get_connector_name(&self) -> &'static str { + "n-of-n" + } + + fn sign_leaf( + &self, + leaf_index: Option, + sighash: Message, + ) -> ::Witness { + assert!(leaf_index.is_none(), "connector has no script-path spend"); + + let connector = self.get_connector(); + let merkle_root = connector.spend_info().merkle_root(); + let output_keypair = self.0.tap_tweak(SECP256K1, merkle_root).to_keypair(); + + output_keypair.sign_schnorr(sighash) + } + } + + #[test] + fn n_of_n_spend() { + let leaf_index = None; + NOfNSigner::assert_connector_is_spendable(leaf_index); + } +} diff --git a/crates/tx-graph2/src/connectors/prelude.rs b/crates/tx-graph2/src/connectors/prelude.rs index 8fd1a7d49..05cb282d1 100644 --- a/crates/tx-graph2/src/connectors/prelude.rs +++ b/crates/tx-graph2/src/connectors/prelude.rs @@ -1,3 +1,3 @@ //! This module exports all connectors in this crate for convenience. -pub use super::{claim_contest_connector::*, claim_payout_connector::*}; +pub use super::{claim_contest_connector::*, claim_payout_connector::*, n_of_n_connector::*}; From 4f416c4cd953e872325590e063b8d76e400b04d6 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Fri, 5 Dec 2025 15:16:13 -0500 Subject: [PATCH 08/20] feat: CPFP connector --- .../src/connectors/cpfp_connector.rs | 95 +++++++++++++++++++ crates/tx-graph2/src/connectors/mod.rs | 1 + crates/tx-graph2/src/connectors/prelude.rs | 4 +- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 crates/tx-graph2/src/connectors/cpfp_connector.rs diff --git a/crates/tx-graph2/src/connectors/cpfp_connector.rs b/crates/tx-graph2/src/connectors/cpfp_connector.rs new file mode 100644 index 000000000..60f7454a7 --- /dev/null +++ b/crates/tx-graph2/src/connectors/cpfp_connector.rs @@ -0,0 +1,95 @@ +//! This module contains the CPFP connector. + +use bitcoin::{taproot::TaprootSpendInfo, Address, Amount, Network, ScriptBuf, WitnessProgram}; + +use crate::connectors::{Connector, TaprootWitness}; + +/// CPFP connector that uses the P2A locking script. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct CpfpConnector { + network: Network, +} + +impl CpfpConnector { + /// Creates a new connector. + pub const fn new(network: Network) -> Self { + Self { network } + } +} + +// We want to implement the [`Connector`] trait because it provides a unit testing interface. +// Because P2A is not a Taproot output, we have to be creative in how we implement the methods. +impl Connector for CpfpConnector { + type Witness = (); + + fn network(&self) -> Network { + self.network + } + + fn value(&self) -> Amount { + Amount::ZERO + } + + fn address(&self) -> Address { + Address::from_witness_program(WitnessProgram::p2a(), self.network) + } + + fn script_pubkey(&self) -> bitcoin::ScriptBuf { + let witness_program = WitnessProgram::p2a(); + ScriptBuf::new_witness_program(&witness_program) + } + + fn spend_info(&self) -> TaprootSpendInfo { + panic!("P2A is not a taproot output") + } + + fn get_taproot_witness(&self, _witness: &Self::Witness) -> TaprootWitness { + panic!("P2A is not a taproot output") + } + + fn finalize_input(&self, _input: &mut bitcoin::psbt::Input, _witness: &Self::Witness) { + // Do nothing + } +} + +#[cfg(test)] +mod tests { + use secp256k1::Message; + + use super::*; + use crate::connectors::test_utils::Signer; + + struct P2ASigner; + + impl Signer for P2ASigner { + type Connector = CpfpConnector; + + fn generate() -> Self { + Self + } + + fn get_connector(&self) -> Self::Connector { + CpfpConnector { + network: Network::Regtest, + } + } + + fn get_connector_name(&self) -> &'static str { + "p2a" + } + + fn sign_leaf( + &self, + _leaf_index: Option, + _sighash: Message, + ) -> ::Witness { + // Return unit + } + } + + #[test] + fn p2a_spend() { + let leaf_index = None; + P2ASigner::assert_connector_is_spendable(leaf_index); + } +} diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index da8064c47..32d99f18e 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -2,6 +2,7 @@ pub mod claim_contest_connector; pub mod claim_payout_connector; +pub mod cpfp_connector; pub mod n_of_n_connector; pub mod prelude; diff --git a/crates/tx-graph2/src/connectors/prelude.rs b/crates/tx-graph2/src/connectors/prelude.rs index 05cb282d1..a0e76088a 100644 --- a/crates/tx-graph2/src/connectors/prelude.rs +++ b/crates/tx-graph2/src/connectors/prelude.rs @@ -1,3 +1,5 @@ //! This module exports all connectors in this crate for convenience. -pub use super::{claim_contest_connector::*, claim_payout_connector::*, n_of_n_connector::*}; +pub use super::{ + claim_contest_connector::*, claim_payout_connector::*, cpfp_connector::*, n_of_n_connector::*, +}; From 5231092da5ca75eeff90d9f1824d263fecbb27de Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Fri, 5 Dec 2025 15:21:16 -0500 Subject: [PATCH 09/20] refactor: Use new CPFP connector Stop using old connectors crate as a dependency. --- Cargo.lock | 1 - crates/tx-graph2/Cargo.toml | 1 - crates/tx-graph2/src/transactions/claim.rs | 10 +++++----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48a5ff191..ad3471a1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9750,7 +9750,6 @@ dependencies = [ "corepc-node", "secp256k1", "strata-bridge-common", - "strata-bridge-connectors", "strata-bridge-primitives", "strata-bridge-test-utils", "strata-primitives 0.3.0-alpha.1 (git+https://github.com/alpenlabs/alpen.git?rev=dbffc47d8bde81fc713814ee314ea8ab86c98be6)", diff --git a/crates/tx-graph2/Cargo.toml b/crates/tx-graph2/Cargo.toml index 3301feb72..5d80e9f69 100644 --- a/crates/tx-graph2/Cargo.toml +++ b/crates/tx-graph2/Cargo.toml @@ -7,7 +7,6 @@ version = "0.1.0" workspace = true [dependencies] -strata-bridge-connectors.workspace = true strata-bridge-primitives.workspace = true strata-primitives.workspace = true diff --git a/crates/tx-graph2/src/transactions/claim.rs b/crates/tx-graph2/src/transactions/claim.rs index 1e90652ff..c2aadc88f 100644 --- a/crates/tx-graph2/src/transactions/claim.rs +++ b/crates/tx-graph2/src/transactions/claim.rs @@ -1,11 +1,10 @@ //! This module contains the claim transaction. -use bitcoin::{transaction, Amount, OutPoint, Transaction, TxOut}; -use strata_bridge_connectors::prelude::ConnectorCpfp; +use bitcoin::{transaction, OutPoint, Transaction, TxOut}; use strata_bridge_primitives::scripts::prelude::{create_tx, create_tx_ins, create_tx_outs}; use crate::connectors::{ - prelude::{ClaimContestConnector, ClaimPayoutConnector}, + prelude::{ClaimContestConnector, ClaimPayoutConnector, CpfpConnector}, Connector, }; @@ -30,11 +29,12 @@ impl ClaimTx { data: ClaimData, claim_contest_connector: ClaimContestConnector, claim_payout_connector: ClaimPayoutConnector, - cpfp_connector: ConnectorCpfp, + cpfp_connector: CpfpConnector, ) -> Self { let claim_funds = data.claim_funds; let claim_contest_tx_out = claim_contest_connector.tx_out(); let claim_payout_tx_out = claim_payout_connector.tx_out(); + let cpfp_tx_out = cpfp_connector.tx_out(); let tx_ins = create_tx_ins([claim_funds]); let scripts_and_amounts = [ @@ -46,7 +46,7 @@ impl ClaimTx { claim_payout_tx_out.script_pubkey, claim_contest_tx_out.value, ), - (cpfp_connector.locking_script(), Amount::ZERO), + (cpfp_tx_out.script_pubkey, cpfp_tx_out.value), ]; let tx_outs = create_tx_outs(scripts_and_amounts); From d51a9c10798c44cb39ecdfa09570dd1de82f07fb Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Mon, 8 Dec 2025 13:44:25 -0500 Subject: [PATCH 10/20] feat: timelocked N/N connector It turns out that half of the Glock connectors follow the same pattern: - some internal key - a single tap leaf: N/N + some timelock --- crates/tx-graph2/src/connectors/mod.rs | 1 + crates/tx-graph2/src/connectors/prelude.rs | 1 + .../src/connectors/timelocked_n_of_nr.rs | 301 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 crates/tx-graph2/src/connectors/timelocked_n_of_nr.rs diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 32d99f18e..357eef706 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -5,6 +5,7 @@ pub mod claim_payout_connector; pub mod cpfp_connector; pub mod n_of_n_connector; pub mod prelude; +pub mod timelocked_n_of_nr; #[cfg(test)] pub mod test_utils; diff --git a/crates/tx-graph2/src/connectors/prelude.rs b/crates/tx-graph2/src/connectors/prelude.rs index a0e76088a..081c805cb 100644 --- a/crates/tx-graph2/src/connectors/prelude.rs +++ b/crates/tx-graph2/src/connectors/prelude.rs @@ -2,4 +2,5 @@ pub use super::{ claim_contest_connector::*, claim_payout_connector::*, cpfp_connector::*, n_of_n_connector::*, + timelocked_n_of_nr::*, }; diff --git a/crates/tx-graph2/src/connectors/timelocked_n_of_nr.rs b/crates/tx-graph2/src/connectors/timelocked_n_of_nr.rs new file mode 100644 index 000000000..811f0492f --- /dev/null +++ b/crates/tx-graph2/src/connectors/timelocked_n_of_nr.rs @@ -0,0 +1,301 @@ +//! This module contains a generic timelocked N/N connector. +//! +//! The following connectors are built on top: +//! 1. contest proof, and +//! 2. contest payout, and +//! 3. contest slash, and +//! 4. counterproof_i. + +use std::num::NonZero; + +use bitcoin::{opcodes, relative, script, Amount, Network, ScriptBuf}; +use secp256k1::{schnorr, Scalar, XOnlyPublicKey, SECP256K1}; + +use crate::connectors::{Connector, TaprootWitness}; + +/// Generic connector output that is locked in a tap tree: +/// 1. (key path) internal key +/// 2. (single tap leaf) N/N + relative timelock. +/// +/// The internal key of the **contest proof** connector, +/// is the N/N key tweaked with the game index. +/// +/// The internal key of the **contest payout** connector +/// and of the **contest slash** connector is just the N/N key. +/// +/// The internal key of the **counterproof_i** connector is +/// `wt_i_fault * G`, where `G` is the generator point. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct TimelockedNOfNConnector { + network: Network, + internal_pubkey: XOnlyPublicKey, + n_of_n_pubkey: XOnlyPublicKey, + timelock: relative::LockTime, +} + +impl TimelockedNOfNConnector { + /// Creates a new connector. + pub const fn new( + network: Network, + internal_pubkey: XOnlyPublicKey, + n_of_n_pubkey: XOnlyPublicKey, + timelock: relative::LockTime, + ) -> Self { + Self { + network, + internal_pubkey, + n_of_n_pubkey, + timelock, + } + } + + /// Creates a new contest proof connector. + /// + /// # Panics + /// + /// The game index must be less than the secp curve order. + pub fn new_contest_proof( + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + operator_pubkey: XOnlyPublicKey, + game_index: NonZero, + proof_timelock: relative::LockTime, + ) -> Self { + let mut tweak_bytes = [0u8; 32]; + tweak_bytes[28..32].copy_from_slice(&game_index.get().to_be_bytes()); + let game_index_tweak = Scalar::from_be_bytes(tweak_bytes) + .expect("the game index must be less than the secp curve order"); + // This can only fail if the private key of operator_pubkey equals + // (curve_order - game_index), which is cryptographically impossible + // for honestly-generated keys. + let internal_pubkey = operator_pubkey + .add_tweak(SECP256K1, &game_index_tweak) + .expect("tweak is valid") + .0; + + Self { + network, + internal_pubkey, + n_of_n_pubkey, + timelock: proof_timelock, + } + } + + /// Creates a new contest payout connector. + pub const fn new_contest_payout( + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + ack_timelock: relative::LockTime, + ) -> Self { + Self { + network, + internal_pubkey: n_of_n_pubkey, + n_of_n_pubkey, + timelock: ack_timelock, + } + } + + /// Creates a new contest slash connector. + pub const fn new_contest_slash( + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + contested_payout_timelock: relative::LockTime, + ) -> Self { + Self { + network, + internal_pubkey: n_of_n_pubkey, + n_of_n_pubkey, + timelock: contested_payout_timelock, + } + } + + /// Creates a new counterproof_i connector. + pub const fn new_counterproof_i( + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + wt_i_fault_pubkey: XOnlyPublicKey, + nack_timelock: relative::LockTime, + ) -> Self { + Self { + network, + internal_pubkey: wt_i_fault_pubkey, + n_of_n_pubkey, + timelock: nack_timelock, + } + } + + /// Returns the relative timelock of the connector. + /// + /// - For the **contest proof** connector, this is the **proof** timelock. + /// - For the **contest payout** connector, this is the **ack** timelock. + /// - For the **contest slash** connector, this is the **contested payout** timelock. + /// - For the **counterproof_i** connector, this is the **nack** timelock. + pub const fn timelock(&self) -> relative::LockTime { + self.timelock + } +} + +impl Connector for TimelockedNOfNConnector { + type Witness = TimelockedNOfNWitness; + + fn network(&self) -> Network { + self.network + } + + fn internal_key(&self) -> XOnlyPublicKey { + self.internal_pubkey + } + + fn leaf_scripts(&self) -> Vec { + let mut scripts = Vec::new(); + + let payout_script = script::Builder::new() + .push_slice(self.n_of_n_pubkey.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) + .push_sequence(self.timelock.to_sequence()) + .push_opcode(opcodes::all::OP_CSV) + .into_script(); + scripts.push(payout_script); + + scripts + } + + fn value(&self) -> Amount { + self.script_pubkey().minimal_non_dust() + } + + fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness { + match witness { + TimelockedNOfNWitness::Normal { + output_key_signature, + } => TaprootWitness::Key { + output_key_signature: *output_key_signature, + }, + TimelockedNOfNWitness::Timeout { n_of_n_signature } => TaprootWitness::Script { + leaf_index: 0, + script_inputs: vec![n_of_n_signature.serialize().to_vec()], + }, + } + } +} + +/// Witness data to spend a [`TimelockedNOfNConnector`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum TimelockedNOfNWitness { + /// The connector is spent before the timeout. + /// + /// - The **contest proof** connector is spent in the `Bridge Proof` transaction. + /// - The **contest payout** connector is spent in the `Bridge Proof Timeout` transaction or in + /// the `Watchtower i Ack` transaction. + /// - The **contest slash** connector is spent in the `Contested Payout` transaction. + /// - The **counterproof_i** connector is spent in the `Watchtower i Nack` transaction. + Normal { + /// Output key signature (key-path spend). + /// + /// The output key is the internal key tweaked with the tap tree merkle root. + /// + /// The internal key of the... + /// - **contest proof** connector is the **N/N key tweaked with the game index**. + /// - **contest payout** connector is the **N/N** key. + /// - **contest slash** connector is the **N/N** key. + /// - **counterproof_i** connector is the `wt_i_fault * G` public key. + output_key_signature: schnorr::Signature, + }, + /// The connector is spent after the timeout. + /// + /// - The **contest proof** connector is spent in the `Bridge Proof Timeout` transaction. + /// - The **contest payout** connector is spent in the `Contested Payout` transaction. + /// - The **contest slash** connector is spent in the `Slash` transaction. + /// - The **counterproof_i** connector is spent in the `Watchtower i Ack` transaction. + /// + /// # Warning + /// + /// The sequence number of the transaction input needs to be large enough to cover + /// [`TimelockedNOfNConnector::timelock()`]. + Timeout { + /// N/N signature. + n_of_n_signature: schnorr::Signature, + }, +} + +#[cfg(test)] +mod tests { + use bitcoin::key::TapTweak; + use secp256k1::{Keypair, Message, SECP256K1}; + use strata_bridge_test_utils::prelude::generate_keypair; + + use super::*; + use crate::connectors::test_utils::Signer; + + const TIMELOCK: relative::LockTime = relative::LockTime::from_height(10); + + struct TimelockedNOfNSigner { + internal_keypair: Keypair, + n_of_n_keypair: Keypair, + } + + impl Signer for TimelockedNOfNSigner { + type Connector = TimelockedNOfNConnector; + + fn generate() -> Self { + Self { + internal_keypair: generate_keypair(), + n_of_n_keypair: generate_keypair(), + } + } + + fn get_connector(&self) -> Self::Connector { + TimelockedNOfNConnector { + network: Network::Regtest, + internal_pubkey: self.internal_keypair.x_only_public_key().0, + n_of_n_pubkey: self.n_of_n_keypair.x_only_public_key().0, + timelock: TIMELOCK, + } + } + + fn get_connector_name(&self) -> &'static str { + "timelocked-n-of-n" + } + + fn get_relative_timelock(&self, leaf_index: usize) -> Option { + (leaf_index == 0).then_some(TIMELOCK) + } + + fn sign_leaf( + &self, + leaf_index: Option, + sighash: Message, + ) -> ::Witness { + match leaf_index { + None => { + let connector = self.get_connector(); + let merkle_root = connector.spend_info().merkle_root(); + let output_keypair = self + .internal_keypair + .tap_tweak(SECP256K1, merkle_root) + .to_keypair(); + + TimelockedNOfNWitness::Normal { + output_key_signature: output_keypair.sign_schnorr(sighash), + } + } + Some(0) => TimelockedNOfNWitness::Timeout { + n_of_n_signature: self.n_of_n_keypair.sign_schnorr(sighash), + }, + Some(_) => panic!("Leaf index is out of bounds"), + } + } + } + + #[test] + fn normal_spend() { + let leaf_index = None; + TimelockedNOfNSigner::assert_connector_is_spendable(leaf_index); + } + + #[test] + fn timeout_spend() { + let leaf_index = Some(0); + TimelockedNOfNSigner::assert_connector_is_spendable(leaf_index); + } +} From efa853ca4b95d6c864b774ac1b50f228ea9e7b82 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Mon, 8 Dec 2025 14:54:12 -0500 Subject: [PATCH 11/20] chore: Rename X-connector -> X --- .../{claim_contest_connector.rs => claim_contest.rs} | 0 .../{claim_payout_connector.rs => claim_payout.rs} | 0 .../src/connectors/{cpfp_connector.rs => cpfp.rs} | 0 crates/tx-graph2/src/connectors/mod.rs | 8 ++++---- .../src/connectors/{n_of_n_connector.rs => n_of_n.rs} | 0 crates/tx-graph2/src/connectors/prelude.rs | 5 +---- 6 files changed, 5 insertions(+), 8 deletions(-) rename crates/tx-graph2/src/connectors/{claim_contest_connector.rs => claim_contest.rs} (100%) rename crates/tx-graph2/src/connectors/{claim_payout_connector.rs => claim_payout.rs} (100%) rename crates/tx-graph2/src/connectors/{cpfp_connector.rs => cpfp.rs} (100%) rename crates/tx-graph2/src/connectors/{n_of_n_connector.rs => n_of_n.rs} (100%) diff --git a/crates/tx-graph2/src/connectors/claim_contest_connector.rs b/crates/tx-graph2/src/connectors/claim_contest.rs similarity index 100% rename from crates/tx-graph2/src/connectors/claim_contest_connector.rs rename to crates/tx-graph2/src/connectors/claim_contest.rs diff --git a/crates/tx-graph2/src/connectors/claim_payout_connector.rs b/crates/tx-graph2/src/connectors/claim_payout.rs similarity index 100% rename from crates/tx-graph2/src/connectors/claim_payout_connector.rs rename to crates/tx-graph2/src/connectors/claim_payout.rs diff --git a/crates/tx-graph2/src/connectors/cpfp_connector.rs b/crates/tx-graph2/src/connectors/cpfp.rs similarity index 100% rename from crates/tx-graph2/src/connectors/cpfp_connector.rs rename to crates/tx-graph2/src/connectors/cpfp.rs diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 357eef706..cb59bd560 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -1,9 +1,9 @@ //! This module contains connectors for the Glock transaction graph. -pub mod claim_contest_connector; -pub mod claim_payout_connector; -pub mod cpfp_connector; -pub mod n_of_n_connector; +pub mod claim_contest; +pub mod claim_payout; +pub mod cpfp; +pub mod n_of_n; pub mod prelude; pub mod timelocked_n_of_nr; diff --git a/crates/tx-graph2/src/connectors/n_of_n_connector.rs b/crates/tx-graph2/src/connectors/n_of_n.rs similarity index 100% rename from crates/tx-graph2/src/connectors/n_of_n_connector.rs rename to crates/tx-graph2/src/connectors/n_of_n.rs diff --git a/crates/tx-graph2/src/connectors/prelude.rs b/crates/tx-graph2/src/connectors/prelude.rs index 081c805cb..696102b96 100644 --- a/crates/tx-graph2/src/connectors/prelude.rs +++ b/crates/tx-graph2/src/connectors/prelude.rs @@ -1,6 +1,3 @@ //! This module exports all connectors in this crate for convenience. -pub use super::{ - claim_contest_connector::*, claim_payout_connector::*, cpfp_connector::*, n_of_n_connector::*, - timelocked_n_of_nr::*, -}; +pub use super::{claim_contest::*, claim_payout::*, cpfp::*, n_of_n::*, timelocked_n_of_nr::*}; From bfe889e7c393e88502e93ce25f3737ca633ac08f Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Mon, 8 Dec 2025 17:36:51 -0500 Subject: [PATCH 12/20] feat: Support OP_CODESEPARATOR tests This commit extends the connector testing functionality to support leaf scripts that contain OP_CODESEPARATOR. --- crates/tx-graph2/src/connectors/mod.rs | 30 +++++ crates/tx-graph2/src/connectors/test_utils.rs | 127 +++++++++++++++--- 2 files changed, 136 insertions(+), 21 deletions(-) diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index cb59bd560..fee6592cc 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -11,7 +11,9 @@ pub mod timelocked_n_of_nr; pub mod test_utils; use bitcoin::{ + opcodes, psbt::Input, + script, taproot::{LeafVersion, TaprootSpendInfo}, Address, Amount, Network, ScriptBuf, TxOut, }; @@ -44,6 +46,34 @@ pub trait Connector { /// Returns the value of the connector. fn value(&self) -> Amount; + /// Returns a vector of all `OP_CODESEPARATOR` positions in the leaf script + /// at the given index. + /// + /// This method returns at least the default position `u32::MAX`, + /// followed by 1 position for each `OP_CODESEPARATOR` in the leaf script. + /// The returned vector is never empty. + /// + /// # Panics + /// + /// This method panics if the leaf index is out of bounds. + /// + /// # See + /// + /// [BIP 342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki#common-signature-message-extension). + fn code_separator_positions(&self, leaf_index: usize) -> Vec { + let script = &self.leaf_scripts()[leaf_index]; + let mut positions = vec![u32::MAX]; + + for (opcode_index, instruction) in script.instructions().enumerate() { + if let Ok(script::Instruction::Op(opcodes::all::OP_CODESEPARATOR)) = instruction { + // Cast safety: script will not be larger than u32::MAX + positions.push(opcode_index as u32); + } + } + + positions + } + /// Generates the address of the connector. fn address(&self) -> Address { create_taproot_addr( diff --git a/crates/tx-graph2/src/connectors/test_utils.rs b/crates/tx-graph2/src/connectors/test_utils.rs index 30fe2ddd7..3f2ffae7d 100644 --- a/crates/tx-graph2/src/connectors/test_utils.rs +++ b/crates/tx-graph2/src/connectors/test_utils.rs @@ -1,14 +1,18 @@ //! Utilities to test connectors. use bitcoin::{ - absolute, consensus, relative, + absolute, consensus, + hashes::Hash, + relative, sighash::{Prevouts, SighashCache}, - transaction, Amount, BlockHash, OutPoint, Psbt, TapSighashType, Transaction, TxIn, TxOut, + taproot::LeafVersion, + transaction, Amount, BlockHash, OutPoint, Psbt, TapLeafHash, TapSighashType, Transaction, TxIn, + TxOut, }; use bitcoind_async_client::types::SignRawTransactionWithWallet; use corepc_node::{serde_json::json, Conf, Node}; +use secp256k1::Message; use strata_bridge_common::logging::{self, LoggerConfig}; -use strata_bridge_primitives::scripts::taproot::{create_key_spend_hash, create_script_spend_hash}; use tracing::info; use crate::connectors::Connector; @@ -38,13 +42,76 @@ pub trait Signer: Sized { /// /// # Warning /// - /// The sighash has to be computed based on the chosen key-path or script-path spend. + /// The `sighash` has to be computed based on the chosen key-path or script-path spend. fn sign_leaf( &self, leaf_index: Option, - sighash: secp256k1::Message, + sighash: Message, ) -> ::Witness; + /// Generates a witness for the given `leaf_index` using the provided `sighashes`. + /// + /// # OP_CODESEPARATOR positions + /// + /// Each `OP_CHECKSIG(VERIFY)` operation checks a signature based on a sighash. + /// The sighash is computed based on the position of the last executed `OP_CODESEPARATOR`. + /// If there is no executed OP_CODESEPARATOR, then the position is set to `u32::MAX`. + /// + /// - All `OP_CHECKSIG(VERIFY)` operations in front of the first `OP_CODESEPARATOR` use position + /// `u32::MAX`. + /// - All `OP_CHECKSIG(VERIFY)` operations between the first and the second `OP_CODESEPARATOR` + /// use the position of the first `OP_CODESEPARATOR`. + /// - ... + /// - All `OP_CHECKSIG(VERIFY)` operations after the last `OP_CODESEPARATOR` use the position of + /// the last `OP_CODESEPARATOR`. + /// + /// # Calling this method + /// + /// `sighashes` should be computed via [`Connector::code_separator_positions()`]. + /// There should be 1 sighash per code separator position. + /// + /// # Implementing this method + /// + /// Assume there is 1 sighash per code separator position. + /// This includes the default position `u32::MAX` at the front! + /// + /// When multiple `OP_CHECKSIG(VERIFY)` operations are between the same code separators, + /// then they use the same sighash. Keep this in mind when creating signatures for the + /// respective `OP_CHECKSIG(VERIFY)` operation. + /// + /// In the following trivial example, `#1` and `#2` use the same sighash. + /// + /// ```text + /// OP_CHECKSIGVERIFY #1 + /// OP_CHECKSIG #2 + /// ``` + /// + /// In the following more elaborate example, `#2` and `#3` use the same sighash. + /// `#1` uses a different sighash. + /// + /// ```text + /// OP_CHECKSIGVERIFY #1 + /// OP_CODESEPARATOR + /// OP_CHECKSIGVERIFY #2 + /// OP_CHECKSIG #3 + /// ``` + /// + /// # See + /// + /// [BIP 342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki#common-signature-message-extension). + fn sign_leaf_with_code_separator( + &self, + leaf_index: Option, + sighashes: &[Message], + ) -> ::Witness { + dbg!(sighashes.len()); + assert!( + sighashes.len() == 1, + "You need to manually implement this method to handle multiple sighashes" + ); + self.sign_leaf(leaf_index, sighashes[0]) + } + /// Asserts that the connector is spendable at the given `leaf_index`. /// /// A random signer is generated using [`Signer::generate`]. @@ -166,31 +233,49 @@ pub trait Signer: Sized { output: vec![spending_output], }; - // Update locktime and sequence + // Update the sequence number // This influences the sighash! if let Some(timelock) = leaf_index.and_then(|i| signer.get_relative_timelock(i)) { spending_tx.input[0].sequence = timelock.to_sequence(); } let mut sighash_cache = SighashCache::new(&spending_tx); + + let input_index = 0; let utxos = [funding_tx.output[0].clone()]; let prevouts = Prevouts::All(&utxos); + let annex = None; let sighash_type = TapSighashType::Default; - let input_index = 0; - let sighash = if let Some(leaf_index) = leaf_index { - let leaf_scripts = connector.leaf_scripts(); - create_script_spend_hash( - &mut sighash_cache, - &leaf_scripts[leaf_index], - prevouts, - sighash_type, - input_index, - ) - } else { - create_key_spend_hash(&mut sighash_cache, prevouts, sighash_type, input_index) - } - .expect("should be able to compute sighash"); + // Key-path spend: There is 1 sighash. + // Script-path spend: There is 1 sighash for each code separator position. + let leaf_hash_code_separators: Vec<_> = match leaf_index { + Some(i) => { + let leaf_scripts = connector.leaf_scripts(); + let leaf_hash = TapLeafHash::from_script(&leaf_scripts[i], LeafVersion::TapScript); + connector + .code_separator_positions(i) + .into_iter() + .map(|pos| Some((leaf_hash, pos))) + .collect() + } + None => vec![None], + }; + let sighashes: Vec = leaf_hash_code_separators + .into_iter() + .map(|leaf_hash_code_separator| { + let sighash = sighash_cache + .taproot_signature_hash( + input_index, + &prevouts, + annex.clone(), + leaf_hash_code_separator, + sighash_type, + ) + .expect("should be able to compute sighash"); + Message::from_digest(sighash.to_raw_hash().to_byte_array()) + }) + .collect(); // Set the witness in the transaction let mut psbt = Psbt::from_unsigned_tx(spending_tx).unwrap(); @@ -199,7 +284,7 @@ pub trait Signer: Sized { script_pubkey: connector.script_pubkey(), }); - let witness = signer.sign_leaf(leaf_index, sighash); + let witness = signer.sign_leaf_with_code_separator(leaf_index, &sighashes); connector.finalize_input(&mut psbt.inputs[0], &witness); let spending_tx = psbt.extract_tx().expect("must be signed"); From fefa47ed7be1ff84e59e4e399ef3c5e72b573c52 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Mon, 8 Dec 2025 17:37:03 -0500 Subject: [PATCH 13/20] feat: Add contest counterproof output Because this output is only spent in one place of the transaction graph, it is strictly speaking not a connector. However, it is convenient to implement the Connector trait regardless. --- .../src/connectors/contest_counterproof.rs | 181 ++++++++++++++++++ crates/tx-graph2/src/connectors/mod.rs | 1 + 2 files changed, 182 insertions(+) create mode 100644 crates/tx-graph2/src/connectors/contest_counterproof.rs diff --git a/crates/tx-graph2/src/connectors/contest_counterproof.rs b/crates/tx-graph2/src/connectors/contest_counterproof.rs new file mode 100644 index 000000000..b00a216fd --- /dev/null +++ b/crates/tx-graph2/src/connectors/contest_counterproof.rs @@ -0,0 +1,181 @@ +//! This module contains the claim counterproof output. + +use std::num::NonZero; + +use bitcoin::{opcodes, script, Amount, Network, ScriptBuf}; +use secp256k1::{schnorr, XOnlyPublicKey}; + +use crate::connectors::{Connector, TaprootWitness}; + +/// Output between `Contest` and `Watchtower i Counterproof`. +/// +/// Strictly speaking, this is not a connector output. +/// However, we still implement the [`Connector`] trait for convenience. +/// +/// The output requires a series of operator signatures for spending. +/// Each operator signature comes from an adaptor, +/// which publishes one byte of Mosaic data. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct ContestCounterproofOutput { + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + operator_pubkey: XOnlyPublicKey, + n_data: NonZero, +} + +impl ContestCounterproofOutput { + /// Creates a new connector. + /// + /// `n_data` is the length of the data that will be published onchain. + /// This is equal to the number of required operator signatures. + pub const fn new( + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + operator_pubkey: XOnlyPublicKey, + n_data: NonZero, + ) -> Self { + Self { + network, + n_of_n_pubkey, + operator_pubkey, + n_data, + } + } + + /// Returns the length of the data that will be published onchain. + /// + /// This is 1 operator signature per byte of data. + pub const fn n_data(&self) -> NonZero { + self.n_data + } +} + +impl Connector for ContestCounterproofOutput { + type Witness = ContestCounterproofWitness; + + fn network(&self) -> Network { + self.network + } + + fn leaf_scripts(&self) -> Vec { + let mut builder = script::Builder::new() + .push_slice(self.n_of_n_pubkey.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) + .push_slice(self.operator_pubkey.serialize()); + + for _ in 0..self.n_data.get() - 1 { + builder = builder + .push_opcode(opcodes::all::OP_TUCK) + .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) + .push_opcode(opcodes::all::OP_CODESEPARATOR); + } + + let counterproof_script = builder.push_opcode(opcodes::all::OP_CHECKSIG).into_script(); + vec![counterproof_script] + } + + fn value(&self) -> Amount { + self.script_pubkey().minimal_non_dust() + } + + fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness { + TaprootWitness::Script { + leaf_index: 0, + script_inputs: witness + .operator_signatures + .iter() + .rev() + .map(|sig| sig.serialize().to_vec()) + .chain(std::iter::once( + witness.n_of_n_signature.serialize().to_vec(), + )) + .collect(), + } + } +} + +/// Witness data to spend a [`ContestCounterproofOutput`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ContestCounterproofWitness { + /// N/N signature. + pub n_of_n_signature: schnorr::Signature, + /// Operator signatures. + /// + /// There is 1 operator signature per byte of data. + pub operator_signatures: Vec, +} + +#[cfg(test)] +mod tests { + use secp256k1::Keypair; + use strata_bridge_test_utils::prelude::generate_keypair; + + use super::*; + use crate::connectors::test_utils::Signer; + + const N_DATA: NonZero = NonZero::new(10).unwrap(); + + struct ContestWatchtowerSigner { + n_of_n_keypair: Keypair, + operator_keypair: Keypair, + } + + impl Signer for ContestWatchtowerSigner { + type Connector = ContestCounterproofOutput; + + fn generate() -> Self { + Self { + n_of_n_keypair: generate_keypair(), + operator_keypair: generate_keypair(), + } + } + + fn get_connector(&self) -> Self::Connector { + ContestCounterproofOutput { + network: Network::Regtest, + n_of_n_pubkey: self.n_of_n_keypair.x_only_public_key().0, + operator_pubkey: self.operator_keypair.x_only_public_key().0, + n_data: N_DATA, + } + } + + fn get_connector_name(&self) -> &'static str { + "contest-counterproof" + } + + fn sign_leaf( + &self, + _leaf_index: Option, + _sighash: secp256k1::Message, + ) -> ::Witness { + unimplemented!("use sign_leaf_with_code_separator") + } + + fn sign_leaf_with_code_separator( + &self, + leaf_index: Option, + sighashes: &[secp256k1::Message], + ) -> ::Witness { + if leaf_index != Some(0) { + panic!("Unsupported leaf index"); + } + + let n_of_n_signature = self.n_of_n_keypair.sign_schnorr(sighashes[0]); + let operator_signatures = sighashes + .iter() + .copied() + .map(|sighash| self.operator_keypair.sign_schnorr(sighash)) + .collect(); + + ContestCounterproofWitness { + n_of_n_signature, + operator_signatures, + } + } + } + + #[test] + fn counterproof_spend() { + ContestWatchtowerSigner::assert_connector_is_spendable(Some(0)); + } +} diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index fee6592cc..158fa1c63 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -2,6 +2,7 @@ pub mod claim_contest; pub mod claim_payout; +pub mod contest_counterproof; pub mod cpfp; pub mod n_of_n; pub mod prelude; From aeda373becef068645b40bf02afbfdcb8d826a9c Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Tue, 9 Dec 2025 16:00:05 -0500 Subject: [PATCH 14/20] fix: pr comments --- .../tx-graph2/src/connectors/claim_payout.rs | 2 ++ .../src/connectors/contest_counterproof.rs | 5 ++-- crates/tx-graph2/src/connectors/mod.rs | 10 +++++--- crates/tx-graph2/src/connectors/prelude.rs | 2 +- crates/tx-graph2/src/connectors/test_utils.rs | 16 +++--------- ...locked_n_of_nr.rs => timelocked_n_of_n.rs} | 25 +++++++++++-------- 6 files changed, 30 insertions(+), 30 deletions(-) rename crates/tx-graph2/src/connectors/{timelocked_n_of_nr.rs => timelocked_n_of_n.rs} (93%) diff --git a/crates/tx-graph2/src/connectors/claim_payout.rs b/crates/tx-graph2/src/connectors/claim_payout.rs index c31d868b6..bcdb5b4cb 100644 --- a/crates/tx-graph2/src/connectors/claim_payout.rs +++ b/crates/tx-graph2/src/connectors/claim_payout.rs @@ -19,6 +19,8 @@ pub struct ClaimPayoutConnector { impl ClaimPayoutConnector { /// Creates a new connector. + /// + /// The preimage of `unstaking_image` must be 32 bytes long. pub const fn new( network: Network, n_of_n_pubkey: XOnlyPublicKey, diff --git a/crates/tx-graph2/src/connectors/contest_counterproof.rs b/crates/tx-graph2/src/connectors/contest_counterproof.rs index b00a216fd..846803bb2 100644 --- a/crates/tx-graph2/src/connectors/contest_counterproof.rs +++ b/crates/tx-graph2/src/connectors/contest_counterproof.rs @@ -9,9 +9,6 @@ use crate::connectors::{Connector, TaprootWitness}; /// Output between `Contest` and `Watchtower i Counterproof`. /// -/// Strictly speaking, this is not a connector output. -/// However, we still implement the [`Connector`] trait for convenience. -/// /// The output requires a series of operator signatures for spending. /// Each operator signature comes from an adaptor, /// which publishes one byte of Mosaic data. @@ -50,6 +47,8 @@ impl ContestCounterproofOutput { } } +// Strictly speaking, this is not a connector output. +// However, we still implement the [`Connector`] trait for convenience. impl Connector for ContestCounterproofOutput { type Witness = ContestCounterproofWitness; diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 158fa1c63..4ccce922d 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -6,7 +6,7 @@ pub mod contest_counterproof; pub mod cpfp; pub mod n_of_n; pub mod prelude; -pub mod timelocked_n_of_nr; +pub mod timelocked_n_of_n; #[cfg(test)] pub mod test_utils; @@ -103,6 +103,7 @@ pub trait Connector { /// Generates the taproot spend info of the connector. fn spend_info(&self) -> TaprootSpendInfo { + // Note (@uncomputable) // It seems wasteful to have almost the same function body as [`Connector::address`], // but in practice we only ever need one of the two: the address or the spend info. // We may want to reimplement `create_taproot_addr` to reduce code duplication. @@ -125,8 +126,11 @@ pub trait Connector { /// # Warning /// /// If the connector uses relative timelocks, - /// then the **sequence** field of the transaction input - /// and the **locktime** field of the transaction must be updated accordingly. + /// then the **sequence** field of the transaction input must be updated accordingly. + /// + /// # Panics + /// + /// This method panics if the leaf index in the `witness` is out of bounds. fn finalize_input(&self, input: &mut Input, witness: &Self::Witness) { match self.get_taproot_witness(witness) { TaprootWitness::Key { diff --git a/crates/tx-graph2/src/connectors/prelude.rs b/crates/tx-graph2/src/connectors/prelude.rs index 696102b96..9ca1c23aa 100644 --- a/crates/tx-graph2/src/connectors/prelude.rs +++ b/crates/tx-graph2/src/connectors/prelude.rs @@ -1,3 +1,3 @@ //! This module exports all connectors in this crate for convenience. -pub use super::{claim_contest::*, claim_payout::*, cpfp::*, n_of_n::*, timelocked_n_of_nr::*}; +pub use super::{claim_contest::*, claim_payout::*, cpfp::*, n_of_n::*, timelocked_n_of_n::*}; diff --git a/crates/tx-graph2/src/connectors/test_utils.rs b/crates/tx-graph2/src/connectors/test_utils.rs index 3f2ffae7d..c60f38e43 100644 --- a/crates/tx-graph2/src/connectors/test_utils.rs +++ b/crates/tx-graph2/src/connectors/test_utils.rs @@ -104,7 +104,6 @@ pub trait Signer: Sized { leaf_index: Option, sighashes: &[Message], ) -> ::Witness { - dbg!(sighashes.len()); assert!( sighashes.len() == 1, "You need to manually implement this method to handle multiple sighashes" @@ -288,18 +287,9 @@ pub trait Signer: Sized { connector.finalize_input(&mut psbt.inputs[0], &witness); let spending_tx = psbt.extract_tx().expect("must be signed"); - // Broadcast spending transaction - let spending_txid = btc_client - .send_raw_transaction(&spending_tx) - .expect("must be able to broadcast spending transaction") - .txid() - .expect("must have txid"); - - info!(%spending_txid, "Spending transaction broadcasted"); - - // Verify the transaction was mined + // Broadcast the spending transaction btc_client - .generate_to_address(1, &funded_address) - .expect("must be able to generate block"); + .send_raw_transaction(&spending_tx) + .expect("must be able to broadcast spending transaction"); } } diff --git a/crates/tx-graph2/src/connectors/timelocked_n_of_nr.rs b/crates/tx-graph2/src/connectors/timelocked_n_of_n.rs similarity index 93% rename from crates/tx-graph2/src/connectors/timelocked_n_of_nr.rs rename to crates/tx-graph2/src/connectors/timelocked_n_of_n.rs index 811f0492f..33aa2b85a 100644 --- a/crates/tx-graph2/src/connectors/timelocked_n_of_nr.rs +++ b/crates/tx-graph2/src/connectors/timelocked_n_of_n.rs @@ -16,15 +16,6 @@ use crate::connectors::{Connector, TaprootWitness}; /// Generic connector output that is locked in a tap tree: /// 1. (key path) internal key /// 2. (single tap leaf) N/N + relative timelock. -/// -/// The internal key of the **contest proof** connector, -/// is the N/N key tweaked with the game index. -/// -/// The internal key of the **contest payout** connector -/// and of the **contest slash** connector is just the N/N key. -/// -/// The internal key of the **counterproof_i** connector is -/// `wt_i_fault * G`, where `G` is the generator point. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub struct TimelockedNOfNConnector { network: Network, @@ -51,9 +42,17 @@ impl TimelockedNOfNConnector { /// Creates a new contest proof connector. /// + /// The internal key is the operator key tweaked with the game index. + /// + /// # Operator key tweak + /// + /// The game index is used as the least significant bytes of a secp scalar (all big endian). + /// This means that the numeric value of the game index is equal to the numeric value of the + /// resulting scalar. The scalar is then used to tweak the operator key. + /// /// # Panics /// - /// The game index must be less than the secp curve order. + /// This method panics if the game index is greater than or equal to the secp curve order. pub fn new_contest_proof( network: Network, n_of_n_pubkey: XOnlyPublicKey, @@ -82,6 +81,8 @@ impl TimelockedNOfNConnector { } /// Creates a new contest payout connector. + /// + /// The internal key is the N/N key. pub const fn new_contest_payout( network: Network, n_of_n_pubkey: XOnlyPublicKey, @@ -96,6 +97,8 @@ impl TimelockedNOfNConnector { } /// Creates a new contest slash connector. + /// + /// The internal key is the N/N key. pub const fn new_contest_slash( network: Network, n_of_n_pubkey: XOnlyPublicKey, @@ -110,6 +113,8 @@ impl TimelockedNOfNConnector { } /// Creates a new counterproof_i connector. + /// + /// The internal key is `wt_i_fault * G`, where `G` is the generator point. pub const fn new_counterproof_i( network: Network, n_of_n_pubkey: XOnlyPublicKey, From f6e9f01ac697753379bbee0be8b60a2b9b9346b3 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Wed, 10 Dec 2025 10:33:52 -0500 Subject: [PATCH 15/20] doc: address pr comments --- crates/tx-graph2/src/connectors/mod.rs | 10 +++++++--- crates/tx-graph2/src/connectors/test_utils.rs | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 4ccce922d..9dda2d678 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -50,9 +50,9 @@ pub trait Connector { /// Returns a vector of all `OP_CODESEPARATOR` positions in the leaf script /// at the given index. /// - /// This method returns at least the default position `u32::MAX`, - /// followed by 1 position for each `OP_CODESEPARATOR` in the leaf script. - /// The returned vector is never empty. + /// The vector starts with the default position `u32::MAX`, + /// followed by the position of each code separator in order. + /// The vector is never empty. /// /// # Panics /// @@ -62,6 +62,10 @@ pub trait Connector { /// /// [BIP 342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki#common-signature-message-extension). fn code_separator_positions(&self, leaf_index: usize) -> Vec { + // Note (@uncomputable) + // The default position u32::MAX is included to facilitate signing. + // Using the return value of code_separator_positions() for signing always works. + // It generalizes nicely; we don't have to remind callers to include u32::MAX. let script = &self.leaf_scripts()[leaf_index]; let mut positions = vec![u32::MAX]; diff --git a/crates/tx-graph2/src/connectors/test_utils.rs b/crates/tx-graph2/src/connectors/test_utils.rs index c60f38e43..daf4c53fb 100644 --- a/crates/tx-graph2/src/connectors/test_utils.rs +++ b/crates/tx-graph2/src/connectors/test_utils.rs @@ -49,6 +49,8 @@ pub trait Signer: Sized { sighash: Message, ) -> ::Witness; + // TODO (@uncomputable) Consider moving this doc to the method + // that computes the sighashes of a whole transaction. /// Generates a witness for the given `leaf_index` using the provided `sighashes`. /// /// # OP_CODESEPARATOR positions From c17e78f95cd5fb2db79bd5855a6b63740867fcd1 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 11 Dec 2025 11:29:55 -0500 Subject: [PATCH 16/20] doc: pr comment --- crates/tx-graph2/src/connectors/contest_counterproof.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tx-graph2/src/connectors/contest_counterproof.rs b/crates/tx-graph2/src/connectors/contest_counterproof.rs index 846803bb2..1d90750f6 100644 --- a/crates/tx-graph2/src/connectors/contest_counterproof.rs +++ b/crates/tx-graph2/src/connectors/contest_counterproof.rs @@ -100,7 +100,7 @@ pub struct ContestCounterproofWitness { pub n_of_n_signature: schnorr::Signature, /// Operator signatures. /// - /// There is 1 operator signature per byte of data. + /// There is 1 operator signature per byte of data that will be published onchain. pub operator_signatures: Vec, } From dc2ed1bb5f5c9acfd2302e4a63c5feac001723de Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 11 Dec 2025 11:53:31 -0500 Subject: [PATCH 17/20] feat: compute sighash of connectors These methods will be useful to make signartures for testing and for production. Splitting the normal method from the code separator one keeps the code simple. In the following commit, I will refactor the testing infrastructure of connectors. --- crates/tx-graph2/src/connectors/mod.rs | 106 ++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index 9dda2d678..be077e09c 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -12,14 +12,18 @@ pub mod timelocked_n_of_n; pub mod test_utils; use bitcoin::{ + hashes::Hash, opcodes, psbt::Input, script, - taproot::{LeafVersion, TaprootSpendInfo}, - Address, Amount, Network, ScriptBuf, TxOut, + sighash::{Prevouts, SighashCache}, + taproot::{LeafVersion, TapLeafHash, TaprootSpendInfo}, + Address, Amount, Network, ScriptBuf, TapSighashType, Transaction, TxOut, +}; +use secp256k1::{schnorr, Message, XOnlyPublicKey}; +use strata_bridge_primitives::scripts::prelude::{ + create_key_spend_hash, create_script_spend_hash, create_taproot_addr, finalize_input, SpendPath, }; -use secp256k1::{schnorr, XOnlyPublicKey}; -use strata_bridge_primitives::scripts::prelude::{create_taproot_addr, finalize_input, SpendPath}; use strata_primitives::constants::UNSPENDABLE_PUBLIC_KEY; /// A connector output. @@ -122,6 +126,100 @@ pub trait Connector { .1 } + /// Computes the sighash of an input that spends the connector. + fn compute_sighash( + &self, + leaf_index: Option, + cache: &mut SighashCache<&Transaction>, + prevouts: Prevouts<'_, TxOut>, + input_index: usize, + ) -> Message { + // Note (@uncomputable) + // All of our transactions use SIGHASH_ALL aka SIGHASH_DEFAULT. + // There is no reason to make the sighash type variable. + let sighash_type = TapSighashType::Default; + match leaf_index { + None => create_key_spend_hash(cache, prevouts, sighash_type, input_index), + Some(leaf_index) => { + let leaf_script = &self.leaf_scripts()[leaf_index]; + create_script_spend_hash(cache, leaf_script, prevouts, sighash_type, input_index) + } + } + .expect("should be able to compute the sighash") + } + + /// Computes the sighashes of an input that spends the connector, + /// taking into account the code separator positions. + /// + /// # Code separator positions + /// + /// Each `OP_CHECKSIG(VERIFY)` operation checks a signature based on a sighash. + /// The sighash is computed based on the position of the last executed `OP_CODESEPARATOR`. + /// + /// - All `OP_CHECKSIG(VERIFY)` operations in front of the first `OP_CODESEPARATOR` use the + /// default position `u32::MAX`. + /// - All `OP_CHECKSIG(VERIFY)` operations between the first and the second `OP_CODESEPARATOR` + /// use the position of the first `OP_CODESEPARATOR`. + /// - ... + /// - All `OP_CHECKSIG(VERIFY)` operations after the last `OP_CODESEPARATOR` use the position of + /// the last `OP_CODESEPARATOR`. + /// + /// # Choosing the right sighash + /// + /// When multiple `OP_CHECKSIG(VERIFY)` operations are between the same code separators, + /// then they use the same sighash. + /// + /// In the following trivial example, `#1` and `#2` use the same sighash. + /// + /// ```text + /// OP_CHECKSIGVERIFY #1 + /// OP_CHECKSIG #2 + /// ``` + /// + /// In the following more elaborate example, `#2` and `#3` use the same sighash. + /// `#1` uses a different sighash. + /// + /// ```text + /// OP_CHECKSIGVERIFY #1 + /// OP_CODESEPARATOR + /// OP_CHECKSIGVERIFY #2 + /// OP_CHECKSIG #3 + /// ``` + /// + /// # See + /// + /// [BIP 342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki#common-signature-message-extension). + fn compute_sighashes_with_code_separator( + &self, + leaf_index: usize, + cache: &mut SighashCache<&Transaction>, + prevouts: Prevouts<'_, TxOut>, + input_index: usize, + ) -> Vec { + // Note (@uncomputable) + // All of our transactions use SIGHASH_ALL aka SIGHASH_DEFAULT. + // There is no reason to make the sighash type variable. + let sighash_type = TapSighashType::Default; + let leaf_script = &self.leaf_scripts()[leaf_index]; + let leaf_hash = TapLeafHash::from_script(leaf_script, LeafVersion::TapScript); + self.code_separator_positions(leaf_index) + .into_iter() + .map(|pos| Some((leaf_hash, pos))) + .map(|leaf_hash_code_separator| { + let sighash = cache + .taproot_signature_hash( + input_index, + &prevouts, + None, + leaf_hash_code_separator, + sighash_type, + ) + .expect("should be able to compute the sighash"); + Message::from_digest(sighash.to_raw_hash().to_byte_array()) + }) + .collect() + } + /// Converts the witness into a generic taproot witness. fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness; From a3e24af8d68ad5696de4375ceb8d184a8f0051f0 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 11 Dec 2025 12:03:35 -0500 Subject: [PATCH 18/20] refactor: Test connectors with BitcoinNode struct This struct makes it easy to play transactions on Bitcoin Core. The RPC calls are abstracted away, to reduce code duplication. The coinbase outpoints are an easy way to fund transactions. The wallet automatically signs its inputs. This commit temporarily disables the code separator and cpfp tests. I will refactor these tests in the following commits. --- .../src/connectors/contest_counterproof.rs | 23 +- crates/tx-graph2/src/connectors/cpfp.rs | 1 + crates/tx-graph2/src/connectors/test_utils.rs | 415 +++++++++--------- 3 files changed, 210 insertions(+), 229 deletions(-) diff --git a/crates/tx-graph2/src/connectors/contest_counterproof.rs b/crates/tx-graph2/src/connectors/contest_counterproof.rs index 1d90750f6..fb91146f0 100644 --- a/crates/tx-graph2/src/connectors/contest_counterproof.rs +++ b/crates/tx-graph2/src/connectors/contest_counterproof.rs @@ -149,31 +149,10 @@ mod tests { ) -> ::Witness { unimplemented!("use sign_leaf_with_code_separator") } - - fn sign_leaf_with_code_separator( - &self, - leaf_index: Option, - sighashes: &[secp256k1::Message], - ) -> ::Witness { - if leaf_index != Some(0) { - panic!("Unsupported leaf index"); - } - - let n_of_n_signature = self.n_of_n_keypair.sign_schnorr(sighashes[0]); - let operator_signatures = sighashes - .iter() - .copied() - .map(|sighash| self.operator_keypair.sign_schnorr(sighash)) - .collect(); - - ContestCounterproofWitness { - n_of_n_signature, - operator_signatures, - } - } } #[test] + #[ignore] fn counterproof_spend() { ContestWatchtowerSigner::assert_connector_is_spendable(Some(0)); } diff --git a/crates/tx-graph2/src/connectors/cpfp.rs b/crates/tx-graph2/src/connectors/cpfp.rs index 60f7454a7..7f9b12e49 100644 --- a/crates/tx-graph2/src/connectors/cpfp.rs +++ b/crates/tx-graph2/src/connectors/cpfp.rs @@ -88,6 +88,7 @@ mod tests { } #[test] + #[ignore] fn p2a_spend() { let leaf_index = None; P2ASigner::assert_connector_is_spendable(leaf_index); diff --git a/crates/tx-graph2/src/connectors/test_utils.rs b/crates/tx-graph2/src/connectors/test_utils.rs index daf4c53fb..c6a2ee505 100644 --- a/crates/tx-graph2/src/connectors/test_utils.rs +++ b/crates/tx-graph2/src/connectors/test_utils.rs @@ -1,18 +1,17 @@ //! Utilities to test connectors. +use std::collections::VecDeque; + use bitcoin::{ - absolute, consensus, - hashes::Hash, - relative, + absolute, consensus, relative, sighash::{Prevouts, SighashCache}, - taproot::LeafVersion, - transaction, Amount, BlockHash, OutPoint, Psbt, TapLeafHash, TapSighashType, Transaction, TxIn, - TxOut, + transaction, Address, Amount, BlockHash, OutPoint, Psbt, Transaction, TxOut, Txid, }; use bitcoind_async_client::types::SignRawTransactionWithWallet; -use corepc_node::{serde_json::json, Conf, Node}; +use corepc_node::{serde_json::json, Client, Conf, Node}; use secp256k1::Message; use strata_bridge_common::logging::{self, LoggerConfig}; +use strata_bridge_primitives::scripts::prelude::create_tx_ins; use tracing::info; use crate::connectors::Connector; @@ -49,70 +48,6 @@ pub trait Signer: Sized { sighash: Message, ) -> ::Witness; - // TODO (@uncomputable) Consider moving this doc to the method - // that computes the sighashes of a whole transaction. - /// Generates a witness for the given `leaf_index` using the provided `sighashes`. - /// - /// # OP_CODESEPARATOR positions - /// - /// Each `OP_CHECKSIG(VERIFY)` operation checks a signature based on a sighash. - /// The sighash is computed based on the position of the last executed `OP_CODESEPARATOR`. - /// If there is no executed OP_CODESEPARATOR, then the position is set to `u32::MAX`. - /// - /// - All `OP_CHECKSIG(VERIFY)` operations in front of the first `OP_CODESEPARATOR` use position - /// `u32::MAX`. - /// - All `OP_CHECKSIG(VERIFY)` operations between the first and the second `OP_CODESEPARATOR` - /// use the position of the first `OP_CODESEPARATOR`. - /// - ... - /// - All `OP_CHECKSIG(VERIFY)` operations after the last `OP_CODESEPARATOR` use the position of - /// the last `OP_CODESEPARATOR`. - /// - /// # Calling this method - /// - /// `sighashes` should be computed via [`Connector::code_separator_positions()`]. - /// There should be 1 sighash per code separator position. - /// - /// # Implementing this method - /// - /// Assume there is 1 sighash per code separator position. - /// This includes the default position `u32::MAX` at the front! - /// - /// When multiple `OP_CHECKSIG(VERIFY)` operations are between the same code separators, - /// then they use the same sighash. Keep this in mind when creating signatures for the - /// respective `OP_CHECKSIG(VERIFY)` operation. - /// - /// In the following trivial example, `#1` and `#2` use the same sighash. - /// - /// ```text - /// OP_CHECKSIGVERIFY #1 - /// OP_CHECKSIG #2 - /// ``` - /// - /// In the following more elaborate example, `#2` and `#3` use the same sighash. - /// `#1` uses a different sighash. - /// - /// ```text - /// OP_CHECKSIGVERIFY #1 - /// OP_CODESEPARATOR - /// OP_CHECKSIGVERIFY #2 - /// OP_CHECKSIG #3 - /// ``` - /// - /// # See - /// - /// [BIP 342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki#common-signature-message-extension). - fn sign_leaf_with_code_separator( - &self, - leaf_index: Option, - sighashes: &[Message], - ) -> ::Witness { - assert!( - sighashes.len() == 1, - "You need to manually implement this method to handle multiple sighashes" - ); - self.sign_leaf(leaf_index, sighashes[0]) - } - /// Asserts that the connector is spendable at the given `leaf_index`. /// /// A random signer is generated using [`Signer::generate`]. @@ -127,57 +62,24 @@ pub trait Signer: Sized { ))); let connector = signer.get_connector(); - - // Setup Bitcoin node - let mut conf = Conf::default(); - conf.args.push("-txindex=1"); - let bitcoind = Node::with_conf("bitcoind", &conf).unwrap(); - let btc_client = &bitcoind.client; - - // Mine until maturity - let funded_address = btc_client.new_address().unwrap(); - let change_address = btc_client.new_address().unwrap(); - let coinbase_block = btc_client - .generate_to_address(101, &funded_address) - .expect("must be able to generate blocks") - .0 - .first() - .expect("must be able to get the blocks") - .parse::() - .expect("must parse"); - let coinbase_txid = btc_client - .get_block(coinbase_block) - .expect("must be able to get coinbase block") - .coinbase() - .expect("must be able to get the coinbase transaction") - .compute_txid(); - - // Create funding transaction - let funding_input = OutPoint { - txid: coinbase_txid, - vout: 0, - }; - - let coinbase_amount = Amount::from_btc(50.0).expect("must be valid amount"); - let funding_amount = Amount::from_sat(50_000); - let fees = Amount::from_sat(1_000); - - let input = vec![TxIn { - previous_output: funding_input, - ..Default::default() - }]; - + let mut node = BitcoinNode::new(); + let fee = Amount::from_sat(1_000); + + // Create a transaction that funds the connector. + // + // inputs | outputs + // --------------+------------------------ + // N sat: wallet | M sat: connector + // |------------------------ + // | N - M - fee sat: wallet + let input = create_tx_ins([node.next_coinbase_outpoint()]); let output = vec![ + connector.tx_out(), TxOut { - value: funding_amount, - script_pubkey: connector.script_pubkey(), - }, - TxOut { - value: coinbase_amount - funding_amount - fees, - script_pubkey: change_address.script_pubkey(), + value: node.coinbase_amount() - connector.value() - fee, + script_pubkey: node.wallet_address().script_pubkey(), }, ]; - let funding_tx = Transaction { version: transaction::Version(2), lock_time: absolute::LockTime::ZERO, @@ -185,53 +87,33 @@ pub trait Signer: Sized { output, }; - // Sign and broadcast funding transaction - let signed_funding_tx = btc_client - .call::( - "signrawtransactionwithwallet", - &[json!(consensus::encode::serialize_hex(&&funding_tx))], - ) - .expect("must be able to sign transaction"); - - assert!( - signed_funding_tx.complete, - "funding transaction should be complete" - ); - let signed_funding_tx = - consensus::encode::deserialize_hex(&signed_funding_tx.hex).expect("must deserialize"); - - let funding_txid = btc_client - .send_raw_transaction(&signed_funding_tx) - .expect("must be able to broadcast transaction") - .txid() - .expect("must have txid"); - - info!(%funding_txid, "Funding transaction broadcasted"); - - // Mine the funding transaction - let _ = btc_client - .generate_to_address(10, &funded_address) - .expect("must be able to generate blocks"); - - // Create spending transaction that spends the connector p - let spending_input = OutPoint { - txid: funding_txid, - vout: 0, - }; - - let spending_output = TxOut { - value: funding_amount - fees, - script_pubkey: change_address.script_pubkey(), - }; - + let funding_txid = node.sign_and_broadcast(&funding_tx); + info!(%funding_txid, "Funding transaction was broadcasted"); + node.mine_blocks(10); + + // Create a transaction that spends the connector. + // + // inputs | outputs + // -----------------+------------------------ + // M sat: connector | N + M - fee sat: wallet + // -----------------| + // N sat: wallet | + let input = create_tx_ins([ + OutPoint { + txid: funding_txid, + vout: 0, + }, + node.next_coinbase_outpoint(), + ]); + let output = vec![TxOut { + value: node.coinbase_amount() + connector.value() - fee, + script_pubkey: node.wallet_address().script_pubkey(), + }]; let mut spending_tx = Transaction { version: transaction::Version(2), lock_time: absolute::LockTime::ZERO, - input: vec![TxIn { - previous_output: spending_input, - ..Default::default() - }], - output: vec![spending_output], + input, + output, }; // Update the sequence number @@ -240,58 +122,177 @@ pub trait Signer: Sized { spending_tx.input[0].sequence = timelock.to_sequence(); } - let mut sighash_cache = SighashCache::new(&spending_tx); - - let input_index = 0; - let utxos = [funding_tx.output[0].clone()]; + // Sign the spending transaction + let utxos = [connector.tx_out(), node.coinbase_tx_out()]; + let mut cache = SighashCache::new(&spending_tx); let prevouts = Prevouts::All(&utxos); - let annex = None; - let sighash_type = TapSighashType::Default; - - // Key-path spend: There is 1 sighash. - // Script-path spend: There is 1 sighash for each code separator position. - let leaf_hash_code_separators: Vec<_> = match leaf_index { - Some(i) => { - let leaf_scripts = connector.leaf_scripts(); - let leaf_hash = TapLeafHash::from_script(&leaf_scripts[i], LeafVersion::TapScript); - connector - .code_separator_positions(i) - .into_iter() - .map(|pos| Some((leaf_hash, pos))) - .collect() - } - None => vec![None], + let input_index = 0; + let sighash = connector.compute_sighash(leaf_index, &mut cache, prevouts, input_index); + let witness = signer.sign_leaf(leaf_index, sighash); + + let mut psbt = Psbt::from_unsigned_tx(spending_tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(connector.tx_out()); + psbt.inputs[1].witness_utxo = Some(node.coinbase_tx_out()); + connector.finalize_input(&mut psbt.inputs[0], &witness); + info!(%funding_txid, "Spending transaction was signed"); + + let spending_tx = psbt.extract_tx().expect("should be able to extract tx"); + let _ = node.sign_and_broadcast(&spending_tx); + } +} + +/// Bitcoin Core node in regtest mode. +#[derive(Debug)] +pub struct BitcoinNode { + node: Node, + wallet_address: Address, + coinbase_txids: VecDeque, +} + +impl Default for BitcoinNode { + fn default() -> Self { + Self::new() + } +} + +impl BitcoinNode { + /// Creates a new bitcoin node. + pub fn new() -> Self { + let mut conf = Conf::default(); + conf.args.push("-txindex=1"); + let bitcoind = Node::with_conf("bitcoind", &conf).unwrap(); + let client = &bitcoind.client; + + let mut node = Self { + wallet_address: client.new_address().unwrap(), + node: bitcoind, + coinbase_txids: VecDeque::new(), }; - let sighashes: Vec = leaf_hash_code_separators + // Mine 200 blocks so coinbases of blocks 0..100 become mature + // + // Note (@uncomputable) + // 100 spendable coinbase outputs should be enough for most unit tests. + // Tests that run out of coinbases can mine more blocks. + node.mine_blocks(200); + node + } + + /// Accesses the bitcoin client. + pub fn client(&self) -> &Client { + &self.node.client + } + + /// Returns the coinbase amount for blocks of the first halving epoch. + pub const fn coinbase_amount(&self) -> Amount { + Amount::from_sat(50 * 100_000_000) + } + + /// Accesses the wallet address. + /// + /// The node can automatically sign inputs that spend from this address. + pub fn wallet_address(&self) -> &Address { + &self.wallet_address + } + + /// Returns the outpoint of a fresh coinbase transaction. + /// + /// This method implements an iterator, + /// so it returns a fresh coinbase outpoint on each call. + /// + /// The order of coinbase transactions does not follow the block height. + /// Assume an arbitrary order. + /// + /// # Panics + /// + /// This method panics if there are no more coinbases. + /// In this case, you have to mine more blocks. + pub fn next_coinbase_outpoint(&mut self) -> OutPoint { + OutPoint { + txid: self.coinbase_txids.pop_front().expect("no more coinbases"), + vout: 0, + } + } + + /// Returns the transaction output of any coinbase transaction. + /// + /// This node sends coinbase funds always to the wallet address, + /// so the coinbase output is the same regardless of block height. + /// regardless of block height. + pub fn coinbase_tx_out(&self) -> TxOut { + TxOut { + value: self.coinbase_amount(), + script_pubkey: self.wallet_address.script_pubkey(), + } + } + + /// Mines the given number of blocks. + /// + /// Funds go to the wallet address. + pub fn mine_blocks(&mut self, n_blocks: usize) { + let coinbase_txids: Vec = self + .client() + .generate_to_address(n_blocks, self.wallet_address()) + .expect("must be able to generate blocks") + .0 .into_iter() - .map(|leaf_hash_code_separator| { - let sighash = sighash_cache - .taproot_signature_hash( - input_index, - &prevouts, - annex.clone(), - leaf_hash_code_separator, - sighash_type, - ) - .expect("should be able to compute sighash"); - Message::from_digest(sighash.to_raw_hash().to_byte_array()) + .map(|block_hash| block_hash.parse::().expect("must parse")) + .map(|block_hash| { + self.client() + .get_block(block_hash) + .expect("must be able to get coinbase block") + .coinbase() + .expect("must be able to get the coinbase transaction") + .compute_txid() }) .collect(); + self.coinbase_txids.extend(coinbase_txids); + } - // Set the witness in the transaction - let mut psbt = Psbt::from_unsigned_tx(spending_tx).unwrap(); - psbt.inputs[0].witness_utxo = Some(TxOut { - value: funding_amount, - script_pubkey: connector.script_pubkey(), - }); + /// Signs the inputs that the wallet controls and returns the resulting transaction. + pub fn sign(&self, partially_signed_tx: &Transaction) -> Transaction { + let signed_tx = self + .client() + .call::( + "signrawtransactionwithwallet", + &[json!(consensus::encode::serialize_hex( + &partially_signed_tx + ))], + ) + .expect("should be able to sign the transaction inputs"); + consensus::encode::deserialize_hex(&signed_tx.hex).expect("must deserialize") + } - let witness = signer.sign_leaf_with_code_separator(leaf_index, &sighashes); - connector.finalize_input(&mut psbt.inputs[0], &witness); - let spending_tx = psbt.extract_tx().expect("must be signed"); + /// Signs the inputs that the wallet controls and broadcasts the transaction. + /// + /// # Panics + /// + /// This method panics if the transaction is not accepted by the mempool. + pub fn sign_and_broadcast(&self, partially_signed_tx: &Transaction) -> Txid { + let signed_tx = self.sign(partially_signed_tx); + self.client() + .send_raw_transaction(&signed_tx) + .expect("Cannot broadcast. Is the transaction invalid?") + .txid() + .expect("should be able to extract the txid") + } - // Broadcast the spending transaction - btc_client - .send_raw_transaction(&spending_tx) - .expect("must be able to broadcast spending transaction"); + /// Submits a package of transactions to the mempool. + /// + /// # Panics + /// + /// This method panics if the package is not accepted by the mempool. + pub fn submit_package(&self, transactions: &[Transaction]) { + let result = self + .client() + .submit_package(transactions, None, None) + .expect("should be able to submit package"); + assert!( + result.package_msg == "success", + "Package submission failed. Is the package invalid?" + ); + assert!( + result.tx_results.len() == 2, + "tx_results should have 2 elements" + ); } } From a810673ea4bc5c03e2965fe2c92486322689f26e Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 11 Dec 2025 12:04:26 -0500 Subject: [PATCH 19/20] refactor: CPFP test --- crates/tx-graph2/src/connectors/cpfp.rs | 101 +++++++++++++++--------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/crates/tx-graph2/src/connectors/cpfp.rs b/crates/tx-graph2/src/connectors/cpfp.rs index 7f9b12e49..46c1cd6a5 100644 --- a/crates/tx-graph2/src/connectors/cpfp.rs +++ b/crates/tx-graph2/src/connectors/cpfp.rs @@ -1,6 +1,8 @@ //! This module contains the CPFP connector. -use bitcoin::{taproot::TaprootSpendInfo, Address, Amount, Network, ScriptBuf, WitnessProgram}; +use bitcoin::{ + taproot::TaprootSpendInfo, Address, Amount, Network, ScriptBuf, Witness, WitnessProgram, +}; use crate::connectors::{Connector, TaprootWitness}; @@ -47,50 +49,77 @@ impl Connector for CpfpConnector { panic!("P2A is not a taproot output") } - fn finalize_input(&self, _input: &mut bitcoin::psbt::Input, _witness: &Self::Witness) { - // Do nothing + fn finalize_input(&self, input: &mut bitcoin::psbt::Input, _witness: &Self::Witness) { + input.final_script_witness = Some(Witness::default()); } } #[cfg(test)] mod tests { - use secp256k1::Message; + use bitcoin::{absolute, transaction, OutPoint, Transaction, TxOut}; + use strata_bridge_primitives::scripts::prelude::create_tx_ins; use super::*; - use crate::connectors::test_utils::Signer; - - struct P2ASigner; - - impl Signer for P2ASigner { - type Connector = CpfpConnector; - - fn generate() -> Self { - Self - } - - fn get_connector(&self) -> Self::Connector { - CpfpConnector { - network: Network::Regtest, - } - } - - fn get_connector_name(&self) -> &'static str { - "p2a" - } - - fn sign_leaf( - &self, - _leaf_index: Option, - _sighash: Message, - ) -> ::Witness { - // Return unit - } - } + use crate::connectors::test_utils::BitcoinNode; #[test] - #[ignore] fn p2a_spend() { - let leaf_index = None; - P2ASigner::assert_connector_is_spendable(leaf_index); + let mut node = BitcoinNode::new(); + + // Create the parent transaction that funds the P2A connector. + // The parent transaction is v3 and has zero fees. + // + // inputs | outputs + // --------------+-------------- + // N sat: wallet | N sat: wallet + // |-------------- + // | 0 sat: P2A + let connector = CpfpConnector::new(Network::Regtest); + let input = create_tx_ins([node.next_coinbase_outpoint()]); + let output = vec![ + TxOut { + value: node.coinbase_amount(), + script_pubkey: node.wallet_address().script_pubkey(), + }, + connector.tx_out(), + ]; + let parent_tx = Transaction { + version: transaction::Version(3), + lock_time: absolute::LockTime::ZERO, + input, + output, + }; + let signed_parent_tx = node.sign(&parent_tx); + + // Create the child transaction that spends the P2A connector of the parent transaction. + // The child transaction is v3 and pays 2 * fees: for the itself and for the parent. + // + // inputs | outputs + // --------------+------------------------ + // 0 sat: P2A | N - fee * 2 sat: wallet + // --------------| + // N sat: wallet | + let input = create_tx_ins([ + OutPoint { + txid: signed_parent_tx.compute_txid(), + vout: 1, + }, + node.next_coinbase_outpoint(), + ]); + let fee = Amount::from_sat(1_000); + let output = vec![TxOut { + value: node.coinbase_amount() - fee * 2, + script_pubkey: node.wallet_address().script_pubkey(), + }]; + let child_tx = Transaction { + version: transaction::Version(3), + lock_time: absolute::LockTime::ZERO, + input, + output, + }; + let signed_child_tx = node.sign(&child_tx); + + // Submit parent and child in the same package + node.submit_package(&[signed_parent_tx, signed_child_tx]); } } From 22d5f47d94b7e98dd717118899009157f113dee8 Mon Sep 17 00:00:00 2001 From: Christian Lewe Date: Thu, 11 Dec 2025 12:07:44 -0500 Subject: [PATCH 20/20] refactor: Counterproof test Instead of using assert_connector_is_spendable, we use custom code to test the connector. Separately handling code separators keeps the code simple. --- .../src/connectors/contest_counterproof.rs | 114 +++++++++++++++--- 1 file changed, 98 insertions(+), 16 deletions(-) diff --git a/crates/tx-graph2/src/connectors/contest_counterproof.rs b/crates/tx-graph2/src/connectors/contest_counterproof.rs index fb91146f0..d4fed90d6 100644 --- a/crates/tx-graph2/src/connectors/contest_counterproof.rs +++ b/crates/tx-graph2/src/connectors/contest_counterproof.rs @@ -106,11 +106,18 @@ pub struct ContestCounterproofWitness { #[cfg(test)] mod tests { + use bitcoin::{ + absolute, + psbt::Psbt, + sighash::{Prevouts, SighashCache}, + transaction, OutPoint, Transaction, TxOut, + }; use secp256k1::Keypair; + use strata_bridge_primitives::scripts::prelude::create_tx_ins; use strata_bridge_test_utils::prelude::generate_keypair; use super::*; - use crate::connectors::test_utils::Signer; + use crate::connectors::test_utils::BitcoinNode; const N_DATA: NonZero = NonZero::new(10).unwrap(); @@ -119,9 +126,7 @@ mod tests { operator_keypair: Keypair, } - impl Signer for ContestWatchtowerSigner { - type Connector = ContestCounterproofOutput; - + impl ContestWatchtowerSigner { fn generate() -> Self { Self { n_of_n_keypair: generate_keypair(), @@ -129,7 +134,7 @@ mod tests { } } - fn get_connector(&self) -> Self::Connector { + fn get_connector(&self) -> ContestCounterproofOutput { ContestCounterproofOutput { network: Network::Regtest, n_of_n_pubkey: self.n_of_n_keypair.x_only_public_key().0, @@ -138,22 +143,99 @@ mod tests { } } - fn get_connector_name(&self) -> &'static str { - "contest-counterproof" - } + fn sign_leaf(&self, sighashes: &[secp256k1::Message]) -> ContestCounterproofWitness { + assert!(sighashes.len() == N_DATA.get()); + let n_of_n_signature = self.n_of_n_keypair.sign_schnorr(sighashes[0]); + let operator_signatures = sighashes + .iter() + .copied() + .map(|sighash| self.operator_keypair.sign_schnorr(sighash)) + .collect(); - fn sign_leaf( - &self, - _leaf_index: Option, - _sighash: secp256k1::Message, - ) -> ::Witness { - unimplemented!("use sign_leaf_with_code_separator") + ContestCounterproofWitness { + n_of_n_signature, + operator_signatures, + } } } #[test] - #[ignore] fn counterproof_spend() { - ContestWatchtowerSigner::assert_connector_is_spendable(Some(0)); + let mut node = BitcoinNode::new(); + let signer = ContestWatchtowerSigner::generate(); + let connector = signer.get_connector(); + let fee = Amount::from_sat(1_000); + + // Create a transaction that funds the connector. + // + // inputs | outputs + // --------------+------------------------ + // N sat: wallet | M sat: connector + // |------------------------ + // | N - M - fee sat: wallet + let input = create_tx_ins([node.next_coinbase_outpoint()]); + let output = vec![ + connector.tx_out(), + TxOut { + value: node.coinbase_amount() - connector.value() - fee, + script_pubkey: node.wallet_address().script_pubkey(), + }, + ]; + let funding_tx = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input, + output, + }; + + let funding_txid = node.sign_and_broadcast(&funding_tx); + node.mine_blocks(10); + + // Create a transaction that spends the connector. + // + // inputs | outputs + // -----------------+------------------------ + // M sat: connector | N + M - fee sat: wallet + // -----------------| + // N sat: wallet | + let input = create_tx_ins([ + OutPoint { + txid: funding_txid, + vout: 0, + }, + node.next_coinbase_outpoint(), + ]); + let output = vec![TxOut { + value: node.coinbase_amount() + connector.value() - fee, + script_pubkey: node.wallet_address().script_pubkey(), + }]; + let spending_tx = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input, + output, + }; + + // Sign the spending transaction + let leaf_index = 0; + let mut cache = SighashCache::new(&spending_tx); + let utxos = [connector.tx_out(), node.coinbase_tx_out()]; + let prevouts = Prevouts::All(&utxos); + let input_index = 0; + let sighashes = connector.compute_sighashes_with_code_separator( + leaf_index, + &mut cache, + prevouts, + input_index, + ); + let witness = signer.sign_leaf(&sighashes); + + let mut psbt = Psbt::from_unsigned_tx(spending_tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(connector.tx_out()); + psbt.inputs[1].witness_utxo = Some(node.coinbase_tx_out()); + connector.finalize_input(&mut psbt.inputs[0], &witness); + + let spending_tx = psbt.extract_tx().expect("should be able to extract tx"); + let _ = node.sign_and_broadcast(&spending_tx); } }