From 56cdf17a6e66c2840d106239d4e2bafafcaaa20a Mon Sep 17 00:00:00 2001 From: BowTiedDeployer Date: Wed, 24 May 2023 21:37:20 +0300 Subject: [PATCH 1/6] added new default cargo project --- .gitignore | 2 ++ Cargo.toml | 3 ++- first-build/Cargo.toml | 8 ++++++++ first-build/src/helper.rs | 7 +++++++ first-build/src/main.rs | 8 ++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 first-build/Cargo.toml create mode 100644 first-build/src/helper.rs create mode 100644 first-build/src/main.rs diff --git a/.gitignore b/.gitignore index 7f3ac0b7..8561029c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ Cargo.lock cobertura.xml tarpaulin-report.html *~ +**/.DS_Store +**/.idea diff --git a/Cargo.toml b/Cargo.toml index a1c9a2bf..72bdf980 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,8 @@ members = [ "stacks-doctor", "test-utils", "test-vectors", - "yarpc"] + "yarpc", + "first-build"] [workspace.dependencies] bs58 = "0.4" diff --git a/first-build/Cargo.toml b/first-build/Cargo.toml new file mode 100644 index 00000000..6d1ba3e1 --- /dev/null +++ b/first-build/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "first-build" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/first-build/src/helper.rs b/first-build/src/helper.rs new file mode 100644 index 00000000..894edd09 --- /dev/null +++ b/first-build/src/helper.rs @@ -0,0 +1,7 @@ +pub fn public_available() { + println!("This function is public"); +} + +pub fn public_sum(a: i32, b: i32) -> i32 { + a + b +} diff --git a/first-build/src/main.rs b/first-build/src/main.rs new file mode 100644 index 00000000..e8f1c501 --- /dev/null +++ b/first-build/src/main.rs @@ -0,0 +1,8 @@ +mod helper; + +fn main() { + helper::public_available(); + println!("Sum of 1 and 2 is {}", helper::public_sum(1, 2)); + + println!("Hello, world!"); +} From 7e761207bb0a3469822014ed0aef29d1dfcc78e5 Mon Sep 17 00:00:00 2001 From: Andrei Alexandrescu Date: Fri, 26 May 2023 16:38:11 +0300 Subject: [PATCH 2/6] Copy over files --- Cargo.toml | 24 +- degen-coordinator/Cargo.toml | 8 + degen-coordinator/src/bitcoin_node.rs | 288 ++++++++++ degen-coordinator/src/bitcoin_wallet.rs | 288 ++++++++++ degen-coordinator/src/cli.rs | 35 ++ degen-coordinator/src/config.rs | 42 ++ degen-coordinator/src/coordinator.rs | 395 ++++++++++++++ degen-coordinator/src/lib.rs | 10 + degen-coordinator/src/main.rs | 70 +++ degen-coordinator/src/peg_queue/mod.rs | 46 ++ .../src/peg_queue/sqlite_peg_queue.rs | 505 ++++++++++++++++++ degen-coordinator/src/peg_wallet.rs | 77 +++ degen-coordinator/src/stacks_node/client.rs | 227 ++++++++ degen-coordinator/src/stacks_node/mod.rs | 40 ++ degen-coordinator/src/stacks_wallet.rs | 414 ++++++++++++++ degen-coordinator/src/util.rs | 103 ++++ degen-signer/Cargo.toml | 8 + degen-signer/src/cli.rs | 33 ++ degen-signer/src/lib.rs | 24 + degen-signer/src/main.rs | 47 ++ degen-signer/src/secp256k1.rs | 52 ++ degen-signer/src/signer.rs | 20 + 22 files changed, 2745 insertions(+), 11 deletions(-) create mode 100644 degen-coordinator/Cargo.toml create mode 100644 degen-coordinator/src/bitcoin_node.rs create mode 100644 degen-coordinator/src/bitcoin_wallet.rs create mode 100644 degen-coordinator/src/cli.rs create mode 100644 degen-coordinator/src/config.rs create mode 100644 degen-coordinator/src/coordinator.rs create mode 100644 degen-coordinator/src/lib.rs create mode 100644 degen-coordinator/src/main.rs create mode 100644 degen-coordinator/src/peg_queue/mod.rs create mode 100644 degen-coordinator/src/peg_queue/sqlite_peg_queue.rs create mode 100644 degen-coordinator/src/peg_wallet.rs create mode 100644 degen-coordinator/src/stacks_node/client.rs create mode 100644 degen-coordinator/src/stacks_node/mod.rs create mode 100644 degen-coordinator/src/stacks_wallet.rs create mode 100644 degen-coordinator/src/util.rs create mode 100644 degen-signer/Cargo.toml create mode 100644 degen-signer/src/cli.rs create mode 100644 degen-signer/src/lib.rs create mode 100644 degen-signer/src/main.rs create mode 100644 degen-signer/src/secp256k1.rs create mode 100644 degen-signer/src/signer.rs diff --git a/Cargo.toml b/Cargo.toml index 72bdf980..ed8f656e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,18 @@ [workspace] members = [ - "relay-server", - "frost-test", - "frost-signer", - "frost-coordinator", - "stacks-coordinator", - "stacks-signer", - "stacks-doctor", - "test-utils", - "test-vectors", - "yarpc", - "first-build"] + "relay-server", + "frost-test", + "frost-signer", + "frost-coordinator", + "stacks-coordinator", + "stacks-signer", + "stacks-doctor", + "test-utils", + "test-vectors", + "yarpc", + "first-build", + "degen-coordinator", + "degen-signer"] [workspace.dependencies] bs58 = "0.4" diff --git a/degen-coordinator/Cargo.toml b/degen-coordinator/Cargo.toml new file mode 100644 index 00000000..1f2868e4 --- /dev/null +++ b/degen-coordinator/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "degen-coordinator" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/degen-coordinator/src/bitcoin_node.rs b/degen-coordinator/src/bitcoin_node.rs new file mode 100644 index 00000000..f3916a35 --- /dev/null +++ b/degen-coordinator/src/bitcoin_node.rs @@ -0,0 +1,288 @@ +use std::{io::Cursor, str::FromStr}; + +use bitcoin::{ + consensus::Encodable, + hashes::{hex::ToHex, sha256d::Hash}, + Address as BitcoinAddress, Txid, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::{debug, warn}; +pub trait BitcoinNode { + /// Broadcast the BTC transaction to the bitcoin node + fn broadcast_transaction(&self, tx: &BitcoinTransaction) -> Result; + /// Load the Bitcoin wallet from the given address + fn load_wallet(&self, address: &BitcoinAddress) -> Result<(), Error>; + /// Get all utxos from the given address + fn list_unspent(&self, address: &BitcoinAddress) -> Result, Error>; +} + +pub type BitcoinTransaction = bitcoin::Transaction; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("IO Error: {0}")] + IOError(#[from] std::io::Error), + #[error("RPC Error: {0}")] + RPCError(String), + #[error("{0}")] + InvalidResponseJSON(String), + #[error("Invalid utxo: {0}")] + InvalidUTXO(String), + #[error("Invalid transaction hash")] + InvalidTxHash, +} + +#[allow(non_snake_case)] +#[derive(Debug, Deserialize, Serialize, Default, PartialEq, Eq)] +pub struct UTXO { + pub txid: String, + pub vout: u32, + pub address: String, + pub label: String, + pub scriptPubKey: String, + pub amount: u64, + pub confirmations: u64, + pub redeemScript: String, + pub witnessScript: String, + pub spendable: bool, + pub solvable: bool, + pub reused: bool, + pub desc: String, + pub safe: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +struct Wallet { + name: String, + warning: String, +} + +pub struct LocalhostBitcoinNode { + bitcoind_api: String, +} + +impl BitcoinNode for LocalhostBitcoinNode { + fn broadcast_transaction(&self, tx: &BitcoinTransaction) -> Result { + let mut writer = Cursor::new(vec![]); + tx.consensus_encode(&mut writer)?; + let raw_tx = writer.into_inner().to_hex(); + + let result = self + .call("sendrawtransaction", vec![raw_tx])? + .as_str() + .ok_or(Error::InvalidResponseJSON( + "No transaction hash in sendrawtransaction response".to_string(), + ))? + .to_string(); + + Ok(Txid::from_hash( + Hash::from_str(&result).map_err(|_| Error::InvalidTxHash)?, + )) + } + + fn load_wallet(&self, address: &BitcoinAddress) -> Result<(), Error> { + let result = self.create_empty_wallet(); + if let Err(Error::RPCError(message)) = &result { + if !message.ends_with("Database already exists.\"") { + return result; + } + // If the database already exists, no problem. Just emit a warning. + warn!(message); + } + // Import the address + self.import_address(address)?; + Ok(()) + } + + /// List the UTXOs filtered on a given address. + fn list_unspent(&self, address: &BitcoinAddress) -> Result, Error> { + // Construct the params using defaults found at https://developer.bitcoin.org/reference/rpc/listunspent.html?highlight=listunspent + let addresses: Vec = vec![address.to_string()]; + let min_conf = 0i64; + let max_conf = 9999999i64; + let params = (min_conf, max_conf, addresses); + + let response = self.call("listunspent", params)?; + + // Convert the response to a vector of unspent transactions + let result: Result, Error> = response + .as_array() + .ok_or(Error::InvalidResponseJSON( + "Listunspent response is not an array".to_string(), + ))? + .iter() + .map(Self::raw_to_utxo) + .collect(); + + result + } +} + +impl LocalhostBitcoinNode { + pub fn new(bitcoind_api: String) -> LocalhostBitcoinNode { + Self { bitcoind_api } + } + + /// Make the Bitcoin RPC method call with the corresponding paramenters + fn call( + &self, + method: &str, + params: impl ureq::serde::Serialize, + ) -> Result { + debug!("Making Bitcoin RPC {} call...", method); + let json_rpc = + ureq::json!({"jsonrpc": "2.0", "id": "stx", "method": method, "params": params}); + let response = ureq::post(&self.bitcoind_api) + .send_json(json_rpc) + .map_err(|e| Error::RPCError(e.to_string()))?; + let json_response = response.into_json::()?; + let json_result = json_response + .get("result") + .ok_or_else(|| Error::InvalidResponseJSON("Missing entry 'result'.".to_string()))? + .to_owned(); + Ok(json_result) + } + + fn create_empty_wallet(&self) -> Result<(), Error> { + let wallet_name = ""; + let disable_private_keys = false; + let blank = true; + let passphrase = ""; + let avoid_reuse = false; + let descriptors = false; + let load_on_startup = true; + let params = ( + wallet_name, + disable_private_keys, + blank, + passphrase, + avoid_reuse, + descriptors, + load_on_startup, + ); + debug!("Creating wallet..."); + let wallet = serde_json::from_value::(self.call("createwallet", params)?) + .map_err(|e| Error::InvalidResponseJSON(e.to_string()))?; + if !wallet.warning.is_empty() { + warn!( + "Wallet {} was not loaded cleanly: {}", + wallet.name, wallet.warning + ); + } + Ok(()) + } + + fn import_address(&self, address: &BitcoinAddress) -> Result<(), Error> { + let address = address.to_string(); + debug!("Importing address {}...", address); + let label = ""; + let rescan = true; + let p2sh = false; + let params = (address, label, rescan, p2sh); + self.call("importaddress", params)?; + Ok(()) + } + + fn raw_to_utxo(raw: &Value) -> Result { + Ok(UTXO { + txid: raw["txid"] + .as_str() + .ok_or(Error::InvalidResponseJSON( + "Could not parse txid".to_string(), + ))? + .to_string(), + vout: raw["vout"].as_u64().ok_or(Error::InvalidResponseJSON( + "Could not parse vout".to_string(), + ))? as u32, + address: raw["address"] + .as_str() + .ok_or(Error::InvalidResponseJSON( + "Could not parse address".to_string(), + ))? + .to_string(), + label: raw["label"] + .as_str() + .ok_or(Error::InvalidResponseJSON( + "Could not parse label".to_string(), + ))? + .to_string(), + scriptPubKey: raw["scriptPubKey"] + .as_str() + .ok_or(Error::InvalidResponseJSON( + "Could not parse scriptPubKey".to_string(), + ))? + .to_string(), + amount: raw["amount"].as_f64().map(|amount| amount as u64).ok_or( + Error::InvalidResponseJSON("Could not parse amount".to_string()), + )?, + confirmations: raw["confirmations"] + .as_u64() + .ok_or(Error::InvalidResponseJSON( + "Could not parse confirmations".to_string(), + ))?, + redeemScript: "".to_string(), + witnessScript: "".to_string(), + spendable: raw["spendable"] + .as_bool() + .ok_or(Error::InvalidResponseJSON( + "Could not parse spendable".to_string(), + ))?, + solvable: raw["solvable"].as_bool().ok_or(Error::InvalidResponseJSON( + "Could not parse solvable".to_string(), + ))?, + reused: false, + desc: "".to_string(), + safe: raw["safe"].as_bool().ok_or(Error::InvalidResponseJSON( + "Could not parse safe".to_string(), + ))?, + }) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn should_map_json_to_utxo() { + let value = json!({ + "address": "bcrt1qykqup0h6ry9x3c89llzpznrvm9nfd7fqwnt0hu", + "amount": 50.00000000, + "confirmations": 123, + "label": "", + "parent_descs": [], + "safe": true, + "scriptPubKey": "00142581c0befa190a68e0e5ffc4114c6cd96696f920", + "solvable": false, + "spendable": false, + "txid": "19b7fb5fd6dc25b76aeedb812b7fdc7bf8fac343913706c8b39d23ef7375860c", + "vout": 0, + }); + + let res = LocalhostBitcoinNode::raw_to_utxo(&value).unwrap(); + + assert_eq!( + res, + UTXO { + txid: "19b7fb5fd6dc25b76aeedb812b7fdc7bf8fac343913706c8b39d23ef7375860c" + .to_string(), + vout: 0, + address: "bcrt1qykqup0h6ry9x3c89llzpznrvm9nfd7fqwnt0hu".to_string(), + label: "".to_string(), + scriptPubKey: "00142581c0befa190a68e0e5ffc4114c6cd96696f920".to_string(), + amount: 50, + confirmations: 123, + redeemScript: "".to_string(), + witnessScript: "".to_string(), + spendable: false, + solvable: false, + reused: false, + desc: "".to_string(), + safe: true, + } + ); + } +} diff --git a/degen-coordinator/src/bitcoin_wallet.rs b/degen-coordinator/src/bitcoin_wallet.rs new file mode 100644 index 00000000..705a5035 --- /dev/null +++ b/degen-coordinator/src/bitcoin_wallet.rs @@ -0,0 +1,288 @@ +use crate::bitcoin_node::UTXO; +use crate::coordinator::PublicKey; +use crate::peg_wallet::{BitcoinWallet as BitcoinWalletTrait, Error as PegWalletError}; +use crate::stacks_node::PegOutRequestOp; +use bitcoin::{ + hashes::hex::FromHex, secp256k1::Secp256k1, Address, Network, OutPoint, Script, Transaction, + TxIn, +}; +use tracing::{debug, warn}; + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { + #[error("Unable to fulfill peg-out request op due to insufficient funds.")] + InsufficientFunds, + #[error("Invalid unspent transaction id: {0}")] + InvalidTransactionID(String), + #[error("Missing peg-out fulfillment utxo.")] + MissingFulfillmentUTXO, + #[error("Fulfillment UTXO amount does not equal the fulfillment fee.")] + MismatchedFulfillmentFee, +} + +pub struct BitcoinWallet { + address: Address, + public_key: PublicKey, +} + +impl BitcoinWallet { + pub fn new(public_key: PublicKey, network: Network) -> Self { + let secp = Secp256k1::verification_only(); + let address = bitcoin::Address::p2tr(&secp, public_key, None, network); + Self { + address, + public_key, + } + } +} + +/// Minimum dust required +const DUST_UTXO_LIMIT: u64 = 5500; + +impl BitcoinWalletTrait for BitcoinWallet { + type Error = Error; + fn fulfill_peg_out( + &self, + op: &PegOutRequestOp, + available_utxos: Vec, + ) -> Result { + // Create an empty transaction + let mut tx = Transaction { + version: 2, + lock_time: bitcoin::PackedLockTime(0), + input: vec![], + output: vec![], + }; + // Consume UTXOs until we have enough to cover the total spend (fulfillment fee and peg out amount) + let mut total_consumed = 0; + let mut utxos = vec![]; + let mut fulfillment_utxo = None; + for utxo in available_utxos.into_iter() { + if utxo.txid == op.txid.to_string() && utxo.vout == 2 { + // This is the fulfillment utxo. + if utxo.amount != op.fulfillment_fee { + // Something is wrong. The fulfillment fee should match the fulfillment utxo amount. + // Malformed Peg Request Op + return Err(PegWalletError::from(Error::MismatchedFulfillmentFee)); + } + fulfillment_utxo = Some(utxo); + } else if total_consumed < op.amount { + total_consumed += utxo.amount; + utxos.push(utxo); + } else if fulfillment_utxo.is_some() { + // We have consumed enough to cover the total spend + // i.e. have found the fulfillment utxo and covered the peg out amount + break; + } + } + // Sanity check all the things! + // If we did not find the fulfillment utxo, something went wrong + let fulfillment_utxo = fulfillment_utxo.ok_or_else(|| { + warn!("Failed to find fulfillment utxo."); + Error::MissingFulfillmentUTXO + })?; + // Check that we have sufficient funds and didn't just run out of available utxos. + if total_consumed < op.amount { + warn!( + "Consumed total {} is less than intended spend: {}", + total_consumed, op.amount + ); + return Err(PegWalletError::from(Error::InsufficientFunds)); + } + // Get the transaction change amount + let change_amount = total_consumed - op.amount; + debug!( + "change_amount: {:?}, total_consumed: {:?}, op.amount: {:?}", + change_amount, total_consumed, op.amount + ); + if change_amount >= DUST_UTXO_LIMIT { + let secp = Secp256k1::verification_only(); + let script_pubkey = Script::new_v1_p2tr(&secp, self.public_key, None); + let change_output = bitcoin::TxOut { + value: change_amount, + script_pubkey, + }; + tx.output.push(change_output); + } else { + // Instead of leaving that change to the BTC miner, we could / should bump the sortition fee + debug!("Not enough change to clear dust limit. Not adding change address."); + } + // Convert the utxos to inputs for the transaction, ensuring the fulfillment utxo is the first input + let fulfillment_input = utxo_to_input(fulfillment_utxo)?; + tx.input.push(fulfillment_input); + for utxo in utxos { + let input = utxo_to_input(utxo)?; + tx.input.push(input); + } + Ok(tx) + } + + fn address(&self) -> &Address { + &self.address + } +} + +// Helper function to convert a utxo to an unsigned input +fn utxo_to_input(utxo: UTXO) -> Result { + let input = TxIn { + previous_output: OutPoint { + txid: bitcoin::Txid::from_hex(&utxo.txid) + .map_err(|_| Error::InvalidTransactionID(utxo.txid))?, + vout: utxo.vout, + }, + script_sig: Default::default(), + sequence: bitcoin::Sequence(0xFFFFFFFD), // allow RBF + witness: Default::default(), + }; + Ok(input) +} + +#[cfg(test)] +mod tests { + use super::{BitcoinWallet, Error}; + use crate::bitcoin_node::UTXO; + use crate::coordinator::PublicKey; + use crate::peg_wallet::{BitcoinWallet as BitcoinWalletTrait, Error as PegWalletError}; + use crate::util::test::{build_peg_out_request_op, PRIVATE_KEY_HEX}; + use hex::encode; + use rand::Rng; + use std::str::FromStr; + + /// Helper function to build a valid bitcoin wallet + fn bitcoin_wallet() -> BitcoinWallet { + let public_key = + PublicKey::from_str("cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115") + .expect("Failed to construct a valid public key for the bitcoin wallet"); + BitcoinWallet::new(public_key, bitcoin::Network::Testnet) + } + + /// Helper function for building a random txid (32 byte hex string) + fn generate_txid() -> String { + let data: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + encode(data) + } + + /// Helper function for building a utxo with the given txid, vout, and amount + fn build_utxo(txid: String, vout: u32, amount: u64) -> UTXO { + UTXO { + txid, + vout, + amount, + ..Default::default() + } + } + + /// Helper function for building a vector of nmb utxos with amounts increasing by 10000 + fn build_utxos(nmb: u32) -> Vec { + (1..=nmb) + .map(|i| build_utxo(generate_txid(), i, i as u64 * 10000)) + .collect() + } + + #[test] + fn fulfill_peg_out_insufficient_funds() { + let wallet = bitcoin_wallet(); + let amount = 200000; + + // (1+2+3+4+5)*10000 = 1500000 < 200000. Insufficient funds. + let mut txouts = build_utxos(5); + + let op = build_peg_out_request_op(PRIVATE_KEY_HEX, amount, 1, 1); + // Build a fulfillment utxo that matches the generated op + let fulfillment_utxo = build_utxo(op.txid.to_string(), 2, 1); + txouts.push(fulfillment_utxo); + + let result = wallet.fulfill_peg_out(&op, txouts); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap(), + PegWalletError::BitcoinWalletError(Error::InsufficientFunds) + ); + } + + #[test] + fn fulfill_peg_out_change() { + let wallet = bitcoin_wallet(); + let amount = 200000; + + // (1+2+3+4+5)*10000 = 210000 > 200000. We have change of 10000 + let mut txouts = build_utxos(6); // (1+2+3+4+5+6)*10000 = 210000 + + let op = build_peg_out_request_op(PRIVATE_KEY_HEX, amount, 1, 1); + // Build a fulfillment utxo that matches the generated op + let fulfillment_utxo = build_utxo(op.txid.to_string(), 2, 1); + txouts.push(fulfillment_utxo); + + let btc_tx = wallet.fulfill_peg_out(&op, txouts).unwrap(); + assert_eq!(btc_tx.input.len(), 7); + assert_eq!(btc_tx.output.len(), 1); // We have change! + assert_eq!(btc_tx.output[0].value, 10000); + } + + #[test] + fn fulfill_peg_out_no_change() { + let wallet = bitcoin_wallet(); + let amount = 9999; + + // 1*10000 = 10000 > 9999. We only have change of 1...not enough to cover dust + let mut txouts = build_utxos(1); // 1*10000 = 10000 + + let op = build_peg_out_request_op(PRIVATE_KEY_HEX, amount, 1, 1); + // Build a fulfillment utxo that matches the generated op + let fulfillment_utxo = build_utxo(op.txid.to_string(), 2, 1); + txouts.push(fulfillment_utxo); + + let btc_tx = wallet.fulfill_peg_out(&op, txouts).unwrap(); + assert_eq!(btc_tx.input.len(), 2); + assert_eq!(btc_tx.output.len(), 0); // No change! + } + + #[test] + fn fulfill_peg_out_missing_fulfillment_utxo() { + let wallet = bitcoin_wallet(); + let amount = 9999; + + let mut txouts = vec![]; + + let op = build_peg_out_request_op(PRIVATE_KEY_HEX, amount, 1, 1); + // Build a fulfillment utxo that matches the generated op, but with an invalid vout (i.e. incorrect vout) + let fulfillment_utxo_invalid_vout = build_utxo(op.txid.to_string(), 1, 1); + // Build a fulfillment utxo that does not match the generated op (i.e. mismatched txid) + let fulfillment_utxo_invalid_txid = build_utxo(generate_txid(), 2, 1); + txouts.push(fulfillment_utxo_invalid_vout); + txouts.push(fulfillment_utxo_invalid_txid); + + let result = wallet.fulfill_peg_out(&op, txouts); + + assert!(result.is_err()); + assert_eq!( + result.err().unwrap(), + PegWalletError::BitcoinWalletError(Error::MissingFulfillmentUTXO) + ); + } + + #[test] + fn fulfill_peg_out_mismatched_fulfillment_utxo() { + let wallet = bitcoin_wallet(); + let amount = 9999; + + let mut txouts = vec![]; + + let op = build_peg_out_request_op(PRIVATE_KEY_HEX, amount, 1, 10); + // Build a fulfillment utxo that matches the generated op, but has an invalid amount (does not cover the fulfillment fee) + let fulfillment_utxo_invalid_amount = build_utxo(op.txid.to_string(), 2, 1); + txouts.push(fulfillment_utxo_invalid_amount); + + let result = wallet.fulfill_peg_out(&op, txouts); + + assert!(result.is_err()); + assert_eq!( + result.err().unwrap(), + PegWalletError::BitcoinWalletError(Error::MismatchedFulfillmentFee) + ); + } +} diff --git a/degen-coordinator/src/cli.rs b/degen-coordinator/src/cli.rs new file mode 100644 index 00000000..d6adeba3 --- /dev/null +++ b/degen-coordinator/src/cli.rs @@ -0,0 +1,35 @@ +use clap::Parser; + +///Command line interface for stacks coordinator +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + /// Config file path + /// TODO: pull this info from sBTC + #[arg(short, long)] + pub config: String, + + /// Optional starting block height to use. + /// Will override any listed value within the config file + #[arg(short = 'b', long)] + pub start_block_height: Option, + + /// Signer Config file path + /// TODO: this should not be a seperate option really + #[arg(short, long)] + pub signer_config: String, + + /// Subcommand to perform + #[clap(subcommand)] + pub command: Command, +} + +#[derive(clap::Subcommand, Debug)] +pub enum Command { + // Listen for incoming peg in and peg out requests. + Run, + // Run distributed key generation round + Dkg, + // Run distributed key generation round then sign a message + DkgSign, +} diff --git a/degen-coordinator/src/config.rs b/degen-coordinator/src/config.rs new file mode 100644 index 00000000..77a92e84 --- /dev/null +++ b/degen-coordinator/src/config.rs @@ -0,0 +1,42 @@ +// TODO: Set appropriate types +type ContractIdentifier = String; +type StacksPrivateKey = String; +type Url = String; + +/// Errors associated with reading the Config file +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("IO Error: {0}")] + IOError(#[from] std::io::Error), + #[error("Toml Error: {0}")] + TomlError(#[from] toml::de::Error), +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Network { + Mainnet, + Testnet, +} + +#[derive(serde::Deserialize)] +pub struct Config { + pub sbtc_contract: ContractIdentifier, + pub stacks_private_key: StacksPrivateKey, + pub stacks_node_rpc_url: Url, + pub bitcoin_node_rpc_url: Url, + pub frost_dkg_round_id: u64, + pub signer_config_path: String, + pub start_block_height: Option, + pub rusqlite_path: Option, + /// The network version we are using ('mainnet' or 'testnet'). Default: 'mainnet' + pub network: Option, + /// The transaction fee in Satoshis used to broadcast transactions to the stacks node + pub transaction_fee: u64, +} + +impl Config { + pub fn from_path(path: impl AsRef) -> Result { + Ok(toml::from_str(&std::fs::read_to_string(path)?)?) + } +} diff --git a/degen-coordinator/src/coordinator.rs b/degen-coordinator/src/coordinator.rs new file mode 100644 index 00000000..44b4a453 --- /dev/null +++ b/degen-coordinator/src/coordinator.rs @@ -0,0 +1,395 @@ +use bitcoin::{ + psbt::Prevouts, + secp256k1::Error as Secp256k1Error, + util::{ + base58, + sighash::{Error as SighashError, SighashCache}, + }, + SchnorrSighashType, XOnlyPublicKey, +}; +use blockstack_lib::chainstate::stacks::TransactionVersion; +use frost_coordinator::{coordinator::Error as FrostCoordinatorError, create_coordinator}; +use frost_signer::net::{Error as HttpNetError, HttpNetListen}; +use std::sync::{ + mpsc, + mpsc::{RecvError, Sender}, +}; +use std::{thread, time}; +use tracing::debug; +use wsts::{bip340::SchnorrProof, common::Signature}; + +use crate::bitcoin_wallet::BitcoinWallet; +use crate::config::{Config, Network}; +use crate::peg_wallet::{ + BitcoinWallet as BitcoinWalletTrait, Error as PegWalletError, PegWallet, + StacksWallet as StacksWalletTrait, WrapPegWallet, +}; +use crate::stacks_node::{self, Error as StacksNodeError}; +use crate::stacks_wallet::StacksWallet; + +// Traits in scope +use crate::bitcoin_node::{ + BitcoinNode, BitcoinTransaction, Error as BitcoinNodeError, LocalhostBitcoinNode, +}; +use crate::peg_queue::{ + Error as PegQueueError, PegQueue, SbtcOp, SqlitePegQueue, SqlitePegQueueError, +}; +use crate::stacks_node::{client::NodeClient, StacksNode}; + +type FrostCoordinator = frost_coordinator::coordinator::Coordinator; + +pub type PublicKey = XOnlyPublicKey; + +/// Helper that uses this module's error type +pub type Result = std::result::Result; + +/// Kinds of common errors used by stacks coordinator +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Error occurred with the HTTP Relay + #[error("Http Network Error: {0}")] + HttpNetError(#[from] HttpNetError), + /// Error occurred in the Peg Queue + #[error("Peg Queue Error: {0}")] + PegQueueError(#[from] PegQueueError), + // Error occurred in the Peg Wallet + #[error("Peg Wallet Error: {0}")] + PegWalletError(#[from] PegWalletError), + /// Error occurred in the Frost Coordinator + #[error("Frost Coordinator Error: {0}")] + FrostCoordinatorError(#[from] FrostCoordinatorError), + /// Error occurred in the Sqlite Peg Queue + #[error("Sqlite Peg Queue Error: {0}")] + SqlitePegQueueError(#[from] SqlitePegQueueError), + #[error("Command sender disconnected unexpectedly: {0}")] + UnexpectedSenderDisconnect(#[from] RecvError), + #[error("Stacks Node Error: {0}")] + StacksNodeError(#[from] StacksNodeError), + #[error("Bitcoin Node Error: {0}")] + BitcoinNodeError(#[from] BitcoinNodeError), + #[error("{0}")] + ConfigError(String), + #[error( + "Invalid generated aggregate public key. Frost coordinator/signers may be misconfigured." + )] + InvalidPublicKey(#[from] Secp256k1Error), + #[error("Error occured during signing: {0}")] + SigningError(#[from] SighashError), +} + +pub trait Coordinator: Sized { + type PegQueue: PegQueue; + type FeeWallet: PegWallet; + type StacksNode: StacksNode; + type BitcoinNode: BitcoinNode; + + // Required methods + fn peg_queue(&self) -> &Self::PegQueue; + fn fee_wallet_mut(&mut self) -> &mut Self::FeeWallet; + fn fee_wallet(&self) -> &Self::FeeWallet; + fn frost_coordinator(&self) -> &FrostCoordinator; + fn frost_coordinator_mut(&mut self) -> &mut FrostCoordinator; + fn stacks_node(&self) -> &Self::StacksNode; + fn bitcoin_node(&self) -> &Self::BitcoinNode; + + // Provided methods + fn run(mut self) -> Result<()> { + let (sender, receiver) = mpsc::channel::(); + Self::poll_ping_thread(sender); + + loop { + match receiver.recv()? { + Command::Stop => break, + Command::Timeout => { + self.peg_queue().poll(self.stacks_node())?; + self.process_queue()?; + } + } + } + Ok(()) + } + + fn poll_ping_thread(sender: Sender) { + thread::spawn(move || loop { + sender + .send(Command::Timeout) + .expect("thread send error {0}"); + thread::sleep(time::Duration::from_millis(500)); + }); + } + + fn process_queue(&mut self) -> Result<()> { + match self.peg_queue().sbtc_op()? { + Some(SbtcOp::PegIn(op)) => self.peg_in(op), + Some(SbtcOp::PegOutRequest(op)) => self.peg_out(op), + None => Ok(()), + } + } +} + +// Private helper functions +trait CoordinatorHelpers: Coordinator { + fn peg_in(&mut self, op: stacks_node::PegInOp) -> Result<()> { + // Retrieve the nonce from the stacks node using the sBTC wallet address + let nonce = self + .stacks_node() + .next_nonce(self.fee_wallet().stacks().address())?; + + // Build a mint transaction using the peg in op and calculated nonce + let tx = self + .fee_wallet() + .stacks() + .build_mint_transaction(&op, nonce)?; + + // Broadcast the resulting sBTC transaction to the stacks node + self.stacks_node().broadcast_transaction(&tx)?; + Ok(()) + } + + fn peg_out(&mut self, op: stacks_node::PegOutRequestOp) -> Result<()> { + // Retrieve the nonce from the stacks node using the sBTC wallet address + let nonce = self + .stacks_node() + .next_nonce(self.fee_wallet().stacks().address())?; + + // Build a burn transaction using the peg out request op and calculated nonce + let burn_tx = self + .fee_wallet() + .stacks() + .build_burn_transaction(&op, nonce)?; + + // Broadcast the resulting sBTC transaction to the stacks node + self.stacks_node().broadcast_transaction(&burn_tx)?; + + // Build and sign a fulfilled bitcoin transaction + let fulfill_tx = self.fulfill_peg_out(&op)?; + + // Broadcast the resulting BTC transaction to the Bitcoin node + self.bitcoin_node().broadcast_transaction(&fulfill_tx)?; + Ok(()) + } + + fn fulfill_peg_out(&mut self, op: &stacks_node::PegOutRequestOp) -> Result { + // Retreive the utxos + let utxos = self + .bitcoin_node() + .list_unspent(self.fee_wallet().bitcoin().address())?; + + // Build unsigned fulfilled peg out transaction + let mut tx = self.fee_wallet().bitcoin().fulfill_peg_out(op, utxos)?; + + // Sign the transaction + for index in 0..tx.input.len() { + let mut comp = SighashCache::new(&tx); + + let taproot_sighash = comp + .taproot_signature_hash( + index, + &Prevouts::All(&tx.output), + None, + None, + SchnorrSighashType::All, + ) + .map_err(Error::SigningError)?; + let (_frost_sig, schnorr_proof) = self + .frost_coordinator_mut() + .sign_message(&taproot_sighash)?; + + debug!( + "Fulfill Tx {:?} SchnorrProof ({},{})", + &tx, schnorr_proof.r, schnorr_proof.s + ); + + let finalized = [ + schnorr_proof.to_bytes().as_ref(), + &[SchnorrSighashType::All as u8], + ] + .concat(); + let finalized_b58 = base58::encode_slice(&finalized); + debug!("CALC SIG ({}) {}", finalized.len(), finalized_b58); + + tx.input[index].witness.push(finalized); + } + //Return the signed transaction + Ok(tx) + } +} + +impl CoordinatorHelpers for T {} + +pub enum Command { + Stop, + Timeout, +} + +pub struct StacksCoordinator { + frost_coordinator: FrostCoordinator, + local_peg_queue: SqlitePegQueue, + local_stacks_node: NodeClient, + local_bitcoin_node: LocalhostBitcoinNode, + pub local_fee_wallet: WrapPegWallet, +} + +impl StacksCoordinator { + pub fn run_dkg_round(&mut self) -> Result { + let p = self.frost_coordinator.run_distributed_key_generation()?; + PublicKey::from_slice(&p.x().to_bytes()).map_err(Error::InvalidPublicKey) + } + + pub fn sign_message(&mut self, message: &str) -> Result<(Signature, SchnorrProof)> { + Ok(self.frost_coordinator.sign_message(message.as_bytes())?) + } +} + +impl TryFrom for StacksCoordinator { + type Error = Error; + fn try_from(mut config: Config) -> Result { + // Determine what network we are running on + let (version, bitcoin_network) = match config.network.as_ref().unwrap_or(&Network::Mainnet) + { + Network::Mainnet => (TransactionVersion::Mainnet, bitcoin::Network::Bitcoin), + Network::Testnet => (TransactionVersion::Testnet, bitcoin::Network::Testnet), + }; + + // Create the frost coordinator and use it to generate the aggregate public key and corresponding bitcoin wallet address + // Note: all errors returned from create_coordinator relate to configuration issues. Convert to this error. + let mut frost_coordinator = + create_coordinator(&config.signer_config_path).map_err(|e| { + Error::ConfigError(format!( + "Invalid signer_config_path {:?}: {}", + &config.signer_config_path, e + )) + })?; + frost_coordinator.run_distributed_key_generation()?; + // This should not be run on startup unless required: + // 1. No aggregate public key stored in persitent storage anywhere + // 2. no address already set in sbtc contract (get-bitcoin-wallet-address) + let pubkey = frost_coordinator.get_aggregate_public_key()?; + let xonly_pubkey = + PublicKey::from_slice(&pubkey.x().to_bytes()).map_err(Error::InvalidPublicKey)?; + + let local_stacks_node = NodeClient::new(&config.stacks_node_rpc_url); + // If a user has not specified a start block height, begin from the current burn block height by default + let burn_block_height = local_stacks_node.burn_block_height()?; + config.start_block_height = config.start_block_height.or(Some(burn_block_height)); + + // Create the bitcoin and stacks wallets + let bitcoin_wallet = BitcoinWallet::new(xonly_pubkey, bitcoin_network); + let stacks_wallet = StacksWallet::new( + config.sbtc_contract.clone(), + &config.stacks_private_key, + version, + 10, + ) + .map_err(|e| Error::ConfigError(e.to_string()))?; + + // Set the bitcoin address using the sbtc contract + let nonce = local_stacks_node.next_nonce(stacks_wallet.address())?; + let tx = + stacks_wallet.build_set_btc_address_transaction(bitcoin_wallet.address(), nonce)?; + local_stacks_node.broadcast_transaction(&tx)?; + + let local_bitcoin_node = LocalhostBitcoinNode::new(config.bitcoin_node_rpc_url.clone()); + local_bitcoin_node.load_wallet(bitcoin_wallet.address())?; + + let local_fee_wallet = WrapPegWallet { + bitcoin_wallet, + stacks_wallet, + }; + let local_peg_queue = SqlitePegQueue::try_from(&config)?; + + Ok(Self { + local_peg_queue, + local_stacks_node, + local_bitcoin_node, + frost_coordinator, + local_fee_wallet, + }) + } +} + +impl Coordinator for StacksCoordinator { + type PegQueue = SqlitePegQueue; + type FeeWallet = WrapPegWallet; + type StacksNode = NodeClient; + type BitcoinNode = LocalhostBitcoinNode; + + fn peg_queue(&self) -> &Self::PegQueue { + &self.local_peg_queue + } + + fn fee_wallet_mut(&mut self) -> &mut Self::FeeWallet { + &mut self.local_fee_wallet + } + + fn fee_wallet(&self) -> &Self::FeeWallet { + &self.local_fee_wallet + } + + fn frost_coordinator(&self) -> &FrostCoordinator { + &self.frost_coordinator + } + + fn frost_coordinator_mut(&mut self) -> &mut FrostCoordinator { + &mut self.frost_coordinator + } + + fn stacks_node(&self) -> &Self::StacksNode { + &self.local_stacks_node + } + + fn bitcoin_node(&self) -> &Self::BitcoinNode { + &self.local_bitcoin_node + } +} + +#[cfg(test)] +mod tests { + use crate::config::Config; + use crate::coordinator::{CoordinatorHelpers, StacksCoordinator}; + use crate::stacks_node::PegOutRequestOp; + use bitcoin::consensus::Encodable; + use blockstack_lib::burnchains::Txid; + use blockstack_lib::chainstate::stacks::address::{PoxAddress, PoxAddressType20}; + use blockstack_lib::types::chainstate::BurnchainHeaderHash; + + #[ignore] + #[test] + fn btc_fulfill_peg_out() { + let config = Config { + sbtc_contract: "".to_string(), + stacks_private_key: "".to_string(), + stacks_node_rpc_url: "".to_string(), + bitcoin_node_rpc_url: "".to_string(), + frost_dkg_round_id: 0, + signer_config_path: "conf/signer.toml".to_string(), + start_block_height: None, + rusqlite_path: None, + network: None, + transaction_fee: 10, + }; + // todo: make StacksCoordinator with mock FrostCoordinator to locally generate PublicKey and Signature for unit test + let mut sc = StacksCoordinator::try_from(config).unwrap(); + let recipient = PoxAddress::Addr20(false, PoxAddressType20::P2WPKH, [0; 20]); + let peg_wallet_address = PoxAddress::Addr20(false, PoxAddressType20::P2WPKH, [0; 20]); + let op = PegOutRequestOp { + amount: 0, + recipient: recipient, + signature: blockstack_lib::util::secp256k1::MessageSignature([0; 65]), + peg_wallet_address: peg_wallet_address, + fulfillment_fee: 0, + memo: vec![], + txid: Txid([0; 32]), + vtxindex: 0, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0; 32]), + }; + let btc_tx_result = sc.fulfill_peg_out(&op); + assert!(btc_tx_result.is_ok()); + let btc_tx = btc_tx_result.unwrap(); + let mut btc_tx_encoded: Vec = vec![]; + btc_tx.consensus_encode(&mut btc_tx_encoded).unwrap(); + let verify_result = bitcoin::bitcoinconsensus::verify(&[], 100, &btc_tx_encoded, 0); + assert!(verify_result.is_ok()) + } +} diff --git a/degen-coordinator/src/lib.rs b/degen-coordinator/src/lib.rs new file mode 100644 index 00000000..15960fe9 --- /dev/null +++ b/degen-coordinator/src/lib.rs @@ -0,0 +1,10 @@ +pub mod bitcoin_node; +pub mod bitcoin_wallet; +pub mod cli; +pub mod config; +pub mod coordinator; +pub mod peg_queue; +pub mod peg_wallet; +pub mod stacks_node; +pub mod stacks_wallet; +mod util; diff --git a/degen-coordinator/src/main.rs b/degen-coordinator/src/main.rs new file mode 100644 index 00000000..42f444a5 --- /dev/null +++ b/degen-coordinator/src/main.rs @@ -0,0 +1,70 @@ +use clap::Parser; +use frost_signer::logging; +use stacks_coordinator::cli::{Cli, Command}; +use stacks_coordinator::config::Config; +use stacks_coordinator::coordinator::{Coordinator, StacksCoordinator}; +use tracing::{error, info, warn}; + +fn main() { + let cli = Cli::parse(); + + // Initialize logging + logging::initiate_tracing_subscriber().unwrap(); + + //TODO: get configs from sBTC contract + match Config::from_path(&cli.config) { + Ok(mut config) => { + config.signer_config_path = cli.signer_config; + if cli.start_block_height.is_some() { + config.start_block_height = cli.start_block_height; + } + match StacksCoordinator::try_from(config) { + Ok(mut coordinator) => { + // Determine what action the caller wishes to perform + match cli.command { + Command::Run => { + info!("Running Coordinator"); + //TODO: set up coordination with the stacks node + if let Err(e) = coordinator.run() { + error!("An error occurred running the coordinator: {}", e); + } + } + Command::Dkg => { + info!("Running DKG Round"); + if let Err(e) = coordinator.run_dkg_round() { + error!("An error occurred during DKG round: {}", e); + } + } + Command::DkgSign => { + info!("Running DKG Round"); + if let Err(e) = coordinator.run_dkg_round() { + warn!("An error occurred during DKG round: {}", e); + }; + info!("Running Signing Round"); + let (signature, schnorr_proof) = + match coordinator.sign_message("Hello, world!") { + Ok((sig, proof)) => (sig, proof), + Err(e) => { + panic!("signing message failed: {e}"); + } + }; + info!( + "Got good signature ({},{}) and schnorr proof ({},{})", + &signature.R, &signature.z, &schnorr_proof.r, &schnorr_proof.s + ); + } + }; + } + Err(e) => { + error!("An error occurred creating coordinator: {}", e); + } + } + } + Err(e) => { + error!( + "An error occurred reading config file {}: {}", + cli.config, e + ); + } + } +} diff --git a/degen-coordinator/src/peg_queue/mod.rs b/degen-coordinator/src/peg_queue/mod.rs new file mode 100644 index 00000000..19e0f190 --- /dev/null +++ b/degen-coordinator/src/peg_queue/mod.rs @@ -0,0 +1,46 @@ +use blockstack_lib::burnchains::Txid; +use blockstack_lib::types::chainstate::BurnchainHeaderHash; + +use crate::stacks_node; +use crate::stacks_node::Error as StacksNodeError; +mod sqlite_peg_queue; + +pub use sqlite_peg_queue::{Error as SqlitePegQueueError, SqlitePegQueue}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Sqlite Peg Queue Error: {0}")] + SqlitePegQueueError(#[from] SqlitePegQueueError), + #[error("Stacks Node Error: {0}")] + StacksNodeError(#[from] StacksNodeError), +} + +pub trait PegQueue { + fn sbtc_op(&self) -> Result, Error>; + fn poll(&self, stacks_node: &N) -> Result<(), Error>; + + fn acknowledge(&self, txid: &Txid, burn_header_hash: &BurnchainHeaderHash) + -> Result<(), Error>; +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub enum SbtcOp { + PegIn(stacks_node::PegInOp), + PegOutRequest(stacks_node::PegOutRequestOp), +} + +impl SbtcOp { + pub fn as_peg_in(&self) -> Option<&stacks_node::PegInOp> { + match self { + Self::PegIn(op) => Some(op), + _ => None, + } + } + + pub fn as_peg_out_request(&self) -> Option<&stacks_node::PegOutRequestOp> { + match self { + Self::PegOutRequest(op) => Some(op), + _ => None, + } + } +} diff --git a/degen-coordinator/src/peg_queue/sqlite_peg_queue.rs b/degen-coordinator/src/peg_queue/sqlite_peg_queue.rs new file mode 100644 index 00000000..05d84c99 --- /dev/null +++ b/degen-coordinator/src/peg_queue/sqlite_peg_queue.rs @@ -0,0 +1,505 @@ +use rusqlite::{Connection as RusqliteConnection, Error as RusqliteError, Row as SqliteRow}; +use std::path::Path; +use std::str::FromStr; + +use blockstack_lib::burnchains::Txid; +use blockstack_lib::types::chainstate::BurnchainHeaderHash; +use blockstack_lib::util::HexError; + +use crate::config::Config; +use crate::peg_queue::{Error as PegQueueError, PegQueue, SbtcOp}; +use crate::stacks_node::{Error as StacksNodeError, PegInOp, PegOutRequestOp, StacksNode}; + +use tracing::{debug, info}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Rusqlite Error: {0}")] + RusqliteError(#[from] RusqliteError), + #[error("JSON serialization failure: {0}")] + JsonError(#[from] serde_json::Error), + #[error("Hex codec error: {0}")] + HexError(#[from] HexError), + #[error("Did not recognize status: {0}")] + InvalidStatusError(String), + #[error("Entry does not exist")] + EntryDoesNotExist, + #[error("Missing Start Block Height")] + MissingStartBlockHeight, +} + +// Workaround to allow non-perfect conversions in `Entry::from_row` +impl From for rusqlite::Error { + fn from(err: Error) -> Self { + Self::InvalidColumnType(0, err.to_string(), rusqlite::types::Type::Text) + } +} + +pub struct SqlitePegQueue { + conn: rusqlite::Connection, + start_block_height: u64, +} + +impl TryFrom<&Config> for SqlitePegQueue { + type Error = Error; + fn try_from(cfg: &Config) -> Result { + let start_block_height = cfg + .start_block_height + .ok_or_else(|| Error::MissingStartBlockHeight)?; + if let Some(path) = &cfg.rusqlite_path { + Self::new(path, start_block_height) + } else { + Self::in_memory(start_block_height) + } + } +} +impl SqlitePegQueue { + pub fn new>(path: P, start_block_height: u64) -> Result { + Self::from_connection(RusqliteConnection::open(path)?, start_block_height) + } + + pub fn in_memory(start_block_height: u64) -> Result { + Self::from_connection(RusqliteConnection::open_in_memory()?, start_block_height) + } + + fn from_connection(conn: RusqliteConnection, start_block_height: u64) -> Result { + let this = Self { + conn, + start_block_height, + }; + this.conn.execute(Self::sql_schema(), rusqlite::params![])?; + Ok(this) + } + + fn poll_peg_in_ops( + &self, + stacks_node: &N, + block_height: u64, + ) -> Result<(), PegQueueError> { + match stacks_node.get_peg_in_ops(block_height) { + Err(StacksNodeError::UnknownBlockHeight(height)) => { + debug!("Failed to find burn block height {}", height); + } + Err(e) => return Err(PegQueueError::from(e)), + Ok(peg_in_ops) => { + for peg_in_op in peg_in_ops { + let entry = Entry::from(peg_in_op); + self.insert(&entry)?; + } + } + } + Ok(()) + } + + fn poll_peg_out_request_ops( + &self, + stacks_node: &N, + block_height: u64, + ) -> Result<(), PegQueueError> { + match stacks_node.get_peg_out_request_ops(block_height) { + Err(StacksNodeError::UnknownBlockHeight(height)) => { + debug!("Failed to find burn block height {}", height); + } + Err(e) => return Err(PegQueueError::from(e)), + Ok(peg_out_request_ops) => { + for peg_out_request_op in peg_out_request_ops { + let entry = Entry::from(peg_out_request_op); + self.insert(&entry)?; + } + } + } + Ok(()) + } + fn insert(&self, entry: &Entry) -> Result<(), Error> { + self.conn.execute( + Self::sql_insert(), + rusqlite::params![ + entry.txid.to_hex(), + entry.burn_header_hash.to_hex(), + entry.block_height as i64, // Stacks will crash before the coordinator if this is invalid + serde_json::to_string(&entry.op)?, + entry.status.as_str(), + ], + )?; + + Ok(()) + } + + fn get_single_entry_with_status(&self, status: &Status) -> Result, Error> { + Ok(self + .conn + .prepare(Self::sql_select_status())? + .query_map(rusqlite::params![status.as_str()], Entry::from_row)? + .next() + .transpose()?) + } + + fn get_entry( + &self, + txid: &Txid, + burn_header_hash: &BurnchainHeaderHash, + ) -> Result { + Ok(self.conn.prepare(Self::sql_select_pk())?.query_row( + rusqlite::params![txid.to_hex(), burn_header_hash.to_hex()], + Entry::from_row, + )?) + } + + fn max_observed_block_height(&self) -> Result { + Ok(self + .conn + .query_row( + Self::sql_select_max_burn_height(), + rusqlite::params![], + |row| row.get::<_, i64>(0), + ) + .map(|count| count as u64)?) + } + + const fn sql_schema() -> &'static str { + r#" + CREATE TABLE IF NOT EXISTS sbtc_ops ( + txid TEXT NOT NULL, + burn_header_hash TEXT NOT NULL, + block_height INTEGER NOT NULL, + op TEXT NOT NULL, + status TEXT NOT NULL, + + PRIMARY KEY(txid, burn_header_hash) + ) + "# + } + + const fn sql_insert() -> &'static str { + r#" + REPLACE INTO sbtc_ops (txid, burn_header_hash, block_height, op, status) VALUES (?1, ?2, ?3, ?4, ?5) + "# + } + + const fn sql_select_status() -> &'static str { + r#" + SELECT txid, burn_header_hash, block_height, op, status FROM sbtc_ops WHERE status=?1 ORDER BY block_height, op ASC + "# + } + + const fn sql_select_pk() -> &'static str { + r#" + SELECT txid, burn_header_hash, block_height, op, status FROM sbtc_ops WHERE txid=?1 AND burn_header_hash=?2 + "# + } + + const fn sql_select_max_burn_height() -> &'static str { + r#" + SELECT MAX(block_height) FROM sbtc_ops + "# + } +} + +impl PegQueue for SqlitePegQueue { + fn sbtc_op(&self) -> Result, PegQueueError> { + let maybe_entry = self.get_single_entry_with_status(&Status::New)?; + + let Some(mut entry) = maybe_entry else { + return Ok(None) + }; + + entry.status = Status::Pending; + self.insert(&entry)?; + + Ok(Some(entry.op)) + } + + fn poll(&self, stacks_node: &N) -> Result<(), PegQueueError> { + let target_block_height = stacks_node.burn_block_height()?; + let start_block_height = self + .max_observed_block_height() + .map(|count| count + 1) + .unwrap_or(self.start_block_height); + info!( + "Checking for peg-in and peg-out requests for block heights {} to {}", + start_block_height, target_block_height + ); + for block_height in start_block_height..=target_block_height { + self.poll_peg_in_ops(stacks_node, block_height)?; + self.poll_peg_out_request_ops(stacks_node, block_height)?; + } + Ok(()) + } + + fn acknowledge( + &self, + txid: &Txid, + burn_header_hash: &BurnchainHeaderHash, + ) -> Result<(), PegQueueError> { + let mut entry = self.get_entry(txid, burn_header_hash)?; + + entry.status = Status::Acknowledged; + self.insert(&entry)?; + + Ok(()) + } +} + +#[derive(Debug)] +struct Entry { + burn_header_hash: BurnchainHeaderHash, + txid: Txid, + block_height: u64, + op: SbtcOp, + status: Status, +} + +impl Entry { + fn from_row(row: &SqliteRow) -> Result { + let txid = Txid::from_hex(&row.get::<_, String>(0)?).map_err(Error::from)?; + + let burn_header_hash = + BurnchainHeaderHash::from_hex(&row.get::<_, String>(1)?).map_err(Error::from)?; + + let block_height = row.get::<_, i64>(2)? as u64; // Stacks will crash before the coordinator if this is invalid + + let op: SbtcOp = serde_json::from_str(&row.get::<_, String>(3)?).map_err(Error::from)?; + + let status: Status = row.get::<_, String>(4)?.parse()?; + + Ok(Self { + burn_header_hash, + txid, + block_height, + op, + status, + }) + } +} + +impl From for Entry { + fn from(op: PegInOp) -> Self { + Self { + block_height: op.block_height, + status: Status::New, + txid: op.txid, + burn_header_hash: op.burn_header_hash, + op: SbtcOp::PegIn(op), + } + } +} + +impl From for Entry { + fn from(op: PegOutRequestOp) -> Self { + Self { + block_height: op.block_height, + status: Status::New, + txid: op.txid, + burn_header_hash: op.burn_header_hash, + op: SbtcOp::PegOutRequest(op), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Status { + New, + Pending, + Acknowledged, +} + +impl Status { + fn as_str(&self) -> &'static str { + match self { + Self::New => "new", + Self::Pending => "pending", + Self::Acknowledged => "acknowledged", + } + } +} + +impl FromStr for Status { + type Err = Error; + fn from_str(s: &str) -> Result { + Ok(match s { + "new" => Self::New, + "pending" => Self::Pending, + "acknowledged" => Self::Acknowledged, + other => return Err(Error::InvalidStatusError(other.to_owned())), + }) + } +} + +#[cfg(test)] +mod tests { + use crate::stacks_node; + + use blockstack_lib::{ + chainstate::stacks::address::PoxAddress, + types::chainstate::StacksAddress, + util::{hash::Hash160, secp256k1::MessageSignature}, + }; + use std::{collections::hash_map::DefaultHasher, hash::Hasher}; + + use crate::peg_queue::PegQueue; + + use super::*; + + #[test] + fn calling_sbtc_op_should_return_new_peg_ops() { + let peg_queue = SqlitePegQueue::in_memory(1).unwrap(); + let number_of_simulated_blocks: u64 = 3; + + let stacks_node_mock = default_stacks_node_mock(number_of_simulated_blocks); + + // No ops before polling + assert!(peg_queue.sbtc_op().unwrap().is_none()); + + // Should cause the peg_queue to fetch 3 peg in ops + peg_queue.poll(&stacks_node_mock).unwrap(); + + for height in 1..=number_of_simulated_blocks { + let next_op = peg_queue.sbtc_op().unwrap().unwrap(); + assert!(next_op.as_peg_in().is_some()); + assert_eq!(next_op.as_peg_in().unwrap().block_height, height); + + let next_op = peg_queue.sbtc_op().unwrap().unwrap(); + assert!(next_op.as_peg_out_request().is_some()); + assert_eq!(next_op.as_peg_out_request().unwrap().block_height, height); + } + } + + #[test] + fn calling_poll_should_not_query_new_ops_if_at_block_height() { + let peg_queue = SqlitePegQueue::in_memory(1).unwrap(); + let number_of_simulated_blocks: u64 = 3; + + let stacks_node_mock = default_stacks_node_mock(number_of_simulated_blocks); + + // Fast forward past first poll + peg_queue.poll(&stacks_node_mock).unwrap(); + for _ in 1..=number_of_simulated_blocks { + peg_queue.sbtc_op().unwrap().unwrap(); + peg_queue.sbtc_op().unwrap().unwrap(); + } + + let mut stacks_node_mock = stacks_node::MockStacksNode::new(); + + stacks_node_mock + .expect_burn_block_height() + .returning(move || Ok(number_of_simulated_blocks)); + + stacks_node_mock.expect_get_peg_in_ops().never(); + stacks_node_mock.expect_get_peg_out_request_ops().never(); + + peg_queue.poll(&stacks_node_mock).unwrap(); + } + + #[test] + fn calling_poll_should_find_new_ops_if_at_new_block_height() { + let peg_queue = SqlitePegQueue::in_memory(1).unwrap(); + let number_of_simulated_blocks: u64 = 3; + let number_of_simulated_blocks_second_poll: u64 = 5; + + let stacks_node_mock = default_stacks_node_mock(number_of_simulated_blocks); + + // Fast forward past first poll + peg_queue.poll(&stacks_node_mock).unwrap(); + for _ in 1..=number_of_simulated_blocks { + peg_queue.sbtc_op().unwrap().unwrap(); + peg_queue.sbtc_op().unwrap().unwrap(); + } + + let stacks_node_mock = default_stacks_node_mock(number_of_simulated_blocks_second_poll); + peg_queue.poll(&stacks_node_mock).unwrap(); + + for height in number_of_simulated_blocks + 1..=number_of_simulated_blocks_second_poll { + let next_op = peg_queue.sbtc_op().unwrap().unwrap(); + assert!(next_op.as_peg_in().is_some()); + assert_eq!(next_op.as_peg_in().unwrap().block_height, height); + + let next_op = peg_queue.sbtc_op().unwrap().unwrap(); + assert!(next_op.as_peg_out_request().is_some()); + assert_eq!(next_op.as_peg_out_request().unwrap().block_height, height); + } + } + + #[test] + fn acknowledged_entries_should_have_acknowledge_status() { + let peg_queue = SqlitePegQueue::in_memory(1).unwrap(); + let number_of_simulated_blocks: u64 = 1; + + let stacks_node_mock = default_stacks_node_mock(number_of_simulated_blocks); + peg_queue.poll(&stacks_node_mock).unwrap(); + + let next_op = peg_queue.sbtc_op().unwrap().unwrap(); + let peg_in_op = next_op.as_peg_in().unwrap(); + peg_queue + .acknowledge(&peg_in_op.txid, &peg_in_op.burn_header_hash) + .unwrap(); + + let entry = peg_queue + .get_entry(&peg_in_op.txid, &peg_in_op.burn_header_hash) + .unwrap(); + + assert_eq!(entry.status, Status::Acknowledged); + } + + fn default_stacks_node_mock(block_height: u64) -> stacks_node::MockStacksNode { + let mut stacks_node_mock = stacks_node::MockStacksNode::new(); + + stacks_node_mock + .expect_burn_block_height() + .returning(move || Ok(block_height)); + + stacks_node_mock + .expect_get_peg_in_ops() + .returning(|height| Ok(vec![peg_in_op(height)])); + + stacks_node_mock + .expect_get_peg_out_request_ops() + .returning(|height| Ok(vec![peg_out_request_op(height)])); + + stacks_node_mock + } + + fn peg_in_op(block_height: u64) -> PegInOp { + let recipient_stx_addr = StacksAddress::new(26, Hash160([0; 20])); + let peg_wallet_address = + PoxAddress::Standard(StacksAddress::new(0, Hash160([0; 20])), None); + + PegInOp { + recipient: recipient_stx_addr.into(), + peg_wallet_address, + amount: 1337, + memo: vec![1, 3, 3, 7], + txid: Txid(hash_and_expand(block_height, 1)), + burn_header_hash: BurnchainHeaderHash(hash_and_expand(block_height, 0)), + block_height, + vtxindex: 0, + } + } + + fn peg_out_request_op(block_height: u64) -> PegOutRequestOp { + let recipient_stx_addr = StacksAddress::new(26, Hash160([0; 20])); + let peg_wallet_address = + PoxAddress::Standard(StacksAddress::new(0, Hash160([0; 20])), None); + + stacks_node::PegOutRequestOp { + recipient: PoxAddress::Standard(recipient_stx_addr, None), + peg_wallet_address, + amount: 1337, + fulfillment_fee: 1000, + signature: MessageSignature([0; 65]), + memo: vec![1, 3, 3, 7], + txid: Txid(hash_and_expand(block_height, 2)), + burn_header_hash: BurnchainHeaderHash(hash_and_expand(block_height, 0)), + block_height, + vtxindex: 0, + } + } + + fn hash_and_expand(val: u64, nonce: u64) -> [u8; 32] { + let mut hasher = DefaultHasher::new(); + hasher.write_u64(val); + hasher.write_u64(nonce); + let hash = hasher.finish(); + + hash.to_be_bytes().repeat(4).try_into().unwrap() + } +} diff --git a/degen-coordinator/src/peg_wallet.rs b/degen-coordinator/src/peg_wallet.rs new file mode 100644 index 00000000..87a6f2d4 --- /dev/null +++ b/degen-coordinator/src/peg_wallet.rs @@ -0,0 +1,77 @@ +use crate::bitcoin_node::{self, UTXO}; +use crate::bitcoin_wallet::{BitcoinWallet as BitcoinWalletStruct, Error as BitcoinWalletError}; +use crate::stacks_node::{self, PegOutRequestOp}; +use crate::stacks_wallet::{Error as StacksWalletError, StacksWallet as StacksWalletStruct}; +use bitcoin::Address as BitcoinAddress; +use blockstack_lib::{chainstate::stacks::StacksTransaction, types::chainstate::StacksAddress}; +use std::fmt::Debug; + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { + #[error("Stacks Wallet Error: {0}")] + StacksWalletError(#[from] StacksWalletError), + #[error("Bitcoin Wallet Error: {0}")] + BitcoinWalletError(#[from] BitcoinWalletError), +} + +pub trait StacksWallet { + /// Builds a verified signed transaction for a given peg-in operation + fn build_mint_transaction( + &self, + op: &stacks_node::PegInOp, + nonce: u64, + ) -> Result; + /// Builds a verified signed transaction for a given peg-out request operation + fn build_burn_transaction( + &self, + op: &stacks_node::PegOutRequestOp, + nonce: u64, + ) -> Result; + /// Builds a verified signed transaction for setting the sBTC wallet address + fn build_set_btc_address_transaction( + &self, + address: &BitcoinAddress, + nonce: u64, + ) -> Result; + /// Returns the sBTC address for the wallet + fn address(&self) -> &StacksAddress; +} + +pub trait BitcoinWallet { + type Error: Debug; + // Builds a fulfilled unsigned transaction using the provided utxos to cover the spend amount + fn fulfill_peg_out( + &self, + op: &PegOutRequestOp, + txouts: Vec, + ) -> Result; + /// Returns the BTC address for the wallet + fn address(&self) -> &BitcoinAddress; +} + +pub trait PegWallet { + type StacksWallet: StacksWallet; + type BitcoinWallet: BitcoinWallet; + fn stacks(&self) -> &Self::StacksWallet; + fn bitcoin(&self) -> &Self::BitcoinWallet; +} + +pub type PegWalletAddress = bitcoin::Address; + +pub struct WrapPegWallet { + pub(crate) bitcoin_wallet: BitcoinWalletStruct, + pub(crate) stacks_wallet: StacksWalletStruct, +} + +impl PegWallet for WrapPegWallet { + type StacksWallet = StacksWalletStruct; + type BitcoinWallet = BitcoinWalletStruct; + + fn stacks(&self) -> &Self::StacksWallet { + &self.stacks_wallet + } + + fn bitcoin(&self) -> &Self::BitcoinWallet { + &self.bitcoin_wallet + } +} diff --git a/degen-coordinator/src/stacks_node/client.rs b/degen-coordinator/src/stacks_node/client.rs new file mode 100644 index 00000000..f97069a7 --- /dev/null +++ b/degen-coordinator/src/stacks_node/client.rs @@ -0,0 +1,227 @@ +use std::time::{Duration, Instant}; + +use crate::stacks_node::{Error as StacksNodeError, PegInOp, PegOutRequestOp, StacksNode}; +use blockstack_lib::{ + chainstate::stacks::StacksTransaction, codec::StacksMessageCodec, + types::chainstate::StacksAddress, +}; +use reqwest::{ + blocking::{Client, Response}, + StatusCode, +}; +use serde_json::Value; +use tracing::{debug, warn}; + +pub struct NodeClient { + node_url: String, + client: Client, +} + +impl NodeClient { + pub fn new(url: &str) -> Self { + Self { + node_url: url.to_string(), + client: Client::new(), + } + } + + fn build_url(&self, route: &str) -> String { + format!("{}{}", self.node_url, route) + } + + fn get_response(&self, route: &str) -> Result { + let url = self.build_url(route); + debug!("Sending Request to Stacks Node: {}", &url); + let now = Instant::now(); + let notify = |_err, dur| { + debug!("Failed to connect to {}. Next attempt in {:?}", &url, dur); + }; + + let send_request = || { + if now.elapsed().as_secs() > 5 { + debug!("Timeout exceeded."); + return Err(backoff::Error::Permanent(StacksNodeError::Timeout)); + } + let request = self.client.get(&url); + let response = request.send().map_err(StacksNodeError::ReqwestError)?; + Ok(response) + }; + let backoff_timer = backoff::ExponentialBackoffBuilder::new() + .with_initial_interval(Duration::from_millis(2)) + .with_max_interval(Duration::from_millis(128)) + .build(); + + let response = backoff::retry_notify(backoff_timer, send_request, notify) + .map_err(|_| StacksNodeError::Timeout)?; + + Ok(response) + } + + fn get_burn_ops(&self, block_height: u64, op: &str) -> Result, StacksNodeError> + where + T: serde::de::DeserializeOwned, + { + let json = self + .get_response(&format!("/v2/burn_ops/{block_height}/{op}"))? + .json::() + .map_err(|_| StacksNodeError::UnknownBlockHeight(block_height))?; + Ok(serde_json::from_value(json[op].clone())?) + } +} + +impl StacksNode for NodeClient { + fn get_peg_in_ops(&self, block_height: u64) -> Result, StacksNodeError> { + debug!("Retrieving peg-in ops..."); + self.get_burn_ops::(block_height, "peg_in") + } + + fn get_peg_out_request_ops( + &self, + block_height: u64, + ) -> Result, StacksNodeError> { + debug!("Retrieving peg-out request ops..."); + self.get_burn_ops::(block_height, "peg_out_request") + } + + fn burn_block_height(&self) -> Result { + debug!("Retrieving burn block height..."); + let json = self.get_response("/v2/info")?.json::()?; + let entry = "burn_block_height"; + json[entry] + .as_u64() + .ok_or_else(|| StacksNodeError::InvalidJsonEntry(entry.to_string())) + } + + fn next_nonce(&self, address: &StacksAddress) -> Result { + debug!("Retrieving next nonce..."); + let address = address.to_string(); + let entry = "nonce"; + let json = self + .get_response(&format!("/v2/accounts/{}", address))? + .json::() + .map_err(|_| StacksNodeError::BehindChainTip)?; + json[entry] + .as_u64() + .ok_or_else(|| StacksNodeError::InvalidJsonEntry(entry.to_string())) + } + + fn broadcast_transaction(&self, tx: &StacksTransaction) -> Result<(), StacksNodeError> { + debug!("Broadcasting transaction..."); + let url = self.build_url("/v2/transactions"); + let mut buffer = vec![]; + + tx.consensus_serialize(&mut buffer)?; + + let response = self + .client + .post(url) + .header("content-type", "application/octet-stream") + .body(buffer) + .send()?; + + if response.status() != StatusCode::OK { + let json_response = response + .json::() + .map_err(|_| StacksNodeError::BehindChainTip)?; + let error_str = json_response.as_str().unwrap_or("Unknown Reason"); + warn!( + "Failed to broadcast transaction to the stacks node: {:?}", + error_str + ); + return Err(StacksNodeError::BroadcastFailure(error_str.to_string())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + io::{BufWriter, Read, Write}, + net::{SocketAddr, TcpListener}, + thread::{sleep, spawn}, + time::Duration, + }; + + use blockstack_lib::{ + chainstate::stacks::{ + CoinbasePayload, SinglesigHashMode, SinglesigSpendingCondition, TransactionAnchorMode, + TransactionAuth, TransactionPayload, TransactionPostConditionMode, + TransactionPublicKeyEncoding, TransactionSpendingCondition, TransactionVersion, + }, + util::{hash::Hash160, secp256k1::MessageSignature}, + }; + + use super::*; + + #[test] + fn should_send_tx_bytes_to_node() { + let tx = StacksTransaction { + version: TransactionVersion::Testnet, + chain_id: 0, + auth: TransactionAuth::Standard(TransactionSpendingCondition::Singlesig( + SinglesigSpendingCondition { + hash_mode: SinglesigHashMode::P2PKH, + signer: Hash160([0; 20]), + nonce: 0, + tx_fee: 0, + key_encoding: TransactionPublicKeyEncoding::Uncompressed, + signature: MessageSignature([0; 65]), + }, + )), + anchor_mode: TransactionAnchorMode::Any, + post_condition_mode: TransactionPostConditionMode::Allow, + post_conditions: vec![], + payload: TransactionPayload::Coinbase(CoinbasePayload([0; 32]), None), + }; + + let mut tx_bytes = [0u8; 1024]; + + { + let mut tx_bytes_writer = BufWriter::new(&mut tx_bytes[..]); + + tx.consensus_serialize(&mut tx_bytes_writer).unwrap(); + + tx_bytes_writer.flush().unwrap(); + } + + let bytes_len = tx_bytes + .iter() + .enumerate() + .rev() + .find(|(_, &x)| x != 0) + .unwrap() + .0 + + 1; + + let mut mock_server_addr = SocketAddr::from(([127, 0, 0, 1], 0)); + let mock_server = TcpListener::bind(mock_server_addr).unwrap(); + + mock_server_addr.set_port(mock_server.local_addr().unwrap().port()); + + let h = spawn(move || { + sleep(Duration::from_millis(100)); + + let client = NodeClient::new(&format!("http://{}", mock_server_addr)); + client.broadcast_transaction(&tx).unwrap(); + }); + + let mut request_bytes = [0u8; 1024]; + + { + let mut stream = mock_server.accept().unwrap().0; + + stream.read(&mut request_bytes).unwrap(); + stream.write("HTTP/1.1 200 OK\n\n".as_bytes()).unwrap(); + } + + h.join().unwrap(); + + assert!( + request_bytes + .windows(bytes_len) + .any(|window| window == &tx_bytes[..bytes_len]), + "Request bytes did not contain the transaction bytes" + ); + } +} diff --git a/degen-coordinator/src/stacks_node/mod.rs b/degen-coordinator/src/stacks_node/mod.rs new file mode 100644 index 00000000..730dc01c --- /dev/null +++ b/degen-coordinator/src/stacks_node/mod.rs @@ -0,0 +1,40 @@ +pub mod client; + +use blockstack_lib::{ + chainstate::{burn::operations as burn_ops, stacks::StacksTransaction}, + codec::Error as CodecError, + types::chainstate::StacksAddress, +}; + +/// Kinds of common errors used by stacks coordinator +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Invalid JSON entry: {0}")] + InvalidJsonEntry(String), + #[error("Failed to find burn block height: {0}")] + UnknownBlockHeight(u64), + #[error("{0}")] + JsonError(#[from] serde_json::Error), + #[error("{0}")] + ReqwestError(#[from] reqwest::Error), + #[error("Failed to serialize transaction. {0}")] + CodecError(#[from] CodecError), + #[error("Failed to connect to stacks node.")] + Timeout, + #[error("Failed to load Stacks chain tip.")] + BehindChainTip, + #[error("Broadcast failure: {0}")] + BroadcastFailure(String), +} + +#[cfg_attr(test, mockall::automock)] +pub trait StacksNode { + fn get_peg_in_ops(&self, block_height: u64) -> Result, Error>; + fn get_peg_out_request_ops(&self, block_height: u64) -> Result, Error>; + fn burn_block_height(&self) -> Result; + fn next_nonce(&self, addr: &StacksAddress) -> Result; + fn broadcast_transaction(&self, tx: &StacksTransaction) -> Result<(), Error>; +} + +pub type PegInOp = burn_ops::PegInOp; +pub type PegOutRequestOp = burn_ops::PegOutRequestOp; diff --git a/degen-coordinator/src/stacks_wallet.rs b/degen-coordinator/src/stacks_wallet.rs new file mode 100644 index 00000000..c8a2ebbc --- /dev/null +++ b/degen-coordinator/src/stacks_wallet.rs @@ -0,0 +1,414 @@ +use crate::{ + peg_wallet::{Error as PegWalletError, StacksWallet as StacksWalletTrait}, + stacks_node::{PegInOp, PegOutRequestOp}, +}; +use bitcoin::Address as BitcoinAddress; +use blockstack_lib::{ + address::{ + AddressHashMode, C32_ADDRESS_VERSION_MAINNET_SINGLESIG, + C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + }, + chainstate::stacks::{ + StacksTransaction, StacksTransactionSigner, TransactionAnchorMode, TransactionAuth, + TransactionContractCall, TransactionPayload, TransactionPostConditionMode, + TransactionSpendingCondition, TransactionVersion, + }, + core::{CHAIN_ID_MAINNET, CHAIN_ID_TESTNET}, + types::{ + chainstate::{StacksAddress, StacksPrivateKey, StacksPublicKey}, + Address, + }, + vm::{ + errors::RuntimeErrorType, + types::{ASCIIData, StacksAddressExtensions}, + ClarityName, ContractName, Value, + }, +}; + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { + ///Error due to invalid configuration values + #[error("{0}")] + ConfigError(String), + ///Error occured while signing a transaction + #[error("Failed to sign transaction: {0}")] + SigningError(String), + ///Error occurred due to a malformed op + #[error("{0}")] + MalformedOp(String), + ///Error occurred at Clarity runtime + #[error("Clarity runtime error ocurred: {0}")] + ClarityRuntimeError(#[from] RuntimeErrorType), +} + +pub struct StacksWallet { + contract_address: StacksAddress, + contract_name: ContractName, + sender_key: StacksPrivateKey, + version: TransactionVersion, + address: StacksAddress, + fee: u64, +} + +impl StacksWallet { + pub fn new( + contract: String, + sender_key: &str, + version: TransactionVersion, + fee: u64, + ) -> Result { + let sender_key = StacksPrivateKey::from_hex(sender_key) + .map_err(|e| Error::ConfigError(e.to_string()))?; + + let pk = StacksPublicKey::from_private(&sender_key); + + let address = StacksAddress::from_public_keys( + address_version(&version), + &AddressHashMode::SerializeP2PKH, + 1, + &vec![pk], + ) + .ok_or(Error::ConfigError( + "Failed to generate stacks address from private key.".to_string(), + ))?; + + let contract_info: Vec<&str> = contract.split('.').collect(); + if contract_info.len() != 2 { + return Err(Error::ConfigError( + "Invalid sBTC contract. Expected a period seperated contract address and contract name." + .to_string())); + } + let contract_address = contract_info[0]; + let contract_name = contract_info[1].to_owned(); + + let contract_address = StacksAddress::from_string(contract_address).ok_or( + Error::ConfigError("Invalid sBTC contract address.".to_string()), + )?; + let contract_name = ContractName::try_from(contract_name) + .map_err(|_| Error::ConfigError("Invalid sBTC contract name.".to_string()))?; + Ok(Self { + contract_address, + contract_name, + sender_key, + version, + address, + fee, + }) + } + + fn build_transaction_signed( + &self, + function_name: impl Into, + function_args: Vec, + nonce: u64, + ) -> Result { + // First build an unsigned transaction + let unsigned_tx = self.build_transaction_unsigned(function_name, function_args, nonce)?; + + // Do the signing + let mut tx_signer = StacksTransactionSigner::new(&unsigned_tx); + tx_signer + .sign_origin(&self.sender_key) + .map_err(|e| Error::SigningError(e.to_string()))?; + + // Retrieve the signed transaction from the signer + let signed_tx = tx_signer.get_tx().ok_or(Error::SigningError( + "Unable to retrieve signed transaction from the signer.".to_string(), + ))?; + Ok(signed_tx) + } + + fn build_transaction_unsigned( + &self, + function_name: impl Into, + function_args: Vec, + nonce: u64, + ) -> Result { + // First build the payload from the provided function and its arguments + let payload = self.build_transaction_payload(function_name, function_args)?; + + // Next build the authorization from the provided sender key + let public_key = StacksPublicKey::from_private(&self.sender_key); + let mut spending_condition = TransactionSpendingCondition::new_singlesig_p2pkh(public_key) + .ok_or_else(|| { + Error::SigningError( + "Failed to create transaction spending condition from provided sender_key." + .to_string(), + ) + })?; + spending_condition.set_nonce(nonce); + spending_condition.set_tx_fee(self.fee); + let auth = TransactionAuth::Standard(spending_condition); + + // Viola! We have an unsigned transaction + let mut unsigned_tx = StacksTransaction::new(self.version, auth, payload); + unsigned_tx.anchor_mode = TransactionAnchorMode::Any; + unsigned_tx.post_condition_mode = TransactionPostConditionMode::Allow; + unsigned_tx.chain_id = if self.version == TransactionVersion::Testnet { + CHAIN_ID_TESTNET + } else { + CHAIN_ID_MAINNET + }; + + Ok(unsigned_tx) + } + + fn build_transaction_payload( + &self, + function_name: impl Into, + function_args: Vec, + ) -> Result { + let function_name = ClarityName::try_from(function_name.into())?; + let payload = TransactionContractCall { + address: self.contract_address, + contract_name: self.contract_name.clone(), + function_name, + function_args, + }; + Ok(payload.into()) + } +} + +impl StacksWalletTrait for StacksWallet { + fn build_mint_transaction( + &self, + op: &PegInOp, + nonce: u64, + ) -> Result { + let function_name = "mint!"; + + // Build the function arguments + let amount = Value::UInt(op.amount.into()); + let principal = Value::from(op.recipient.clone()); + //Note that this tx_id is only used to print info in the contract call. + let tx_id = Value::from(ASCIIData { + data: op.txid.to_string().as_bytes().to_vec(), + }); + let function_args: Vec = vec![amount, principal, tx_id]; + let tx = self.build_transaction_signed(function_name, function_args, nonce)?; + Ok(tx) + } + + fn build_burn_transaction( + &self, + op: &PegOutRequestOp, + nonce: u64, + ) -> Result { + let function_name = "burn!"; + + // Build the function arguments + let amount = Value::UInt(op.amount.into()); + // Retrieve the stacks address to burn from + let address = op + .stx_address(address_version(&self.version)) + .map_err(|_| { + Error::MalformedOp( + "Failed to recover stx address from peg-out request op.".to_string(), + ) + })?; + let principal_data = address.to_account_principal(); + let principal = Value::Principal(principal_data); + //Note that this tx_id is only used to print info inside the contract call. + let tx_id = Value::from(ASCIIData { + data: op.txid.to_string().as_bytes().to_vec(), + }); + let function_args: Vec = vec![amount, principal, tx_id]; + + let tx = self.build_transaction_signed(function_name, function_args, nonce)?; + Ok(tx) + } + + fn build_set_btc_address_transaction( + &self, + address: &BitcoinAddress, + nonce: u64, + ) -> Result { + let function_name = "set-bitcoin-wallet-address"; + // Build the function arguments + let address = Value::from(ASCIIData { + data: address.to_string().into_bytes(), + }); + let function_args = vec![address]; + let tx = self.build_transaction_signed(function_name, function_args, nonce)?; + Ok(tx) + } + + fn address(&self) -> &StacksAddress { + &self.address + } +} + +fn address_version(version: &TransactionVersion) -> u8 { + match version { + TransactionVersion::Mainnet => C32_ADDRESS_VERSION_MAINNET_SINGLESIG, + TransactionVersion::Testnet => C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + } +} + +#[cfg(test)] +mod tests { + use crate::{ + peg_wallet::StacksWallet as StacksWalletTrait, + stacks_wallet::StacksWallet, + util::test::{build_peg_out_request_op, PRIVATE_KEY_HEX, PUBLIC_KEY_HEX}, + }; + use bitcoin::{secp256k1::Secp256k1, XOnlyPublicKey}; + use blockstack_lib::{ + address::C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + burnchains::Txid, + chainstate::{ + burn::operations::{PegInOp, PegOutRequestOp}, + stacks::{address::PoxAddress, TransactionVersion}, + }, + types::chainstate::{BurnchainHeaderHash, StacksAddress}, + util::{hash::Hash160, secp256k1::MessageSignature}, + vm::types::{PrincipalData, StandardPrincipalData}, + }; + use rand::Rng; + use std::str::FromStr; + + fn pox_address() -> PoxAddress { + PoxAddress::Standard( + StacksAddress::new( + C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + Hash160::from_data(&rand::thread_rng().gen::<[u8; 20]>()), + ), + None, + ) + } + + fn stacks_wallet() -> StacksWallet { + StacksWallet::new( + "SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sbtc-alpha".to_string(), + PRIVATE_KEY_HEX, + TransactionVersion::Testnet, + 10, + ) + .expect("Failed to construct a stacks wallet for testing.") + } + + #[test] + fn build_mint_transaction_test() { + let p = PegInOp { + recipient: PrincipalData::Standard(StandardPrincipalData(0, [0u8; 20])), + peg_wallet_address: pox_address(), + amount: 55155, + memo: Vec::default(), + txid: Txid([0u8; 32]), + vtxindex: 0, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0; 32]), + }; + let wallet = stacks_wallet(); + let tx = wallet + .build_mint_transaction(&p, 0) + .expect("Failed to construct mint transaction."); + tx.verify() + .expect("build_mint_transaction generated a transaction with an invalid signature"); + } + + #[test] + fn build_burn_transaction_test() { + let wallet = stacks_wallet(); + let op = build_peg_out_request_op(PRIVATE_KEY_HEX, 10, 1, 3); + let tx = wallet + .build_burn_transaction(&op, 0) + .expect("Failed to construct burn transaction."); + tx.verify() + .expect("build_burn_transaction generated a transaction with an invalid signature."); + } + + #[test] + fn invalid_burn_op_test() { + let wallet = stacks_wallet(); + // Construct an invalid peg-out request op. + let op = PegOutRequestOp { + amount: 1000, + recipient: pox_address(), + signature: MessageSignature([0x00; 65]), + peg_wallet_address: pox_address(), + fulfillment_fee: 0, + memo: vec![], + txid: Txid([0x04; 32]), + vtxindex: 0, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0x00; 32]), + }; + assert_eq!( + wallet + .build_burn_transaction(&op, 0) + .err() + .unwrap() + .to_string(), + "Stacks Wallet Error: Failed to recover stx address from peg-out request op." + ); + } + + #[test] + fn build_set_btc_address_transaction_test() { + let wallet = stacks_wallet(); + let internal_key = XOnlyPublicKey::from_str(PUBLIC_KEY_HEX).unwrap(); + let secp = Secp256k1::verification_only(); + let address = bitcoin::Address::p2tr(&secp, internal_key, None, bitcoin::Network::Testnet); + + let tx = wallet + .build_set_btc_address_transaction(&address, 0) + .expect("Failed to construct a set btc address transaction."); + tx.verify().expect( + "build_set_btc_address_transaction generated a transaction with an invalid signature.", + ); + } + + #[test] + fn stacks_wallet_invalid_config_test() { + // Test an invalid key + assert_eq!( + StacksWallet::new( + "SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sbtc-alpha".to_string(), + "", + TransactionVersion::Testnet, + 10 + ) + .err() + .unwrap() + .to_string(), + "Invalid private key hex string" + ); + // Test an invalid contract + assert_eq!( + StacksWallet::new( + "SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTEsbtc-alpha".to_string(), + PRIVATE_KEY_HEX, TransactionVersion::Testnet, 10) + .err() + .unwrap() + .to_string(), + "Invalid sBTC contract. Expected a period seperated contract address and contract name." + ); + // Test an invalid contract address + assert_eq!( + StacksWallet::new( + "SP3FBR2AGK5H9QBDH3EEN6DF8E.sbtc-alpha".to_string(), + PRIVATE_KEY_HEX, + TransactionVersion::Testnet, + 10 + ) + .err() + .unwrap() + .to_string(), + "Invalid sBTC contract address." + ); + // Test an invalid contract name + assert_eq!( + StacksWallet::new( + "SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.12".to_string(), + PRIVATE_KEY_HEX, + TransactionVersion::Testnet, + 10 + ) + .err() + .unwrap() + .to_string(), + "Invalid sBTC contract name." + ); + } +} diff --git a/degen-coordinator/src/util.rs b/degen-coordinator/src/util.rs new file mode 100644 index 00000000..dbada243 --- /dev/null +++ b/degen-coordinator/src/util.rs @@ -0,0 +1,103 @@ +#[cfg(test)] +pub mod test { + use blockstack_lib::{ + burnchains::{ + bitcoin::{ + address::{BitcoinAddress, SegwitBitcoinAddress}, + BitcoinTransaction, BitcoinTxInput, BitcoinTxOutput, + }, + BurnchainBlockHeader, BurnchainTransaction, PrivateKey, Txid, + }, + chainstate::burn::{operations::PegOutRequestOp, Opcodes}, + util::{hash::Sha256Sum, secp256k1::Secp256k1PrivateKey}, + }; + use rand::Rng; + + pub const PRIVATE_KEY_HEX: &str = + "b244296d5907de9864c0b0d51f98a13c52890be0404e83f273144cd5b9960eed01"; + pub const PUBLIC_KEY_HEX: &str = + "cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115"; + + /// Helper function to construct a valid signed peg out request op + pub fn build_peg_out_request_op( + key_hex: &str, + amount: u64, + dust_amount: u64, + fulfillment_fee: u64, + ) -> PegOutRequestOp { + let mut rng = rand::thread_rng(); + let private_key = Secp256k1PrivateKey::from_hex(key_hex) + .expect("Failed to construct a valid private key."); + + // Build a dust txo + let recipient_address_bytes = rng.gen::<[u8; 32]>(); + let output2 = BitcoinTxOutput { + units: dust_amount, + address: BitcoinAddress::Segwit(SegwitBitcoinAddress::P2TR( + true, + recipient_address_bytes, + )), + }; + + // Build a fulfillment fee txo + let peg_wallet_address = rng.gen::<[u8; 32]>(); + let output3 = BitcoinTxOutput { + units: fulfillment_fee, + address: BitcoinAddress::Segwit(SegwitBitcoinAddress::P2TR(true, peg_wallet_address)), + }; + + // Generate the message signature by signing the amount and recipient fields + let mut script_pubkey = vec![81, 32]; // OP_1 OP_PUSHBYTES_32 + script_pubkey.extend_from_slice(&recipient_address_bytes); + + let mut msg = amount.to_be_bytes().to_vec(); + msg.extend_from_slice(&script_pubkey); + + let signature = private_key + .sign(Sha256Sum::from_data(&msg).as_bytes()) + .expect("Failed to sign amount and recipient fields."); + + let mut data = vec![]; + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(signature.as_bytes()); + + let outputs = vec![output2, output3]; + let inputs = vec![]; + + // Build the burnchain tx using the above generated data + let tx = build_burnchain_transaction(Opcodes::PegOutRequest as u8, data, inputs, outputs); + + // Build an empty block header + let header = build_empty_block_header(); + + // use the header and tx to generate a peg out request + PegOutRequestOp::from_tx(&header, &tx).expect("Failed to construct peg-out request op") + } + + fn build_empty_block_header() -> BurnchainBlockHeader { + BurnchainBlockHeader { + block_height: 0, + block_hash: [0; 32].into(), + parent_block_hash: [0; 32].into(), + num_txs: 0, + timestamp: 0, + } + } + + fn build_burnchain_transaction( + opcode: u8, + data: Vec, + inputs: Vec, + outputs: Vec, + ) -> BurnchainTransaction { + BurnchainTransaction::Bitcoin(BitcoinTransaction { + txid: Txid([0; 32]), + vtxindex: 0, + opcode, + data, + data_amt: 0, + inputs, + outputs, + }) + } +} diff --git a/degen-signer/Cargo.toml b/degen-signer/Cargo.toml new file mode 100644 index 00000000..36b751b1 --- /dev/null +++ b/degen-signer/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "degen-signer" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/degen-signer/src/cli.rs b/degen-signer/src/cli.rs new file mode 100644 index 00000000..77e0b376 --- /dev/null +++ b/degen-signer/src/cli.rs @@ -0,0 +1,33 @@ +use crate::secp256k1::Secp256k1; +use clap::{Parser, Subcommand}; + +///Command line interface for stacks signer +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + /// Subcommand action to take + #[clap(subcommand)] + pub command: Command, +} + +/// Possible actions that stacks signer can perform +#[derive(Subcommand)] +pub enum Command { + /// Join the p2p network as specified in the config file + Run { + /// Associated signer id + #[arg(short, long)] + id: u32, + /// Config file path + #[arg(short, long)] + config: String, + }, + /// Generate Secp256k1 Private Key + PrivateKey(Secp256k1), + /// Generate Secp256k1 Public Key + PublicKey { + /// Config file path + #[arg(short, long)] + config: String, + }, +} diff --git a/degen-signer/src/lib.rs b/degen-signer/src/lib.rs new file mode 100644 index 00000000..b19f5d60 --- /dev/null +++ b/degen-signer/src/lib.rs @@ -0,0 +1,24 @@ +/// Module for defining the CLI and its operations +pub mod cli; +/// Module for secp256k1 operations +pub mod secp256k1; +/// Module for signer operations +pub mod signer; + +// set via _compile-time_ envars +const GIT_BRANCH: Option<&'static str> = option_env!("GIT_BRANCH"); +const GIT_COMMIT: Option<&'static str> = option_env!("GIT_COMMIT"); + +#[cfg(debug_assertions)] +const BUILD_TYPE: &str = "debug"; +#[cfg(not(debug_assertions))] +const BUILD_TYPE: &'static str = "release"; + +pub fn version() -> String { + format!( + "stacks-signer {} {} {}", + BUILD_TYPE, + GIT_BRANCH.unwrap_or(""), + GIT_COMMIT.unwrap_or("") + ) +} diff --git a/degen-signer/src/main.rs b/degen-signer/src/main.rs new file mode 100644 index 00000000..5b17c6c0 --- /dev/null +++ b/degen-signer/src/main.rs @@ -0,0 +1,47 @@ +use clap::Parser; +use frost_signer::config::Config; +use frost_signer::logging; +use stacks_signer::cli::{Cli, Command}; +use stacks_signer::signer::Signer; +use tracing::info; +use wsts::Point; + +fn main() { + let cli = Cli::parse(); + + // Initialize logging + logging::initiate_tracing_subscriber().unwrap(); + + // Determine what action the caller wishes to perform + match cli.command { + Command::Run { id, config } => { + //TODO: getConf from sBTC contract instead + match Config::from_path(&config) { + Ok(config) => { + let mut signer = Signer::new(config, id); + info!("{} signer id #{}", stacks_signer::version(), id); // sign-on message + if let Err(e) = signer.start_p2p_sync() { + panic!("An error occurred on the P2P Network: {}", e); + } + } + Err(e) => { + panic!("An error occurred reading config file {}: {}", config, e); + } + } + } + Command::PrivateKey(secp256k1) => { + if let Err(e) = secp256k1.generate_private_key() { + panic!("An error occurred generating private key: {}", e); + } + } + Command::PublicKey { config } => match Config::from_path(&config) { + Ok(config) => { + let public_key = Point::from(&config.network_private_key); + println!("{public_key}") + } + Err(e) => { + panic!("An error occurred reading config file {}: {}", config, e); + } + }, + }; +} diff --git a/degen-signer/src/secp256k1.rs b/degen-signer/src/secp256k1.rs new file mode 100644 index 00000000..5b706e72 --- /dev/null +++ b/degen-signer/src/secp256k1.rs @@ -0,0 +1,52 @@ +use clap::Args; +use rand_core::OsRng; +use std::{fs::File, io::prelude::*, path::PathBuf}; +use tracing::info; +use wsts::Scalar; + +#[derive(Args)] +pub struct Secp256k1 { + #[arg(short, long)] + /// Path to output generated private Secp256k1 key + filepath: Option, +} + +impl Secp256k1 { + /// Generate a random Secp256k1 private key + pub fn generate_private_key(self) -> std::io::Result<()> { + info!("Generating a new private key."); + let mut rnd = OsRng::default(); + let private_key = Scalar::random(&mut rnd); + if let Some(filepath) = self.filepath { + info!( + "Writing private key to provided output file: {}", + filepath.to_string_lossy() + ); + let mut file = File::create(filepath)?; + file.write_all(private_key.to_string().as_bytes())?; + info!("Private key written successfully."); + } else { + println!("{private_key}"); + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use crate::secp256k1::Secp256k1; + use testdir::testdir; + + #[test] + fn generate_private_key() { + let mut filepath = testdir!(); + filepath.push(".priv_key"); + assert!(!filepath.exists()); + + let secp256k1 = Secp256k1 { + filepath: Some(filepath.clone()), + }; + secp256k1.generate_private_key().unwrap(); + assert!(filepath.exists()); + } +} diff --git a/degen-signer/src/signer.rs b/degen-signer/src/signer.rs new file mode 100644 index 00000000..c5a84cb4 --- /dev/null +++ b/degen-signer/src/signer.rs @@ -0,0 +1,20 @@ +use frost_signer::config::Config; +use frost_signer::signer::{Error as SignerError, Signer as FrostSigner}; + +#[derive(Clone)] +pub struct Signer { + frost_signer: FrostSigner, + //TODO: Are there any StacksSigner specific items or maybe a stacks signer specific config that needs to be wrapped around Config? +} + +impl Signer { + pub fn new(config: Config, id: u32) -> Self { + Self { + frost_signer: FrostSigner::new(config, id), + } + } + + pub fn start_p2p_sync(&mut self) -> Result<(), SignerError> { + self.frost_signer.start_p2p_sync() + } +} From c5f13dd015de5fc12ada9f9c99df779c15c9fdcc Mon Sep 17 00:00:00 2001 From: Andrei Alexandrescu Date: Fri, 26 May 2023 18:10:29 +0300 Subject: [PATCH 3/6] Working version --- degen-coordinator/Cargo.toml | 24 ++++++++++++++++++++++++ degen-coordinator/conf/coordinator.toml | 8 ++++++++ degen-coordinator/conf/signer.toml | 10 ++++++++++ degen-coordinator/src/main.rs | 6 +++--- degen-signer/Cargo.toml | 14 ++++++++++++++ degen-signer/conf/signer.toml | 10 ++++++++++ degen-signer/src/main.rs | 6 +++--- 7 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 degen-coordinator/conf/coordinator.toml create mode 100644 degen-coordinator/conf/signer.toml create mode 100644 degen-signer/conf/signer.toml diff --git a/degen-coordinator/Cargo.toml b/degen-coordinator/Cargo.toml index 1f2868e4..f82eaa59 100644 --- a/degen-coordinator/Cargo.toml +++ b/degen-coordinator/Cargo.toml @@ -6,3 +6,27 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +bs58 = { workspace = true } +blockstack-core = { workspace = true } +clap = { workspace = true } +frost-coordinator = { path = "../frost-coordinator" } +frost-signer = { path = "../frost-signer" } +rusqlite = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +stacks-signer = { path = "../stacks-signer" } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +wsts = { workspace = true } +bitcoin = { version = "0.29.2", features = ["rand", "bitcoinconsensus"] } +reqwest = { version = "0.11.14", features = ["blocking", "json"] } +backoff = { workspace = true } +ureq.workspace = true + +[dev-dependencies] +mockall = { workspace = true } +rand = "0.8.5" +hex = "0.4.3" +test-utils = { path = "../test-utils" } diff --git a/degen-coordinator/conf/coordinator.toml b/degen-coordinator/conf/coordinator.toml new file mode 100644 index 00000000..a9c47f0a --- /dev/null +++ b/degen-coordinator/conf/coordinator.toml @@ -0,0 +1,8 @@ +sbtc_contract = "SP2BJA4JYFJ7SDMNFJZ9TJ3GB80P9Z80ADPGK1C2F.sbtc-alpha" +stacks_private_key = "" +stacks_node_rpc_url = "http://localhost:20443" +bitcoin_node_rpc_url = "http://abcd:abcd@localhost:18445" +frost_dkg_round_id = 0 +signer_config_path = "" +network_private_key = "" +transaction_fee = 2000 \ No newline at end of file diff --git a/degen-coordinator/conf/signer.toml b/degen-coordinator/conf/signer.toml new file mode 100644 index 00000000..f6221107 --- /dev/null +++ b/degen-coordinator/conf/signer.toml @@ -0,0 +1,10 @@ +http_relay_url = "http://localhost:9776" +keys_threshold = 4 +frost_state_file = "frost.state.bin" +network_private_key = "9aSCCR6eirt1NAHwJtSz4HMwBHTyMo62SyPMvVDt5DQn" +signers = [ + {public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj", key_ids = [1, 2]}, + {public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj", key_ids = [3, 4]}, + {public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj", key_ids = [5, 6]} +] +coordinator_public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj" \ No newline at end of file diff --git a/degen-coordinator/src/main.rs b/degen-coordinator/src/main.rs index 42f444a5..20f0a355 100644 --- a/degen-coordinator/src/main.rs +++ b/degen-coordinator/src/main.rs @@ -1,8 +1,8 @@ use clap::Parser; use frost_signer::logging; -use stacks_coordinator::cli::{Cli, Command}; -use stacks_coordinator::config::Config; -use stacks_coordinator::coordinator::{Coordinator, StacksCoordinator}; +use degen_coordinator::cli::{Cli, Command}; +use degen_coordinator::config::Config; +use degen_coordinator::coordinator::{Coordinator, StacksCoordinator}; use tracing::{error, info, warn}; fn main() { diff --git a/degen-signer/Cargo.toml b/degen-signer/Cargo.toml index 36b751b1..100c10ad 100644 --- a/degen-signer/Cargo.toml +++ b/degen-signer/Cargo.toml @@ -6,3 +6,17 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +clap = { workspace = true } +frost-signer = { path = "../frost-signer" } +rand_core = "0.6" +serde = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +wsts = { workspace = true } + +[dev-dependencies] +assert_cmd = "2.0.8" +predicates = "3.0.1" +testdir = "0.7.2" diff --git a/degen-signer/conf/signer.toml b/degen-signer/conf/signer.toml new file mode 100644 index 00000000..6e2b7ff6 --- /dev/null +++ b/degen-signer/conf/signer.toml @@ -0,0 +1,10 @@ +http_relay_url = "http://localhost:9776" +keys_threshold = 4 +frost_state_file = "frost.state.bin" +network_private_key = "9aSCCR6eirt1NAHwJtSz4HMwBHTyMo62SyPMvVDt5DQn" +signers = [ + {public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj", key_ids = [1, 2]}, + {public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj", key_ids = [3, 4]}, + {public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj", key_ids = [5, 6]} +] +coordinator_public_key = "22Rm48xUdpuTuva5gz9S7yDaaw9f8sjMcPSTHYVzPLNcj" diff --git a/degen-signer/src/main.rs b/degen-signer/src/main.rs index 5b17c6c0..6d1e903f 100644 --- a/degen-signer/src/main.rs +++ b/degen-signer/src/main.rs @@ -1,8 +1,8 @@ use clap::Parser; use frost_signer::config::Config; use frost_signer::logging; -use stacks_signer::cli::{Cli, Command}; -use stacks_signer::signer::Signer; +use degen_signer::cli::{Cli, Command}; +use degen_signer::signer::Signer; use tracing::info; use wsts::Point; @@ -19,7 +19,7 @@ fn main() { match Config::from_path(&config) { Ok(config) => { let mut signer = Signer::new(config, id); - info!("{} signer id #{}", stacks_signer::version(), id); // sign-on message + info!("{} signer id #{}", degen_signer::version(), id); // sign-on message if let Err(e) = signer.start_p2p_sync() { panic!("An error occurred on the P2P Network: {}", e); } From f74d04b3adb90713ec8cb97d0899f46c0178c43d Mon Sep 17 00:00:00 2001 From: Andrei Alexandrescu Date: Fri, 26 May 2023 20:59:43 +0300 Subject: [PATCH 4/6] Push --- degen-coordinator/conf/coordinator.toml | 7 +- degen-coordinator/src/bitcoin_node.rs | 39 ++++++++--- degen-coordinator/src/bitcoin_wallet.rs | 76 +++++++++++++++++++++ degen-coordinator/src/cli.rs | 2 + degen-coordinator/src/coordinator.rs | 36 +++++++++- degen-coordinator/src/main.rs | 7 ++ degen-coordinator/src/peg_wallet.rs | 5 ++ degen-coordinator/src/stacks_node/client.rs | 67 +++++++++++------- 8 files changed, 200 insertions(+), 39 deletions(-) diff --git a/degen-coordinator/conf/coordinator.toml b/degen-coordinator/conf/coordinator.toml index a9c47f0a..6170ea76 100644 --- a/degen-coordinator/conf/coordinator.toml +++ b/degen-coordinator/conf/coordinator.toml @@ -1,8 +1,9 @@ sbtc_contract = "SP2BJA4JYFJ7SDMNFJZ9TJ3GB80P9Z80ADPGK1C2F.sbtc-alpha" -stacks_private_key = "" +stacks_private_key = "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801" stacks_node_rpc_url = "http://localhost:20443" -bitcoin_node_rpc_url = "http://abcd:abcd@localhost:18445" +bitcoin_node_rpc_url = "http://devnet:devnet@localhost:18443" frost_dkg_round_id = 0 signer_config_path = "" network_private_key = "" -transaction_fee = 2000 \ No newline at end of file +network = "testnet" +transaction_fee = 2000 diff --git a/degen-coordinator/src/bitcoin_node.rs b/degen-coordinator/src/bitcoin_node.rs index f3916a35..d5678839 100644 --- a/degen-coordinator/src/bitcoin_node.rs +++ b/degen-coordinator/src/bitcoin_node.rs @@ -8,6 +8,7 @@ use bitcoin::{ use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::{debug, warn}; + pub trait BitcoinNode { /// Broadcast the BTC transaction to the bitcoin node fn broadcast_transaction(&self, tx: &BitcoinTransaction) -> Result; @@ -133,15 +134,35 @@ impl LocalhostBitcoinNode { debug!("Making Bitcoin RPC {} call...", method); let json_rpc = ureq::json!({"jsonrpc": "2.0", "id": "stx", "method": method, "params": params}); - let response = ureq::post(&self.bitcoind_api) - .send_json(json_rpc) - .map_err(|e| Error::RPCError(e.to_string()))?; - let json_response = response.into_json::()?; - let json_result = json_response - .get("result") - .ok_or_else(|| Error::InvalidResponseJSON("Missing entry 'result'.".to_string()))? - .to_owned(); - Ok(json_result) + println!("{:#?}", &json_rpc); + // let response = + match ureq::post(&self.bitcoind_api) + .send_json(json_rpc) { + Ok(response) => { + let json_response = response.into_json::()?; + let json_result = json_response + .get("result") + .ok_or_else(|| Error::InvalidResponseJSON("Missing entry 'result'.".to_string()))? + .to_owned(); + Ok(json_result) + } + Err(ureq::Error::Status(code, response)) => { + println!("Response {:#?}", &response); + println!("JSON response {:#?}", &response.into_json()?); + println!("JSON response {:#?}", &response.into_string()?); + Err(Error::RPCError("Andrei".to_string())) + } + Err(_) => {Err(Error::RPCError("Andrei".to_string()))} + } + // let response = ureq::post(&self.bitcoind_api) + // .send_json(json_rpc) + // .map_err(|e| Error::RPCError(e.to_string()))?; + // let json_response = response.into_json::()?; + // let json_result = json_response + // .get("result") + // .ok_or_else(|| Error::InvalidResponseJSON("Missing entry 'result'.".to_string()))? + // .to_owned(); + // Ok(json_result) } fn create_empty_wallet(&self) -> Result<(), Error> { diff --git a/degen-coordinator/src/bitcoin_wallet.rs b/degen-coordinator/src/bitcoin_wallet.rs index 705a5035..72812934 100644 --- a/degen-coordinator/src/bitcoin_wallet.rs +++ b/degen-coordinator/src/bitcoin_wallet.rs @@ -41,6 +41,82 @@ const DUST_UTXO_LIMIT: u64 = 5500; impl BitcoinWalletTrait for BitcoinWallet { type Error = Error; + // fn fulfill_degen( + // &self, + // available_utxos: Vec, + // ) -> Result { + // // Create an empty transaction + // let mut tx = Transaction { + // version: 2, + // lock_time: bitcoin::PackedLockTime(0), + // input: vec![], + // output: vec![], + // }; + // // Consume UTXOs until we have enough to cover the total spend (fulfillment fee and peg out amount) + // let mut total_consumed = 0; + // let mut utxos = vec![]; + // let mut fulfillment_utxo = None; + // for utxo in available_utxos.into_iter() { + // if utxo.txid == op.txid.to_string() && utxo.vout == 2 { + // // This is the fulfillment utxo. + // if utxo.amount != op.fulfillment_fee { + // // Something is wrong. The fulfillment fee should match the fulfillment utxo amount. + // // Malformed Peg Request Op + // return Err(PegWalletError::from(Error::MismatchedFulfillmentFee)); + // } + // fulfillment_utxo = Some(utxo); + // } else if total_consumed < op.amount { + // total_consumed += utxo.amount; + // utxos.push(utxo); + // } else if fulfillment_utxo.is_some() { + // // We have consumed enough to cover the total spend + // // i.e. have found the fulfillment utxo and covered the peg out amount + // break; + // } + // } + // // Sanity check all the things! + // // If we did not find the fulfillment utxo, something went wrong + // let fulfillment_utxo = fulfillment_utxo.ok_or_else(|| { + // warn!("Failed to find fulfillment utxo."); + // Error::MissingFulfillmentUTXO + // })?; + // // Check that we have sufficient funds and didn't just run out of available utxos. + // if total_consumed < op.amount { + // warn!( + // "Consumed total {} is less than intended spend: {}", + // total_consumed, op.amount + // ); + // return Err(PegWalletError::from(Error::InsufficientFunds)); + // } + // // Get the transaction change amount + // let change_amount = total_consumed - op.amount; + // debug!( + // "change_amount: {:?}, total_consumed: {:?}, op.amount: {:?}", + // change_amount, total_consumed, op.amount + // ); + // if change_amount >= DUST_UTXO_LIMIT { + // let secp = Secp256k1::verification_only(); + // let script_pubkey = Script::new_v1_p2tr(&secp, self.public_key, None); + // let change_output = bitcoin::TxOut { + // value: change_amount, + // script_pubkey, + // }; + // tx.output.push(change_output); + // } else { + // // Instead of leaving that change to the BTC miner, we could / should bump the sortition fee + // debug!("Not enough change to clear dust limit. Not adding change address."); + // } + // // Convert the utxos to inputs for the transaction, ensuring the fulfillment utxo is the first input + // let fulfillment_input = utxo_to_input(fulfillment_utxo)?; + // tx.input.push(fulfillment_input); + // for utxo in utxos { + // let input = utxo_to_input(utxo)?; + // tx.input.push(input); + // } + // Ok(tx) + // } + // + // fn fulfill_peg_out( &self, op: &PegOutRequestOp, diff --git a/degen-coordinator/src/cli.rs b/degen-coordinator/src/cli.rs index d6adeba3..542d92e7 100644 --- a/degen-coordinator/src/cli.rs +++ b/degen-coordinator/src/cli.rs @@ -32,4 +32,6 @@ pub enum Command { Dkg, // Run distributed key generation round then sign a message DkgSign, + // Us + DegenRunOne } diff --git a/degen-coordinator/src/coordinator.rs b/degen-coordinator/src/coordinator.rs index 44b4a453..c7ac73ff 100644 --- a/degen-coordinator/src/coordinator.rs +++ b/degen-coordinator/src/coordinator.rs @@ -93,6 +93,23 @@ pub trait Coordinator: Sized { fn bitcoin_node(&self) -> &Self::BitcoinNode; // Provided methods + fn degen_run_one(mut self) -> Result<()> { + let (sender, receiver) = mpsc::channel::(); + Self::poll_ping_thread(sender); + + loop { + match receiver.recv()? { + Command::Stop => break, + Command::Timeout => { + println!("Timeout on connection"); + self.peg_queue().poll(self.stacks_node())?; + self.process_queue()?; + } + } + } + Ok(()) + } + fn run(mut self) -> Result<()> { let (sender, receiver) = mpsc::channel::(); Self::poll_ping_thread(sender); @@ -127,9 +144,23 @@ pub trait Coordinator: Sized { } } +// Degens helper functions +trait DegenCoordinator: Coordinator { + fn create_transaction(&mut self) { + // Retreive the utxos + let utxos = self + .bitcoin_node() + .list_unspent(self.fee_wallet().bitcoin().address()); + } +} +impl DegenCoordinator for T {} + + + // Private helper functions trait CoordinatorHelpers: Coordinator { fn peg_in(&mut self, op: stacks_node::PegInOp) -> Result<()> { + println!("Peg In"); // Retrieve the nonce from the stacks node using the sBTC wallet address let nonce = self .stacks_node() @@ -146,6 +177,7 @@ trait CoordinatorHelpers: Coordinator { Ok(()) } + // This is what we need to do... fn peg_out(&mut self, op: stacks_node::PegOutRequestOp) -> Result<()> { // Retrieve the nonce from the stacks node using the sBTC wallet address let nonce = self @@ -165,7 +197,8 @@ trait CoordinatorHelpers: Coordinator { let fulfill_tx = self.fulfill_peg_out(&op)?; // Broadcast the resulting BTC transaction to the Bitcoin node - self.bitcoin_node().broadcast_transaction(&fulfill_tx)?; + // self.bitcoin_node().broadcast_transaction(&fulfill_tx)?; + println!("Success on transaction"); Ok(()) } @@ -264,6 +297,7 @@ impl TryFrom for StacksCoordinator { // This should not be run on startup unless required: // 1. No aggregate public key stored in persitent storage anywhere // 2. no address already set in sbtc contract (get-bitcoin-wallet-address) + // X This is getting the public key from the coordinator let pubkey = frost_coordinator.get_aggregate_public_key()?; let xonly_pubkey = PublicKey::from_slice(&pubkey.x().to_bytes()).map_err(Error::InvalidPublicKey)?; diff --git a/degen-coordinator/src/main.rs b/degen-coordinator/src/main.rs index 20f0a355..db691622 100644 --- a/degen-coordinator/src/main.rs +++ b/degen-coordinator/src/main.rs @@ -22,6 +22,13 @@ fn main() { Ok(mut coordinator) => { // Determine what action the caller wishes to perform match cli.command { + Command::DegenRunOne => { + info!("Running Coordinator in Degen Run One"); + //TODO: set up coordination with the stacks node + if let Err(e) = coordinator.degen_run_one() { + error!("An error occurred running the coordinator: {}", e); + } + } Command::Run => { info!("Running Coordinator"); //TODO: set up coordination with the stacks node diff --git a/degen-coordinator/src/peg_wallet.rs b/degen-coordinator/src/peg_wallet.rs index 87a6f2d4..833be93e 100644 --- a/degen-coordinator/src/peg_wallet.rs +++ b/degen-coordinator/src/peg_wallet.rs @@ -39,6 +39,11 @@ pub trait StacksWallet { pub trait BitcoinWallet { type Error: Debug; + // Builds a degenerate transaction + // fn fulfill_degen( + // &self, + // txouts: Vec, + // ) -> Result; // Builds a fulfilled unsigned transaction using the provided utxos to cover the spend amount fn fulfill_peg_out( &self, diff --git a/degen-coordinator/src/stacks_node/client.rs b/degen-coordinator/src/stacks_node/client.rs index f97069a7..441dd01e 100644 --- a/degen-coordinator/src/stacks_node/client.rs +++ b/degen-coordinator/src/stacks_node/client.rs @@ -58,8 +58,8 @@ impl NodeClient { } fn get_burn_ops(&self, block_height: u64, op: &str) -> Result, StacksNodeError> - where - T: serde::de::DeserializeOwned, + where + T: serde::de::DeserializeOwned, { let json = self .get_response(&format!("/v2/burn_ops/{block_height}/{op}"))? @@ -105,32 +105,47 @@ impl StacksNode for NodeClient { .ok_or_else(|| StacksNodeError::InvalidJsonEntry(entry.to_string())) } + // SBTC contracts are not there. fn broadcast_transaction(&self, tx: &StacksTransaction) -> Result<(), StacksNodeError> { - debug!("Broadcasting transaction..."); - let url = self.build_url("/v2/transactions"); - let mut buffer = vec![]; - - tx.consensus_serialize(&mut buffer)?; - - let response = self - .client - .post(url) - .header("content-type", "application/octet-stream") - .body(buffer) - .send()?; - - if response.status() != StatusCode::OK { - let json_response = response - .json::() - .map_err(|_| StacksNodeError::BehindChainTip)?; - let error_str = json_response.as_str().unwrap_or("Unknown Reason"); - warn!( - "Failed to broadcast transaction to the stacks node: {:?}", - error_str - ); - return Err(StacksNodeError::BroadcastFailure(error_str.to_string())); - } + debug!("Broadcasting transaction... Nope... "); Ok(()) + // let url = self.build_url("/v2/transactions"); + // let mut buffer = vec![]; + // + // tx.consensus_serialize(&mut buffer)?; + // println!( "{:?}", &tx); + // + // let req = self + // .client + // .post(url) + // .header("content-type", "application/octet-stream") + // .body(buffer) + // .build()?; + // + // println!("{:#?}", &req.body().unwrap()); + + // let response = self.client.execute(req)?; + // let response = self + // .client + // .post(url) + // .header("content-type", "application/octet-stream") + // .body(buffer) + // .send()?; + // + // if response.status() != StatusCode::OK { + // println!("{:#?}", &response); + // println!("{:#?}", &response.status()); + // let json_response = response + // .json::() + // .map_err(|_| StacksNodeError::BehindChainTip)?; + // let error_str = json_response.as_str().unwrap_or("Unknown Reason"); + // warn!( + // "Failed to broadcast transaction to the stacks node: {:?}", + // error_str + // ); + // return Err(StacksNodeError::BroadcastFailure(error_str.to_string())); + // } + // Ok(()) } } From 9c68b1ec4b48d87b164413e7d8a5490ba6870976 Mon Sep 17 00:00:00 2001 From: Andrei Alexandrescu Date: Tue, 30 May 2023 14:42:56 +0300 Subject: [PATCH 5/6] Regtest --- degen-coordinator/conf/coordinator.toml | 2 +- degen-coordinator/src/bitcoin_node.rs | 27 ++++++++++++++++++------- degen-coordinator/src/bitcoin_wallet.rs | 2 +- degen-coordinator/src/config.rs | 1 + degen-coordinator/src/coordinator.rs | 3 ++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/degen-coordinator/conf/coordinator.toml b/degen-coordinator/conf/coordinator.toml index 6170ea76..4d4b9494 100644 --- a/degen-coordinator/conf/coordinator.toml +++ b/degen-coordinator/conf/coordinator.toml @@ -5,5 +5,5 @@ bitcoin_node_rpc_url = "http://devnet:devnet@localhost:18443" frost_dkg_round_id = 0 signer_config_path = "" network_private_key = "" -network = "testnet" +network = "regtest" transaction_fee = 2000 diff --git a/degen-coordinator/src/bitcoin_node.rs b/degen-coordinator/src/bitcoin_node.rs index d5678839..e565eee5 100644 --- a/degen-coordinator/src/bitcoin_node.rs +++ b/degen-coordinator/src/bitcoin_node.rs @@ -85,7 +85,7 @@ impl BitcoinNode for LocalhostBitcoinNode { fn load_wallet(&self, address: &BitcoinAddress) -> Result<(), Error> { let result = self.create_empty_wallet(); if let Err(Error::RPCError(message)) = &result { - if !message.ends_with("Database already exists.\"") { + if !message.ends_with("Database already exists.") { return result; } // If the database already exists, no problem. Just emit a warning. @@ -120,6 +120,18 @@ impl BitcoinNode for LocalhostBitcoinNode { } } + +#[derive(Debug, Deserialize)] +struct RpcErrorResponse { + error: RpcError, +} + +#[derive(Debug, Deserialize)] +struct RpcError { + code: i32, + message: String, +} + impl LocalhostBitcoinNode { pub fn new(bitcoind_api: String) -> LocalhostBitcoinNode { Self { bitcoind_api } @@ -147,12 +159,13 @@ impl LocalhostBitcoinNode { Ok(json_result) } Err(ureq::Error::Status(code, response)) => { - println!("Response {:#?}", &response); - println!("JSON response {:#?}", &response.into_json()?); - println!("JSON response {:#?}", &response.into_string()?); - Err(Error::RPCError("Andrei".to_string())) + let rpc_response: RpcErrorResponse = serde_json::from_str(&response.into_string().unwrap()).unwrap(); + println!("Response {:#?}", &rpc_response); + // println!("JSON response {:#?}", &response.into_json()?); + // println!("JSON response {:#?}", &response.into_string()?); + Err(Error::RPCError(rpc_response.error.message)) } - Err(_) => {Err(Error::RPCError("Andrei".to_string()))} + Err(error) => { Err(Error::RPCError(error.to_string())) } } // let response = ureq::post(&self.bitcoind_api) // .send_json(json_rpc) @@ -166,7 +179,7 @@ impl LocalhostBitcoinNode { } fn create_empty_wallet(&self) -> Result<(), Error> { - let wallet_name = ""; + let wallet_name = "test"; let disable_private_keys = false; let blank = true; let passphrase = ""; diff --git a/degen-coordinator/src/bitcoin_wallet.rs b/degen-coordinator/src/bitcoin_wallet.rs index 72812934..ed0ce035 100644 --- a/degen-coordinator/src/bitcoin_wallet.rs +++ b/degen-coordinator/src/bitcoin_wallet.rs @@ -229,7 +229,7 @@ mod tests { let public_key = PublicKey::from_str("cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115") .expect("Failed to construct a valid public key for the bitcoin wallet"); - BitcoinWallet::new(public_key, bitcoin::Network::Testnet) + BitcoinWallet::new(public_key, bitcoin::Network::Regtest) } /// Helper function for building a random txid (32 byte hex string) diff --git a/degen-coordinator/src/config.rs b/degen-coordinator/src/config.rs index 77a92e84..ec4cc985 100644 --- a/degen-coordinator/src/config.rs +++ b/degen-coordinator/src/config.rs @@ -17,6 +17,7 @@ pub enum Error { pub enum Network { Mainnet, Testnet, + Regtest } #[derive(serde::Deserialize)] diff --git a/degen-coordinator/src/coordinator.rs b/degen-coordinator/src/coordinator.rs index c7ac73ff..a726f881 100644 --- a/degen-coordinator/src/coordinator.rs +++ b/degen-coordinator/src/coordinator.rs @@ -282,6 +282,7 @@ impl TryFrom for StacksCoordinator { { Network::Mainnet => (TransactionVersion::Mainnet, bitcoin::Network::Bitcoin), Network::Testnet => (TransactionVersion::Testnet, bitcoin::Network::Testnet), + Network::Regtest => (TransactionVersion::Testnet, bitcoin::Network::Regtest), }; // Create the frost coordinator and use it to generate the aggregate public key and corresponding bitcoin wallet address @@ -324,7 +325,7 @@ impl TryFrom for StacksCoordinator { local_stacks_node.broadcast_transaction(&tx)?; let local_bitcoin_node = LocalhostBitcoinNode::new(config.bitcoin_node_rpc_url.clone()); - local_bitcoin_node.load_wallet(bitcoin_wallet.address())?; + // local_bitcoin_node.load_wallet(bitcoin_wallet.address())?; let local_fee_wallet = WrapPegWallet { bitcoin_wallet, From 6c0d35d56edc5ef5e2f6dd6e787cf97ae819d73c Mon Sep 17 00:00:00 2001 From: Andrei Alexandrescu Date: Sun, 4 Jun 2023 01:08:54 +0300 Subject: [PATCH 6/6] Comms on creating txs --- degen-coordinator/src/coordinator.rs | 3 ++ degen-coordinator/src/main.rs | 1 + frost-coordinator/src/coordinator.rs | 65 +++++++++++++++++++++++++--- frost-signer/src/signer.rs | 65 ++++++++++++++++++---------- frost-signer/src/signing_round.rs | 56 ++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 28 deletions(-) diff --git a/degen-coordinator/src/coordinator.rs b/degen-coordinator/src/coordinator.rs index a726f881..eb7dc2c1 100644 --- a/degen-coordinator/src/coordinator.rs +++ b/degen-coordinator/src/coordinator.rs @@ -87,6 +87,7 @@ pub trait Coordinator: Sized { fn peg_queue(&self) -> &Self::PegQueue; fn fee_wallet_mut(&mut self) -> &mut Self::FeeWallet; fn fee_wallet(&self) -> &Self::FeeWallet; + // QQ: Deployer why is there a frost_coordinator and one mut? fn frost_coordinator(&self) -> &FrostCoordinator; fn frost_coordinator_mut(&mut self) -> &mut FrostCoordinator; fn stacks_node(&self) -> &Self::StacksNode; @@ -94,6 +95,8 @@ pub trait Coordinator: Sized { // Provided methods fn degen_run_one(mut self) -> Result<()> { + self.frost_coordinator_mut().run_degen_create_funding_txs(); + let (sender, receiver) = mpsc::channel::(); Self::poll_ping_thread(sender); diff --git a/degen-coordinator/src/main.rs b/degen-coordinator/src/main.rs index db691622..a075b992 100644 --- a/degen-coordinator/src/main.rs +++ b/degen-coordinator/src/main.rs @@ -21,6 +21,7 @@ fn main() { match StacksCoordinator::try_from(config) { Ok(mut coordinator) => { // Determine what action the caller wishes to perform + println!("{:?}", cli.command); match cli.command { Command::DegenRunOne => { info!("Running Coordinator in Degen Run One"); diff --git a/frost-coordinator/src/coordinator.rs b/frost-coordinator/src/coordinator.rs index b979c3ed..e6a04bde 100644 --- a/frost-coordinator/src/coordinator.rs +++ b/frost-coordinator/src/coordinator.rs @@ -7,7 +7,7 @@ use frost_signer::{ net::{Error as HttpNetError, Message, NetListen}, signing_round::{ DkgBegin, DkgPublicShare, MessageTypes, NonceRequest, NonceResponse, Signable, - SignatureShareRequest, + SignatureShareRequest, CreateFundingTx, }, }; use hashbrown::HashSet; @@ -52,12 +52,15 @@ pub enum Command { } pub struct Coordinator { - id: u32, // Used for relay coordination + id: u32, + // Used for relay coordination current_dkg_id: u64, current_dkg_public_id: u64, current_sign_id: u64, current_sign_nonce_id: u64, - total_signers: u32, // Assuming the signers cover all id:s in {1, 2, ..., total_signers} + current_create_tx_id: u64, + total_signers: u32, + // Assuming the signers cover all id:s in {1, 2, ..., total_signers} total_keys: u32, threshold: u32, network: Network, @@ -76,6 +79,7 @@ impl Coordinator { current_dkg_public_id: 0, current_sign_id: 1, current_sign_nonce_id: 1, + current_create_tx_id: 0, total_signers: config.total_signers, total_keys: config.total_keys, threshold: config.keys_threshold, @@ -90,8 +94,8 @@ impl Coordinator { } impl Coordinator -where - Error: From, + where + Error: From, { pub fn run(&mut self, command: &Command) -> Result<(), Error> { match command { @@ -127,6 +131,31 @@ where Ok(public_key) } + pub fn run_degen_create_funding_txs(&mut self) -> Result<(), Error> { + self.current_create_tx_id = self.current_create_tx_id.wrapping_add(1); + self.start_create_funding_txs()?; + let something = self.wait_for_funding_txs()?; + Ok(()) + } + + fn start_create_funding_txs(&mut self) -> Result<(), Error> { + info!( + "Create funding txs number #{}.", + self.current_create_tx_id + ); + let create_funding_tx = CreateFundingTx { + funding_tx_id: self.current_dkg_id + }; + + let create_funding_txs_message = Message { + sig: create_funding_tx.sign(&self.network_private_key).expect(""), + msg: MessageTypes::CreateFundingTx(create_funding_tx), + }; + + self.network.send_message(create_funding_txs_message)?; + Ok(()) + } + fn start_public_shares(&mut self) -> Result<(), Error> { self.dkg_public_shares.clear(); info!( @@ -464,6 +493,32 @@ where .build(); backoff::retry_notify(backoff_timer, get_next_message, notify).map_err(|_| Error::Timeout) } + + fn wait_for_funding_txs(&mut self) -> Result { + let mut ids_to_await: HashSet = (1..=self.total_signers).collect(); + + info!( + "Funding Tx Round #{}: waiting for funding txs from signers {:?}", + self.current_create_tx_id, ids_to_await + ); + + loop { + if ids_to_await.is_empty() { + info!("We have all the input txs"); + } + + match self.wait_for_next_message()?.msg { + MessageTypes::FundingTxDone(funding_tx_done) => { + ids_to_await.remove(&funding_tx_done.signer_id); + info!( + "Received round #{} from signer #{}. Waiting on {:?}. Message was {:?}", + funding_tx_done.funding_tx_id, funding_tx_done.signer_id, ids_to_await, funding_tx_done.funding_tx_done + ); + } + _ => {} + } + } + } } #[cfg(test)] diff --git a/frost-signer/src/signer.rs b/frost-signer/src/signer.rs index b967714a..d3a88f74 100644 --- a/frost-signer/src/signer.rs +++ b/frost-signer/src/signer.rs @@ -45,6 +45,7 @@ impl Signer { // Retreive a message from coordinator let inbound = rx.recv()?; // blocking let outbounds = round.process(inbound.msg)?; + // Everything beneath is what this signer is passing along to the relay. for out in outbounds { let msg = Message { msg: out.clone(), @@ -73,6 +74,12 @@ impl Signer { MessageTypes::SignShareResponse(msg) => { msg.sign(&network_private_key).expect("").to_vec() } + MessageTypes::CreateFundingTx(msg) => { + msg.sign(&network_private_key).expect("").to_vec() + } + MessageTypes::FundingTxDone(msg) => { + msg.sign(&network_private_key).expect("").to_vec() + } }, }; net.send_message(msg)?; @@ -237,6 +244,18 @@ fn verify_msg( return false; } } + MessageTypes::CreateFundingTx(msg)=> { + if !msg.verify(&m.sig, coordinator_public_key) { + warn!("Received a CreateFundingTx message with an invalid signature."); + return false; + } + } + MessageTypes::FundingTxDone(msg)=> { + if !msg.verify(&m.sig, coordinator_public_key) { + warn!("Received a FundingTxDone message with an invalid signature."); + return false; + } + } } true } @@ -323,12 +342,12 @@ mod test { assert!(verify_msg( &dkg_begin, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); assert!(verify_msg( &dkg_private_begin, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); // Check with incorrect public key @@ -368,13 +387,13 @@ mod test { assert!(verify_msg( &dkg_end, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); assert!(verify_msg( &dkg_public_end, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); //Let us sign with the wrong sec key... @@ -389,13 +408,13 @@ mod test { assert!(!verify_msg( &dkg_end, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); assert!(!verify_msg( &dkg_public_end, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -421,12 +440,12 @@ mod test { assert!(!verify_msg( &dkg_end, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); assert!(!verify_msg( &dkg_public_end, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -454,7 +473,7 @@ mod test { assert!(verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); // Let's sign with the wrong sec key... @@ -465,7 +484,7 @@ mod test { assert!(!verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -490,7 +509,7 @@ mod test { assert!(!verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -512,7 +531,7 @@ mod test { assert!(verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); // Let us sign with the wrong sec key... @@ -522,7 +541,7 @@ mod test { assert!(!verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -541,7 +560,7 @@ mod test { assert!(!verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -562,7 +581,7 @@ mod test { assert!(verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); // Let's check with the wrong pub key assert!(!verify_msg( @@ -593,7 +612,7 @@ mod test { assert!(verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); // Let's sign with the wrong sec key... @@ -603,7 +622,7 @@ mod test { assert!(!verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -625,7 +644,7 @@ mod test { assert!(!verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -656,14 +675,14 @@ mod test { assert!(verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); // Let's check the wrong pub key... assert!(!verify_msg( &message, &config.signer_keys, - &config.signer_keys.key_ids.get(&1).unwrap() + &config.signer_keys.key_ids.get(&1).unwrap(), )); } @@ -688,7 +707,7 @@ mod test { assert!(verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); // Let's sign with the wrong sec key... @@ -697,7 +716,7 @@ mod test { assert!(!verify_msg( &message, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } @@ -718,7 +737,7 @@ mod test { assert!(!verify_msg( &sign_share_response, &config.signer_keys, - &config.coordinator_pub_key + &config.coordinator_pub_key, )); } } diff --git a/frost-signer/src/signing_round.rs b/frost-signer/src/signing_round.rs index 5b299f6d..db8a1551 100644 --- a/frost-signer/src/signing_round.rs +++ b/frost-signer/src/signing_round.rs @@ -142,6 +142,9 @@ pub enum MessageTypes { NonceResponse(NonceResponse), SignShareRequest(SignatureShareRequest), SignShareResponse(SignatureShareResponse), + // Degen messages + CreateFundingTx(CreateFundingTx), + FundingTxDone(FundingTxDone), } #[derive(Clone, Serialize, Deserialize, Debug)] @@ -196,6 +199,36 @@ impl Signable for DkgBegin { } } +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct CreateFundingTx { + pub funding_tx_id: u64, // QQ: Why do we need this? +} + +impl Signable for CreateFundingTx { + fn hash(&self, hasher: &mut Sha256) { + hasher.update("CREATE_FUNDING_TX".as_bytes()); + hasher.update(self.funding_tx_id.to_be_bytes()); + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct FundingTxDone { + pub funding_tx_id: u64, + pub signer_id: u32, + // QQ: Keep in sync messages + // TODO Deployer whatever you like man + pub funding_tx_done: String, +} + +impl Signable for FundingTxDone { + fn hash(&self, hasher: &mut Sha256) { + hasher.update("FUNDING_TX_DONE".as_bytes()); + hasher.update(self.funding_tx_id.to_be_bytes()); + hasher.update(self.signer_id.to_be_bytes()); + hasher.update(self.funding_tx_done.as_bytes()); + } +} + #[derive(Clone, Serialize, Deserialize, Debug)] pub struct DkgEnd { pub dkg_id: u64, @@ -360,6 +393,9 @@ impl SigningRound { MessageTypes::SignShareRequest(sign_share_request) => { self.sign_share_request(sign_share_request) } + MessageTypes::CreateFundingTx(create_funding_tx) => { + self.create_funding_tx(create_funding_tx) + } MessageTypes::NonceRequest(nonce_request) => self.nonce_request(nonce_request), _ => Ok(vec![]), // TODO }; @@ -699,6 +735,26 @@ impl SigningRound { ); Ok(vec![]) } + + fn create_funding_tx(&mut self, create_funding_tx: CreateFundingTx) -> Result, Error> { + let mut msgs = vec![]; + + info!( + "create funding tx #{} for signer #{}", + create_funding_tx.funding_tx_id, + self.signer.frost_signer.get_id(), + ); + + let funding_tx_done = FundingTxDone { + funding_tx_done: "Andrei is here have no fear".to_string(), + funding_tx_id: create_funding_tx.funding_tx_id, + signer_id: self.signer.signer_id, + }; + let funding_tx_done_msg = MessageTypes::FundingTxDone(funding_tx_done); + msgs.push(funding_tx_done_msg); + + Ok(msgs) + } } impl From<&FrostSigner> for SigningRound {