diff --git a/Cargo.lock b/Cargo.lock
index 0ab32a2dec..d47ff6e40d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -650,6 +650,7 @@ version = "0.0.1"
dependencies = [
"lazy_static",
"mutants",
+ "proptest",
"regex",
"rstest",
"rusqlite",
diff --git a/clarity-serialization/Cargo.toml b/clarity-serialization/Cargo.toml
index f7518e3a18..e82d9b5e1e 100644
--- a/clarity-serialization/Cargo.toml
+++ b/clarity-serialization/Cargo.toml
@@ -21,6 +21,7 @@ stacks_common = { package = "stacks-common", path = "../stacks-common", default-
[dev-dependencies]
mutants = "0.0.3"
+proptest = { version = "1.6.0", default-features = false, features = ["std"] }
rstest = "0.17.0"
[features]
diff --git a/clarity-serialization/src/tests/representations.rs b/clarity-serialization/src/tests/representations.rs
index ac395809c7..033d871e13 100644
--- a/clarity-serialization/src/tests/representations.rs
+++ b/clarity-serialization/src/tests/representations.rs
@@ -13,11 +13,14 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
+use proptest::prelude::*;
+use proptest::string::string_regex;
use rstest::rstest;
use crate::errors::RuntimeErrorType;
use crate::representations::{
- CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, ClarityName, ContractName, MAX_STRING_LEN,
+ CLARITY_NAME_REGEX_STRING, CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH,
+ CONTRACT_NAME_REGEX_STRING, ClarityName, ContractName, MAX_STRING_LEN,
};
use crate::stacks_common::codec::StacksMessageCodec;
@@ -44,6 +47,59 @@ fn test_clarity_name_valid(#[case] name: &str) {
assert_eq!(clarity_name.as_str(), name);
}
+/// Generates a proptest strategy for valid Clarity names.
+///
+/// This function creates a branched strategy based on the `CLARITY_NAME_REGEX_STRING` pattern.
+///
+/// The strategy covers three categories of valid names:
+/// - Letter-based names starting with a letter followed by alphanumeric or symbol characters
+/// - Single arithmetic operators (`-`, `+`, `=`, `/`, `*`)
+/// - Comparison operators (`<`, `>`, `<=`, `>=`)
+fn any_valid_clarity_name() -> impl Strategy {
+ // Ensure the regex branches match the actual validator.
+ let expected_regex = "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$";
+ assert_eq!(
+ CLARITY_NAME_REGEX_STRING.as_str(),
+ expected_regex,
+ "CLARITY_NAME_REGEX_STRING has changed"
+ );
+
+ let letter_names = string_regex(&format!(
+ "[a-zA-Z][a-zA-Z0-9_!?+<>=/*-]{{0,{}}}",
+ (MAX_STRING_LEN as usize).saturating_sub(1)
+ ))
+ .unwrap();
+
+ let single_ops = prop_oneof![
+ Just("-".to_string()),
+ Just("+".to_string()),
+ Just("=".to_string()),
+ Just("/".to_string()),
+ Just("*".to_string()),
+ ];
+
+ let comparison_ops = prop_oneof![
+ Just("<".to_string()),
+ Just(">".to_string()),
+ Just("<=".to_string()),
+ Just(">=".to_string()),
+ ];
+
+ prop_oneof![letter_names, single_ops, comparison_ops]
+}
+
+#[test]
+fn prop_clarity_name_valid_patterns() {
+ proptest!(|(name in any_valid_clarity_name())| {
+ prop_assume!(!name.is_empty());
+ prop_assume!(name.len() <= MAX_STRING_LEN as usize);
+
+ let clarity_name = ClarityName::try_from(name.clone())
+ .unwrap_or_else(|_| panic!("Should parse valid clarity name: {}", name));
+ prop_assert_eq!(clarity_name.as_str(), name);
+ });
+}
+
#[rstest]
#[case::empty("")]
#[case::starts_with_number("123abc")]
@@ -77,6 +133,98 @@ fn test_clarity_name_invalid(#[case] name: &str) {
));
}
+/// Generates a proptest strategy for invalid Clarity names.
+///
+/// This function creates a strategy that generates strings that should be rejected
+/// by `ClarityName::try_from()` validation by systematically violating each valid branch.
+///
+/// The strategy generates names that violate the three valid branches:
+/// - Branch 1 violations: Invalid starting characters or invalid characters in letter-based names
+/// - Branch 2 violations: Multi-character strings starting with single operators
+/// - Branch 3 violations: Invalid extensions to comparison operators
+/// - General violations: Empty strings and length violations
+///
+/// Valid branches being violated:
+/// 1. `^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$` - Letter-based names
+/// 2. `^[-+=/*]$` - Single arithmetic operators
+/// 3. `^[<>]=?$` - Comparison operators
+fn any_invalid_clarity_name() -> impl Strategy {
+ // Ensure the regex branches match the actual validator.
+ let expected_regex = "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$";
+ assert_eq!(
+ CLARITY_NAME_REGEX_STRING.as_str(),
+ expected_regex,
+ "CLARITY_NAME_REGEX_STRING has changed"
+ );
+
+ let empty_string = Just("".to_string());
+
+ // Names starting with numbers (violates first branch requirement of starting with letter).
+ let starts_with_number = string_regex(&format!(
+ "[0-9][a-zA-Z0-9_!?+<>=/*-]{{0,{}}}",
+ (MAX_STRING_LEN as usize).saturating_sub(1)
+ ))
+ .unwrap();
+
+ // Names starting with invalid symbols (violates all branches - not letters, not valid single
+ // operators, not comparison operators).
+ let starts_with_invalid_symbol = string_regex(&format!(
+ "[@ #$%&.,;:|\\\"'\\[\\](){{}}][a-zA-Z0-9_!?+<>=/*-]{{0,{}}}",
+ (MAX_STRING_LEN as usize).saturating_sub(1)
+ ))
+ .unwrap();
+
+ // Names starting with letters but containing invalid characters (violates first branch
+ // character set restrictions).
+ let invalid_chars_in_letter_names = string_regex(&format!(
+ "[a-zA-Z][a-zA-Z0-9_!?+<>=/*-]*[@ #$%&.,;:|\\\"'\\[\\](){{}}][a-zA-Z0-9_!?+<>=/*-]*"
+ ))
+ .unwrap();
+
+ // Multi-character strings starting with single operators (violates second branch which only
+ // allows single characters).
+ // Covers: --, ++, ==, //, **, -a, +1, =x, etc.
+ let invalid_operator_extensions = string_regex(&format!(
+ "[-+=/*][a-zA-Z0-9_!?+<>=/*-]{{1,{}}}",
+ (MAX_STRING_LEN as usize).saturating_sub(1)
+ ))
+ .unwrap();
+
+ // Invalid comparison operator extensions (violates third branch pattern).
+ // Covers: <<, >>, 1, <=x, >=z, <==, >==, etc.
+ let invalid_comparison_ops = string_regex(&format!(
+ "[<>]=?[a-zA-Z0-9_!?+<>=/*-]{{1,{}}}",
+ (MAX_STRING_LEN as usize).saturating_sub(1)
+ ))
+ .unwrap();
+
+ // Names that are too long (exceeds MAX_STRING_LEN).
+ let too_long = (MAX_STRING_LEN as usize + 1..=MAX_STRING_LEN as usize + 10)
+ .prop_map(|len| "a".repeat(len));
+
+ prop_oneof![
+ empty_string,
+ starts_with_number,
+ starts_with_invalid_symbol,
+ invalid_chars_in_letter_names,
+ invalid_operator_extensions,
+ invalid_comparison_ops,
+ too_long,
+ ]
+}
+
+#[test]
+fn prop_clarity_name_invalid_patterns() {
+ proptest!(|(name in any_invalid_clarity_name())| {
+ let result = ClarityName::try_from(name.clone());
+ prop_assert!(result.is_err(), "Expected invalid name '{}' to be rejected", name);
+ prop_assert!(matches!(
+ result.unwrap_err(),
+ RuntimeErrorType::BadNameValue(_, _)
+ ), "Expected BadNameValue error for invalid name '{}'", name);
+ });
+}
+
#[rstest]
#[case("test-name")]
#[case::max_length(&"a".repeat(MAX_STRING_LEN as usize))]
@@ -96,6 +244,22 @@ fn test_clarity_name_serialization(#[case] name: &str) {
assert_eq!(deserialized, name);
}
+#[test]
+fn prop_clarity_name_roundtrip() {
+ proptest!(|(s in any_valid_clarity_name())| {
+ let name = ClarityName::try_from(s.clone()).unwrap();
+ prop_assert_eq!(name.as_str(), s);
+
+ let mut buf = Vec::new();
+ name.consensus_serialize(&mut buf).unwrap();
+ prop_assert_eq!(buf.first().copied(), Some(name.len()));
+ prop_assert_eq!(&buf[1..], name.as_bytes());
+
+ let back = ClarityName::consensus_deserialize(&mut buf.as_slice()).unwrap();
+ prop_assert_eq!(back, name);
+ });
+}
+
// the first byte is the length of the buffer.
#[rstest]
#[case::invalid_utf8(vec![4, 0xFF, 0xFE, 0xFD, 0xFC], "Failed to parse Clarity name: could not contruct from utf8")]
@@ -123,6 +287,54 @@ fn test_contract_name_valid(#[case] name: &str) {
assert_eq!(contract_name.as_str(), name);
}
+/// Generates a proptest strategy for valid contract names.
+///
+/// This function creates a strategy based on the `CONTRACT_NAME_REGEX_STRING` pattern
+/// and includes the special `"__transient"` contract name.
+///
+/// The strategy generates:
+/// - 90% regular contract names (letter followed by letters, digits, hyphens, or underscores)
+/// - 10% the special `"__transient"` contract name
+fn any_valid_contract_name() -> impl Strategy {
+ // Ensure the regex branches match the actual validator.
+ let expected_regex = format!(
+ r#"([a-zA-Z](([a-zA-Z0-9]|[-_])){{{},{}}})"#,
+ CONTRACT_MIN_NAME_LENGTH - 1,
+ MAX_STRING_LEN - 1
+ );
+ assert_eq!(
+ CONTRACT_NAME_REGEX_STRING.as_str(),
+ &expected_regex,
+ "CONTRACT_NAME_REGEX_STRING has changed"
+ );
+
+ let regular_names = string_regex(&format!(
+ "[a-zA-Z][a-zA-Z0-9_-]{{0,{}}}",
+ CONTRACT_MAX_NAME_LENGTH
+ .saturating_sub(1)
+ .min((MAX_STRING_LEN as usize).saturating_sub(1))
+ ))
+ .unwrap();
+
+ // 90% regular names, 10% the special "__transient" contract name.
+ prop_oneof![
+ 9 => regular_names,
+ 1 => Just("__transient".to_string()),
+ ]
+}
+
+#[test]
+fn prop_contract_name_valid_patterns() {
+ proptest!(|(name in any_valid_contract_name())| {
+ prop_assume!(!name.is_empty());
+ prop_assume!(name.len() <= MAX_STRING_LEN as usize);
+
+ let contract_name = ContractName::try_from(name.clone())
+ .unwrap_or_else(|_| panic!("Should parse valid contract name: {}", name));
+ prop_assert_eq!(contract_name.as_str(), name);
+ });
+}
+
#[rstest]
#[case::empty("")]
#[case::starts_with_number("123contract")]
@@ -161,6 +373,88 @@ fn test_contract_name_invalid(#[case] name: &str) {
));
}
+/// Generates a proptest strategy for invalid contract names.
+///
+/// This function creates a strategy that generates strings that should be rejected by
+/// `ContractName::try_from()` validation by systematically violating the validation rules.
+///
+/// The strategy generates names that violate the contract name validation:
+/// - Empty strings
+/// - Names starting with invalid characters (numbers, symbols)
+/// - Names containing invalid characters (symbols not allowed in contract names)
+/// - Names that are too short or too long
+/// - Names that violate length constraints
+fn any_invalid_contract_name() -> impl Strategy {
+ // Ensure the regex pattern matches the actual validator.
+ let expected_regex = format!(
+ r#"([a-zA-Z](([a-zA-Z0-9]|[-_])){{{},{}}})"#,
+ CONTRACT_MIN_NAME_LENGTH - 1,
+ MAX_STRING_LEN - 1
+ );
+ assert_eq!(
+ CONTRACT_NAME_REGEX_STRING.as_str(),
+ &expected_regex,
+ "CONTRACT_NAME_REGEX_STRING has changed"
+ );
+
+ let empty_string = Just("".to_string());
+
+ // Names starting with numbers (violates requirement of starting with letter).
+ let starts_with_number = string_regex(&format!(
+ "[0-9][a-zA-Z0-9_-]{{0,{}}}",
+ (MAX_STRING_LEN as usize).saturating_sub(1)
+ ))
+ .unwrap();
+
+ // Names starting with invalid symbols (violates starting letter requirement).
+ let starts_with_invalid_symbol = string_regex(&format!(
+ "[!@#$%^&*()+=\\[\\]{{}}|\\\\:;\"'<>,.?/~`][a-zA-Z0-9_-]{{0,{}}}",
+ (MAX_STRING_LEN as usize).saturating_sub(1)
+ ))
+ .unwrap();
+
+ // Names starting with letters but containing invalid characters.
+ let invalid_chars_in_names = string_regex(&format!(
+ "[a-zA-Z][a-zA-Z0-9_-]*[!@#$%^&*()+=\\[\\]{{}}|\\\\:;\"'<>,.?/~`][a-zA-Z0-9_-]*"
+ ))
+ .unwrap();
+
+ // Names that are too long.
+ let too_long = (MAX_STRING_LEN as usize + 1..=MAX_STRING_LEN as usize + 10)
+ .prop_map(|len| "a".repeat(len));
+
+ // Invalid variations of the __transient name (close but not exact).
+ let invalid_transient_variants = prop_oneof![
+ Just("_transient".to_string()), // Single underscore.
+ Just("___transient".to_string()), // Triple underscore.
+ Just("__Transient".to_string()), // Wrong case.
+ Just("__TRANSIENT".to_string()), // All caps.
+ Just("__transient_".to_string()), // Extra underscore.
+ Just("__transient1".to_string()), // Extra character.
+ ];
+
+ prop_oneof![
+ empty_string,
+ starts_with_number,
+ starts_with_invalid_symbol,
+ invalid_chars_in_names,
+ too_long,
+ invalid_transient_variants,
+ ]
+}
+
+#[test]
+fn prop_contract_name_invalid_patterns() {
+ proptest!(|(name in any_invalid_contract_name())| {
+ let result = ContractName::try_from(name.clone());
+ prop_assert!(result.is_err(), "Expected invalid contract name '{}' to be rejected", name);
+ prop_assert!(matches!(
+ result.unwrap_err(),
+ RuntimeErrorType::BadNameValue(_, _)
+ ), "Expected BadNameValue error for invalid contract name '{}'", name);
+ });
+}
+
#[rstest]
#[case::valid_name("test-contract")]
#[case::dash("contract-name")]
@@ -182,6 +476,22 @@ fn test_contract_name_serialization(#[case] name: &str) {
assert_eq!(deserialized, name);
}
+#[test]
+fn prop_contract_name_roundtrip() {
+ proptest!(|(s in any_valid_contract_name())| {
+ let name = ContractName::try_from(s.clone()).unwrap();
+ prop_assert_eq!(name.as_str(), s);
+
+ let mut buf = Vec::with_capacity((name.len() + 1) as usize);
+ name.consensus_serialize(&mut buf).unwrap();
+ prop_assert_eq!(buf.first().copied(), Some(name.len()));
+ prop_assert_eq!(&buf[1..], name.as_bytes());
+
+ let back = ContractName::consensus_deserialize(&mut buf.as_slice()).unwrap();
+ prop_assert_eq!(back, name);
+ });
+}
+
#[test]
fn test_contract_name_serialization_too_long() {
let name =
@@ -209,3 +519,32 @@ fn test_contract_name_deserialization_errors(#[case] buffer: Vec, #[case] er
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), error_message);
}
+
+#[test]
+fn prop_contract_name_length_bounds() {
+ proptest!(|(extra in 0usize..3)| {
+ let min = CONTRACT_MIN_NAME_LENGTH as usize;
+ let max = CONTRACT_MAX_NAME_LENGTH as usize;
+ let hard = MAX_STRING_LEN as usize;
+
+ // Too short must fail to parse.
+ let short = "a".repeat(min.saturating_sub(1));
+ prop_assert!(ContractName::try_from(short).is_err());
+
+ // At max parses and serializes.
+ let at_len = max.min(hard);
+ let at = "a".repeat(at_len);
+ let name = ContractName::try_from(at).unwrap();
+ let mut buf = Vec::new();
+ name.consensus_serialize(&mut buf).unwrap();
+
+ // Over contract max parses, but serialization must fail.
+ if max < hard {
+ let over_len = (max + 1 + extra).min(hard);
+ let over = "a".repeat(over_len);
+ let name = ContractName::try_from(over).unwrap();
+ let mut b = Vec::new();
+ prop_assert!(name.consensus_serialize(&mut b).is_err());
+ }
+ });
+}