diff --git a/Cargo.lock b/Cargo.lock index cc29c2b76..6f0d0c621 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.8", + "instant", + "rand 0.8.5", +] + [[package]] name = "backtrace" version = "0.3.67" @@ -3440,6 +3451,7 @@ dependencies = [ name = "stacks-signer" version = "0.0.1" dependencies = [ + "backoff", "bincode", "clap 4.4.1", "clarity", diff --git a/stacks-signer/Cargo.toml b/stacks-signer/Cargo.toml index ff4888453..50f501b51 100644 --- a/stacks-signer/Cargo.toml +++ b/stacks-signer/Cargo.toml @@ -20,6 +20,7 @@ name = "stacks-signer" path = "src/main.rs" [dependencies] +backoff = "0.4" bincode = "1.3.3" clarity = { path = "../clarity" } clap = { version = "4.1.1", features = ["derive", "env"] } diff --git a/stacks-signer/src/config.rs b/stacks-signer/src/config.rs index 4251913cf..d634dd0cd 100644 --- a/stacks-signer/src/config.rs +++ b/stacks-signer/src/config.rs @@ -101,6 +101,8 @@ pub struct Config { pub endpoint: SocketAddr, /// smart contract that controls the target stackerdb pub stackerdb_contract_id: QualifiedContractIdentifier, + /// smart contract that controls the target stackerdb + pub pox_contract_id: Option, /// The Scalar representation of the private key for signer communication pub message_private_key: Scalar, /// The signer's Stacks private key @@ -133,8 +135,11 @@ struct RawConfigFile { pub node_host: String, /// endpoint to stackerdb receiver pub endpoint: String, - /// contract identifier + // FIXME: these contract's should go away in non testing scenarios. Make them both optionals. + /// Stacker db contract identifier pub stackerdb_contract_id: String, + /// pox contract identifier + pub pox_contract_id: Option, /// the 32 byte ECDSA private key used to sign blocks, chunks, and transactions pub message_private_key: String, /// The hex representation of the signer's Stacks private key used for communicating @@ -214,6 +219,17 @@ impl TryFrom for Config { ) })?; + let pox_contract_id = if let Some(id) = raw_data.pox_contract_id.as_ref() { + Some(QualifiedContractIdentifier::parse(id).map_err(|_| { + ConfigError::BadField( + "pox_contract_id".to_string(), + raw_data.pox_contract_id.unwrap_or("".to_string()), + ) + })?) + } else { + None + }; + let message_private_key = Scalar::try_from(raw_data.message_private_key.as_str()).map_err(|_| { ConfigError::BadField( @@ -265,6 +281,7 @@ impl TryFrom for Config { node_host, endpoint, stackerdb_contract_id, + pox_contract_id, message_private_key, stacks_private_key, stacks_address, diff --git a/stacks-signer/src/main.rs b/stacks-signer/src/main.rs index cf8b4bd72..dce30b8d4 100644 --- a/stacks-signer/src/main.rs +++ b/stacks-signer/src/main.rs @@ -269,6 +269,7 @@ fn handle_generate_files(args: GenerateFilesArgs) { args.num_keys, &args.db_args.host.to_string(), &args.db_args.contract.to_string(), + None, args.timeout.map(Duration::from_millis), ); debug!("Built {:?} signer config tomls.", signer_config_tomls.len()); diff --git a/stacks-signer/src/runloop.rs b/stacks-signer/src/runloop.rs index 69a1904e0..7d6bf54d1 100644 --- a/stacks-signer/src/runloop.rs +++ b/stacks-signer/src/runloop.rs @@ -15,7 +15,7 @@ use wsts::state_machine::{OperationResult, PublicKeys}; use wsts::v2; use crate::config::Config; -use crate::stacks_client::StacksClient; +use crate::stacks_client::{retry_with_exponential_backoff, ClientError, StacksClient}; /// Which operation to perform #[derive(PartialEq, Clone)] @@ -36,6 +36,9 @@ pub enum RunLoopCommand { /// The RunLoop state #[derive(PartialEq, Debug)] pub enum State { + // TODO: Uninitialized should indicate we need to replay events/configure the signer + /// The runloop signer is uninitialized + Uninitialized, /// The runloop is idle Idle, /// The runloop is executing a DKG round @@ -48,7 +51,7 @@ pub enum State { pub struct RunLoop { /// The timeout for events pub event_timeout: Duration, - /// the coordinator for inbound messages + /// The coordinator for inbound messages pub coordinator: C, /// The signing round used to sign messages // TODO: update this to use frost_signer directly instead of the frost signing round @@ -63,7 +66,27 @@ pub struct RunLoop { } impl RunLoop { - /// Helper function to actually execute the command and update state accordingly + /// Initialize the signer, reading the stacker-db state and setting the aggregate public key + fn initialize(&mut self) -> Result<(), ClientError> { + // TODO: update to read stacker db to get state. + // Check if the aggregate key is set in the pox contract + if let Some(key) = self.stacks_client.get_aggregate_public_key()? { + debug!("Aggregate public key is set: {:?}", key); + self.coordinator.set_aggregate_public_key(Some(key)); + } else { + // Update the state to IDLE so we don't needlessy requeue the DKG command. + let (coordinator_id, _) = calculate_coordinator(&self.signing_round.public_keys); + if coordinator_id == self.signing_round.signer_id + && self.commands.front() != Some(&RunLoopCommand::Dkg) + { + self.commands.push_front(RunLoopCommand::Dkg); + } + } + self.state = State::Idle; + Ok(()) + } + + /// Execute the given command and update state accordingly /// Returns true when it is successfully executed, else false fn execute_command(&mut self, command: &RunLoopCommand) -> bool { match command { @@ -73,7 +96,7 @@ impl RunLoop { Ok(msg) => { let ack = self .stacks_client - .send_message(self.signing_round.signer_id, msg); + .send_message_with_retry(self.signing_round.signer_id, msg); debug!("ACK: {:?}", ack); self.state = State::Dkg; true @@ -99,7 +122,7 @@ impl RunLoop { Ok(msg) => { let ack = self .stacks_client - .send_message(self.signing_round.signer_id, msg); + .send_message_with_retry(self.signing_round.signer_id, msg); debug!("ACK: {:?}", ack); self.state = State::Sign; true @@ -115,9 +138,14 @@ impl RunLoop { } } - /// Helper function to check the current state, process the next command in the queue, and update state accordingly + /// Attempt to process the next command in the queue, and update state accordingly fn process_next_command(&mut self) { match self.state { + State::Uninitialized => { + debug!( + "Signer is uninitialized. Waiting for aggregate public key from stacks node..." + ); + } State::Idle => { if let Some(command) = self.commands.pop_front() { while !self.execute_command(&command) { @@ -205,26 +233,29 @@ impl From<&Config> for RunLoop> { .iter() .map(|i| i - 1) // SigningRound::new (unlike SigningRound::from) doesn't do this .collect::>(); + let coordinator = FrostCoordinator::new( + total_signers, + total_keys, + threshold, + config.message_private_key, + ); + let signing_round = SigningRound::new( + threshold, + total_signers, + total_keys, + config.signer_id, + key_ids, + config.message_private_key, + config.signer_ids_public_keys.clone(), + ); + let stacks_client = StacksClient::from(config); RunLoop { event_timeout: config.event_timeout, - coordinator: FrostCoordinator::new( - total_signers, - total_keys, - threshold, - config.message_private_key, - ), - signing_round: SigningRound::new( - threshold, - total_signers, - total_keys, - config.signer_id, - key_ids, - config.message_private_key, - config.signer_ids_public_keys.clone(), - ), - stacks_client: StacksClient::from(config), + coordinator, + signing_round, + stacks_client, commands: VecDeque::new(), - state: State::Idle, + state: State::Uninitialized, } } } @@ -244,10 +275,19 @@ impl SignerRunLoop, RunLoopCommand> for R cmd: Option, res: Sender>, ) -> Option> { + info!( + "Running one pass for signer ID# {}. Current state: {:?}", + self.signing_round.signer_id, self.state + ); if let Some(command) = cmd { self.commands.push_back(command); } - // First process any arrived events + if self.state == State::Uninitialized { + let request_fn = || self.initialize().map_err(backoff::Error::transient); + retry_with_exponential_backoff(request_fn) + .expect("Failed to connect to initialize due to timeout. Stacks node may be down."); + } + // Process any arrived events if let Some(event) = event { let (outbound_messages, operation_results) = self.process_event(&event); debug!( @@ -257,7 +297,7 @@ impl SignerRunLoop, RunLoopCommand> for R for msg in outbound_messages { let ack = self .stacks_client - .send_message(self.signing_round.signer_id, msg); + .send_message_with_retry(self.signing_round.signer_id, msg); if let Ok(ack) = ack { debug!("ACK: {:?}", ack); } else { diff --git a/stacks-signer/src/stacks_client.rs b/stacks-signer/src/stacks_client.rs index 68ce99705..700858cfb 100644 --- a/stacks-signer/src/stacks_client.rs +++ b/stacks-signer/src/stacks_client.rs @@ -1,22 +1,41 @@ +use std::time::Duration; + use bincode::Error as BincodeError; -use blockstack_lib::chainstate::stacks::{ - StacksTransaction, StacksTransactionSigner, TransactionAnchorMode, TransactionAuth, - TransactionContractCall, TransactionPayload, TransactionPostConditionMode, - TransactionSpendingCondition, TransactionVersion, +use blockstack_lib::{ + burnchains::Txid, + chainstate::stacks::{ + StacksTransaction, StacksTransactionSigner, TransactionAnchorMode, TransactionAuth, + TransactionContractCall, TransactionPayload, TransactionPostConditionMode, + TransactionSpendingCondition, TransactionVersion, + }, +}; +use clarity::vm::{ + types::{serialization::SerializationError, QualifiedContractIdentifier, SequenceData}, + Value as ClarityValue, {ClarityName, ContractName}, }; -use clarity::vm::{ClarityName, ContractName, Value as ClarityValue}; use hashbrown::HashMap; use libsigner::{RPCError, SignerSession, StackerDBSession}; use libstackerdb::{Error as StackerDBError, StackerDBChunkAckData, StackerDBChunkData}; use serde_json::json; use slog::{slog_debug, slog_warn}; -use stacks_common::codec::StacksMessageCodec; -use stacks_common::types::chainstate::{StacksAddress, StacksPrivateKey, StacksPublicKey}; -use stacks_common::{codec, debug, warn}; -use wsts::net::{Message, Packet}; +use stacks_common::{ + codec::StacksMessageCodec, + debug, + types::chainstate::{StacksAddress, StacksPrivateKey, StacksPublicKey}, + warn, +}; +use wsts::{ + net::{Message, Packet}, + Point, Scalar, +}; use crate::config::Config; +/// Backoff timer initial interval in milliseconds +const BACKOFF_INITIAL_INTERVAL: u64 = 128; +/// Backoff timer max interval in milliseconds +const BACKOFF_MAX_INTERVAL: u64 = 16384; + /// Temporary placeholder for the number of slots allocated to a stacker-db writer. This will be retrieved from the stacker-db instance in the future /// See: https://github.com/stacks-network/stacks-blockchain/issues/3921 /// Is equal to the number of message types @@ -27,7 +46,7 @@ pub const SLOTS_PER_USER: u32 = 10; pub enum ClientError { /// An error occurred serializing the message #[error("Unable to serialize stacker-db message: {0}")] - Serialize(#[from] BincodeError), + StackerDBSerializationError(#[from] BincodeError), /// Failed to sign stacker-db chunk #[error("Failed to sign stacker-db chunk: {0}")] FailToSign(#[from] StackerDBError), @@ -46,27 +65,24 @@ pub enum ClientError { /// Reqwest specific error occurred #[error("{0}")] ReqwestError(#[from] reqwest::Error), - /// Failure to submit a read only contract call - #[error("Failure to submit tx")] - TransactionSubmissionFailure, - /// Failed to sign with the provided private key - #[error("Failed to sign with the given private key")] - SignatureGenerationFailure, - /// Failed to sign with the provided private key - #[error("Failed to sign with the sponsor private key")] - SponsorSignatureGenerationFailure, - /// Failed to sign with the provided private key - #[error("Failed to serialize tx {0}")] - FailureToSerializeTx(String), - /// Failed to sign with the provided private key - #[error("{0}")] - FailureToDeserializeTx(#[from] codec::Error), - /// Failed to create a p2pkh spending condition - #[error("Failed to create p2pkh spending condition from public key {0}")] - FailureToCreateSpendingFromPublicKey(String), + /// Failed to build and sign a new Stacks transaction. + #[error("Failed to generate transaction from a transaction signer: {0}")] + TransactionGenerationFailure(String), /// Stacks node client request failed #[error("Stacks node client request failed: {0}")] RequestFailure(reqwest::StatusCode), + /// Failed to serialize a Clarity value + #[error("Failed to serialize Clarity value: {0}")] + ClaritySerializationError(#[from] SerializationError), + /// Failed to parse a Clarity value + #[error("Recieved a malformed clarity value: {0}")] + MalformedClarityValue(ClarityValue), + /// Invalid Clarity Name + #[error("Invalid Clarity Name: {0}")] + InvalidClarityName(String), + /// Backoff retry timeout + #[error("Backoff retry timeout occurred. Stacks node may be down.")] + RetryTimeout, } /// The Stacks signer client used to communicate with the stacker-db instance @@ -79,7 +95,7 @@ pub struct StacksClient { stacks_private_key: StacksPrivateKey, /// A map of a slot ID to last chunk version slot_versions: HashMap, - /// The RPC endpoint used to communicate HTTP endpoints with + /// The stacks node HTTP base endpoint http_origin: String, /// The types of transactions tx_version: TransactionVersion, @@ -87,6 +103,8 @@ pub struct StacksClient { chain_id: u32, /// The Client used to make HTTP connects stacks_node_client: reqwest::blocking::Client, + /// The pox contract ID + pox_contract_id: Option, } impl From<&Config> for StacksClient { @@ -103,13 +121,14 @@ impl From<&Config> for StacksClient { tx_version: config.network.to_transaction_version(), chain_id: config.network.to_chain_id(), stacks_node_client: reqwest::blocking::Client::new(), + pox_contract_id: config.pox_contract_id.clone(), } } } impl StacksClient { - /// Sends messages to the stacker-db - pub fn send_message( + /// Sends messages to the stacker-db with an exponential backoff retry + pub fn send_message_with_retry( &mut self, id: u32, message: Packet, @@ -122,7 +141,12 @@ impl StacksClient { let mut chunk = StackerDBChunkData::new(slot_id, slot_version, message_bytes.clone()); chunk.sign(&self.stacks_private_key)?; debug!("Sending a chunk to stackerdb!\n{:?}", chunk.clone()); - let chunk_ack = self.stackerdb_session.put_chunk(chunk)?; + let send_request = || { + self.stackerdb_session + .put_chunk(chunk.clone()) + .map_err(backoff::Error::transient) + }; + let chunk_ack: StackerDBChunkAckData = retry_with_exponential_backoff(send_request)?; self.slot_versions.insert(slot_id, slot_version); if chunk_ack.accepted { @@ -144,6 +168,23 @@ impl StacksClient { } } + /// Retrieve the current DKG aggregate public key + pub fn get_aggregate_public_key(&self) -> Result, ClientError> { + let reward_cycle = self.get_current_reward_cycle()?; + let function_name_str = "get-aggregate-public-key"; // FIXME: this may need to be modified to match .pox-4 + let function_name = ClarityName::try_from(function_name_str) + .map_err(|_| ClientError::InvalidClarityName(function_name_str.to_string()))?; + let (contract_addr, contract_name) = self.get_pox_contract()?; + let function_args = &[ClarityValue::UInt(reward_cycle as u128)]; + let contract_response_hex = self.read_only_contract_call_with_retry( + &contract_addr, + &contract_name, + &function_name, + function_args, + )?; + self.parse_aggregate_public_key(&contract_response_hex) + } + /// Retrieve the total number of slots allocated to a stacker-db writer #[allow(dead_code)] pub fn slots_per_user(&self) -> u32 { @@ -152,143 +193,194 @@ impl StacksClient { SLOTS_PER_USER } - fn serialize_sign_sig_tx_anchor_mode_version( - &self, - payload: TransactionPayload, - sender_nonce: u64, - tx_fee: u64, - anchor_mode: TransactionAnchorMode, - ) -> Result, ClientError> { - self.seralize_sign_sponsored_tx_anchor_mode_version( - payload, - None, - sender_nonce, - None, - tx_fee, - anchor_mode, - ) + /// Helper function to retrieve the current reward cycle number from the stacks node + fn get_current_reward_cycle(&self) -> Result { + let send_request = || { + self.stacks_node_client + .get(self.pox_path()) + .send() + .map_err(backoff::Error::transient) + }; + let response = retry_with_exponential_backoff(send_request)?; + if !response.status().is_success() { + return Err(ClientError::RequestFailure(response.status())); + } + let json_response = response.json::()?; + let entry = "current_cycle"; + json_response + .get(entry) + .and_then(|cycle: &serde_json::Value| cycle.get("id")) + .and_then(|id| id.as_u64()) + .ok_or_else(|| ClientError::InvalidJsonEntry(format!("{}.id", entry))) } - fn seralize_sign_sponsored_tx_anchor_mode_version( - &self, - payload: TransactionPayload, - payer: Option<&StacksPrivateKey>, - sender_nonce: u64, - payer_nonce: Option, - tx_fee: u64, - anchor_mode: TransactionAnchorMode, - ) -> Result, ClientError> { - let pubkey = StacksPublicKey::from_private(&self.stacks_private_key); - let mut sender_spending_condition = - TransactionSpendingCondition::new_singlesig_p2pkh(pubkey).ok_or( - ClientError::FailureToCreateSpendingFromPublicKey(pubkey.to_hex()), - )?; - sender_spending_condition.set_nonce(sender_nonce); + /// Helper function to retrieve the next possible nonce for the signer from the stacks node + #[allow(dead_code)] + fn get_next_possible_nonce(&self) -> Result { + //FIXME: use updated RPC call to get mempool nonces. Depends on https://github.com/stacks-network/stacks-blockchain/issues/4000 + todo!("Get the next possible nonce from the stacks node"); + } - let auth = match (payer, payer_nonce) { - (Some(payer), Some(payer_nonce)) => { - let pubkey = StacksPublicKey::from_private(payer); - let mut payer_spending_condition = - TransactionSpendingCondition::new_singlesig_p2pkh(pubkey).ok_or( - ClientError::FailureToCreateSpendingFromPublicKey(pubkey.to_hex()), - )?; - payer_spending_condition.set_nonce(payer_nonce); - payer_spending_condition.set_tx_fee(tx_fee); - TransactionAuth::Sponsored(sender_spending_condition, payer_spending_condition) - } - _ => { - sender_spending_condition.set_tx_fee(tx_fee); - TransactionAuth::Standard(sender_spending_condition) - } + /// Helper function to retrieve the pox contract address and name from the stacks node + fn get_pox_contract(&self) -> Result<(StacksAddress, ContractName), ClientError> { + // Check if we have overwritten the pox contract ID in the config + if let Some(pox_contract) = self.pox_contract_id.clone() { + return Ok((pox_contract.issuer.into(), pox_contract.name)); + } + // TODO: we may want to cache the pox contract inside the client itself (calling this function once on init) + // https://github.com/stacks-network/stacks-blockchain/issues/4005 + let send_request = || { + self.stacks_node_client + .get(self.pox_path()) + .send() + .map_err(backoff::Error::transient) }; - let mut unsigned_tx = StacksTransaction::new(self.tx_version, auth, payload); - unsigned_tx.anchor_mode = anchor_mode; + let response = retry_with_exponential_backoff(send_request)?; + if !response.status().is_success() { + return Err(ClientError::RequestFailure(response.status())); + } + let json_response = response.json::()?; + let entry = "contract_id"; + let contract_id_string = json_response + .get(entry) + .and_then(|id: &serde_json::Value| id.as_str()) + .ok_or_else(|| ClientError::InvalidJsonEntry(entry.to_string()))?; + let id = QualifiedContractIdentifier::parse(contract_id_string).unwrap(); + Ok((id.issuer.into(), id.name)) + } + + /// Helper function that attempts to deserialize a clarity hex string as the aggregate public key + fn parse_aggregate_public_key(&self, hex: &str) -> Result, ClientError> { + let public_key_clarity_value = ClarityValue::try_deserialize_hex_untyped(hex)?; + if let ClarityValue::Optional(optional_data) = public_key_clarity_value.clone() { + if let Some(ClarityValue::Sequence(SequenceData::Buffer(public_key))) = + optional_data.data.map(|boxed| *boxed) + { + if public_key.data.len() != 32 { + return Err(ClientError::MalformedClarityValue(public_key_clarity_value)); + } + let mut bytes = [0_u8; 32]; + bytes.copy_from_slice(&public_key.data); + Ok(Some(Point::from(Scalar::from(bytes)))) + } else { + Ok(None) + } + } else { + Err(ClientError::MalformedClarityValue(public_key_clarity_value)) + } + } + + /// Sends a transaction to the stacks node for a modifying contract call + #[allow(dead_code)] + fn transaction_contract_call( + &self, + contract_addr: &StacksAddress, + contract_name: ContractName, + function_name: ClarityName, + function_args: &[ClarityValue], + ) -> Result { + debug!("Making a contract call to {contract_addr}.{contract_name}..."); + let signed_tx = self.build_signed_transaction( + contract_addr, + contract_name, + function_name, + function_args, + )?; + self.submit_tx(&signed_tx) + } + + /// Helper function to create a stacks transaction for a modifying contract call + fn build_signed_transaction( + &self, + contract_addr: &StacksAddress, + contract_name: ContractName, + function_name: ClarityName, + function_args: &[ClarityValue], + ) -> Result { + let tx_payload = TransactionPayload::ContractCall(TransactionContractCall { + address: *contract_addr, + contract_name, + function_name, + function_args: function_args.to_vec(), + }); + let public_key = StacksPublicKey::from_private(&self.stacks_private_key); + let tx_auth = TransactionAuth::Standard( + TransactionSpendingCondition::new_singlesig_p2pkh(public_key).ok_or( + ClientError::TransactionGenerationFailure(format!( + "Failed to create spending condition from public key: {}", + public_key.to_hex() + )), + )?, + ); + + let mut unsigned_tx = StacksTransaction::new(self.tx_version, tx_auth, tx_payload); + + // FIXME: Because signers are given priority, we can put down a tx fee of 0 + // https://github.com/stacks-network/stacks-blockchain/issues/4006 + // Note: if set to 0 now, will cause a failure (MemPoolRejection::FeeTooLow) + unsigned_tx.set_tx_fee(10_000); + unsigned_tx.set_origin_nonce(self.get_next_possible_nonce()?); + + unsigned_tx.anchor_mode = TransactionAnchorMode::Any; unsigned_tx.post_condition_mode = TransactionPostConditionMode::Allow; unsigned_tx.chain_id = self.chain_id; let mut tx_signer = StacksTransactionSigner::new(&unsigned_tx); tx_signer .sign_origin(&self.stacks_private_key) - .map_err(|_| ClientError::SignatureGenerationFailure)?; - if let (Some(payer), Some(_)) = (payer, payer_nonce) { - tx_signer - .sign_sponsor(payer) - .map_err(|_| ClientError::SponsorSignatureGenerationFailure)?; - } + .map_err(|e| ClientError::TransactionGenerationFailure(e.to_string()))?; - let Some(tx) = tx_signer.get_tx() else { - return Err(ClientError::SignatureGenerationFailure); - }; - - Ok(tx.serialize_to_vec()) + tx_signer + .get_tx() + .ok_or(ClientError::TransactionGenerationFailure( + "Failed to generate transaction from a transaction signer".to_string(), + )) } - /// Creates a transaction for a contract call that can be submitted to a stacks node - pub fn transaction_contract_call( - &self, - nonce: u64, - contract_addr: &StacksAddress, - contract_name: ContractName, - function_name: ClarityName, - function_args: &[ClarityValue], - ) -> Result, ClientError> { - let payload = TransactionContractCall { - address: *contract_addr, - contract_name, - function_name, - function_args: function_args.to_vec(), + /// Helper function to submit a transaction to the Stacks node + fn submit_tx(&self, tx: &StacksTransaction) -> Result { + let txid = tx.txid(); + let tx = tx.serialize_to_vec(); + let send_request = || { + self.stacks_node_client + .post(self.transaction_path()) + .header("Content-Type", "application/octet-stream") + .body(tx.clone()) + .send() + .map_err(backoff::Error::transient) }; - - let tx_fee = 0; - - self.serialize_sign_sig_tx_anchor_mode_version( - payload.into(), - nonce, - tx_fee, - TransactionAnchorMode::OnChainOnly, - ) - } - - /// Submits a transaction to the Stacks node - pub fn submit_tx(&self, tx: Vec) -> Result { - let path = format!("{}/v2/transactions", self.http_origin); - let res = self - .stacks_node_client - .post(path) - .header("Content-Type", "application/octet-stream") - .body(tx.clone()) - .send()?; - if res.status().is_success() { - let res: String = res.json()?; - let tx_deserialized = StacksTransaction::consensus_deserialize(&mut &tx[..])?; - assert_eq!(res, tx_deserialized.txid().to_string()); - Ok(res) - } else { - Err(ClientError::TransactionSubmissionFailure) + let response = retry_with_exponential_backoff(send_request)?; + if !response.status().is_success() { + return Err(ClientError::RequestFailure(response.status())); } + Ok(txid) } /// Makes a read only contract call to a stacks contract - pub fn read_only_contract_call( + pub fn read_only_contract_call_with_retry( &self, contract_addr: &StacksAddress, - contract_name: ContractName, - function_name: ClarityName, + contract_name: &ContractName, + function_name: &ClarityName, function_args: &[ClarityValue], ) -> Result { debug!("Calling read-only function {}...", function_name); - let body = json!({"sender": self.stacks_address.to_string(), "arguments": function_args}) - .to_string(); - let path = format!( - "{}/v2/contracts/call-read/{contract_addr}/{contract_name}/{function_name}", - self.http_origin - ); - let response = self - .stacks_node_client - .post(path) - .header("Content-Type", "application/json") - .body(body) - .send()?; + let args = function_args + .iter() + .map(|arg| arg.serialize_to_hex()) + .collect::>(); + let body = + json!({"sender": self.stacks_address.to_string(), "arguments": args}).to_string(); + let path = self.read_only_path(contract_addr, contract_name, function_name); + let send_request = || { + self.stacks_node_client + .post(path.clone()) + .header("Content-Type", "application/json") + .body(body.clone()) + .send() + .map_err(backoff::Error::transient) + }; + let response = retry_with_exponential_backoff(send_request)?; if !response.status().is_success() { return Err(ClientError::RequestFailure(response.status())); } @@ -314,6 +406,46 @@ impl StacksClient { .to_string(); Ok(result) } + + fn pox_path(&self) -> String { + format!("{}/v2/pox", self.http_origin) + } + + fn transaction_path(&self) -> String { + format!("{}/v2/transactions", self.http_origin) + } + + fn read_only_path( + &self, + contract_addr: &StacksAddress, + contract_name: &ContractName, + function_name: &ClarityName, + ) -> String { + format!( + "{}/v2/contracts/call-read/{contract_addr}/{contract_name}/{function_name}", + self.http_origin + ) + } +} + +/// Retry a function F with an exponential backoff and notification on transient failure +pub fn retry_with_exponential_backoff(request_fn: F) -> Result +where + F: FnMut() -> Result>, +{ + let notify = |_err, dur| { + debug!( + "Failed to connect to stacks-node. Next attempt in {:?}", + dur + ); + }; + + let backoff_timer = backoff::ExponentialBackoffBuilder::new() + .with_initial_interval(Duration::from_millis(BACKOFF_INITIAL_INTERVAL)) + .with_max_interval(Duration::from_millis(BACKOFF_MAX_INTERVAL)) + .build(); + + backoff::retry_notify(backoff_timer, request_fn, notify).map_err(|_| ClientError::RetryTimeout) } /// Helper function to determine the slot ID for the provided stacker-db writer id and the message type @@ -334,9 +466,11 @@ fn slot_id(id: u32, message: &Message) -> u32 { #[cfg(test)] mod tests { - use std::io::{Read, Write}; - use std::net::{SocketAddr, TcpListener}; - use std::thread::spawn; + use std::{ + io::{BufWriter, Read, Write}, + net::{SocketAddr, TcpListener}, + thread::spawn, + }; use super::*; @@ -380,10 +514,10 @@ mod tests { fn read_only_contract_call_200_success() { let config = TestConfig::new(); let h = spawn(move || { - config.client.read_only_contract_call( + config.client.read_only_contract_call_with_retry( &config.client.stacks_address, - ContractName::try_from("contract-name").unwrap(), - ClarityName::try_from("function-name").unwrap(), + &ContractName::try_from("contract-name").unwrap(), + &ClarityName::try_from("function-name").unwrap(), &[], ) }); @@ -395,14 +529,33 @@ mod tests { assert_eq!(result, "0x070d0000000473425443"); } + #[test] + fn read_only_contract_call_with_function_args_200_success() { + let config = TestConfig::new(); + let h = spawn(move || { + config.client.read_only_contract_call_with_retry( + &config.client.stacks_address, + &ContractName::try_from("contract-name").unwrap(), + &ClarityName::try_from("function-name").unwrap(), + &[ClarityValue::UInt(10_u128)], + ) + }); + write_response( + config.mock_server, + b"HTTP/1.1 200 OK\n\n{\"okay\":true,\"result\":\"0x070d0000000473425443\"}", + ); + let result = h.join().unwrap().unwrap(); + assert_eq!(result, "0x070d0000000473425443"); + } + #[test] fn read_only_contract_call_200_failure() { let config = TestConfig::new(); let h = spawn(move || { - config.client.read_only_contract_call( + config.client.read_only_contract_call_with_retry( &config.client.stacks_address, - ContractName::try_from("contract-name").unwrap(), - ClarityName::try_from("function-name").unwrap(), + &ContractName::try_from("contract-name").unwrap(), + &ClarityName::try_from("function-name").unwrap(), &[], ) }); @@ -419,17 +572,17 @@ mod tests { let config = TestConfig::new(); // Simulate a 400 Bad Request response let h = spawn(move || { - config.client.read_only_contract_call( + config.client.read_only_contract_call_with_retry( &config.client.stacks_address, - ContractName::try_from("contract-name").unwrap(), - ClarityName::try_from("function-name").unwrap(), + &ContractName::try_from("contract-name").unwrap(), + &ClarityName::try_from("function-name").unwrap(), &[], ) }); write_response(config.mock_server, b"HTTP/1.1 400 Bad Request\n\n"); let result = h.join().unwrap(); assert!(matches!( - dbg!(result), + result, Err(ClientError::RequestFailure( reqwest::StatusCode::BAD_REQUEST )) @@ -441,18 +594,170 @@ mod tests { let config = TestConfig::new(); // Simulate a 400 Bad Request response let h = spawn(move || { - config.client.read_only_contract_call( + config.client.read_only_contract_call_with_retry( &config.client.stacks_address, - ContractName::try_from("contract-name").unwrap(), - ClarityName::try_from("function-name").unwrap(), + &ContractName::try_from("contract-name").unwrap(), + &ClarityName::try_from("function-name").unwrap(), &[], ) }); write_response(config.mock_server, b"HTTP/1.1 404 Not Found\n\n"); let result = h.join().unwrap(); assert!(matches!( - dbg!(result), + result, Err(ClientError::RequestFailure(reqwest::StatusCode::NOT_FOUND)) )); } + + #[test] + fn pox_contract_success() { + let config = TestConfig::new(); + let h = spawn(move || config.client.get_pox_contract()); + write_response( + config.mock_server, + b"HTTP/1.1 200 Ok\n\n{\"contract_id\":\"ST000000000000000000002AMW42H.pox-3\"}", + ); + let (address, name) = h.join().unwrap().unwrap(); + assert_eq!( + (address.to_string().as_str(), name.to_string().as_str()), + ("ST000000000000000000002AMW42H", "pox-3") + ); + } + + #[test] + fn valid_reward_cycle_should_succeed() { + let config = TestConfig::new(); + let h = spawn(move || config.client.get_current_reward_cycle()); + write_response( + config.mock_server, + b"HTTP/1.1 200 Ok\n\n{\"current_cycle\":{\"id\":506,\"min_threshold_ustx\":5190000000000,\"stacked_ustx\":5690000000000,\"is_pox_active\":false}}", + ); + let current_cycle_id = h.join().unwrap().unwrap(); + assert_eq!(506, current_cycle_id); + } + + #[test] + fn invalid_reward_cycle_should_fail() { + let config = TestConfig::new(); + let h = spawn(move || config.client.get_current_reward_cycle()); + write_response( + config.mock_server, + b"HTTP/1.1 200 Ok\n\n{\"current_cycle\":{\"id\":\"fake id\", \"is_pox_active\":false}}", + ); + let res = h.join().unwrap(); + assert!(matches!(res, Err(ClientError::InvalidJsonEntry(_)))); + } + + #[test] + fn missing_reward_cycle_should_fail() { + let config = TestConfig::new(); + let h = spawn(move || config.client.get_current_reward_cycle()); + write_response( + config.mock_server, + b"HTTP/1.1 200 Ok\n\n{\"current_cycle\":{\"is_pox_active\":false}}", + ); + let res = h.join().unwrap(); + assert!(matches!(res, Err(ClientError::InvalidJsonEntry(_)))); + } + + #[test] + fn parse_valid_aggregate_public_key_should_succeed() { + let config = TestConfig::new(); + let clarity_value_hex = + "0x0a0200000020b8c8b0652cb2851a52374c7acd47181eb031e8fa5c62883f636e0d4fe695d6ca"; + let result = config + .client + .parse_aggregate_public_key(clarity_value_hex) + .unwrap(); + assert_eq!( + result.map(|point| point.to_string()), + Some("yzwdjwPz36Has1MSkg8JGwo38avvATkiTZvRiH1e5MLd".to_string()) + ); + + let clarity_value_hex = "0x09"; + let result = config + .client + .parse_aggregate_public_key(clarity_value_hex) + .unwrap(); + assert!(result.is_none()); + } + + #[test] + fn parse_invalid_aggregate_public_key_should_fail() { + let config = TestConfig::new(); + let clarity_value_hex = "0x00"; + let result = config.client.parse_aggregate_public_key(clarity_value_hex); + assert!(matches!( + result, + Err(ClientError::ClaritySerializationError(..)) + )); + // TODO: add further tests for malformed clarity values (an optional of any other type for example) + } + + #[ignore] + #[test] + fn transaction_contract_call_should_send_bytes_to_node() { + let config = TestConfig::new(); + let tx = config + .client + .build_signed_transaction( + &config.client.stacks_address, + ContractName::try_from("contract-name").unwrap(), + ClarityName::try_from("function-name").unwrap(), + &[], + ) + .unwrap(); + + 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 tx_clone = tx.clone(); + let h = spawn(move || config.client.submit_tx(&tx_clone)); + + let request_bytes = write_response( + config.mock_server, + format!("HTTP/1.1 200 OK\n\n{}", tx.txid()).as_bytes(), + ); + let returned_txid = h.join().unwrap().unwrap(); + + assert_eq!(returned_txid, tx.txid()); + assert!( + request_bytes + .windows(bytes_len) + .any(|window| window == &tx_bytes[..bytes_len]), + "Request bytes did not contain the transaction bytes" + ); + } + + #[ignore] + #[test] + fn transaction_contract_call_should_succeed() { + let config = TestConfig::new(); + let h = spawn(move || { + config.client.transaction_contract_call( + &config.client.stacks_address, + ContractName::try_from("contract-name").unwrap(), + ClarityName::try_from("function-name").unwrap(), + &[], + ) + }); + write_response( + config.mock_server, + b"HTTP/1.1 200 OK\n\n4e99f99bc4a05437abb8c7d0c306618f45b203196498e2ebe287f10497124958", + ); + assert!(h.join().unwrap().is_ok()); + } } diff --git a/stacks-signer/src/utils.rs b/stacks-signer/src/utils.rs index 3c1e1b36c..86436f09a 100644 --- a/stacks-signer/src/utils.rs +++ b/stacks-signer/src/utils.rs @@ -14,7 +14,8 @@ pub fn build_signer_config_tomls( signer_stacks_private_keys: &[StacksPrivateKey], num_keys: u32, node_host: &str, - contract_id: &str, + stackerdb_contract_id: &str, + pox_contract_id: Option<&str>, timeout: Option, ) -> Vec { let num_signers = signer_stacks_private_keys.len() as u32; @@ -73,7 +74,7 @@ stacks_private_key = "{stacks_private_key}" node_host = "{node_host}" endpoint = "{endpoint}" network = "testnet" -stackerdb_contract_id = "{contract_id}" +stackerdb_contract_id = "{stackerdb_contract_id}" signer_id = {id} {signers_array} "# @@ -88,8 +89,18 @@ event_timeout = {event_timeout_ms} "# ) } + if let Some(pox_contract_id) = pox_contract_id { + signer_config_toml = format!( + r#" +{signer_config_toml} +pox_contract_id = "{pox_contract_id}" +"# + ); + } + signer_config_tomls.push(signer_config_toml); } + signer_config_tomls } diff --git a/testnet/stacks-node/src/tests/signer.rs b/testnet/stacks-node/src/tests/signer.rs index 9b86f9e4f..f064c5ed8 100644 --- a/testnet/stacks-node/src/tests/signer.rs +++ b/testnet/stacks-node/src/tests/signer.rs @@ -55,11 +55,16 @@ fn spawn_signer( signer.spawn(endpoint).unwrap() } +#[allow(clippy::too_many_arguments)] fn setup_stx_btc_node( conf: &mut NeonConfig, num_signers: u32, signer_stacks_private_keys: &[StacksPrivateKey], + publisher_private_key: &StacksPrivateKey, stackerdb_contract: &str, + stackerdb_contract_id: &QualifiedContractIdentifier, + pox_contract: &str, + pox_contract_id: &QualifiedContractIdentifier, signer_config_tomls: &Vec, ) -> RunningNodes { for toml in signer_config_tomls { @@ -72,6 +77,12 @@ fn setup_stx_btc_node( } let mut initial_balances = Vec::new(); + + initial_balances.push(InitialBalance { + address: to_addr(publisher_private_key).into(), + amount: 10_000_000_000_000, + }); + for i in 0..num_signers { initial_balances.push(InitialBalance { address: to_addr(&signer_stacks_private_keys[i as usize]).into(), @@ -80,10 +91,7 @@ fn setup_stx_btc_node( } conf.initial_balances.append(&mut initial_balances); - conf.node.stacker_dbs.push(QualifiedContractIdentifier::new( - to_addr(&signer_stacks_private_keys[0]).into(), - "hello-world".into(), - )); + conf.node.stacker_dbs.push(stackerdb_contract_id.clone()); info!("Make new BitcoinCoreController"); let mut btcd_controller = BitcoinCoreController::new(conf.clone()); @@ -122,18 +130,30 @@ fn setup_stx_btc_node( next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); let http_origin = format!("http://{}", &conf.node.rpc_bind); - info!("Send contract-publish..."); + + info!("Send pox contract-publish..."); + let tx = make_contract_publish( - &signer_stacks_private_keys[0], + publisher_private_key, 0, 10_000, - "hello-world", + &pox_contract_id.name, + pox_contract, + ); + submit_tx(&http_origin, &tx); + + info!("Send stacker-db contract-publish..."); + let tx = make_contract_publish( + publisher_private_key, + 1, + 10_000, + &stackerdb_contract_id.name, stackerdb_contract, ); submit_tx(&http_origin, &tx); // mine it - info!("Mine it..."); + info!("Mining the pox and stackerdb contract..."); next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); @@ -145,6 +165,28 @@ fn setup_stx_btc_node( } } +/// Helper function for building our fake pox contract +pub fn build_pox_contract(num_signers: u32) -> String { + let mut pox_contract = String::new(); // " + pox_contract += r#" +;; data vars +;; +(define-data-var aggregate-public-key (optional (buff 33)) none) +"#; + pox_contract += &format!("(define-data-var num-signers uint u{num_signers})\n"); + pox_contract += r#" + +;; read only functions +;; + +(define-read-only (get-aggregate-public-key (reward-cycle uint)) + (var-get aggregate-public-key) +) + +"#; + pox_contract +} + #[test] fn test_stackerdb_dkg() { if env::var("BITCOIND_TEST") != Ok("1".into()) { @@ -159,6 +201,7 @@ fn test_stackerdb_dkg() { // Generate Signer Data let num_signers: u32 = 100; let num_keys: u32 = 4000; + let publisher_private_key = StacksPrivateKey::new(); let signer_stacks_private_keys = (0..num_signers) .map(|_| StacksPrivateKey::new()) .collect::>(); @@ -170,17 +213,24 @@ fn test_stackerdb_dkg() { // Setup the neon node let (mut conf, _) = neon_integration_test_conf(); + // Build our simulated pox-4 stacks contract TODO: replace this with the real deal? + let pox_contract = build_pox_contract(num_signers); + let pox_contract_id = + QualifiedContractIdentifier::new(to_addr(&publisher_private_key).into(), "pox-4".into()); // Build the stackerdb contract let stackerdb_contract = build_stackerdb_contract(&signer_stacks_addresses); - let contract_id = - QualifiedContractIdentifier::new(signer_stacks_addresses[0].into(), "hello-world".into()); + let stacker_db_contract_id = QualifiedContractIdentifier::new( + to_addr(&publisher_private_key).into(), + "hello-world".into(), + ); // Setup the signer and coordinator configurations let signer_configs = build_signer_config_tomls( &signer_stacks_private_keys, num_keys, &conf.node.rpc_bind, - &contract_id.to_string(), + &stacker_db_contract_id.to_string(), + Some(&pox_contract_id.to_string()), Some(Duration::from_millis(128)), // Timeout defaults to 5 seconds. Let's override it to 128 milliseconds. ); @@ -215,15 +265,16 @@ fn test_stackerdb_dkg() { &mut conf, num_signers, &signer_stacks_private_keys, + &publisher_private_key, &stackerdb_contract, + &stacker_db_contract_id, + &pox_contract, + &pox_contract_id, &signer_configs, ); let now = std::time::Instant::now(); info!("signer_runloop: spawn send commands to do dkg and then sign"); - coordinator_cmd_send - .send(RunLoopCommand::Dkg) - .expect("failed to send DKG command"); coordinator_cmd_send .send(RunLoopCommand::Sign { message: vec![1, 2, 3, 4, 5],