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/connectors/claim_contest.rs b/crates/tx-graph2/src/connectors/claim_contest.rs new file mode 100644 index 000000000..e2e42b31c --- /dev/null +++ b/crates/tx-graph2/src/connectors/claim_contest.rs @@ -0,0 +1,222 @@ +//! This module contains the claim contest connector. + +use bitcoin::{opcodes, relative, script, Amount, Network, ScriptBuf}; +use secp256k1::{schnorr, XOnlyPublicKey}; + +use crate::connectors::{Connector, TaprootWitness}; + +/// Connector output between `Claim` and: +/// 1. `UncontestedPayout`, and +/// 2. `Contest`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ClaimContestConnector { + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + watchtower_pubkeys: Vec, + contest_timelock: relative::LockTime, +} + +impl ClaimContestConnector { + /// Creates a new connector. + pub const fn new( + network: Network, + n_of_n_pubkey: XOnlyPublicKey, + watchtower_pubkeys: Vec, + contest_timelock: relative::LockTime, + ) -> Self { + Self { + network, + n_of_n_pubkey, + watchtower_pubkeys, + contest_timelock, + } + } + + /// Returns the number of watchtowers for the connector. + pub const fn n_watchtowers(&self) -> usize { + self.watchtower_pubkeys.len() + } + + /// Returns the relative contest timelock of the connector. + pub const fn contest_timelock(&self) -> relative::LockTime { + self.contest_timelock + } +} + +impl Connector for ClaimContestConnector { + type Witness = ClaimContestWitness; + + fn network(&self) -> Network { + self.network + } + + fn leaf_scripts(&self) -> Vec { + let mut scripts = Vec::new(); + + for watchtower_pubkey in &self.watchtower_pubkeys { + let contest_script = script::Builder::new() + .push_slice(self.n_of_n_pubkey.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) + .push_slice(watchtower_pubkey.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIG) + .into_script(); + scripts.push(contest_script); + } + + let uncontested_payout_script = script::Builder::new() + .push_slice(self.n_of_n_pubkey.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) + .push_sequence(self.contest_timelock.to_sequence()) + .push_opcode(opcodes::all::OP_CSV) + .into_script(); + scripts.push(uncontested_payout_script); + + scripts + } + + 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 + minimal_non_dust * (3 * self.n_watchtowers() as u64) + } + + fn get_taproot_witness(&self, witness: &Self::Witness) -> TaprootWitness { + match witness.spend_path { + ClaimContestSpendPath::Contested { + watchtower_index, + watchtower_signature, + } => TaprootWitness::Script { + leaf_index: watchtower_index as usize, + script_inputs: vec![ + watchtower_signature.serialize().to_vec(), + 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()], + }, + } + } +} + +/// Witness data to spend a [`ClaimContestConnector`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct ClaimContestWitness { + /// N/N signature of the transaction that spends the connector. + pub n_of_n_signature: schnorr::Signature, + /// Used spending path. + pub spend_path: ClaimContestSpendPath, +} + +/// Available spending paths for a [`ClaimContestConnector`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum ClaimContestSpendPath { + /// The connector is spent in the `Contest` transaction. + Contested { + /// Index of the spending watchtower. + watchtower_index: u32, + /// Signature of the spending watchtower. + 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, +} + +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use secp256k1::{Keypair, Message}; + use strata_bridge_test_utils::prelude::generate_keypair; + + use super::*; + use crate::connectors::test_utils::Signer; + + const N_WATCHTOWERS: usize = 10; + const DELTA_CONTEST: relative::LockTime = relative::LockTime::from_height(10); + + struct ClaimContestSigner { + n_of_n_keypair: Keypair, + watchtower_keypairs: Vec, + } + + impl Signer for ClaimContestSigner { + type Connector = ClaimContestConnector; + + fn generate() -> Self { + Self { + n_of_n_keypair: generate_keypair(), + watchtower_keypairs: (0..N_WATCHTOWERS).map(|_| generate_keypair()).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" + } + + 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: 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()) { + Ordering::Less => { + let watchtower_signature = + self.watchtower_keypairs[leaf_index].sign_schnorr(sighash); + let spend_path = ClaimContestSpendPath::Contested { + watchtower_index: leaf_index as u32, + watchtower_signature, + }; + ClaimContestWitness { + n_of_n_signature, + spend_path, + } + } + Ordering::Equal => ClaimContestWitness { + n_of_n_signature, + spend_path: ClaimContestSpendPath::Uncontested, + }, + Ordering::Greater => panic!("Leaf index is out of bounds"), + } + } + } + + #[test] + fn contested_spend() { + let leaf_index = Some(0); + ClaimContestSigner::assert_connector_is_spendable(leaf_index); + } + + #[test] + fn uncontested_spend() { + let leaf_index = Some(N_WATCHTOWERS); + ClaimContestSigner::assert_connector_is_spendable(leaf_index); + } +} diff --git a/crates/tx-graph2/src/connectors/claim_contest_connector.rs b/crates/tx-graph2/src/connectors/claim_contest_connector.rs deleted file mode 100644 index 8e4ff34ba..000000000 --- a/crates/tx-graph2/src/connectors/claim_contest_connector.rs +++ /dev/null @@ -1,428 +0,0 @@ -//! This module contains the connector between `Claim`, `UncontestedPayout`, and `Contest`. - -use bitcoin::{ - opcodes, - psbt::Input, - relative, script, - taproot::{LeafVersion, TaprootSpendInfo}, - 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; - -/// Connector output between `Claim` and: -/// 1. `UncontestedPayout`, and -/// 2. `Contest`. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ClaimContestConnector { - network: Network, - n_of_n_pubkey: XOnlyPublicKey, - watchtower_pubkeys: Vec, - contest_timelock: relative::LockTime, -} - -impl ClaimContestConnector { - /// Creates a new connector. - pub const fn new( - network: Network, - n_of_n_pubkey: XOnlyPublicKey, - watchtower_pubkeys: Vec, - contest_timelock: relative::LockTime, - ) -> Self { - Self { - network, - n_of_n_pubkey, - watchtower_pubkeys, - contest_timelock, - } - } - - /// Returns the number of watchtowers for the connector. - pub const fn n_watchtowers(&self) -> usize { - self.watchtower_pubkeys.len() - } - - /// 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 - } - - /// Generates a vector of all leaf scripts of the connector. - pub fn leaf_scripts(&self) -> Vec { - let mut scripts = Vec::new(); - - for watchtower_pubkey in &self.watchtower_pubkeys { - let contest_script = script::Builder::new() - .push_slice(self.n_of_n_pubkey.serialize()) - .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) - .push_slice(watchtower_pubkey.serialize()) - .push_opcode(opcodes::all::OP_CHECKSIG) - .into_script(); - scripts.push(contest_script); - } - - let uncontested_payout_script = script::Builder::new() - .push_slice(self.n_of_n_pubkey.serialize()) - .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) - .push_sequence(self.contest_timelock.to_sequence()) - .push_opcode(opcodes::all::OP_CSV) - .into_script(); - scripts.push(uncontested_payout_script); - - 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(); - // 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, - } - } - - /// 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 { - 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 = [ - 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 data to spend a [`ClaimContestConnector`]. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub struct ClaimContestWitness { - /// N/N signature of the transaction that spends the connector. - pub n_of_n_signature: schnorr::Signature, - /// Used spending path. - pub spend_path: ClaimContestSpendPath, -} - -/// Available spending paths for a [`ClaimContestConnector`]. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub enum ClaimContestSpendPath { - /// The connector is spent in the `Contest` transaction. - Contested { - /// Index of the spending watchtower. - watchtower_index: u32, - /// Signature of the spending watchtower. - watchtower_signature: schnorr::Signature, - }, - /// The connector is spent in the `UncontestedPayout` transaction. - Uncontested, -} - -#[cfg(test)] -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::*; - - const N_WATCHTOWERS: usize = 10; - const DELTA_CONTEST: relative::LockTime = relative::LockTime::from_height(10); - - struct Signer { - 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(); - - Self { - n_of_n_keypair, - watchtower_keypairs, - } - } - - 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(); - - ClaimContestConnector::new( - Network::Regtest, - n_of_n_pubkey, - watchtower_pubkeys, - DELTA_CONTEST, - ) - } - - fn sign_leaf(&self, leaf_index: usize, sighash: Message) -> ClaimContestWitness { - let n_of_n_signature = self.n_of_n_keypair.sign_schnorr(sighash); - - match leaf_index.cmp(&self.watchtower_keypairs.len()) { - Ordering::Less => { - let watchtower_signature = - self.watchtower_keypairs[leaf_index].sign_schnorr(sighash); - let spend_path = ClaimContestSpendPath::Contested { - watchtower_index: leaf_index as u32, - watchtower_signature, - }; - ClaimContestWitness { - n_of_n_signature, - spend_path, - } - } - Ordering::Equal => ClaimContestWitness { - n_of_n_signature, - spend_path: ClaimContestSpendPath::Uncontested, - }, - Ordering::Greater => panic!("Leaf index is out of bounds"), - } - } - } - - 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.tx_out().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.tx_out().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); - } - - #[test] - fn uncontested_spend() { - let signer = Signer::generate(); - let connector = signer.get_connector(); - spend_connector(connector, signer, N_WATCHTOWERS); - } -} diff --git a/crates/tx-graph2/src/connectors/claim_payout.rs b/crates/tx-graph2/src/connectors/claim_payout.rs new file mode 100644 index 000000000..bcdb5b4cb --- /dev/null +++ b/crates/tx-graph2/src/connectors/claim_payout.rs @@ -0,0 +1,198 @@ +//! 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. + /// + /// The preimage of `unstaking_image` must be 32 bytes long. + 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/contest_counterproof.rs b/crates/tx-graph2/src/connectors/contest_counterproof.rs new file mode 100644 index 000000000..d4fed90d6 --- /dev/null +++ b/crates/tx-graph2/src/connectors/contest_counterproof.rs @@ -0,0 +1,241 @@ +//! 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`. +/// +/// 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 + } +} + +// Strictly speaking, this is not a connector output. +// However, we still implement the [`Connector`] trait for convenience. +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 that will be published onchain. + pub operator_signatures: Vec, +} + +#[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::BitcoinNode; + + const N_DATA: NonZero = NonZero::new(10).unwrap(); + + struct ContestWatchtowerSigner { + n_of_n_keypair: Keypair, + operator_keypair: Keypair, + } + + impl ContestWatchtowerSigner { + fn generate() -> Self { + Self { + n_of_n_keypair: generate_keypair(), + operator_keypair: generate_keypair(), + } + } + + fn get_connector(&self) -> ContestCounterproofOutput { + 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 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(); + + ContestCounterproofWitness { + n_of_n_signature, + operator_signatures, + } + } + } + + #[test] + fn counterproof_spend() { + 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); + } +} diff --git a/crates/tx-graph2/src/connectors/cpfp.rs b/crates/tx-graph2/src/connectors/cpfp.rs new file mode 100644 index 000000000..46c1cd6a5 --- /dev/null +++ b/crates/tx-graph2/src/connectors/cpfp.rs @@ -0,0 +1,125 @@ +//! This module contains the CPFP connector. + +use bitcoin::{ + taproot::TaprootSpendInfo, Address, Amount, Network, ScriptBuf, Witness, 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) { + input.final_script_witness = Some(Witness::default()); + } +} + +#[cfg(test)] +mod tests { + use bitcoin::{absolute, transaction, OutPoint, Transaction, TxOut}; + use strata_bridge_primitives::scripts::prelude::create_tx_ins; + + use super::*; + use crate::connectors::test_utils::BitcoinNode; + + #[test] + fn p2a_spend() { + 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]); + } +} diff --git a/crates/tx-graph2/src/connectors/mod.rs b/crates/tx-graph2/src/connectors/mod.rs index c98e7decf..be077e09c 100644 --- a/crates/tx-graph2/src/connectors/mod.rs +++ b/crates/tx-graph2/src/connectors/mod.rs @@ -1,3 +1,286 @@ //! This module contains connectors for the Glock transaction graph. -pub mod claim_contest_connector; +pub mod claim_contest; +pub mod claim_payout; +pub mod contest_counterproof; +pub mod cpfp; +pub mod n_of_n; +pub mod prelude; +pub mod timelocked_n_of_n; + +#[cfg(test)] +pub mod test_utils; + +use bitcoin::{ + hashes::Hash, + opcodes, + psbt::Input, + script, + 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 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; + + /// Returns a vector of all `OP_CODESEPARATOR` positions in the leaf script + /// at the given index. + /// + /// 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 + /// + /// 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 { + // 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]; + + 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( + &self.network(), + SpendPath::Both { + internal_key: self.internal_key(), + scripts: self.leaf_scripts().as_slice(), + }, + ) + .expect("tap tree is valid") + .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 tx_out(&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 { + // 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. + create_taproot_addr( + &self.network(), + SpendPath::Both { + internal_key: self.internal_key(), + scripts: self.leaf_scripts().as_slice(), + }, + ) + .expect("tap tree is valid") + .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; + + /// 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 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 { + 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>, + }, +} diff --git a/crates/tx-graph2/src/connectors/n_of_n.rs b/crates/tx-graph2/src/connectors/n_of_n.rs new file mode 100644 index 000000000..b94efbf9a --- /dev/null +++ b/crates/tx-graph2/src/connectors/n_of_n.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 new file mode 100644 index 000000000..9ca1c23aa --- /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::*, 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 new file mode 100644 index 000000000..c6a2ee505 --- /dev/null +++ b/crates/tx-graph2/src/connectors/test_utils.rs @@ -0,0 +1,298 @@ +//! Utilities to test connectors. + +use std::collections::VecDeque; + +use bitcoin::{ + absolute, consensus, relative, + sighash::{Prevouts, SighashCache}, + transaction, Address, Amount, BlockHash, OutPoint, Psbt, Transaction, TxOut, Txid, +}; +use bitcoind_async_client::types::SignRawTransactionWithWallet; +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; + +/// 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: 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(); + 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: 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); + 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, + output, + }; + + // 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(); + } + + // 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 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(), + }; + // 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(|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); + } + + /// 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") + } + + /// 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") + } + + /// 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" + ); + } +} diff --git a/crates/tx-graph2/src/connectors/timelocked_n_of_n.rs b/crates/tx-graph2/src/connectors/timelocked_n_of_n.rs new file mode 100644 index 000000000..33aa2b85a --- /dev/null +++ b/crates/tx-graph2/src/connectors/timelocked_n_of_n.rs @@ -0,0 +1,306 @@ +//! 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. +#[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. + /// + /// 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 + /// + /// 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, + 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. + /// + /// The internal key is the N/N key. + 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. + /// + /// The internal key is the N/N key. + 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. + /// + /// 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, + 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); + } +} diff --git a/crates/tx-graph2/src/transactions/claim.rs b/crates/tx-graph2/src/transactions/claim.rs index 8b2df6e7c..c2aadc88f 100644 --- a/crates/tx-graph2/src/transactions/claim.rs +++ b/crates/tx-graph2/src/transactions/claim.rs @@ -1,10 +1,12 @@ //! This module contains the claim transaction. -use bitcoin::{transaction, Amount, OutPoint, Transaction, TxOut}; -use strata_bridge_connectors::connector_cpfp::ConnectorCpfp; +use bitcoin::{transaction, OutPoint, Transaction, TxOut}; 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, ClaimPayoutConnector, CpfpConnector}, + Connector, +}; /// Data that is needed to construct a [`ClaimTx`]. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] @@ -18,17 +20,21 @@ 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, - cpfp_connector: ConnectorCpfp, + claim_payout_connector: ClaimPayoutConnector, + 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 = [ @@ -36,7 +42,11 @@ impl ClaimTx { claim_contest_tx_out.script_pubkey, claim_contest_tx_out.value, ), - (cpfp_connector.locking_script(), Amount::ZERO), + ( + claim_payout_tx_out.script_pubkey, + claim_contest_tx_out.value, + ), + (cpfp_tx_out.script_pubkey, cpfp_tx_out.value), ]; 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] 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::*;