Merge branch 'develop' into feat/http-rpc-refactor

This commit is contained in:
Jude Nelson
2023-11-01 12:57:52 -04:00
8 changed files with 643 additions and 205 deletions

12
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"] }

View File

@@ -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<QualifiedContractIdentifier>,
/// 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<String>,
/// 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<RawConfigFile> 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<RawConfigFile> for Config {
node_host,
endpoint,
stackerdb_contract_id,
pox_contract_id,
message_private_key,
stacks_private_key,
stacks_address,

View File

@@ -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());

View File

@@ -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<C> {
/// 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<C> {
}
impl<C: Coordinatable> RunLoop<C> {
/// 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<C: Coordinatable> RunLoop<C> {
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<C: Coordinatable> RunLoop<C> {
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<C: Coordinatable> RunLoop<C> {
}
}
/// 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<FrostCoordinator<v2::Aggregator>> {
.iter()
.map(|i| i - 1) // SigningRound::new (unlike SigningRound::from) doesn't do this
.collect::<Vec<u32>>();
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<C: Coordinatable> SignerRunLoop<Vec<OperationResult>, RunLoopCommand> for R
cmd: Option<RunLoopCommand>,
res: Sender<Vec<OperationResult>>,
) -> Option<Vec<OperationResult>> {
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<C: Coordinatable> SignerRunLoop<Vec<OperationResult>, 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 {

View File

@@ -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<u32, u32>,
/// 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<QualifiedContractIdentifier>,
}
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<Option<Point>, 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<Vec<u8>, 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<u64, ClientError> {
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::<serde_json::Value>()?;
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<u64>,
tx_fee: u64,
anchor_mode: TransactionAnchorMode,
) -> Result<Vec<u8>, 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<u64, ClientError> {
//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::<serde_json::Value>()?;
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<Option<Point>, 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<Txid, ClientError> {
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<StacksTransaction, ClientError> {
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<Vec<u8>, 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<Txid, ClientError> {
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<u8>) -> Result<String, ClientError> {
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<String, ClientError> {
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::<Vec<String>>();
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<F, E, T>(request_fn: F) -> Result<T, ClientError>
where
F: FnMut() -> Result<T, backoff::Error<E>>,
{
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());
}
}

View File

@@ -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<Duration>,
) -> Vec<String> {
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
}

View File

@@ -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<String>,
) -> 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::<Vec<StacksPrivateKey>>();
@@ -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],