From 2a4e415da4e0cc61c2162b60b0208c121472fe41 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 15 Oct 2025 17:27:07 -0400 Subject: [PATCH 01/18] test: initial setup for property testing --- clarity/src/vm/contexts.rs | 2 +- clarity/src/vm/mod.rs | 38 +++ clarity/src/vm/tests/post_conditions.rs | 381 +++++++++++++++++++++++- 3 files changed, 415 insertions(+), 6 deletions(-) diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 2120b508b32..8d26da204b2 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -89,7 +89,7 @@ pub enum AssetMapEntry { The AssetMap is used to track which assets have been transfered from whom during the execution of a transaction. */ -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AssetMap { /// Sum of all STX transfers by principal stx_map: HashMap, diff --git a/clarity/src/vm/mod.rs b/clarity/src/vm/mod.rs index 0ef0875be8d..1b670fcc059 100644 --- a/clarity/src/vm/mod.rs +++ b/clarity/src/vm/mod.rs @@ -549,6 +549,44 @@ where }) } +/// Runs `program` in a test environment, first calling `global_context_function`. +/// Returns the final evaluated result along with the asset map. +#[cfg(any(test, feature = "testing"))] +pub fn execute_call_in_global_context_and_return_asset_map( + program: &str, + clarity_version: ClarityVersion, + epoch: StacksEpochId, + use_mainnet: bool, + mut global_context_function: F, +) -> Result<(Option, crate::vm::contexts::AssetMap)> +where + F: FnMut(&mut GlobalContext) -> Result<()>, +{ + use crate::vm::database::MemoryBackingStore; + use crate::vm::tests::test_only_mainnet_to_chain_id; + use crate::vm::types::QualifiedContractIdentifier; + + let contract_id = QualifiedContractIdentifier::transient(); + let mut contract_context = ContractContext::new(contract_id.clone(), clarity_version); + let mut marf = MemoryBackingStore::new(); + let conn = marf.as_clarity_db(); + let chain_id = test_only_mainnet_to_chain_id(use_mainnet); + let mut global_context = GlobalContext::new( + use_mainnet, + chain_id, + conn, + LimitedCostTracker::new_free(), + epoch, + ); + global_context.execute(|g| { + global_context_function(g)?; + let parsed = + ast::build_ast(&contract_id, program, &mut (), clarity_version, epoch)?.expressions; + eval_all(&parsed, &mut contract_context, g, None) + .map(|r| (r, g.get_readonly_asset_map().cloned().unwrap_or_default())) + }) +} + #[cfg(any(test, feature = "testing"))] pub fn execute_with_parameters( program: &str, diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 9b18f8b062c..59176a27b92 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -18,13 +18,25 @@ //! in integration tests, since they require changes made outside of the VM. use clarity_types::errors::{Error as ClarityError, InterpreterResult, ShortReturnType}; -use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; -use clarity_types::Value; +use clarity_types::types::{ + CharType, PrincipalData, QualifiedContractIdentifier, SequenceData, StandardPrincipalData, + TypeSignature, UTF8Data, +}; +use clarity_types::{ContractName, Value}; +use proptest::array::uniform20; +use proptest::collection::vec; +use proptest::prelude::*; +use proptest::strategy::BoxedStrategy; +use proptest::string::string_regex; use stacks_common::types::StacksEpochId; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; +use crate::vm::contexts::AssetMap; use crate::vm::database::STXBalance; -use crate::vm::{execute_with_parameters_and_call_in_global_context, ClarityVersion}; +use crate::vm::{ + execute_call_in_global_context_and_return_asset_map, + execute_with_parameters_and_call_in_global_context, ClarityVersion, +}; fn execute(snippet: &str) -> InterpreterResult> { execute_with_parameters_and_call_in_global_context( @@ -37,7 +49,7 @@ fn execute(snippet: &str) -> InterpreterResult> { let sender_principal = PrincipalData::Standard(StandardPrincipalData::transient()); let contract_id = QualifiedContractIdentifier::transient(); let contract_principal = PrincipalData::Contract(contract_id); - let balance = STXBalance::initial(1000); + let balance = STXBalance::initial(1000000); let mut snapshot = g .database .get_stx_balance_snapshot_genesis(&sender_principal) @@ -50,7 +62,37 @@ fn execute(snippet: &str) -> InterpreterResult> { .unwrap(); snapshot.set_balance(balance); snapshot.save().unwrap(); - g.database.increment_ustx_liquid_supply(2000).unwrap(); + g.database.increment_ustx_liquid_supply(2000000).unwrap(); + Ok(()) + }, + ) +} + +fn execute_and_return_asset_map(snippet: &str) -> InterpreterResult<(Option, AssetMap)> { + execute_call_in_global_context_and_return_asset_map( + snippet, + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + |g| { + // Setup initial balances for the sender and the contract + let sender_principal = PrincipalData::Standard(StandardPrincipalData::transient()); + let contract_id = QualifiedContractIdentifier::transient(); + let contract_principal = PrincipalData::Contract(contract_id); + let balance = STXBalance::initial(1000000); + let mut snapshot = g + .database + .get_stx_balance_snapshot_genesis(&sender_principal) + .unwrap(); + snapshot.set_balance(balance.clone()); + snapshot.save().unwrap(); + let mut snapshot = g + .database + .get_stx_balance_snapshot_genesis(&contract_principal) + .unwrap(); + snapshot.set_balance(balance); + snapshot.save().unwrap(); + g.database.increment_ustx_liquid_supply(2000000).unwrap(); Ok(()) }, ) @@ -1404,3 +1446,332 @@ fn test_restrict_assets_with_error_in_body() { ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected_err.into())); assert_eq!(short_return, execute(snippet).unwrap_err()); } + +// ---------- Property Tests ---------- + +/// Builds a strategy that produces arbitrary Clarity values. +fn clarity_values_inner(include_responses: bool) -> BoxedStrategy { + let ascii_strings = string_regex("[A-Za-z0-9 \\-_=+*/?!]{0,1024}") + .unwrap() + .prop_map(|s| { + Value::string_ascii_from_bytes(s.into_bytes()) + .expect("ASCII literal within allowed character set") + }); + + let utf8_strings = + string_regex(r#"[\u{00A1}-\u{024F}\u{0370}-\u{03FF}\u{1F300}-\u{1F64F}]{0,1024}"#) + .unwrap() + .prop_map(|s| { + Value::string_utf8_from_bytes(s.into_bytes()) + .expect("UTF-8 literal within allowed character set") + }); + + let standard_principal_data = (any::(), uniform20(any::())) + .prop_filter_map("Invalid standard principal", |(version, bytes)| { + let version = version % 32; + StandardPrincipalData::new(version, bytes).ok() + }) + .boxed(); + + let standard_principals = standard_principal_data + .clone() + .prop_map(|principal| Value::Principal(PrincipalData::Standard(principal))) + .boxed(); + + let contract_name_strings = prop_oneof![ + string_regex("[a-tv-z][a-z0-9-?!]{0,39}").unwrap(), + string_regex("u[a-z-?!][a-z0-9-?!]{0,38}").unwrap(), + ] + .boxed(); + + let contract_names = contract_name_strings + .prop_filter_map("Invalid contract name", |name| { + ContractName::try_from(name).ok() + }) + .boxed(); + + let contract_principals = (standard_principal_data, contract_names) + .prop_map(|(issuer, name)| { + Value::Principal(PrincipalData::Contract(QualifiedContractIdentifier::new( + issuer, name, + ))) + }) + .boxed(); + + let principal_values = prop_oneof![standard_principals, contract_principals]; + + let buffer_values = vec(any::(), 0..1024).prop_map(|bytes| { + Value::buff_from(bytes).expect("Buffer construction should succeed with any byte data") + }); + + let base_values = prop_oneof![ + any::().prop_map(Value::Bool), + any::().prop_map(|v| Value::Int(v as i128)), + any::().prop_map(|v| Value::UInt(v as u128)), + ascii_strings, + utf8_strings, + Just(Value::none()), + principal_values, + buffer_values, + ]; + + base_values + .prop_recursive( + 3, // max nesting depth + 64, // total size budget (unused but required) + 6, // branching factor + move |inner| { + let option_values = inner + .clone() + .prop_filter_map("Option construction failed", |v| Value::some(v).ok()) + .boxed(); + + let inner_for_lists = inner.clone(); + let lists_from_inner = inner + .clone() + .prop_flat_map(move |prototype| { + let sig = TypeSignature::type_of(&prototype) + .expect("Values generated by strategy should have a type signature"); + let sig_for_filter = sig.clone(); + let prototype_for_elements = prototype.clone(); + let element_strategy = inner_for_lists.clone().prop_map(move |candidate| { + if TypeSignature::type_of(&candidate) + .ok() + .is_some_and(|t| t == sig_for_filter) + { + candidate + } else { + prototype_for_elements.clone() + } + }); + let prototype_for_list = prototype.clone(); + vec(element_strategy, 0..3).prop_map(move |rest| { + let mut values = Vec::with_capacity(rest.len() + 1); + values.push(prototype_for_list.clone()); + values.extend(rest); + Value::list_from(values) + .expect("List construction should succeed with homogeneous values") + }) + }) + .boxed(); + + let bool_lists = vec(any::().prop_map(Value::Bool), 1..4) + .prop_filter_map("List construction failed", |values| { + Value::list_from(values).ok() + }) + .boxed(); + + let uint_lists = vec(any::().prop_map(|v| Value::UInt(v as u128)), 1..4) + .prop_filter_map("List construction failed", |values| { + Value::list_from(values).ok() + }) + .boxed(); + + if include_responses { + let ok_responses = inner + .clone() + .prop_filter_map("Response(ok) construction failed", |v| { + Value::okay(v).ok() + }) + .boxed(); + + let err_responses = inner + .clone() + .prop_filter_map("Response(err) construction failed", |v| { + Value::error(v).ok() + }) + .boxed(); + + prop_oneof![ + option_values, + ok_responses, + err_responses, + lists_from_inner, + bool_lists, + uint_lists, + ] + .boxed() + } else { + prop_oneof![option_values, lists_from_inner, bool_lists, uint_lists,].boxed() + } + }, + ) + .boxed() +} + +/// Generates Clarity values, including response values. +fn clarity_values() -> impl Strategy { + clarity_values_inner(true) +} + +/// Generates Clarity values but excludes responses. +fn clarity_values_no_response() -> impl Strategy { + clarity_values_inner(false) +} + +/// Generates STX transfer expressions with random amounts. +fn stx_transfer_expressions() -> impl Strategy { + (1u64..1_000_000u64).prop_map(|amount| { + format!("(try! (stx-transfer? u{amount} tx-sender 'SP000000000000000000002Q6VF78))") + }) +} + +/// Generates a `begin` block with a random number of random expressions. +fn begin_block() -> impl Strategy { + vec( + prop_oneof![ + clarity_values_no_response().prop_map(|value| value_to_string(&value)), + stx_transfer_expressions(), + ], + 1..8, + ) + .prop_shuffle() + .prop_map(|expressions| { + let body = expressions.join(" "); + format!("(begin {body})") + }) +} + +/// Produces a Clarity string literal for the given UTF-8 data. +fn utf8_string_literal(data: &UTF8Data) -> String { + let mut literal = String::from("u\""); + for bytes in &data.data { + if bytes.len() == 1 { + for escaped in std::ascii::escape_default(bytes[0]) { + literal.push(escaped as char); + } + } else { + let ch = std::str::from_utf8(bytes) + .expect("UTF-8 data should decode to a scalar value") + .chars() + .next() + .expect("UTF-8 data should contain at least one scalar"); + literal.push_str(&format!("\\u{{{:X}}}", ch as u32)); + } + } + literal.push('"'); + literal +} + +/// Converts a Clarity `Value` into its Clarity literal representation. +fn value_to_string(value: &Value) -> String { + match value { + Value::Sequence(SequenceData::List(list_data)) => { + let items: Vec<_> = list_data.data.iter().map(value_to_string).collect(); + if items.is_empty() { + "(list)".to_string() + } else { + format!("(list {})", items.join(" ")) + } + } + Value::Sequence(SequenceData::String(CharType::ASCII(data))) => format!("{data}"), + Value::Sequence(SequenceData::String(CharType::UTF8(data))) => utf8_string_literal(data), + Value::Optional(optional) => match optional.data.as_deref() { + Some(inner) => format!("(some {})", value_to_string(inner)), + None => "none".to_string(), + }, + Value::Response(response) => { + let inner = value_to_string(response.data.as_ref()); + if response.committed { + format!("(ok {})", inner) + } else { + format!("(err {})", inner) + } + } + Value::Principal(principal) => format!("'{}", principal), + Value::Tuple(tuple) => { + let mut literal = String::from("(tuple"); + for (name, field) in tuple.data_map.iter() { + literal.push(' '); + literal.push('('); + literal.push_str(&name.to_string()); + literal.push(' '); + literal.push_str(&value_to_string(field)); + literal.push(')'); + } + literal.push(')'); + literal + } + _ => format!("{value}"), + } +} + +proptest! { + /// Property: restrict-assets? should return `(ok )` where `` is the + /// result of evaluating the body if no assets are moved in the body. + #[test] + fn prop_restrict_assets_returns_body_value_when_pure(body_value in clarity_values_no_response()) { + let body_literal = value_to_string(&body_value); + let snippet = format!("(restrict-assets? tx-sender () {body_literal})"); + + let evaluation = execute(&snippet) + .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); + + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); + + prop_assert!(evaluation == expected); + } + + /// Property: restrict-assets? should return an error if there are no + /// allowances and the body moves assets + #[test] + fn prop_restrict_assets_errors_when_no_allowances_and_body_moves_assets(body in begin_block()) { + let snippet = format!("(restrict-assets? tx-sender () {body})"); + + let body_execution = execute_and_return_asset_map(&body); + let snippet_execution = execute_and_return_asset_map(&snippet); + + match (body_execution, snippet_execution) { + (Err(body_err), snippet_outcome) => { + match snippet_outcome { + Err(snippet_err) => { + prop_assert_eq!(snippet_err, body_err); + } + Ok((Some(result_value), _)) => { + if let ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected)) = &body_err { + prop_assert_eq!(result_value, *expected.clone()); + } else { + panic!("Body `{body}` failed with {body_err:?} but snippet `{snippet}` returned value {result_value:?}"); + } + } + Ok((None, _)) => { + panic!("Snippet `{snippet}` returned no value while body `{body}` failed with {body_err:?}"); + } + } + } + (Ok(_), Err(snippet_err)) => { + panic!("Body `{body}` succeeded but snippet `{snippet}` failed with {snippet_err:?}"); + } + (Ok((body_result, unrestricted_asset_map)), Ok((result, asset_map))) => { + let body_value = body_result + .unwrap_or_else(|| panic!("Execution returned no value for body `{body}`")); + let result_value = result + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); + + // If the body moves any STX from the sender, the restricted version should error + let sender = PrincipalData::Standard(StandardPrincipalData::transient()); + if let Some(stx_moved) = unrestricted_asset_map.get_stx(&sender) { + let expected_err = Value::error(Value::UInt(MAX_ALLOWANCES as u128)) + .unwrap_or_else(|e| panic!("Wrapping expected error failed for snippet `{snippet}`: {e:?}")); + + prop_assert_eq!(result_value, expected_err); + + // And the asset map should show that no STX was moved + let stx_moved_in_restricted = asset_map.get_stx(&sender).unwrap_or(0); + prop_assert_eq!(stx_moved_in_restricted, 0); + } else { + // If the body doesn't move any STX, the restricted version should return the same value as the body + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); + + prop_assert_eq!(result_value, expected); + + // And the asset maps should be identical + prop_assert_eq!(asset_map, unrestricted_asset_map); + } + } + } + } +} From b8834bae7c244a2e12fef189146d9a8b1292f6b7 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 15 Oct 2025 17:40:05 -0400 Subject: [PATCH 02/18] fix: runtime errors from `restrict-assets?` and `as-contract?` body If the body hits a runtime error, that should be passed up before bothering to check the allowances (and the changes should be rolled back). --- clarity/src/vm/functions/post_conditions.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index f3fe9586301..b7e462cefc6 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -244,6 +244,13 @@ pub fn special_restrict_assets( Ok(last_result) })(); + // If there was a runtime error, pass it up immediately. We will roll back + // any way, so no need to check allowances. + if let Err(runtime_err) = eval_result { + env.global_context.roll_back()?; + return Err(runtime_err); + } + let asset_maps = env.global_context.get_readonly_asset_map()?; // If the allowances are violated: @@ -274,7 +281,8 @@ pub fn special_restrict_assets( Err(InterpreterError::Expect("Failed to get body result".into()).into()) } Err(e) => { - // Runtime error inside body, pass it up + // Runtime error inside body, pass it up (but this should have been + // caught already above) Err(e) } } @@ -338,6 +346,13 @@ pub fn special_as_contract( Ok(last_result) })(); + // If there was a runtime error, pass it up immediately. We will roll back + // any way, so no need to check allowances. + if let Err(runtime_err) = eval_result { + nested_env.global_context.roll_back()?; + return Err(runtime_err); + } + let asset_maps = nested_env.global_context.get_readonly_asset_map()?; // If the allowances are violated: @@ -368,7 +383,8 @@ pub fn special_as_contract( Err(InterpreterError::Expect("Failed to get body result".into()).into()) } Err(e) => { - // Runtime error inside body, pass it up + // Runtime error inside body, pass it up (but this should have been + // caught already above) Err(e) } } From a1a48e3aea82b253647e5c66c4121c05d0dd36b9 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 15 Oct 2025 23:06:14 -0400 Subject: [PATCH 03/18] test: add `as-contract?` prop tests --- clarity/src/vm/tests/post_conditions.rs | 137 +++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 59176a27b92..6fb1cc305d5 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -69,9 +69,16 @@ fn execute(snippet: &str) -> InterpreterResult> { } fn execute_and_return_asset_map(snippet: &str) -> InterpreterResult<(Option, AssetMap)> { + execute_and_return_asset_map_versioned(snippet, ClarityVersion::Clarity4) +} + +fn execute_and_return_asset_map_versioned( + snippet: &str, + version: ClarityVersion, +) -> InterpreterResult<(Option, AssetMap)> { execute_call_in_global_context_and_return_asset_map( snippet, - ClarityVersion::Clarity4, + version, StacksEpochId::Epoch33, false, |g| { @@ -1774,4 +1781,132 @@ proptest! { } } } + + /// Property: as-contract? should return `(ok )` where `` is the + /// result of evaluating the body if no assets are moved in the body. + #[test] + fn prop_as_contract_returns_body_value_when_pure(body_value in clarity_values_no_response()) { + let body_literal = value_to_string(&body_value); + let snippet = format!("(as-contract? () {body_literal})"); + + let evaluation = execute(&snippet) + .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); + + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); + + prop_assert!(evaluation == expected); + } + + /// Property: as-contract? should return an error if there are no + /// allowances and the body moves assets + #[test] + fn prop_as_contract_errors_when_no_allowances_and_body_moves_assets(body in begin_block()) { + let snippet = format!("(as-contract? () {body})"); + let c3_snippet = format!("(as-contract {body})"); + + let body_execution = execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3); + let snippet_execution = execute_and_return_asset_map(&snippet); + + match (body_execution, snippet_execution) { + (Err(body_err), snippet_outcome) => { + match snippet_outcome { + Err(snippet_err) => { + prop_assert_eq!(snippet_err, body_err); + } + Ok((Some(result_value), _)) => { + if let ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected)) = &body_err { + prop_assert_eq!(result_value, *expected.clone()); + } else { + panic!("Body `{body}` failed with {body_err:?} but snippet `{snippet}` returned value {result_value:?}"); + } + } + Ok((None, _)) => { + panic!("Snippet `{snippet}` returned no value while body `{body}` failed with {body_err:?}"); + } + } + } + (Ok(_), Err(snippet_err)) => { + panic!("Body `{body}` succeeded but snippet `{snippet}` failed with {snippet_err:?}"); + } + (Ok((body_result, unrestricted_asset_map)), Ok((result, asset_map))) => { + let body_value = body_result + .unwrap_or_else(|| panic!("Execution returned no value for body `{body}`")); + let result_value = result + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); + + // If the body moves any STX from the contract, the restricted version should error + let contract_id = QualifiedContractIdentifier::transient(); + let contract_principal = PrincipalData::Contract(contract_id); + if let Some(stx_moved) = unrestricted_asset_map.get_stx(&contract_principal) { + let expected_err = Value::error(Value::UInt(MAX_ALLOWANCES as u128)) + .unwrap_or_else(|e| panic!("Wrapping expected error failed for snippet `{snippet}`: {e:?}")); + + prop_assert_eq!(result_value, expected_err); + + // And the asset map should show that no STX was moved + let stx_moved_in_restricted = asset_map.get_stx(&contract_principal).unwrap_or(0); + prop_assert_eq!(stx_moved_in_restricted, 0); + } else { + // If the body doesn't move any STX, the restricted version should return the same value as the body + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); + + prop_assert_eq!(result_value, expected); + + // And the asset maps should be identical + prop_assert_eq!(asset_map, unrestricted_asset_map); + } + } + } + } + + /// Property: as-contract? with `with-all-assets-unsafe` should always return the + /// same as the Clarity3 `as-contract`. + #[test] + fn prop_as_contract_with_all_assets_unsafe_matches_clarity3(body in begin_block()) { + let snippet = format!("(as-contract? ((with-all-assets-unsafe)) {body})"); + let c3_snippet = format!("(as-contract {body})"); + + let c3_execution = execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3); + let snippet_execution = execute_and_return_asset_map(&snippet); + + match (c3_execution, snippet_execution) { + (Err(body_err), snippet_outcome) => { + match snippet_outcome { + Err(snippet_err) => { + prop_assert_eq!(snippet_err, body_err); + } + Ok((Some(result_value), _)) => { + if let ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected)) = &body_err { + prop_assert_eq!(result_value, *expected.clone()); + } else { + panic!("Body `{body}` failed with {body_err:?} but snippet `{snippet}` returned value {result_value:?}"); + } + } + Ok((None, _)) => { + panic!("Snippet `{snippet}` returned no value while body `{body}` failed with {body_err:?}"); + } + } + } + (Ok(_), Err(snippet_err)) => { + panic!("Body `{body}` succeeded but snippet `{snippet}` failed with {snippet_err:?}"); + } + (Ok((body_result, unrestricted_asset_map)), Ok((result, asset_map))) => { + let body_value = body_result + .unwrap_or_else(|| panic!("Execution returned no value for body `{body}`")); + let result_value = result + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); + + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); + + prop_assert_eq!(result_value, expected); + + // And the asset maps should be identical + prop_assert_eq!(asset_map, unrestricted_asset_map); + } + } + } } From 701b0f3880b1555a9854211bcad2ae7d44ef857c Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 16 Oct 2025 13:22:03 -0400 Subject: [PATCH 04/18] refactor: clean up property tests --- clarity/src/vm/tests/mod.rs | 2 + clarity/src/vm/tests/post_conditions.rs | 649 +++++------------------- clarity/src/vm/tests/proptest_utils.rs | 369 ++++++++++++++ 3 files changed, 512 insertions(+), 508 deletions(-) create mode 100644 clarity/src/vm/tests/proptest_utils.rs diff --git a/clarity/src/vm/tests/mod.rs b/clarity/src/vm/tests/mod.rs index 91c05eb9366..f69700f22f0 100644 --- a/clarity/src/vm/tests/mod.rs +++ b/clarity/src/vm/tests/mod.rs @@ -34,6 +34,8 @@ mod defines; mod post_conditions; mod principals; #[cfg(test)] +pub mod proptest_utils; +#[cfg(test)] mod representations; mod sequences; #[cfg(test)] diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 6fb1cc305d5..ecdca4f726d 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -18,92 +18,18 @@ //! in integration tests, since they require changes made outside of the VM. use clarity_types::errors::{Error as ClarityError, InterpreterResult, ShortReturnType}; -use clarity_types::types::{ - CharType, PrincipalData, QualifiedContractIdentifier, SequenceData, StandardPrincipalData, - TypeSignature, UTF8Data, -}; -use clarity_types::{ContractName, Value}; -use proptest::array::uniform20; -use proptest::collection::vec; +use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; +use clarity_types::Value; use proptest::prelude::*; -use proptest::strategy::BoxedStrategy; -use proptest::string::string_regex; -use stacks_common::types::StacksEpochId; +use proptest::test_runner::{TestCaseError, TestCaseResult}; +use super::proptest_utils::{ + begin_block, clarity_values_no_response, execute, execute_and_return_asset_map, + execute_and_return_asset_map_versioned, value_to_clarity_literal, +}; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::contexts::AssetMap; -use crate::vm::database::STXBalance; -use crate::vm::{ - execute_call_in_global_context_and_return_asset_map, - execute_with_parameters_and_call_in_global_context, ClarityVersion, -}; - -fn execute(snippet: &str) -> InterpreterResult> { - execute_with_parameters_and_call_in_global_context( - snippet, - ClarityVersion::Clarity4, - StacksEpochId::Epoch33, - false, - |g| { - // Setup initial balances for the sender and the contract - let sender_principal = PrincipalData::Standard(StandardPrincipalData::transient()); - let contract_id = QualifiedContractIdentifier::transient(); - let contract_principal = PrincipalData::Contract(contract_id); - let balance = STXBalance::initial(1000000); - let mut snapshot = g - .database - .get_stx_balance_snapshot_genesis(&sender_principal) - .unwrap(); - snapshot.set_balance(balance.clone()); - snapshot.save().unwrap(); - let mut snapshot = g - .database - .get_stx_balance_snapshot_genesis(&contract_principal) - .unwrap(); - snapshot.set_balance(balance); - snapshot.save().unwrap(); - g.database.increment_ustx_liquid_supply(2000000).unwrap(); - Ok(()) - }, - ) -} - -fn execute_and_return_asset_map(snippet: &str) -> InterpreterResult<(Option, AssetMap)> { - execute_and_return_asset_map_versioned(snippet, ClarityVersion::Clarity4) -} - -fn execute_and_return_asset_map_versioned( - snippet: &str, - version: ClarityVersion, -) -> InterpreterResult<(Option, AssetMap)> { - execute_call_in_global_context_and_return_asset_map( - snippet, - version, - StacksEpochId::Epoch33, - false, - |g| { - // Setup initial balances for the sender and the contract - let sender_principal = PrincipalData::Standard(StandardPrincipalData::transient()); - let contract_id = QualifiedContractIdentifier::transient(); - let contract_principal = PrincipalData::Contract(contract_id); - let balance = STXBalance::initial(1000000); - let mut snapshot = g - .database - .get_stx_balance_snapshot_genesis(&sender_principal) - .unwrap(); - snapshot.set_balance(balance.clone()); - snapshot.save().unwrap(); - let mut snapshot = g - .database - .get_stx_balance_snapshot_genesis(&contract_principal) - .unwrap(); - snapshot.set_balance(balance); - snapshot.save().unwrap(); - g.database.increment_ustx_liquid_supply(2000000).unwrap(); - Ok(()) - }, - ) -} +use crate::vm::ClarityVersion; // ---------- Tests for as-contract? ---------- @@ -1456,457 +1382,164 @@ fn test_restrict_assets_with_error_in_body() { // ---------- Property Tests ---------- -/// Builds a strategy that produces arbitrary Clarity values. -fn clarity_values_inner(include_responses: bool) -> BoxedStrategy { - let ascii_strings = string_regex("[A-Za-z0-9 \\-_=+*/?!]{0,1024}") - .unwrap() - .prop_map(|s| { - Value::string_ascii_from_bytes(s.into_bytes()) - .expect("ASCII literal within allowed character set") - }); - - let utf8_strings = - string_regex(r#"[\u{00A1}-\u{024F}\u{0370}-\u{03FF}\u{1F300}-\u{1F64F}]{0,1024}"#) - .unwrap() - .prop_map(|s| { - Value::string_utf8_from_bytes(s.into_bytes()) - .expect("UTF-8 literal within allowed character set") - }); - - let standard_principal_data = (any::(), uniform20(any::())) - .prop_filter_map("Invalid standard principal", |(version, bytes)| { - let version = version % 32; - StandardPrincipalData::new(version, bytes).ok() - }) - .boxed(); - - let standard_principals = standard_principal_data - .clone() - .prop_map(|principal| Value::Principal(PrincipalData::Standard(principal))) - .boxed(); - - let contract_name_strings = prop_oneof![ - string_regex("[a-tv-z][a-z0-9-?!]{0,39}").unwrap(), - string_regex("u[a-z-?!][a-z0-9-?!]{0,38}").unwrap(), - ] - .boxed(); - - let contract_names = contract_name_strings - .prop_filter_map("Invalid contract name", |name| { - ContractName::try_from(name).ok() - }) - .boxed(); - - let contract_principals = (standard_principal_data, contract_names) - .prop_map(|(issuer, name)| { - Value::Principal(PrincipalData::Contract(QualifiedContractIdentifier::new( - issuer, name, - ))) - }) - .boxed(); - - let principal_values = prop_oneof![standard_principals, contract_principals]; - - let buffer_values = vec(any::(), 0..1024).prop_map(|bytes| { - Value::buff_from(bytes).expect("Buffer construction should succeed with any byte data") - }); - - let base_values = prop_oneof![ - any::().prop_map(Value::Bool), - any::().prop_map(|v| Value::Int(v as i128)), - any::().prop_map(|v| Value::UInt(v as u128)), - ascii_strings, - utf8_strings, - Just(Value::none()), - principal_values, - buffer_values, - ]; - - base_values - .prop_recursive( - 3, // max nesting depth - 64, // total size budget (unused but required) - 6, // branching factor - move |inner| { - let option_values = inner - .clone() - .prop_filter_map("Option construction failed", |v| Value::some(v).ok()) - .boxed(); - - let inner_for_lists = inner.clone(); - let lists_from_inner = inner - .clone() - .prop_flat_map(move |prototype| { - let sig = TypeSignature::type_of(&prototype) - .expect("Values generated by strategy should have a type signature"); - let sig_for_filter = sig.clone(); - let prototype_for_elements = prototype.clone(); - let element_strategy = inner_for_lists.clone().prop_map(move |candidate| { - if TypeSignature::type_of(&candidate) - .ok() - .is_some_and(|t| t == sig_for_filter) - { - candidate - } else { - prototype_for_elements.clone() - } - }); - let prototype_for_list = prototype.clone(); - vec(element_strategy, 0..3).prop_map(move |rest| { - let mut values = Vec::with_capacity(rest.len() + 1); - values.push(prototype_for_list.clone()); - values.extend(rest); - Value::list_from(values) - .expect("List construction should succeed with homogeneous values") - }) - }) - .boxed(); - - let bool_lists = vec(any::().prop_map(Value::Bool), 1..4) - .prop_filter_map("List construction failed", |values| { - Value::list_from(values).ok() - }) - .boxed(); - - let uint_lists = vec(any::().prop_map(|v| Value::UInt(v as u128)), 1..4) - .prop_filter_map("List construction failed", |values| { - Value::list_from(values).ok() - }) - .boxed(); - - if include_responses { - let ok_responses = inner - .clone() - .prop_filter_map("Response(ok) construction failed", |v| { - Value::okay(v).ok() - }) - .boxed(); - - let err_responses = inner - .clone() - .prop_filter_map("Response(err) construction failed", |v| { - Value::error(v).ok() - }) - .boxed(); - - prop_oneof![ - option_values, - ok_responses, - err_responses, - lists_from_inner, - bool_lists, - uint_lists, - ] - .boxed() - } else { - prop_oneof![option_values, lists_from_inner, bool_lists, uint_lists,].boxed() - } - }, - ) - .boxed() -} - -/// Generates Clarity values, including response values. -fn clarity_values() -> impl Strategy { - clarity_values_inner(true) -} - -/// Generates Clarity values but excludes responses. -fn clarity_values_no_response() -> impl Strategy { - clarity_values_inner(false) -} - -/// Generates STX transfer expressions with random amounts. -fn stx_transfer_expressions() -> impl Strategy { - (1u64..1_000_000u64).prop_map(|amount| { - format!("(try! (stx-transfer? u{amount} tx-sender 'SP000000000000000000002Q6VF78))") - }) -} - -/// Generates a `begin` block with a random number of random expressions. -fn begin_block() -> impl Strategy { - vec( - prop_oneof![ - clarity_values_no_response().prop_map(|value| value_to_string(&value)), - stx_transfer_expressions(), - ], - 1..8, - ) - .prop_shuffle() - .prop_map(|expressions| { - let body = expressions.join(" "); - format!("(begin {body})") - }) -} - -/// Produces a Clarity string literal for the given UTF-8 data. -fn utf8_string_literal(data: &UTF8Data) -> String { - let mut literal = String::from("u\""); - for bytes in &data.data { - if bytes.len() == 1 { - for escaped in std::ascii::escape_default(bytes[0]) { - literal.push(escaped as char); - } - } else { - let ch = std::str::from_utf8(bytes) - .expect("UTF-8 data should decode to a scalar value") - .chars() - .next() - .expect("UTF-8 data should contain at least one scalar"); - literal.push_str(&format!("\\u{{{:X}}}", ch as u32)); +pub fn no_allowance_error() -> Value { + Value::error(Value::UInt(MAX_ALLOWANCES as u128)) + .expect("error response construction never fails") +} + +/// Given the results of running a snippet with and without asset restrictions, +/// assert that the results match and verify that the asset movements are as +/// expected. `asset_check` is a closure that takes the unrestricted and +/// restricted asset maps and returns a `Result, TestCaseError>`. +/// If it returns `Ok(Some(value))`, the test will assert that the restricted +/// execution returned that value. If it returns `Ok(None)`, the test will +/// assert that the restricted execution returned the same value as the +/// unrestricted execution. If it returns `Err`, the test will fail with the +/// provided error. +fn assert_results_match( + unrestricted_result: InterpreterResult<(Option, AssetMap)>, + restricted_result: InterpreterResult<(Option, AssetMap)>, + asset_check: F, +) -> TestCaseResult +where + F: Fn(&AssetMap, &AssetMap) -> Result, TestCaseError>, +{ + match (unrestricted_result, restricted_result) { + (Err(unrestricted_err), Err(restricted_err)) => { + prop_assert_eq!(unrestricted_err, restricted_err); + Ok(()) } - } - literal.push('"'); - literal -} - -/// Converts a Clarity `Value` into its Clarity literal representation. -fn value_to_string(value: &Value) -> String { - match value { - Value::Sequence(SequenceData::List(list_data)) => { - let items: Vec<_> = list_data.data.iter().map(value_to_string).collect(); - if items.is_empty() { - "(list)".to_string() - } else { - format!("(list {})", items.join(" ")) - } + (Err(unrestricted_err), Ok((restricted_result, _))) => { + let detail = match restricted_result { + Some(result_value) => format!( + "Unrestricted execution failed with {unrestricted_err:?} but restricted execution successfully returned value {result_value:?}" + ), + None => format!( + "Unrestricted execution failed with {unrestricted_err:?} but restricted execution successfully returned no value" + ), + }; + Err(TestCaseError::fail(detail)) } - Value::Sequence(SequenceData::String(CharType::ASCII(data))) => format!("{data}"), - Value::Sequence(SequenceData::String(CharType::UTF8(data))) => utf8_string_literal(data), - Value::Optional(optional) => match optional.data.as_deref() { - Some(inner) => format!("(some {})", value_to_string(inner)), - None => "none".to_string(), - }, - Value::Response(response) => { - let inner = value_to_string(response.data.as_ref()); - if response.committed { - format!("(ok {})", inner) + (Ok(_), Err(restricted_err)) => Err(TestCaseError::fail(format!( + "Unrestricted execution succeeded but restricted execution failed with {restricted_err:?}" + ))), + (Ok((unrestricted_value, unrestricted_assets)), Ok((restricted_value, restricted_assets))) => { + let Some(unrestricted_value) = unrestricted_value else { + panic!("Unrestricted execution returned no value"); + }; + let Some(restricted_value) = restricted_value else { + panic!("Restricted execution returned no value"); + }; + + let expected_value = asset_check(&unrestricted_assets, &restricted_assets)?; + if let Some(expected_value) = expected_value { + prop_assert_eq!(expected_value, restricted_value); } else { - format!("(err {})", inner) - } - } - Value::Principal(principal) => format!("'{}", principal), - Value::Tuple(tuple) => { - let mut literal = String::from("(tuple"); - for (name, field) in tuple.data_map.iter() { - literal.push(' '); - literal.push('('); - literal.push_str(&name.to_string()); - literal.push(' '); - literal.push_str(&value_to_string(field)); - literal.push(')'); + let expected = Value::okay(unrestricted_value) + .unwrap_or_else(|e| panic!("Wrapping value failed: {e:?}")); + prop_assert_eq!(expected, restricted_value); } - literal.push(')'); - literal + Ok(()) } - _ => format!("{value}"), } } proptest! { - /// Property: restrict-assets? should return `(ok )` where `` is the - /// result of evaluating the body if no assets are moved in the body. #[test] - fn prop_restrict_assets_returns_body_value_when_pure(body_value in clarity_values_no_response()) { - let body_literal = value_to_string(&body_value); - let snippet = format!("(restrict-assets? tx-sender () {body_literal})"); - - let evaluation = execute(&snippet) - .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) - .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); - - let expected = Value::okay(body_value.clone()) - .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); - - prop_assert!(evaluation == expected); - } - - /// Property: restrict-assets? should return an error if there are no - /// allowances and the body moves assets - #[test] - fn prop_restrict_assets_errors_when_no_allowances_and_body_moves_assets(body in begin_block()) { - let snippet = format!("(restrict-assets? tx-sender () {body})"); - - let body_execution = execute_and_return_asset_map(&body); - let snippet_execution = execute_and_return_asset_map(&snippet); - - match (body_execution, snippet_execution) { - (Err(body_err), snippet_outcome) => { - match snippet_outcome { - Err(snippet_err) => { - prop_assert_eq!(snippet_err, body_err); - } - Ok((Some(result_value), _)) => { - if let ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected)) = &body_err { - prop_assert_eq!(result_value, *expected.clone()); - } else { - panic!("Body `{body}` failed with {body_err:?} but snippet `{snippet}` returned value {result_value:?}"); - } - } - Ok((None, _)) => { - panic!("Snippet `{snippet}` returned no value while body `{body}` failed with {body_err:?}"); - } - } - } - (Ok(_), Err(snippet_err)) => { - panic!("Body `{body}` succeeded but snippet `{snippet}` failed with {snippet_err:?}"); - } - (Ok((body_result, unrestricted_asset_map)), Ok((result, asset_map))) => { - let body_value = body_result - .unwrap_or_else(|| panic!("Execution returned no value for body `{body}`")); - let result_value = result + fn prop_restrict_assets_returns_body_value_when_pure( + body_value in clarity_values_no_response(), + ) { + let body_literal = value_to_clarity_literal(&body_value); + let snippet = format!("(restrict-assets? tx-sender () {body_literal})"); + + let evaluation = execute(&snippet) + .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); - // If the body moves any STX from the sender, the restricted version should error - let sender = PrincipalData::Standard(StandardPrincipalData::transient()); - if let Some(stx_moved) = unrestricted_asset_map.get_stx(&sender) { - let expected_err = Value::error(Value::UInt(MAX_ALLOWANCES as u128)) - .unwrap_or_else(|e| panic!("Wrapping expected error failed for snippet `{snippet}`: {e:?}")); - - prop_assert_eq!(result_value, expected_err); - - // And the asset map should show that no STX was moved - let stx_moved_in_restricted = asset_map.get_stx(&sender).unwrap_or(0); - prop_assert_eq!(stx_moved_in_restricted, 0); - } else { - // If the body doesn't move any STX, the restricted version should return the same value as the body - let expected = Value::okay(body_value.clone()) - .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); - - prop_assert_eq!(result_value, expected); + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); - // And the asset maps should be identical - prop_assert_eq!(asset_map, unrestricted_asset_map); - } - } - } + prop_assert_eq!(expected, evaluation); } - /// Property: as-contract? should return `(ok )` where `` is the - /// result of evaluating the body if no assets are moved in the body. #[test] - fn prop_as_contract_returns_body_value_when_pure(body_value in clarity_values_no_response()) { - let body_literal = value_to_string(&body_value); - let snippet = format!("(as-contract? () {body_literal})"); - - let evaluation = execute(&snippet) - .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) - .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); - - let expected = Value::okay(body_value.clone()) - .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); - - prop_assert!(evaluation == expected); + fn prop_restrict_assets_errors_when_no_allowances_and_body_moves_assets( + body in begin_block(), + ) { + let snippet = format!("(restrict-assets? tx-sender () {body})"); + assert_results_match( + execute_and_return_asset_map(&body), + execute_and_return_asset_map(&snippet), + |unrestricted_assets, restricted_assets| { + let sender = PrincipalData::Standard(StandardPrincipalData::transient()); + let stx_moved = unrestricted_assets.get_stx(&sender).unwrap_or(0); + if stx_moved > 0 { + prop_assert_eq!(&AssetMap::new(), restricted_assets); + Ok(Some(no_allowance_error())) + } else { + prop_assert_eq!(unrestricted_assets, restricted_assets); + Ok(None) + } + }, + ) + .unwrap(); } - /// Property: as-contract? should return an error if there are no - /// allowances and the body moves assets #[test] - fn prop_as_contract_errors_when_no_allowances_and_body_moves_assets(body in begin_block()) { - let snippet = format!("(as-contract? () {body})"); - let c3_snippet = format!("(as-contract {body})"); - - let body_execution = execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3); - let snippet_execution = execute_and_return_asset_map(&snippet); - - match (body_execution, snippet_execution) { - (Err(body_err), snippet_outcome) => { - match snippet_outcome { - Err(snippet_err) => { - prop_assert_eq!(snippet_err, body_err); - } - Ok((Some(result_value), _)) => { - if let ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected)) = &body_err { - prop_assert_eq!(result_value, *expected.clone()); - } else { - panic!("Body `{body}` failed with {body_err:?} but snippet `{snippet}` returned value {result_value:?}"); - } - } - Ok((None, _)) => { - panic!("Snippet `{snippet}` returned no value while body `{body}` failed with {body_err:?}"); - } - } - } - (Ok(_), Err(snippet_err)) => { - panic!("Body `{body}` succeeded but snippet `{snippet}` failed with {snippet_err:?}"); - } - (Ok((body_result, unrestricted_asset_map)), Ok((result, asset_map))) => { - let body_value = body_result - .unwrap_or_else(|| panic!("Execution returned no value for body `{body}`")); - let result_value = result + fn prop_as_contract_returns_body_value_when_pure( + body_value in clarity_values_no_response(), + ) { + let body_literal = value_to_clarity_literal(&body_value); + let snippet = format!("(as-contract? () {body_literal})"); + + let evaluation = execute(&snippet) + .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); - // If the body moves any STX from the contract, the restricted version should error - let contract_id = QualifiedContractIdentifier::transient(); - let contract_principal = PrincipalData::Contract(contract_id); - if let Some(stx_moved) = unrestricted_asset_map.get_stx(&contract_principal) { - let expected_err = Value::error(Value::UInt(MAX_ALLOWANCES as u128)) - .unwrap_or_else(|e| panic!("Wrapping expected error failed for snippet `{snippet}`: {e:?}")); - - prop_assert_eq!(result_value, expected_err); - - // And the asset map should show that no STX was moved - let stx_moved_in_restricted = asset_map.get_stx(&contract_principal).unwrap_or(0); - prop_assert_eq!(stx_moved_in_restricted, 0); - } else { - // If the body doesn't move any STX, the restricted version should return the same value as the body - let expected = Value::okay(body_value.clone()) - .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); - - prop_assert_eq!(result_value, expected); + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); - // And the asset maps should be identical - prop_assert_eq!(asset_map, unrestricted_asset_map); - } - } - } + prop_assert_eq!(expected, evaluation); } - /// Property: as-contract? with `with-all-assets-unsafe` should always return the - /// same as the Clarity3 `as-contract`. #[test] - fn prop_as_contract_with_all_assets_unsafe_matches_clarity3(body in begin_block()) { - let snippet = format!("(as-contract? ((with-all-assets-unsafe)) {body})"); - let c3_snippet = format!("(as-contract {body})"); - - let c3_execution = execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3); - let snippet_execution = execute_and_return_asset_map(&snippet); - - match (c3_execution, snippet_execution) { - (Err(body_err), snippet_outcome) => { - match snippet_outcome { - Err(snippet_err) => { - prop_assert_eq!(snippet_err, body_err); - } - Ok((Some(result_value), _)) => { - if let ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected)) = &body_err { - prop_assert_eq!(result_value, *expected.clone()); - } else { - panic!("Body `{body}` failed with {body_err:?} but snippet `{snippet}` returned value {result_value:?}"); - } - } - Ok((None, _)) => { - panic!("Snippet `{snippet}` returned no value while body `{body}` failed with {body_err:?}"); - } - } - } - (Ok(_), Err(snippet_err)) => { - panic!("Body `{body}` succeeded but snippet `{snippet}` failed with {snippet_err:?}"); - } - (Ok((body_result, unrestricted_asset_map)), Ok((result, asset_map))) => { - let body_value = body_result - .unwrap_or_else(|| panic!("Execution returned no value for body `{body}`")); - let result_value = result - .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); - - let expected = Value::okay(body_value.clone()) - .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); - - prop_assert_eq!(result_value, expected); + fn prop_as_contract_errors_when_no_allowances_and_body_moves_assets( + body in begin_block(), + ) { + let snippet = format!("(as-contract? () {body})"); + let c3_snippet = format!("(as-contract {body})"); + assert_results_match( + execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3), + execute_and_return_asset_map(&snippet), + |unrestricted_assets, restricted_assets| { + let contract = PrincipalData::Contract(QualifiedContractIdentifier::transient()); + let stx_moved = unrestricted_assets.get_stx(&contract).unwrap_or(0); + if stx_moved > 0 { + prop_assert_eq!(&AssetMap::new(), restricted_assets); + Ok(Some(no_allowance_error())) + } else { + prop_assert_eq!(unrestricted_assets, restricted_assets); + Ok(None) + } + }, + ) + .unwrap(); + } - // And the asset maps should be identical - prop_assert_eq!(asset_map, unrestricted_asset_map); - } - } + #[test] + fn prop_as_contract_with_all_assets_unsafe_matches_clarity3( + body in begin_block(), + ) { + let snippet = format!("(as-contract? ((with-all-assets-unsafe)) {body})"); + let c3_snippet = format!("(as-contract {body})"); + assert_results_match( + execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3), + execute_and_return_asset_map(&snippet), + |unrestricted_assets, restricted_assets| { + prop_assert_eq!(unrestricted_assets, restricted_assets); + Ok(None) + }, + ) + .unwrap(); } } diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs new file mode 100644 index 00000000000..7c2d93b2f46 --- /dev/null +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -0,0 +1,369 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! This module contains utility functions and strategies for property-based +//! testing of the Clarity VM. + +use std::result::Result; + +use clarity_types::errors::InterpreterResult; +use clarity_types::types::{ + CharType, PrincipalData, QualifiedContractIdentifier, SequenceData, StandardPrincipalData, + TypeSignature, UTF8Data, +}; +use clarity_types::{ContractName, Value}; +use proptest::array::uniform20; +use proptest::collection::vec; +use proptest::prelude::*; +use proptest::strategy::BoxedStrategy; +use proptest::string::string_regex; +use stacks_common::types::StacksEpochId; + +use crate::vm::contexts::{AssetMap, GlobalContext}; +use crate::vm::database::STXBalance; +use crate::vm::errors::Error as VmError; +use crate::vm::{ + execute_call_in_global_context_and_return_asset_map, + execute_with_parameters_and_call_in_global_context, ClarityVersion, +}; + +const DEFAULT_EPOCH: StacksEpochId = StacksEpochId::Epoch33; +const DEFAULT_CLARITY_VERSION: ClarityVersion = ClarityVersion::Clarity4; +const INITIAL_BALANCE: u128 = 1_000_000; + +fn initialize_balances(g: &mut GlobalContext) -> Result<(), VmError> { + let sender_principal = PrincipalData::Standard(StandardPrincipalData::transient()); + let contract_id = QualifiedContractIdentifier::transient(); + let contract_principal = PrincipalData::Contract(contract_id); + let balance = STXBalance::initial(INITIAL_BALANCE); + + let mut sender_snapshot = g + .database + .get_stx_balance_snapshot_genesis(&sender_principal) + .unwrap(); + sender_snapshot.set_balance(balance.clone()); + sender_snapshot.save().unwrap(); + + let mut contract_snapshot = g + .database + .get_stx_balance_snapshot_genesis(&contract_principal) + .unwrap(); + contract_snapshot.set_balance(balance); + contract_snapshot.save().unwrap(); + + g.database + .increment_ustx_liquid_supply(INITIAL_BALANCE * 2) + .unwrap(); + Ok(()) +} + +/// Execute a Clarity code snippet in a fresh global context with default +/// parameters, setting up initial balances. +pub fn execute(snippet: &str) -> InterpreterResult> { + execute_versioned(snippet, DEFAULT_CLARITY_VERSION) +} + +/// Execute a Clarity code snippet with the specified Clarity version in a +/// fresh global context with default parameters, setting up initial balances. +pub fn execute_versioned( + snippet: &str, + version: ClarityVersion, +) -> InterpreterResult> { + execute_with_parameters_and_call_in_global_context( + snippet, + version, + DEFAULT_EPOCH, + false, + initialize_balances, + ) +} + +/// Execute a Clarity code snippet in a fresh global context with default +/// parameters, setting up initial balances, returning the resulting value +/// along with the final asset map. +pub fn execute_and_return_asset_map(snippet: &str) -> InterpreterResult<(Option, AssetMap)> { + execute_and_return_asset_map_versioned(snippet, DEFAULT_CLARITY_VERSION) +} + +/// Execute a Clarity code snippet with the specified Clarity version in a +/// fresh global context with default parameters, setting up initial balances, +/// returning the resulting value along with the final asset map. +pub fn execute_and_return_asset_map_versioned( + snippet: &str, + version: ClarityVersion, +) -> InterpreterResult<(Option, AssetMap)> { + execute_call_in_global_context_and_return_asset_map( + snippet, + version, + DEFAULT_EPOCH, + false, + initialize_balances, + ) +} + +/// A strategy that generates Clarity values. +pub fn clarity_values() -> BoxedStrategy { + clarity_values_inner(true) +} + +/// A strategy that generates Clarity values, excluding Response types. +pub fn clarity_values_no_response() -> BoxedStrategy { + clarity_values_inner(false) +} + +/// Internal function to generate Clarity values, with an option to include +/// or exclude Response types. +fn clarity_values_inner(include_responses: bool) -> BoxedStrategy { + let ascii_strings = string_regex("[A-Za-z0-9 \\-_=+*/?!]{0,1024}") + .unwrap() + .prop_map(|s| { + Value::string_ascii_from_bytes(s.into_bytes()) + .expect("ASCII literal within allowed character set") + }); + + let utf8_strings = + string_regex(r#"[\u{00A1}-\u{024F}\u{0370}-\u{03FF}\u{1F300}-\u{1F64F}]{0,1024}"#) + .unwrap() + .prop_map(|s| { + Value::string_utf8_from_bytes(s.into_bytes()) + .expect("UTF-8 literal within allowed character set") + }); + + let standard_principal_data = (any::(), uniform20(any::())) + .prop_filter_map("Invalid standard principal", |(version, bytes)| { + let version = version % 32; + StandardPrincipalData::new(version, bytes).ok() + }) + .boxed(); + + let standard_principals = standard_principal_data + .clone() + .prop_map(|principal| Value::Principal(PrincipalData::Standard(principal))) + .boxed(); + + let contract_name_strings = prop_oneof![ + string_regex("[a-tv-z][a-z0-9-?!]{0,39}").unwrap(), + string_regex("u[a-z-?!][a-z0-9-?!]{0,38}").unwrap(), + ] + .boxed(); + + let contract_names = contract_name_strings + .prop_filter_map("Invalid contract name", |name| { + ContractName::try_from(name).ok() + }) + .boxed(); + + let contract_principals = (standard_principal_data, contract_names) + .prop_map(|(issuer, name)| { + Value::Principal(PrincipalData::Contract(QualifiedContractIdentifier::new( + issuer, name, + ))) + }) + .boxed(); + + let principal_values = prop_oneof![standard_principals, contract_principals]; + + let buffer_values = vec(any::(), 0..1024).prop_map(|bytes| { + Value::buff_from(bytes).expect("Buffer construction should succeed with any byte data") + }); + + let base_values = prop_oneof![ + any::().prop_map(Value::Bool), + any::().prop_map(|v| Value::Int(v as i128)), + any::().prop_map(|v| Value::UInt(v as u128)), + ascii_strings, + utf8_strings, + Just(Value::none()), + principal_values, + buffer_values, + ]; + + base_values + .prop_recursive( + 3, // max nesting depth + 64, // total size budget + 6, // branching factor + move |inner| { + let option_values = inner + .clone() + .prop_filter_map("Option construction failed", |v| Value::some(v).ok()) + .boxed(); + + let inner_for_lists = inner.clone(); + let lists_from_inner = inner + .clone() + .prop_flat_map(move |prototype| { + let sig = TypeSignature::type_of(&prototype) + .expect("Values generated by strategy should have a type signature"); + let sig_for_filter = sig.clone(); + let prototype_for_elements = prototype.clone(); + let element_strategy = inner_for_lists.clone().prop_map(move |candidate| { + if TypeSignature::type_of(&candidate) + .ok() + .is_some_and(|t| t == sig_for_filter) + { + candidate + } else { + prototype_for_elements.clone() + } + }); + let prototype_for_list = prototype.clone(); + vec(element_strategy, 0..3).prop_map(move |rest| { + let mut values = Vec::with_capacity(rest.len() + 1); + values.push(prototype_for_list.clone()); + values.extend(rest); + Value::list_from(values) + .expect("List construction should succeed with homogeneous values") + }) + }) + .boxed(); + + let bool_lists = vec(any::().prop_map(Value::Bool), 1..4) + .prop_filter_map("List construction failed", |values| { + Value::list_from(values).ok() + }) + .boxed(); + + let uint_lists = vec(any::().prop_map(|v| Value::UInt(v as u128)), 1..4) + .prop_filter_map("List construction failed", |values| { + Value::list_from(values).ok() + }) + .boxed(); + + if include_responses { + let ok_responses = inner + .clone() + .prop_filter_map("Response(ok) construction failed", |v| { + Value::okay(v).ok() + }) + .boxed(); + + let err_responses = inner + .clone() + .prop_filter_map("Response(err) construction failed", |v| { + Value::error(v).ok() + }) + .boxed(); + + prop_oneof![ + option_values, + ok_responses, + err_responses, + lists_from_inner, + bool_lists, + uint_lists, + ] + .boxed() + } else { + prop_oneof![option_values, lists_from_inner, bool_lists, uint_lists].boxed() + } + }, + ) + .boxed() +} + +/// A strategy that generates STX transfer expressions with amounts between +/// 1 and 1,000,000 micro-STX. +pub fn stx_transfer_expressions() -> impl Strategy { + (1u64..1_000_000u64).prop_map(|amount| { + format!("(try! (stx-transfer? u{amount} tx-sender 'SP000000000000000000002Q6VF78))") + }) +} + +/// A strategy that generates `(begin ...)` expressions containing between +/// 1 and 8 expressions, each of which is either a Clarity value literal or +/// an STX transfer expression. +pub fn begin_block() -> impl Strategy { + vec( + prop_oneof![ + clarity_values_no_response().prop_map(|value| value_to_clarity_literal(&value)), + stx_transfer_expressions(), + ], + 1..8, + ) + .prop_shuffle() + .prop_map(|expressions| { + let body = expressions.join(" "); + format!("(begin {body})") + }) +} + +/// Convert a Clarity `Value` into a Clarity literal string. +pub fn value_to_clarity_literal(value: &Value) -> String { + match value { + Value::Sequence(SequenceData::List(list_data)) => { + let items: Vec<_> = list_data + .data + .iter() + .map(value_to_clarity_literal) + .collect(); + if items.is_empty() { + "(list)".to_string() + } else { + format!("(list {})", items.join(" ")) + } + } + Value::Sequence(SequenceData::String(CharType::ASCII(data))) => format!("{data}"), + Value::Sequence(SequenceData::String(CharType::UTF8(data))) => utf8_string_literal(data), + Value::Optional(optional) => match optional.data.as_deref() { + Some(inner) => format!("(some {})", value_to_clarity_literal(inner)), + None => "none".to_string(), + }, + Value::Response(response) => { + let inner = value_to_clarity_literal(response.data.as_ref()); + if response.committed { + format!("(ok {})", inner) + } else { + format!("(err {})", inner) + } + } + Value::Principal(principal) => format!("'{}", principal), + Value::Tuple(tuple) => { + let mut literal = String::from("(tuple"); + for (name, field) in tuple.data_map.iter() { + literal.push(' '); + literal.push('('); + literal.push_str(&name.to_string()); + literal.push(' '); + literal.push_str(&value_to_clarity_literal(field)); + literal.push(')'); + } + literal.push(')'); + literal + } + _ => format!("{value}"), + } +} + +/// Convert UTF-8 data into a Clarity UTF-8 string literal. +pub fn utf8_string_literal(data: &UTF8Data) -> String { + let mut literal = String::from("u\""); + for bytes in &data.data { + if bytes.len() == 1 { + for escaped in std::ascii::escape_default(bytes[0]) { + literal.push(escaped as char); + } + } else { + let ch = std::str::from_utf8(bytes) + .expect("UTF-8 data should decode to a scalar value") + .chars() + .next() + .expect("UTF-8 data should contain at least one scalar"); + literal.push_str(&format!("\\u{{{:X}}}", ch as u32)); + } + } + literal.push('"'); + literal +} From 792c92f06ded9aedce013f5f215f65d9640bddec Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 16 Oct 2025 17:07:59 -0400 Subject: [PATCH 05/18] test: add more property tests for post-conditions --- clarity/src/vm/tests/post_conditions.rs | 144 ++++++++++++++++++++++-- clarity/src/vm/tests/proptest_utils.rs | 129 +++++++++++++++++++-- 2 files changed, 256 insertions(+), 17 deletions(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index ecdca4f726d..fe5a1749ac0 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -17,9 +17,13 @@ //! `restrict-assets?` expressions. The `with-stacking` allowances are tested //! in integration tests, since they require changes made outside of the VM. +use std::convert::TryFrom; + use clarity_types::errors::{Error as ClarityError, InterpreterResult, ShortReturnType}; -use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; -use clarity_types::Value; +use clarity_types::types::{ + AssetIdentifier, PrincipalData, QualifiedContractIdentifier, StandardPrincipalData, +}; +use clarity_types::{ClarityName, Value}; use proptest::prelude::*; use proptest::test_runner::{TestCaseError, TestCaseResult}; @@ -29,6 +33,10 @@ use super::proptest_utils::{ }; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::contexts::AssetMap; +use crate::vm::tests::proptest_utils::{ + allowance_list_snippets, ft_mint_snippets, ft_transfer_snippets, match_response_snippets, + nft_mint_snippets, nft_transfer_snippets, try_response_snippets, +}; use crate::vm::ClarityVersion; // ---------- Tests for as-contract? ---------- @@ -1382,11 +1390,6 @@ fn test_restrict_assets_with_error_in_body() { // ---------- Property Tests ---------- -pub fn no_allowance_error() -> Value { - Value::error(Value::UInt(MAX_ALLOWANCES as u128)) - .expect("error response construction never fails") -} - /// Given the results of running a snippet with and without asset restrictions, /// assert that the results match and verify that the asset movements are as /// expected. `asset_check` is a closure that takes the unrestricted and @@ -1423,7 +1426,10 @@ where (Ok(_), Err(restricted_err)) => Err(TestCaseError::fail(format!( "Unrestricted execution succeeded but restricted execution failed with {restricted_err:?}" ))), - (Ok((unrestricted_value, unrestricted_assets)), Ok((restricted_value, restricted_assets))) => { + ( + Ok((unrestricted_value, unrestricted_assets)), + Ok((restricted_value, restricted_assets)), + ) => { let Some(unrestricted_value) = unrestricted_value else { panic!("Unrestricted execution returned no value"); }; @@ -1444,6 +1450,13 @@ where } } +/// Construct the error value returned when assets move without matching +/// allowances. +fn no_allowance_error() -> Value { + Value::error(Value::UInt(MAX_ALLOWANCES as u128)) + .expect("error response construction never fails") +} + proptest! { #[test] fn prop_restrict_assets_returns_body_value_when_pure( @@ -1463,7 +1476,25 @@ proptest! { } #[test] - fn prop_restrict_assets_errors_when_no_allowances_and_body_moves_assets( + fn prop_restrict_assets_returns_value_with_allowances( + allowances in allowance_list_snippets(), + body_value in clarity_values_no_response(), + ) { + let body_literal = value_to_clarity_literal(&body_value); + let snippet = format!("(restrict-assets? tx-sender {allowances} {body_literal})"); + + let evaluation = execute(&snippet) + .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); + + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_restrict_assets_errors_when_no_allowances_and_body_moves_stx( body in begin_block(), ) { let snippet = format!("(restrict-assets? tx-sender () {body})"); @@ -1485,6 +1516,81 @@ proptest! { .unwrap(); } + #[test] + fn prop_restrict_assets_errors_when_no_ft_allowance( + ft_mint in match_response_snippets(ft_mint_snippets()), ft_transfer in try_response_snippets(ft_transfer_snippets()) + ) { + let setup_code = format!("(define-fungible-token stackaroo) {ft_mint}"); + let body_program = format!( + "{setup_code} {ft_transfer}", + ); + let wrapper_program = format!( + "{setup_code} (restrict-assets? tx-sender () {ft_transfer})", + ); + let asset_identifier = AssetIdentifier { + contract_identifier: QualifiedContractIdentifier::transient(), + asset_name: ClarityName::try_from("stackaroo".to_string()) + .expect("valid fungible token name"), + }; + + assert_results_match( + execute_and_return_asset_map(&body_program), + execute_and_return_asset_map(&wrapper_program), + move |unrestricted_assets, restricted_assets| { + let sender = PrincipalData::Standard(StandardPrincipalData::transient()); + let moved = unrestricted_assets + .get_fungible_tokens(&sender, &asset_identifier) + .unwrap_or(0); + if moved > 0 { + prop_assert_eq!(&AssetMap::new(), restricted_assets); + Ok(Some(no_allowance_error())) + } else { + prop_assert_eq!(unrestricted_assets, restricted_assets); + Ok(None) + } + }, + ) + .unwrap(); + } + + #[test] + fn prop_restrict_assets_errors_when_no_nft_allowance( + nft_mint in match_response_snippets(nft_mint_snippets()), nft_transfer in try_response_snippets(nft_transfer_snippets()) + ) { + let setup_code = format!("(define-non-fungible-token stackaroo uint) {nft_mint}"); + let body_program = format!( + "{setup_code} {nft_transfer}", + ); + let wrapper_program = format!( + "{setup_code} (restrict-assets? tx-sender () {nft_transfer})", + ); + let asset_identifier = AssetIdentifier { + contract_identifier: QualifiedContractIdentifier::transient(), + asset_name: ClarityName::try_from("stackaroo".to_string()) + .expect("valid non-fungible token name"), + }; + + assert_results_match( + execute_and_return_asset_map(&body_program), + execute_and_return_asset_map(&wrapper_program), + move |unrestricted_assets, restricted_assets| { + let sender = PrincipalData::Standard(StandardPrincipalData::transient()); + let moved = unrestricted_assets + .get_nonfungible_tokens(&sender, &asset_identifier) + .map(|l| l.len()) + .unwrap_or(0); + if moved > 0 { + prop_assert_eq!(&AssetMap::new(), restricted_assets); + Ok(Some(no_allowance_error())) + } else { + prop_assert_eq!(unrestricted_assets, restricted_assets); + Ok(None) + } + }, + ) + .unwrap(); + } + #[test] fn prop_as_contract_returns_body_value_when_pure( body_value in clarity_values_no_response(), @@ -1503,7 +1609,25 @@ proptest! { } #[test] - fn prop_as_contract_errors_when_no_allowances_and_body_moves_assets( + fn prop_as_contract_returns_value_with_allowances( + allowances in allowance_list_snippets(), + body_value in clarity_values_no_response(), + ) { + let body_literal = value_to_clarity_literal(&body_value); + let snippet = format!("(as-contract? {allowances} {body_literal})"); + + let evaluation = execute(&snippet) + .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")); + + let expected = Value::okay(body_value.clone()) + .unwrap_or_else(|e| panic!("Wrapping body value failed for snippet `{snippet}`: {e:?}")); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_as_contract_errors_when_no_allowances_and_body_moves_stx( body in begin_block(), ) { let snippet = format!("(as-contract? () {body})"); diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs index 7c2d93b2f46..5df31e64775 100644 --- a/clarity/src/vm/tests/proptest_utils.rs +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -31,6 +31,9 @@ use proptest::strategy::BoxedStrategy; use proptest::string::string_regex; use stacks_common::types::StacksEpochId; +use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::{ + MAX_ALLOWANCES, MAX_NFT_IDENTIFIERS, +}; use crate::vm::contexts::{AssetMap, GlobalContext}; use crate::vm::database::STXBalance; use crate::vm::errors::Error as VmError; @@ -181,8 +184,8 @@ fn clarity_values_inner(include_responses: bool) -> BoxedStrategy { let base_values = prop_oneof![ any::().prop_map(Value::Bool), - any::().prop_map(|v| Value::Int(v as i128)), - any::().prop_map(|v| Value::UInt(v as u128)), + any::().prop_map(Value::Int), + any::().prop_map(Value::UInt), ascii_strings, utf8_strings, Just(Value::none()), @@ -236,7 +239,7 @@ fn clarity_values_inner(include_responses: bool) -> BoxedStrategy { }) .boxed(); - let uint_lists = vec(any::().prop_map(|v| Value::UInt(v as u128)), 1..4) + let uint_lists = vec(any::().prop_map(Value::UInt), 1..4) .prop_filter_map("List construction failed", |values| { Value::list_from(values).ok() }) @@ -274,11 +277,123 @@ fn clarity_values_inner(include_responses: bool) -> BoxedStrategy { .boxed() } -/// A strategy that generates STX transfer expressions with amounts between +/// A strategy that generates STX transfer snippets with amounts between /// 1 and 1,000,000 micro-STX. -pub fn stx_transfer_expressions() -> impl Strategy { +pub fn stx_transfer_snippets() -> impl Strategy { (1u64..1_000_000u64).prop_map(|amount| { - format!("(try! (stx-transfer? u{amount} tx-sender 'SP000000000000000000002Q6VF78))") + format!("(stx-transfer? u{amount} tx-sender 'SP000000000000000000002Q6VF78)") + }) +} + +/// A strategy that generates FT mint snippets with amounts between +/// 1 and 1,000,000 units of the token. The FT contract is always +/// `current-contract` and the token name is always `stackaroo`. +pub fn ft_mint_snippets() -> impl Strategy { + (1u64..1_000_000u64).prop_map(|amount| format!("(ft-mint? stackaroo u{amount} tx-sender)")) +} + +/// A strategy that generates FT transfer snippets with amounts between +/// 1 and 1,000,000 units of the token. The FT contract is always +/// `current-contract` and the token name is always `stackaroo`. +pub fn ft_transfer_snippets() -> impl Strategy { + (1u64..1_000_000u64).prop_map(|amount| { + format!("(ft-transfer? stackaroo u{amount} tx-sender 'SP000000000000000000002Q6VF78)") + }) +} + +/// A strategy that generates NFT mint snippets. The NFT contract is always +/// `current-contract` and the token name is always `stackaroo`. A random +/// `uint` identifier is generated for each snippet. +pub fn nft_mint_snippets() -> impl Strategy { + any::().prop_map(|id| format!("(nft-mint? stackaroo u{id} tx-sender)")) +} + +/// A strategy that generates NFT transfer snippets. The NFT contract is always +/// `current-contract` and the token name is always `stackaroo`. A random +/// `uint` identifier is generated for each snippet. +pub fn nft_transfer_snippets() -> impl Strategy { + any::().prop_map(|id| { + format!("(nft-transfer? stackaroo u{id} tx-sender 'SP000000000000000000002Q6VF78)") + }) +} + +/// A strategy that generates a `try!`-wrapped version of the given +/// Clarity code snippet generator. This is useful for wrapping expressions +/// that return a `Response` type, so that they can be used in contexts +/// that expect a non-`Response` value. +pub fn try_response_snippets(response: S) -> impl Strategy +where + S: Strategy, +{ + response.prop_map(|snippet| format!("(try! {snippet})")) +} + +/// A strategy that generates a `match`-wrapped version of the given +/// Clarity code snippet generator. This is useful for wrapping expressions +/// that return a `Response` type, so that they can be used in contexts +/// that expect a non-`Response` value and will not cause an error. +pub fn match_response_snippets(response: S) -> impl Strategy +where + S: Strategy, +{ + response.prop_map(|snippet| format!("(match {snippet} v true e false)")) +} + +/// A strategy that generates Clarity code snippets for STX allowances. +fn stx_allowance_snippets() -> impl Strategy { + any::().prop_map(|amount| format!("(with-stx u{amount})")) +} + +/// A stategy that generates Clarity code snippets for FT allowances. +/// The FT contract is always `current-contract` and the token name is always +/// `stackaroo`. +pub fn ft_allowance_snippets() -> impl Strategy { + any::().prop_map(|amount| format!("(with-ft current-contract stackaroo u{amount})")) +} + +/// A strategy that generates Clarity code snippets for NFT allowances. +/// The NFT contract is always `current-contract` and the token name is always +/// `stackaroo`, and a random list of u128 IDs is generated. +pub fn nft_allowance_snippets() -> impl Strategy { + let nft_ids = prop::collection::vec(any::(), 0..=MAX_NFT_IDENTIFIERS as usize); + nft_ids.prop_map(|ids| { + format!( + "(with-nft current-contract stackaroo (list {}))", + ids.iter() + .map(|id| format!("u{id}")) + .collect::>() + .join(" ") + ) + }) +} + +/// A strategy that generates Clarity code snippets for stacking allowances. +pub fn stacking_allowance_snippets() -> impl Strategy { + any::().prop_map(|amount| format!("(with-stacking u{amount})")) +} + +/// A strategy that generates Clarity code snippets for allowances. +pub fn allowance_snippets() -> impl Strategy { + let stx_allowance = stx_allowance_snippets(); + let ft_allowance = ft_allowance_snippets(); + let nft_allowance = nft_allowance_snippets(); + let stacking_allowance = stacking_allowance_snippets(); + prop_oneof![ + stx_allowance, + ft_allowance, + nft_allowance, + stacking_allowance + ] +} + +/// A strategy that generates Clarity code snippets for allowances lists. +pub fn allowance_list_snippets() -> impl Strategy { + prop::collection::vec(allowance_snippets(), 0..MAX_ALLOWANCES).prop_map(|allowances| { + if allowances.is_empty() { + "()".to_string() + } else { + format!("({})", allowances.join(" ")) + } }) } @@ -289,7 +404,7 @@ pub fn begin_block() -> impl Strategy { vec( prop_oneof![ clarity_values_no_response().prop_map(|value| value_to_clarity_literal(&value)), - stx_transfer_expressions(), + try_response_snippets(stx_transfer_snippets()), ], 1..8, ) From fe3a240392f74be540b941006e17b48a390e5796 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Fri, 17 Oct 2025 10:10:44 -0400 Subject: [PATCH 06/18] fix: minor errors from refactoring --- clarity/src/vm/tests/post_conditions.rs | 15 ++++++++++----- clarity/src/vm/tests/proptest_utils.rs | 14 +++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index fe5a1749ac0..cbee6f5a3c8 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -1457,6 +1457,11 @@ fn no_allowance_error() -> Value { .expect("error response construction never fails") } +const TOKEN_DEFINITIONS: &str = r#" +(define-fungible-token stackos) +(define-non-fungible-token stackaroo uint) +"#; + proptest! { #[test] fn prop_restrict_assets_returns_body_value_when_pure( @@ -1481,7 +1486,7 @@ proptest! { body_value in clarity_values_no_response(), ) { let body_literal = value_to_clarity_literal(&body_value); - let snippet = format!("(restrict-assets? tx-sender {allowances} {body_literal})"); + let snippet = format!("{TOKEN_DEFINITIONS}(restrict-assets? tx-sender {allowances} {body_literal})"); let evaluation = execute(&snippet) .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) @@ -1520,7 +1525,7 @@ proptest! { fn prop_restrict_assets_errors_when_no_ft_allowance( ft_mint in match_response_snippets(ft_mint_snippets()), ft_transfer in try_response_snippets(ft_transfer_snippets()) ) { - let setup_code = format!("(define-fungible-token stackaroo) {ft_mint}"); + let setup_code = format!("{TOKEN_DEFINITIONS} {ft_mint}"); let body_program = format!( "{setup_code} {ft_transfer}", ); @@ -1529,7 +1534,7 @@ proptest! { ); let asset_identifier = AssetIdentifier { contract_identifier: QualifiedContractIdentifier::transient(), - asset_name: ClarityName::try_from("stackaroo".to_string()) + asset_name: ClarityName::try_from("stackos".to_string()) .expect("valid fungible token name"), }; @@ -1557,7 +1562,7 @@ proptest! { fn prop_restrict_assets_errors_when_no_nft_allowance( nft_mint in match_response_snippets(nft_mint_snippets()), nft_transfer in try_response_snippets(nft_transfer_snippets()) ) { - let setup_code = format!("(define-non-fungible-token stackaroo uint) {nft_mint}"); + let setup_code = format!("{TOKEN_DEFINITIONS} {nft_mint}"); let body_program = format!( "{setup_code} {nft_transfer}", ); @@ -1614,7 +1619,7 @@ proptest! { body_value in clarity_values_no_response(), ) { let body_literal = value_to_clarity_literal(&body_value); - let snippet = format!("(as-contract? {allowances} {body_literal})"); + let snippet = format!("{TOKEN_DEFINITIONS}(as-contract? {allowances} {body_literal})"); let evaluation = execute(&snippet) .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs index 5df31e64775..694cde13d3e 100644 --- a/clarity/src/vm/tests/proptest_utils.rs +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -287,17 +287,17 @@ pub fn stx_transfer_snippets() -> impl Strategy { /// A strategy that generates FT mint snippets with amounts between /// 1 and 1,000,000 units of the token. The FT contract is always -/// `current-contract` and the token name is always `stackaroo`. +/// `current-contract` and the token name is always `stackos`. pub fn ft_mint_snippets() -> impl Strategy { - (1u64..1_000_000u64).prop_map(|amount| format!("(ft-mint? stackaroo u{amount} tx-sender)")) + (1u64..1_000_000u64).prop_map(|amount| format!("(ft-mint? stackos u{amount} tx-sender)")) } /// A strategy that generates FT transfer snippets with amounts between /// 1 and 1,000,000 units of the token. The FT contract is always -/// `current-contract` and the token name is always `stackaroo`. +/// `current-contract` and the token name is always `stackos`. pub fn ft_transfer_snippets() -> impl Strategy { (1u64..1_000_000u64).prop_map(|amount| { - format!("(ft-transfer? stackaroo u{amount} tx-sender 'SP000000000000000000002Q6VF78)") + format!("(ft-transfer? stackos u{amount} tx-sender 'SP000000000000000000002Q6VF78)") }) } @@ -346,9 +346,9 @@ fn stx_allowance_snippets() -> impl Strategy { /// A stategy that generates Clarity code snippets for FT allowances. /// The FT contract is always `current-contract` and the token name is always -/// `stackaroo`. +/// `stackos`. pub fn ft_allowance_snippets() -> impl Strategy { - any::().prop_map(|amount| format!("(with-ft current-contract stackaroo u{amount})")) + any::().prop_map(|amount| format!("(with-ft current-contract \"stackos\" u{amount})")) } /// A strategy that generates Clarity code snippets for NFT allowances. @@ -358,7 +358,7 @@ pub fn nft_allowance_snippets() -> impl Strategy { let nft_ids = prop::collection::vec(any::(), 0..=MAX_NFT_IDENTIFIERS as usize); nft_ids.prop_map(|ids| { format!( - "(with-nft current-contract stackaroo (list {}))", + "(with-nft current-contract \"stackaroo\" (list {}))", ids.iter() .map(|id| format!("u{id}")) .collect::>() From cdaa24dbef5d64bb4a395fb78cdddf3c0b2445e8 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 22 Oct 2025 15:56:32 -0400 Subject: [PATCH 07/18] test: add `as-contract?` prop test with allowed transfers --- clarity/src/vm/tests/post_conditions.rs | 28 +++++- clarity/src/vm/tests/proptest_utils.rs | 110 +++++++++++++++++++++++- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index cbee6f5a3c8..7dc990ae33d 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -34,8 +34,8 @@ use super::proptest_utils::{ use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::contexts::AssetMap; use crate::vm::tests::proptest_utils::{ - allowance_list_snippets, ft_mint_snippets, ft_transfer_snippets, match_response_snippets, - nft_mint_snippets, nft_transfer_snippets, try_response_snippets, + allowance_list_snippets, body_with_allowances_snippets, ft_mint_snippets, ft_transfer_snippets, + match_response_snippets, nft_mint_snippets, nft_transfer_snippets, try_response_snippets, }; use crate::vm::ClarityVersion; @@ -1523,7 +1523,7 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_ft_allowance( - ft_mint in match_response_snippets(ft_mint_snippets()), ft_transfer in try_response_snippets(ft_transfer_snippets()) + ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), ft_transfer in try_response_snippets(ft_transfer_snippets()) ) { let setup_code = format!("{TOKEN_DEFINITIONS} {ft_mint}"); let body_program = format!( @@ -1560,7 +1560,7 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_nft_allowance( - nft_mint in match_response_snippets(nft_mint_snippets()), nft_transfer in try_response_snippets(nft_transfer_snippets()) + nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), nft_transfer in try_response_snippets(nft_transfer_snippets()) ) { let setup_code = format!("{TOKEN_DEFINITIONS} {nft_mint}"); let body_program = format!( @@ -1671,4 +1671,24 @@ proptest! { ) .unwrap(); } + + #[test] + fn prop_as_contract_with_transfers_and_allowances_matches_clarity3( + allowances_and_body in body_with_allowances_snippets(), + ft_mint in ft_mint_snippets("tx-sender".into()), + nft_mint in nft_mint_snippets("tx-sender".into()), + ) { + let (allowances, body) = allowances_and_body; + let snippet = format!("{TOKEN_DEFINITIONS}(as-contract? {allowances} {ft_mint} {nft_mint} {body})"); + let c3_snippet = format!("{TOKEN_DEFINITIONS}(as-contract (begin {ft_mint} {nft_mint} {body}))"); + assert_results_match( + execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3), + execute_and_return_asset_map(&snippet), + |unrestricted_assets, restricted_assets| { + prop_assert_eq!(unrestricted_assets, restricted_assets); + Ok(None) + }, + ) + .unwrap(); + } } diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs index 694cde13d3e..357a77c3031 100644 --- a/clarity/src/vm/tests/proptest_utils.rs +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -285,11 +285,25 @@ pub fn stx_transfer_snippets() -> impl Strategy { }) } +/// A strategy that generates STX transfer snippets with amounts between +/// 1 and 1,000,000 micro-STX and a corresponding allowance to use with +/// `as-contract?` or `restrict-assets?`. +pub fn stx_transfer_and_allowance_snippets() -> impl Strategy { + (1u64..1_000_000u64).prop_flat_map(|amount| { + let transfer_snippet = + format!("(stx-transfer? u{amount} tx-sender 'SP000000000000000000002Q6VF78)"); + (amount as u128..=u128::MAX).prop_map(move |allowance_amount| { + let allowance_snippet = format!("(with-stx u{allowance_amount})"); + (transfer_snippet.clone(), allowance_snippet) + }) + }) +} + /// A strategy that generates FT mint snippets with amounts between /// 1 and 1,000,000 units of the token. The FT contract is always /// `current-contract` and the token name is always `stackos`. -pub fn ft_mint_snippets() -> impl Strategy { - (1u64..1_000_000u64).prop_map(|amount| format!("(ft-mint? stackos u{amount} tx-sender)")) +pub fn ft_mint_snippets(recipient: String) -> impl Strategy { + (1u64..1_000_000u64).prop_map(move |amount| format!("(ft-mint? stackos u{amount} {recipient})")) } /// A strategy that generates FT transfer snippets with amounts between @@ -301,11 +315,27 @@ pub fn ft_transfer_snippets() -> impl Strategy { }) } +/// A strategy that generates FT transfer snippets with amounts between +/// 1 and 1,000,000 units of the token and a corresponding allowance to use with +/// `as-contract?` or `restrict-assets?`. The FT contract is always +/// `current-contract` and the token name is always `stackos`. +pub fn ft_transfer_and_allowance_snippets() -> impl Strategy { + (1u64..1_000_000u64).prop_flat_map(|amount| { + let transfer_snippet = + format!("(ft-transfer? stackos u{amount} tx-sender 'SP000000000000000000002Q6VF78)"); + (amount as u128..=u128::MAX).prop_map(move |allowance_amount| { + let allowance_snippet = + format!("(with-ft current-contract \"stackos\" u{allowance_amount})"); + (transfer_snippet.clone(), allowance_snippet) + }) + }) +} + /// A strategy that generates NFT mint snippets. The NFT contract is always /// `current-contract` and the token name is always `stackaroo`. A random /// `uint` identifier is generated for each snippet. -pub fn nft_mint_snippets() -> impl Strategy { - any::().prop_map(|id| format!("(nft-mint? stackaroo u{id} tx-sender)")) +pub fn nft_mint_snippets(recipient: String) -> impl Strategy { + any::().prop_map(move |id| format!("(nft-mint? stackaroo u{id} {recipient})")) } /// A strategy that generates NFT transfer snippets. The NFT contract is always @@ -317,6 +347,39 @@ pub fn nft_transfer_snippets() -> impl Strategy { }) } +/// A strategy that generates NFT transfer snippets with a corresponding +/// allowance to use with `as-contract?` or `restrict-assets?`. The NFT +/// contract is always `current-contract` and the token name is always +/// `stackaroo`. A random list of u128 IDs is generated for the allowance, then +/// one of those is transferred. +pub fn nft_transfer_and_allowance_snippets() -> impl Strategy { + prop::collection::vec(any::(), 1..=MAX_NFT_IDENTIFIERS as usize).prop_flat_map(|ids| { + let allowance_snippet = format!( + "(with-nft current-contract \"stackaroo\" (list {}))", + ids.iter() + .map(|id| format!("u{id}")) + .collect::>() + .join(" ") + ); + + // Choose one of those ids to transfer + prop::sample::select(ids).prop_map(move |transfer_id| { + let transfer_snippet = format!( + "(nft-transfer? stackaroo u{transfer_id} tx-sender 'SP000000000000000000002Q6VF78)" + ); + (transfer_snippet, allowance_snippet.clone()) + }) + }) +} + +pub fn any_transfer_and_allowance_snippets() -> impl Strategy { + prop_oneof![ + stx_transfer_and_allowance_snippets().boxed(), + ft_transfer_and_allowance_snippets().boxed(), + nft_transfer_and_allowance_snippets().boxed(), + ] +} + /// A strategy that generates a `try!`-wrapped version of the given /// Clarity code snippet generator. This is useful for wrapping expressions /// that return a `Response` type, so that they can be used in contexts @@ -397,6 +460,45 @@ pub fn allowance_list_snippets() -> impl Strategy { }) } +/// A strategy that generates a list of expressions, including STX, FT, and NFT +/// transfers along with allowances that cover any of those transfers. +pub fn body_with_allowances_snippets() -> impl Strategy { + prop::collection::vec(any_transfer_and_allowance_snippets(), 1..8).prop_flat_map( + |transfer_and_allowance_snippets| { + let allowances_snippet = { + let allowances: Vec = transfer_and_allowance_snippets + .iter() + .map(|(_, a)| a.clone()) + .collect(); + format!("({})", allowances.join(" ")) + }; + + // Transfer expressions (wrapped in a match so they don't error) + let transfer_exprs: Vec = transfer_and_allowance_snippets + .into_iter() + .map(|(t, _)| format!("(match {t} v true e false)")) + .collect(); + + // Generate 0..=8 pure value expressions. + prop::collection::vec( + clarity_values_no_response().prop_map(|v| value_to_clarity_literal(&v)), + 0..=3, + ) + .prop_flat_map(move |extra_exprs| { + let mut all = transfer_exprs.clone(); + all.extend(extra_exprs); + Just(all).prop_shuffle().prop_map({ + let allowances_snippet = allowances_snippet.clone(); + move |shuffled| { + let body = shuffled.join(" "); + (allowances_snippet.clone(), body) + } + }) + }) + }, + ) +} + /// A strategy that generates `(begin ...)` expressions containing between /// 1 and 8 expressions, each of which is either a Clarity value literal or /// an STX transfer expression. From 1d49918734030b520442812000bcdb6f7de27f5b Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 22 Oct 2025 17:38:52 -0400 Subject: [PATCH 08/18] test: improve property tests --- clarity/src/vm/tests/post_conditions.rs | 19 +++- clarity/src/vm/tests/proptest_utils.rs | 118 +++++++++++++++++------- 2 files changed, 101 insertions(+), 36 deletions(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 7dc990ae33d..25cee580599 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -1392,7 +1392,10 @@ fn test_restrict_assets_with_error_in_body() { /// Given the results of running a snippet with and without asset restrictions, /// assert that the results match and verify that the asset movements are as -/// expected. `asset_check` is a closure that takes the unrestricted and +/// expected. If `error_allowed` is true, then it's acceptable for both runs to +/// error out with the same error, but if it is false, then only successful +/// runs are allowed. +/// `asset_check` is a closure that takes the unrestricted and /// restricted asset maps and returns a `Result, TestCaseError>`. /// If it returns `Ok(Some(value))`, the test will assert that the restricted /// execution returned that value. If it returns `Ok(None)`, the test will @@ -1403,15 +1406,21 @@ fn assert_results_match( unrestricted_result: InterpreterResult<(Option, AssetMap)>, restricted_result: InterpreterResult<(Option, AssetMap)>, asset_check: F, + error_allowed: bool, ) -> TestCaseResult where F: Fn(&AssetMap, &AssetMap) -> Result, TestCaseError>, { match (unrestricted_result, restricted_result) { - (Err(unrestricted_err), Err(restricted_err)) => { + (Err(unrestricted_err), Err(restricted_err)) if error_allowed => { prop_assert_eq!(unrestricted_err, restricted_err); Ok(()) } + (Err(unrestricted_err), Err(restricted_err)) => { + Err(TestCaseError::fail(format!( + "Both unrestricted and restricted execution failed, but errors are not allowed. Unrestricted error: {unrestricted_err:?}, Restricted error: {restricted_err:?}" + ))) + } (Err(unrestricted_err), Ok((restricted_result, _))) => { let detail = match restricted_result { Some(result_value) => format!( @@ -1517,6 +1526,7 @@ proptest! { Ok(None) } }, + true, ) .unwrap(); } @@ -1554,6 +1564,7 @@ proptest! { Ok(None) } }, + true, ) .unwrap(); } @@ -1592,6 +1603,7 @@ proptest! { Ok(None) } }, + true, ) .unwrap(); } @@ -1651,6 +1663,7 @@ proptest! { Ok(None) } }, + true, ) .unwrap(); } @@ -1668,6 +1681,7 @@ proptest! { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) }, + true, ) .unwrap(); } @@ -1688,6 +1702,7 @@ proptest! { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) }, + false, ) .unwrap(); } diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs index 357a77c3031..997a335c604 100644 --- a/clarity/src/vm/tests/proptest_utils.rs +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -16,6 +16,7 @@ //! This module contains utility functions and strategies for property-based //! testing of the Clarity VM. +use std::collections::BTreeSet; use std::result::Result; use clarity_types::errors::InterpreterResult; @@ -292,7 +293,7 @@ pub fn stx_transfer_and_allowance_snippets() -> impl Strategy impl Strategy impl Strategy { /// A strategy that generates a list of expressions, including STX, FT, and NFT /// transfers along with allowances that cover any of those transfers. pub fn body_with_allowances_snippets() -> impl Strategy { - prop::collection::vec(any_transfer_and_allowance_snippets(), 1..8).prop_flat_map( - |transfer_and_allowance_snippets| { - let allowances_snippet = { - let allowances: Vec = transfer_and_allowance_snippets - .iter() - .map(|(_, a)| a.clone()) - .collect(); - format!("({})", allowances.join(" ")) - }; - - // Transfer expressions (wrapped in a match so they don't error) - let transfer_exprs: Vec = transfer_and_allowance_snippets - .into_iter() - .map(|(t, _)| format!("(match {t} v true e false)")) - .collect(); + #[derive(Clone, Debug)] + enum TransferSpec { + Stx(u128), + Ft(u128), + Nft(u128), + } + + let transfer_spec = prop_oneof![ + (1u64..1_000_000u64) + .prop_map(|amount| TransferSpec::Stx(amount as u128)) + .boxed(), + (1u64..1_000_000u64) + .prop_map(|amount| TransferSpec::Ft(amount as u128)) + .boxed(), + any::().prop_map(TransferSpec::Nft).boxed(), + ]; + + prop::collection::vec(transfer_spec, 1..8).prop_flat_map(|transfer_specs| { + let mut stx_allowance: Option = None; + let mut ft_allowance: Option = None; + let mut nft_ids = BTreeSet::new(); + let mut transfer_exprs = Vec::new(); + + for spec in transfer_specs.iter() { + match spec { + TransferSpec::Stx(amount) => { + stx_allowance = Some(stx_allowance.map_or(*amount, |m| m + *amount)); + transfer_exprs.push(format!( + "(match (stx-transfer? u{amount} tx-sender 'SP000000000000000000002Q6VF78) v true e false)" + )); + } + TransferSpec::Ft(amount) => { + ft_allowance = Some(ft_allowance.map_or(*amount, |m| m + *amount)); + transfer_exprs.push(format!( + "(match (ft-transfer? stackos u{amount} tx-sender 'SP000000000000000000002Q6VF78) v true e false)" + )); + } + TransferSpec::Nft(token_id) => { + nft_ids.insert(*token_id); + transfer_exprs.push(format!( + "(match (nft-transfer? stackaroo u{token_id} tx-sender 'SP000000000000000000002Q6VF78) v true e false)" + )); + } + } + } + + let mut allowances: Vec = Vec::new(); + if let Some(amount) = stx_allowance { + allowances.push(format!("(with-stx u{amount})")); + } + if let Some(amount) = ft_allowance { + allowances.push(format!("(with-ft current-contract \"stackos\" u{amount})")); + } + if !nft_ids.is_empty() { + let ids = nft_ids + .iter() + .map(|id| format!("u{id}")) + .collect::>() + .join(" "); + allowances.push(format!( + "(with-nft current-contract \"stackaroo\" (list {}))", + ids + )); + } + + let allowances_snippet = format!("({})", allowances.join(" ")); - // Generate 0..=8 pure value expressions. - prop::collection::vec( - clarity_values_no_response().prop_map(|v| value_to_clarity_literal(&v)), - 0..=3, - ) - .prop_flat_map(move |extra_exprs| { - let mut all = transfer_exprs.clone(); - all.extend(extra_exprs); - Just(all).prop_shuffle().prop_map({ - let allowances_snippet = allowances_snippet.clone(); - move |shuffled| { - let body = shuffled.join(" "); - (allowances_snippet.clone(), body) - } - }) + prop::collection::vec( + clarity_values_no_response().prop_map(|v| value_to_clarity_literal(&v)), + 0..=8, + ) + .prop_flat_map(move |extra_exprs| { + let mut all = transfer_exprs.clone(); + all.extend(extra_exprs); + Just(all).prop_shuffle().prop_map({ + let allowances_snippet = allowances_snippet.clone(); + move |shuffled| { + let body = shuffled.join(" "); + (allowances_snippet.clone(), body) + } }) - }, - ) + }) + }) } /// A strategy that generates `(begin ...)` expressions containing between From b739d7277f398346d8afa0a469bfcc5e6c8ee58b Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 23 Oct 2025 13:43:57 -0400 Subject: [PATCH 09/18] test: add `restrict-assets?` prop test with allowed transfers --- clarity/src/vm/tests/post_conditions.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 25cee580599..09ffff85ca2 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -1689,8 +1689,8 @@ proptest! { #[test] fn prop_as_contract_with_transfers_and_allowances_matches_clarity3( allowances_and_body in body_with_allowances_snippets(), - ft_mint in ft_mint_snippets("tx-sender".into()), - nft_mint in nft_mint_snippets("tx-sender".into()), + ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), + nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), ) { let (allowances, body) = allowances_and_body; let snippet = format!("{TOKEN_DEFINITIONS}(as-contract? {allowances} {ft_mint} {nft_mint} {body})"); @@ -1706,4 +1706,25 @@ proptest! { ) .unwrap(); } + + #[test] + fn prop_restrict_assets_with_transfers_and_allowances_ok( + allowances_and_body in body_with_allowances_snippets(), + ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), + nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), + ) { + let (allowances, body) = allowances_and_body; + let snippet = format!("{TOKEN_DEFINITIONS}(restrict-assets? tx-sender {allowances} {ft_mint} {nft_mint} {body})"); + let simple_snippet = format!("{TOKEN_DEFINITIONS}(begin {ft_mint} {nft_mint} {body})"); + assert_results_match( + execute_and_return_asset_map_versioned(&simple_snippet, ClarityVersion::Clarity3), + execute_and_return_asset_map(&snippet), + |unrestricted_assets, restricted_assets| { + prop_assert_eq!(unrestricted_assets, restricted_assets); + Ok(None) + }, + false, + ) + .unwrap(); + } } From c8ec1e76e9e038b1815bae7bc84772acdf1f1e70 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 23 Oct 2025 15:52:58 -0400 Subject: [PATCH 10/18] test: use proptest generated sender when applicable --- clarity/src/vm/mod.rs | 8 ++- clarity/src/vm/tests/post_conditions.rs | 75 ++++++++++++++----------- clarity/src/vm/tests/proptest_utils.rs | 42 +++++++++++--- 3 files changed, 83 insertions(+), 42 deletions(-) diff --git a/clarity/src/vm/mod.rs b/clarity/src/vm/mod.rs index 1b670fcc059..03588fbd2c1 100644 --- a/clarity/src/vm/mod.rs +++ b/clarity/src/vm/mod.rs @@ -520,6 +520,7 @@ pub fn execute_with_parameters_and_call_in_global_context( clarity_version: ClarityVersion, epoch: StacksEpochId, use_mainnet: bool, + sender: clarity_types::types::StandardPrincipalData, mut global_context_function: F, ) -> Result> where @@ -529,7 +530,7 @@ where use crate::vm::tests::test_only_mainnet_to_chain_id; use crate::vm::types::QualifiedContractIdentifier; - let contract_id = QualifiedContractIdentifier::transient(); + let contract_id = QualifiedContractIdentifier::new(sender, "contract".into()); let mut contract_context = ContractContext::new(contract_id.clone(), clarity_version); let mut marf = MemoryBackingStore::new(); let conn = marf.as_clarity_db(); @@ -557,6 +558,7 @@ pub fn execute_call_in_global_context_and_return_asset_map( clarity_version: ClarityVersion, epoch: StacksEpochId, use_mainnet: bool, + sender: clarity_types::types::StandardPrincipalData, mut global_context_function: F, ) -> Result<(Option, crate::vm::contexts::AssetMap)> where @@ -566,7 +568,7 @@ where use crate::vm::tests::test_only_mainnet_to_chain_id; use crate::vm::types::QualifiedContractIdentifier; - let contract_id = QualifiedContractIdentifier::transient(); + let contract_id = QualifiedContractIdentifier::new(sender, "contract".into()); let mut contract_context = ContractContext::new(contract_id.clone(), clarity_version); let mut marf = MemoryBackingStore::new(); let conn = marf.as_clarity_db(); @@ -599,6 +601,7 @@ pub fn execute_with_parameters( clarity_version, epoch, use_mainnet, + clarity_types::types::StandardPrincipalData::transient(), |_| Ok(()), ) } @@ -631,6 +634,7 @@ pub fn execute_with_limited_execution_time( ClarityVersion::Clarity1, StacksEpochId::Epoch20, false, + clarity_types::types::StandardPrincipalData::transient(), |g| { g.set_max_execution_time(max_execution_time); Ok(()) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 09ffff85ca2..17c96cc595f 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -20,22 +20,19 @@ use std::convert::TryFrom; use clarity_types::errors::{Error as ClarityError, InterpreterResult, ShortReturnType}; -use clarity_types::types::{ - AssetIdentifier, PrincipalData, QualifiedContractIdentifier, StandardPrincipalData, -}; +use clarity_types::types::{AssetIdentifier, PrincipalData, QualifiedContractIdentifier}; use clarity_types::{ClarityName, Value}; use proptest::prelude::*; use proptest::test_runner::{TestCaseError, TestCaseResult}; -use super::proptest_utils::{ - begin_block, clarity_values_no_response, execute, execute_and_return_asset_map, - execute_and_return_asset_map_versioned, value_to_clarity_literal, -}; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::contexts::AssetMap; use crate::vm::tests::proptest_utils::{ - allowance_list_snippets, body_with_allowances_snippets, ft_mint_snippets, ft_transfer_snippets, - match_response_snippets, nft_mint_snippets, nft_transfer_snippets, try_response_snippets, + allowance_list_snippets, begin_block, body_with_allowances_snippets, + clarity_values_no_response, execute, execute_and_return_asset_map, + execute_and_return_asset_map_versioned, ft_mint_snippets, ft_transfer_snippets, + match_response_snippets, nft_mint_snippets, nft_transfer_snippets, + testnet_standard_principal_strategy, try_response_snippets, value_to_clarity_literal, }; use crate::vm::ClarityVersion; @@ -1509,15 +1506,16 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_allowances_and_body_moves_stx( + sender in testnet_standard_principal_strategy(), body in begin_block(), ) { let snippet = format!("(restrict-assets? tx-sender () {body})"); + let sender_principal = sender.clone().into(); assert_results_match( - execute_and_return_asset_map(&body), - execute_and_return_asset_map(&snippet), + execute_and_return_asset_map(&body, sender.clone()), + execute_and_return_asset_map(&snippet, sender), |unrestricted_assets, restricted_assets| { - let sender = PrincipalData::Standard(StandardPrincipalData::transient()); - let stx_moved = unrestricted_assets.get_stx(&sender).unwrap_or(0); + let stx_moved = unrestricted_assets.get_stx(&sender_principal).unwrap_or(0); if stx_moved > 0 { prop_assert_eq!(&AssetMap::new(), restricted_assets); Ok(Some(no_allowance_error())) @@ -1533,6 +1531,7 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_ft_allowance( + sender in testnet_standard_principal_strategy(), ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), ft_transfer in try_response_snippets(ft_transfer_snippets()) ) { let setup_code = format!("{TOKEN_DEFINITIONS} {ft_mint}"); @@ -1542,19 +1541,22 @@ proptest! { let wrapper_program = format!( "{setup_code} (restrict-assets? tx-sender () {ft_transfer})", ); + let sender_principal = sender.clone().into(); let asset_identifier = AssetIdentifier { - contract_identifier: QualifiedContractIdentifier::transient(), + contract_identifier: QualifiedContractIdentifier::new( + sender.clone(), + "contract".into(), + ), asset_name: ClarityName::try_from("stackos".to_string()) .expect("valid fungible token name"), }; assert_results_match( - execute_and_return_asset_map(&body_program), - execute_and_return_asset_map(&wrapper_program), + execute_and_return_asset_map(&body_program, sender.clone()), + execute_and_return_asset_map(&wrapper_program, sender), move |unrestricted_assets, restricted_assets| { - let sender = PrincipalData::Standard(StandardPrincipalData::transient()); let moved = unrestricted_assets - .get_fungible_tokens(&sender, &asset_identifier) + .get_fungible_tokens(&sender_principal, &asset_identifier) .unwrap_or(0); if moved > 0 { prop_assert_eq!(&AssetMap::new(), restricted_assets); @@ -1571,6 +1573,7 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_nft_allowance( + sender in testnet_standard_principal_strategy(), nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), nft_transfer in try_response_snippets(nft_transfer_snippets()) ) { let setup_code = format!("{TOKEN_DEFINITIONS} {nft_mint}"); @@ -1580,19 +1583,22 @@ proptest! { let wrapper_program = format!( "{setup_code} (restrict-assets? tx-sender () {nft_transfer})", ); + let sender_principal = sender.clone().into(); let asset_identifier = AssetIdentifier { - contract_identifier: QualifiedContractIdentifier::transient(), + contract_identifier: QualifiedContractIdentifier::new( + sender.clone(), + "contract".into(), + ), asset_name: ClarityName::try_from("stackaroo".to_string()) .expect("valid non-fungible token name"), }; assert_results_match( - execute_and_return_asset_map(&body_program), - execute_and_return_asset_map(&wrapper_program), + execute_and_return_asset_map(&body_program, sender.clone()), + execute_and_return_asset_map(&wrapper_program, sender), move |unrestricted_assets, restricted_assets| { - let sender = PrincipalData::Standard(StandardPrincipalData::transient()); let moved = unrestricted_assets - .get_nonfungible_tokens(&sender, &asset_identifier) + .get_nonfungible_tokens(&sender_principal, &asset_identifier) .map(|l| l.len()) .unwrap_or(0); if moved > 0 { @@ -1645,15 +1651,17 @@ proptest! { #[test] fn prop_as_contract_errors_when_no_allowances_and_body_moves_stx( + sender in testnet_standard_principal_strategy(), body in begin_block(), ) { let snippet = format!("(as-contract? () {body})"); let c3_snippet = format!("(as-contract {body})"); + let contract_id = QualifiedContractIdentifier::new(sender.clone(), "contract".into()); + let contract = PrincipalData::Contract(contract_id); assert_results_match( - execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3), - execute_and_return_asset_map(&snippet), + execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3, sender.clone()), + execute_and_return_asset_map(&snippet, sender), |unrestricted_assets, restricted_assets| { - let contract = PrincipalData::Contract(QualifiedContractIdentifier::transient()); let stx_moved = unrestricted_assets.get_stx(&contract).unwrap_or(0); if stx_moved > 0 { prop_assert_eq!(&AssetMap::new(), restricted_assets); @@ -1670,13 +1678,14 @@ proptest! { #[test] fn prop_as_contract_with_all_assets_unsafe_matches_clarity3( + sender in testnet_standard_principal_strategy(), body in begin_block(), ) { let snippet = format!("(as-contract? ((with-all-assets-unsafe)) {body})"); let c3_snippet = format!("(as-contract {body})"); assert_results_match( - execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3), - execute_and_return_asset_map(&snippet), + execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3, sender.clone()), + execute_and_return_asset_map(&snippet, sender), |unrestricted_assets, restricted_assets| { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) @@ -1688,6 +1697,7 @@ proptest! { #[test] fn prop_as_contract_with_transfers_and_allowances_matches_clarity3( + sender in testnet_standard_principal_strategy(), allowances_and_body in body_with_allowances_snippets(), ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), @@ -1696,8 +1706,8 @@ proptest! { let snippet = format!("{TOKEN_DEFINITIONS}(as-contract? {allowances} {ft_mint} {nft_mint} {body})"); let c3_snippet = format!("{TOKEN_DEFINITIONS}(as-contract (begin {ft_mint} {nft_mint} {body}))"); assert_results_match( - execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3), - execute_and_return_asset_map(&snippet), + execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3, sender.clone()), + execute_and_return_asset_map(&snippet, sender), |unrestricted_assets, restricted_assets| { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) @@ -1709,6 +1719,7 @@ proptest! { #[test] fn prop_restrict_assets_with_transfers_and_allowances_ok( + sender in testnet_standard_principal_strategy(), allowances_and_body in body_with_allowances_snippets(), ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), @@ -1717,8 +1728,8 @@ proptest! { let snippet = format!("{TOKEN_DEFINITIONS}(restrict-assets? tx-sender {allowances} {ft_mint} {nft_mint} {body})"); let simple_snippet = format!("{TOKEN_DEFINITIONS}(begin {ft_mint} {nft_mint} {body})"); assert_results_match( - execute_and_return_asset_map_versioned(&simple_snippet, ClarityVersion::Clarity3), - execute_and_return_asset_map(&snippet), + execute_and_return_asset_map_versioned(&simple_snippet, ClarityVersion::Clarity3, sender.clone()), + execute_and_return_asset_map(&snippet, sender), |unrestricted_assets, restricted_assets| { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs index 997a335c604..2b47b21a227 100644 --- a/clarity/src/vm/tests/proptest_utils.rs +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -30,6 +30,8 @@ use proptest::collection::vec; use proptest::prelude::*; use proptest::strategy::BoxedStrategy; use proptest::string::string_regex; +use stacks_common::address::C32_ADDRESS_VERSION_TESTNET_SINGLESIG; +use stacks_common::types::chainstate::StacksPrivateKey; use stacks_common::types::StacksEpochId; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::{ @@ -47,10 +49,15 @@ const DEFAULT_EPOCH: StacksEpochId = StacksEpochId::Epoch33; const DEFAULT_CLARITY_VERSION: ClarityVersion = ClarityVersion::Clarity4; const INITIAL_BALANCE: u128 = 1_000_000; -fn initialize_balances(g: &mut GlobalContext) -> Result<(), VmError> { - let sender_principal = PrincipalData::Standard(StandardPrincipalData::transient()); - let contract_id = QualifiedContractIdentifier::transient(); - let contract_principal = PrincipalData::Contract(contract_id); +fn initialize_balances( + g: &mut GlobalContext, + sender: &StandardPrincipalData, +) -> Result<(), VmError> { + let sender_principal = PrincipalData::Standard(sender.clone()); + let contract_principal = PrincipalData::Contract(QualifiedContractIdentifier::new( + sender.clone(), + "contract".into(), + )); let balance = STXBalance::initial(INITIAL_BALANCE); let mut sender_snapshot = g @@ -85,20 +92,28 @@ pub fn execute_versioned( snippet: &str, version: ClarityVersion, ) -> InterpreterResult> { + let sender_pk = StacksPrivateKey::random(); + let sender: StandardPrincipalData = (&sender_pk).into(); + let contract_id = QualifiedContractIdentifier::new(sender.clone(), "contract".into()); + let sender_for_init = sender.clone(); execute_with_parameters_and_call_in_global_context( snippet, version, DEFAULT_EPOCH, false, - initialize_balances, + sender, + move |g| initialize_balances(g, &sender_for_init), ) } /// Execute a Clarity code snippet in a fresh global context with default /// parameters, setting up initial balances, returning the resulting value /// along with the final asset map. -pub fn execute_and_return_asset_map(snippet: &str) -> InterpreterResult<(Option, AssetMap)> { - execute_and_return_asset_map_versioned(snippet, DEFAULT_CLARITY_VERSION) +pub fn execute_and_return_asset_map( + snippet: &str, + sender: StandardPrincipalData, +) -> InterpreterResult<(Option, AssetMap)> { + execute_and_return_asset_map_versioned(snippet, DEFAULT_CLARITY_VERSION, sender) } /// Execute a Clarity code snippet with the specified Clarity version in a @@ -107,13 +122,17 @@ pub fn execute_and_return_asset_map(snippet: &str) -> InterpreterResult<(Option< pub fn execute_and_return_asset_map_versioned( snippet: &str, version: ClarityVersion, + sender: StandardPrincipalData, ) -> InterpreterResult<(Option, AssetMap)> { + let contract_id = QualifiedContractIdentifier::new(sender.clone(), "contract".into()); + let sender_for_init = sender.clone(); execute_call_in_global_context_and_return_asset_map( snippet, version, DEFAULT_EPOCH, false, - initialize_balances, + sender, + move |g| initialize_balances(g, &sender_for_init), ) } @@ -634,3 +653,10 @@ pub fn utf8_string_literal(data: &UTF8Data) -> String { literal.push('"'); literal } + +/// A strategy that generates a random StandardPrincipalData +pub fn testnet_standard_principal_strategy() -> impl Strategy { + (uniform20(any::())).prop_filter_map("Invalid standard principal", |bytes| { + StandardPrincipalData::new(C32_ADDRESS_VERSION_TESTNET_SINGLESIG, bytes).ok() + }) +} From 0051afe3a12dcca73ff8fc117242272e3ee2179a Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Fri, 24 Oct 2025 09:51:56 -0400 Subject: [PATCH 11/18] test: add more unit tests for contract post-conditions --- clarity/src/vm/tests/post_conditions.rs | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 17c96cc595f..6adac777821 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -1385,6 +1385,56 @@ fn test_restrict_assets_with_error_in_body() { assert_eq!(short_return, execute(snippet).unwrap_err()); } +#[test] +fn test_restrict_assets_with_receiving_principal() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? 'SP000000000000000000002Q6VF78 () + (try! (stx-transfer? u10 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_other_principal() { + let snippet = r#" +(let ((recipient 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5)) + (restrict-assets? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM () + (try! (stx-transfer? u10 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_nested_outer_restrict_assets_with_stx_exceeds() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u10)) + (try! (restrict-assets? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM () + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) + )) +)"#; + let expected = Value::error(Value::UInt(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_nested_inner_restrict_assets_with_stx_exceeds() { + let snippet = r#" +(restrict-assets? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM () + (try! (restrict-assets? tx-sender ((with-stx u10)) + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) + )) +)"#; + let expected_err = Value::error(Value::UInt(0)).unwrap(); + let short_return = + ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected_err.into())); + assert_eq!(short_return, execute(snippet).unwrap_err()); +} + // ---------- Property Tests ---------- /// Given the results of running a snippet with and without asset restrictions, From 279d963a992ea6b7ff56e076d6d6719a27c72b53 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Fri, 24 Oct 2025 16:09:12 -0400 Subject: [PATCH 12/18] test: add property tests for `to-ascii?` --- clarity/src/vm/tests/conversions.rs | 146 +++++++++++++++++++++ clarity/src/vm/tests/post_conditions.rs | 18 +-- clarity/src/vm/tests/proptest_utils.rs | 160 ++++++++++++++++++++++-- 3 files changed, 306 insertions(+), 18 deletions(-) diff --git a/clarity/src/vm/tests/conversions.rs b/clarity/src/vm/tests/conversions.rs index ba8fccd6a29..85a3146beb0 100644 --- a/clarity/src/vm/tests/conversions.rs +++ b/clarity/src/vm/tests/conversions.rs @@ -15,9 +15,15 @@ // along with this program. If not, see . use clarity_types::types::MAX_TO_ASCII_BUFFER_LEN; +use proptest::prelude::*; use stacks_common::types::StacksEpochId; pub use crate::vm::analysis::errors::CheckErrors; +use crate::vm::tests::proptest_utils::{ + contract_name_strategy, execute_versioned, standard_principal_strategy, + to_ascii_buffer_snippet_strategy, utf8_string_ascii_only_snippet_strategy, + utf8_string_snippet_strategy, +}; use crate::vm::tests::test_clarity_versions; use crate::vm::types::SequenceSubtype::BufferType; use crate::vm::types::TypeSignature::SequenceType; @@ -589,3 +595,143 @@ fn test_to_ascii(version: ClarityVersion, epoch: StacksEpochId) { // This should fail at analysis time since the value is too big assert!(result.is_err()); } + +fn evaluate_to_ascii(snippet: &str) -> Value { + execute_versioned(snippet, ClarityVersion::Clarity4) + .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) + .unwrap_or_else(|| panic!("Execution returned no value for snippet `{snippet}`")) +} + +proptest! { + #[test] + fn prop_to_ascii_from_ints(int_value in any::()) { + let snippet = format!("(to-ascii? {int_value})"); + let evaluation = evaluate_to_ascii(&snippet); + + let expected_inner = Value::string_ascii_from_bytes(int_value.to_string().into_bytes()) + .expect("int string should be valid ASCII"); + let expected = Value::okay(expected_inner).expect("response wrapping should succeed"); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_to_ascii_from_uints(uint_value in any::()) { + let snippet = format!("(to-ascii? u{uint_value})"); + let evaluation = evaluate_to_ascii(&snippet); + + let expected_inner = Value::string_ascii_from_bytes(format!("u{uint_value}").into_bytes()) + .expect("uint string should be valid ASCII"); + let expected = Value::okay(expected_inner).expect("response wrapping should succeed"); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_to_ascii_from_bools(bool_value in any::()) { + let literal = if bool_value { "true" } else { "false" }; + let snippet = format!("(to-ascii? {literal})"); + let evaluation = evaluate_to_ascii(&snippet); + + let expected_inner = Value::string_ascii_from_bytes(literal.as_bytes().to_vec()) + .expect("bool string should be valid ASCII"); + let expected = Value::okay(expected_inner).expect("response wrapping should succeed"); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_to_ascii_from_standard_principals(principal in standard_principal_strategy()) { + let literal = format!("'{}", principal); + let snippet = format!("(to-ascii? {literal})"); + let evaluation = evaluate_to_ascii(&snippet); + + let expected_inner = Value::string_ascii_from_bytes(principal.to_string().into_bytes()) + .expect("principal string should be valid ASCII"); + let expected = Value::okay(expected_inner).expect("response wrapping should succeed"); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_to_ascii_from_contract_principals( + issuer in standard_principal_strategy(), + contract_name in contract_name_strategy(), + ) { + let contract_name_str = contract_name.to_string(); + let literal = format!("'{}.{}", issuer, contract_name_str); + let snippet = format!("(to-ascii? {literal})"); + let evaluation = evaluate_to_ascii(&snippet); + + let expected_inner = Value::string_ascii_from_bytes( + format!("{}.{}", issuer, contract_name_str).into_bytes() + ) + .expect("contract principal string should be valid ASCII"); + let expected = Value::okay(expected_inner).expect("response wrapping should succeed"); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_to_ascii_from_buffers(buffer in to_ascii_buffer_snippet_strategy()) { + let snippet = format!("(to-ascii? {buffer})"); + let evaluation = evaluate_to_ascii(&snippet); + + let expected_inner = Value::string_ascii_from_bytes(buffer.to_string().into_bytes()) + .expect("buffer string should be valid ASCII"); + let expected = Value::okay(expected_inner).expect("response wrapping should succeed"); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_to_ascii_from_ascii_utf8_strings(utf8_string in utf8_string_ascii_only_snippet_strategy()) { + let snippet = format!("(to-ascii? {utf8_string})"); + let evaluation = evaluate_to_ascii(&snippet); + + let ascii_snippet = &utf8_string[1..]; // Remove the u prefix + let expected_inner = execute_versioned(ascii_snippet, ClarityVersion::Clarity4) + .unwrap_or_else(|e| panic!("Execution failed for `{ascii_snippet}`: {e:?}")) + .unwrap_or_else(|| panic!("Execution returned no value for `{ascii_snippet}`")); + let expected = Value::okay(expected_inner).expect("response wrapping should succeed"); + + prop_assert_eq!(expected, evaluation); + } + + #[test] + fn prop_to_ascii_from_utf8_strings(utf8_string in utf8_string_snippet_strategy()) { + let snippet = format!("(to-ascii? {utf8_string})"); + let evaluation = evaluate_to_ascii(&snippet); + + let literal_value = execute_versioned(&utf8_string, ClarityVersion::Clarity4) + .unwrap_or_else(|e| panic!("Execution failed for literal `{utf8_string}`: {e:?}")) + .unwrap_or_else(|| panic!("Execution returned no value for literal `{utf8_string}`")); + + let utf8_chars = match &literal_value { + Value::Sequence(SequenceData::String(CharType::UTF8(data))) => data.data.clone(), + _ => panic!("Expected UTF-8 string literal, got `{literal_value:?}`"), + }; + let is_ascii = utf8_chars + .iter() + .all(|char_bytes| char_bytes.len() == 1 && char_bytes[0].is_ascii()); + + if is_ascii { + let ascii_bytes: Vec = utf8_chars + .iter() + .map(|char_bytes| char_bytes[0]) + .collect(); + match Value::string_ascii_from_bytes(ascii_bytes) { + Ok(expected_inner) => { + let expected = Value::okay(expected_inner) + .expect("response wrapping should succeed"); + prop_assert_eq!(expected, evaluation); + } + Err(_) => { + prop_assert_eq!(Value::err_uint(1), evaluation); + } + } + } else { + prop_assert_eq!(Value::err_uint(1), evaluation); + } + } +} diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 35eddbea084..c71c7b27e4b 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -31,8 +31,8 @@ use crate::vm::tests::proptest_utils::{ allowance_list_snippets, begin_block, body_with_allowances_snippets, clarity_values_no_response, execute, execute_and_return_asset_map, execute_and_return_asset_map_versioned, ft_mint_snippets, ft_transfer_snippets, - match_response_snippets, nft_mint_snippets, nft_transfer_snippets, - testnet_standard_principal_strategy, try_response_snippets, value_to_clarity_literal, + match_response_snippets, nft_mint_snippets, nft_transfer_snippets, standard_principal_strategy, + try_response_snippets, value_to_clarity_literal, }; use crate::vm::ClarityVersion; @@ -1592,7 +1592,7 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_allowances_and_body_moves_stx( - sender in testnet_standard_principal_strategy(), + sender in standard_principal_strategy(), body in begin_block(), ) { let snippet = format!("(restrict-assets? tx-sender () {body})"); @@ -1617,7 +1617,7 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_ft_allowance( - sender in testnet_standard_principal_strategy(), + sender in standard_principal_strategy(), ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), ft_transfer in try_response_snippets(ft_transfer_snippets()) ) { let setup_code = format!("{TOKEN_DEFINITIONS} {ft_mint}"); @@ -1659,7 +1659,7 @@ proptest! { #[test] fn prop_restrict_assets_errors_when_no_nft_allowance( - sender in testnet_standard_principal_strategy(), + sender in standard_principal_strategy(), nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), nft_transfer in try_response_snippets(nft_transfer_snippets()) ) { let setup_code = format!("{TOKEN_DEFINITIONS} {nft_mint}"); @@ -1737,7 +1737,7 @@ proptest! { #[test] fn prop_as_contract_errors_when_no_allowances_and_body_moves_stx( - sender in testnet_standard_principal_strategy(), + sender in standard_principal_strategy(), body in begin_block(), ) { let snippet = format!("(as-contract? () {body})"); @@ -1764,7 +1764,7 @@ proptest! { #[test] fn prop_as_contract_with_all_assets_unsafe_matches_clarity3( - sender in testnet_standard_principal_strategy(), + sender in standard_principal_strategy(), body in begin_block(), ) { let snippet = format!("(as-contract? ((with-all-assets-unsafe)) {body})"); @@ -1783,7 +1783,7 @@ proptest! { #[test] fn prop_as_contract_with_transfers_and_allowances_matches_clarity3( - sender in testnet_standard_principal_strategy(), + sender in standard_principal_strategy(), allowances_and_body in body_with_allowances_snippets(), ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), @@ -1805,7 +1805,7 @@ proptest! { #[test] fn prop_restrict_assets_with_transfers_and_allowances_ok( - sender in testnet_standard_principal_strategy(), + sender in standard_principal_strategy(), allowances_and_body in body_with_allowances_snippets(), ft_mint in match_response_snippets(ft_mint_snippets("tx-sender".into())), nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs index 2b47b21a227..792c6efd2b6 100644 --- a/clarity/src/vm/tests/proptest_utils.rs +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -22,7 +22,7 @@ use std::result::Result; use clarity_types::errors::InterpreterResult; use clarity_types::types::{ CharType, PrincipalData, QualifiedContractIdentifier, SequenceData, StandardPrincipalData, - TypeSignature, UTF8Data, + TypeSignature, UTF8Data, MAX_TO_ASCII_BUFFER_LEN, MAX_UTF8_VALUE_SIZE, MAX_VALUE_SIZE, }; use clarity_types::{ContractName, Value}; use proptest::array::uniform20; @@ -30,9 +30,9 @@ use proptest::collection::vec; use proptest::prelude::*; use proptest::strategy::BoxedStrategy; use proptest::string::string_regex; -use stacks_common::address::C32_ADDRESS_VERSION_TESTNET_SINGLESIG; use stacks_common::types::chainstate::StacksPrivateKey; use stacks_common::types::StacksEpochId; +use stacks_common::util::hash::to_hex; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::{ MAX_ALLOWANCES, MAX_NFT_IDENTIFIERS, @@ -48,6 +48,8 @@ use crate::vm::{ const DEFAULT_EPOCH: StacksEpochId = StacksEpochId::Epoch33; const DEFAULT_CLARITY_VERSION: ClarityVersion = ClarityVersion::Clarity4; const INITIAL_BALANCE: u128 = 1_000_000; +const UTF8_SNIPPET_MAX_SEGMENTS: usize = 16; +const UTF8_SIMPLE_ESCAPES: [&str; 6] = ["\\\"", "\\\\", "\\n", "\\t", "\\r", "\\0"]; fn initialize_balances( g: &mut GlobalContext, @@ -136,6 +138,153 @@ pub fn execute_and_return_asset_map_versioned( ) } +/// A strategy that generates valid Clarity contract names. +pub fn contract_name_strategy() -> BoxedStrategy { + prop_oneof![ + string_regex("[a-tv-z][a-z0-9-?!]{0,39}").unwrap(), + string_regex("u[a-z-?!][a-z0-9-?!]{0,38}").unwrap(), + ] + .prop_filter_map("Invalid contract name", |name| { + ContractName::try_from(name).ok() + }) + .boxed() +} + +/// A strategy that generates `uint` snippets +pub fn uint_snippet_strategy() -> impl Strategy { + any::().prop_map(|value| format!("u{value}")) +} + +/// A strategy that generates `int` snippets +pub fn int_snippet_strategy() -> impl Strategy { + any::().prop_map(|value| value.to_string()) +} + +/// A strategy that generates `bool` snippets +pub fn bool_snippet_strategy() -> impl Strategy { + any::().prop_map(|value| value.to_string()) +} + +/// A strategy that generates standard `principal`s +/// The version is restricted to those currently valid: 20, 21, 22, and 26 +pub fn standard_principal_strategy() -> impl Strategy { + ( + prop::sample::select(&[20u8, 21u8, 22u8, 26u8]), + uniform20(any::()), + ) + .prop_filter_map("Invalid standard principal", |(version, bytes)| { + StandardPrincipalData::new(version, bytes).ok() + }) +} + +/// A strategy that generates standard `principal` snippets +pub fn standard_principal_snippet_strategy() -> impl Strategy { + standard_principal_strategy().prop_map(|principal| format!("'{principal}")) +} + +/// A strategy that generates contract `principal` snippets +pub fn contract_principal_snippet_strategy() -> impl Strategy { + (standard_principal_strategy(), contract_name_strategy()).prop_map(|(issuer, name)| { + let contract_id = QualifiedContractIdentifier::new(issuer, name); + format!("'{contract_id}") + }) +} + +/// A strategy that generates `principal` snippets, either standard or contract +pub fn principal_snippet_strategy() -> impl Strategy { + prop_oneof![ + standard_principal_snippet_strategy().boxed(), + contract_principal_snippet_strategy().boxed(), + ] +} + +/// A strategy that generates `buff` snippets +pub fn buffer_snippet_strategy() -> impl Strategy { + vec(any::(), 0..MAX_VALUE_SIZE as usize).prop_map(|bytes| { + let hex = to_hex(&bytes); + format!("0x{hex}") + }) +} + +pub fn to_ascii_buffer_snippet_strategy() -> impl Strategy { + vec(any::(), 0..=MAX_TO_ASCII_BUFFER_LEN as usize).prop_map(|bytes| { + let hex = to_hex(&bytes); + format!("0x{hex}") + }) +} + +/// A strategy that generates ASCII snippets +pub fn ascii_string_snippet_strategy() -> impl Strategy { + string_regex(&format!( + r#"(?x) + " # opening quote + (?: # body: zero or more of... + [\x20\x21\x23-\x5B\x5D-\x7E] # printable ASCII except " and \ + | \\[\\"ntr] # valid escape sequences + ){{0,{}}} # up to MAX_VALUE_SIZE + " # closing quote + "#, + MAX_VALUE_SIZE + )) + .unwrap() +} + +/// A strategy that generates UTF8 snippets that only contain ASCII characters +pub fn utf8_string_ascii_only_snippet_strategy() -> impl Strategy { + string_regex(&format!( + r#"(?x) + u" # opening quote + (?: # body: zero or more of... + [\x20\x21\x23-\x5B\x5D-\x7E] # printable ASCII except " and \ + | \\[\\"ntr] # valid escape sequences + ){{0,{}}} # up to MAX_UTF8_VALUE_SIZE + " # closing quote + "#, + MAX_UTF8_VALUE_SIZE + )) + .unwrap() +} + +/// A strategy that generates UTF-8 string snippets +pub fn utf8_string_snippet_strategy() -> impl Strategy { + let ascii_chars: Vec = (0x20u8..=0x7E) + .filter(|byte| *byte != b'"' && *byte != b'\\') + .map(|byte| byte as char) + .collect(); + + let ascii_char_segment = prop::sample::select(ascii_chars).prop_map(|ch| ch.to_string()); + + let simple_escape_segment = + prop::sample::select(&UTF8_SIMPLE_ESCAPES).prop_map(|escape| escape.to_string()); + + let unicode_escape_segment = + proptest::char::any().prop_map(|ch| format!("\\u{{{:X}}}", ch as u32)); + + vec( + prop_oneof![ + ascii_char_segment, + simple_escape_segment, + unicode_escape_segment, + ], + 0..=MAX_UTF8_VALUE_SIZE as usize, + ) + .prop_map(|segments: Vec| format!("u\"{}\"", segments.concat())) +} + +/// A strategy that generates simple Clarity value snippets +/// including uint, int, bool, principal, buffer, and string types. +pub fn simple_value_snippet_strategy() -> impl Strategy { + prop_oneof![ + uint_snippet_strategy().boxed(), + int_snippet_strategy().boxed(), + bool_snippet_strategy().boxed(), + principal_snippet_strategy().boxed(), + buffer_snippet_strategy().boxed(), + ascii_string_snippet_strategy().boxed(), + utf8_string_snippet_strategy().boxed(), + ] +} + /// A strategy that generates Clarity values. pub fn clarity_values() -> BoxedStrategy { clarity_values_inner(true) @@ -653,10 +802,3 @@ pub fn utf8_string_literal(data: &UTF8Data) -> String { literal.push('"'); literal } - -/// A strategy that generates a random StandardPrincipalData -pub fn testnet_standard_principal_strategy() -> impl Strategy { - (uniform20(any::())).prop_filter_map("Invalid standard principal", |bytes| { - StandardPrincipalData::new(C32_ADDRESS_VERSION_TESTNET_SINGLESIG, bytes).ok() - }) -} From aa935ad0799bdef2877610b32dcd02f53248814e Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 27 Oct 2025 21:00:36 -0400 Subject: [PATCH 13/18] fix: revert post-condition optimization for short return --- clarity/src/vm/functions/post_conditions.rs | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index 4b94a18c449..9786b653eb5 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -244,13 +244,6 @@ pub fn special_restrict_assets( Ok(last_result) })(); - // If there was a runtime error, pass it up immediately. We will roll back - // any way, so no need to check allowances. - if let Err(runtime_err) = eval_result { - env.global_context.roll_back()?; - return Err(runtime_err); - } - let asset_maps = env.global_context.get_readonly_asset_map()?; // If the allowances are violated: @@ -281,8 +274,7 @@ pub fn special_restrict_assets( Err(InterpreterError::Expect("Failed to get body result".into()).into()) } Err(e) => { - // Runtime error inside body, pass it up (but this should have been - // caught already above) + // Runtime error inside body, pass it up Err(e) } } @@ -346,13 +338,6 @@ pub fn special_as_contract( Ok(last_result) })(); - // If there was a runtime error, pass it up immediately. We will roll back - // any way, so no need to check allowances. - if let Err(runtime_err) = eval_result { - nested_env.global_context.roll_back()?; - return Err(runtime_err); - } - let asset_maps = nested_env.global_context.get_readonly_asset_map()?; // If the allowances are violated: @@ -383,8 +368,7 @@ pub fn special_as_contract( Err(InterpreterError::Expect("Failed to get body result".into()).into()) } Err(e) => { - // Runtime error inside body, pass it up (but this should have been - // caught already above) + // Runtime error inside body, pass it up Err(e) } } From 557f5da8d751754830a7770eef69a05422870f52 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 27 Oct 2025 21:08:35 -0400 Subject: [PATCH 14/18] test: refactor and clean up tests --- clarity/src/vm/mod.rs | 56 ++----- clarity/src/vm/tests/post_conditions.rs | 213 ++++++++++++++++-------- clarity/src/vm/tests/proptest_utils.rs | 32 ++-- 3 files changed, 178 insertions(+), 123 deletions(-) diff --git a/clarity/src/vm/mod.rs b/clarity/src/vm/mod.rs index 03588fbd2c1..cb13790e523 100644 --- a/clarity/src/vm/mod.rs +++ b/clarity/src/vm/mod.rs @@ -513,18 +513,21 @@ pub fn execute_on_network(program: &str, use_mainnet: bool) -> Result( +pub fn execute_with_parameters_and_call_in_global_context( program: &str, clarity_version: ClarityVersion, epoch: StacksEpochId, use_mainnet: bool, sender: clarity_types::types::StandardPrincipalData, - mut global_context_function: F, + mut before_function: F, + mut after_function: G, ) -> Result> where F: FnMut(&mut GlobalContext) -> Result<()>, + G: FnMut(&mut GlobalContext) -> Result<()>, { use crate::vm::database::MemoryBackingStore; use crate::vm::tests::test_only_mainnet_to_chain_id; @@ -543,49 +546,12 @@ where epoch, ); global_context.execute(|g| { - global_context_function(g)?; + before_function(g)?; let parsed = ast::build_ast(&contract_id, program, &mut (), clarity_version, epoch)?.expressions; - eval_all(&parsed, &mut contract_context, g, None) - }) -} - -/// Runs `program` in a test environment, first calling `global_context_function`. -/// Returns the final evaluated result along with the asset map. -#[cfg(any(test, feature = "testing"))] -pub fn execute_call_in_global_context_and_return_asset_map( - program: &str, - clarity_version: ClarityVersion, - epoch: StacksEpochId, - use_mainnet: bool, - sender: clarity_types::types::StandardPrincipalData, - mut global_context_function: F, -) -> Result<(Option, crate::vm::contexts::AssetMap)> -where - F: FnMut(&mut GlobalContext) -> Result<()>, -{ - use crate::vm::database::MemoryBackingStore; - use crate::vm::tests::test_only_mainnet_to_chain_id; - use crate::vm::types::QualifiedContractIdentifier; - - let contract_id = QualifiedContractIdentifier::new(sender, "contract".into()); - let mut contract_context = ContractContext::new(contract_id.clone(), clarity_version); - let mut marf = MemoryBackingStore::new(); - let conn = marf.as_clarity_db(); - let chain_id = test_only_mainnet_to_chain_id(use_mainnet); - let mut global_context = GlobalContext::new( - use_mainnet, - chain_id, - conn, - LimitedCostTracker::new_free(), - epoch, - ); - global_context.execute(|g| { - global_context_function(g)?; - let parsed = - ast::build_ast(&contract_id, program, &mut (), clarity_version, epoch)?.expressions; - eval_all(&parsed, &mut contract_context, g, None) - .map(|r| (r, g.get_readonly_asset_map().cloned().unwrap_or_default())) + let res = eval_all(&parsed, &mut contract_context, g, None); + after_function(g)?; + res }) } @@ -603,6 +569,7 @@ pub fn execute_with_parameters( use_mainnet, clarity_types::types::StandardPrincipalData::transient(), |_| Ok(()), + |_| Ok(()), ) } @@ -639,6 +606,7 @@ pub fn execute_with_limited_execution_time( g.set_max_execution_time(max_execution_time); Ok(()) }, + |_| Ok(()), ) } diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index c71c7b27e4b..25ade311b94 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -20,7 +20,9 @@ use std::convert::TryFrom; use clarity_types::errors::{Error as ClarityError, InterpreterResult, ShortReturnType}; -use clarity_types::types::{AssetIdentifier, PrincipalData, QualifiedContractIdentifier}; +use clarity_types::types::{ + AssetIdentifier, PrincipalData, QualifiedContractIdentifier, StandardPrincipalData, +}; use clarity_types::{ClarityName, Value}; use proptest::prelude::*; use proptest::test_runner::{TestCaseError, TestCaseResult}; @@ -29,10 +31,10 @@ use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOW use crate::vm::contexts::AssetMap; use crate::vm::tests::proptest_utils::{ allowance_list_snippets, begin_block, body_with_allowances_snippets, - clarity_values_no_response, execute, execute_and_return_asset_map, - execute_and_return_asset_map_versioned, ft_mint_snippets, ft_transfer_snippets, - match_response_snippets, nft_mint_snippets, nft_transfer_snippets, standard_principal_strategy, - try_response_snippets, value_to_clarity_literal, + clarity_values_no_response, execute, execute_and_check, execute_and_check_versioned, + ft_mint_snippets, ft_transfer_snippets, match_response_snippets, nft_mint_snippets, + nft_transfer_snippets, standard_principal_strategy, try_response_snippets, + value_to_clarity_literal, }; use crate::vm::ClarityVersion; @@ -1471,64 +1473,134 @@ fn test_nested_inner_restrict_assets_with_stx_exceeds() { assert_eq!(short_return, execute(snippet).unwrap_err()); } +/// Test that when an error occurs in the body of a restrict-assets? call, the +/// post-condition check still checks the allowances. +#[test] +fn test_restrict_assets_bad_transfer_with_short_return_in_body() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-stx u100)) + (try! (stx-transfer? u150 tx-sender recipient)) + (try! (if false (ok true) (err u200))) + true + ) +)"#; + let sender = StandardPrincipalData::transient(); + let expected = Value::error(Value::UInt(0)).unwrap(); + let opt_value = execute_and_check(snippet, sender.clone(), |g| { + let assets = g.get_readonly_asset_map().expect("failed to get asset map"); + let stx_moved = assets.get_stx(&sender.clone().into()); + assert!(stx_moved.is_none(), "STX should not have moved"); + Ok(()) + }) + .expect("execution failed"); + assert_eq!(expected, opt_value.expect("no value returned")); +} + +/// Test that when an error occurs in the body of a restrict-assets? call, the +/// error is passed up if no allowances are violated. +#[test] +fn test_restrict_assets_good_transfer_with_short_return_in_body() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-stx u100)) + (try! (stx-transfer? u50 tx-sender recipient)) + (try! (if false (ok true) (err u200))) + true + ) +)"#; + let sender = StandardPrincipalData::transient(); + let expected_err = Value::error(Value::UInt(200)).unwrap(); + let short_return = + ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected_err.into())); + let res = execute(snippet).expect_err("execution passed unexpectedly"); + assert_eq!(short_return, res); +} + // ---------- Property Tests ---------- -/// Given the results of running a snippet with and without asset restrictions, -/// assert that the results match and verify that the asset movements are as -/// expected. If `error_allowed` is true, then it's acceptable for both runs to -/// error out with the same error, but if it is false, then only successful -/// runs are allowed. -/// `asset_check` is a closure that takes the unrestricted and -/// restricted asset maps and returns a `Result, TestCaseError>`. -/// If it returns `Ok(Some(value))`, the test will assert that the restricted -/// execution returned that value. If it returns `Ok(None)`, the test will -/// assert that the restricted execution returned the same value as the -/// unrestricted execution. If it returns `Err`, the test will fail with the -/// provided error. +fn execute_with_assets_for_version( + program: &str, + version: ClarityVersion, + sender: StandardPrincipalData, +) -> (InterpreterResult>, Option) { + let mut assets: Option = None; + + let result = execute_and_check_versioned(program, version, sender, |g| { + assets = Some(g.get_readonly_asset_map()?.clone()); + Ok(()) + }); + + (result, assets) +} + +/// Execute two snippets—one unrestricted and one restricted—using the same +/// sender, then compare their results and asset movements. If `error_allowed` +/// is true, both executions may fail as long as the errors match; otherwise, +/// both executions must succeed. +/// `asset_check` is a closure that takes the unrestricted and restricted asset +/// maps and returns a `Result, TestCaseError>`. If it returns +/// `Ok(Some(value))`, the restricted execution is expected to return that +/// value. If it returns `Ok(None)`, the restricted execution is expected to +/// mirror the unrestricted execution's `(ok ...)` result. If it returns `Err`, +/// the property test fails with the provided error. fn assert_results_match( - unrestricted_result: InterpreterResult<(Option, AssetMap)>, - restricted_result: InterpreterResult<(Option, AssetMap)>, + unrestricted: (&str, ClarityVersion), + restricted: (&str, ClarityVersion), + sender: StandardPrincipalData, asset_check: F, error_allowed: bool, ) -> TestCaseResult where F: Fn(&AssetMap, &AssetMap) -> Result, TestCaseError>, { + let (unrestricted_result, unrestricted_assets) = + execute_with_assets_for_version(unrestricted.0, unrestricted.1, sender.clone()); + let (restricted_result, restricted_assets) = + execute_with_assets_for_version(restricted.0, restricted.1, sender); + + let unrestricted_assets = unrestricted_assets + .ok_or_else(|| TestCaseError::fail("Unrestricted execution returned no asset map"))?; + let restricted_assets = restricted_assets + .ok_or_else(|| TestCaseError::fail("Restricted execution returned no asset map"))?; + match (unrestricted_result, restricted_result) { - (Err(unrestricted_err), Err(restricted_err)) if error_allowed => { - prop_assert_eq!(unrestricted_err, restricted_err); - Ok(()) - } (Err(unrestricted_err), Err(restricted_err)) => { - Err(TestCaseError::fail(format!( - "Both unrestricted and restricted execution failed, but errors are not allowed. Unrestricted error: {unrestricted_err:?}, Restricted error: {restricted_err:?}" - ))) + if error_allowed { + prop_assert_eq!(unrestricted_err, restricted_err); + Ok(()) + } else { + Err(TestCaseError::fail(format!( + "Both unrestricted and restricted execution failed, but errors are not allowed. Unrestricted error: {unrestricted_err:?}, Restricted error: {restricted_err:?}" + ))) + } } - (Err(unrestricted_err), Ok((restricted_result, _))) => { - let detail = match restricted_result { - Some(result_value) => format!( - "Unrestricted execution failed with {unrestricted_err:?} but restricted execution successfully returned value {result_value:?}" - ), - None => format!( - "Unrestricted execution failed with {unrestricted_err:?} but restricted execution successfully returned no value" - ), - }; - Err(TestCaseError::fail(detail)) + (Err(_unrestricted_err), Ok(restricted_value_opt)) => { + if !error_allowed { + return Err(TestCaseError::fail( + "Unrestricted execution failed but errors are not allowed", + )); + } + let restricted_value = restricted_value_opt + .ok_or_else(|| TestCaseError::fail("Restricted execution returned no value"))?; + let expected_value = asset_check(&unrestricted_assets, &restricted_assets)?; + if let Some(expected_value) = expected_value { + prop_assert_eq!(expected_value, restricted_value); + Ok(()) + } else { + Err(TestCaseError::fail( + "Unrestricted execution failed but asset check expected success", + )) + } } (Ok(_), Err(restricted_err)) => Err(TestCaseError::fail(format!( "Unrestricted execution succeeded but restricted execution failed with {restricted_err:?}" ))), - ( - Ok((unrestricted_value, unrestricted_assets)), - Ok((restricted_value, restricted_assets)), - ) => { - let Some(unrestricted_value) = unrestricted_value else { - panic!("Unrestricted execution returned no value"); - }; - let Some(restricted_value) = restricted_value else { - panic!("Restricted execution returned no value"); - }; - + (Ok(unrestricted_value_opt), Ok(restricted_value_opt)) => { + let unrestricted_value = unrestricted_value_opt + .ok_or_else(|| TestCaseError::fail("Unrestricted execution returned no value"))?; + let restricted_value = restricted_value_opt + .ok_or_else(|| TestCaseError::fail("Restricted execution returned no value"))?; let expected_value = asset_check(&unrestricted_assets, &restricted_assets)?; if let Some(expected_value) = expected_value { prop_assert_eq!(expected_value, restricted_value); @@ -1598,8 +1670,9 @@ proptest! { let snippet = format!("(restrict-assets? tx-sender () {body})"); let sender_principal = sender.clone().into(); assert_results_match( - execute_and_return_asset_map(&body, sender.clone()), - execute_and_return_asset_map(&snippet, sender), + (body.as_str(), ClarityVersion::Clarity4), + (snippet.as_str(), ClarityVersion::Clarity4), + sender, |unrestricted_assets, restricted_assets| { let stx_moved = unrestricted_assets.get_stx(&sender_principal).unwrap_or(0); if stx_moved > 0 { @@ -1638,8 +1711,9 @@ proptest! { }; assert_results_match( - execute_and_return_asset_map(&body_program, sender.clone()), - execute_and_return_asset_map(&wrapper_program, sender), + (body_program.as_str(), ClarityVersion::Clarity4), + (wrapper_program.as_str(), ClarityVersion::Clarity4), + sender, move |unrestricted_assets, restricted_assets| { let moved = unrestricted_assets .get_fungible_tokens(&sender_principal, &asset_identifier) @@ -1680,8 +1754,9 @@ proptest! { }; assert_results_match( - execute_and_return_asset_map(&body_program, sender.clone()), - execute_and_return_asset_map(&wrapper_program, sender), + (body_program.as_str(), ClarityVersion::Clarity4), + (wrapper_program.as_str(), ClarityVersion::Clarity4), + sender, move |unrestricted_assets, restricted_assets| { let moved = unrestricted_assets .get_nonfungible_tokens(&sender_principal, &asset_identifier) @@ -1745,8 +1820,9 @@ proptest! { let contract_id = QualifiedContractIdentifier::new(sender.clone(), "contract".into()); let contract = PrincipalData::Contract(contract_id); assert_results_match( - execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3, sender.clone()), - execute_and_return_asset_map(&snippet, sender), + (c3_snippet.as_str(), ClarityVersion::Clarity3), + (snippet.as_str(), ClarityVersion::Clarity4), + sender, |unrestricted_assets, restricted_assets| { let stx_moved = unrestricted_assets.get_stx(&contract).unwrap_or(0); if stx_moved > 0 { @@ -1770,8 +1846,9 @@ proptest! { let snippet = format!("(as-contract? ((with-all-assets-unsafe)) {body})"); let c3_snippet = format!("(as-contract {body})"); assert_results_match( - execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3, sender.clone()), - execute_and_return_asset_map(&snippet, sender), + (c3_snippet.as_str(), ClarityVersion::Clarity3), + (snippet.as_str(), ClarityVersion::Clarity4), + sender, |unrestricted_assets, restricted_assets| { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) @@ -1789,11 +1866,14 @@ proptest! { nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), ) { let (allowances, body) = allowances_and_body; - let snippet = format!("{TOKEN_DEFINITIONS}(as-contract? {allowances} {ft_mint} {nft_mint} {body})"); - let c3_snippet = format!("{TOKEN_DEFINITIONS}(as-contract (begin {ft_mint} {nft_mint} {body}))"); + let snippet = + format!("{TOKEN_DEFINITIONS}(as-contract? {allowances} {ft_mint} {nft_mint} {body})"); + let c3_snippet = + format!("{TOKEN_DEFINITIONS}(as-contract (begin {ft_mint} {nft_mint} {body}))"); assert_results_match( - execute_and_return_asset_map_versioned(&c3_snippet, ClarityVersion::Clarity3, sender.clone()), - execute_and_return_asset_map(&snippet, sender), + (c3_snippet.as_str(), ClarityVersion::Clarity3), + (snippet.as_str(), ClarityVersion::Clarity4), + sender, |unrestricted_assets, restricted_assets| { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) @@ -1811,11 +1891,12 @@ proptest! { nft_mint in match_response_snippets(nft_mint_snippets("tx-sender".into())), ) { let (allowances, body) = allowances_and_body; - let snippet = format!("{TOKEN_DEFINITIONS}(restrict-assets? tx-sender {allowances} {ft_mint} {nft_mint} {body})"); - let simple_snippet = format!("{TOKEN_DEFINITIONS}(begin {ft_mint} {nft_mint} {body})"); - assert_results_match( - execute_and_return_asset_map_versioned(&simple_snippet, ClarityVersion::Clarity3, sender.clone()), - execute_and_return_asset_map(&snippet, sender), + let snippet = format!("{TOKEN_DEFINITIONS}(restrict-assets? tx-sender {allowances} {ft_mint} {nft_mint} {body})"); + let simple_snippet = format!("{TOKEN_DEFINITIONS}(begin {ft_mint} {nft_mint} {body})"); + assert_results_match( + (simple_snippet.as_str(), ClarityVersion::Clarity3), + (snippet.as_str(), ClarityVersion::Clarity4), + sender, |unrestricted_assets, restricted_assets| { prop_assert_eq!(unrestricted_assets, restricted_assets); Ok(None) diff --git a/clarity/src/vm/tests/proptest_utils.rs b/clarity/src/vm/tests/proptest_utils.rs index 792c6efd2b6..f7eca62c14b 100644 --- a/clarity/src/vm/tests/proptest_utils.rs +++ b/clarity/src/vm/tests/proptest_utils.rs @@ -37,17 +37,14 @@ use stacks_common::util::hash::to_hex; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::{ MAX_ALLOWANCES, MAX_NFT_IDENTIFIERS, }; -use crate::vm::contexts::{AssetMap, GlobalContext}; +use crate::vm::contexts::GlobalContext; use crate::vm::database::STXBalance; use crate::vm::errors::Error as VmError; -use crate::vm::{ - execute_call_in_global_context_and_return_asset_map, - execute_with_parameters_and_call_in_global_context, ClarityVersion, -}; +use crate::vm::{execute_with_parameters_and_call_in_global_context, ClarityVersion}; const DEFAULT_EPOCH: StacksEpochId = StacksEpochId::Epoch33; const DEFAULT_CLARITY_VERSION: ClarityVersion = ClarityVersion::Clarity4; -const INITIAL_BALANCE: u128 = 1_000_000; +const INITIAL_BALANCE: u128 = 1_000_000_000; const UTF8_SNIPPET_MAX_SEGMENTS: usize = 16; const UTF8_SIMPLE_ESCAPES: [&str; 6] = ["\\\"", "\\\\", "\\n", "\\t", "\\r", "\\0"]; @@ -105,36 +102,45 @@ pub fn execute_versioned( false, sender, move |g| initialize_balances(g, &sender_for_init), + |_| Ok(()), ) } /// Execute a Clarity code snippet in a fresh global context with default /// parameters, setting up initial balances, returning the resulting value /// along with the final asset map. -pub fn execute_and_return_asset_map( +pub fn execute_and_check( snippet: &str, sender: StandardPrincipalData, -) -> InterpreterResult<(Option, AssetMap)> { - execute_and_return_asset_map_versioned(snippet, DEFAULT_CLARITY_VERSION, sender) + check: F, +) -> InterpreterResult> +where + F: FnMut(&mut GlobalContext) -> InterpreterResult<()>, +{ + execute_and_check_versioned(snippet, DEFAULT_CLARITY_VERSION, sender, check) } /// Execute a Clarity code snippet with the specified Clarity version in a /// fresh global context with default parameters, setting up initial balances, /// returning the resulting value along with the final asset map. -pub fn execute_and_return_asset_map_versioned( +pub fn execute_and_check_versioned( snippet: &str, version: ClarityVersion, sender: StandardPrincipalData, -) -> InterpreterResult<(Option, AssetMap)> { - let contract_id = QualifiedContractIdentifier::new(sender.clone(), "contract".into()); + mut check: F, +) -> InterpreterResult> +where + F: FnMut(&mut GlobalContext) -> InterpreterResult<()>, +{ let sender_for_init = sender.clone(); - execute_call_in_global_context_and_return_asset_map( + execute_with_parameters_and_call_in_global_context( snippet, version, DEFAULT_EPOCH, false, sender, move |g| initialize_balances(g, &sender_for_init), + move |g| check(g), ) } From 25264ff5a8261b4954b477c628724b2911fa5fa0 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 27 Oct 2025 21:28:00 -0400 Subject: [PATCH 15/18] test: fix copy/paste error in `test_to_ascii` --- clarity/src/vm/tests/conversions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clarity/src/vm/tests/conversions.rs b/clarity/src/vm/tests/conversions.rs index 85a3146beb0..44026e70be3 100644 --- a/clarity/src/vm/tests/conversions.rs +++ b/clarity/src/vm/tests/conversions.rs @@ -591,7 +591,7 @@ fn test_to_ascii(version: ClarityVersion, epoch: StacksEpochId) { "(to-ascii? 0x{})", "ff".repeat(MAX_TO_ASCII_BUFFER_LEN as usize + 1) ); - let result = execute_with_parameters(response_to_ascii, version, epoch, false); + let result = execute_with_parameters(&oversized_buffer_to_ascii, version, epoch, false); // This should fail at analysis time since the value is too big assert!(result.is_err()); } From 76fbc56011f89e25d8aa413174767fb1ac6d4389 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Tue, 28 Oct 2025 09:41:11 -0400 Subject: [PATCH 16/18] test: add `restrict-assets?` tests with `ok` short returns --- clarity/src/vm/tests/post_conditions.rs | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 25ade311b94..81eab364935 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -1517,6 +1517,49 @@ fn test_restrict_assets_good_transfer_with_short_return_in_body() { assert_eq!(short_return, res); } +/// Test that when a short-return of an ok value occurs in the body of a +/// restrict-assets? call, the post-condition check still checks the allowances +/// and returns an error if violated. +#[test] +fn test_restrict_assets_bad_transfer_with_short_return_ok_in_body() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-stx u100)) + (try! (stx-transfer? u150 tx-sender recipient)) + (asserts! false (ok false)) + true + ) +)"#; + let sender = StandardPrincipalData::transient(); + let expected = Value::error(Value::UInt(0)).unwrap(); + let opt_value = execute_and_check(snippet, sender.clone(), |g| { + let assets = g.get_readonly_asset_map().expect("failed to get asset map"); + let stx_moved = assets.get_stx(&sender.clone().into()); + assert!(stx_moved.is_none(), "STX should not have moved"); + Ok(()) + }) + .expect("execution failed"); + assert_eq!(expected, opt_value.expect("no value returned")); +} + +/// Test that when a short-return of an ok value occurs in the body of a +/// restrict-assets? call, the ok value is returned. +#[test] +fn test_restrict_assets_good_transfer_with_short_return_ok_in_body() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u100)) + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) + (asserts! false (ok false)) + true +)"#; + let sender = StandardPrincipalData::transient(); + let expected_err = Value::okay(Value::Bool(false)).unwrap(); + let short_return = + ClarityError::ShortReturn(ShortReturnType::AssertionFailed(expected_err.into())); + let err = execute(snippet).expect_err("execution passed unexpectedly"); + assert_eq!(short_return, err); +} + // ---------- Property Tests ---------- fn execute_with_assets_for_version( From 5753d73328c611eb15b3e6594658eaa42af83787 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Tue, 28 Oct 2025 09:48:38 -0400 Subject: [PATCH 17/18] test: add `as-contract?` short-return tests --- clarity/src/vm/tests/post_conditions.rs | 91 ++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 81eab364935..428edbf9426 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -728,6 +728,96 @@ fn test_as_contract_with_error_in_body() { assert_eq!(short_return, execute(snippet).unwrap_err()); } +/// Test that when an error occurs in the body of an `as-contract?` call, the +/// post-condition check still checks the allowances. +#[test] +fn test_as_contract_bad_transfer_with_short_return_in_body() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (as-contract? ((with-stx u100)) + (try! (stx-transfer? u150 tx-sender recipient)) + (try! (if false (ok true) (err u200))) + true + ) +)"#; + let sender = StandardPrincipalData::transient(); + let contract_id = QualifiedContractIdentifier::new(sender.clone(), "contract".into()); + let contract = PrincipalData::Contract(contract_id); + let expected = Value::error(Value::UInt(0)).unwrap(); + let opt_value = execute_and_check(snippet, sender.clone(), |g| { + let assets = g.get_readonly_asset_map().expect("failed to get asset map"); + let stx_moved = assets.get_stx(&contract); + assert!(stx_moved.is_none(), "STX should not have moved"); + Ok(()) + }) + .expect("execution failed"); + assert_eq!(expected, opt_value.expect("no value returned")); +} + +/// Test that when an error occurs in the body of an `as-contract?` call, the +/// error is passed up if no allowances are violated. +#[test] +fn test_as_contract_good_transfer_with_short_return_in_body() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (as-contract? ((with-stx u100)) + (try! (stx-transfer? u50 tx-sender recipient)) + (try! (if false (ok true) (err u200))) + true + ) +)"#; + let sender = StandardPrincipalData::transient(); + let expected_err = Value::error(Value::UInt(200)).unwrap(); + let short_return = + ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected_err.into())); + let res = execute(snippet).expect_err("execution passed unexpectedly"); + assert_eq!(short_return, res); +} + +/// Test that when a short-return of an ok value occurs in the body of an +/// `as-contract?` call, the post-condition check still checks the allowances +/// and returns an error if violated. +#[test] +fn test_as_contract_bad_transfer_with_short_return_ok_in_body() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (as-contract? ((with-stx u100)) + (try! (stx-transfer? u150 tx-sender recipient)) + (asserts! false (ok false)) + true + ) +)"#; + let sender = StandardPrincipalData::transient(); + let contract_id = QualifiedContractIdentifier::new(sender.clone(), "contract".into()); + let contract = PrincipalData::Contract(contract_id); + let expected = Value::error(Value::UInt(0)).unwrap(); + let opt_value = execute_and_check(snippet, sender.clone(), |g| { + let assets = g.get_readonly_asset_map().expect("failed to get asset map"); + let stx_moved = assets.get_stx(&contract); + assert!(stx_moved.is_none(), "STX should not have moved"); + Ok(()) + }) + .expect("execution failed"); + assert_eq!(expected, opt_value.expect("no value returned")); +} + +/// Test that when a short-return of an ok value occurs in the body of an +/// `as-contract?` call, the ok value is returned. +#[test] +fn test_as_contract_good_transfer_with_short_return_ok_in_body() { + let snippet = r#" +(as-contract? ((with-stx u100)) + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) + (asserts! false (ok false)) + true +)"#; + let expected_err = Value::okay(Value::Bool(false)).unwrap(); + let short_return = + ClarityError::ShortReturn(ShortReturnType::AssertionFailed(expected_err.into())); + let err = execute(snippet).expect_err("execution passed unexpectedly"); + assert_eq!(short_return, err); +} + // ---------- Tests for restrict-assets? ---------- #[test] @@ -1552,7 +1642,6 @@ fn test_restrict_assets_good_transfer_with_short_return_ok_in_body() { (asserts! false (ok false)) true )"#; - let sender = StandardPrincipalData::transient(); let expected_err = Value::okay(Value::Bool(false)).unwrap(); let short_return = ClarityError::ShortReturn(ShortReturnType::AssertionFailed(expected_err.into())); From 5635f8816f54cd8db5688643fc7fd33e5c23f129 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Tue, 28 Oct 2025 10:48:20 -0400 Subject: [PATCH 18/18] test: add property tests for secp functions --- clarity/src/vm/tests/crypto.rs | 283 +++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) diff --git a/clarity/src/vm/tests/crypto.rs b/clarity/src/vm/tests/crypto.rs index 5565e90adf1..9a16bc42360 100644 --- a/clarity/src/vm/tests/crypto.rs +++ b/clarity/src/vm/tests/crypto.rs @@ -1,3 +1,4 @@ +use proptest::prelude::*; use stacks_common::types::chainstate::{StacksPrivateKey, StacksPublicKey}; use stacks_common::types::{PrivateKey, StacksEpochId}; use stacks_common::util::hash::{to_hex, Sha256Sum}; @@ -370,3 +371,285 @@ fn test_secp256k1_recover_invalid_signature_returns_err_code() { other => panic!("expected err response, found {other:?}"), } } + +proptest! { + #[test] + fn prop_secp256k1_verify_accepts_valid_signatures( + seed in any::<[u8; 32]>(), + message in any::<[u8; 32]>() + ) { + let privk = StacksPrivateKey::from_seed(&seed); + let pubk = StacksPublicKey::from_private(&privk); + let pubkey_bytes = pubk.to_bytes_compressed(); + let message = message.to_vec(); + let signature: Secp256k1Signature = privk.sign(&message).expect("secp256k1 signing should succeed"); + let signature_bytes = signature.to_rsv(); + let program = format!( + "(secp256k1-verify {} {} {})", + buff_literal(&message), + buff_literal(&signature_bytes), + buff_literal(&pubkey_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(true), result); + } + + #[test] + fn prop_secp256k1_recover_matches_public_key( + seed in any::<[u8; 32]>(), + message in any::<[u8; 32]>() + ) { + let privk = StacksPrivateKey::from_seed(&seed); + let pubk = StacksPublicKey::from_private(&privk); + let pubkey_bytes = pubk.to_bytes_compressed(); + let message = message.to_vec(); + let signature: Secp256k1Signature = privk.sign(&message).expect("secp256k1 signing should succeed"); + let signature_bytes = signature.to_rsv(); + let program = format!( + "(is-eq (unwrap! (secp256k1-recover? {} {}) (err u1)) {})", + buff_literal(&message), + buff_literal(&signature_bytes), + buff_literal(&pubkey_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(true), result); + } + + #[test] + fn prop_secp256r1_verify_accepts_valid_signatures( + seed in any::<[u8; 32]>(), + message in any::<[u8; 32]>() + ) { + let privk = Secp256r1PrivateKey::from_seed(&seed); + let pubk = Secp256r1PublicKey::from_private(&privk); + let pubkey_bytes = pubk.to_bytes_compressed(); + let message = message.to_vec(); + let signature = privk.sign(&message).expect("secp256r1 signing should succeed"); + let program = format!( + "(secp256r1-verify {} {} {})", + buff_literal(&message), + buff_literal(&signature.0), + buff_literal(&pubkey_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(true), result); + } + + #[test] + fn prop_secp256k1_verify_rejects_tampered_msg( + seed in any::<[u8; 32]>(), + message in any::<[u8; 32]>(), + bit in 0usize..32 + ) { + let privk = StacksPrivateKey::from_seed(&seed); + let pubk = StacksPublicKey::from_private(&privk); + let pubkey_bytes = pubk.to_bytes_compressed(); + let mut m = message.to_vec(); + let sig: Secp256k1Signature = privk.sign(&m).unwrap(); + let sig_bytes = sig.to_rsv(); + + // flip one bit + m[bit] ^= 0x01; + + let program = format!( + "(secp256k1-verify {} {} {})", + buff_literal(&m), + buff_literal(&sig_bytes), + buff_literal(&pubkey_bytes) + ); + let result = execute_with_parameters( + &program, ClarityVersion::Clarity4, StacksEpochId::Epoch33, false + ).unwrap().unwrap(); + + prop_assert_eq!(Value::Bool(false), result); + } + + #[test] + fn prop_secp256r1_verify_rejects_tampered_msg( + seed in any::<[u8; 32]>(), + message in any::<[u8; 32]>(), + bit in 0usize..32 + ) { + let privk = Secp256r1PrivateKey::from_seed(&seed); + let pubk = Secp256r1PublicKey::from_private(&privk); + let pubkey_bytes = pubk.to_bytes_compressed(); + let mut message = message.to_vec(); + let signature = privk.sign(&message).expect("secp256r1 signing should succeed"); + + // flip one bit + message[bit] ^= 0x01; + + let program = format!( + "(secp256r1-verify {} {} {})", + buff_literal(&message), + buff_literal(&signature.0), + buff_literal(&pubkey_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(false), result); + } + + #[test] + fn prop_secp256k1_recover_fails_to_match_with_tampered_msg( + seed in any::<[u8; 32]>(), + message in any::<[u8; 32]>(), + bit in 0usize..32 + ) { + let privk = StacksPrivateKey::from_seed(&seed); + let pubk = StacksPublicKey::from_private(&privk); + let pubkey_bytes = pubk.to_bytes_compressed(); + let mut message = message.to_vec(); + let signature: Secp256k1Signature = privk.sign(&message).expect("secp256k1 signing should succeed"); + let signature_bytes = signature.to_rsv(); + + // flip one bit + message[bit] ^= 0x01; + + let program = format!( + "(is-eq (unwrap! (secp256k1-recover? {} {}) (err u1)) {})", + buff_literal(&message), + buff_literal(&signature_bytes), + buff_literal(&pubkey_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(false), result); + } + + #[test] + fn prop_secp256r1_verify_rejects_wrong_key( + seed_a in any::<[u8; 32]>(), + seed_b in any::<[u8; 32]>(), + message in any::<[u8; 32]>() + ) { + prop_assume!(seed_a != seed_b); + + let priv_a = Secp256r1PrivateKey::from_seed(&seed_a); + let pub_b = Secp256r1PublicKey::from_private(&Secp256r1PrivateKey::from_seed(&seed_b)); + let pub_b_bytes = pub_b.to_bytes_compressed(); + + let msg = message.to_vec(); + let signature = priv_a.sign(&msg).unwrap(); + + let program = format!( + "(secp256r1-verify {} {} {})", + buff_literal(&msg), + buff_literal(&signature.0), + buff_literal(&pub_b_bytes) + ); + let result = execute_with_parameters( + &program, ClarityVersion::Clarity4, StacksEpochId::Epoch33, false + ).unwrap().unwrap(); + + prop_assert_eq!(Value::Bool(false), result); + } + + #[test] + fn prop_secp256k1_verify_rejects_wrong_key( + seed_a in any::<[u8; 32]>(), + seed_b in any::<[u8; 32]>(), + message in any::<[u8; 32]>() + ) { + prop_assume!(seed_a != seed_b); + let priv_a = StacksPrivateKey::from_seed(&seed_a); + let pub_b = StacksPublicKey::from_private(&StacksPrivateKey::from_seed(&seed_b)); + let pub_b_bytes = pub_b.to_bytes_compressed(); + + let message = message.to_vec(); + let signature: Secp256k1Signature = priv_a.sign(&message).expect("secp256k1 signing should succeed"); + let signature_bytes = signature.to_rsv(); + let program = format!( + "(secp256k1-verify {} {} {})", + buff_literal(&message), + buff_literal(&signature_bytes), + buff_literal(&pub_b_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(false), result); + } + + #[test] + fn prop_secp256k1_recover_fails_to_match_with_wrong_key( + seed_a in any::<[u8; 32]>(), + seed_b in any::<[u8; 32]>(), + message in any::<[u8; 32]>() + ) { + let priv_a = StacksPrivateKey::from_seed(&seed_a); + let pub_b = StacksPublicKey::from_private(&StacksPrivateKey::from_seed(&seed_b)); + let pub_b_bytes = pub_b.to_bytes_compressed(); + + let message = message.to_vec(); + let signature: Secp256k1Signature = priv_a.sign(&message).expect("secp256k1 signing should succeed"); + let signature_bytes = signature.to_rsv(); + let program = format!( + "(is-eq (unwrap! (secp256k1-recover? {} {}) (err u1)) {})", + buff_literal(&message), + buff_literal(&signature_bytes), + buff_literal(&pub_b_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::Clarity4, + StacksEpochId::Epoch33, + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(false), result); + } +}