From 35534ab4e73f013023bdd765448e14ef4122e10c Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 15 Sep 2025 15:40:27 -0400 Subject: [PATCH 01/29] feat: initial implementation for `restric-assets?` This just adds the basic support for the syntax and some setup for the full implementation. --- clarity-types/src/errors/analysis.rs | 4 + .../src/vm/analysis/arithmetic_checker/mod.rs | 4 +- .../src/vm/analysis/read_only_checker/mod.rs | 17 +++ .../type_checker/v2_05/natives/mod.rs | 11 +- .../src/vm/analysis/type_checker/v2_1/mod.rs | 6 +- .../analysis/type_checker/v2_1/natives/mod.rs | 55 +++++++-- clarity/src/vm/costs/cost_functions.rs | 3 + clarity/src/vm/costs/costs_1.rs | 8 +- clarity/src/vm/costs/costs_2.rs | 8 +- clarity/src/vm/costs/costs_2_testnet.rs | 8 +- clarity/src/vm/costs/costs_3.rs | 8 +- clarity/src/vm/costs/costs_4.rs | 10 +- clarity/src/vm/docs/mod.rs | 92 ++++++++++----- clarity/src/vm/functions/mod.rs | 18 ++- clarity/src/vm/functions/post_conditions.rs | 110 ++++++++++++++++++ .../src/chainstate/stacks/boot/costs-4.clar | 3 + stackslib/src/clarity_vm/tests/costs.rs | 1 + 17 files changed, 302 insertions(+), 64 deletions(-) create mode 100644 clarity/src/vm/functions/post_conditions.rs diff --git a/clarity-types/src/errors/analysis.rs b/clarity-types/src/errors/analysis.rs index 09e4e5f056..f4c1a3d7bf 100644 --- a/clarity-types/src/errors/analysis.rs +++ b/clarity-types/src/errors/analysis.rs @@ -307,6 +307,9 @@ pub enum CheckErrors { // time checker errors ExecutionTimeExpired, + + // contract post-conditions + RestrictAssetsExpectedListOfAllowances, } #[derive(Debug, PartialEq)] @@ -605,6 +608,7 @@ impl DiagnosableError for CheckErrors { CheckErrors::CostComputationFailed(s) => format!("contract cost computation failed: {s}"), CheckErrors::CouldNotDetermineSerializationType => "could not determine the input type for the serialization function".into(), CheckErrors::ExecutionTimeExpired => "execution time expired".into(), + CheckErrors::RestrictAssetsExpectedListOfAllowances => "restrict-assets? expects a list of asset allowances as its second argument".into(), } } diff --git a/clarity/src/vm/analysis/arithmetic_checker/mod.rs b/clarity/src/vm/analysis/arithmetic_checker/mod.rs index 83be68ab23..95b459c227 100644 --- a/clarity/src/vm/analysis/arithmetic_checker/mod.rs +++ b/clarity/src/vm/analysis/arithmetic_checker/mod.rs @@ -175,7 +175,9 @@ impl ArithmeticOnlyChecker<'_> { | StxGetAccount => Err(Error::FunctionNotPermitted(function)), Append | Concat | AsMaxLen | ContractOf | PrincipalOf | ListCons | Print | AsContract | ElementAt | ElementAtAlias | IndexOf | IndexOfAlias | Map | Filter - | Fold | Slice | ReplaceAt | ContractHash => Err(Error::FunctionNotPermitted(function)), + | Fold | Slice | ReplaceAt | ContractHash | RestrictAssets => { + Err(Error::FunctionNotPermitted(function)) + } BuffToIntLe | BuffToUIntLe | BuffToIntBe | BuffToUIntBe => { Err(Error::FunctionNotPermitted(function)) } diff --git a/clarity/src/vm/analysis/read_only_checker/mod.rs b/clarity/src/vm/analysis/read_only_checker/mod.rs index 21b2bbd8f7..5eeb1b014f 100644 --- a/clarity/src/vm/analysis/read_only_checker/mod.rs +++ b/clarity/src/vm/analysis/read_only_checker/mod.rs @@ -427,6 +427,23 @@ impl<'a, 'b> ReadOnlyChecker<'a, 'b> { self.check_each_expression_is_read_only(&args[2..]) .map(|args_read_only| args_read_only && is_function_read_only) } + RestrictAssets => { + check_arguments_at_least(3, args)?; + + // Check the asset owner argument. + let asset_owner_read_only = self.check_read_only(&args[0])?; + + // Check the allowances argument. + let allowances = args[1] + .match_list() + .ok_or(CheckErrors::RestrictAssetsExpectedListOfAllowances)?; + let allowances_read_only = self.check_each_expression_is_read_only(allowances)?; + + // Check the body expressions. + let body_read_only = self.check_each_expression_is_read_only(&args[2..])?; + + Ok(asset_owner_read_only && allowances_read_only && body_read_only) + } } } diff --git a/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs index 08297f7440..99bb0583c8 100644 --- a/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs @@ -16,15 +16,15 @@ use stacks_common::types::StacksEpochId; -use super::{check_argument_count, check_arguments_at_least, no_type, TypeChecker, TypingContext}; +use super::{TypeChecker, TypingContext, check_argument_count, check_arguments_at_least, no_type}; use crate::vm::analysis::errors::{CheckError, CheckErrors, SyntaxBindingErrorType}; use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::{analysis_typecheck_cost, runtime_cost}; use crate::vm::diagnostic::DiagnosableError; -use crate::vm::functions::{handle_binding_list, NativeFunctions}; +use crate::vm::functions::{NativeFunctions, handle_binding_list}; use crate::vm::types::{ - BlockInfoProperty, FixedFunction, FunctionArg, FunctionSignature, FunctionType, PrincipalData, - TupleTypeSignature, TypeSignature, Value, BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, + BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, BlockInfoProperty, FixedFunction, FunctionArg, + FunctionSignature, FunctionType, PrincipalData, TupleTypeSignature, TypeSignature, Value, }; use crate::vm::{ClarityName, ClarityVersion, SymbolicExpression, SymbolicExpressionType}; @@ -782,7 +782,8 @@ impl TypedNativeFunction { | StringToUInt | IntToAscii | IntToUtf8 | GetBurnBlockInfo | StxTransferMemo | StxGetAccount | BitwiseAnd | BitwiseOr | BitwiseNot | BitwiseLShift | BitwiseRShift | BitwiseXor2 | Slice | ToConsensusBuff | FromConsensusBuff - | ReplaceAt | GetStacksBlockInfo | GetTenureInfo | ContractHash | ToAscii => { + | ReplaceAt | GetStacksBlockInfo | GetTenureInfo | ContractHash | ToAscii + | RestrictAssets => { return Err(CheckErrors::Expects( "Clarity 2+ keywords should not show up in 2.05".into(), )); diff --git a/clarity/src/vm/analysis/type_checker/v2_1/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/mod.rs index b3f1386a72..8bed512c34 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/mod.rs @@ -1135,7 +1135,7 @@ impl<'a, 'b> TypeChecker<'a, 'b> { Ok(()) } - // Type check an expression, with an expected_type that should _admit_ the expression. + /// Type check an expression, with an expected_type that should _admit_ the expression. pub fn type_check_expects( &mut self, expr: &SymbolicExpression, @@ -1157,7 +1157,7 @@ impl<'a, 'b> TypeChecker<'a, 'b> { } } - // Type checks an expression, recursively type checking its subexpressions + /// Type checks an expression, recursively type checking its subexpressions pub fn type_check( &mut self, expr: &SymbolicExpression, @@ -1176,6 +1176,8 @@ impl<'a, 'b> TypeChecker<'a, 'b> { result } + /// Type checks a list of statements, ensuring that each statement is valid + /// and any responses before the last statement are handled. fn type_check_consecutive_statements( &mut self, args: &[SymbolicExpression], diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index 161e6a5689..b7ed52dde1 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -17,23 +17,23 @@ use stacks_common::types::StacksEpochId; use super::{ - check_argument_count, check_arguments_at_least, check_arguments_at_most, - compute_typecheck_cost, no_type, TypeChecker, TypingContext, + TypeChecker, TypingContext, check_argument_count, check_arguments_at_least, + check_arguments_at_most, compute_typecheck_cost, no_type, }; use crate::vm::analysis::errors::{CheckError, CheckErrors, SyntaxBindingErrorType}; use crate::vm::costs::cost_functions::ClarityCostFunction; -use crate::vm::costs::{analysis_typecheck_cost, runtime_cost, CostErrors, CostTracker}; +use crate::vm::costs::{CostErrors, CostTracker, analysis_typecheck_cost, runtime_cost}; use crate::vm::diagnostic::DiagnosableError; -use crate::vm::functions::{handle_binding_list, NativeFunctions}; +use crate::vm::functions::{NativeFunctions, handle_binding_list}; use crate::vm::types::signatures::{ - CallableSubtype, FunctionArgSignature, FunctionReturnsSignature, SequenceSubtype, ASCII_40, + ASCII_40, CallableSubtype, FunctionArgSignature, FunctionReturnsSignature, SequenceSubtype, TO_ASCII_MAX_BUFF, TO_ASCII_RESPONSE_STRING, UTF8_40, }; use crate::vm::types::{ - BlockInfoProperty, BufferLength, BurnBlockInfoProperty, FixedFunction, FunctionArg, - FunctionSignature, FunctionType, PrincipalData, StacksBlockInfoProperty, TenureInfoProperty, - TupleTypeSignature, TypeSignature, Value, BUFF_1, BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, - MAX_VALUE_SIZE, + BUFF_1, BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, BlockInfoProperty, BufferLength, + BurnBlockInfoProperty, FixedFunction, FunctionArg, FunctionSignature, FunctionType, + MAX_VALUE_SIZE, PrincipalData, StacksBlockInfoProperty, TenureInfoProperty, TupleTypeSignature, + TypeSignature, Value, }; use crate::vm::{ClarityName, ClarityVersion, SymbolicExpression, SymbolicExpressionType}; @@ -819,6 +819,42 @@ fn check_get_tenure_info( Ok(TypeSignature::new_option(block_info_prop.type_result())?) } +fn check_restrict_assets( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, +) -> Result { + check_arguments_at_least(3, args)?; + + let asset_owner = &args[0]; + let allowance_list = args[1].match_list().ok_or(CheckError::new( + CheckErrors::RestrictAssetsExpectedListOfAllowances, + ))?; + let body_exprs = &args[2..]; + + runtime_cost( + ClarityCostFunction::AnalysisListItemsCheck, + checker, + allowance_list.len() + body_exprs.len(), + )?; + + checker.type_check_expects(asset_owner, context, &TypeSignature::PrincipalType)?; + + // TODO: type-check the allowances + + // Check the body expressions, ensuring any intermediate responses are handled + let mut last_return = None; + for expr in body_exprs { + let type_return = checker.type_check(expr, context)?; + if type_return.is_response_type() { + return Err(CheckErrors::UncheckedIntermediaryResponses.into()); + } + last_return = Some(type_return); + } + + last_return.ok_or_else(|| CheckError::new(CheckErrors::CheckerImplementationFailure)) +} + impl TypedNativeFunction { pub fn type_check_application( &self, @@ -1209,6 +1245,7 @@ impl TypedNativeFunction { CheckErrors::Expects("FATAL: Legal Clarity response type marked invalid".into()) })?, ))), + RestrictAssets => Special(SpecialNativeFunction(&check_restrict_assets)), }; Ok(out) diff --git a/clarity/src/vm/costs/cost_functions.rs b/clarity/src/vm/costs/cost_functions.rs index 6abbaec555..ac9aa39c2b 100644 --- a/clarity/src/vm/costs/cost_functions.rs +++ b/clarity/src/vm/costs/cost_functions.rs @@ -159,6 +159,7 @@ define_named_enum!(ClarityCostFunction { BitwiseRShift("cost_bitwise_right_shift"), ContractHash("cost_contract_hash"), ToAscii("cost_to_ascii"), + RestrictAssets("cost_restrict_assets"), Unimplemented("cost_unimplemented"), }); @@ -330,6 +331,7 @@ pub trait CostValues { fn cost_bitwise_right_shift(n: u64) -> InterpreterResult; fn cost_contract_hash(n: u64) -> InterpreterResult; fn cost_to_ascii(n: u64) -> InterpreterResult; + fn cost_restrict_assets(n: u64) -> InterpreterResult; } impl ClarityCostFunction { @@ -484,6 +486,7 @@ impl ClarityCostFunction { ClarityCostFunction::BitwiseRShift => C::cost_bitwise_right_shift(n), ClarityCostFunction::ContractHash => C::cost_contract_hash(n), ClarityCostFunction::ToAscii => C::cost_to_ascii(n), + ClarityCostFunction::RestrictAssets => C::cost_restrict_assets(n), ClarityCostFunction::Unimplemented => Err(RuntimeErrorType::NotImplemented.into()), } } diff --git a/clarity/src/vm/costs/costs_1.rs b/clarity/src/vm/costs/costs_1.rs index 1e400f56bd..00f3d53ce1 100644 --- a/clarity/src/vm/costs/costs_1.rs +++ b/clarity/src/vm/costs/costs_1.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -/// This file implements the cost functions from costs.clar in Rust. -use super::cost_functions::{linear, logn, nlogn, CostValues}; use super::ExecutionCost; +/// This file implements the cost functions from costs.clar in Rust. +use super::cost_functions::{CostValues, linear, logn, nlogn}; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs1; @@ -753,4 +753,8 @@ impl CostValues for Costs1 { fn cost_to_ascii(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_restrict_assets(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_2.rs b/clarity/src/vm/costs/costs_2.rs index 451008bd1b..56d1921acf 100644 --- a/clarity/src/vm/costs/costs_2.rs +++ b/clarity/src/vm/costs/costs_2.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -/// This file implements the cost functions from costs-2.clar in Rust. -use super::cost_functions::{linear, logn, nlogn, CostValues}; use super::ExecutionCost; +/// This file implements the cost functions from costs-2.clar in Rust. +use super::cost_functions::{CostValues, linear, logn, nlogn}; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs2; @@ -753,4 +753,8 @@ impl CostValues for Costs2 { fn cost_to_ascii(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_restrict_assets(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_2_testnet.rs b/clarity/src/vm/costs/costs_2_testnet.rs index 647bafedb9..919a0c71c2 100644 --- a/clarity/src/vm/costs/costs_2_testnet.rs +++ b/clarity/src/vm/costs/costs_2_testnet.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -/// This file implements the cost functions from costs-2-testnet.clar in Rust. -use super::cost_functions::{linear, logn, nlogn, CostValues}; use super::ExecutionCost; +/// This file implements the cost functions from costs-2-testnet.clar in Rust. +use super::cost_functions::{CostValues, linear, logn, nlogn}; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs2Testnet; @@ -753,4 +753,8 @@ impl CostValues for Costs2Testnet { fn cost_to_ascii(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_restrict_assets(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_3.rs b/clarity/src/vm/costs/costs_3.rs index b195303510..d9dfa0482d 100644 --- a/clarity/src/vm/costs/costs_3.rs +++ b/clarity/src/vm/costs/costs_3.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -/// This file implements the cost functions from costs-3.clar in Rust. -use super::cost_functions::{linear, logn, nlogn, CostValues}; use super::ExecutionCost; +/// This file implements the cost functions from costs-3.clar in Rust. +use super::cost_functions::{CostValues, linear, logn, nlogn}; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs3; @@ -771,4 +771,8 @@ impl CostValues for Costs3 { fn cost_to_ascii(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_restrict_assets(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_4.rs b/clarity/src/vm/costs/costs_4.rs index d1c92732d9..52304f8a41 100644 --- a/clarity/src/vm/costs/costs_4.rs +++ b/clarity/src/vm/costs/costs_4.rs @@ -13,6 +13,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use super::ExecutionCost; /// This file implements the cost functions from costs-4.clar in Rust. /// For Clarity 4, all cost functions are the same as in costs-3, except /// for the new `cost_contract_hash` function. To avoid duplication, this @@ -20,7 +21,6 @@ /// overrides only `cost_contract_hash`. use super::cost_functions::CostValues; use super::costs_3::Costs3; -use super::ExecutionCost; use crate::vm::costs::cost_functions::linear; use crate::vm::errors::InterpreterResult; @@ -446,7 +446,8 @@ impl CostValues for Costs4 { Costs3::cost_bitwise_right_shift(n) } - // New in costs-4 + // --- New in costs-4 --- + fn cost_contract_hash(_n: u64) -> InterpreterResult { Ok(ExecutionCost { runtime: 100, // TODO: needs criterion benchmark @@ -461,4 +462,9 @@ impl CostValues for Costs4 { // TODO: needs criterion benchmark Ok(ExecutionCost::runtime(linear(n, 1, 100))) } + + fn cost_restrict_assets(n: u64) -> InterpreterResult { + // TODO: needs criterion benchmark + Ok(ExecutionCost::runtime(linear(n, 1, 100))) + } } diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index df84474a39..88f551c215 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -15,13 +15,13 @@ // along with this program. If not, see . use super::types::signatures::{FunctionArgSignature, FunctionReturnsSignature}; -use crate::vm::analysis::type_checker::v2_1::natives::SimpleNativeFunction; +use crate::vm::ClarityVersion; use crate::vm::analysis::type_checker::v2_1::TypedNativeFunction; -use crate::vm::functions::define::DefineFunctions; +use crate::vm::analysis::type_checker::v2_1::natives::SimpleNativeFunction; use crate::vm::functions::NativeFunctions; +use crate::vm::functions::define::DefineFunctions; use crate::vm::types::{FixedFunction, FunctionType}; use crate::vm::variables::NativeVariables; -use crate::vm::ClarityVersion; #[cfg(feature = "rusqlite")] pub mod contracts; @@ -102,8 +102,7 @@ const BLOCK_HEIGHT: SimpleKeywordAPI = SimpleKeywordAPI { description: "Returns the current block height of the Stacks blockchain in Clarity 1 and 2. Upon activation of epoch 3.0, `block-height` will return the same value as `tenure-height`. In Clarity 3, `block-height` is removed and has been replaced with `stacks-block-height`.", - example: - "(> block-height u1000) ;; returns true if the current block-height has passed 1000 blocks.", + example: "(> block-height u1000) ;; returns true if the current block-height has passed 1000 blocks.", }; const BURN_BLOCK_HEIGHT: SimpleKeywordAPI = SimpleKeywordAPI { @@ -139,8 +138,7 @@ const STACKS_BLOCK_HEIGHT_KEYWORD: SimpleKeywordAPI = SimpleKeywordAPI { snippet: "stacks-block-height", output_type: "uint", description: "Returns the current block height of the Stacks blockchain.", - example: - "(<= stacks-block-height u500000) ;; returns true if the current block-height has not passed 500,000 blocks.", + example: "(<= stacks-block-height u500000) ;; returns true if the current block-height has not passed 500,000 blocks.", }; const TENURE_HEIGHT_KEYWORD: SimpleKeywordAPI = SimpleKeywordAPI { @@ -193,8 +191,7 @@ const REGTEST_KEYWORD: SimpleKeywordAPI = SimpleKeywordAPI { snippet: "is-in-regtest", output_type: "bool", description: "Returns whether or not the code is running in a regression test", - example: - "(print is-in-regtest) ;; Will print 'true' if the code is running in a regression test", + example: "(print is-in-regtest) ;; Will print 'true' if the code is running in a regression test", }; const MAINNET_KEYWORD: SimpleKeywordAPI = SimpleKeywordAPI { @@ -255,7 +252,7 @@ const TO_UINT_API: SimpleFunctionAPI = SimpleFunctionAPI { snippet: "to-uint ${1:int}", signature: "(to-uint i)", description: "Tries to convert the `int` argument to a `uint`. Will cause a runtime error and abort if the supplied argument is negative.", - example: "(to-uint 238) ;; Returns u238" + example: "(to-uint 238) ;; Returns u238", }; const TO_INT_API: SimpleFunctionAPI = SimpleFunctionAPI { @@ -263,7 +260,7 @@ const TO_INT_API: SimpleFunctionAPI = SimpleFunctionAPI { snippet: "to-int ${1:uint}", signature: "(to-int u)", description: "Tries to convert the `uint` argument to an `int`. Will cause a runtime error and abort if the supplied argument is >= `pow(2, 127)`", - example: "(to-int u238) ;; Returns 238" + example: "(to-int u238) ;; Returns 238", }; const BUFF_TO_INT_LE_API: SimpleFunctionAPI = SimpleFunctionAPI { @@ -464,7 +461,7 @@ const ADD_API: SimpleFunctionAPI = SimpleFunctionAPI { snippet: "+ ${1:expr-1} ${2:expr-2}", signature: "(+ i1 i2...)", description: "Adds a variable number of integer inputs and returns the result. In the event of an _overflow_, throws a runtime error.", - example: "(+ 1 2 3) ;; Returns 6" + example: "(+ 1 2 3) ;; Returns 6", }; const SUB_API: SimpleFunctionAPI = SimpleFunctionAPI { @@ -474,7 +471,7 @@ const SUB_API: SimpleFunctionAPI = SimpleFunctionAPI { description: "Subtracts a variable number of integer inputs and returns the result. In the event of an _underflow_, throws a runtime error.", example: "(- 2 1 1) ;; Returns 0 (- 0 3) ;; Returns -3 -" +", }; const DIV_API: SimpleFunctionAPI = SimpleFunctionAPI { @@ -485,7 +482,7 @@ const DIV_API: SimpleFunctionAPI = SimpleFunctionAPI { example: "(/ 2 3) ;; Returns 0 (/ 5 2) ;; Returns 2 (/ 4 2 2) ;; Returns 1 -" +", }; const MUL_API: SimpleFunctionAPI = SimpleFunctionAPI { @@ -496,7 +493,7 @@ const MUL_API: SimpleFunctionAPI = SimpleFunctionAPI { example: "(* 2 3) ;; Returns 6 (* 5 2) ;; Returns 10 (* 2 2 2) ;; Returns 8 -" +", }; const MOD_API: SimpleFunctionAPI = SimpleFunctionAPI { @@ -507,7 +504,7 @@ const MOD_API: SimpleFunctionAPI = SimpleFunctionAPI { example: "(mod 2 3) ;; Returns 2 (mod 5 2) ;; Returns 1 (mod 7 1) ;; Returns 0 -" +", }; const POW_API: SimpleFunctionAPI = SimpleFunctionAPI { @@ -569,8 +566,7 @@ const BITWISE_XOR_API: SimpleFunctionAPI = SimpleFunctionAPI { name: None, snippet: "bit-xor ${1:expr-1} ${2:expr-2}", signature: "(bit-xor i1 i2...)", - description: - "Returns the result of bitwise exclusive or'ing a variable number of integer inputs.", + description: "Returns the result of bitwise exclusive or'ing a variable number of integer inputs.", example: "(bit-xor 1 2) ;; Returns 3 (bit-xor 120 280) ;; Returns 352 (bit-xor -128 64) ;; Returns -64 @@ -596,8 +592,7 @@ const BITWISE_OR_API: SimpleFunctionAPI = SimpleFunctionAPI { name: None, snippet: "bit-or ${1:expr-1} ${2:expr-2}", signature: "(bit-or i1 i2...)", - description: - "Returns the result of bitwise inclusive or'ing a variable number of integer inputs.", + description: "Returns the result of bitwise inclusive or'ing a variable number of integer inputs.", example: "(bit-or 4 8) ;; Returns 12 (bit-or 1 2 4) ;; Returns 7 (bit-or 64 -32 -16) ;; Returns -16 @@ -833,7 +828,9 @@ pub fn get_output_type_string(function_type: &FunctionType) -> String { let arg_sig = match pos { 0 => left, 1 => right, - _ => panic!("Index out of range: TypeOfArgAtPosition for FunctionType::Binary can only handle two arguments, zero-indexed (0 or 1).") + _ => panic!( + "Index out of range: TypeOfArgAtPosition for FunctionType::Binary can only handle two arguments, zero-indexed (0 or 1)." + ), }; match arg_sig { @@ -1360,7 +1357,7 @@ const KECCAK256_API: SpecialAPI = SpecialAPI { Note: this differs from the `NIST SHA-3` (that is, FIPS 202) standard. If an integer (128 bit) is supplied the hash is computed over the little-endian representation of the integer.", - example: "(keccak256 0) ;; Returns 0xf490de2920c8a35fabeb13208852aa28c76f9be9b03a4dd2b3c075f7a26923b4" + example: "(keccak256 0) ;; Returns 0xf490de2920c8a35fabeb13208852aa28c76f9be9b03a4dd2b3c075f7a26923b4", }; const SECP256K1RECOVER_API: SpecialAPI = SpecialAPI { @@ -1433,7 +1430,8 @@ const PRINCIPAL_OF_API: SpecialAPI = SpecialAPI { snippet: "principal-of? ${1:public-key}", output_type: "(response principal uint)", signature: "(principal-of? public-key)", - description: "The `principal-of?` function returns the principal derived from the provided public key. + description: + "The `principal-of?` function returns the principal derived from the provided public key. This function may fail with the error code: * `(err u1)` -- `public-key` is invalid @@ -1444,7 +1442,7 @@ with Stacks 2.1, this bug is fixed, so that this function will return a principa the network it is called on. In particular, if this is called on the mainnet, it will return a single-signature mainnet principal. ", - example: "(principal-of? 0x03adb8de4bfb65db2cfd6120d55c6526ae9c52e675db7e47308636534ba7786110) ;; Returns (ok ST1AW6EKPGT61SQ9FNVDS17RKNWT8ZP582VF9HSCP)" + example: "(principal-of? 0x03adb8de4bfb65db2cfd6120d55c6526ae9c52e675db7e47308636534ba7786110) ;; Returns (ok ST1AW6EKPGT61SQ9FNVDS17RKNWT8ZP582VF9HSCP)", }; const AT_BLOCK: SpecialAPI = SpecialAPI { @@ -1580,8 +1578,7 @@ If the supplied argument is an `(ok ...)` value, }; const MATCH_API: SpecialAPI = SpecialAPI { - input_type: - "(optional A) name expression expression | (response A B) name expression name expression", + input_type: "(optional A) name expression expression | (response A B) name expression name expression", snippet: "match ${1:algebraic-expr} ${2:some-binding-name} ${3:some-branch} ${4:none-branch}", output_type: "C", signature: "(match opt-input some-binding-name some-branch none-branch) | @@ -2566,6 +2563,35 @@ characters.", "#, }; +const RESTRICT_ASSETS: SpecialAPI = SpecialAPI { + input_type: "principal, ((Allowance)*), AnyType, ... A", + snippet: "restrict-assets? ${1:asset-owner} (${2:allowance-1} ${3:allowance-2}) ${4:expr-1}", + output_type: "(response A int)", + signature: "(restrict-assets? asset-owner ((with-stx|with-ft|with-nft|with-stacking)*) expr-body1 expr-body2 ... expr-body-last)", + description: "Executes the body expressions, then checks the asset +outflows against the granted allowances, in declaration order. If any +allowance is violated, the body expressions are reverted, an error is +returned, and an event is emitted with the full details of the violation to +help with debugging. Note that the `asset-owner` and allowance setup +expressions are evaluated before executing the body expressions. The final +body expression cannot return a `response` value in order to avoid returning +a nested `response` value from `restrict-assets?` (nested responses are +error-prone). Returns: +* `(ok x)` if the outflows are within the allowances, where `x` is the + result of the final body expression and has type `A`. +* `(err index)` if an allowance was violated, where `index` is the 0-based + index of the first violated allowance in the list of granted allowances, + or -1 if an asset with no allowance caused the violation.", + example: r#" +(restrict-assets? tx-sender () + (try! (stx-transfer? u1000000 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) +) ;; Returns (err -1) +(restrict-assets? tx-sender () + (+ u1 u2) +) ;; Returns (ok u3) +"#, +}; + pub fn make_api_reference(function: &NativeFunctions) -> FunctionAPI { use crate::vm::functions::NativeFunctions::*; let name = function.get_name(); @@ -2680,6 +2706,7 @@ pub fn make_api_reference(function: &NativeFunctions) -> FunctionAPI { BitwiseRShift => make_for_simple_native(&BITWISE_RIGHT_SHIFT_API, function, name), ContractHash => make_for_simple_native(&CONTRACT_HASH, function, name), ToAscii => make_for_special(&TO_ASCII, function), + RestrictAssets => make_for_special(&RESTRICT_ASSETS, function), } } @@ -2791,11 +2818,11 @@ pub fn make_json_api_reference() -> String { #[cfg(test)] mod test { use stacks_common::consts::{CHAIN_ID_TESTNET, PEER_VERSION_EPOCH_2_1}; + use stacks_common::types::StacksEpochId; use stacks_common::types::chainstate::{ BlockHeaderHash, BurnchainHeaderHash, ConsensusHash, SortitionId, StacksAddress, StacksBlockId, VRFSeed, }; - use stacks_common::types::StacksEpochId; use stacks_common::util::hash::hex_bytes; use super::{get_input_type_string, make_all_api_reference, make_json_api_reference}; @@ -2806,13 +2833,13 @@ mod test { BurnStateDB, ClarityDatabase, HeadersDB, MemoryBackingStore, STXBalance, }; use crate::vm::docs::get_output_type_string; - use crate::vm::types::signatures::{FunctionArgSignature, FunctionReturnsSignature, ASCII_40}; + use crate::vm::types::signatures::{ASCII_40, FunctionArgSignature, FunctionReturnsSignature}; use crate::vm::types::{ FunctionType, PrincipalData, QualifiedContractIdentifier, TupleData, TypeSignature, }; use crate::vm::{ - ast, eval_all, execute, ClarityVersion, ContractContext, GlobalContext, LimitedCostTracker, - StacksEpoch, Value, + ClarityVersion, ContractContext, GlobalContext, LimitedCostTracker, StacksEpoch, Value, + ast, eval_all, execute, }; struct DocHeadersDB {} @@ -3362,7 +3389,10 @@ mod test { ret, ); result = get_input_type_string(&function_type); - assert_eq!(result, "uint, uint | uint, int | uint, principal | principal, uint | principal, int | principal, principal | int, uint | int, int | int, principal"); + assert_eq!( + result, + "uint, uint | uint, int | uint, principal | principal, uint | principal, int | principal, principal | int, uint | int, int | int, principal" + ); } #[test] diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index f458c282e6..b587ffbb73 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -16,18 +16,18 @@ use stacks_common::types::StacksEpochId; -use crate::vm::callables::{cost_input_sized_vararg, CallableType, NativeHandle}; +use crate::vm::Value::CallableContract; +use crate::vm::callables::{CallableType, NativeHandle, cost_input_sized_vararg}; use crate::vm::costs::cost_functions::ClarityCostFunction; -use crate::vm::costs::{constants as cost_constants, runtime_cost, CostTracker, MemoryConsumer}; +use crate::vm::costs::{CostTracker, MemoryConsumer, constants as cost_constants, runtime_cost}; use crate::vm::errors::{ - check_argument_count, check_arguments_at_least, CheckErrors, Error, - InterpreterResult as Result, ShortReturnType, SyntaxBindingError, SyntaxBindingErrorType, + CheckErrors, Error, InterpreterResult as Result, ShortReturnType, SyntaxBindingError, + SyntaxBindingErrorType, check_argument_count, check_arguments_at_least, }; pub use crate::vm::functions::assets::stx_transfer_consolidated; use crate::vm::representations::{ClarityName, SymbolicExpression, SymbolicExpressionType}; use crate::vm::types::{PrincipalData, TypeSignature, Value}; -use crate::vm::Value::CallableContract; -use crate::vm::{eval, is_reserved, Environment, LocalContext}; +use crate::vm::{Environment, LocalContext, eval, is_reserved}; macro_rules! switch_on_global_epoch { ($Name:ident ($Epoch2Version:ident, $Epoch205Version:ident)) => { @@ -76,6 +76,7 @@ mod crypto; mod database; pub mod define; mod options; +mod post_conditions; pub mod principals; mod sequences; pub mod tuples; @@ -193,6 +194,7 @@ define_versioned_named_enum_with_max!(NativeFunctions(ClarityVersion) { GetTenureInfo("get-tenure-info?", ClarityVersion::Clarity3, None), ContractHash("contract-hash?", ClarityVersion::Clarity4, None), ToAscii("to-ascii?", ClarityVersion::Clarity4, None), + RestrictAssets("restrict-assets?", ClarityVersion::Clarity4, None) }); /// @@ -565,6 +567,10 @@ pub fn lookup_reserved_functions(name: &str, version: &ClarityVersion) -> Option SpecialFunction("special_contract_hash", &database::special_contract_hash) } ToAscii => SpecialFunction("special_to_ascii", &conversions::special_to_ascii), + RestrictAssets => SpecialFunction( + "special_restrict_assets", + &post_conditions::special_restrict_assets, + ), }; Some(callable) } else { diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs new file mode 100644 index 0000000000..3c52a6a307 --- /dev/null +++ b/clarity/src/vm/functions/post_conditions.rs @@ -0,0 +1,110 @@ +// 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 . + +use crate::vm::costs::cost_functions::ClarityCostFunction; +use crate::vm::costs::runtime_cost; +use crate::vm::errors::{ + check_arguments_at_least, CheckErrors, InterpreterError, InterpreterResult, +}; +use crate::vm::representations::SymbolicExpression; +use crate::vm::types::{QualifiedContractIdentifier, Value}; +use crate::vm::{eval, Environment, LocalContext}; + +struct StxAllowance { + amount: u128, +} + +struct FtAllowance { + contract: QualifiedContractIdentifier, + token: String, + amount: u128, +} + +struct NftAllowance { + contract: QualifiedContractIdentifier, + token: String, + asset_id: Value, +} + +struct StackingAllowance { + amount: u128, +} + +enum Allowance { + Stx(StxAllowance), + Ft(FtAllowance), + Nft(NftAllowance), + Stacking(StackingAllowance), + All, +} + +fn eval_allowance( + _allowance_expr: &SymbolicExpression, + _env: &mut Environment, + _context: &LocalContext, +) -> InterpreterResult { + // FIXME: Placeholder + Ok(Allowance::All) +} + +/// Handles the function `restrict-assets?` +pub fn special_restrict_assets( + args: &[SymbolicExpression], + env: &mut Environment, + context: &LocalContext, +) -> InterpreterResult { + // (restrict-assets? asset-owner ((with-stx|with-ft|with-nft|with-stacking)*) expr-body1 expr-body2 ... expr-body-last) + // arg1 => asset owner to protect + // arg2 => list of asset allowances + // arg3..n => body + check_arguments_at_least(3, args)?; + + let asset_owner_expr = &args[0]; + let allowance_list = args[1] + .match_list() + .ok_or(CheckErrors::RestrictAssetsExpectedListOfAllowances)?; + let body_exprs = &args[2..]; + + let _asset_owner = eval(asset_owner_expr, env, context)?; + + runtime_cost( + ClarityCostFunction::RestrictAssets, + env, + allowance_list.len(), + )?; + + let mut allowances = Vec::with_capacity(allowance_list.len()); + for allowance in allowance_list { + allowances.push(eval_allowance(allowance, env, context)?); + } + + // Create a new evaluation context, so that we can rollback if the + // post-conditions are violated + env.global_context.begin(); + + // evaluate the body expressions + let mut last_result = None; + for expr in body_exprs { + let result = eval(expr, env, context)?; + last_result.replace(result); + } + + // TODO: Check the post-conditions and rollback if they are violated + + env.global_context.commit()?; + + // last_result should always be Some(...), because of the arg len check above. + last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()).into()) +} diff --git a/stackslib/src/chainstate/stacks/boot/costs-4.clar b/stackslib/src/chainstate/stacks/boot/costs-4.clar index 715bee4966..a1654273f7 100644 --- a/stackslib/src/chainstate/stacks/boot/costs-4.clar +++ b/stackslib/src/chainstate/stacks/boot/costs-4.clar @@ -663,3 +663,6 @@ (define-read-only (cost_to_ascii (n uint)) (runtime (linear n u1 u100))) ;; TODO: needs criterion benchmark + +(define-read-only (cost_restrict_assets (n uint)) + (runtime (linear n u1 u100))) ;; TODO: needs criterion benchmark diff --git a/stackslib/src/clarity_vm/tests/costs.rs b/stackslib/src/clarity_vm/tests/costs.rs index 75be448599..c4bced3342 100644 --- a/stackslib/src/clarity_vm/tests/costs.rs +++ b/stackslib/src/clarity_vm/tests/costs.rs @@ -165,6 +165,7 @@ pub fn get_simple_test(function: &NativeFunctions) -> &'static str { GetTenureInfo => "(get-tenure-info? time u1)", ContractHash => "(contract-hash? .contract-other)", ToAscii => "(to-ascii? 65)", + RestrictAssets => "(restrict-assets? tx-sender () (+ u1 u2))", } } From 9672363fa641d17e71205b5317b1de6d3dc09985 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 18 Sep 2025 15:50:14 -0400 Subject: [PATCH 02/29] feat: add syntax and type-checking for `as-contract?` and allowances --- clarity-types/src/errors/analysis.rs | 10 +- clarity-types/src/types/signatures.rs | 3 + .../src/vm/analysis/arithmetic_checker/mod.rs | 30 +- .../src/vm/analysis/read_only_checker/mod.rs | 137 ++- .../type_checker/v2_05/natives/mod.rs | 52 +- .../analysis/type_checker/v2_1/natives/mod.rs | 65 +- .../v2_1/natives/post_conditions.rs | 254 ++++++ .../analysis/type_checker/v2_1/tests/mod.rs | 1 + .../v2_1/tests/post_conditions.rs | 817 ++++++++++++++++++ clarity/src/vm/costs/cost_functions.rs | 3 + clarity/src/vm/costs/costs_1.rs | 8 +- clarity/src/vm/costs/costs_2.rs | 8 +- clarity/src/vm/costs/costs_2_testnet.rs | 8 +- clarity/src/vm/costs/costs_3.rs | 8 +- clarity/src/vm/costs/costs_4.rs | 7 +- clarity/src/vm/docs/mod.rs | 193 ++++- clarity/src/vm/functions/mod.rs | 32 +- clarity/src/vm/functions/post_conditions.rs | 66 +- .../chainstate/stacks/boot/contract_tests.rs | 2 +- .../src/chainstate/stacks/boot/costs-4.clar | 3 + .../src/clarity_vm/tests/analysis_costs.rs | 27 +- stackslib/src/clarity_vm/tests/costs.rs | 55 +- 22 files changed, 1644 insertions(+), 145 deletions(-) create mode 100644 clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs create mode 100644 clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs diff --git a/clarity-types/src/errors/analysis.rs b/clarity-types/src/errors/analysis.rs index f4c1a3d7bf..698181a65d 100644 --- a/clarity-types/src/errors/analysis.rs +++ b/clarity-types/src/errors/analysis.rs @@ -309,7 +309,10 @@ pub enum CheckErrors { ExecutionTimeExpired, // contract post-conditions - RestrictAssetsExpectedListOfAllowances, + ExpectedListOfAllowances(String, i32), + AllowanceExprNotAllowed, + ExpectedAllowanceExpr(String), + WithAllAllowanceNotAllowed, } #[derive(Debug, PartialEq)] @@ -608,7 +611,10 @@ impl DiagnosableError for CheckErrors { CheckErrors::CostComputationFailed(s) => format!("contract cost computation failed: {s}"), CheckErrors::CouldNotDetermineSerializationType => "could not determine the input type for the serialization function".into(), CheckErrors::ExecutionTimeExpired => "execution time expired".into(), - CheckErrors::RestrictAssetsExpectedListOfAllowances => "restrict-assets? expects a list of asset allowances as its second argument".into(), + CheckErrors::ExpectedListOfAllowances(fn_name, arg_num) => format!("{fn_name} expects a list of asset allowances as argument {arg_num}"), + CheckErrors::AllowanceExprNotAllowed => "allowance expressions are only allowed in the context of a `restrict-assets?` or `as-contract?`".into(), + CheckErrors::ExpectedAllowanceExpr(got_name) => format!("expected an allowance expression, got: {got_name}"), + CheckErrors::WithAllAllowanceNotAllowed => "with-all-assets-unsafe is not allowed here, only in the allowance list for `as-contract?`".into(), } } diff --git a/clarity-types/src/types/signatures.rs b/clarity-types/src/types/signatures.rs index a8e49b92f9..d07492f5cd 100644 --- a/clarity-types/src/types/signatures.rs +++ b/clarity-types/src/types/signatures.rs @@ -261,6 +261,9 @@ lazy_static! { pub const ASCII_40: TypeSignature = SequenceType(SequenceSubtype::StringType( StringSubtype::ASCII(BufferLength(40)), )); +pub const ASCII_128: TypeSignature = SequenceType(SequenceSubtype::StringType( + StringSubtype::ASCII(BufferLength(128)), +)); pub const UTF8_40: TypeSignature = SequenceType(SequenceSubtype::StringType(StringSubtype::UTF8( StringUTF8Length(40), ))); diff --git a/clarity/src/vm/analysis/arithmetic_checker/mod.rs b/clarity/src/vm/analysis/arithmetic_checker/mod.rs index 95b459c227..65815a7a37 100644 --- a/clarity/src/vm/analysis/arithmetic_checker/mod.rs +++ b/clarity/src/vm/analysis/arithmetic_checker/mod.rs @@ -173,11 +173,31 @@ impl ArithmeticOnlyChecker<'_> { | ContractCall | StxTransfer | StxTransferMemo | StxBurn | AtBlock | GetStxBalance | GetTokenSupply | BurnToken | FromConsensusBuff | ToConsensusBuff | BurnAsset | StxGetAccount => Err(Error::FunctionNotPermitted(function)), - Append | Concat | AsMaxLen | ContractOf | PrincipalOf | ListCons | Print - | AsContract | ElementAt | ElementAtAlias | IndexOf | IndexOfAlias | Map | Filter - | Fold | Slice | ReplaceAt | ContractHash | RestrictAssets => { - Err(Error::FunctionNotPermitted(function)) - } + Append + | Concat + | AsMaxLen + | ContractOf + | PrincipalOf + | ListCons + | Print + | AsContract + | ElementAt + | ElementAtAlias + | IndexOf + | IndexOfAlias + | Map + | Filter + | Fold + | Slice + | ReplaceAt + | ContractHash + | RestrictAssets + | AsContractSafe + | AllowanceWithStx + | AllowanceWithFt + | AllowanceWithNft + | AllowanceWithStacking + | AllowanceAll => Err(Error::FunctionNotPermitted(function)), BuffToIntLe | BuffToUIntLe | BuffToIntBe | BuffToUIntBe => { Err(Error::FunctionNotPermitted(function)) } diff --git a/clarity/src/vm/analysis/read_only_checker/mod.rs b/clarity/src/vm/analysis/read_only_checker/mod.rs index 5eeb1b014f..80700cad66 100644 --- a/clarity/src/vm/analysis/read_only_checker/mod.rs +++ b/clarity/src/vm/analysis/read_only_checker/mod.rs @@ -282,20 +282,101 @@ impl<'a, 'b> ReadOnlyChecker<'a, 'b> { use crate::vm::functions::NativeFunctions::*; match function { - Add | Subtract | Divide | Multiply | CmpGeq | CmpLeq | CmpLess | CmpGreater - | Modulo | Power | Sqrti | Log2 | BitwiseXor | And | Or | Not | Hash160 | Sha256 - | Keccak256 | Equals | If | Sha512 | Sha512Trunc256 | Secp256k1Recover - | Secp256k1Verify | ConsSome | ConsOkay | ConsError | DefaultTo | UnwrapRet - | UnwrapErrRet | IsOkay | IsNone | Asserts | Unwrap | UnwrapErr | Match | IsErr - | IsSome | TryRet | ToUInt | ToInt | BuffToIntLe | BuffToUIntLe | BuffToIntBe - | BuffToUIntBe | IntToAscii | IntToUtf8 | StringToInt | StringToUInt | IsStandard - | ToConsensusBuff | PrincipalDestruct | PrincipalConstruct | Append | Concat - | AsMaxLen | ContractOf | PrincipalOf | ListCons | GetBlockInfo | GetBurnBlockInfo - | GetStacksBlockInfo | GetTenureInfo | TupleGet | TupleMerge | Len | Print - | AsContract | Begin | FetchVar | GetStxBalance | StxGetAccount | GetTokenBalance - | GetAssetOwner | GetTokenSupply | ElementAt | IndexOf | Slice | ReplaceAt - | BitwiseAnd | BitwiseOr | BitwiseNot | BitwiseLShift | BitwiseRShift | BitwiseXor2 - | ElementAtAlias | IndexOfAlias | ContractHash | ToAscii => { + Add + | Subtract + | Divide + | Multiply + | CmpGeq + | CmpLeq + | CmpLess + | CmpGreater + | Modulo + | Power + | Sqrti + | Log2 + | BitwiseXor + | And + | Or + | Not + | Hash160 + | Sha256 + | Keccak256 + | Equals + | If + | Sha512 + | Sha512Trunc256 + | Secp256k1Recover + | Secp256k1Verify + | ConsSome + | ConsOkay + | ConsError + | DefaultTo + | UnwrapRet + | UnwrapErrRet + | IsOkay + | IsNone + | Asserts + | Unwrap + | UnwrapErr + | Match + | IsErr + | IsSome + | TryRet + | ToUInt + | ToInt + | BuffToIntLe + | BuffToUIntLe + | BuffToIntBe + | BuffToUIntBe + | IntToAscii + | IntToUtf8 + | StringToInt + | StringToUInt + | IsStandard + | ToConsensusBuff + | PrincipalDestruct + | PrincipalConstruct + | Append + | Concat + | AsMaxLen + | ContractOf + | PrincipalOf + | ListCons + | GetBlockInfo + | GetBurnBlockInfo + | GetStacksBlockInfo + | GetTenureInfo + | TupleGet + | TupleMerge + | Len + | Print + | AsContract + | Begin + | FetchVar + | GetStxBalance + | StxGetAccount + | GetTokenBalance + | GetAssetOwner + | GetTokenSupply + | ElementAt + | IndexOf + | Slice + | ReplaceAt + | BitwiseAnd + | BitwiseOr + | BitwiseNot + | BitwiseLShift + | BitwiseRShift + | BitwiseXor2 + | ElementAtAlias + | IndexOfAlias + | ContractHash + | ToAscii + | AllowanceWithStx + | AllowanceWithFt + | AllowanceWithNft + | AllowanceWithStacking + | AllowanceAll => { // Check all arguments. self.check_each_expression_is_read_only(args) } @@ -434,9 +515,13 @@ impl<'a, 'b> ReadOnlyChecker<'a, 'b> { let asset_owner_read_only = self.check_read_only(&args[0])?; // Check the allowances argument. - let allowances = args[1] - .match_list() - .ok_or(CheckErrors::RestrictAssetsExpectedListOfAllowances)?; + let allowances = + args[1] + .match_list() + .ok_or(CheckErrors::ExpectedListOfAllowances( + "restrict-assets?".into(), + 2, + ))?; let allowances_read_only = self.check_each_expression_is_read_only(allowances)?; // Check the body expressions. @@ -444,6 +529,24 @@ impl<'a, 'b> ReadOnlyChecker<'a, 'b> { Ok(asset_owner_read_only && allowances_read_only && body_read_only) } + AsContractSafe => { + check_arguments_at_least(2, args)?; + + // Check the allowances argument. + let allowances = + args[0] + .match_list() + .ok_or(CheckErrors::ExpectedListOfAllowances( + "as-contract?".into(), + 1, + ))?; + let allowances_read_only = self.check_each_expression_is_read_only(allowances)?; + + // Check the body expressions. + let body_read_only = self.check_each_expression_is_read_only(&args[1..])?; + + Ok(allowances_read_only && body_read_only) + } } } diff --git a/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs index 99bb0583c8..459fb8a5bc 100644 --- a/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs @@ -16,15 +16,15 @@ use stacks_common::types::StacksEpochId; -use super::{TypeChecker, TypingContext, check_argument_count, check_arguments_at_least, no_type}; +use super::{check_argument_count, check_arguments_at_least, no_type, TypeChecker, TypingContext}; use crate::vm::analysis::errors::{CheckError, CheckErrors, SyntaxBindingErrorType}; use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::{analysis_typecheck_cost, runtime_cost}; use crate::vm::diagnostic::DiagnosableError; -use crate::vm::functions::{NativeFunctions, handle_binding_list}; +use crate::vm::functions::{handle_binding_list, NativeFunctions}; use crate::vm::types::{ - BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, BlockInfoProperty, FixedFunction, FunctionArg, - FunctionSignature, FunctionType, PrincipalData, TupleTypeSignature, TypeSignature, Value, + BlockInfoProperty, FixedFunction, FunctionArg, FunctionSignature, FunctionType, PrincipalData, + TupleTypeSignature, TypeSignature, Value, BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, }; use crate::vm::{ClarityName, ClarityVersion, SymbolicExpression, SymbolicExpressionType}; @@ -777,13 +777,43 @@ impl TypedNativeFunction { IsNone => Special(SpecialNativeFunction(&options::check_special_is_optional)), IsSome => Special(SpecialNativeFunction(&options::check_special_is_optional)), AtBlock => Special(SpecialNativeFunction(&check_special_at_block)), - ElementAtAlias | IndexOfAlias | BuffToIntLe | BuffToUIntLe | BuffToIntBe - | BuffToUIntBe | IsStandard | PrincipalDestruct | PrincipalConstruct | StringToInt - | StringToUInt | IntToAscii | IntToUtf8 | GetBurnBlockInfo | StxTransferMemo - | StxGetAccount | BitwiseAnd | BitwiseOr | BitwiseNot | BitwiseLShift - | BitwiseRShift | BitwiseXor2 | Slice | ToConsensusBuff | FromConsensusBuff - | ReplaceAt | GetStacksBlockInfo | GetTenureInfo | ContractHash | ToAscii - | RestrictAssets => { + ElementAtAlias + | IndexOfAlias + | BuffToIntLe + | BuffToUIntLe + | BuffToIntBe + | BuffToUIntBe + | IsStandard + | PrincipalDestruct + | PrincipalConstruct + | StringToInt + | StringToUInt + | IntToAscii + | IntToUtf8 + | GetBurnBlockInfo + | StxTransferMemo + | StxGetAccount + | BitwiseAnd + | BitwiseOr + | BitwiseNot + | BitwiseLShift + | BitwiseRShift + | BitwiseXor2 + | Slice + | ToConsensusBuff + | FromConsensusBuff + | ReplaceAt + | GetStacksBlockInfo + | GetTenureInfo + | ContractHash + | ToAscii + | RestrictAssets + | AsContractSafe + | AllowanceWithStx + | AllowanceWithFt + | AllowanceWithNft + | AllowanceWithStacking + | AllowanceAll => { return Err(CheckErrors::Expects( "Clarity 2+ keywords should not show up in 2.05".into(), )); diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index b7ed52dde1..ee016f2dbf 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -17,23 +17,23 @@ use stacks_common::types::StacksEpochId; use super::{ - TypeChecker, TypingContext, check_argument_count, check_arguments_at_least, - check_arguments_at_most, compute_typecheck_cost, no_type, + check_argument_count, check_arguments_at_least, check_arguments_at_most, + compute_typecheck_cost, no_type, TypeChecker, TypingContext, }; use crate::vm::analysis::errors::{CheckError, CheckErrors, SyntaxBindingErrorType}; use crate::vm::costs::cost_functions::ClarityCostFunction; -use crate::vm::costs::{CostErrors, CostTracker, analysis_typecheck_cost, runtime_cost}; +use crate::vm::costs::{analysis_typecheck_cost, runtime_cost, CostErrors, CostTracker}; use crate::vm::diagnostic::DiagnosableError; -use crate::vm::functions::{NativeFunctions, handle_binding_list}; +use crate::vm::functions::{handle_binding_list, NativeFunctions}; use crate::vm::types::signatures::{ - ASCII_40, CallableSubtype, FunctionArgSignature, FunctionReturnsSignature, SequenceSubtype, + CallableSubtype, FunctionArgSignature, FunctionReturnsSignature, SequenceSubtype, ASCII_40, TO_ASCII_MAX_BUFF, TO_ASCII_RESPONSE_STRING, UTF8_40, }; use crate::vm::types::{ - BUFF_1, BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, BlockInfoProperty, BufferLength, - BurnBlockInfoProperty, FixedFunction, FunctionArg, FunctionSignature, FunctionType, - MAX_VALUE_SIZE, PrincipalData, StacksBlockInfoProperty, TenureInfoProperty, TupleTypeSignature, - TypeSignature, Value, + BlockInfoProperty, BufferLength, BurnBlockInfoProperty, FixedFunction, FunctionArg, + FunctionSignature, FunctionType, PrincipalData, StacksBlockInfoProperty, TenureInfoProperty, + TupleTypeSignature, TypeSignature, Value, BUFF_1, BUFF_20, BUFF_32, BUFF_33, BUFF_64, BUFF_65, + MAX_VALUE_SIZE, }; use crate::vm::{ClarityName, ClarityVersion, SymbolicExpression, SymbolicExpressionType}; @@ -41,6 +41,7 @@ mod assets; mod conversions; mod maps; mod options; +mod post_conditions; mod sequences; #[allow(clippy::large_enum_variant)] @@ -819,42 +820,6 @@ fn check_get_tenure_info( Ok(TypeSignature::new_option(block_info_prop.type_result())?) } -fn check_restrict_assets( - checker: &mut TypeChecker, - args: &[SymbolicExpression], - context: &TypingContext, -) -> Result { - check_arguments_at_least(3, args)?; - - let asset_owner = &args[0]; - let allowance_list = args[1].match_list().ok_or(CheckError::new( - CheckErrors::RestrictAssetsExpectedListOfAllowances, - ))?; - let body_exprs = &args[2..]; - - runtime_cost( - ClarityCostFunction::AnalysisListItemsCheck, - checker, - allowance_list.len() + body_exprs.len(), - )?; - - checker.type_check_expects(asset_owner, context, &TypeSignature::PrincipalType)?; - - // TODO: type-check the allowances - - // Check the body expressions, ensuring any intermediate responses are handled - let mut last_return = None; - for expr in body_exprs { - let type_return = checker.type_check(expr, context)?; - if type_return.is_response_type() { - return Err(CheckErrors::UncheckedIntermediaryResponses.into()); - } - last_return = Some(type_return); - } - - last_return.ok_or_else(|| CheckError::new(CheckErrors::CheckerImplementationFailure)) -} - impl TypedNativeFunction { pub fn type_check_application( &self, @@ -1245,7 +1210,15 @@ impl TypedNativeFunction { CheckErrors::Expects("FATAL: Legal Clarity response type marked invalid".into()) })?, ))), - RestrictAssets => Special(SpecialNativeFunction(&check_restrict_assets)), + RestrictAssets => Special(SpecialNativeFunction( + &post_conditions::check_restrict_assets, + )), + AsContractSafe => Special(SpecialNativeFunction(&post_conditions::check_as_contract)), + AllowanceWithStx + | AllowanceWithFt + | AllowanceWithNft + | AllowanceWithStacking + | AllowanceAll => Special(SpecialNativeFunction(&post_conditions::check_allowance_err)), }; Ok(out) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs new file mode 100644 index 0000000000..8caa6279a4 --- /dev/null +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs @@ -0,0 +1,254 @@ +// 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 . + +use clarity_types::errors::analysis::{check_argument_count, check_arguments_at_least}; +use clarity_types::errors::{CheckError, CheckErrors}; +use clarity_types::representations::SymbolicExpression; +use clarity_types::types::signatures::ASCII_128; +use clarity_types::types::TypeSignature; + +use crate::vm::analysis::type_checker::contexts::TypingContext; +use crate::vm::analysis::type_checker::v2_1::TypeChecker; +use crate::vm::costs::cost_functions::ClarityCostFunction; +use crate::vm::costs::runtime_cost; +use crate::vm::functions::NativeFunctions; + +pub fn check_restrict_assets( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, +) -> Result { + check_arguments_at_least(3, args)?; + + let asset_owner = &args[0]; + let allowance_list = args[1] + .match_list() + .ok_or(CheckErrors::ExpectedListOfAllowances( + "restrict-assets?".into(), + 2, + ))?; + let body_exprs = &args[2..]; + + runtime_cost( + ClarityCostFunction::AnalysisListItemsCheck, + checker, + allowance_list.len() + body_exprs.len(), + )?; + + checker.type_check_expects(asset_owner, context, &TypeSignature::PrincipalType)?; + + for allowance in allowance_list { + check_allowance( + checker, + allowance, + context, + &NativeFunctions::RestrictAssets, + )?; + } + + // Check the body expressions, ensuring any intermediate responses are handled + let mut last_return = None; + for expr in body_exprs { + let type_return = checker.type_check(expr, context)?; + if type_return.is_response_type() { + return Err(CheckErrors::UncheckedIntermediaryResponses.into()); + } + last_return = Some(type_return); + } + + let ok_type = last_return.ok_or_else(|| CheckErrors::CheckerImplementationFailure)?; + Ok(TypeSignature::new_response( + ok_type, + TypeSignature::IntType, + )?) +} + +pub fn check_as_contract( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, +) -> Result { + check_arguments_at_least(2, args)?; + + let allowance_list = args[0] + .match_list() + .ok_or(CheckErrors::ExpectedListOfAllowances( + "as-contract?".into(), + 1, + ))?; + let body_exprs = &args[1..]; + + runtime_cost( + ClarityCostFunction::AnalysisListItemsCheck, + checker, + allowance_list.len() + body_exprs.len(), + )?; + + for allowance in allowance_list { + check_allowance( + checker, + allowance, + context, + &NativeFunctions::AsContractSafe, + )?; + } + + // Check the body expressions, ensuring any intermediate responses are handled + let mut last_return = None; + for expr in body_exprs { + let type_return = checker.type_check(expr, context)?; + if type_return.is_response_type() { + return Err(CheckErrors::UncheckedIntermediaryResponses.into()); + } + last_return = Some(type_return); + } + + let ok_type = last_return.ok_or_else(|| CheckErrors::CheckerImplementationFailure)?; + Ok(TypeSignature::new_response( + ok_type, + TypeSignature::IntType, + )?) +} + +/// Type-checking for allowance expressions. These are only allowed within the +/// context of an `restrict-assets?` or `as-contract?` expression. All other +/// uses will reach this function and return an error. +pub fn check_allowance_err( + _checker: &mut TypeChecker, + _args: &[SymbolicExpression], + _context: &TypingContext, +) -> Result { + Err(CheckErrors::AllowanceExprNotAllowed.into()) +} + +pub fn check_allowance( + checker: &mut TypeChecker, + allowance: &SymbolicExpression, + context: &TypingContext, + parent_expr: &NativeFunctions, +) -> Result<(), CheckError> { + let list = allowance + .match_list() + .ok_or(CheckErrors::ExpectedListApplication)?; + let (allowance_fn, args) = list + .split_first() + .ok_or(CheckErrors::ExpectedListApplication)?; + let function_name = allowance_fn + .match_atom() + .ok_or(CheckErrors::NonFunctionApplication)?; + let Some(ref native_function) = + NativeFunctions::lookup_by_name_at_version(function_name, &checker.clarity_version) + else { + return Err(CheckErrors::ExpectedAllowanceExpr(function_name.to_string()).into()); + }; + + match native_function { + NativeFunctions::AllowanceWithStx => { + check_allowance_with_stx(checker, args, context, parent_expr) + } + NativeFunctions::AllowanceWithFt => { + check_allowance_with_ft(checker, args, context, parent_expr) + } + NativeFunctions::AllowanceWithNft => { + check_allowance_with_nft(checker, args, context, parent_expr) + } + NativeFunctions::AllowanceWithStacking => { + check_allowance_with_stacking(checker, args, context, parent_expr) + } + NativeFunctions::AllowanceAll => check_allowance_all(checker, args, context, parent_expr), + _ => Err(CheckErrors::ExpectedAllowanceExpr(function_name.to_string()).into()), + } +} + +/// Type check a `with-stx` allowance expression. +/// `(with-stx amount:uint)` +fn check_allowance_with_stx( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, + _parent_expr: &NativeFunctions, +) -> Result<(), CheckError> { + check_argument_count(1, args)?; + + checker.type_check_expects(&args[0], context, &TypeSignature::UIntType)?; + + Ok(()) +} + +/// Type check a `with-ft` allowance expression. +/// `(with-ft contract-id:principal token-name:(string-ascii 128) amount:uint)` +fn check_allowance_with_ft( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, + _parent_expr: &NativeFunctions, +) -> Result<(), CheckError> { + check_argument_count(3, args)?; + + checker.type_check_expects(&args[0], context, &TypeSignature::PrincipalType)?; + checker.type_check_expects(&args[1], context, &ASCII_128)?; + checker.type_check_expects(&args[2], context, &TypeSignature::UIntType)?; + + Ok(()) +} + +/// Type check a `with-nft` allowance expression. +/// `(with-nft contract-id:principal token-name:(string-ascii 128) asset-id:any)` +fn check_allowance_with_nft( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, + _parent_expr: &NativeFunctions, +) -> Result<(), CheckError> { + check_argument_count(3, args)?; + + checker.type_check_expects(&args[0], context, &TypeSignature::PrincipalType)?; + checker.type_check_expects(&args[1], context, &ASCII_128)?; + // Asset ID can be any type + + Ok(()) +} + +/// Type check a `with-stacking` allowance expression. +/// `(with-stacking amount:uint)` +fn check_allowance_with_stacking( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, + _parent_expr: &NativeFunctions, +) -> Result<(), CheckError> { + check_argument_count(1, args)?; + + checker.type_check_expects(&args[0], context, &TypeSignature::UIntType)?; + + Ok(()) +} + +/// Type check an `with-all-assets-unsafe` allowance expression. +/// `(with-all-assets-unsafe)` +fn check_allowance_all( + _checker: &mut TypeChecker, + args: &[SymbolicExpression], + _context: &TypingContext, + parent_expr: &NativeFunctions, +) -> Result<(), CheckError> { + check_argument_count(0, args)?; + + if parent_expr != &NativeFunctions::AsContractSafe { + return Err(CheckErrors::WithAllAllowanceNotAllowed.into()); + } + + Ok(()) +} diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/mod.rs index ac978b277b..0aaac2174a 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/mod.rs @@ -39,6 +39,7 @@ use crate::vm::{execute_v2, ClarityName, ClarityVersion}; mod assets; pub mod contracts; +mod post_conditions; /// Backwards-compatibility shim for type_checker tests. Runs at latest Clarity version. pub fn mem_type_check(exp: &str) -> Result<(Option, ContractAnalysis), CheckError> { diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs new file mode 100644 index 0000000000..45dd4bd03f --- /dev/null +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -0,0 +1,817 @@ +// 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 . + +use clarity_types::errors::CheckErrors; +use clarity_types::types::TypeSignature; +use stacks_common::types::StacksEpochId; + +use crate::vm::analysis::type_checker::v2_1::tests::type_check_helper_version; +use crate::vm::tests::test_clarity_versions; +use crate::vm::ClarityVersion; + +/// Test type-checking for `restrict-assets?` expressions +#[apply(test_clarity_versions)] +fn test_restrict_assets(#[case] version: ClarityVersion, #[case] _epoch: StacksEpochId) { + let good = [ + // simple + ( + "(restrict-assets? tx-sender ((with-stx u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // literal asset owner + ( + "(restrict-assets? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4 ((with-stx u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // literal asset owner with contract id + ( + "(restrict-assets? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token ((with-stx u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // variable asset owner + ( + "(let ((p tx-sender)) + (restrict-assets? p ((with-stx u1000)) true))", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // no allowances + ( + "(restrict-assets? tx-sender () true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // multiple allowances + ( + "(restrict-assets? tx-sender ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" 0x01) (with-stacking u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // multiple body expressions + ( + "(restrict-assets? tx-sender ((with-stx u1000)) (+ u1 u2) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + ]; + let bad = [ + // with-all-assets-unsafe + ( + "(restrict-assets? tx-sender ((with-all-assets-unsafe)) true)", + CheckErrors::WithAllAllowanceNotAllowed, + ), + // no asset-owner + ( + "(restrict-assets? ((with-stx u5000)) true)", + CheckErrors::RequiresAtLeastArguments(3, 2), + ), + // no asset-owner, 3 args + ( + "(restrict-assets? ((with-stx u5000)) true true)", + CheckErrors::NonFunctionApplication, + ), + // bad asset-owner type + ( + "(restrict-assets? u100 ((with-stx u5000)) true)", + CheckErrors::TypeError( + TypeSignature::PrincipalType.into(), + TypeSignature::UIntType.into(), + ), + ), + // no allowances + ( + "(restrict-assets? tx-sender true)", + CheckErrors::RequiresAtLeastArguments(3, 2), + ), + // allowance not in list + ( + "(restrict-assets? tx-sender (with-stx u1) true)", + CheckErrors::ExpectedListApplication, + ), + // other value in place of allowance list + ( + "(restrict-assets? tx-sender u1 true)", + CheckErrors::ExpectedListOfAllowances("restrict-assets?".into(), 2), + ), + // non-allowance in allowance list + ( + "(restrict-assets? tx-sender (u1) true)", + CheckErrors::ExpectedListApplication, + ), + // empty list in allowance list + ( + "(restrict-assets? tx-sender (()) true)", + CheckErrors::NonFunctionApplication, + ), + // list with literal in allowance list + ( + "(restrict-assets? tx-sender ((123)) true)", + CheckErrors::NonFunctionApplication, + ), + // non-allowance function in allowance list + ( + "(restrict-assets? tx-sender ((foo)) true)", + CheckErrors::UnknownFunction("foo".into()), + ), + // no body expressions + ( + "(restrict-assets? tx-sender ((with-stx u5000)))", + CheckErrors::RequiresAtLeastArguments(3, 2), + ), + // unhandled response in only body expression + ( + "(restrict-assets? tx-sender ((with-stx u1000)) (err u1))", + CheckErrors::UncheckedIntermediaryResponses, + ), + // unhandled response in last body expression + ( + "(restrict-assets? tx-sender ((with-stx u1000)) true (err u1))", + CheckErrors::UncheckedIntermediaryResponses, + ), + // unhandled response in other body expression + ( + "(restrict-assets? tx-sender ((with-stx u1000)) (err u1) true)", + CheckErrors::UncheckedIntermediaryResponses, + ), + ]; + + for (good_code, expected_type) in &good { + info!("test good code: '{}'", good_code); + if version < ClarityVersion::Clarity4 { + // restrict-assets? is only available in Clarity 4+ + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(good_code, version) + .unwrap_err() + .err + ); + } else { + assert_eq!( + expected_type, + &type_check_helper_version(good_code, version).unwrap() + ); + } + } + + for (bad_code, expected_err) in &bad { + info!("test bad code: '{}'", bad_code); + if version < ClarityVersion::Clarity4 { + // restrict-assets? is only available in Clarity 4+ + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(bad_code, version) + .unwrap_err() + .err + ); + } else { + assert_eq!( + expected_err, + type_check_helper_version(bad_code, version) + .unwrap_err() + .err + .as_ref() + ); + } + } +} + +/// Test type-checking for `as-contract?` expressions +#[apply(test_clarity_versions)] +fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpochId) { + let good = [ + // simple + ( + "(as-contract? ((with-stx u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // no allowances + ( + "(as-contract? () true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // multiple allowances + ( + "(as-contract? ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" 0x01) (with-stacking u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // multiple body expressions + ( + "(as-contract? ((with-stx u1000)) (+ u1 u2) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // with-all-assets-unsafe + ( + "(as-contract? ((with-all-assets-unsafe)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + + ]; + let bad = [ + // no allowances + ( + "(as-contract? true)", + CheckErrors::RequiresAtLeastArguments(2, 1), + ), + // allowance not in list + ( + "(as-contract? (with-stx u1) true)", + CheckErrors::ExpectedListApplication, + ), + // other value in place of allowance list + ( + "(as-contract? u1 true)", + CheckErrors::ExpectedListOfAllowances("as-contract?".into(), 1), + ), + // non-allowance in allowance list + ( + "(as-contract? (u1) true)", + CheckErrors::ExpectedListApplication, + ), + // empty list in allowance list + ( + "(as-contract? (()) true)", + CheckErrors::NonFunctionApplication, + ), + // list with literal in allowance list + ( + "(as-contract? ((123)) true)", + CheckErrors::NonFunctionApplication, + ), + // non-allowance function in allowance list + ( + "(as-contract? ((foo)) true)", + CheckErrors::UnknownFunction("foo".into()), + ), + // no body expressions + ( + "(as-contract? ((with-stx u5000)))", + CheckErrors::RequiresAtLeastArguments(2, 1), + ), + // unhandled response in only body expression + ( + "(as-contract? ((with-stx u1000)) (err u1))", + CheckErrors::UncheckedIntermediaryResponses, + ), + // unhandled response in last body expression + ( + "(as-contract? ((with-stx u1000)) true (err u1))", + CheckErrors::UncheckedIntermediaryResponses, + ), + // unhandled response in other body expression + ( + "(as-contract? ((with-stx u1000)) (err u1) true)", + CheckErrors::UncheckedIntermediaryResponses, + ), + ]; + + for (code, expected_type) in &good { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + // as-contract? is only available in Clarity 4+ + assert_eq!( + CheckErrors::UnknownFunction("as-contract?".to_string()), + *type_check_helper_version(code, version) + .unwrap_err() + .err + ); + } else { + assert_eq!( + expected_type, + &type_check_helper_version(code, version).unwrap() + ); + } + } + + for (code, expected_err) in &bad { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + // as-contract? is only available in Clarity 4+ + assert_eq!( + CheckErrors::UnknownFunction("as-contract?".to_string()), + *type_check_helper_version(code, version) + .unwrap_err() + .err + ); + } else { + assert_eq!( + expected_err, + type_check_helper_version(code, version) + .unwrap_err() + .err + .as_ref() + ); + } + } +} + +/// Test type-checking for `with-stx` allowance expressions +#[apply(test_clarity_versions)] +fn test_with_stx_allowance(#[case] version: ClarityVersion, #[case] _epoch: StacksEpochId) { + let good = [ + // basic usage + ( + "(restrict-assets? tx-sender ((with-stx u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // zero amount + ( + "(restrict-assets? tx-sender ((with-stx u0)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // large amount + ( + "(restrict-assets? tx-sender ((with-stx u340282366920938463463374607431768211455)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + // variable amount + ( + "(let ((amount u1000)) (restrict-assets? tx-sender ((with-stx amount)) true))", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + ), + ]; + + let bad = [ + // no arguments + ( + "(restrict-assets? tx-sender ((with-stx)) true)", + CheckErrors::IncorrectArgumentCount(1, 0), + ), + // too many arguments + ( + "(restrict-assets? tx-sender ((with-stx u1000 u2000)) true)", + CheckErrors::IncorrectArgumentCount(1, 2), + ), + // wrong type - string instead of uint + ( + r#"(restrict-assets? tx-sender ((with-stx "1000")) true)"#, + CheckErrors::TypeError( + TypeSignature::UIntType.into(), + TypeSignature::new_string_ascii(4).unwrap().into(), + ), + ), + // wrong type - int instead of uint + ( + "(restrict-assets? tx-sender ((with-stx 1000)) true)", + CheckErrors::TypeError( + TypeSignature::UIntType.into(), + TypeSignature::IntType.into(), + ), + ), + ]; + + for (code, expected_type) in &good { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_type, + &type_check_helper_version(code, version).unwrap() + ); + } + } + + for (code, expected_err) in &bad { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_err, + type_check_helper_version(code, version) + .unwrap_err() + .err + .as_ref() + ); + } + } +} + +/// Test type-checking for `with-ft` allowance expressions +#[apply(test_clarity_versions)] +fn test_with_ft_allowance(#[case] version: ClarityVersion, #[case] _epoch: StacksEpochId) { + let good = [ + // basic usage with shortcut contract principal + ( + r#"(restrict-assets? tx-sender ((with-ft .token "token-name" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // full literal principal + ( + r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // variable principal + ( + r#"(let ((contract .token)) (restrict-assets? tx-sender ((with-ft contract "token-name" u1000)) true))"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // variable token name + ( + r#"(let ((name "token-name")) (restrict-assets? tx-sender ((with-ft .token name u1000)) true))"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // variable amount + ( + r#"(let ((amount u1000)) (restrict-assets? tx-sender ((with-ft .token "token-name" amount)) true))"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // "*" token name + ( + r#"(restrict-assets? tx-sender ((with-ft .token "*" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // empty token name + ( + r#"(restrict-assets? tx-sender ((with-ft .token "" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + ]; + + let bad = [ + // no arguments + ( + "(restrict-assets? tx-sender ((with-ft)) true)", + CheckErrors::IncorrectArgumentCount(3, 0), + ), + // one argument + ( + "(restrict-assets? tx-sender ((with-ft .token)) true)", + CheckErrors::IncorrectArgumentCount(3, 1), + ), + // two arguments + ( + r#"(restrict-assets? tx-sender ((with-ft .token "token-name")) true)"#, + CheckErrors::IncorrectArgumentCount(3, 2), + ), + // too many arguments + ( + r#"(restrict-assets? tx-sender ((with-ft .token "token-name" u1000 u2000)) true)"#, + CheckErrors::IncorrectArgumentCount(3, 4), + ), + // wrong type for contract-id - uint instead of principal + ( + r#"(restrict-assets? tx-sender ((with-ft u123 "token-name" u1000)) true)"#, + CheckErrors::TypeError( + TypeSignature::PrincipalType.into(), + TypeSignature::UIntType.into(), + ), + ), + // wrong type for token-name - uint instead of string + ( + "(restrict-assets? tx-sender ((with-ft .token u123 u1000)) true)", + CheckErrors::TypeError( + TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::UIntType.into(), + ), + ), + // wrong type for amount - string instead of uint + ( + r#"(restrict-assets? tx-sender ((with-ft .token "token-name" "1000")) true)"#, + CheckErrors::TypeError( + TypeSignature::UIntType.into(), + TypeSignature::new_string_ascii(4).unwrap().into(), + ), + ), + // wrong type for amount - int instead of uint + ( + r#"(restrict-assets? tx-sender ((with-ft .token "token-name" 1000)) true)"#, + CheckErrors::TypeError( + TypeSignature::UIntType.into(), + TypeSignature::IntType.into(), + ), + ), + // too long token name (longer than 128 chars) + ( + "(restrict-assets? tx-sender ((with-ft .token \"this-token-name-is-way-too-long-to-be-valid-because-it-has-more-than-one-hundred-and-twenty-eight-characters-in-it-so-it-is-not-a-valid-token-name\" u1000)) true)", + CheckErrors::TypeError( + TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::new_string_ascii(146).unwrap().into(), + ), + ), + ]; + + for (code, expected_type) in &good { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_type, + &type_check_helper_version(code, version).unwrap() + ); + } + } + + for (code, expected_err) in &bad { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_err, + type_check_helper_version(code, version) + .unwrap_err() + .err + .as_ref() + ); + } + } +} + +/// Test type-checking for `with-nft` allowance expressions +#[apply(test_clarity_versions)] +fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: StacksEpochId) { + let good = [ + // basic usage with shortcut contract principal + ( + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // full literal principal + ( + r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // variable principal + ( + r#"(let ((contract .token)) (restrict-assets? tx-sender ((with-nft contract "token-name" u1000)) true))"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // variable token name + ( + r#"(let ((name "token-name")) (restrict-assets? tx-sender ((with-nft .token name u1000)) true))"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // "*" token name + ( + r#"(restrict-assets? tx-sender ((with-nft .token "*" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // empty token name + ( + r#"(restrict-assets? tx-sender ((with-nft .token "" u1000)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // string asset-id + ( + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" "asset-123")) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // buffer asset-id + ( + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" 0x0123456789)) true)"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // variable asset-id + ( + r#"(let ((asset-id u123)) (restrict-assets? tx-sender ((with-nft .token "token-name" asset-id)) true))"#, + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + ]; + + let bad = [ + // no arguments + ( + "(restrict-assets? tx-sender ((with-nft)) true)", + CheckErrors::IncorrectArgumentCount(3, 0), + ), + // one argument + ( + "(restrict-assets? tx-sender ((with-nft .token)) true)", + CheckErrors::IncorrectArgumentCount(3, 1), + ), + // two arguments + ( + r#"(restrict-assets? tx-sender ((with-nft .token "token-name")) true)"#, + CheckErrors::IncorrectArgumentCount(3, 2), + ), + // too many arguments + ( + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" u123 u456)) true)"#, + CheckErrors::IncorrectArgumentCount(3, 4), + ), + // wrong type for contract-id - uint instead of principal + ( + r#"(restrict-assets? tx-sender ((with-nft u123 "token-name" u456)) true)"#, + CheckErrors::TypeError( + TypeSignature::PrincipalType.into(), + TypeSignature::UIntType.into(), + ), + ), + // wrong type for token-name - uint instead of string + ( + "(restrict-assets? tx-sender ((with-nft .token u123 u456)) true)", + CheckErrors::TypeError( + TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::UIntType.into(), + ), + ), + // too long token name (longer than 128 chars) + ( + "(restrict-assets? tx-sender ((with-ft .token \"this-token-name-is-way-too-long-to-be-valid-because-it-has-more-than-one-hundred-and-twenty-eight-characters-in-it-so-it-is-not-a-valid-token-name\" u1000)) true)", + CheckErrors::TypeError( + TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::new_string_ascii(146).unwrap().into(), + ), + ), + ]; + + for (code, expected_type) in &good { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_type, + &type_check_helper_version(code, version).unwrap() + ); + } + } + + for (code, expected_err) in &bad { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_err, + type_check_helper_version(code, version) + .unwrap_err() + .err + .as_ref() + ); + } + } +} + +/// Test type-checking for `with-stacking` allowance expressions +#[apply(test_clarity_versions)] +fn test_with_stacking_allowance(#[case] version: ClarityVersion, #[case] _epoch: StacksEpochId) { + let good = [ + // basic usage + ( + "(restrict-assets? tx-sender ((with-stacking u1000)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // zero amount + ( + "(restrict-assets? tx-sender ((with-stacking u0)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + // variable amount + ( + "(let ((amount u1000)) (restrict-assets? tx-sender ((with-stacking amount)) true))", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + ]; + + let bad = [ + // no arguments + ( + "(restrict-assets? tx-sender ((with-stacking)) true)", + CheckErrors::IncorrectArgumentCount(1, 0), + ), + // too many arguments + ( + "(restrict-assets? tx-sender ((with-stacking u1000 u2000)) true)", + CheckErrors::IncorrectArgumentCount(1, 2), + ), + // wrong type - string instead of uint + ( + r#"(restrict-assets? tx-sender ((with-stacking "1000")) true)"#, + CheckErrors::TypeError( + TypeSignature::UIntType.into(), + TypeSignature::new_string_ascii(4).unwrap().into(), + ), + ), + // wrong type - int instead of uint + ( + "(restrict-assets? tx-sender ((with-stacking 1000)) true)", + CheckErrors::TypeError( + TypeSignature::UIntType.into(), + TypeSignature::IntType.into(), + ), + ), + ]; + + for (code, expected_type) in &good { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_type, + &type_check_helper_version(code, version).unwrap() + ); + } + } + + for (code, expected_err) in &bad { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_err, + type_check_helper_version(code, version) + .unwrap_err() + .err + .as_ref() + ); + } + } +} + +/// Test type-checking for `with-all-assets-unsafe` allowance expressions +#[apply(test_clarity_versions)] +fn test_with_all_assets_unsafe_allowance( + #[case] version: ClarityVersion, + #[case] _epoch: StacksEpochId, +) { + let good = [ + // basic usage + ( + "(as-contract? ((with-all-assets-unsafe)) true)", + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + ), + ]; + + let bad = [ + // with-all-assets-unsafe in restrict-assets? (not allowed) + ( + "(restrict-assets? tx-sender ((with-all-assets-unsafe)) true)", + CheckErrors::WithAllAllowanceNotAllowed, + ), + // with-all-assets-unsafe with arguments (should take 0) + ( + "(restrict-assets? tx-sender ((with-all-assets-unsafe u123)) true)", + CheckErrors::IncorrectArgumentCount(0, 1), + ), + ]; + + for (code, expected_type) in &good { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("as-contract?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_type, + &type_check_helper_version(code, version).unwrap() + ); + } + } + + for (code, expected_err) in &bad { + info!("test code: '{}'", code); + if version < ClarityVersion::Clarity4 { + assert_eq!( + CheckErrors::UnknownFunction("restrict-assets?".to_string()), + *type_check_helper_version(code, version).unwrap_err().err + ); + } else { + assert_eq!( + expected_err, + type_check_helper_version(code, version) + .unwrap_err() + .err + .as_ref() + ); + } + } +} diff --git a/clarity/src/vm/costs/cost_functions.rs b/clarity/src/vm/costs/cost_functions.rs index ac9aa39c2b..055232ff4e 100644 --- a/clarity/src/vm/costs/cost_functions.rs +++ b/clarity/src/vm/costs/cost_functions.rs @@ -160,6 +160,7 @@ define_named_enum!(ClarityCostFunction { ContractHash("cost_contract_hash"), ToAscii("cost_to_ascii"), RestrictAssets("cost_restrict_assets"), + AsContractSafe("cost_as_contract_safe"), Unimplemented("cost_unimplemented"), }); @@ -332,6 +333,7 @@ pub trait CostValues { fn cost_contract_hash(n: u64) -> InterpreterResult; fn cost_to_ascii(n: u64) -> InterpreterResult; fn cost_restrict_assets(n: u64) -> InterpreterResult; + fn cost_as_contract_safe(n: u64) -> InterpreterResult; } impl ClarityCostFunction { @@ -487,6 +489,7 @@ impl ClarityCostFunction { ClarityCostFunction::ContractHash => C::cost_contract_hash(n), ClarityCostFunction::ToAscii => C::cost_to_ascii(n), ClarityCostFunction::RestrictAssets => C::cost_restrict_assets(n), + ClarityCostFunction::AsContractSafe => C::cost_as_contract_safe(n), ClarityCostFunction::Unimplemented => Err(RuntimeErrorType::NotImplemented.into()), } } diff --git a/clarity/src/vm/costs/costs_1.rs b/clarity/src/vm/costs/costs_1.rs index 00f3d53ce1..7afbe365a9 100644 --- a/clarity/src/vm/costs/costs_1.rs +++ b/clarity/src/vm/costs/costs_1.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use super::ExecutionCost; /// This file implements the cost functions from costs.clar in Rust. -use super::cost_functions::{CostValues, linear, logn, nlogn}; +use super::cost_functions::{linear, logn, nlogn, CostValues}; +use super::ExecutionCost; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs1; @@ -757,4 +757,8 @@ impl CostValues for Costs1 { fn cost_restrict_assets(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_as_contract_safe(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_2.rs b/clarity/src/vm/costs/costs_2.rs index 56d1921acf..bdb4fa3e81 100644 --- a/clarity/src/vm/costs/costs_2.rs +++ b/clarity/src/vm/costs/costs_2.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use super::ExecutionCost; /// This file implements the cost functions from costs-2.clar in Rust. -use super::cost_functions::{CostValues, linear, logn, nlogn}; +use super::cost_functions::{linear, logn, nlogn, CostValues}; +use super::ExecutionCost; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs2; @@ -757,4 +757,8 @@ impl CostValues for Costs2 { fn cost_restrict_assets(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_as_contract_safe(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_2_testnet.rs b/clarity/src/vm/costs/costs_2_testnet.rs index 919a0c71c2..d942d83019 100644 --- a/clarity/src/vm/costs/costs_2_testnet.rs +++ b/clarity/src/vm/costs/costs_2_testnet.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use super::ExecutionCost; /// This file implements the cost functions from costs-2-testnet.clar in Rust. -use super::cost_functions::{CostValues, linear, logn, nlogn}; +use super::cost_functions::{linear, logn, nlogn, CostValues}; +use super::ExecutionCost; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs2Testnet; @@ -757,4 +757,8 @@ impl CostValues for Costs2Testnet { fn cost_restrict_assets(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_as_contract_safe(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_3.rs b/clarity/src/vm/costs/costs_3.rs index d9dfa0482d..46ae6796ed 100644 --- a/clarity/src/vm/costs/costs_3.rs +++ b/clarity/src/vm/costs/costs_3.rs @@ -13,9 +13,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use super::ExecutionCost; /// This file implements the cost functions from costs-3.clar in Rust. -use super::cost_functions::{CostValues, linear, logn, nlogn}; +use super::cost_functions::{linear, logn, nlogn, CostValues}; +use super::ExecutionCost; use crate::vm::errors::{InterpreterResult, RuntimeErrorType}; pub struct Costs3; @@ -775,4 +775,8 @@ impl CostValues for Costs3 { fn cost_restrict_assets(n: u64) -> InterpreterResult { Err(RuntimeErrorType::NotImplemented.into()) } + + fn cost_as_contract_safe(n: u64) -> InterpreterResult { + Err(RuntimeErrorType::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_4.rs b/clarity/src/vm/costs/costs_4.rs index 52304f8a41..aca4731eb2 100644 --- a/clarity/src/vm/costs/costs_4.rs +++ b/clarity/src/vm/costs/costs_4.rs @@ -13,7 +13,6 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use super::ExecutionCost; /// This file implements the cost functions from costs-4.clar in Rust. /// For Clarity 4, all cost functions are the same as in costs-3, except /// for the new `cost_contract_hash` function. To avoid duplication, this @@ -21,6 +20,7 @@ use super::ExecutionCost; /// overrides only `cost_contract_hash`. use super::cost_functions::CostValues; use super::costs_3::Costs3; +use super::ExecutionCost; use crate::vm::costs::cost_functions::linear; use crate::vm::errors::InterpreterResult; @@ -467,4 +467,9 @@ impl CostValues for Costs4 { // TODO: needs criterion benchmark Ok(ExecutionCost::runtime(linear(n, 1, 100))) } + + fn cost_as_contract_safe(n: u64) -> InterpreterResult { + // TODO: needs criterion benchmark + Ok(ExecutionCost::runtime(linear(n, 1, 100))) + } } diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index 88f551c215..202d7c318a 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -15,13 +15,13 @@ // along with this program. If not, see . use super::types::signatures::{FunctionArgSignature, FunctionReturnsSignature}; -use crate::vm::ClarityVersion; -use crate::vm::analysis::type_checker::v2_1::TypedNativeFunction; use crate::vm::analysis::type_checker::v2_1::natives::SimpleNativeFunction; -use crate::vm::functions::NativeFunctions; +use crate::vm::analysis::type_checker::v2_1::TypedNativeFunction; use crate::vm::functions::define::DefineFunctions; +use crate::vm::functions::NativeFunctions; use crate::vm::types::{FixedFunction, FunctionType}; use crate::vm::variables::NativeVariables; +use crate::vm::ClarityVersion; #[cfg(feature = "rusqlite")] pub mod contracts; @@ -102,7 +102,8 @@ const BLOCK_HEIGHT: SimpleKeywordAPI = SimpleKeywordAPI { description: "Returns the current block height of the Stacks blockchain in Clarity 1 and 2. Upon activation of epoch 3.0, `block-height` will return the same value as `tenure-height`. In Clarity 3, `block-height` is removed and has been replaced with `stacks-block-height`.", - example: "(> block-height u1000) ;; returns true if the current block-height has passed 1000 blocks.", + example: + "(> block-height u1000) ;; returns true if the current block-height has passed 1000 blocks.", }; const BURN_BLOCK_HEIGHT: SimpleKeywordAPI = SimpleKeywordAPI { @@ -191,7 +192,8 @@ const REGTEST_KEYWORD: SimpleKeywordAPI = SimpleKeywordAPI { snippet: "is-in-regtest", output_type: "bool", description: "Returns whether or not the code is running in a regression test", - example: "(print is-in-regtest) ;; Will print 'true' if the code is running in a regression test", + example: + "(print is-in-regtest) ;; Will print 'true' if the code is running in a regression test", }; const MAINNET_KEYWORD: SimpleKeywordAPI = SimpleKeywordAPI { @@ -566,7 +568,8 @@ const BITWISE_XOR_API: SimpleFunctionAPI = SimpleFunctionAPI { name: None, snippet: "bit-xor ${1:expr-1} ${2:expr-2}", signature: "(bit-xor i1 i2...)", - description: "Returns the result of bitwise exclusive or'ing a variable number of integer inputs.", + description: + "Returns the result of bitwise exclusive or'ing a variable number of integer inputs.", example: "(bit-xor 1 2) ;; Returns 3 (bit-xor 120 280) ;; Returns 352 (bit-xor -128 64) ;; Returns -64 @@ -592,7 +595,8 @@ const BITWISE_OR_API: SimpleFunctionAPI = SimpleFunctionAPI { name: None, snippet: "bit-or ${1:expr-1} ${2:expr-2}", signature: "(bit-or i1 i2...)", - description: "Returns the result of bitwise inclusive or'ing a variable number of integer inputs.", + description: + "Returns the result of bitwise inclusive or'ing a variable number of integer inputs.", example: "(bit-or 4 8) ;; Returns 12 (bit-or 1 2 4) ;; Returns 7 (bit-or 64 -32 -16) ;; Returns -16 @@ -1578,7 +1582,8 @@ If the supplied argument is an `(ok ...)` value, }; const MATCH_API: SpecialAPI = SpecialAPI { - input_type: "(optional A) name expression expression | (response A B) name expression name expression", + input_type: + "(optional A) name expression expression | (response A B) name expression name expression", snippet: "match ${1:algebraic-expr} ${2:some-binding-name} ${3:some-branch} ${4:none-branch}", output_type: "C", signature: "(match opt-input some-binding-name some-branch none-branch) | @@ -2592,6 +2597,164 @@ error-prone). Returns: "#, }; +const AS_CONTRACT_SAFE: SpecialAPI = SpecialAPI { + input_type: "((Allowance)*), AnyType, ... A", + snippet: "as-contract? (${1:allowance-1} ${2:allowance-2}) ${3:expr-1}", + output_type: "(response A int)", + signature: "(as-contract? ((with-stx|with-ft|with-nft|with-stacking)*) expr-body1 expr-body2 ... expr-body-last)", + description: "Switches the current context's `tx-sender` and +`contract-caller` values to the contract's principal and executes the body +expressions within that context, then checks the asset outflows from the +contract against the granted allowances, in declaration order. If any +allowance is violated, the body expressions are reverted, an error is +returned, and an event is emitted with the full details of the violation to +help with debugging. Note that the allowance setup expressions are evaluated +before executing the body expressions. The final body expression cannot +return a `response` value in order to avoid returning a nested `response` +value from `as-contract?` (nested responses are error-prone). Returns: +* `(ok x)` if the outflows are within the allowances, where `x` is the + result of the final body expression and has type `A`. +* `(err index)` if an allowance was violated, where `index` is the 0-based + index of the first violated allowance in the list of granted allowances, + or -1 if an asset with no allowance caused the violation.", + example: r#" +(define-public (foo) + (as-contract? () + (try! (stx-transfer? u1000000 tx-sender recipient)) + ) +) ;; Returns (err -1) +(define-public (bar) + (as-contract? ((with-stx u1000000)) + (try! (stx-transfer? u1000000 tx-sender recipient)) + ) +) ;; Returns (ok true) +"#, +}; + +const ALLOWANCE_WITH_STX: SpecialAPI = SpecialAPI { + input_type: "uint", + snippet: "with-stx ${1:amount}", + output_type: "Allowance", + signature: "(with-stx amount)", + description: "Adds an outflow allowance for `amount` uSTX from the +`asset-owner` of the enclosing `restrict-assets?` or `as-contract?` +expression. `with-stx` is not allowed outside of `restrict-assets?` or +`as-contract?` contexts.", + example: r#" +(restrict-assets? tx-sender + ((with-stx u1000000)) + (try! (stx-transfer? u2000000 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) +) ;; Returns (err 0) +(restrict-assets? tx-sender + ((with-stx u1000000)) + (try! (stx-transfer? u1000000 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) +) ;; Returns (ok true) +"#, +}; + +const ALLOWANCE_WITH_FT: SpecialAPI = SpecialAPI { + input_type: "principal (string-ascii 128) uint", + snippet: "with-ft ${1:contract-id} ${2:token-name} ${3:amount}", + output_type: "Allowance", + signature: "(with-ft contract-id token-name amount)", + description: r#"Adds an outflow allowance for `amount` of the fungible +token defined in `contract-id` with name `token-name` from the `asset-owner` +of the enclosing `restrict-assets?` or `as-contract?` expression. `with-ft` is +not allowed outside of `restrict-assets?` or `as-contract?` contexts. Note that +`token-name` should match the name used in the `define-fungible-token` call in +the contract. When `"*"` is used for the token name, the allowance applies to +**all** FTs defined in `contract-id`."#, + example: r#" +(restrict-assets? tx-sender + ((with-ft (contract-of token-trait) "stackaroo" u50)) + (try! (contract-call? token-trait transfer u100 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none)) +) ;; Returns (err 0) +(restrict-assets? tx-sender + ((with-ft (contract-of token-trait) "stackaroo" u50)) + (try! (contract-call? token-trait transfer u20 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none)) +) ;; Returns (ok true) +"#, +}; + +const ALLOWANCE_WITH_NFT: SpecialAPI = SpecialAPI { + input_type: "principal (string-ascii 128) T", + snippet: "with-nft ${1:contract-id} ${2:asset-name} ${3:asset-identifier}", + output_type: "Allowance", + signature: "(with-nft contract-id asset-name identifier)", + description: r#"Adds an outflow allowance for the non-fungible token +identified by `identifier` defined in `contract-id` with name `token-name` +from the `asset-owner` of the enclosing `restrict-assets?` or `as-contract?` +expression. `with-nft` is not allowed outside of `restrict-assets?` or +`as-contract?` contexts. Note that `token-name` should match the name used in +the `define-non-fungible-token` call in the contract. When `"*"` is used for +the token name, the allowance applies to **all** NFTs defined in `contract-id`."#, + example: r#" +(restrict-assets? tx-sender + ((with-nft (contract-of nft-trait) "stackaroo" u123)) + (try! (contract-call? nft-trait transfer u4 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) +) ;; Returns (err 0) +(restrict-assets? tx-sender + ((with-nft (contract-of nft-trait) "stackaroo" u123)) + (try! (contract-call? nft-trait transfer u123 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) +) ;; Returns (ok true) +"#, +}; + +const ALLOWANCE_WITH_STACKING: SpecialAPI = SpecialAPI { + input_type: "uint", + snippet: "with-stacking ${1:amount}", + output_type: "Allowance", + signature: "(with-stacking amount)", + description: "Adds a stacking allowance for `amount` uSTX from the +`asset-owner` of the enclosing `restrict-assets?` or `as-contract?` +expression. `with-stacking` is not allowed outside of `restrict-assets?` or +`as-contract?` contexts. This restricts calls to `delegate-stx` and +`stack-stx` in the active PoX contract to lock up to the amount of uSTX +specified.", + example: r#" +(restrict-assets? tx-sender + ((with-stacking u1000000000000)) + (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx + u1100000000000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none + )) +) ;; Returns (err 0) +(restrict-assets? tx-sender + ((with-stacking u1000000000000)) + (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx + u900000000000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none + )) +) ;; Returns (ok true) +"#, +}; + +const ALLOWANCE_WITH_ALL: SpecialAPI = SpecialAPI { + input_type: "N/A", + snippet: "with-all-assets-unsafe", + output_type: "Allowance", + signature: "(with-all-assets-unsafe)", + description: "Grants unrestricted access to all assets of the contract to +the enclosing `as-contract?` expression. `with-stacking` is not allowed outside +of `as-contract?` contexts. Note that this is not allowed in `restrict-assets?` +and will trigger an analysis error, since usage there does not make sense (i.e. +just remove the `restrict-assets?` instead). +**_⚠️ Security Warning: This should be used with extreme caution, as it +effectively disables all asset protection for the contract. ⚠️_** This +dangerous allowance should only be used when the code executing within the +`as-contract?` body is verified to be trusted through other means (e.g. +checking traits against an allow list, passed in from a trusted caller), and +even then the more restrictive allowances should be preferred when possible.", + example: r#" +(define-public (execute-trait (trusted-trait )) + (begin + (asserts! (is-eq contract-caller TRUSTED_CALLER) ERR_UNTRUSTED_CALLER) + (as-contract? ((with-all-assets-unsafe)) + (contract-call? trusted-trait execute) + ) + ) +) +"#, +}; + pub fn make_api_reference(function: &NativeFunctions) -> FunctionAPI { use crate::vm::functions::NativeFunctions::*; let name = function.get_name(); @@ -2707,6 +2870,12 @@ pub fn make_api_reference(function: &NativeFunctions) -> FunctionAPI { ContractHash => make_for_simple_native(&CONTRACT_HASH, function, name), ToAscii => make_for_special(&TO_ASCII, function), RestrictAssets => make_for_special(&RESTRICT_ASSETS, function), + AsContractSafe => make_for_special(&AS_CONTRACT_SAFE, function), + AllowanceWithStx => make_for_special(&ALLOWANCE_WITH_STX, function), + AllowanceWithFt => make_for_special(&ALLOWANCE_WITH_FT, function), + AllowanceWithNft => make_for_special(&ALLOWANCE_WITH_NFT, function), + AllowanceWithStacking => make_for_special(&ALLOWANCE_WITH_STACKING, function), + AllowanceAll => make_for_special(&ALLOWANCE_WITH_ALL, function), } } @@ -2818,11 +2987,11 @@ pub fn make_json_api_reference() -> String { #[cfg(test)] mod test { use stacks_common::consts::{CHAIN_ID_TESTNET, PEER_VERSION_EPOCH_2_1}; - use stacks_common::types::StacksEpochId; use stacks_common::types::chainstate::{ BlockHeaderHash, BurnchainHeaderHash, ConsensusHash, SortitionId, StacksAddress, StacksBlockId, VRFSeed, }; + use stacks_common::types::StacksEpochId; use stacks_common::util::hash::hex_bytes; use super::{get_input_type_string, make_all_api_reference, make_json_api_reference}; @@ -2833,13 +3002,13 @@ mod test { BurnStateDB, ClarityDatabase, HeadersDB, MemoryBackingStore, STXBalance, }; use crate::vm::docs::get_output_type_string; - use crate::vm::types::signatures::{ASCII_40, FunctionArgSignature, FunctionReturnsSignature}; + use crate::vm::types::signatures::{FunctionArgSignature, FunctionReturnsSignature, ASCII_40}; use crate::vm::types::{ FunctionType, PrincipalData, QualifiedContractIdentifier, TupleData, TypeSignature, }; use crate::vm::{ - ClarityVersion, ContractContext, GlobalContext, LimitedCostTracker, StacksEpoch, Value, - ast, eval_all, execute, + ast, eval_all, execute, ClarityVersion, ContractContext, GlobalContext, LimitedCostTracker, + StacksEpoch, Value, }; struct DocHeadersDB {} diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index b587ffbb73..df58bb845c 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -16,18 +16,18 @@ use stacks_common::types::StacksEpochId; -use crate::vm::Value::CallableContract; -use crate::vm::callables::{CallableType, NativeHandle, cost_input_sized_vararg}; +use crate::vm::callables::{cost_input_sized_vararg, CallableType, NativeHandle}; use crate::vm::costs::cost_functions::ClarityCostFunction; -use crate::vm::costs::{CostTracker, MemoryConsumer, constants as cost_constants, runtime_cost}; +use crate::vm::costs::{constants as cost_constants, runtime_cost, CostTracker, MemoryConsumer}; use crate::vm::errors::{ - CheckErrors, Error, InterpreterResult as Result, ShortReturnType, SyntaxBindingError, - SyntaxBindingErrorType, check_argument_count, check_arguments_at_least, + check_argument_count, check_arguments_at_least, CheckErrors, Error, + InterpreterResult as Result, ShortReturnType, SyntaxBindingError, SyntaxBindingErrorType, }; pub use crate::vm::functions::assets::stx_transfer_consolidated; use crate::vm::representations::{ClarityName, SymbolicExpression, SymbolicExpressionType}; use crate::vm::types::{PrincipalData, TypeSignature, Value}; -use crate::vm::{Environment, LocalContext, eval, is_reserved}; +use crate::vm::Value::CallableContract; +use crate::vm::{eval, is_reserved, Environment, LocalContext}; macro_rules! switch_on_global_epoch { ($Name:ident ($Epoch2Version:ident, $Epoch205Version:ident)) => { @@ -144,7 +144,7 @@ define_versioned_named_enum_with_max!(NativeFunctions(ClarityVersion) { Secp256k1Verify("secp256k1-verify", ClarityVersion::Clarity1, None), Print("print", ClarityVersion::Clarity1, None), ContractCall("contract-call?", ClarityVersion::Clarity1, None), - AsContract("as-contract", ClarityVersion::Clarity1, None), + AsContract("as-contract", ClarityVersion::Clarity1, Some(ClarityVersion::Clarity3)), ContractOf("contract-of", ClarityVersion::Clarity1, None), PrincipalOf("principal-of?", ClarityVersion::Clarity1, None), AtBlock("at-block", ClarityVersion::Clarity1, None), @@ -194,7 +194,13 @@ define_versioned_named_enum_with_max!(NativeFunctions(ClarityVersion) { GetTenureInfo("get-tenure-info?", ClarityVersion::Clarity3, None), ContractHash("contract-hash?", ClarityVersion::Clarity4, None), ToAscii("to-ascii?", ClarityVersion::Clarity4, None), - RestrictAssets("restrict-assets?", ClarityVersion::Clarity4, None) + RestrictAssets("restrict-assets?", ClarityVersion::Clarity4, None), + AsContractSafe("as-contract?", ClarityVersion::Clarity4, None), + AllowanceWithStx("with-stx", ClarityVersion::Clarity4, None), + AllowanceWithFt("with-ft", ClarityVersion::Clarity4, None), + AllowanceWithNft("with-nft", ClarityVersion::Clarity4, None), + AllowanceWithStacking("with-stacking", ClarityVersion::Clarity4, None), + AllowanceAll("with-all-assets-unsafe", ClarityVersion::Clarity4, None), }); /// @@ -571,6 +577,16 @@ pub fn lookup_reserved_functions(name: &str, version: &ClarityVersion) -> Option "special_restrict_assets", &post_conditions::special_restrict_assets, ), + AsContractSafe => { + SpecialFunction("special_as_contract", &post_conditions::special_as_contract) + } + AllowanceWithStx + | AllowanceWithFt + | AllowanceWithNft + | AllowanceWithStacking + | AllowanceAll => { + SpecialFunction("special_allowance", &post_conditions::special_allowance) + } }; Some(callable) } else { diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index 3c52a6a307..a6cf691630 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -74,7 +74,10 @@ pub fn special_restrict_assets( let asset_owner_expr = &args[0]; let allowance_list = args[1] .match_list() - .ok_or(CheckErrors::RestrictAssetsExpectedListOfAllowances)?; + .ok_or(CheckErrors::ExpectedListOfAllowances( + "restrict-assets?".into(), + 2, + ))?; let body_exprs = &args[2..]; let _asset_owner = eval(asset_owner_expr, env, context)?; @@ -108,3 +111,64 @@ pub fn special_restrict_assets( // last_result should always be Some(...), because of the arg len check above. last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()).into()) } + +/// Handles the function `as-contract?` +pub fn special_as_contract( + args: &[SymbolicExpression], + env: &mut Environment, + context: &LocalContext, +) -> InterpreterResult { + // (as-contract? ((with-stx|with-ft|with-nft|with-stacking)*) expr-body1 expr-body2 ... expr-body-last) + // arg1 => list of asset allowances + // arg2..n => body + check_arguments_at_least(2, args)?; + + let allowance_list = args[0] + .match_list() + .ok_or(CheckErrors::ExpectedListOfAllowances( + "as-contract?".into(), + 1, + ))?; + let body_exprs = &args[1..]; + + runtime_cost( + ClarityCostFunction::AsContractSafe, + env, + allowance_list.len(), + )?; + + let mut allowances = Vec::with_capacity(allowance_list.len()); + for allowance in allowance_list { + allowances.push(eval_allowance(allowance, env, context)?); + } + + // Create a new evaluation context, so that we can rollback if the + // post-conditions are violated + env.global_context.begin(); + + // evaluate the body expressions + let mut last_result = None; + for expr in body_exprs { + let result = eval(expr, env, context)?; + last_result.replace(result); + } + + // TODO: Check the post-conditions and rollback if they are violated + + env.global_context.commit()?; + + // last_result should always be Some(...), because of the arg len check above. + last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()).into()) +} + +/// Handles all allowance functions, always returning an error, since these are +/// not allowed outside of specific contexts (in `restrict-assets?` and +/// `as-contract?`). When called in the appropriate context, they are handled +/// by the above `eval_allowance` function. +pub fn special_allowance( + _args: &[SymbolicExpression], + _env: &mut Environment, + _context: &LocalContext, +) -> InterpreterResult { + Err(CheckErrors::AllowanceExprNotAllowed.into()) +} diff --git a/stackslib/src/chainstate/stacks/boot/contract_tests.rs b/stackslib/src/chainstate/stacks/boot/contract_tests.rs index e75ed60445..9b042dd8a4 100644 --- a/stackslib/src/chainstate/stacks/boot/contract_tests.rs +++ b/stackslib/src/chainstate/stacks/boot/contract_tests.rs @@ -142,7 +142,7 @@ impl ClarityTestSim { /// Common setup logic for executing blocks in tests /// Returns (store, headers_db, burn_db, current_epoch) fn setup_block_environment( - &mut self, + &'_ mut self, new_tenure: bool, ) -> ( Box, diff --git a/stackslib/src/chainstate/stacks/boot/costs-4.clar b/stackslib/src/chainstate/stacks/boot/costs-4.clar index a1654273f7..f83afdcdf4 100644 --- a/stackslib/src/chainstate/stacks/boot/costs-4.clar +++ b/stackslib/src/chainstate/stacks/boot/costs-4.clar @@ -666,3 +666,6 @@ (define-read-only (cost_restrict_assets (n uint)) (runtime (linear n u1 u100))) ;; TODO: needs criterion benchmark + +(define-read-only (cost_as_contract_safe (n uint)) + (runtime (linear n u1 u100))) ;; TODO: needs criterion benchmark diff --git a/stackslib/src/clarity_vm/tests/analysis_costs.rs b/stackslib/src/clarity_vm/tests/analysis_costs.rs index 6f4244c74e..bee1055261 100644 --- a/stackslib/src/clarity_vm/tests/analysis_costs.rs +++ b/stackslib/src/clarity_vm/tests/analysis_costs.rs @@ -228,9 +228,11 @@ fn epoch_21_test_all(use_mainnet: bool, version: ClarityVersion) { continue; } - let test = get_simple_test(f); - let cost = test_tracked_costs(test, StacksEpochId::Epoch21, version, ix + 1, &mut instance); - assert!(cost.exceeds(&baseline)); + if let Some(test) = get_simple_test(f) { + let cost = + test_tracked_costs(test, StacksEpochId::Epoch21, version, ix + 1, &mut instance); + assert!(cost.exceeds(&baseline)); + } } } @@ -262,15 +264,16 @@ fn epoch_205_test_all(use_mainnet: bool) { for (ix, f) in NativeFunctions::ALL.iter().enumerate() { if f.get_min_version() == ClarityVersion::Clarity1 { - let test = get_simple_test(f); - let cost = test_tracked_costs( - test, - StacksEpochId::Epoch2_05, - ClarityVersion::Clarity1, - ix + 1, - &mut instance, - ); - assert!(cost.exceeds(&baseline)); + if let Some(test) = get_simple_test(f) { + let cost = test_tracked_costs( + test, + StacksEpochId::Epoch2_05, + ClarityVersion::Clarity1, + ix + 1, + &mut instance, + ); + assert!(cost.exceeds(&baseline)); + } } } } diff --git a/stackslib/src/clarity_vm/tests/costs.rs b/stackslib/src/clarity_vm/tests/costs.rs index c4bced3342..0e1f25646d 100644 --- a/stackslib/src/clarity_vm/tests/costs.rs +++ b/stackslib/src/clarity_vm/tests/costs.rs @@ -50,9 +50,9 @@ lazy_static! { boot_code_id("cost-voting", false); } -pub fn get_simple_test(function: &NativeFunctions) -> &'static str { +pub fn get_simple_test(function: &NativeFunctions) -> Option<&'static str> { use clarity::vm::functions::NativeFunctions::*; - match function { + let s = match function { Add => "(+ 1 1)", ToUInt => "(to-uint 1)", ToInt => "(to-int u1)", @@ -166,7 +166,12 @@ pub fn get_simple_test(function: &NativeFunctions) -> &'static str { ContractHash => "(contract-hash? .contract-other)", ToAscii => "(to-ascii? 65)", RestrictAssets => "(restrict-assets? tx-sender () (+ u1 u2))", - } + AsContractSafe => "(as-contract? () (+ u1 u2))", + // These expressions are not usable in this context, since they are + // only allowed within `restrict-assets?` or `as-contract?` + AllowanceWithStx | AllowanceWithFt | AllowanceWithNft | AllowanceWithStacking | AllowanceAll => return None, + }; + Some(s) } fn execute_transaction( @@ -1036,11 +1041,16 @@ fn epoch_20_205_test_all(use_mainnet: bool, epoch: StacksEpochId) { for (ix, f) in NativeFunctions::ALL.iter().enumerate() { // Note: The 2.0 and 2.05 test assumes Clarity1. - if f.get_min_version() == ClarityVersion::Clarity1 { - let test = get_simple_test(f); - let cost = - test_program_cost(test, ClarityVersion::Clarity1, &mut owned_env, ix + 1); - assert!(cost.exceeds(&baseline)); + if f.get_min_version() == ClarityVersion::Clarity1 + && f.get_max_version() + .map(|max| max >= ClarityVersion::Clarity1) + .unwrap_or(true) + { + if let Some(test) = get_simple_test(f) { + let cost = + test_program_cost(test, ClarityVersion::Clarity1, &mut owned_env, ix + 1); + assert!(cost.exceeds(&baseline)); + } } } }) @@ -1078,13 +1088,14 @@ fn epoch_21_test_all(use_mainnet: bool) { // Note: Include Clarity2 functions for Epoch21. if f.get_min_version() <= ClarityVersion::Clarity2 && f.get_max_version() - .map(|max| max < ClarityVersion::Clarity2) + .map(|max| max >= ClarityVersion::Clarity2) .unwrap_or(true) { - let test = get_simple_test(f); - let cost = - test_program_cost(test, ClarityVersion::Clarity2, &mut owned_env, ix + 1); - assert!(cost.exceeds(&baseline)); + if let Some(test) = get_simple_test(f) { + let cost = + test_program_cost(test, ClarityVersion::Clarity2, &mut owned_env, ix + 1); + assert!(cost.exceeds(&baseline)); + } } } }) @@ -1115,10 +1126,11 @@ fn epoch_30_test_all(use_mainnet: bool) { .map(|max| max >= ClarityVersion::Clarity3) .unwrap_or(true) { - let test = get_simple_test(f); - let cost = - test_program_cost(test, ClarityVersion::Clarity3, &mut owned_env, ix + 1); - assert!(cost.exceeds(&baseline)); + if let Some(test) = get_simple_test(f) { + let cost = + test_program_cost(test, ClarityVersion::Clarity3, &mut owned_env, ix + 1); + assert!(cost.exceeds(&baseline)); + } } } }) @@ -1149,10 +1161,11 @@ fn epoch_33_test_all(use_mainnet: bool) { .map(|max| max >= ClarityVersion::Clarity4) .unwrap_or(true) { - let test = get_simple_test(f); - let cost = - test_program_cost(test, ClarityVersion::Clarity4, &mut owned_env, ix + 1); - assert!(cost.exceeds(&baseline)); + if let Some(test) = get_simple_test(f) { + let cost = + test_program_cost(test, ClarityVersion::Clarity4, &mut owned_env, ix + 1); + assert!(cost.exceeds(&baseline)); + } } } }) From e4fc430c24f18855feef2a379a9bdd876c7b68fe Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 18 Sep 2025 20:22:51 -0400 Subject: [PATCH 03/29] chore: fix formatting --- .../analysis/type_checker/v2_1/tests/post_conditions.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 45dd4bd03f..8be23e0070 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -278,9 +278,7 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch // as-contract? is only available in Clarity 4+ assert_eq!( CheckErrors::UnknownFunction("as-contract?".to_string()), - *type_check_helper_version(code, version) - .unwrap_err() - .err + *type_check_helper_version(code, version).unwrap_err().err ); } else { assert_eq!( @@ -296,9 +294,7 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch // as-contract? is only available in Clarity 4+ assert_eq!( CheckErrors::UnknownFunction("as-contract?".to_string()), - *type_check_helper_version(code, version) - .unwrap_err() - .err + *type_check_helper_version(code, version).unwrap_err().err ); } else { assert_eq!( From e29ec1633108418cb2a5fca822a06656761a902a Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Fri, 19 Sep 2025 10:05:33 -0400 Subject: [PATCH 04/29] fix: add Clarity4 version of test contract --- .../src/vm/analysis/type_checker/v2_1/tests/contracts.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/contracts.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/contracts.rs index 82dd67ce0e..71398ca0c5 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/contracts.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/contracts.rs @@ -2647,13 +2647,18 @@ fn clarity_trait_experiments_downcast_trait_5( ) { let mut marf = MemoryBackingStore::new(); let mut db = marf.as_analysis_db(); + let downcast_trait_5 = if version >= ClarityVersion::Clarity4 { + "downcast-trait-5-c4" + } else { + "downcast-trait-5" + }; // Can we use a principal exp where a trait type is expected? // Principal can come from constant/var/map/function/keyword let err = db .execute(|db| { load_versioned(db, "math-trait", version, epoch)?; - load_versioned(db, "downcast-trait-5", version, epoch) + load_versioned(db, downcast_trait_5, version, epoch) }) .unwrap_err(); if epoch <= StacksEpochId::Epoch2_05 { From 196e71e1f0622a93e03e7be86b13ed7c751ee4fc Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Sat, 20 Sep 2025 18:12:43 -0400 Subject: [PATCH 05/29] feat: initial implementation of allowances --- CHANGELOG.md | 8 + clarity-types/src/errors/analysis.rs | 2 + clarity-types/src/types/mod.rs | 10 + .../v2_1/natives/post_conditions.rs | 68 ++- .../tests/contracts/downcast-trait-5-c4.clar | 13 + .../v2_1/tests/post_conditions.rs | 10 + clarity/src/vm/contexts.rs | 22 + clarity/src/vm/docs/mod.rs | 133 +++--- clarity/src/vm/functions/post_conditions.rs | 311 ++++++++++++-- clarity/src/vm/tests/mod.rs | 2 + clarity/src/vm/tests/post_conditions.rs | 406 ++++++++++++++++++ 11 files changed, 847 insertions(+), 138 deletions(-) create mode 100644 clarity/src/vm/analysis/type_checker/v2_1/tests/contracts/downcast-trait-5-c4.clar create mode 100644 clarity/src/vm/tests/post_conditions.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index da077c5051..4c7f8ddca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE - `current-contract` - `block-time` - `to-ascii?` + - `restrict-assets?` + - `as-contract?` + - Special allowance expressions: + - `with-stx` + - `with-ft` + - `with-nft` + - `with-stacking` + - `with-all-assets-unsafe` - Added `contract_cost_limit_percentage` to the miner config file — sets the percentage of a block’s execution cost at which, if a large non-boot contract call would cause a BlockTooBigError, the miner will stop adding further non-boot contract calls and only include STX transfers and boot contract calls for the remainder of the block. ### Changed diff --git a/clarity-types/src/errors/analysis.rs b/clarity-types/src/errors/analysis.rs index 698181a65d..8573d91882 100644 --- a/clarity-types/src/errors/analysis.rs +++ b/clarity-types/src/errors/analysis.rs @@ -313,6 +313,7 @@ pub enum CheckErrors { AllowanceExprNotAllowed, ExpectedAllowanceExpr(String), WithAllAllowanceNotAllowed, + WithAllAllowanceNotAlone, } #[derive(Debug, PartialEq)] @@ -615,6 +616,7 @@ impl DiagnosableError for CheckErrors { CheckErrors::AllowanceExprNotAllowed => "allowance expressions are only allowed in the context of a `restrict-assets?` or `as-contract?`".into(), CheckErrors::ExpectedAllowanceExpr(got_name) => format!("expected an allowance expression, got: {got_name}"), CheckErrors::WithAllAllowanceNotAllowed => "with-all-assets-unsafe is not allowed here, only in the allowance list for `as-contract?`".into(), + CheckErrors::WithAllAllowanceNotAlone => "with-all-assets-unsafe must not be used along with other allowances".into(), } } diff --git a/clarity-types/src/types/mod.rs b/clarity-types/src/types/mod.rs index 5fb3e36a1c..ba15c0530e 100644 --- a/clarity-types/src/types/mod.rs +++ b/clarity-types/src/types/mod.rs @@ -1226,6 +1226,16 @@ impl Value { Err(InterpreterError::Expect("Expected response".into()).into()) } } + + pub fn expect_string_ascii(self) -> Result { + if let Value::Sequence(SequenceData::String(CharType::ASCII(ASCIIData { data }))) = self { + Ok(String::from_utf8(data) + .map_err(|_| InterpreterError::Expect("Non UTF-8 data in string".into()))?) + } else { + error!("Value '{self:?}' is not an ASCII string"); + Err(InterpreterError::Expect("Expected ASCII string".into()).into()) + } + } } impl BuffData { diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs index 8caa6279a4..bf6c0f82d6 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs @@ -50,12 +50,9 @@ pub fn check_restrict_assets( checker.type_check_expects(asset_owner, context, &TypeSignature::PrincipalType)?; for allowance in allowance_list { - check_allowance( - checker, - allowance, - context, - &NativeFunctions::RestrictAssets, - )?; + if check_allowance(checker, allowance, context)? { + return Err(CheckErrors::WithAllAllowanceNotAllowed.into()); + } } // Check the body expressions, ensuring any intermediate responses are handled @@ -97,12 +94,9 @@ pub fn check_as_contract( )?; for allowance in allowance_list { - check_allowance( - checker, - allowance, - context, - &NativeFunctions::AsContractSafe, - )?; + if check_allowance(checker, allowance, context)? && allowance_list.len() > 1 { + return Err(CheckErrors::WithAllAllowanceNotAlone.into()); + } } // Check the body expressions, ensuring any intermediate responses are handled @@ -133,12 +127,13 @@ pub fn check_allowance_err( Err(CheckErrors::AllowanceExprNotAllowed.into()) } +/// Type check an allowance expression, returning whether it is a +/// `with-all-assets-unsafe` allowance (which has special rules). pub fn check_allowance( checker: &mut TypeChecker, allowance: &SymbolicExpression, context: &TypingContext, - parent_expr: &NativeFunctions, -) -> Result<(), CheckError> { +) -> Result { let list = allowance .match_list() .ok_or(CheckErrors::ExpectedListApplication)?; @@ -155,19 +150,13 @@ pub fn check_allowance( }; match native_function { - NativeFunctions::AllowanceWithStx => { - check_allowance_with_stx(checker, args, context, parent_expr) - } - NativeFunctions::AllowanceWithFt => { - check_allowance_with_ft(checker, args, context, parent_expr) - } - NativeFunctions::AllowanceWithNft => { - check_allowance_with_nft(checker, args, context, parent_expr) - } + NativeFunctions::AllowanceWithStx => check_allowance_with_stx(checker, args, context), + NativeFunctions::AllowanceWithFt => check_allowance_with_ft(checker, args, context), + NativeFunctions::AllowanceWithNft => check_allowance_with_nft(checker, args, context), NativeFunctions::AllowanceWithStacking => { - check_allowance_with_stacking(checker, args, context, parent_expr) + check_allowance_with_stacking(checker, args, context) } - NativeFunctions::AllowanceAll => check_allowance_all(checker, args, context, parent_expr), + NativeFunctions::AllowanceAll => check_allowance_all(checker, args, context), _ => Err(CheckErrors::ExpectedAllowanceExpr(function_name.to_string()).into()), } } @@ -178,13 +167,12 @@ fn check_allowance_with_stx( checker: &mut TypeChecker, args: &[SymbolicExpression], context: &TypingContext, - _parent_expr: &NativeFunctions, -) -> Result<(), CheckError> { +) -> Result { check_argument_count(1, args)?; checker.type_check_expects(&args[0], context, &TypeSignature::UIntType)?; - Ok(()) + Ok(false) } /// Type check a `with-ft` allowance expression. @@ -193,15 +181,14 @@ fn check_allowance_with_ft( checker: &mut TypeChecker, args: &[SymbolicExpression], context: &TypingContext, - _parent_expr: &NativeFunctions, -) -> Result<(), CheckError> { +) -> Result { check_argument_count(3, args)?; checker.type_check_expects(&args[0], context, &TypeSignature::PrincipalType)?; checker.type_check_expects(&args[1], context, &ASCII_128)?; checker.type_check_expects(&args[2], context, &TypeSignature::UIntType)?; - Ok(()) + Ok(false) } /// Type check a `with-nft` allowance expression. @@ -210,15 +197,14 @@ fn check_allowance_with_nft( checker: &mut TypeChecker, args: &[SymbolicExpression], context: &TypingContext, - _parent_expr: &NativeFunctions, -) -> Result<(), CheckError> { +) -> Result { check_argument_count(3, args)?; checker.type_check_expects(&args[0], context, &TypeSignature::PrincipalType)?; checker.type_check_expects(&args[1], context, &ASCII_128)?; // Asset ID can be any type - Ok(()) + Ok(false) } /// Type check a `with-stacking` allowance expression. @@ -227,13 +213,12 @@ fn check_allowance_with_stacking( checker: &mut TypeChecker, args: &[SymbolicExpression], context: &TypingContext, - _parent_expr: &NativeFunctions, -) -> Result<(), CheckError> { +) -> Result { check_argument_count(1, args)?; checker.type_check_expects(&args[0], context, &TypeSignature::UIntType)?; - Ok(()) + Ok(false) } /// Type check an `with-all-assets-unsafe` allowance expression. @@ -242,13 +227,8 @@ fn check_allowance_all( _checker: &mut TypeChecker, args: &[SymbolicExpression], _context: &TypingContext, - parent_expr: &NativeFunctions, -) -> Result<(), CheckError> { +) -> Result { check_argument_count(0, args)?; - if parent_expr != &NativeFunctions::AsContractSafe { - return Err(CheckErrors::WithAllAllowanceNotAllowed.into()); - } - - Ok(()) + Ok(true) } diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/contracts/downcast-trait-5-c4.clar b/clarity/src/vm/analysis/type_checker/v2_1/tests/contracts/downcast-trait-5-c4.clar new file mode 100644 index 0000000000..1ecc4bae49 --- /dev/null +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/contracts/downcast-trait-5-c4.clar @@ -0,0 +1,13 @@ +(impl-trait .math-trait.math) +(define-read-only (add (x uint) (y uint)) (ok (+ x y)) ) +(define-read-only (sub (x uint) (y uint)) (ok (- x y)) ) + +(use-trait math .math-trait.math) + +(define-public (use (math-contract )) + (ok true) +) + +(define-public (downcast) + (as-contract? ((with-all-assets-unsafe)) (use tx-sender)) +) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 8be23e0070..488d341541 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -270,6 +270,16 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch "(as-contract? ((with-stx u1000)) (err u1) true)", CheckErrors::UncheckedIntermediaryResponses, ), + // other allowances together with with-all-assets-unsafe (first) + ( + "(as-contract? ((with-all-assets-unsafe) (with-stx u1000)) true)", + CheckErrors::WithAllAllowanceNotAlone, + ), + // other allowances together with with-all-assets-unsafe (second) + ( + "(as-contract? ((with-stx u1000) (with-all-assets-unsafe)) true)", + CheckErrors::WithAllAllowanceNotAlone, + ), ]; for (code, expected_type) in &good { diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 116c631387..6dba496d4b 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -455,6 +455,14 @@ impl AssetMap { assets.get(asset_identifier).copied() } + pub fn get_all_fungible_tokens( + &self, + principal: &PrincipalData, + ) -> Option<&HashMap> { + let assets = self.token_map.get(principal)?; + Some(assets) + } + pub fn get_nonfungible_tokens( &self, principal: &PrincipalData, @@ -463,6 +471,14 @@ impl AssetMap { let assets = self.asset_map.get(principal)?; assets.get(asset_identifier) } + + pub fn get_all_nonfungible_tokens( + &self, + principal: &PrincipalData, + ) -> Option<&HashMap>> { + let assets = self.asset_map.get(principal)?; + Some(assets) + } } impl fmt::Display for AssetMap { @@ -1551,6 +1567,12 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { .ok_or_else(|| InterpreterError::Expect("Failed to obtain asset map".into()).into()) } + pub fn get_readonly_asset_map(&mut self) -> Result<&AssetMap> { + self.asset_maps + .last() + .ok_or_else(|| InterpreterError::Expect("Failed to obtain asset map".into()).into()) + } + pub fn log_asset_transfer( &mut self, sender: &PrincipalData, diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index 202d7c318a..a221064081 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -1412,7 +1412,7 @@ function returns _err_, any database changes resulting from calling `contract-ca If the function returns _ok_, database changes occurred.", example: " ;; instantiate the sample/contracts/tokens.clar contract first! -(as-contract (contract-call? .tokens mint! u19)) ;; Returns (ok u19)" +(as-contract? () (try! (contract-call? .tokens mint! u19))) ;; Returns (ok u19)" }; const CONTRACT_OF_API: SpecialAPI = SpecialAPI { @@ -2374,7 +2374,7 @@ In the event that the `owner` principal isn't materialized, it returns 0. ", example: " (stx-get-balance 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR) ;; Returns u0 -(stx-get-balance (as-contract tx-sender)) ;; Returns u1000 +(stx-get-balance tx-sender) ;; Returns u1000 ", }; @@ -2390,7 +2390,7 @@ unlock height for any locked STX, all denominated in microstacks. ", example: r#" (stx-account 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR) ;; Returns (tuple (locked u0) (unlock-height u0) (unlocked u0)) -(stx-account (as-contract tx-sender)) ;; Returns (tuple (locked u0) (unlock-height u0) (unlocked u1000)) +(stx-account tx-sender) ;; Returns (tuple (locked u0) (unlock-height u0) (unlocked u1000)) "#, }; @@ -2412,12 +2412,9 @@ one of the following error codes: * `(err u4)` -- the `sender` principal is not the current `tx-sender` ", example: r#" -(as-contract - (stx-transfer? u60 tx-sender 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR)) ;; Returns (ok true) -(as-contract - (stx-transfer? u60 tx-sender 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR)) ;; Returns (ok true) -(as-contract - (stx-transfer? u50 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR tx-sender)) ;; Returns (err u4) +(stx-transfer? u60 tx-sender 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR) ;; Returns (ok true) +(stx-transfer? u60 tx-sender 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR) ;; Returns (ok true) +(stx-transfer? u50 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR tx-sender) ;; Returns (err u4) "# }; @@ -2431,8 +2428,7 @@ const STX_TRANSFER_MEMO: SpecialAPI = SpecialAPI { This function returns (ok true) if the transfer is successful, or, on an error, returns the same codes as `stx-transfer?`. ", example: r#" -(as-contract - (stx-transfer-memo? u60 tx-sender 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR 0x010203)) ;; Returns (ok true) +(stx-transfer-memo? u60 tx-sender 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR 0x010203) ;; Returns (ok true) "# }; @@ -2452,10 +2448,8 @@ one of the following error codes: * `(err u4)` -- the `sender` principal is not the current `tx-sender` ", example: " -(as-contract - (stx-burn? u60 tx-sender)) ;; Returns (ok true) -(as-contract - (stx-burn? u50 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR)) ;; Returns (err u4) +(stx-burn? u60 tx-sender) ;; Returns (ok true) +(stx-burn? u50 'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR) ;; Returns (err u4) " }; @@ -2588,12 +2582,12 @@ error-prone). Returns: index of the first violated allowance in the list of granted allowances, or -1 if an asset with no allowance caused the violation.", example: r#" -(restrict-assets? tx-sender () - (try! (stx-transfer? u1000000 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) -) ;; Returns (err -1) (restrict-assets? tx-sender () (+ u1 u2) ) ;; Returns (ok u3) +(restrict-assets? tx-sender () + (try! (stx-transfer? u50 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) +) ;; Returns (err -1) "#, }; @@ -2618,16 +2612,16 @@ value from `as-contract?` (nested responses are error-prone). Returns: index of the first violated allowance in the list of granted allowances, or -1 if an asset with no allowance caused the violation.", example: r#" -(define-public (foo) +(let ((recipient tx-sender)) + (as-contract? ((with-stx u100)) + (try! (stx-transfer? u50 tx-sender recipient)) + ) +) ;; Returns (ok true) +(let ((recipient tx-sender)) (as-contract? () - (try! (stx-transfer? u1000000 tx-sender recipient)) + (try! (stx-transfer? u50 tx-sender recipient)) ) ) ;; Returns (err -1) -(define-public (bar) - (as-contract? ((with-stx u1000000)) - (try! (stx-transfer? u1000000 tx-sender recipient)) - ) -) ;; Returns (ok true) "#, }; @@ -2642,13 +2636,13 @@ expression. `with-stx` is not allowed outside of `restrict-assets?` or `as-contract?` contexts.", example: r#" (restrict-assets? tx-sender - ((with-stx u1000000)) - (try! (stx-transfer? u2000000 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) -) ;; Returns (err 0) -(restrict-assets? tx-sender - ((with-stx u1000000)) - (try! (stx-transfer? u1000000 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) + ((with-stx u100)) + (try! (stx-transfer? u100 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) ) ;; Returns (ok true) +(restrict-assets? tx-sender + ((with-stx u50)) + (try! (stx-transfer? u100 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) +) ;; Returns (err 0) "#, }; @@ -2665,14 +2659,16 @@ not allowed outside of `restrict-assets?` or `as-contract?` contexts. Note that the contract. When `"*"` is used for the token name, the allowance applies to **all** FTs defined in `contract-id`."#, example: r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) (restrict-assets? tx-sender - ((with-ft (contract-of token-trait) "stackaroo" u50)) - (try! (contract-call? token-trait transfer u100 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none)) -) ;; Returns (err 0) -(restrict-assets? tx-sender - ((with-ft (contract-of token-trait) "stackaroo" u50)) - (try! (contract-call? token-trait transfer u20 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none)) + ((with-ft current-contract "stackaroo" u50)) + (try! (ft-transfer? stackaroo u100 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) ) ;; Returns (ok true) +(restrict-assets? tx-sender + ((with-ft current-contract "stackaroo" u50)) + (try! (ft-transfer? stackaroo u100 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) +) ;; Returns (err 0) "#, }; @@ -2689,14 +2685,18 @@ expression. `with-nft` is not allowed outside of `restrict-assets?` or the `define-non-fungible-token` call in the contract. When `"*"` is used for the token name, the allowance applies to **all** NFTs defined in `contract-id`."#, example: r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 tx-sender) +(nft-mint? stackaroo u124 tx-sender) +(nft-mint? stackaroo u125 tx-sender) (restrict-assets? tx-sender - ((with-nft (contract-of nft-trait) "stackaroo" u123)) - (try! (contract-call? nft-trait transfer u4 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) -) ;; Returns (err 0) -(restrict-assets? tx-sender - ((with-nft (contract-of nft-trait) "stackaroo" u123)) - (try! (contract-call? nft-trait transfer u123 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) + ((with-nft current-contract "stackaroo" u123)) + (try! (nft-transfer? stackaroo u123 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) ) ;; Returns (ok true) +(restrict-assets? tx-sender + ((with-nft current-contract "stackaroo" u125)) + (try! (nft-transfer? stackaroo u124 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) +) ;; Returns (err 0) "#, }; @@ -2708,9 +2708,9 @@ const ALLOWANCE_WITH_STACKING: SpecialAPI = SpecialAPI { description: "Adds a stacking allowance for `amount` uSTX from the `asset-owner` of the enclosing `restrict-assets?` or `as-contract?` expression. `with-stacking` is not allowed outside of `restrict-assets?` or -`as-contract?` contexts. This restricts calls to `delegate-stx` and -`stack-stx` in the active PoX contract to lock up to the amount of uSTX -specified.", +`as-contract?` contexts. This restricts calls to the active PoX contract +that either delegate funds for stacking or stack directly, ensuring that the +locked amount is limited by the amount of uSTX specified.", example: r#" (restrict-assets? tx-sender ((with-stacking u1000000000000)) @@ -2744,14 +2744,11 @@ dangerous allowance should only be used when the code executing within the checking traits against an allow list, passed in from a trusted caller), and even then the more restrictive allowances should be preferred when possible.", example: r#" -(define-public (execute-trait (trusted-trait )) - (begin - (asserts! (is-eq contract-caller TRUSTED_CALLER) ERR_UNTRUSTED_CALLER) - (as-contract? ((with-all-assets-unsafe)) - (contract-call? trusted-trait execute) - ) +(let ((recipient tx-sender)) + (as-contract? ((with-all-assets-unsafe)) + (try! (stx-transfer? u100 tx-sender recipient)) ) -) +) ;; Returns (ok true) "#, }; @@ -2986,6 +2983,7 @@ pub fn make_json_api_reference() -> String { #[cfg(test)] mod test { + use clarity_types::types::StandardPrincipalData; use stacks_common::consts::{CHAIN_ID_TESTNET, PEER_VERSION_EPOCH_2_1}; use stacks_common::types::chainstate::{ BlockHeaderHash, BurnchainHeaderHash, ConsensusHash, SortitionId, StacksAddress, @@ -3229,7 +3227,7 @@ mod test { } } - fn docs_execute(store: &mut MemoryBackingStore, program: &str) { + fn docs_execute(store: &mut MemoryBackingStore, program: &str, version: ClarityVersion) { // execute the program, iterating at each ";; Returns" comment // there are maybe more rust-y ways of doing this, but this is the simplest. let mut segments = vec![]; @@ -3256,7 +3254,7 @@ mod test { &contract_id, &whole_contract, &mut (), - ClarityVersion::latest(), + version, StacksEpochId::latest(), ) .unwrap() @@ -3268,7 +3266,7 @@ mod test { &mut analysis_db, false, &StacksEpochId::latest(), - &ClarityVersion::latest(), + &version, ) .expect("Failed to type check"); } @@ -3281,7 +3279,7 @@ mod test { &contract_id, &total_example, &mut (), - ClarityVersion::latest(), + version, StacksEpochId::latest(), ) .unwrap() @@ -3294,7 +3292,7 @@ mod test { &mut analysis_db, false, &StacksEpochId::latest(), - &ClarityVersion::latest(), + &version, ) .expect("Failed to type check"); type_results.push( @@ -3308,8 +3306,7 @@ mod test { } let conn = store.as_docs_clarity_db(); - let mut contract_context = - ContractContext::new(contract_id.clone(), ClarityVersion::latest()); + let mut contract_context = ContractContext::new(contract_id.clone(), version); let mut global_context = GlobalContext::new( false, CHAIN_ID_TESTNET, @@ -3385,7 +3382,7 @@ mod test { let mut store = MemoryBackingStore::new(); // first, load the samples for contract-call - // and give the doc environment's contract some STX + // and give the doc environment sender and its contract some STX { let contract_id = QualifiedContractIdentifier::local("tokens").unwrap(); let trait_def_id = QualifiedContractIdentifier::parse( @@ -3440,6 +3437,7 @@ mod test { } let conn = store.as_docs_clarity_db(); + let sender_principal = PrincipalData::Standard(StandardPrincipalData::transient()); let docs_test_id = QualifiedContractIdentifier::local("docs-test").unwrap(); let docs_principal_id = PrincipalData::Contract(docs_test_id); let mut env = OwnedEnvironment::new(conn, StacksEpochId::latest()); @@ -3454,6 +3452,13 @@ mod test { .database .get_stx_balance_snapshot_genesis(&docs_principal_id) .unwrap(); + snapshot.set_balance(balance.clone()); + snapshot.save().unwrap(); + let mut snapshot = e + .global_context + .database + .get_stx_balance_snapshot_genesis(&sender_principal) + .unwrap(); snapshot.set_balance(balance); snapshot.save().unwrap(); e.global_context @@ -3479,7 +3484,11 @@ mod test { .collect::>() .join("\n"); let the_throws = example.lines().filter(|x| x.contains(";; Throws")); - docs_execute(&mut store, &without_throws); + docs_execute( + &mut store, + &without_throws, + func_api.max_version.unwrap_or(ClarityVersion::latest()), + ); for expect_err in the_throws { eprintln!("{expect_err}"); execute(expect_err).unwrap_err(); diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index a6cf691630..9b8691d13d 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -13,36 +13,39 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::collections::{HashMap, HashSet}; + +use clarity_types::types::{AssetIdentifier, PrincipalData}; + +use crate::vm::contexts::AssetMap; use crate::vm::costs::cost_functions::ClarityCostFunction; -use crate::vm::costs::runtime_cost; +use crate::vm::costs::{constants as cost_constants, runtime_cost, CostTracker}; use crate::vm::errors::{ check_arguments_at_least, CheckErrors, InterpreterError, InterpreterResult, }; use crate::vm::representations::SymbolicExpression; -use crate::vm::types::{QualifiedContractIdentifier, Value}; +use crate::vm::types::Value; use crate::vm::{eval, Environment, LocalContext}; -struct StxAllowance { +pub struct StxAllowance { amount: u128, } -struct FtAllowance { - contract: QualifiedContractIdentifier, - token: String, +pub struct FtAllowance { + asset: AssetIdentifier, amount: u128, } -struct NftAllowance { - contract: QualifiedContractIdentifier, - token: String, +pub struct NftAllowance { + asset: AssetIdentifier, asset_id: Value, } -struct StackingAllowance { +pub struct StackingAllowance { amount: u128, } -enum Allowance { +pub enum Allowance { Stx(StxAllowance), Ft(FtAllowance), Nft(NftAllowance), @@ -51,12 +54,100 @@ enum Allowance { } fn eval_allowance( - _allowance_expr: &SymbolicExpression, - _env: &mut Environment, - _context: &LocalContext, + allowance_expr: &SymbolicExpression, + env: &mut Environment, + context: &LocalContext, ) -> InterpreterResult { - // FIXME: Placeholder - Ok(Allowance::All) + let list = allowance_expr + .match_list() + .ok_or(CheckErrors::NonFunctionApplication)?; + let (name_expr, rest) = list + .split_first() + .ok_or(CheckErrors::NonFunctionApplication)?; + let name = name_expr.match_atom().ok_or(CheckErrors::BadFunctionName)?; + + match name.as_str() { + "with-stx" => { + if rest.len() != 1 { + return Err(CheckErrors::IncorrectArgumentCount(1, rest.len()).into()); + } + let amount = eval(&rest[0], env, context)?; + let amount = amount.expect_u128()?; + Ok(Allowance::Stx(StxAllowance { amount })) + } + "with-ft" => { + if rest.len() != 3 { + return Err(CheckErrors::IncorrectArgumentCount(3, rest.len()).into()); + } + + let contract_value = eval(&rest[0], env, context)?; + let contract = contract_value.clone().expect_principal()?; + let contract_identifier = match contract { + PrincipalData::Standard(_) => { + return Err( + CheckErrors::ExpectedContractPrincipalValue(contract_value.into()).into(), + ); + } + PrincipalData::Contract(c) => c, + }; + + let asset_name = eval(&rest[1], env, context)?; + let asset_name = asset_name.expect_string_ascii()?.as_str().into(); + + let asset = AssetIdentifier { + contract_identifier, + asset_name, + }; + + let amount = eval(&rest[2], env, context)?; + let amount = amount.expect_u128()?; + + Ok(Allowance::Ft(FtAllowance { asset, amount })) + } + "with-nft" => { + if rest.len() != 3 { + return Err(CheckErrors::IncorrectArgumentCount(3, rest.len()).into()); + } + + let contract_value = eval(&rest[0], env, context)?; + let contract = contract_value.clone().expect_principal()?; + let contract_identifier = match contract { + PrincipalData::Standard(_) => { + return Err( + CheckErrors::ExpectedContractPrincipalValue(contract_value.into()).into(), + ); + } + PrincipalData::Contract(c) => c, + }; + + let asset_name = eval(&rest[1], env, context)?; + let asset_name = asset_name.expect_string_ascii()?.as_str().into(); + + let asset = AssetIdentifier { + contract_identifier, + asset_name, + }; + + let asset_id = eval(&rest[1], env, context)?; + + Ok(Allowance::Nft(NftAllowance { asset, asset_id })) + } + "with-stacking" => { + if rest.len() != 1 { + return Err(CheckErrors::IncorrectArgumentCount(1, rest.len()).into()); + } + let amount = eval(&rest[0], env, context)?; + let amount = amount.expect_u128()?; + Ok(Allowance::Stacking(StackingAllowance { amount })) + } + "with-all-assets-unsafe" => { + if !rest.is_empty() { + return Err(CheckErrors::IncorrectArgumentCount(1, rest.len()).into()); + } + Ok(Allowance::All) + } + _ => Err(CheckErrors::ExpectedAllowanceExpr(name.to_string()).into()), + } } /// Handles the function `restrict-assets?` @@ -80,7 +171,8 @@ pub fn special_restrict_assets( ))?; let body_exprs = &args[2..]; - let _asset_owner = eval(asset_owner_expr, env, context)?; + let asset_owner = eval(asset_owner_expr, env, context)?; + let asset_owner = asset_owner.expect_principal()?; runtime_cost( ClarityCostFunction::RestrictAssets, @@ -104,12 +196,24 @@ pub fn special_restrict_assets( last_result.replace(result); } - // TODO: Check the post-conditions and rollback if they are violated + let asset_maps = env.global_context.get_readonly_asset_map()?; + + // If the allowances are violated: + // - Rollback the context + // - Emit an event + if let Some(violation_index) = check_allowances(&asset_owner, &allowances, asset_maps)? { + env.global_context.roll_back()?; + // TODO: Emit an event about the allowance violation + return Value::error(Value::Int(violation_index)); + } env.global_context.commit()?; - // last_result should always be Some(...), because of the arg len check above. - last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()).into()) + // Wrap the result in an `ok` value + Value::okay( + // last_result should always be Some(...), because of the arg len check above. + last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()))?, + ) } /// Handles the function `as-contract?` @@ -142,23 +246,166 @@ pub fn special_as_contract( allowances.push(eval_allowance(allowance, env, context)?); } - // Create a new evaluation context, so that we can rollback if the - // post-conditions are violated - env.global_context.begin(); + let mut memory_use = 0; - // evaluate the body expressions - let mut last_result = None; - for expr in body_exprs { - let result = eval(expr, env, context)?; - last_result.replace(result); + finally_drop_memory!( env, memory_use; { + env.add_memory(cost_constants::AS_CONTRACT_MEMORY)?; + memory_use += cost_constants::AS_CONTRACT_MEMORY; + + let contract_principal: PrincipalData = env.contract_context.contract_identifier.clone().into(); + let mut nested_env = env.nest_as_principal(contract_principal.clone()); + + // Create a new evaluation context, so that we can rollback if the + // post-conditions are violated + nested_env.global_context.begin(); + + // evaluate the body expressions + let mut last_result = None; + for expr in body_exprs { + let result = eval(expr, &mut nested_env, context)?; + last_result.replace(result); + } + + let asset_maps = nested_env.global_context.get_readonly_asset_map()?; + + // If the allowances are violated: + // - Rollback the context + // - Emit an event + if let Some(violation_index) = check_allowances(&contract_principal, &allowances, asset_maps)? { + nested_env.global_context.roll_back()?; + // TODO: Emit an event about the allowance violation + return Value::error(Value::Int(violation_index)); + } + + nested_env.global_context.commit()?; + + // Wrap the result in an `ok` value + Value::okay( + // last_result should always be Some(...), because of the arg len check above. + last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()))?, + ) + }) +} + +/// Check the allowances against the asset map. If any assets moved without a +/// corresponding allowance return a `Some` with an index of the violated +/// allowance, or -1 if an asset with no allowance caused the violation. If all +/// allowances are satisfied, return `Ok(None)`. +fn check_allowances( + owner: &PrincipalData, + allowances: &[Allowance], + assets: &AssetMap, +) -> InterpreterResult> { + // Elements are (index in allowances, amount) + let mut stx_allowances: Vec<(usize, u128)> = Vec::new(); + // Map assets to a vector of (index in allowances, amount) + let mut ft_allowances: HashMap<&AssetIdentifier, Vec<(usize, u128)>> = HashMap::new(); + // Map assets to a tuple with the first allowance's index and a hashset of + // serialized asset identifiers + let mut nft_allowances: HashMap<&AssetIdentifier, (usize, HashSet)> = HashMap::new(); + // Elements are (index in allowances, amount) + let mut stacking_allowances: Vec<(usize, u128)> = Vec::new(); + + for (i, allowance) in allowances.iter().enumerate() { + match allowance { + Allowance::All => { + // any asset movement is allowed + return Ok(None); + } + Allowance::Stx(stx) => { + stx_allowances.push((i, stx.amount)); + } + Allowance::Ft(ft) => { + ft_allowances + .entry(&ft.asset) + .or_default() + .push((i, ft.amount)); + } + Allowance::Nft(nft) => { + let (_, set) = nft_allowances + .entry(&nft.asset) + .or_insert_with(|| (i, HashSet::new())); + set.insert(nft.asset_id.serialize_to_hex()?); + } + Allowance::Stacking(stacking) => { + stacking_allowances.push((i, stacking.amount)); + } + } } - // TODO: Check the post-conditions and rollback if they are violated + // Check STX movements + if let Some(stx_moved) = assets.get_stx(owner) { + // If there are no allowances for STX, any movement is a violation + if stx_allowances.is_empty() { + return Ok(Some(-1)); + } - env.global_context.commit()?; + // Check against the STX allowances + for (index, allowance) in &stx_allowances { + if stx_moved > *allowance { + return Ok(Some(i128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to i128".into()) + })?)); + } + } + } + + // Check STX burns + if let Some(stx_burned) = assets.get_stx_burned(owner) { + // If there are no allowances for STX, any burn is a violation + if stx_allowances.is_empty() { + return Ok(Some(-1)); + } + + // Check against the STX allowances + for (index, allowance) in &stx_allowances { + if stx_burned > *allowance { + return Ok(Some(i128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to i128".into()) + })?)); + } + } + } + + // Check FT movements + if let Some(ft_moved) = assets.get_all_fungible_tokens(owner) { + for (asset, amount_moved) in ft_moved { + if let Some(allowance_vec) = ft_allowances.get(asset) { + // Check against the FT allowances + for (index, allowance) in allowance_vec { + if *amount_moved > *allowance { + return Ok(Some(i128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to i128".into()) + })?)); + } + } + } else { + // No allowance for this asset, any movement is a violation + return Ok(Some(-1)); + } + } + } + + // Check NFT movements + if let Some(nft_moved) = assets.get_all_nonfungible_tokens(owner) { + for (asset, ids_moved) in nft_moved { + if let Some((index, allowance_map)) = nft_allowances.get(asset) { + // Check against the NFT allowances + for id_moved in ids_moved { + if !allowance_map.contains(&id_moved.serialize_to_hex()?) { + return Ok(Some(i128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to i128".into()) + })?)); + } + } + } else { + // No allowance for this asset, any movement is a violation + return Ok(Some(-1)); + } + } + } - // last_result should always be Some(...), because of the arg len check above. - last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()).into()) + Ok(None) } /// Handles all allowance functions, always returning an error, since these are diff --git a/clarity/src/vm/tests/mod.rs b/clarity/src/vm/tests/mod.rs index a10aa7b128..91c05eb936 100644 --- a/clarity/src/vm/tests/mod.rs +++ b/clarity/src/vm/tests/mod.rs @@ -30,6 +30,8 @@ mod contracts; mod conversions; mod datamaps; mod defines; +#[cfg(test)] +mod post_conditions; mod principals; #[cfg(test)] mod representations; diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs new file mode 100644 index 0000000000..4b55f55b94 --- /dev/null +++ b/clarity/src/vm/tests/post_conditions.rs @@ -0,0 +1,406 @@ +// 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 . + +use clarity_types::errors::InterpreterResult; +use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; +use clarity_types::Value; +use stacks_common::types::StacksEpochId; + +use crate::vm::ast::ASTRules; +use crate::vm::database::STXBalance; +use crate::vm::{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, + ASTRules::PrecheckSize, + 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(1000); + 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(2000).unwrap(); + Ok(()) + }, + ) +} + +// ---------- Tests for as-contract? ---------- + +#[test] +fn test_as_contract_with_stx_ok() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? ((with-stx u100)) + (try! (stx-transfer? u50 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_stx_exceeds() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? ((with-stx u10)) + (try! (stx-transfer? u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_stx_no_allowance() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? () + (try! (stx-transfer? u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_stx_all() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? ((with-all-assets-unsafe)) + (try! (stx-transfer? u50 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_stx_other_allowances() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) + (try! (stx-transfer? u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_stx_burn_ok() { + let snippet = r#" +(as-contract? ((with-stx u100)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_stx_burn_exceeds() { + let snippet = r#" +(as-contract? ((with-stx u10)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_stx_burn_no_allowance() { + let snippet = r#" +(as-contract? () + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_stx_burn_all() { + let snippet = r#" +(as-contract? ((with-all-assets-unsafe)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_stx_burn_other_allowances() { + let snippet = r#" +(as-contract? ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_multiple_allowances_both_low() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? ((with-stx u30) (with-stx u20)) + (try! (stx-transfer? u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_multiple_allowances_both_ok() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? ((with-stx u300) (with-stx u200)) + (try! (stx-transfer? u40 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_multiple_allowances_one_low() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? ((with-stx u100) (with-stx u20)) + (try! (stx-transfer? u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +// #[test] +// fn test_as_contract_with_stacking_delegate_ok() { +// let snippet = r#" +// (as-contract? ((with-stacking u2000)) +// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx +// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none +// )) +// )"#; +// let expected = Value::okay_true(); +// assert_eq!(expected, execute(snippet).unwrap().unwrap()); +// } + +// #[test] +// fn test_as_contract_with_stacking_stack_ok() { +// let snippet = r#" +// (as-contract? ((with-stacking u100)) +// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 stack-stx +// u1100000000000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none +// )) +// )"#; +// let expected = Value::okay_true(); +// assert_eq!(expected, execute(snippet).unwrap().unwrap()); +// } + +// #[test] +// fn test_as_contract_with_stacking_exceeds() { +// let snippet = r#" +// (as-contract? ((with-stacking u10)) +// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx +// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none +// )) +// )"#; +// let expected = Value::error(Value::Int(0)).unwrap(); +// assert_eq!(expected, execute(snippet).unwrap().unwrap()); +// } + +// ---------- Tests for restrict-assets? ---------- + +#[test] +fn test_restrict_assets_with_stx_ok() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u100)) + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_stx_exceeds() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u10)) + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_stx_no_allowance() { + let snippet = r#" +(restrict-assets? tx-sender () + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_stx_all() { + let snippet = r#" +(restrict-assets? tx-sender ((with-all-assets-unsafe)) + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_stx_other_allowances() { + let snippet = r#" +(restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) + (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_stx_burn_ok() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u100)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_stx_burn_exceeds() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u10)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_stx_burn_no_allowance() { + let snippet = r#" +(restrict-assets? tx-sender () + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_stx_burn_all() { + let snippet = r#" +(restrict-assets? tx-sender ((with-all-assets-unsafe)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_stx_burn_other_allowances() { + let snippet = r#" +(restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) + (try! (stx-burn? u50 tx-sender)) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_multiple_allowances_both_low() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u30) (with-stx u20)) + (try! (stx-transfer? u40 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_multiple_allowances_both_ok() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u300) (with-stx u200)) + (try! (stx-transfer? u40 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_multiple_allowances_one_low() { + let snippet = r#" +(restrict-assets? tx-sender ((with-stx u100) (with-stx u20)) + (try! (stx-transfer? u40 tx-sender 'SP000000000000000000002Q6VF78)) +)"#; + let expected = Value::error(Value::Int(1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +// #[test] +// fn test_restrict_assets_with_stacking_delegate_ok() { +// let snippet = r#" +// (restrict-assets? tx-sender ((with-stacking u2000)) +// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx +// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none +// )) +// )"#; +// let expected = Value::okay_true(); +// assert_eq!(expected, execute(snippet).unwrap().unwrap()); +// } + +// #[test] +// fn test_restrict_assets_with_stacking_stack_ok() { +// let snippet = r#" +// (restrict-assets? tx-sender ((with-stacking u100)) +// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 stack-stx +// u1100000000000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none +// )) +// )"#; +// let expected = Value::okay_true(); +// assert_eq!(expected, execute(snippet).unwrap().unwrap()); +// } + +// #[test] +// fn test_restrict_assets_with_stacking_exceeds() { +// let snippet = r#" +// (restrict-assets? tx-sender ((with-stacking u10)) +// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx +// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none +// )) +// )"#; +// let expected = Value::error(Value::Int(0)).unwrap(); +// assert_eq!(expected, execute(snippet).unwrap().unwrap()); +// } From 0a141ae92357de4e1da92184b7cb4a18150688ed Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Sun, 21 Sep 2025 09:19:35 -0400 Subject: [PATCH 06/29] fix: remove ASTRules --- clarity/src/vm/tests/post_conditions.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 4b55f55b94..e56ade010e 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -18,7 +18,6 @@ use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardP use clarity_types::Value; use stacks_common::types::StacksEpochId; -use crate::vm::ast::ASTRules; use crate::vm::database::STXBalance; use crate::vm::{execute_with_parameters_and_call_in_global_context, ClarityVersion}; @@ -27,7 +26,6 @@ fn execute(snippet: &str) -> InterpreterResult> { snippet, ClarityVersion::Clarity4, StacksEpochId::Epoch33, - ASTRules::PrecheckSize, false, |g| { // Setup initial balances for the sender and the contract From 7e8698fa0814e8c03a5b8e94eb5526a352306e92 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Sun, 21 Sep 2025 09:54:05 -0400 Subject: [PATCH 07/29] fix: error in docs example --- clarity/src/vm/docs/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index a221064081..657761ff67 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -2662,7 +2662,7 @@ the contract. When `"*"` is used for the token name, the allowance applies to (define-fungible-token stackaroo) (ft-mint? stackaroo u200 tx-sender) (restrict-assets? tx-sender - ((with-ft current-contract "stackaroo" u50)) + ((with-ft current-contract "stackaroo" u100)) (try! (ft-transfer? stackaroo u100 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) ) ;; Returns (ok true) (restrict-assets? tx-sender From 204e84874a8e0d1314e18ba7e9d7cf91cdb2556d Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 09:51:52 -0400 Subject: [PATCH 08/29] fix: copy/paste error --- clarity/src/vm/functions/post_conditions.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index 9b8691d13d..f5081d0e6b 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -128,7 +128,7 @@ fn eval_allowance( asset_name, }; - let asset_id = eval(&rest[1], env, context)?; + let asset_id = eval(&rest[2], env, context)?; Ok(Allowance::Nft(NftAllowance { asset, asset_id })) } @@ -368,6 +368,7 @@ fn check_allowances( } // Check FT movements + // TODO: Handle "*" asset name if let Some(ft_moved) = assets.get_all_fungible_tokens(owner) { for (asset, amount_moved) in ft_moved { if let Some(allowance_vec) = ft_allowances.get(asset) { @@ -387,6 +388,7 @@ fn check_allowances( } // Check NFT movements + // TODO: Handle "*" asset name if let Some(nft_moved) = assets.get_all_nonfungible_tokens(owner) { for (asset, ids_moved) in nft_moved { if let Some((index, allowance_map)) = nft_allowances.get(asset) { From 771262c742023ab49c65b94eb444bde009af61ae Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 11:28:22 -0400 Subject: [PATCH 09/29] feat: update `with-nft` to support list of identifiers --- clarity-types/src/errors/analysis.rs | 4 +++ .../v2_1/natives/post_conditions.rs | 12 +++++++-- .../v2_1/tests/post_conditions.rs | 26 +++++++++---------- clarity/src/vm/docs/mod.rs | 14 +++++----- clarity/src/vm/functions/post_conditions.rs | 11 +++++--- clarity/src/vm/tests/post_conditions.rs | 8 +++--- 6 files changed, 45 insertions(+), 30 deletions(-) diff --git a/clarity-types/src/errors/analysis.rs b/clarity-types/src/errors/analysis.rs index 8573d91882..1b16162107 100644 --- a/clarity-types/src/errors/analysis.rs +++ b/clarity-types/src/errors/analysis.rs @@ -314,6 +314,8 @@ pub enum CheckErrors { ExpectedAllowanceExpr(String), WithAllAllowanceNotAllowed, WithAllAllowanceNotAlone, + WithNftExpectedListOfIdentifiers, + MaxIdentifierLengthExceeded(u32), } #[derive(Debug, PartialEq)] @@ -617,6 +619,8 @@ impl DiagnosableError for CheckErrors { CheckErrors::ExpectedAllowanceExpr(got_name) => format!("expected an allowance expression, got: {got_name}"), CheckErrors::WithAllAllowanceNotAllowed => "with-all-assets-unsafe is not allowed here, only in the allowance list for `as-contract?`".into(), CheckErrors::WithAllAllowanceNotAlone => "with-all-assets-unsafe must not be used along with other allowances".into(), + CheckErrors::WithNftExpectedListOfIdentifiers => "with-nft allowance must include a list of asset identifiers".into(), + CheckErrors::MaxIdentifierLengthExceeded(max_len) => format!("with-nft allowance identifiers list must not exceed 128 elements, got {max_len}"), } } diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs index bf6c0f82d6..8092513bf4 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs @@ -17,7 +17,7 @@ use clarity_types::errors::analysis::{check_argument_count, check_arguments_at_l use clarity_types::errors::{CheckError, CheckErrors}; use clarity_types::representations::SymbolicExpression; use clarity_types::types::signatures::ASCII_128; -use clarity_types::types::TypeSignature; +use clarity_types::types::{SequenceSubtype, TypeSignature}; use crate::vm::analysis::type_checker::contexts::TypingContext; use crate::vm::analysis::type_checker::v2_1::TypeChecker; @@ -202,7 +202,15 @@ fn check_allowance_with_nft( checker.type_check_expects(&args[0], context, &TypeSignature::PrincipalType)?; checker.type_check_expects(&args[1], context, &ASCII_128)?; - // Asset ID can be any type + + // Asset identifiers must be a Clarity list with any type of elements + let id_list_ty = checker.type_check(&args[2], context)?; + let TypeSignature::SequenceType(SequenceSubtype::ListType(list_data)) = id_list_ty else { + return Err(CheckErrors::WithNftExpectedListOfIdentifiers.into()); + }; + if list_data.get_max_len() > 128 { + return Err(CheckErrors::MaxIdentifierLengthExceeded(list_data.get_max_len()).into()); + } Ok(false) } diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 488d341541..b336a136a5 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -199,7 +199,7 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch ), // multiple allowances ( - "(as-contract? ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" 0x01) (with-stacking u1000)) true)", + "(as-contract? ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" (list 0x01)) (with-stacking u1000)) true)", TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() ), // multiple body expressions @@ -551,47 +551,47 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac let good = [ // basic usage with shortcut contract principal ( - r#"(restrict-assets? tx-sender ((with-nft .token "token-name" u1000)) true)"#, + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" (list u1000))) true)"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // full literal principal ( - r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" u1000)) true)"#, + r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" (list u1000))) true)"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // variable principal ( - r#"(let ((contract .token)) (restrict-assets? tx-sender ((with-nft contract "token-name" u1000)) true))"#, + r#"(let ((contract .token)) (restrict-assets? tx-sender ((with-nft contract "token-name" (list u1000))) true))"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // variable token name ( - r#"(let ((name "token-name")) (restrict-assets? tx-sender ((with-nft .token name u1000)) true))"#, + r#"(let ((name "token-name")) (restrict-assets? tx-sender ((with-nft .token name (list u1000))) true))"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // "*" token name ( - r#"(restrict-assets? tx-sender ((with-nft .token "*" u1000)) true)"#, + r#"(restrict-assets? tx-sender ((with-nft .token "*" (list u1000))) true)"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // empty token name ( - r#"(restrict-assets? tx-sender ((with-nft .token "" u1000)) true)"#, + r#"(restrict-assets? tx-sender ((with-nft .token "" (list u1000))) true)"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // string asset-id ( - r#"(restrict-assets? tx-sender ((with-nft .token "token-name" "asset-123")) true)"#, + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" (list "asset-123"))) true)"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // buffer asset-id ( - r#"(restrict-assets? tx-sender ((with-nft .token "token-name" 0x0123456789)) true)"#, + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" (list 0x0123456789))) true)"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // variable asset-id ( - r#"(let ((asset-id u123)) (restrict-assets? tx-sender ((with-nft .token "token-name" asset-id)) true))"#, + r#"(let ((asset-id (list u123))) (restrict-assets? tx-sender ((with-nft .token "token-name" asset-id)) true))"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), ]; @@ -614,12 +614,12 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ), // too many arguments ( - r#"(restrict-assets? tx-sender ((with-nft .token "token-name" u123 u456)) true)"#, + r#"(restrict-assets? tx-sender ((with-nft .token "token-name" (list u123) (list u456))) true)"#, CheckErrors::IncorrectArgumentCount(3, 4), ), // wrong type for contract-id - uint instead of principal ( - r#"(restrict-assets? tx-sender ((with-nft u123 "token-name" u456)) true)"#, + r#"(restrict-assets? tx-sender ((with-nft u123 "token-name" (list u456))) true)"#, CheckErrors::TypeError( TypeSignature::PrincipalType.into(), TypeSignature::UIntType.into(), @@ -627,7 +627,7 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ), // wrong type for token-name - uint instead of string ( - "(restrict-assets? tx-sender ((with-nft .token u123 u456)) true)", + "(restrict-assets? tx-sender ((with-nft .token u123 (list u456))) true)", CheckErrors::TypeError( TypeSignature::new_string_ascii(128).unwrap().into(), TypeSignature::UIntType.into(), diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index 657761ff67..ed1a94140d 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -2673,12 +2673,12 @@ the contract. When `"*"` is used for the token name, the allowance applies to }; const ALLOWANCE_WITH_NFT: SpecialAPI = SpecialAPI { - input_type: "principal (string-ascii 128) T", - snippet: "with-nft ${1:contract-id} ${2:asset-name} ${3:asset-identifier}", + input_type: "principal (string-ascii 128) (list 128 T)", + snippet: "with-nft ${1:contract-id} ${2:asset-name} ${3:asset-identifiers}", output_type: "Allowance", - signature: "(with-nft contract-id asset-name identifier)", - description: r#"Adds an outflow allowance for the non-fungible token -identified by `identifier` defined in `contract-id` with name `token-name` + signature: "(with-nft contract-id asset-name identifiers)", + description: r#"Adds an outflow allowance for the non-fungible tokens +identified by `identifiers` defined in `contract-id` with name `token-name` from the `asset-owner` of the enclosing `restrict-assets?` or `as-contract?` expression. `with-nft` is not allowed outside of `restrict-assets?` or `as-contract?` contexts. Note that `token-name` should match the name used in @@ -2690,11 +2690,11 @@ the token name, the allowance applies to **all** NFTs defined in `contract-id`." (nft-mint? stackaroo u124 tx-sender) (nft-mint? stackaroo u125 tx-sender) (restrict-assets? tx-sender - ((with-nft current-contract "stackaroo" u123)) + ((with-nft current-contract "stackaroo" (list u123))) (try! (nft-transfer? stackaroo u123 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) ) ;; Returns (ok true) (restrict-assets? tx-sender - ((with-nft current-contract "stackaroo" u125)) + ((with-nft current-contract "stackaroo" (list u125))) (try! (nft-transfer? stackaroo u124 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) ) ;; Returns (err 0) "#, diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index f5081d0e6b..b04203d12b 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -38,7 +38,7 @@ pub struct FtAllowance { pub struct NftAllowance { asset: AssetIdentifier, - asset_id: Value, + asset_ids: Vec, } pub struct StackingAllowance { @@ -128,9 +128,10 @@ fn eval_allowance( asset_name, }; - let asset_id = eval(&rest[2], env, context)?; + let asset_id_list = eval(&rest[2], env, context)?; + let asset_ids = asset_id_list.expect_list()?; - Ok(Allowance::Nft(NftAllowance { asset, asset_id })) + Ok(Allowance::Nft(NftAllowance { asset, asset_ids })) } "with-stacking" => { if rest.len() != 1 { @@ -325,7 +326,9 @@ fn check_allowances( let (_, set) = nft_allowances .entry(&nft.asset) .or_insert_with(|| (i, HashSet::new())); - set.insert(nft.asset_id.serialize_to_hex()?); + for id in &nft.asset_ids { + set.insert(id.serialize_to_hex()?); + } } Allowance::Stacking(stacking) => { stacking_allowances.push((i, stacking.amount)); diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index e56ade010e..7ed32fdfd2 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -105,7 +105,7 @@ fn test_as_contract_stx_all() { fn test_as_contract_stx_other_allowances() { let snippet = r#" (let ((recipient tx-sender)) - (as-contract? ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) + (as-contract? ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" (list 123))) (try! (stx-transfer? u50 tx-sender recipient)) ) )"#; @@ -156,7 +156,7 @@ fn test_as_contract_stx_burn_all() { #[test] fn test_as_contract_stx_burn_other_allowances() { let snippet = r#" -(as-contract? ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) +(as-contract? ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" (list 123))) (try! (stx-burn? u50 tx-sender)) )"#; let expected = Value::error(Value::Int(-1)).unwrap(); @@ -280,7 +280,7 @@ fn test_restrict_assets_stx_all() { #[test] fn test_restrict_assets_stx_other_allowances() { let snippet = r#" -(restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) +(restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" (list 123))) (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) )"#; let expected = Value::error(Value::Int(-1)).unwrap(); @@ -330,7 +330,7 @@ fn test_restrict_assets_stx_burn_all() { #[test] fn test_restrict_assets_stx_burn_other_allowances() { let snippet = r#" -(restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" 123)) +(restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" (list 123))) (try! (stx-burn? u50 tx-sender)) )"#; let expected = Value::error(Value::Int(-1)).unwrap(); From b450cd18fa00dc6b883653e0b715fbb181c6b745 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 11:28:43 -0400 Subject: [PATCH 10/29] test: add tests for ft and nft post-conditions --- clarity/src/vm/tests/post_conditions.rs | 510 ++++++++++++++++++++++++ 1 file changed, 510 insertions(+) diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 7ed32fdfd2..b142f8e6bc 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -235,6 +235,261 @@ fn test_as_contract_multiple_allowances_one_low() { // assert_eq!(expected, execute(snippet).unwrap().unwrap()); // } +#[test] +fn test_as_contract_with_ft_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "stackaroo" u100)) + (try! (ft-transfer? stackaroo u100 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_exceeds() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "stackaroo" u10)) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_no_allowance() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? () + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_all() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-all-assets-unsafe)) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_other_allowances() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? + ( + (with-stx u200) + (with-ft .other "stackaroo" u100) ;; other contract, same token name + (with-ft current-contract "other" u100) ;; same contract, different token name + (with-nft .token "stackaroo" (list 123)) + ) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_multiple_allowances_both_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "stackaroo" u30) (with-ft current-contract "stackaroo" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_multiple_allowances_both_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "stackaroo" u300) (with-ft current-contract "stackaroo" u200)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_multiple_allowances_one_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "stackaroo" u100) (with-ft current-contract "stackaroo" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_ok() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "stackaroo" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_not_allowed() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "stackaroo" (list u122))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_no_allowance() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? () + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_all() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-all-assets-unsafe)) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_other_allowances() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? + ( + (with-stx u123) + (with-nft .other "stackaroo" (list u123)) ;; other contract, same token name + (with-nft current-contract "other" (list u123)) ;; same contract, different token name + (with-ft .token "stackaroo" u123) + ) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_multiple_allowances_both_different() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "stackaroo" (list u122)) (with-nft current-contract "stackaroo" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_multiple_allowances_including() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "stackaroo" (list u122)) (with-nft current-contract "stackaroo" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_multiple_allowances_in_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "stackaroo" (list u122 u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_empty_id_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "stackaroo" (list))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + // ---------- Tests for restrict-assets? ---------- #[test] @@ -402,3 +657,258 @@ fn test_restrict_assets_multiple_allowances_one_low() { // let expected = Value::error(Value::Int(0)).unwrap(); // assert_eq!(expected, execute(snippet).unwrap().unwrap()); // } + +#[test] +fn test_restrict_assets_with_ft_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "stackaroo" u100)) + (try! (ft-transfer? stackaroo u100 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_exceeds() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "stackaroo" u10)) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_no_allowance() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender () + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_all() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-all-assets-unsafe)) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_other_allowances() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender + ( + (with-stx u200) + (with-ft .other "stackaroo" u100) ;; other contract, same token name + (with-ft current-contract "other" u100) ;; same contract, different token name + (with-nft .token "stackaroo" (list 123)) + ) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_multiple_allowances_both_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "stackaroo" u30) (with-ft current-contract "stackaroo" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_multiple_allowances_both_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "stackaroo" u300) (with-ft current-contract "stackaroo" u200)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_multiple_allowances_one_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "stackaroo" u100) (with-ft current-contract "stackaroo" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_ok() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_not_allowed() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list u122))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_no_allowance() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender () + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_all() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-all-assets-unsafe)) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_other_allowances() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender + ( + (with-stx u123) + (with-nft .other "stackaroo" (list u123)) ;; other contract, same token name + (with-nft current-contract "other" (list u123)) ;; same contract, different token name + (with-ft .token "stackaroo" u123) + ) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_multiple_allowances_both_different() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list u122)) (with-nft current-contract "stackaroo" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_multiple_allowances_including() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list u122)) (with-nft current-contract "stackaroo" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_multiple_allowances_in_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list u122 u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_empty_id_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} From 11c4b1412d303828b512f903a1e4df89e20c4088 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 12:24:47 -0400 Subject: [PATCH 11/29] feat: add checks for max number of allowances and max NFT identifiers --- clarity-types/src/errors/analysis.rs | 6 ++- .../analysis/type_checker/v2_1/natives/mod.rs | 2 +- .../v2_1/natives/post_conditions.rs | 21 ++++++++- .../v2_1/tests/post_conditions.rs | 45 +++++++++++++++++-- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/clarity-types/src/errors/analysis.rs b/clarity-types/src/errors/analysis.rs index 1b16162107..5cb3176c02 100644 --- a/clarity-types/src/errors/analysis.rs +++ b/clarity-types/src/errors/analysis.rs @@ -315,7 +315,8 @@ pub enum CheckErrors { WithAllAllowanceNotAllowed, WithAllAllowanceNotAlone, WithNftExpectedListOfIdentifiers, - MaxIdentifierLengthExceeded(u32), + MaxIdentifierLengthExceeded(u32, u32), + TooManyAllowances(usize, usize), } #[derive(Debug, PartialEq)] @@ -620,7 +621,8 @@ impl DiagnosableError for CheckErrors { CheckErrors::WithAllAllowanceNotAllowed => "with-all-assets-unsafe is not allowed here, only in the allowance list for `as-contract?`".into(), CheckErrors::WithAllAllowanceNotAlone => "with-all-assets-unsafe must not be used along with other allowances".into(), CheckErrors::WithNftExpectedListOfIdentifiers => "with-nft allowance must include a list of asset identifiers".into(), - CheckErrors::MaxIdentifierLengthExceeded(max_len) => format!("with-nft allowance identifiers list must not exceed 128 elements, got {max_len}"), + CheckErrors::MaxIdentifierLengthExceeded(max_len, len) => format!("with-nft allowance identifiers list must not exceed {max_len} elements, got {len}"), + CheckErrors::TooManyAllowances(max_allowed, found) => format!("too many allowances specified, the maximum is {max_allowed}, found {found}"), } } diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index ee016f2dbf..26415fe7f1 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -41,7 +41,7 @@ mod assets; mod conversions; mod maps; mod options; -mod post_conditions; +pub(crate) mod post_conditions; mod sequences; #[allow(clippy::large_enum_variant)] diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs index 8092513bf4..931cec4feb 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs @@ -25,6 +25,11 @@ use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::runtime_cost; use crate::vm::functions::NativeFunctions; +/// Maximum number of allowances allowed in a `restrict-assets?` or `as-contract?` expression. +pub(crate) const MAX_ALLOWANCES: usize = 128; +/// Maximum number of asset identifiers allowed in a `with-nft` allowance expression. +pub(crate) const MAX_NFT_IDENTIFIERS: u32 = 128; + pub fn check_restrict_assets( checker: &mut TypeChecker, args: &[SymbolicExpression], @@ -41,6 +46,10 @@ pub fn check_restrict_assets( ))?; let body_exprs = &args[2..]; + if allowance_list.len() > MAX_ALLOWANCES { + return Err(CheckErrors::TooManyAllowances(MAX_ALLOWANCES, allowance_list.len()).into()); + } + runtime_cost( ClarityCostFunction::AnalysisListItemsCheck, checker, @@ -87,6 +96,10 @@ pub fn check_as_contract( ))?; let body_exprs = &args[1..]; + if allowance_list.len() > MAX_ALLOWANCES { + return Err(CheckErrors::TooManyAllowances(MAX_ALLOWANCES, allowance_list.len()).into()); + } + runtime_cost( ClarityCostFunction::AnalysisListItemsCheck, checker, @@ -208,8 +221,12 @@ fn check_allowance_with_nft( let TypeSignature::SequenceType(SequenceSubtype::ListType(list_data)) = id_list_ty else { return Err(CheckErrors::WithNftExpectedListOfIdentifiers.into()); }; - if list_data.get_max_len() > 128 { - return Err(CheckErrors::MaxIdentifierLengthExceeded(list_data.get_max_len()).into()); + if list_data.get_max_len() > MAX_NFT_IDENTIFIERS { + return Err(CheckErrors::MaxIdentifierLengthExceeded( + MAX_NFT_IDENTIFIERS, + list_data.get_max_len(), + ) + .into()); } Ok(false) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index b336a136a5..60ec6e0173 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -14,9 +14,13 @@ // along with this program. If not, see . use clarity_types::errors::CheckErrors; +use clarity_types::representations::MAX_STRING_LEN; use clarity_types::types::TypeSignature; use stacks_common::types::StacksEpochId; +use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::{ + MAX_ALLOWANCES, MAX_NFT_IDENTIFIERS, +}; use crate::vm::analysis::type_checker::v2_1::tests::type_check_helper_version; use crate::vm::tests::test_clarity_versions; use crate::vm::ClarityVersion; @@ -141,6 +145,17 @@ fn test_restrict_assets(#[case] version: ClarityVersion, #[case] _epoch: StacksE "(restrict-assets? tx-sender ((with-stx u1000)) (err u1) true)", CheckErrors::UncheckedIntermediaryResponses, ), + // too many allowances + ( + &format!( + "(restrict-assets? tx-sender ({} ) true)", + std::iter::repeat("(with-stx u1)") + .take(130) + .collect::>() + .join(" ") + ), + CheckErrors::TooManyAllowances(MAX_ALLOWANCES, 130), + ), ]; for (good_code, expected_type) in &good { @@ -280,6 +295,17 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch "(as-contract? ((with-stx u1000) (with-all-assets-unsafe)) true)", CheckErrors::WithAllAllowanceNotAlone, ), + // too many allowances + ( + &format!( + "(as-contract? ({} ) true)", + std::iter::repeat("(with-stx u1)") + .take(130) + .collect::>() + .join(" ") + ), + CheckErrors::TooManyAllowances(MAX_ALLOWANCES, 130), + ), ]; for (code, expected_type) in &good { @@ -481,7 +507,7 @@ fn test_with_ft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stack ( "(restrict-assets? tx-sender ((with-ft .token u123 u1000)) true)", CheckErrors::TypeError( - TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::new_string_ascii(MAX_STRING_LEN as usize).unwrap().into(), TypeSignature::UIntType.into(), ), ), @@ -505,7 +531,7 @@ fn test_with_ft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stack ( "(restrict-assets? tx-sender ((with-ft .token \"this-token-name-is-way-too-long-to-be-valid-because-it-has-more-than-one-hundred-and-twenty-eight-characters-in-it-so-it-is-not-a-valid-token-name\" u1000)) true)", CheckErrors::TypeError( - TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::new_string_ascii(MAX_STRING_LEN as usize).unwrap().into(), TypeSignature::new_string_ascii(146).unwrap().into(), ), ), @@ -629,7 +655,7 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ( "(restrict-assets? tx-sender ((with-nft .token u123 (list u456))) true)", CheckErrors::TypeError( - TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::new_string_ascii(MAX_STRING_LEN as usize).unwrap().into(), TypeSignature::UIntType.into(), ), ), @@ -637,10 +663,21 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ( "(restrict-assets? tx-sender ((with-ft .token \"this-token-name-is-way-too-long-to-be-valid-because-it-has-more-than-one-hundred-and-twenty-eight-characters-in-it-so-it-is-not-a-valid-token-name\" u1000)) true)", CheckErrors::TypeError( - TypeSignature::new_string_ascii(128).unwrap().into(), + TypeSignature::new_string_ascii(MAX_STRING_LEN as usize).unwrap().into(), TypeSignature::new_string_ascii(146).unwrap().into(), ), ), + // too many identifiers (more than 128) + ( + &format!( + "(restrict-assets? tx-sender ((with-nft .token \"token-name\" (list {}))) true)", + std::iter::repeat("u1") + .take(130) + .collect::>() + .join(" ") + ), + CheckErrors::MaxIdentifierLengthExceeded(MAX_NFT_IDENTIFIERS, 130), + ), ]; for (code, expected_type) in &good { From 1afa16842a78cba2ef6457724f430c811865c613 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 13:41:19 -0400 Subject: [PATCH 12/29] fix: update test and fix clippy issues --- .../type_checker/v2_1/tests/post_conditions.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 60ec6e0173..0a24369e0b 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -57,7 +57,7 @@ fn test_restrict_assets(#[case] version: ClarityVersion, #[case] _epoch: StacksE ), // multiple allowances ( - "(restrict-assets? tx-sender ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" 0x01) (with-stacking u1000)) true)", + "(restrict-assets? tx-sender ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" (list 0x01)) (with-stacking u1000)) true)", TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() ), // multiple body expressions @@ -149,8 +149,7 @@ fn test_restrict_assets(#[case] version: ClarityVersion, #[case] _epoch: StacksE ( &format!( "(restrict-assets? tx-sender ({} ) true)", - std::iter::repeat("(with-stx u1)") - .take(130) + std::iter::repeat_n("(with-stx u1)", 130) .collect::>() .join(" ") ), @@ -299,8 +298,7 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch ( &format!( "(as-contract? ({} ) true)", - std::iter::repeat("(with-stx u1)") - .take(130) + std::iter::repeat_n("(with-stx u1)", 130) .collect::>() .join(" ") ), @@ -671,8 +669,7 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ( &format!( "(restrict-assets? tx-sender ((with-nft .token \"token-name\" (list {}))) true)", - std::iter::repeat("u1") - .take(130) + std::iter::repeat_n("u1", 130) .collect::>() .join(" ") ), From 156724d3bce615ce953484ce25f3b75db6df9d27 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 14:07:32 -0400 Subject: [PATCH 13/29] test: ignore `with-stacking` in doc examples tests That infrastructure is not setup for handling PoX state, and the stacking tracking in the asset maps is not setup yet any way. --- clarity/src/vm/docs/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index ed1a94140d..bd37d3b654 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -3379,6 +3379,10 @@ mod test { ); continue; } + if func_api.name == "with-stacking" { + eprintln!("Skipping with-stacking, because it requires PoX state"); + continue; + } let mut store = MemoryBackingStore::new(); // first, load the samples for contract-call From df2b5818216006efc1397a0e4ed7e5252fabc64a Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 15:28:40 -0400 Subject: [PATCH 14/29] fix: typo in tests --- .../src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 0a24369e0b..663da3c97c 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -580,7 +580,7 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ), // full literal principal ( - r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" (list u1000))) true)"#, + r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" u1000)) true)"#, TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), ), // variable principal From b20511e805c1880ebe55a4398a441b4266d32c11 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Mon, 22 Sep 2025 17:26:19 -0400 Subject: [PATCH 15/29] feat: implement tracking of stacking for post-conditions Testing still needed. --- clarity-types/src/errors/mod.rs | 1 + clarity/src/vm/contexts.rs | 34 ++++++++++++++++++++++++++++++++- pox-locking/src/pox_4.rs | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/clarity-types/src/errors/mod.rs b/clarity-types/src/errors/mod.rs index 58b42b8a99..3c8e41e090 100644 --- a/clarity-types/src/errors/mod.rs +++ b/clarity-types/src/errors/mod.rs @@ -101,6 +101,7 @@ pub enum RuntimeErrorType { PoxAlreadyLocked, BlockTimeNotAvailable, + Unreachable, } #[derive(Debug, PartialEq)] diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 6dba496d4b..05cefc3bd9 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -82,6 +82,7 @@ pub enum AssetMapEntry { Burn(u128), Token(u128), Asset(Vec), + Stacking(u128), } /** @@ -90,10 +91,16 @@ during the execution of a transaction. */ #[derive(Debug, Clone)] pub struct AssetMap { + /// Sum of all STX transfers by principal stx_map: HashMap, + /// Sum of all STX burns by principal burn_map: HashMap, + /// Sum of FT transfers by principal, by asset identifier token_map: HashMap>, + /// NFT transfers by principal, by asset identifier asset_map: HashMap>>, + /// Amount of STX stacked or delegated for stacking by principal + stacking_map: HashMap, } impl AssetMap { @@ -169,11 +176,23 @@ impl AssetMap { }) .collect(); + let stacking: serde_json::map::Map<_, _> = self + .stacking_map + .iter() + .map(|(principal, amount)| { + ( + format!("{principal}"), + serde_json::value::Value::String(format!("{amount}")), + ) + }) + .collect(); + json!({ "stx": stx, "burns": burns, "tokens": tokens, - "assets": assets + "assets": assets, + "stacking": stacking, }) } } @@ -264,6 +283,7 @@ impl AssetMap { burn_map: HashMap::new(), token_map: HashMap::new(), asset_map: HashMap::new(), + stacking_map: HashMap::new(), } } @@ -343,6 +363,13 @@ impl AssetMap { Ok(()) } + /// Log an amount of STX to be stacked or delegated for stacking by a + /// principal. Since any given principal can only stack once, this will + /// overwrite any previous amount for the principal. + pub fn add_stacking(&mut self, principal: &PrincipalData, amount: u128) { + self.stacking_map.insert(principal.clone(), amount); + } + // This will add any asset transfer data from other to self, // aborting _all_ changes in the event of an error, leaving self unchanged pub fn commit_other(&mut self, mut other: AssetMap) -> Result<()> { @@ -1612,6 +1639,11 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { self.get_asset_map()?.add_stx_burn(sender, transfered) } + pub fn log_stacking(&mut self, sender: &PrincipalData, amount: u128) -> Result<()> { + self.get_asset_map()?.add_stacking(sender, amount); + Ok(()) + } + pub fn execute(&mut self, f: F) -> Result where F: FnOnce(&mut Self) -> Result, diff --git a/pox-locking/src/pox_4.rs b/pox-locking/src/pox_4.rs index 733d6d6c54..2aa636d4d3 100644 --- a/pox-locking/src/pox_4.rs +++ b/pox-locking/src/pox_4.rs @@ -181,6 +181,11 @@ fn handle_stack_lockup_pox_v4( unlock_height, ) { Ok(_) => { + // For direct stacking, we log the locked amount in the asset map. + if function_name == "stack-stx" { + global_context.log_stacking(&stacker, locked_amount)?; + } + let event = StacksTransactionEvent::STXEvent(STXEventType::STXLockEvent(STXLockEventData { locked_amount, @@ -243,6 +248,11 @@ fn handle_stack_lockup_extension_pox_v4( match pox_lock_extend_v4(&mut global_context.database, &stacker, unlock_height) { Ok(locked_amount) => { + // For direct stacking, we log the locked amount in the asset map. + if function_name == "stack-extend" { + global_context.log_stacking(&stacker, locked_amount)?; + } + let event = StacksTransactionEvent::STXEvent(STXEventType::STXLockEvent(STXLockEventData { locked_amount, @@ -298,6 +308,11 @@ fn handle_stack_lockup_increase_pox_v4( }; match pox_lock_increase_v4(&mut global_context.database, &stacker, total_locked) { Ok(new_balance) => { + // For direct stacking, we log the locked amount in the asset map. + if function_name == "stack-increase" { + global_context.log_stacking(&stacker, new_balance.amount_locked())?; + } + let event = StacksTransactionEvent::STXEvent(STXEventType::STXLockEvent(STXLockEventData { locked_amount: new_balance.amount_locked(), @@ -379,6 +394,24 @@ pub fn handle_contract_call( None }; + if function_name == "delegate-stx" { + // Update the asset map to reflect the delegation + match (sender_opt, args.first()) { + (Some(sender), Some(Value::UInt(amount))) => { + global_context.log_stacking(sender, *amount)?; + } + _ => { + // This should be unreachable! + error!( + "Unreachable: failed to log STX delegation in PoX-4 delegate-stx call"; + "sender" => ?sender_opt, + "arg0" => ?args.first(), + ); + return Err(ClarityError::Runtime(RuntimeErrorType::Unreachable, None)); + } + } + } + // append the lockup event, so it looks as if the print event happened before the lock-up if let Some(batch) = global_context.event_batches.last_mut() { if let Some(print_event) = print_event_opt { From b396d86fa441536a3a5956cbced92119c1d1f721 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Tue, 23 Sep 2025 11:43:44 -0400 Subject: [PATCH 16/29] feat: add support for "*" wildcard in allowances --- clarity/src/vm/functions/post_conditions.rs | 61 ++- clarity/src/vm/tests/post_conditions.rs | 514 ++++++++++++++++++++ 2 files changed, 560 insertions(+), 15 deletions(-) diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index b04203d12b..82bb1cf138 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -371,41 +371,72 @@ fn check_allowances( } // Check FT movements - // TODO: Handle "*" asset name if let Some(ft_moved) = assets.get_all_fungible_tokens(owner) { for (asset, amount_moved) in ft_moved { + // Build merged allowance list: exact-match entries + wildcard entries for the same contract + let mut merged: Vec<(usize, u128)> = Vec::new(); + if let Some(allowance_vec) = ft_allowances.get(asset) { - // Check against the FT allowances - for (index, allowance) in allowance_vec { - if *amount_moved > *allowance { - return Ok(Some(i128::try_from(*index).map_err(|_| { - InterpreterError::Expect("failed to convert index to i128".into()) - })?)); - } - } - } else { + merged.extend(allowance_vec.iter().cloned()); + } + + if let Some(wildcard_vec) = ft_allowances.get(&AssetIdentifier { + contract_identifier: asset.contract_identifier.clone(), + asset_name: "*".into(), + }) { + merged.extend(wildcard_vec.iter().cloned()); + } + + if merged.is_empty() { // No allowance for this asset, any movement is a violation return Ok(Some(-1)); } + + // Sort by allowance index so we check allowances in order + merged.sort_by_key(|(idx, _)| *idx); + + for (index, allowance) in merged { + if *amount_moved > allowance { + return Ok(Some(i128::try_from(index).map_err(|_| { + InterpreterError::Expect("failed to convert index to i128".into()) + })?)); + } + } } } // Check NFT movements - // TODO: Handle "*" asset name if let Some(nft_moved) = assets.get_all_nonfungible_tokens(owner) { for (asset, ids_moved) in nft_moved { + let mut merged: Vec<(usize, HashSet)> = Vec::new(); if let Some((index, allowance_map)) = nft_allowances.get(asset) { + merged.push((*index, allowance_map.clone())); + } + + if let Some((index, allowance_map)) = nft_allowances.get(&AssetIdentifier { + contract_identifier: asset.contract_identifier.clone(), + asset_name: "*".into(), + }) { + merged.push((*index, allowance_map.clone())); + } + + if merged.is_empty() { + // No allowance for this asset, any movement is a violation + return Ok(Some(-1)); + } + + // Sort by allowance index so we check allowances in order + merged.sort_by_key(|(idx, _)| *idx); + + for (index, allowance_map) in merged { // Check against the NFT allowances for id_moved in ids_moved { if !allowance_map.contains(&id_moved.serialize_to_hex()?) { - return Ok(Some(i128::try_from(*index).map_err(|_| { + return Ok(Some(i128::try_from(index).map_err(|_| { InterpreterError::Expect("failed to convert index to i128".into()) })?)); } } - } else { - // No allowance for this asset, any movement is a violation - return Ok(Some(-1)); } } } diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index b142f8e6bc..5ef6b673de 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -353,6 +353,124 @@ fn test_as_contract_with_ft_multiple_allowances_one_low() { assert_eq!(expected, execute(snippet).unwrap().unwrap()); } +#[test] +fn test_as_contract_with_ft_wildcard_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "*" u100)) + (try! (ft-transfer? stackaroo u100 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_wildcard_exceeds() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "*" u10)) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_wildcard_other_allowances() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? + ( + (with-stx u200) + (with-ft .other "*" u100) ;; other contract, same token name + (with-ft current-contract "other" u100) ;; same contract, different token name + (with-nft .token "*" (list 123)) + ) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_wildcard_multiple_allowances_both_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "*" u30) (with-ft current-contract "*" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_wildcard_multiple_allowances_both_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "*" u300) (with-ft current-contract "*" u200)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_wildcard_multiple_allowances_one_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "*" u100) (with-ft current-contract "*" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_wildcard_multiple_allowances_low1() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "*" u20) (with-ft current-contract "stackaroo" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_ft_wildcard_multiple_allowances_low2() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-ft current-contract "stackaroo" u20) (with-ft current-contract "*" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + #[test] fn test_as_contract_with_nft_ok() { let snippet = r#" @@ -490,6 +608,145 @@ fn test_as_contract_with_nft_empty_id_list() { assert_eq!(expected, execute(snippet).unwrap().unwrap()); } +#[test] +fn test_as_contract_with_nft_wildcard_ok() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "*" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_not_allowed() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "*" (list u122))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_other_allowances() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? + ( + (with-stx u123) + (with-nft .other "*" (list u123)) ;; other contract, same token name + (with-nft current-contract "other" (list u123)) ;; same contract, different token name + (with-ft .token "*" u123) + ) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_multiple_allowances_both_different() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "*" (list u122)) (with-nft current-contract "*" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_multiple_allowances_including() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "*" (list u122)) (with-nft current-contract "*" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_multiple_allowances_in_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "*" (list u122 u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_empty_id_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "*" (list))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_multiple_allowances_order1() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "*" (list u122)) (with-nft current-contract "stackaroo" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_as_contract_with_nft_wildcard_multiple_allowances_order2() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 current-contract) +(nft-mint? stackaroo u123 current-contract) +(let ((recipient tx-sender)) + (as-contract? ((with-nft current-contract "stackaroo" (list u122)) (with-nft current-contract "*" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + // ---------- Tests for restrict-assets? ---------- #[test] @@ -776,6 +1033,124 @@ fn test_restrict_assets_with_ft_multiple_allowances_one_low() { assert_eq!(expected, execute(snippet).unwrap().unwrap()); } +#[test] +fn test_restrict_assets_with_ft_wildcard_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "*" u100)) + (try! (ft-transfer? stackaroo u100 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_wildcard_exceeds() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "*" u10)) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_wildcard_other_allowances() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender + ( + (with-stx u200) + (with-ft .other "*" u100) ;; other contract, same token name + (with-ft current-contract "other" u100) ;; same contract, different token name + (with-nft .token "*" (list 123)) + ) + (try! (ft-transfer? stackaroo u50 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_wildcard_multiple_allowances_both_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "*" u30) (with-ft current-contract "*" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_wildcard_multiple_allowances_both_ok() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "*" u300) (with-ft current-contract "*" u200)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_wildcard_multiple_allowances_one_low() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "*" u100) (with-ft current-contract "*" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_wildcard_multiple_allowances_low1() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "*" u20) (with-ft current-contract "stackaroo" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_ft_wildcard_multiple_allowances_low2() { + let snippet = r#" +(define-fungible-token stackaroo) +(ft-mint? stackaroo u200 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-ft current-contract "stackaroo" u20) (with-ft current-contract "*" u20)) + (try! (ft-transfer? stackaroo u40 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + #[test] fn test_restrict_assets_with_nft_ok() { let snippet = r#" @@ -912,3 +1287,142 @@ fn test_restrict_assets_with_nft_empty_id_list() { let expected = Value::error(Value::Int(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } + +#[test] +fn test_restrict_assets_with_nft_wildcard_ok() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "*" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_not_allowed() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "*" (list u122))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_other_allowances() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender + ( + (with-stx u123) + (with-nft .other "*" (list u123)) ;; other contract, same token name + (with-nft current-contract "other" (list u123)) ;; same contract, different token name + (with-ft .token "*" u123) + ) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(-1)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_multiple_allowances_both_different() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "*" (list u122)) (with-nft current-contract "*" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_multiple_allowances_including() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "*" (list u122)) (with-nft current-contract "*" (list u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_multiple_allowances_in_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "*" (list u122 u123))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::okay_true(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_empty_id_list() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "*" (list))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_multiple_allowances_order1() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "*" (list u122)) (with-nft current-contract "stackaroo" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} + +#[test] +fn test_restrict_assets_with_nft_wildcard_multiple_allowances_order2() { + let snippet = r#" +(define-non-fungible-token stackaroo uint) +(nft-mint? stackaroo u122 tx-sender) +(nft-mint? stackaroo u123 tx-sender) +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list u122)) (with-nft current-contract "*" (list u124))) + (try! (nft-transfer? stackaroo u123 tx-sender recipient)) + ) +)"#; + let expected = Value::error(Value::Int(0)).unwrap(); + assert_eq!(expected, execute(snippet).unwrap().unwrap()); +} From dd13789d05e13689816438e839b34f3f0e70ab67 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 24 Sep 2025 11:23:52 -0400 Subject: [PATCH 17/29] fix: add stacking allowance check --- clarity/src/vm/contexts.rs | 8 ++++++++ clarity/src/vm/functions/post_conditions.rs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 05cefc3bd9..2120b508b3 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -419,6 +419,10 @@ impl AssetMap { principal_map.insert(asset, amount); } + for (principal, stacking_amount) in other.stacking_map.drain() { + self.stacking_map.insert(principal, stacking_amount); + } + Ok(()) } @@ -506,6 +510,10 @@ impl AssetMap { let assets = self.asset_map.get(principal)?; Some(assets) } + + pub fn get_stacking(&self, principal: &PrincipalData) -> Option { + self.stacking_map.get(principal).copied() + } } impl fmt::Display for AssetMap { diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index 82bb1cf138..40d91f3511 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -441,6 +441,23 @@ fn check_allowances( } } + // Check stacking + if let Some(stx_stacked) = assets.get_stacking(owner) { + // If there are no allowances for stacking, any stacking is a violation + if stacking_allowances.is_empty() { + return Ok(Some(-1)); + } + + // Check against the stacking allowances + for (index, allowance) in &stacking_allowances { + if stx_stacked > *allowance { + return Ok(Some(i128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to i128".into()) + })?)); + } + } + } + Ok(None) } From 103027cca110aa763d9b05ba9691c92f150ca76b Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 24 Sep 2025 23:23:34 -0400 Subject: [PATCH 18/29] test: add integration test for `with-stacking` allowances --- clarity/src/vm/functions/post_conditions.rs | 17 +- clarity/src/vm/tests/post_conditions.rs | 76 +--- .../src/tests/nakamoto_integrations.rs | 397 ++++++++++++++++++ 3 files changed, 414 insertions(+), 76 deletions(-) diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index 40d91f3511..ad320bb021 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -263,6 +263,8 @@ pub fn special_as_contract( // evaluate the body expressions let mut last_result = None; for expr in body_exprs { + // TODO: handle runtime errors inside the body expressions correctly + // (ensure that the context is always popped and asset maps are checked against allowances) let result = eval(expr, &mut nested_env, context)?; last_result.replace(result); } @@ -272,10 +274,17 @@ pub fn special_as_contract( // If the allowances are violated: // - Rollback the context // - Emit an event - if let Some(violation_index) = check_allowances(&contract_principal, &allowances, asset_maps)? { - nested_env.global_context.roll_back()?; - // TODO: Emit an event about the allowance violation - return Value::error(Value::Int(violation_index)); + match check_allowances(&contract_principal, &allowances, asset_maps) { + Ok(None) => {} + Ok(Some(violation_index)) => { + nested_env.global_context.roll_back()?; + // TODO: Emit an event about the allowance violation + return Value::error(Value::Int(violation_index)); + } + Err(e) => { + nested_env.global_context.roll_back()?; + return Err(e); + } } nested_env.global_context.commit()?; diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 5ef6b673de..71c7e28d60 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -13,6 +13,10 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +//! This module contains unit tests for the `as-contract?` and +//! `restrict-assets?` expressions. The `with-stacking` allowances are tested +//! in integration tests, since they require changes made outside of the VM. + use clarity_types::errors::InterpreterResult; use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; use clarity_types::Value; @@ -199,42 +203,6 @@ fn test_as_contract_multiple_allowances_one_low() { assert_eq!(expected, execute(snippet).unwrap().unwrap()); } -// #[test] -// fn test_as_contract_with_stacking_delegate_ok() { -// let snippet = r#" -// (as-contract? ((with-stacking u2000)) -// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx -// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none -// )) -// )"#; -// let expected = Value::okay_true(); -// assert_eq!(expected, execute(snippet).unwrap().unwrap()); -// } - -// #[test] -// fn test_as_contract_with_stacking_stack_ok() { -// let snippet = r#" -// (as-contract? ((with-stacking u100)) -// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 stack-stx -// u1100000000000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none -// )) -// )"#; -// let expected = Value::okay_true(); -// assert_eq!(expected, execute(snippet).unwrap().unwrap()); -// } - -// #[test] -// fn test_as_contract_with_stacking_exceeds() { -// let snippet = r#" -// (as-contract? ((with-stacking u10)) -// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx -// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none -// )) -// )"#; -// let expected = Value::error(Value::Int(0)).unwrap(); -// assert_eq!(expected, execute(snippet).unwrap().unwrap()); -// } - #[test] fn test_as_contract_with_ft_ok() { let snippet = r#" @@ -879,42 +847,6 @@ fn test_restrict_assets_multiple_allowances_one_low() { assert_eq!(expected, execute(snippet).unwrap().unwrap()); } -// #[test] -// fn test_restrict_assets_with_stacking_delegate_ok() { -// let snippet = r#" -// (restrict-assets? tx-sender ((with-stacking u2000)) -// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx -// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none -// )) -// )"#; -// let expected = Value::okay_true(); -// assert_eq!(expected, execute(snippet).unwrap().unwrap()); -// } - -// #[test] -// fn test_restrict_assets_with_stacking_stack_ok() { -// let snippet = r#" -// (restrict-assets? tx-sender ((with-stacking u100)) -// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 stack-stx -// u1100000000000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none -// )) -// )"#; -// let expected = Value::okay_true(); -// assert_eq!(expected, execute(snippet).unwrap().unwrap()); -// } - -// #[test] -// fn test_restrict_assets_with_stacking_exceeds() { -// let snippet = r#" -// (restrict-assets? tx-sender ((with-stacking u10)) -// (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx -// u1000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none -// )) -// )"#; -// let expected = Value::error(Value::Int(0)).unwrap(); -// assert_eq!(expected, execute(snippet).unwrap().unwrap()); -// } - #[test] fn test_restrict_assets_with_ft_ok() { let snippet = r#" diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 9fe72b8887..dbd5aaa9ed 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -15324,3 +15324,400 @@ fn check_block_time_keyword() { run_loop_thread.join().unwrap(); } + +#[test] +#[ignore] +/// Verify the `with-stacking` allowances work as expected +fn check_with_stacking_allowances() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut signers = TestSigners::default(); + let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None); + let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); + naka_conf.burnchain.chain_id = CHAIN_ID_TESTNET + 1; + let sender_sk = Secp256k1PrivateKey::random(); + let sender_signer_sk = Secp256k1PrivateKey::random(); + let sender_signer_addr = tests::to_addr(&sender_signer_sk); + + // setup sender + recipient for some test stx transfers + // these are necessary for the interim blocks to get mined at all + let sender_addr = tests::to_addr(&sender_sk); + let deploy_fee = 3000; + let call_fee = 400; + naka_conf.add_initial_balance( + PrincipalData::from(sender_addr.clone()).to_string(), + deploy_fee + call_fee * 30, + ); + naka_conf.add_initial_balance( + PrincipalData::from(sender_signer_addr.clone()).to_string(), + 100000, + ); + + // Add epoch 3.3 to the configuration because it is not yet added to the + // default epoch list for integration tests. + naka_conf.burnchain.epochs = Some(EpochList::new(&*NAKAMOTO_INTEGRATION_3_3_EPOCHS)); + + let stacker_sk = setup_stacker(&mut naka_conf); + + test_observer::spawn(); + test_observer::register_any(&mut naka_conf); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(&naka_conf); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None); + btc_regtest_controller.bootstrap_chain(201); + + let mut run_loop = boot_nakamoto::BootRunLoop::new(naka_conf.clone()).unwrap(); + let run_loop_stopper = run_loop.get_termination_switch(); + let Counters { + blocks_processed, .. + } = run_loop.counters(); + let counters = run_loop.counters(); + + let coord_channel = run_loop.coordinator_channels(); + + let run_loop_thread = thread::Builder::new() + .name("run_loop".into()) + .spawn(move || run_loop.start(None, 0)) + .unwrap(); + wait_for_runloop(&blocks_processed); + + boot_to_epoch_3( + &naka_conf, + &blocks_processed, + &[stacker_sk.clone()], + &[sender_signer_sk], + &mut Some(&mut signers), + &mut btc_regtest_controller, + ); + + info!("Bootstrapped to Epoch-3.0 boundary, starting nakamoto miner"); + + info!("Nakamoto miner started..."); + blind_signer(&naka_conf, &signers, &counters); + wait_for_first_naka_block_commit(60, &counters.naka_submitted_commits); + + // mine until epoch 3.3 height + loop { + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel) + .unwrap(); + + // once we actually get a block in epoch 3.3, exit + let blocks = test_observer::get_blocks(); + let last_block = blocks.last().unwrap(); + if last_block + .get("burn_block_height") + .unwrap() + .as_u64() + .unwrap() + >= naka_conf.burnchain.epochs.as_ref().unwrap()[StacksEpochId::Epoch33].start_height + { + break; + } + } + + info!( + "Nakamoto miner has advanced to bitcoin height {}", + get_chain_info_opt(&naka_conf).unwrap().burn_block_height + ); + + let info = get_chain_info_result(&naka_conf).unwrap(); + let last_stacks_block_height = info.stacks_tip_height as u128; + + next_block_and_mine_commit(&mut btc_regtest_controller, 60, &naka_conf, &counters).unwrap(); + + let mut sender_nonce = 0; + let contract_name = "test-contract"; + let contract = format!( + r#" +(define-public (delegate-stx (amount uint) (allowed uint)) + (as-contract? ((with-stacking allowed)) + (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none + )) + ) +) +(define-public (delegate-stx-2-allowances (amount uint) (allowed-1 uint) (allowed-2 uint)) + (as-contract? ((with-stacking allowed-1) (with-stacking allowed-2)) + (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none + )) + ) +) +(define-public (delegate-stx-no-allowance (amount uint)) + (as-contract? () + (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none + )) + ) +) +(define-public (delegate-stx-all (amount uint)) + (as-contract? ((with-all-assets-unsafe)) + (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none + )) + ) +) +(define-public (revoke-delegate-stx) + (as-contract? () + (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 revoke-delegate-stx)) + (ok true) + ) +) +"# + ); + + let contract_tx = make_contract_publish_versioned( + &sender_sk, + sender_nonce, + deploy_fee, + naka_conf.burnchain.chain_id, + contract_name, + &contract, + Some(ClarityVersion::Clarity4), + ); + sender_nonce += 1; + let deploy_txid = submit_tx(&http_origin, &contract_tx); + info!("Submitted deploy txid: {deploy_txid}"); + + let mut stacks_block_height = 0; + wait_for(60, || { + let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce; + let info = get_chain_info_result(&naka_conf).unwrap(); + stacks_block_height = info.stacks_tip_height as u128; + Ok(stacks_block_height > last_stacks_block_height && cur_sender_nonce == sender_nonce) + }) + .expect("Timed out waiting for contracts to publish"); + + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 30, &coord_channel) + .unwrap(); + + test_observer::clear(); + + let mut expected_results = HashMap::new(); + + let delegate_ok_tx = make_contract_call( + &sender_sk, + sender_nonce, + deploy_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx", + &[Value::UInt(1000), Value::UInt(2000)], + ); + sender_nonce += 1; + let delegate_ok_txid = submit_tx(&http_origin, &delegate_ok_tx); + info!("Submitted delegate_ok txid: {delegate_ok_txid}"); + expected_results.insert(delegate_ok_txid, Value::okay_true()); + + let revoke_delegate_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "revoke-delegate-stx", + &[], + ); + sender_nonce += 1; + let revoke_delegate_txid = submit_tx(&http_origin, &revoke_delegate_tx); + info!("Submitted revoke_delegate txid: {revoke_delegate_txid}"); + expected_results.insert(revoke_delegate_txid, Value::okay_true()); + + let delegate_err_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx", + &[Value::UInt(1000), Value::UInt(200)], + ); + sender_nonce += 1; + let delegate_err_txid = submit_tx(&http_origin, &delegate_err_tx); + info!("Submitted delegate_err txid: {delegate_err_txid}"); + expected_results.insert(delegate_err_txid, Value::error(Value::Int(0)).unwrap()); + + let delegate_2_ok_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx-2-allowances", + &[Value::UInt(1000), Value::UInt(2000), Value::UInt(3000)], + ); + sender_nonce += 1; + let delegate_2_ok_txid = submit_tx(&http_origin, &delegate_2_ok_tx); + info!("Submitted delegate_2_ok txid: {delegate_2_ok_txid}"); + expected_results.insert(delegate_2_ok_txid, Value::okay_true()); + + let revoke_delegate_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "revoke-delegate-stx", + &[], + ); + sender_nonce += 1; + let revoke_delegate_txid = submit_tx(&http_origin, &revoke_delegate_tx); + info!("Submitted revoke_delegate txid: {revoke_delegate_txid}"); + expected_results.insert(revoke_delegate_txid, Value::okay_true()); + + let delegate_2_both_err_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx-2-allowances", + &[Value::UInt(1000), Value::UInt(600), Value::UInt(700)], + ); + sender_nonce += 1; + let delegate_2_both_err_txid = submit_tx(&http_origin, &delegate_2_both_err_tx); + info!("Submitted delegate_2_both_err txid: {delegate_2_both_err_txid}"); + expected_results.insert( + delegate_2_both_err_txid, + Value::error(Value::Int(0)).unwrap(), + ); + + let delegate_2_first_err_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx-2-allowances", + &[Value::UInt(1000), Value::UInt(600), Value::UInt(1000)], + ); + sender_nonce += 1; + let delegate_2_first_err_txid = submit_tx(&http_origin, &delegate_2_first_err_tx); + info!("Submitted delegate_2_first_err txid: {delegate_2_first_err_txid}"); + expected_results.insert( + delegate_2_first_err_txid, + Value::error(Value::Int(0)).unwrap(), + ); + + let delegate_2_second_err_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx-2-allowances", + &[Value::UInt(1000), Value::UInt(2000), Value::UInt(100)], + ); + sender_nonce += 1; + let delegate_2_second_err_txid = submit_tx(&http_origin, &delegate_2_second_err_tx); + info!("Submitted delegate_2_second_err txid: {delegate_2_second_err_txid}"); + expected_results.insert( + delegate_2_second_err_txid, + Value::error(Value::Int(1)).unwrap(), + ); + + let delegate_no_allowance_err_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx-no-allowance", + &[Value::UInt(1000)], + ); + sender_nonce += 1; + let delegate_no_allowance_err_txid = submit_tx(&http_origin, &delegate_no_allowance_err_tx); + info!("Submitted delegate_no_allowance_err txid: {delegate_no_allowance_err_txid}"); + expected_results.insert( + delegate_no_allowance_err_txid, + Value::error(Value::Int(-1)).unwrap(), + ); + + let delegate_all_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "delegate-stx-all", + &[Value::UInt(1000)], + ); + sender_nonce += 1; + let delegate_all_txid = submit_tx(&http_origin, &delegate_all_tx); + info!("Submitted delegate_all txid: {delegate_all_txid}"); + expected_results.insert(delegate_all_txid, Value::okay_true()); + + let revoke_delegate_tx = make_contract_call( + &sender_sk, + sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "revoke-delegate-stx", + &[], + ); + sender_nonce += 1; + let revoke_delegate_txid = submit_tx(&http_origin, &revoke_delegate_tx); + info!("Submitted revoke_delegate txid: {revoke_delegate_txid}"); + expected_results.insert(revoke_delegate_txid, Value::okay_true()); + + wait_for(60, || { + let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce; + Ok(cur_sender_nonce == sender_nonce) + }) + .expect("Timed out waiting for contract calls"); + + let blocks = test_observer::get_blocks(); + let mut found = 0; + for block in blocks.iter() { + for tx in block.get("transactions").unwrap().as_array().unwrap() { + let txid = tx + .get("txid") + .unwrap() + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap(); + if let Some(expected) = expected_results.get(txid) { + let raw_result = tx.get("raw_result").unwrap().as_str().unwrap(); + let parsed = Value::try_deserialize_hex_untyped(&raw_result[2..]).unwrap(); + found += 1; + assert_eq!(&parsed, expected); + } else { + // If there are any txids we don't expect, panic, because it probably means + // there is an error in the test itself. + panic!("Found unexpected txid: {txid}"); + } + } + } + + assert_eq!( + found, + expected_results.len(), + "Should have found all expected txs" + ); + + coord_channel + .lock() + .expect("Mutex poisoned") + .stop_chains_coordinator(); + run_loop_stopper.store(false, Ordering::SeqCst); + + run_loop_thread.join().unwrap(); +} From c4fb73d4be74bcbbb2b9b7a7392536d2f2d73af1 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 25 Sep 2025 13:48:46 -0400 Subject: [PATCH 19/29] refactor: return `uint` error from post-conditions --- .../v2_1/natives/post_conditions.rs | 4 +- .../v2_1/tests/post_conditions.rs | 72 +++++------ clarity/src/vm/docs/mod.rs | 20 +-- clarity/src/vm/functions/post_conditions.rs | 39 +++--- clarity/src/vm/tests/post_conditions.rs | 121 +++++++++--------- .../src/tests/nakamoto_integrations.rs | 2 +- 6 files changed, 130 insertions(+), 128 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs index 931cec4feb..4f041f0794 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs @@ -77,7 +77,7 @@ pub fn check_restrict_assets( let ok_type = last_return.ok_or_else(|| CheckErrors::CheckerImplementationFailure)?; Ok(TypeSignature::new_response( ok_type, - TypeSignature::IntType, + TypeSignature::UIntType, )?) } @@ -125,7 +125,7 @@ pub fn check_as_contract( let ok_type = last_return.ok_or_else(|| CheckErrors::CheckerImplementationFailure)?; Ok(TypeSignature::new_response( ok_type, - TypeSignature::IntType, + TypeSignature::UIntType, )?) } diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 663da3c97c..6f6a5ae4ff 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -32,38 +32,38 @@ fn test_restrict_assets(#[case] version: ClarityVersion, #[case] _epoch: StacksE // simple ( "(restrict-assets? tx-sender ((with-stx u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // literal asset owner ( "(restrict-assets? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4 ((with-stx u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // literal asset owner with contract id ( "(restrict-assets? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token ((with-stx u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // variable asset owner ( "(let ((p tx-sender)) (restrict-assets? p ((with-stx u1000)) true))", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // no allowances ( "(restrict-assets? tx-sender () true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // multiple allowances ( "(restrict-assets? tx-sender ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" (list 0x01)) (with-stacking u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // multiple body expressions ( "(restrict-assets? tx-sender ((with-stx u1000)) (+ u1 u2) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), ]; let bad = [ @@ -204,27 +204,27 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch // simple ( "(as-contract? ((with-stx u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // no allowances ( "(as-contract? () true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // multiple allowances ( "(as-contract? ((with-stx u1000) (with-ft .token \"foo\" u5000) (with-nft .token \"foo\" (list 0x01)) (with-stacking u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // multiple body expressions ( "(as-contract? ((with-stx u1000)) (+ u1 u2) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // with-all-assets-unsafe ( "(as-contract? ((with-all-assets-unsafe)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), ]; @@ -349,22 +349,22 @@ fn test_with_stx_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac // basic usage ( "(restrict-assets? tx-sender ((with-stx u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // zero amount ( "(restrict-assets? tx-sender ((with-stx u0)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // large amount ( "(restrict-assets? tx-sender ((with-stx u340282366920938463463374607431768211455)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), // variable amount ( "(let ((amount u1000)) (restrict-assets? tx-sender ((with-stx amount)) true))", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap() + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap() ), ]; @@ -438,37 +438,37 @@ fn test_with_ft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stack // basic usage with shortcut contract principal ( r#"(restrict-assets? tx-sender ((with-ft .token "token-name" u1000)) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // full literal principal ( r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" u1000)) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // variable principal ( r#"(let ((contract .token)) (restrict-assets? tx-sender ((with-ft contract "token-name" u1000)) true))"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // variable token name ( r#"(let ((name "token-name")) (restrict-assets? tx-sender ((with-ft .token name u1000)) true))"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // variable amount ( r#"(let ((amount u1000)) (restrict-assets? tx-sender ((with-ft .token "token-name" amount)) true))"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // "*" token name ( r#"(restrict-assets? tx-sender ((with-ft .token "*" u1000)) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // empty token name ( r#"(restrict-assets? tx-sender ((with-ft .token "" u1000)) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), ]; @@ -576,47 +576,47 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac // basic usage with shortcut contract principal ( r#"(restrict-assets? tx-sender ((with-nft .token "token-name" (list u1000))) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // full literal principal ( r#"(restrict-assets? tx-sender ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.token "token-name" u1000)) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // variable principal ( r#"(let ((contract .token)) (restrict-assets? tx-sender ((with-nft contract "token-name" (list u1000))) true))"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // variable token name ( r#"(let ((name "token-name")) (restrict-assets? tx-sender ((with-nft .token name (list u1000))) true))"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // "*" token name ( r#"(restrict-assets? tx-sender ((with-nft .token "*" (list u1000))) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // empty token name ( r#"(restrict-assets? tx-sender ((with-nft .token "" (list u1000))) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // string asset-id ( r#"(restrict-assets? tx-sender ((with-nft .token "token-name" (list "asset-123"))) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // buffer asset-id ( r#"(restrict-assets? tx-sender ((with-nft .token "token-name" (list 0x0123456789))) true)"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // variable asset-id ( r#"(let ((asset-id (list u123))) (restrict-assets? tx-sender ((with-nft .token "token-name" asset-id)) true))"#, - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), ]; @@ -718,17 +718,17 @@ fn test_with_stacking_allowance(#[case] version: ClarityVersion, #[case] _epoch: // basic usage ( "(restrict-assets? tx-sender ((with-stacking u1000)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // zero amount ( "(restrict-assets? tx-sender ((with-stacking u0)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), // variable amount ( "(let ((amount u1000)) (restrict-assets? tx-sender ((with-stacking amount)) true))", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), ]; @@ -805,7 +805,7 @@ fn test_with_all_assets_unsafe_allowance( // basic usage ( "(as-contract? ((with-all-assets-unsafe)) true)", - TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::IntType).unwrap(), + TypeSignature::new_response(TypeSignature::BoolType, TypeSignature::UIntType).unwrap(), ), ]; diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index bd37d3b654..8093b0fdc9 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -2563,7 +2563,7 @@ characters.", }; const RESTRICT_ASSETS: SpecialAPI = SpecialAPI { - input_type: "principal, ((Allowance)*), AnyType, ... A", + input_type: "principal, ((Allowance){0,128}), AnyType, ... A", snippet: "restrict-assets? ${1:asset-owner} (${2:allowance-1} ${3:allowance-2}) ${4:expr-1}", output_type: "(response A int)", signature: "(restrict-assets? asset-owner ((with-stx|with-ft|with-nft|with-stacking)*) expr-body1 expr-body2 ... expr-body-last)", @@ -2580,21 +2580,21 @@ error-prone). Returns: result of the final body expression and has type `A`. * `(err index)` if an allowance was violated, where `index` is the 0-based index of the first violated allowance in the list of granted allowances, - or -1 if an asset with no allowance caused the violation.", + or `u128` if an asset with no allowance caused the violation.", example: r#" (restrict-assets? tx-sender () (+ u1 u2) ) ;; Returns (ok u3) (restrict-assets? tx-sender () (try! (stx-transfer? u50 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) -) ;; Returns (err -1) +) ;; Returns (err u128) "#, }; const AS_CONTRACT_SAFE: SpecialAPI = SpecialAPI { - input_type: "((Allowance)*), AnyType, ... A", + input_type: "((Allowance){0,128}), AnyType, ... A", snippet: "as-contract? (${1:allowance-1} ${2:allowance-2}) ${3:expr-1}", - output_type: "(response A int)", + output_type: "(response A uint)", signature: "(as-contract? ((with-stx|with-ft|with-nft|with-stacking)*) expr-body1 expr-body2 ... expr-body-last)", description: "Switches the current context's `tx-sender` and `contract-caller` values to the contract's principal and executes the body @@ -2610,7 +2610,7 @@ value from `as-contract?` (nested responses are error-prone). Returns: result of the final body expression and has type `A`. * `(err index)` if an allowance was violated, where `index` is the 0-based index of the first violated allowance in the list of granted allowances, - or -1 if an asset with no allowance caused the violation.", + or `u128` if an asset with no allowance caused the violation.", example: r#" (let ((recipient tx-sender)) (as-contract? ((with-stx u100)) @@ -2621,7 +2621,7 @@ value from `as-contract?` (nested responses are error-prone). Returns: (as-contract? () (try! (stx-transfer? u50 tx-sender recipient)) ) -) ;; Returns (err -1) +) ;; Returns (err u128) "#, }; @@ -2668,7 +2668,7 @@ the contract. When `"*"` is used for the token name, the allowance applies to (restrict-assets? tx-sender ((with-ft current-contract "stackaroo" u50)) (try! (ft-transfer? stackaroo u100 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) -) ;; Returns (err 0) +) ;; Returns (err u0) "#, }; @@ -2696,7 +2696,7 @@ the token name, the allowance applies to **all** NFTs defined in `contract-id`." (restrict-assets? tx-sender ((with-nft current-contract "stackaroo" (list u125))) (try! (nft-transfer? stackaroo u124 tx-sender 'SPAXYA5XS51713FDTQ8H94EJ4V579CXMTRNBZKSF)) -) ;; Returns (err 0) +) ;; Returns (err u0) "#, }; @@ -2717,7 +2717,7 @@ locked amount is limited by the amount of uSTX specified.", (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx u1100000000000 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none )) -) ;; Returns (err 0) +) ;; Returns (err u0) (restrict-assets? tx-sender ((with-stacking u1000000000000)) (try! (contract-call? 'SP000000000000000000002Q6VF78.pox-4 delegate-stx diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index ad320bb021..f19bfad864 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -17,6 +17,7 @@ use std::collections::{HashMap, HashSet}; use clarity_types::types::{AssetIdentifier, PrincipalData}; +use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::contexts::AssetMap; use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::{constants as cost_constants, runtime_cost, CostTracker}; @@ -205,7 +206,7 @@ pub fn special_restrict_assets( if let Some(violation_index) = check_allowances(&asset_owner, &allowances, asset_maps)? { env.global_context.roll_back()?; // TODO: Emit an event about the allowance violation - return Value::error(Value::Int(violation_index)); + return Value::error(Value::UInt(violation_index)); } env.global_context.commit()?; @@ -279,7 +280,7 @@ pub fn special_as_contract( Ok(Some(violation_index)) => { nested_env.global_context.roll_back()?; // TODO: Emit an event about the allowance violation - return Value::error(Value::Int(violation_index)); + return Value::error(Value::UInt(violation_index)); } Err(e) => { nested_env.global_context.roll_back()?; @@ -299,13 +300,13 @@ pub fn special_as_contract( /// Check the allowances against the asset map. If any assets moved without a /// corresponding allowance return a `Some` with an index of the violated -/// allowance, or -1 if an asset with no allowance caused the violation. If all +/// allowance, or 128 if an asset with no allowance caused the violation. If all /// allowances are satisfied, return `Ok(None)`. fn check_allowances( owner: &PrincipalData, allowances: &[Allowance], assets: &AssetMap, -) -> InterpreterResult> { +) -> InterpreterResult> { // Elements are (index in allowances, amount) let mut stx_allowances: Vec<(usize, u128)> = Vec::new(); // Map assets to a vector of (index in allowances, amount) @@ -349,14 +350,14 @@ fn check_allowances( if let Some(stx_moved) = assets.get_stx(owner) { // If there are no allowances for STX, any movement is a violation if stx_allowances.is_empty() { - return Ok(Some(-1)); + return Ok(Some(MAX_ALLOWANCES as u128)); } // Check against the STX allowances for (index, allowance) in &stx_allowances { if stx_moved > *allowance { - return Ok(Some(i128::try_from(*index).map_err(|_| { - InterpreterError::Expect("failed to convert index to i128".into()) + return Ok(Some(u128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to u128".into()) })?)); } } @@ -366,14 +367,14 @@ fn check_allowances( if let Some(stx_burned) = assets.get_stx_burned(owner) { // If there are no allowances for STX, any burn is a violation if stx_allowances.is_empty() { - return Ok(Some(-1)); + return Ok(Some(MAX_ALLOWANCES as u128)); } // Check against the STX allowances for (index, allowance) in &stx_allowances { if stx_burned > *allowance { - return Ok(Some(i128::try_from(*index).map_err(|_| { - InterpreterError::Expect("failed to convert index to i128".into()) + return Ok(Some(u128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to u128".into()) })?)); } } @@ -398,7 +399,7 @@ fn check_allowances( if merged.is_empty() { // No allowance for this asset, any movement is a violation - return Ok(Some(-1)); + return Ok(Some(MAX_ALLOWANCES as u128)); } // Sort by allowance index so we check allowances in order @@ -406,8 +407,8 @@ fn check_allowances( for (index, allowance) in merged { if *amount_moved > allowance { - return Ok(Some(i128::try_from(index).map_err(|_| { - InterpreterError::Expect("failed to convert index to i128".into()) + return Ok(Some(u128::try_from(index).map_err(|_| { + InterpreterError::Expect("failed to convert index to u128".into()) })?)); } } @@ -431,7 +432,7 @@ fn check_allowances( if merged.is_empty() { // No allowance for this asset, any movement is a violation - return Ok(Some(-1)); + return Ok(Some(MAX_ALLOWANCES as u128)); } // Sort by allowance index so we check allowances in order @@ -441,8 +442,8 @@ fn check_allowances( // Check against the NFT allowances for id_moved in ids_moved { if !allowance_map.contains(&id_moved.serialize_to_hex()?) { - return Ok(Some(i128::try_from(index).map_err(|_| { - InterpreterError::Expect("failed to convert index to i128".into()) + return Ok(Some(u128::try_from(index).map_err(|_| { + InterpreterError::Expect("failed to convert index to u128".into()) })?)); } } @@ -454,14 +455,14 @@ fn check_allowances( if let Some(stx_stacked) = assets.get_stacking(owner) { // If there are no allowances for stacking, any stacking is a violation if stacking_allowances.is_empty() { - return Ok(Some(-1)); + return Ok(Some(MAX_ALLOWANCES as u128)); } // Check against the stacking allowances for (index, allowance) in &stacking_allowances { if stx_stacked > *allowance { - return Ok(Some(i128::try_from(*index).map_err(|_| { - InterpreterError::Expect("failed to convert index to i128".into()) + return Ok(Some(u128::try_from(*index).map_err(|_| { + InterpreterError::Expect("failed to convert index to u128".into()) })?)); } } diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 71c7e28d60..139d80eb1f 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -22,6 +22,7 @@ use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardP use clarity_types::Value; use stacks_common::types::StacksEpochId; +use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::database::STXBalance; use crate::vm::{execute_with_parameters_and_call_in_global_context, ClarityVersion}; @@ -77,7 +78,7 @@ fn test_as_contract_with_stx_exceeds() { (try! (stx-transfer? u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -89,7 +90,7 @@ fn test_as_contract_with_stx_no_allowance() { (try! (stx-transfer? u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -113,7 +114,7 @@ fn test_as_contract_stx_other_allowances() { (try! (stx-transfer? u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -133,7 +134,7 @@ fn test_as_contract_with_stx_burn_exceeds() { (as-contract? ((with-stx u10)) (try! (stx-burn? u50 tx-sender)) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -143,7 +144,7 @@ fn test_as_contract_with_stx_burn_no_allowance() { (as-contract? () (try! (stx-burn? u50 tx-sender)) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -163,7 +164,7 @@ fn test_as_contract_stx_burn_other_allowances() { (as-contract? ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" (list 123))) (try! (stx-burn? u50 tx-sender)) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -175,7 +176,7 @@ fn test_as_contract_multiple_allowances_both_low() { (try! (stx-transfer? u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -199,7 +200,7 @@ fn test_as_contract_multiple_allowances_one_low() { (try! (stx-transfer? u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(1)).unwrap(); + let expected = Value::error(Value::UInt(1)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -227,7 +228,7 @@ fn test_as_contract_with_ft_exceeds() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -241,7 +242,7 @@ fn test_as_contract_with_ft_no_allowance() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -275,7 +276,7 @@ fn test_as_contract_with_ft_other_allowances() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -289,7 +290,7 @@ fn test_as_contract_with_ft_multiple_allowances_both_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -317,7 +318,7 @@ fn test_as_contract_with_ft_multiple_allowances_one_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(1)).unwrap(); + let expected = Value::error(Value::UInt(1)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -345,7 +346,7 @@ fn test_as_contract_with_ft_wildcard_exceeds() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -365,7 +366,7 @@ fn test_as_contract_with_ft_wildcard_other_allowances() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -379,7 +380,7 @@ fn test_as_contract_with_ft_wildcard_multiple_allowances_both_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -407,7 +408,7 @@ fn test_as_contract_with_ft_wildcard_multiple_allowances_one_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(1)).unwrap(); + let expected = Value::error(Value::UInt(1)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -421,7 +422,7 @@ fn test_as_contract_with_ft_wildcard_multiple_allowances_low1() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -435,7 +436,7 @@ fn test_as_contract_with_ft_wildcard_multiple_allowances_low2() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -464,7 +465,7 @@ fn test_as_contract_with_nft_not_allowed() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -478,7 +479,7 @@ fn test_as_contract_with_nft_no_allowance() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -512,7 +513,7 @@ fn test_as_contract_with_nft_other_allowances() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -527,7 +528,7 @@ fn test_as_contract_with_nft_multiple_allowances_both_different() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -572,7 +573,7 @@ fn test_as_contract_with_nft_empty_id_list() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -601,7 +602,7 @@ fn test_as_contract_with_nft_wildcard_not_allowed() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -621,7 +622,7 @@ fn test_as_contract_with_nft_wildcard_other_allowances() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -636,7 +637,7 @@ fn test_as_contract_with_nft_wildcard_multiple_allowances_both_different() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -681,7 +682,7 @@ fn test_as_contract_with_nft_wildcard_empty_id_list() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -696,7 +697,7 @@ fn test_as_contract_with_nft_wildcard_multiple_allowances_order1() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -711,7 +712,7 @@ fn test_as_contract_with_nft_wildcard_multiple_allowances_order2() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -733,7 +734,7 @@ fn test_restrict_assets_with_stx_exceeds() { (restrict-assets? tx-sender ((with-stx u10)) (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -743,7 +744,7 @@ fn test_restrict_assets_with_stx_no_allowance() { (restrict-assets? tx-sender () (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -763,7 +764,7 @@ fn test_restrict_assets_stx_other_allowances() { (restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" (list 123))) (try! (stx-transfer? u50 tx-sender 'SP000000000000000000002Q6VF78)) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -783,7 +784,7 @@ fn test_restrict_assets_with_stx_burn_exceeds() { (restrict-assets? tx-sender ((with-stx u10)) (try! (stx-burn? u50 tx-sender)) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -793,7 +794,7 @@ fn test_restrict_assets_with_stx_burn_no_allowance() { (restrict-assets? tx-sender () (try! (stx-burn? u50 tx-sender)) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -813,7 +814,7 @@ fn test_restrict_assets_stx_burn_other_allowances() { (restrict-assets? tx-sender ((with-ft .token "stackaroo" u100) (with-nft .token "stackaroo" (list 123))) (try! (stx-burn? u50 tx-sender)) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -823,7 +824,7 @@ fn test_restrict_assets_multiple_allowances_both_low() { (restrict-assets? tx-sender ((with-stx u30) (with-stx u20)) (try! (stx-transfer? u40 tx-sender 'SP000000000000000000002Q6VF78)) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -843,7 +844,7 @@ fn test_restrict_assets_multiple_allowances_one_low() { (restrict-assets? tx-sender ((with-stx u100) (with-stx u20)) (try! (stx-transfer? u40 tx-sender 'SP000000000000000000002Q6VF78)) )"#; - let expected = Value::error(Value::Int(1)).unwrap(); + let expected = Value::error(Value::UInt(1)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -871,7 +872,7 @@ fn test_restrict_assets_with_ft_exceeds() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -885,7 +886,7 @@ fn test_restrict_assets_with_ft_no_allowance() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -919,7 +920,7 @@ fn test_restrict_assets_with_ft_other_allowances() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -933,7 +934,7 @@ fn test_restrict_assets_with_ft_multiple_allowances_both_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -961,7 +962,7 @@ fn test_restrict_assets_with_ft_multiple_allowances_one_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(1)).unwrap(); + let expected = Value::error(Value::UInt(1)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -989,7 +990,7 @@ fn test_restrict_assets_with_ft_wildcard_exceeds() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1009,7 +1010,7 @@ fn test_restrict_assets_with_ft_wildcard_other_allowances() { (try! (ft-transfer? stackaroo u50 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1023,7 +1024,7 @@ fn test_restrict_assets_with_ft_wildcard_multiple_allowances_both_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1051,7 +1052,7 @@ fn test_restrict_assets_with_ft_wildcard_multiple_allowances_one_low() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(1)).unwrap(); + let expected = Value::error(Value::UInt(1)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1065,7 +1066,7 @@ fn test_restrict_assets_with_ft_wildcard_multiple_allowances_low1() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1079,7 +1080,7 @@ fn test_restrict_assets_with_ft_wildcard_multiple_allowances_low2() { (try! (ft-transfer? stackaroo u40 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1108,7 +1109,7 @@ fn test_restrict_assets_with_nft_not_allowed() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1122,7 +1123,7 @@ fn test_restrict_assets_with_nft_no_allowance() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1156,7 +1157,7 @@ fn test_restrict_assets_with_nft_other_allowances() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1171,7 +1172,7 @@ fn test_restrict_assets_with_nft_multiple_allowances_both_different() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1216,7 +1217,7 @@ fn test_restrict_assets_with_nft_empty_id_list() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1245,7 +1246,7 @@ fn test_restrict_assets_with_nft_wildcard_not_allowed() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1265,7 +1266,7 @@ fn test_restrict_assets_with_nft_wildcard_other_allowances() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(-1)).unwrap(); + let expected = Value::error(Value::UInt(MAX_ALLOWANCES as u128)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1280,7 +1281,7 @@ fn test_restrict_assets_with_nft_wildcard_multiple_allowances_both_different() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1325,7 +1326,7 @@ fn test_restrict_assets_with_nft_wildcard_empty_id_list() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1340,7 +1341,7 @@ fn test_restrict_assets_with_nft_wildcard_multiple_allowances_order1() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } @@ -1355,6 +1356,6 @@ fn test_restrict_assets_with_nft_wildcard_multiple_allowances_order2() { (try! (nft-transfer? stackaroo u123 tx-sender recipient)) ) )"#; - let expected = Value::error(Value::Int(0)).unwrap(); + let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index dbd5aaa9ed..62cb77c87a 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -15465,7 +15465,7 @@ fn check_with_stacking_allowances() { (define-public (revoke-delegate-stx) (as-contract? () (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 revoke-delegate-stx)) - (ok true) + true ) ) "# From 22449dd6d772d48405f5469ef74a1b02ccfa9650 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 25 Sep 2025 15:23:44 -0400 Subject: [PATCH 20/29] fix: handle errors in `restrict-assets?` and `as-contract?` body --- .../v2_1/natives/post_conditions.rs | 4 +- .../v2_1/tests/post_conditions.rs | 156 ++++++++++++------ clarity/src/vm/functions/post_conditions.rs | 72 +++++--- clarity/src/vm/tests/post_conditions.rs | 32 +++- .../src/tests/nakamoto_integrations.rs | 33 ++-- 5 files changed, 208 insertions(+), 89 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs index 4f041f0794..8f0a0cbbac 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs @@ -25,7 +25,9 @@ use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::runtime_cost; use crate::vm::functions::NativeFunctions; -/// Maximum number of allowances allowed in a `restrict-assets?` or `as-contract?` expression. +/// Maximum number of allowances allowed in a `restrict-assets?` or +/// `as-contract?` expression. This value is also used to indicate an allowance +/// violation for an asset with no allowances. pub(crate) const MAX_ALLOWANCES: usize = 128; /// Maximum number of asset identifiers allowed in a `with-nft` allowance expression. pub(crate) const MAX_NFT_IDENTIFIERS: u32 = 128; diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 6f6a5ae4ff..21885f1604 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -155,43 +155,65 @@ fn test_restrict_assets(#[case] version: ClarityVersion, #[case] _epoch: StacksE ), CheckErrors::TooManyAllowances(MAX_ALLOWANCES, 130), ), + // different error types thrown from body expressions + ( + "(define-public (test) + (restrict-assets? tx-sender () + (try! (if true (ok true) (err u1))) + (try! (if true (ok 1) (err 2))) + u0 + ) + )", + CheckErrors::ReturnTypesMustMatch( + TypeSignature::new_response( + TypeSignature::NoType.into(), + TypeSignature::UIntType.into(), + ) + .unwrap() + .into(), + TypeSignature::new_response( + TypeSignature::NoType.into(), + TypeSignature::IntType.into(), + ) + .unwrap() + .into(), + ), + ), ]; - for (good_code, expected_type) in &good { - info!("test good code: '{}'", good_code); + for (code, expected_type) in &good { if version < ClarityVersion::Clarity4 { // restrict-assets? is only available in Clarity 4+ assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(good_code, version) - .unwrap_err() - .err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( expected_type, - &type_check_helper_version(good_code, version).unwrap() + &type_check_helper_version(code, version).unwrap(), + "{code}", ); } } - for (bad_code, expected_err) in &bad { - info!("test bad code: '{}'", bad_code); + for (code, expected_err) in &bad { if version < ClarityVersion::Clarity4 { // restrict-assets? is only available in Clarity 4+ assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(bad_code, version) - .unwrap_err() - .err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( expected_err, - type_check_helper_version(bad_code, version) + type_check_helper_version(code, version) .unwrap_err() .err - .as_ref() + .as_ref(), + "{code}", ); } } @@ -304,31 +326,56 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch ), CheckErrors::TooManyAllowances(MAX_ALLOWANCES, 130), ), + // different error types thrown from body expressions + ( + "(define-public (test) + (as-contract? () + (try! (if true (ok true) (err u1))) + (try! (if true (ok 1) (err 2))) + u0 + ) + )", + CheckErrors::ReturnTypesMustMatch( + TypeSignature::new_response( + TypeSignature::NoType.into(), + TypeSignature::UIntType.into(), + ) + .unwrap() + .into(), + TypeSignature::new_response( + TypeSignature::NoType.into(), + TypeSignature::IntType.into(), + ) + .unwrap() + .into(), + ), + ), ]; for (code, expected_type) in &good { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { // as-contract? is only available in Clarity 4+ assert_eq!( CheckErrors::UnknownFunction("as-contract?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}" ); } else { assert_eq!( expected_type, - &type_check_helper_version(code, version).unwrap() + &type_check_helper_version(code, version).unwrap(), + "{code}" ); } } for (code, expected_err) in &bad { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { // as-contract? is only available in Clarity 4+ assert_eq!( CheckErrors::UnknownFunction("as-contract?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}" ); } else { assert_eq!( @@ -336,7 +383,8 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch type_check_helper_version(code, version) .unwrap_err() .err - .as_ref() + .as_ref(), + "{code}" ); } } @@ -398,26 +446,27 @@ fn test_with_stx_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ]; for (code, expected_type) in &good { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}" ); } else { assert_eq!( expected_type, - &type_check_helper_version(code, version).unwrap() + &type_check_helper_version(code, version).unwrap(), + "{code}" ); } } for (code, expected_err) in &bad { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}" ); } else { assert_eq!( @@ -425,7 +474,8 @@ fn test_with_stx_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac type_check_helper_version(code, version) .unwrap_err() .err - .as_ref() + .as_ref(), + "{code}" ); } } @@ -536,26 +586,27 @@ fn test_with_ft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stack ]; for (code, expected_type) in &good { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( expected_type, - &type_check_helper_version(code, version).unwrap() + &type_check_helper_version(code, version).unwrap(), + "{code}", ); } } for (code, expected_err) in &bad { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( @@ -563,7 +614,8 @@ fn test_with_ft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stack type_check_helper_version(code, version) .unwrap_err() .err - .as_ref() + .as_ref(), + "{code}", ); } } @@ -678,26 +730,27 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac ]; for (code, expected_type) in &good { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( expected_type, - &type_check_helper_version(code, version).unwrap() + &type_check_helper_version(code, version).unwrap(), + "{code}", ); } } for (code, expected_err) in &bad { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( @@ -705,7 +758,8 @@ fn test_with_nft_allowance(#[case] version: ClarityVersion, #[case] _epoch: Stac type_check_helper_version(code, version) .unwrap_err() .err - .as_ref() + .as_ref(), + "{code}", ); } } @@ -762,26 +816,27 @@ fn test_with_stacking_allowance(#[case] version: ClarityVersion, #[case] _epoch: ]; for (code, expected_type) in &good { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( expected_type, - &type_check_helper_version(code, version).unwrap() + &type_check_helper_version(code, version).unwrap(), + "{code}", ); } } for (code, expected_err) in &bad { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( @@ -789,7 +844,8 @@ fn test_with_stacking_allowance(#[case] version: ClarityVersion, #[case] _epoch: type_check_helper_version(code, version) .unwrap_err() .err - .as_ref() + .as_ref(), + "{code}", ); } } @@ -823,26 +879,27 @@ fn test_with_all_assets_unsafe_allowance( ]; for (code, expected_type) in &good { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("as-contract?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( expected_type, - &type_check_helper_version(code, version).unwrap() + &type_check_helper_version(code, version).unwrap(), + "{code}", ); } } for (code, expected_err) in &bad { - info!("test code: '{}'", code); if version < ClarityVersion::Clarity4 { assert_eq!( CheckErrors::UnknownFunction("restrict-assets?".to_string()), - *type_check_helper_version(code, version).unwrap_err().err + *type_check_helper_version(code, version).unwrap_err().err, + "{code}", ); } else { assert_eq!( @@ -850,7 +907,8 @@ fn test_with_all_assets_unsafe_allowance( type_check_helper_version(code, version) .unwrap_err() .err - .as_ref() + .as_ref(), + "{code}", ); } } diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index f19bfad864..12dd2f3294 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -191,12 +191,15 @@ pub fn special_restrict_assets( // post-conditions are violated env.global_context.begin(); - // evaluate the body expressions - let mut last_result = None; - for expr in body_exprs { - let result = eval(expr, env, context)?; - last_result.replace(result); - } + // Evaluate the body expressions inside a closure so `?` only exits the closure + let eval_result: InterpreterResult> = (|| -> InterpreterResult> { + let mut last_result = None; + for expr in body_exprs { + let result = eval(expr, env, context)?; + last_result.replace(result); + } + Ok(last_result) + })(); let asset_maps = env.global_context.get_readonly_asset_map()?; @@ -211,11 +214,21 @@ pub fn special_restrict_assets( env.global_context.commit()?; - // Wrap the result in an `ok` value - Value::okay( - // last_result should always be Some(...), because of the arg len check above. - last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()))?, - ) + // No allowance violation, so handle the result of the body evaluation + match eval_result { + Ok(Some(last)) => { + // body completed successfully — commit and return ok(last) + Value::okay(last) + } + Ok(None) => { + // Body had no expressions (shouldn't happen due to argument checks) + Err(InterpreterError::Expect("Failed to get body result".into()).into()) + } + Err(e) => { + // Runtime error inside body, pass it up + Err(e) + } + } } /// Handles the function `as-contract?` @@ -261,14 +274,15 @@ pub fn special_as_contract( // post-conditions are violated nested_env.global_context.begin(); - // evaluate the body expressions - let mut last_result = None; - for expr in body_exprs { - // TODO: handle runtime errors inside the body expressions correctly - // (ensure that the context is always popped and asset maps are checked against allowances) - let result = eval(expr, &mut nested_env, context)?; - last_result.replace(result); - } + // Evaluate the body expressions inside a closure so `?` only exits the closure + let eval_result: InterpreterResult> = (|| -> InterpreterResult> { + let mut last_result = None; + for expr in body_exprs { + let result = eval(expr, &mut nested_env, context)?; + last_result.replace(result); + } + Ok(last_result) + })(); let asset_maps = nested_env.global_context.get_readonly_asset_map()?; @@ -290,11 +304,21 @@ pub fn special_as_contract( nested_env.global_context.commit()?; - // Wrap the result in an `ok` value - Value::okay( - // last_result should always be Some(...), because of the arg len check above. - last_result.ok_or_else(|| InterpreterError::Expect("Failed to get let result".into()))?, - ) + // No allowance violation, so handle the result of the body evaluation + match eval_result { + Ok(Some(last)) => { + // body completed successfully — commit and return ok(last) + Value::okay(last) + } + Ok(None) => { + // Body had no expressions (shouldn't happen due to argument checks) + Err(InterpreterError::Expect("Failed to get body result".into()).into()) + } + Err(e) => { + // Runtime error inside body, pass it up + Err(e) + } + } }) } diff --git a/clarity/src/vm/tests/post_conditions.rs b/clarity/src/vm/tests/post_conditions.rs index 139d80eb1f..7a966b56d7 100644 --- a/clarity/src/vm/tests/post_conditions.rs +++ b/clarity/src/vm/tests/post_conditions.rs @@ -17,7 +17,7 @@ //! `restrict-assets?` expressions. The `with-stacking` allowances are tested //! in integration tests, since they require changes made outside of the VM. -use clarity_types::errors::InterpreterResult; +use clarity_types::errors::{Error as ClarityError, InterpreterResult, ShortReturnType}; use clarity_types::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; use clarity_types::Value; use stacks_common::types::StacksEpochId; @@ -716,6 +716,21 @@ fn test_as_contract_with_nft_wildcard_multiple_allowances_order2() { assert_eq!(expected, execute(snippet).unwrap().unwrap()); } +#[test] +fn test_as_contract_with_error_in_body() { + let snippet = r#" +(let ((recipient tx-sender)) + (as-contract? () + (try! (if false (ok true) (err u200))) + true + ) +)"#; + let expected_err = Value::error(Value::UInt(200)).unwrap(); + let short_return = + ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected_err.into())); + assert_eq!(short_return, execute(snippet).unwrap_err()); +} + // ---------- Tests for restrict-assets? ---------- #[test] @@ -1359,3 +1374,18 @@ fn test_restrict_assets_with_nft_wildcard_multiple_allowances_order2() { let expected = Value::error(Value::UInt(0)).unwrap(); assert_eq!(expected, execute(snippet).unwrap().unwrap()); } + +#[test] +fn test_restrict_assets_with_error_in_body() { + let snippet = r#" +(let ((recipient 'SP000000000000000000002Q6VF78)) + (restrict-assets? tx-sender () + (try! (if false (ok true) (err u200))) + true + ) +)"#; + let expected_err = Value::error(Value::UInt(200)).unwrap(); + let short_return = + ClarityError::ShortReturn(ShortReturnType::ExpectedValue(expected_err.into())); + assert_eq!(short_return, execute(snippet).unwrap_err()); +} diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 62cb77c87a..d51fc869f6 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -15436,35 +15436,35 @@ fn check_with_stacking_allowances() { r#" (define-public (delegate-stx (amount uint) (allowed uint)) (as-contract? ((with-stacking allowed)) - (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + (unwrap! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none - )) + ) (err u1)) ) ) (define-public (delegate-stx-2-allowances (amount uint) (allowed-1 uint) (allowed-2 uint)) (as-contract? ((with-stacking allowed-1) (with-stacking allowed-2)) - (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + (unwrap! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none - )) + ) (err u1)) ) ) (define-public (delegate-stx-no-allowance (amount uint)) (as-contract? () - (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + (unwrap! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none - )) + ) (err u1)) ) ) (define-public (delegate-stx-all (amount uint)) (as-contract? ((with-all-assets-unsafe)) - (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx + (unwrap! (contract-call? 'ST000000000000000000002AMW42H.pox-4 delegate-stx amount 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM none none - )) + ) (err u1)) ) ) (define-public (revoke-delegate-stx) (as-contract? () - (try! (contract-call? 'ST000000000000000000002AMW42H.pox-4 revoke-delegate-stx)) + (unwrap! (contract-call? 'ST000000000000000000002AMW42H.pox-4 revoke-delegate-stx) (err u1)) true ) ) @@ -15543,7 +15543,7 @@ fn check_with_stacking_allowances() { sender_nonce += 1; let delegate_err_txid = submit_tx(&http_origin, &delegate_err_tx); info!("Submitted delegate_err txid: {delegate_err_txid}"); - expected_results.insert(delegate_err_txid, Value::error(Value::Int(0)).unwrap()); + expected_results.insert(delegate_err_txid, Value::error(Value::UInt(0)).unwrap()); let delegate_2_ok_tx = make_contract_call( &sender_sk, @@ -15590,7 +15590,7 @@ fn check_with_stacking_allowances() { info!("Submitted delegate_2_both_err txid: {delegate_2_both_err_txid}"); expected_results.insert( delegate_2_both_err_txid, - Value::error(Value::Int(0)).unwrap(), + Value::error(Value::UInt(0)).unwrap(), ); let delegate_2_first_err_tx = make_contract_call( @@ -15608,7 +15608,7 @@ fn check_with_stacking_allowances() { info!("Submitted delegate_2_first_err txid: {delegate_2_first_err_txid}"); expected_results.insert( delegate_2_first_err_txid, - Value::error(Value::Int(0)).unwrap(), + Value::error(Value::UInt(0)).unwrap(), ); let delegate_2_second_err_tx = make_contract_call( @@ -15626,7 +15626,7 @@ fn check_with_stacking_allowances() { info!("Submitted delegate_2_second_err txid: {delegate_2_second_err_txid}"); expected_results.insert( delegate_2_second_err_txid, - Value::error(Value::Int(1)).unwrap(), + Value::error(Value::UInt(1)).unwrap(), ); let delegate_no_allowance_err_tx = make_contract_call( @@ -15644,7 +15644,7 @@ fn check_with_stacking_allowances() { info!("Submitted delegate_no_allowance_err txid: {delegate_no_allowance_err_txid}"); expected_results.insert( delegate_no_allowance_err_txid, - Value::error(Value::Int(-1)).unwrap(), + Value::error(Value::UInt(128)).unwrap(), ); let delegate_all_tx = make_contract_call( @@ -15721,3 +15721,8 @@ fn check_with_stacking_allowances() { run_loop_thread.join().unwrap(); } + +// TODO: +// - Test stack-stx +// - Test roll backs +// - Test successful asset movement \ No newline at end of file From 00601292919541f1bb1eba02125fadd609487a51 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 25 Sep 2025 15:31:51 -0400 Subject: [PATCH 21/29] chore: fix clippy and formatting --- .../v2_1/tests/post_conditions.rs | 36 +++++++------------ .../src/tests/nakamoto_integrations.rs | 2 +- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs index 21885f1604..1ba8c086cd 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/tests/post_conditions.rs @@ -165,18 +165,12 @@ fn test_restrict_assets(#[case] version: ClarityVersion, #[case] _epoch: StacksE ) )", CheckErrors::ReturnTypesMustMatch( - TypeSignature::new_response( - TypeSignature::NoType.into(), - TypeSignature::UIntType.into(), - ) - .unwrap() - .into(), - TypeSignature::new_response( - TypeSignature::NoType.into(), - TypeSignature::IntType.into(), - ) - .unwrap() - .into(), + TypeSignature::new_response(TypeSignature::NoType, TypeSignature::UIntType) + .unwrap() + .into(), + TypeSignature::new_response(TypeSignature::NoType, TypeSignature::IntType) + .unwrap() + .into(), ), ), ]; @@ -336,18 +330,12 @@ fn test_as_contract(#[case] version: ClarityVersion, #[case] _epoch: StacksEpoch ) )", CheckErrors::ReturnTypesMustMatch( - TypeSignature::new_response( - TypeSignature::NoType.into(), - TypeSignature::UIntType.into(), - ) - .unwrap() - .into(), - TypeSignature::new_response( - TypeSignature::NoType.into(), - TypeSignature::IntType.into(), - ) - .unwrap() - .into(), + TypeSignature::new_response(TypeSignature::NoType, TypeSignature::UIntType) + .unwrap() + .into(), + TypeSignature::new_response(TypeSignature::NoType, TypeSignature::IntType) + .unwrap() + .into(), ), ), ]; diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index d51fc869f6..6f98a85c59 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -15725,4 +15725,4 @@ fn check_with_stacking_allowances() { // TODO: // - Test stack-stx // - Test roll backs -// - Test successful asset movement \ No newline at end of file +// - Test successful asset movement From 489793b77f9d6d6c244b41139d5152b6b48278d6 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Sat, 27 Sep 2025 09:57:37 -0400 Subject: [PATCH 22/29] test: begin adding rollback integration tests --- .../src/tests/nakamoto_integrations.rs | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 6f98a85c59..584abbe708 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -15722,6 +15722,416 @@ fn check_with_stacking_allowances() { run_loop_thread.join().unwrap(); } +#[test] +#[ignore] +/// Verify the error handling and rollback works as expected in +/// `restrict-assets?` expressions +fn check_restrict_assets_rollback() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut signers = TestSigners::default(); + let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None); + let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); + naka_conf.burnchain.chain_id = CHAIN_ID_TESTNET + 1; + let sender_sk = Secp256k1PrivateKey::random(); + let recipient_sk = Secp256k1PrivateKey::random(); + let recipient = tests::to_addr(&recipient_sk); + let sender_signer_sk = Secp256k1PrivateKey::random(); + let sender_signer_addr = tests::to_addr(&sender_signer_sk); + + // setup sender + recipient for some test stx transfers + // these are necessary for the interim blocks to get mined at all + let sender_addr = tests::to_addr(&sender_sk); + let deploy_fee = 3000; + let call_fee = 400; + let max_transfer_amt = 1000; + naka_conf.add_initial_balance( + PrincipalData::from(sender_addr.clone()).to_string(), + deploy_fee + (max_transfer_amt + call_fee) * 30, + ); + naka_conf.add_initial_balance( + PrincipalData::from(sender_signer_addr.clone()).to_string(), + 100000, + ); + + // Add epoch 3.3 to the configuration because it is not yet added to the + // default epoch list for integration tests. + naka_conf.burnchain.epochs = Some(EpochList::new(&*NAKAMOTO_INTEGRATION_3_3_EPOCHS)); + + let stacker_sk = setup_stacker(&mut naka_conf); + + test_observer::spawn(); + test_observer::register_any(&mut naka_conf); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(&naka_conf); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None); + btc_regtest_controller.bootstrap_chain(201); + + let mut run_loop = boot_nakamoto::BootRunLoop::new(naka_conf.clone()).unwrap(); + let run_loop_stopper = run_loop.get_termination_switch(); + let Counters { + blocks_processed, .. + } = run_loop.counters(); + let counters = run_loop.counters(); + + let coord_channel = run_loop.coordinator_channels(); + + let run_loop_thread = thread::Builder::new() + .name("run_loop".into()) + .spawn(move || run_loop.start(None, 0)) + .unwrap(); + wait_for_runloop(&blocks_processed); + + boot_to_epoch_3( + &naka_conf, + &blocks_processed, + &[stacker_sk.clone()], + &[sender_signer_sk], + &mut Some(&mut signers), + &mut btc_regtest_controller, + ); + + info!("Bootstrapped to Epoch-3.0 boundary, starting nakamoto miner"); + + info!("Nakamoto miner started..."); + blind_signer(&naka_conf, &signers, &counters); + wait_for_first_naka_block_commit(60, &counters.naka_submitted_commits); + + // mine until epoch 3.3 height + loop { + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel) + .unwrap(); + + // once we actually get a block in epoch 3.3, exit + let blocks = test_observer::get_blocks(); + let last_block = blocks.last().unwrap(); + if last_block + .get("burn_block_height") + .unwrap() + .as_u64() + .unwrap() + >= naka_conf.burnchain.epochs.as_ref().unwrap()[StacksEpochId::Epoch33].start_height + { + break; + } + } + + info!( + "Nakamoto miner has advanced to bitcoin height {}", + get_chain_info_opt(&naka_conf).unwrap().burn_block_height + ); + + let info = get_chain_info_result(&naka_conf).unwrap(); + let last_stacks_block_height = info.stacks_tip_height as u128; + + next_block_and_mine_commit(&mut btc_regtest_controller, 60, &naka_conf, &counters).unwrap(); + + let mut sender_nonce = 0; + let contract_name = "test-contract"; + let contract = format!( + r#" +(define-public (single-transfer (recipient principal) (amount uint) (allowed uint)) + (restrict-assets? tx-sender ((with-stx allowed)) + (unwrap! (stx-transfer? amount tx-sender recipient) (err u200)) + ) +) +(define-public (two-transfers (recipient principal) (amount-1 uint) (amount-2 uint) (allowed uint)) + (restrict-assets? tx-sender ((with-stx allowed)) + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) e (err (+ u200 e)))) + (try! (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) e (err (+ u300 e)))) + ) +) +(define-public (transfer-then-err (recipient principal) (amount uint) (allowed uint)) + (restrict-assets? tx-sender ((with-stx allowed)) + (try! (match (stx-transfer? amount tx-sender recipient) + v (ok v) e (err (+ u200 e)))) + (try! (if false (ok true) (err u200))) + ) +) +(define-public (err-then-transfer (recipient principal) (amount uint) (allowed uint)) + (restrict-assets? tx-sender ((with-stx allowed)) + (try! (if false (ok true) (err u200))) + (try! (match (stx-transfer? amount tx-sender recipient) + v (ok v) e (err (+ u200 e)))) + ) +) +(define-public (transfer-before (recipient principal) (amount-1 uint) (amount-2 uint) (allowed uint)) + (begin + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) e (err (+ u200 e)))) + (restrict-assets? tx-sender ((with-stx allowed)) + (try! (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) e (err (+ u300 e)))) + ) + ) +) +(define-public (transfer-after (recipient principal) (amount-1 uint) (amount-2 uint) (allowed uint)) + (begin + (unwrap! (restrict-assets? tx-sender ((with-stx allowed)) + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) e (err (+ u200 e)))) + ) (err u300)) + (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) e (err (+ u200 e))) + ) +) +"# + ); + + let contract_tx = make_contract_publish_versioned( + &sender_sk, + sender_nonce, + deploy_fee, + naka_conf.burnchain.chain_id, + contract_name, + &contract, + Some(ClarityVersion::Clarity4), + ); + sender_nonce += 1; + let deploy_txid = submit_tx(&http_origin, &contract_tx); + info!("Submitted deploy txid: {deploy_txid}"); + + let mut stacks_block_height = 0; + wait_for(60, || { + let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce; + let info = get_chain_info_result(&naka_conf).unwrap(); + stacks_block_height = info.stacks_tip_height as u128; + Ok(stacks_block_height > last_stacks_block_height && cur_sender_nonce == sender_nonce) + }) + .expect("Timed out waiting for contracts to publish"); + + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 30, &coord_channel) + .unwrap(); + + let mut sender_balance = get_account(&http_origin, &sender_addr).balance; + let mut recipient_balance = get_account(&http_origin, &recipient).balance; + + // helper to submit a call, wait for it to be mined/processed, and return the parsed result + fn submit_call_and_get_result( + http_origin: &str, + sender_sk: &Secp256k1PrivateKey, + sender_nonce: &mut u64, + call_fee: u64, + chain_id: u32, + sender_addr: &StacksAddress, + contract_name: &str, + function_name: &str, + function_args: &[Value], + recipient: &StacksAddress, + ) -> (Value, u128, u128) { + let sender_balance = get_account(http_origin, sender_addr).balance; + let recipient_balance = get_account(http_origin, recipient).balance; + info!("sender balance: {sender_balance}"); + info!("recipient balance: {recipient_balance}"); + + test_observer::clear(); + + let call_tx = make_contract_call( + sender_sk, + *sender_nonce, + call_fee, + chain_id, + sender_addr, + contract_name, + function_name, + function_args, + ); + *sender_nonce += 1; + let call_txid = submit_tx(http_origin, &call_tx); + info!("Submitted call txid: {call_txid}"); + + wait_for(60, || { + let cur_sender_nonce = get_account(http_origin, sender_addr).nonce; + Ok(cur_sender_nonce == *sender_nonce) + }) + .expect("Timed out waiting for contract calls"); + + let mut found = false; + let blocks = test_observer::get_blocks(); + let mut parsed: Option = None; + for block in blocks.iter() { + for tx in block.get("transactions").unwrap().as_array().unwrap() { + let txid = tx + .get("txid") + .unwrap() + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap(); + if txid == call_txid { + let raw_result = tx.get("raw_result").unwrap().as_str().unwrap(); + parsed = Some(Value::try_deserialize_hex_untyped(&raw_result[2..]).unwrap()); + found = true; + break; + } + } + if found { + break; + } + } + assert!(found, "Should have found expected tx"); + + let parsed = parsed.expect("parsed value"); + let sender_balance = get_account(http_origin, sender_addr).balance; + let recipient_balance = get_account(http_origin, recipient).balance; + (parsed, sender_balance, recipient_balance) + } + + info!("Test: Successful transfer"); + let amount = 1000; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "single-transfer", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - amount - call_fee as u128; + let recipient_expected = recipient_balance + amount; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + + info!("Test: Transfer that exceeds allowance"); + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "single-transfer", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(1000), + Value::UInt(500), + ], + &recipient, + ); + let expected = Value::err_uint(0); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128; + let recipient_expected = recipient_balance; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + + info!("Test: 2 transfers within allowance"); + let amount1 = 200; + let amount2 = 600; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "two-transfers", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128 - amount1 - amount2; + let recipient_expected = recipient_balance + amount1 + amount2; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + + info!("Test: 2 transfers that exceed allowance"); + let amount1 = 500; + let amount2 = 600; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "two-transfers", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::err_uint(0); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128; + let recipient_expected = recipient_balance; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + + coord_channel + .lock() + .expect("Mutex poisoned") + .stop_chains_coordinator(); + run_loop_stopper.store(false, Ordering::SeqCst); + + run_loop_thread.join().unwrap(); +} + // TODO: // - Test stack-stx // - Test roll backs From ce562a36e03da55eb28e1b161a13b8713210bf18 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Sat, 27 Sep 2025 12:52:48 -0400 Subject: [PATCH 23/29] test: additional rollback tests --- .../src/tests/nakamoto_integrations.rs | 357 ++++++++++++++++-- 1 file changed, 331 insertions(+), 26 deletions(-) diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 584abbe708..6d6c21b3e1 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -15744,7 +15744,7 @@ fn check_restrict_assets_rollback() { // setup sender + recipient for some test stx transfers // these are necessary for the interim blocks to get mined at all let sender_addr = tests::to_addr(&sender_sk); - let deploy_fee = 3000; + let deploy_fee = 4000; let call_fee = 400; let max_transfer_amt = 1000; naka_conf.add_initial_balance( @@ -15835,51 +15835,145 @@ fn check_restrict_assets_rollback() { let contract_name = "test-contract"; let contract = format!( r#" -(define-public (single-transfer (recipient principal) (amount uint) (allowed uint)) +(define-public (single-transfer + (recipient principal) + (amount uint) + (allowed uint) + ) (restrict-assets? tx-sender ((with-stx allowed)) (unwrap! (stx-transfer? amount tx-sender recipient) (err u200)) ) ) -(define-public (two-transfers (recipient principal) (amount-1 uint) (amount-2 uint) (allowed uint)) +(define-public (two-transfers + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) (restrict-assets? tx-sender ((with-stx allowed)) (try! (match (stx-transfer? amount-1 tx-sender recipient) - v (ok v) e (err (+ u200 e)))) + v (ok v) + e (err (+ u200 e)) + )) (try! (match (stx-transfer? amount-2 tx-sender recipient) - v (ok v) e (err (+ u300 e)))) + v (ok v) + e (err (+ u300 e)) + )) ) ) -(define-public (transfer-then-err (recipient principal) (amount uint) (allowed uint)) +(define-public (transfer-then-err + (recipient principal) + (amount uint) + (allowed uint) + ) (restrict-assets? tx-sender ((with-stx allowed)) (try! (match (stx-transfer? amount tx-sender recipient) - v (ok v) e (err (+ u200 e)))) - (try! (if false (ok true) (err u200))) + v (ok v) + e (err (+ u200 e)) + )) + (try! (if false + (ok true) + (err u300) + )) ) ) -(define-public (err-then-transfer (recipient principal) (amount uint) (allowed uint)) +(define-public (err-then-transfer + (recipient principal) + (amount uint) + (allowed uint) + ) (restrict-assets? tx-sender ((with-stx allowed)) - (try! (if false (ok true) (err u200))) + (try! (if false + (ok true) + (err u200) + )) (try! (match (stx-transfer? amount tx-sender recipient) - v (ok v) e (err (+ u200 e)))) + v (ok v) + e (err (+ u300 e)) + )) ) ) -(define-public (transfer-before (recipient principal) (amount-1 uint) (amount-2 uint) (allowed uint)) +(define-public (transfer-before + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) (begin (try! (match (stx-transfer? amount-1 tx-sender recipient) - v (ok v) e (err (+ u200 e)))) + v (ok v) + e (err (+ u200 e)) + )) (restrict-assets? tx-sender ((with-stx allowed)) (try! (match (stx-transfer? amount-2 tx-sender recipient) - v (ok v) e (err (+ u300 e)))) + v (ok v) + e (err (+ u300 e)) + )) + ) + ) +) +(define-public (transfer-before-catch-err + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) + (begin + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + )) + (unwrap-err! (restrict-assets? tx-sender ((with-stx allowed)) + (try! (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) + e (err (+ u300 e)) + )) + ) + (err u400) + ) + (ok true) + ) +) +(define-public (transfer-after + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) + (begin + (unwrap! + (restrict-assets? tx-sender ((with-stx allowed)) + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + )) + ) + (err u300) + ) + (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) ) ) ) -(define-public (transfer-after (recipient principal) (amount-1 uint) (amount-2 uint) (allowed uint)) +(define-public (transfer-after-catch-err + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) (begin - (unwrap! (restrict-assets? tx-sender ((with-stx allowed)) + (unwrap-err! (restrict-assets? tx-sender ((with-stx allowed)) (try! (match (stx-transfer? amount-1 tx-sender recipient) - v (ok v) e (err (+ u200 e)))) - ) (err u300)) + v (ok v) + e (err (+ u300 e)) + ))) + (err u400) + ) (match (stx-transfer? amount-2 tx-sender recipient) - v (ok v) e (err (+ u200 e))) + v (ok v) + e (err (+ u200 e)) + ) ) ) "# @@ -16015,10 +16109,11 @@ fn check_restrict_assets_rollback() { recipient_expected, new_recipient_balance, "incorrect recipient balance" ); - sender_balance = new_sender_balance; - recipient_balance = new_recipient_balance; info!("Test: Transfer that exceeds allowance"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + let amount = 1000; let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( &http_origin, @@ -16031,7 +16126,7 @@ fn check_restrict_assets_rollback() { "single-transfer", &[ Value::Principal(recipient.clone().into()), - Value::UInt(1000), + Value::UInt(amount), Value::UInt(500), ], &recipient, @@ -16048,10 +16143,10 @@ fn check_restrict_assets_rollback() { recipient_expected, new_recipient_balance, "incorrect recipient balance" ); - sender_balance = new_sender_balance; - recipient_balance = new_recipient_balance; info!("Test: 2 transfers within allowance"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; let amount1 = 200; let amount2 = 600; @@ -16084,10 +16179,10 @@ fn check_restrict_assets_rollback() { recipient_expected, new_recipient_balance, "incorrect recipient balance" ); - sender_balance = new_sender_balance; - recipient_balance = new_recipient_balance; info!("Test: 2 transfers that exceed allowance"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; let amount1 = 500; let amount2 = 600; @@ -16120,8 +16215,218 @@ fn check_restrict_assets_rollback() { recipient_expected, new_recipient_balance, "incorrect recipient balance" ); + + info!("Test: transfer then trigger an error in restrict-assets?"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + let amount = 500; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-then-err", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::err_uint(300); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128; + let recipient_expected = recipient_balance; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: error then transfer in restrict-assets?"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + let amount = 500; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "err-then-transfer", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::err_uint(200); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128; + let recipient_expected = recipient_balance; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: transfer before successful restrict-assets?"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + let amount1 = 700; + let amount2 = 500; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-before", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128 - amount1 - amount2; + let recipient_expected = recipient_balance + amount1 + amount2; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: transfer before restrict-assets? violation"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + let amount1 = 700; + let amount2 = 1200; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-before-catch-err", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128 - amount1; + let recipient_expected = recipient_balance + amount1; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: transfer after successful restrict-assets?"); + sender_balance = new_sender_balance; + recipient_balance = new_recipient_balance; + let amount1 = 700; + let amount2 = 500; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-after", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128 - amount1 - amount2; + let recipient_expected = recipient_balance + amount1 + amount2; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: transfer after restrict-assets? violation"); sender_balance = new_sender_balance; recipient_balance = new_recipient_balance; + let amount1 = 1200; + let amount2 = 700; + + let (parsed, new_sender_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-after-catch-err", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let sender_expected = sender_balance - call_fee as u128 - amount2; + let recipient_expected = recipient_balance + amount2; + assert_eq!( + sender_expected, new_sender_balance, + "incorrect sender balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); coord_channel .lock() From 7806db3152e2e7a0abc6c0a88d498f6692ce6e3b Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Tue, 30 Sep 2025 09:50:47 -0400 Subject: [PATCH 24/29] docs: update doc example --- clarity/src/vm/docs/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index 8093b0fdc9..13f7de54f8 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -2642,7 +2642,7 @@ expression. `with-stx` is not allowed outside of `restrict-assets?` or (restrict-assets? tx-sender ((with-stx u50)) (try! (stx-transfer? u100 tx-sender 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM)) -) ;; Returns (err 0) +) ;; Returns (err u0) "#, }; From 610c42174750858f31c0f30e7f58b18bad73e032 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Tue, 30 Sep 2025 10:13:14 -0400 Subject: [PATCH 25/29] refactor: use non-panicking array access --- .../v2_1/natives/post_conditions.rs | 75 +++++++++++++++---- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs index 8f0a0cbbac..0c60496a51 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/post_conditions.rs @@ -39,14 +39,20 @@ pub fn check_restrict_assets( ) -> Result { check_arguments_at_least(3, args)?; - let asset_owner = &args[0]; - let allowance_list = args[1] + let asset_owner = args + .first() + .ok_or(CheckErrors::CheckerImplementationFailure)?; + let allowance_list = args + .get(1) + .ok_or(CheckErrors::CheckerImplementationFailure)? .match_list() .ok_or(CheckErrors::ExpectedListOfAllowances( "restrict-assets?".into(), 2, ))?; - let body_exprs = &args[2..]; + let body_exprs = args + .get(2..) + .ok_or(CheckErrors::CheckerImplementationFailure)?; if allowance_list.len() > MAX_ALLOWANCES { return Err(CheckErrors::TooManyAllowances(MAX_ALLOWANCES, allowance_list.len()).into()); @@ -90,13 +96,17 @@ pub fn check_as_contract( ) -> Result { check_arguments_at_least(2, args)?; - let allowance_list = args[0] + let allowance_list = args + .first() + .ok_or(CheckErrors::CheckerImplementationFailure)? .match_list() .ok_or(CheckErrors::ExpectedListOfAllowances( "as-contract?".into(), 1, ))?; - let body_exprs = &args[1..]; + let body_exprs = args + .get(1..) + .ok_or(CheckErrors::CheckerImplementationFailure)?; if allowance_list.len() > MAX_ALLOWANCES { return Err(CheckErrors::TooManyAllowances(MAX_ALLOWANCES, allowance_list.len()).into()); @@ -185,7 +195,12 @@ fn check_allowance_with_stx( ) -> Result { check_argument_count(1, args)?; - checker.type_check_expects(&args[0], context, &TypeSignature::UIntType)?; + checker.type_check_expects( + args.first() + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + &TypeSignature::UIntType, + )?; Ok(false) } @@ -199,9 +214,24 @@ fn check_allowance_with_ft( ) -> Result { check_argument_count(3, args)?; - checker.type_check_expects(&args[0], context, &TypeSignature::PrincipalType)?; - checker.type_check_expects(&args[1], context, &ASCII_128)?; - checker.type_check_expects(&args[2], context, &TypeSignature::UIntType)?; + checker.type_check_expects( + args.first() + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + &TypeSignature::PrincipalType, + )?; + checker.type_check_expects( + args.get(1) + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + &ASCII_128, + )?; + checker.type_check_expects( + args.get(2) + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + &TypeSignature::UIntType, + )?; Ok(false) } @@ -215,11 +245,25 @@ fn check_allowance_with_nft( ) -> Result { check_argument_count(3, args)?; - checker.type_check_expects(&args[0], context, &TypeSignature::PrincipalType)?; - checker.type_check_expects(&args[1], context, &ASCII_128)?; + checker.type_check_expects( + args.first() + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + &TypeSignature::PrincipalType, + )?; + checker.type_check_expects( + args.get(1) + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + &ASCII_128, + )?; // Asset identifiers must be a Clarity list with any type of elements - let id_list_ty = checker.type_check(&args[2], context)?; + let id_list_ty = checker.type_check( + args.get(2) + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + )?; let TypeSignature::SequenceType(SequenceSubtype::ListType(list_data)) = id_list_ty else { return Err(CheckErrors::WithNftExpectedListOfIdentifiers.into()); }; @@ -243,7 +287,12 @@ fn check_allowance_with_stacking( ) -> Result { check_argument_count(1, args)?; - checker.type_check_expects(&args[0], context, &TypeSignature::UIntType)?; + checker.type_check_expects( + args.first() + .ok_or(CheckErrors::CheckerImplementationFailure)?, + context, + &TypeSignature::UIntType, + )?; Ok(false) } From 43b17c53eab9739ebbe27fd76f8bcdc3d7ed14cd Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Tue, 30 Sep 2025 15:27:25 -0400 Subject: [PATCH 26/29] test: add `cheeck_as_contract_rollback` integration test --- .../src/tests/nakamoto_integrations.rs | 755 +++++++++++++++++- stackslib/src/config/mod.rs | 2 +- 2 files changed, 752 insertions(+), 5 deletions(-) diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 6d6c21b3e1..1a159169fa 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -16437,7 +16437,754 @@ fn check_restrict_assets_rollback() { run_loop_thread.join().unwrap(); } -// TODO: -// - Test stack-stx -// - Test roll backs -// - Test successful asset movement +#[test] +#[ignore] +/// Verify the error handling and rollback works as expected in +/// `as-contract?` expressions +fn check_as_contract_rollback() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut signers = TestSigners::default(); + let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None); + let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); + naka_conf.burnchain.chain_id = CHAIN_ID_TESTNET + 1; + let sender_sk = Secp256k1PrivateKey::random(); + let recipient_sk = Secp256k1PrivateKey::random(); + let recipient = tests::to_addr(&recipient_sk); + let sender_signer_sk = Secp256k1PrivateKey::random(); + let sender_signer_addr = tests::to_addr(&sender_signer_sk); + + // setup sender + recipient for some test stx transfers + // these are necessary for the interim blocks to get mined at all + let sender_addr = tests::to_addr(&sender_sk); + let contract_name = "test-contract"; + let contract_addr = PrincipalData::Contract(QualifiedContractIdentifier { + issuer: sender_addr.clone().into(), + name: contract_name.into(), + }); + let deploy_fee = 4000; + let call_fee = 400; + let max_transfer_amt = 1000; + naka_conf.add_initial_balance( + PrincipalData::from(sender_addr.clone()).to_string(), + deploy_fee + call_fee * 30, + ); + naka_conf.add_initial_balance(contract_addr.to_string(), max_transfer_amt * 30); + naka_conf.add_initial_balance( + PrincipalData::from(sender_signer_addr.clone()).to_string(), + 100000, + ); + + // Add epoch 3.3 to the configuration because it is not yet added to the + // default epoch list for integration tests. + naka_conf.burnchain.epochs = Some(EpochList::new(&*NAKAMOTO_INTEGRATION_3_3_EPOCHS)); + + let stacker_sk = setup_stacker(&mut naka_conf); + + test_observer::spawn(); + test_observer::register_any(&mut naka_conf); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(&naka_conf); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None); + btc_regtest_controller.bootstrap_chain(201); + + let mut run_loop = boot_nakamoto::BootRunLoop::new(naka_conf.clone()).unwrap(); + let run_loop_stopper = run_loop.get_termination_switch(); + let Counters { + blocks_processed, .. + } = run_loop.counters(); + let counters = run_loop.counters(); + + let coord_channel = run_loop.coordinator_channels(); + + let run_loop_thread = thread::Builder::new() + .name("run_loop".into()) + .spawn(move || run_loop.start(None, 0)) + .unwrap(); + wait_for_runloop(&blocks_processed); + + boot_to_epoch_3( + &naka_conf, + &blocks_processed, + &[stacker_sk.clone()], + &[sender_signer_sk], + &mut Some(&mut signers), + &mut btc_regtest_controller, + ); + + info!("Bootstrapped to Epoch-3.0 boundary, starting nakamoto miner"); + + info!("Nakamoto miner started..."); + blind_signer(&naka_conf, &signers, &counters); + wait_for_first_naka_block_commit(60, &counters.naka_submitted_commits); + + // mine until epoch 3.3 height + loop { + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel) + .unwrap(); + + // once we actually get a block in epoch 3.3, exit + let blocks = test_observer::get_blocks(); + let last_block = blocks.last().unwrap(); + if last_block + .get("burn_block_height") + .unwrap() + .as_u64() + .unwrap() + >= naka_conf.burnchain.epochs.as_ref().unwrap()[StacksEpochId::Epoch33].start_height + { + break; + } + } + + info!( + "Nakamoto miner has advanced to bitcoin height {}", + get_chain_info_opt(&naka_conf).unwrap().burn_block_height + ); + + let info = get_chain_info_result(&naka_conf).unwrap(); + let last_stacks_block_height = info.stacks_tip_height as u128; + + next_block_and_mine_commit(&mut btc_regtest_controller, 60, &naka_conf, &counters).unwrap(); + + let mut sender_nonce = 0; + let contract = format!( + r#" +(define-public (single-transfer + (recipient principal) + (amount uint) + (allowed uint) + ) + (as-contract? ((with-stx allowed)) + (unwrap! (stx-transfer? amount tx-sender recipient) (err u200)) + ) +) +(define-public (two-transfers + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) + (as-contract? ((with-stx allowed)) + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + )) + (try! (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) + e (err (+ u300 e)) + )) + ) +) +(define-public (transfer-then-err + (recipient principal) + (amount uint) + (allowed uint) + ) + (as-contract? ((with-stx allowed)) + (try! (match (stx-transfer? amount tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + )) + (try! (if false + (ok true) + (err u300) + )) + ) +) +(define-public (err-then-transfer + (recipient principal) + (amount uint) + (allowed uint) + ) + (as-contract? ((with-stx allowed)) + (try! (if false + (ok true) + (err u200) + )) + (try! (match (stx-transfer? amount tx-sender recipient) + v (ok v) + e (err (+ u300 e)) + )) + ) +) +(define-public (transfer-before + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) + (begin + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + )) + (as-contract? ((with-stx allowed)) + (try! (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) + e (err (+ u300 e)) + )) + ) + ) +) +(define-public (transfer-before-catch-err + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) + (begin + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + )) + (unwrap-err! (as-contract? ((with-stx allowed)) + (try! (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) + e (err (+ u300 e)) + )) + ) + (err u400) + ) + (ok true) + ) +) +(define-public (transfer-after + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) + (begin + (unwrap! + (as-contract? ((with-stx allowed)) + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + )) + ) + (err u300) + ) + (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + ) + ) +) +(define-public (transfer-after-catch-err + (recipient principal) + (amount-1 uint) + (amount-2 uint) + (allowed uint) + ) + (begin + (unwrap-err! (as-contract? ((with-stx allowed)) + (try! (match (stx-transfer? amount-1 tx-sender recipient) + v (ok v) + e (err (+ u300 e)) + ))) + (err u400) + ) + (match (stx-transfer? amount-2 tx-sender recipient) + v (ok v) + e (err (+ u200 e)) + ) + ) +) +"# + ); + + let contract_tx = make_contract_publish_versioned( + &sender_sk, + sender_nonce, + deploy_fee, + naka_conf.burnchain.chain_id, + contract_name, + &contract, + Some(ClarityVersion::Clarity4), + ); + sender_nonce += 1; + let deploy_txid = submit_tx(&http_origin, &contract_tx); + info!("Submitted deploy txid: {deploy_txid}"); + + let mut stacks_block_height = 0; + wait_for(60, || { + let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce; + let info = get_chain_info_result(&naka_conf).unwrap(); + stacks_block_height = info.stacks_tip_height as u128; + Ok(stacks_block_height > last_stacks_block_height && cur_sender_nonce == sender_nonce) + }) + .expect("Timed out waiting for contracts to publish"); + + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 30, &coord_channel) + .unwrap(); + + let mut contract_balance = get_account(&http_origin, &contract_addr).balance; + let mut recipient_balance = get_account(&http_origin, &recipient).balance; + + // helper to submit a call, wait for it to be mined/processed, and return the parsed result + fn submit_call_and_get_result( + http_origin: &str, + sender_sk: &Secp256k1PrivateKey, + sender_nonce: &mut u64, + call_fee: u64, + chain_id: u32, + sender_addr: &StacksAddress, + contract_name: &str, + function_name: &str, + function_args: &[Value], + recipient: &StacksAddress, + ) -> (Value, u128, u128) { + let contract_addr = PrincipalData::Contract(QualifiedContractIdentifier { + issuer: sender_addr.clone().into(), + name: contract_name.into(), + }); + let contract_balance = get_account(http_origin, &contract_addr).balance; + let recipient_balance = get_account(http_origin, recipient).balance; + info!("contract balance: {contract_balance}"); + info!("recipient balance: {recipient_balance}"); + + test_observer::clear(); + + let call_tx = make_contract_call( + sender_sk, + *sender_nonce, + call_fee, + chain_id, + sender_addr, + contract_name, + function_name, + function_args, + ); + *sender_nonce += 1; + let call_txid = submit_tx(http_origin, &call_tx); + info!("Submitted call txid: {call_txid}"); + + wait_for(60, || { + let cur_sender_nonce = get_account(http_origin, sender_addr).nonce; + Ok(cur_sender_nonce == *sender_nonce) + }) + .expect("Timed out waiting for contract calls"); + + let mut found = false; + let blocks = test_observer::get_blocks(); + let mut parsed: Option = None; + for block in blocks.iter() { + for tx in block.get("transactions").unwrap().as_array().unwrap() { + let txid = tx + .get("txid") + .unwrap() + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap(); + if txid == call_txid { + let raw_result = tx.get("raw_result").unwrap().as_str().unwrap(); + parsed = Some(Value::try_deserialize_hex_untyped(&raw_result[2..]).unwrap()); + found = true; + break; + } + } + if found { + break; + } + } + assert!(found, "Should have found expected tx"); + + let parsed = parsed.expect("parsed value"); + let contract_balance = get_account(http_origin, &contract_addr).balance; + let recipient_balance = get_account(http_origin, recipient).balance; + (parsed, contract_balance, recipient_balance) + } + + info!("Test: Successful transfer"); + let amount = 1000; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "single-transfer", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let contract_expected = contract_balance - amount; + let recipient_expected = recipient_balance + amount; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: Transfer that exceeds allowance"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let amount = 1000; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "single-transfer", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount), + Value::UInt(500), + ], + &recipient, + ); + let expected = Value::err_uint(0); + assert_eq!(expected, parsed); + let contract_expected = contract_balance; + let recipient_expected = recipient_balance; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: 2 transfers within allowance"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let amount1 = 200; + let amount2 = 600; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "two-transfers", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let contract_expected = contract_balance - amount1 - amount2; + let recipient_expected = recipient_balance + amount1 + amount2; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: 2 transfers that exceed allowance"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let amount1 = 500; + let amount2 = 600; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "two-transfers", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::err_uint(0); + assert_eq!(expected, parsed); + let sender_expected = contract_balance; + let recipient_expected = recipient_balance; + assert_eq!( + sender_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: transfer then trigger an error in restrict-assets?"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let amount = 500; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-then-err", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::err_uint(300); + assert_eq!(expected, parsed); + let contract_expected = contract_balance; + let recipient_expected = recipient_balance; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: error then transfer in restrict-assets?"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let amount = 500; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "err-then-transfer", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::err_uint(200); + assert_eq!(expected, parsed); + let contract_expected = contract_balance; + let recipient_expected = recipient_balance; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + + info!("Test: transfer before successful restrict-assets?"); + let sender_balance = get_account(&http_origin, &sender_addr).balance; + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let amount1 = 700; + let amount2 = 500; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-before", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let contract_expected = contract_balance - amount2; + let recipient_expected = recipient_balance + amount1 + amount2; + let sender_expected = sender_balance - call_fee as u128 - amount1; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + assert_eq!( + sender_expected, + get_account(&http_origin, &sender_addr).balance, + "incorrect sender balance" + ); + + info!("Test: transfer before restrict-assets? violation"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let sender_balance = get_account(&http_origin, &sender_addr).balance; + let amount1 = 700; + let amount2 = 1200; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-before-catch-err", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let contract_expected = contract_balance; + let recipient_expected = recipient_balance + amount1; + let sender_expected = sender_balance - call_fee as u128 - amount1; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + assert_eq!( + sender_expected, + get_account(&http_origin, &sender_addr).balance, + "incorrect sender balance" + ); + + info!("Test: transfer after successful restrict-assets?"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let sender_balance = get_account(&http_origin, &sender_addr).balance; + let amount1 = 700; + let amount2 = 500; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-after", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let contract_expected = contract_balance - amount1; + let recipient_expected = recipient_balance + amount1 + amount2; + let sender_expected = sender_balance - call_fee as u128 - amount2; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + assert_eq!( + sender_expected, + get_account(&http_origin, &sender_addr).balance, + "incorrect sender balance" + ); + + info!("Test: transfer after restrict-assets? violation"); + contract_balance = new_contract_balance; + recipient_balance = new_recipient_balance; + let sender_balance = get_account(&http_origin, &sender_addr).balance; + let amount1 = 1200; + let amount2 = 700; + + let (parsed, new_contract_balance, new_recipient_balance) = submit_call_and_get_result( + &http_origin, + &sender_sk, + &mut sender_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "transfer-after-catch-err", + &[ + Value::Principal(recipient.clone().into()), + Value::UInt(amount1), + Value::UInt(amount2), + Value::UInt(max_transfer_amt.into()), + ], + &recipient, + ); + let expected = Value::okay_true(); + assert_eq!(expected, parsed); + let contract_expected = contract_balance; + let recipient_expected = recipient_balance + amount2; + let sender_expected = sender_balance - call_fee as u128 - amount2; + assert_eq!( + contract_expected, new_contract_balance, + "incorrect contract balance" + ); + assert_eq!( + recipient_expected, new_recipient_balance, + "incorrect recipient balance" + ); + assert_eq!( + sender_expected, + get_account(&http_origin, &sender_addr).balance, + "incorrect sender balance" + ); + + coord_channel + .lock() + .expect("Mutex poisoned") + .stop_chains_coordinator(); + run_loop_stopper.store(false, Ordering::SeqCst); + + run_loop_thread.join().unwrap(); +} diff --git a/stackslib/src/config/mod.rs b/stackslib/src/config/mod.rs index b7d1919752..bfd13535ae 100644 --- a/stackslib/src/config/mod.rs +++ b/stackslib/src/config/mod.rs @@ -1101,7 +1101,7 @@ impl Config { pub fn add_initial_balance(&mut self, address: String, amount: u64) { let new_balance = InitialBalance { - address: PrincipalData::parse_standard_principal(&address) + address: PrincipalData::parse(&address) .unwrap() .into(), amount, From a65dda44f67016a5056023ca938bda1d4f742516 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 1 Oct 2025 15:40:25 -0400 Subject: [PATCH 27/29] test: add integration tests for `stack-stx` allowances --- .../src/tests/nakamoto_integrations.rs | 597 +++++++++++++++++- 1 file changed, 595 insertions(+), 2 deletions(-) diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 1a159169fa..e6ae447829 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -15327,8 +15327,8 @@ fn check_block_time_keyword() { #[test] #[ignore] -/// Verify the `with-stacking` allowances work as expected -fn check_with_stacking_allowances() { +/// Verify the `with-stacking` allowances work as expected when delegating STX. +fn check_with_stacking_allowances_delegate_stx() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } @@ -15722,6 +15722,599 @@ fn check_with_stacking_allowances() { run_loop_thread.join().unwrap(); } +#[test] +#[ignore] +/// Verify the `with-stacking` allowances work as expected when stacking STX +fn check_with_stacking_allowances_stack_stx() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut signers = TestSigners::default(); + let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None); + let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); + naka_conf.burnchain.chain_id = CHAIN_ID_TESTNET + 1; + let sender_sk = Secp256k1PrivateKey::random(); + let sender_signer_sk = Secp256k1PrivateKey::random(); + let sender_signer_addr = tests::to_addr(&sender_signer_sk); + + let signer_sk = signers.signer_keys[0].clone(); + let signer_pk = StacksPublicKey::from_private(&signer_sk); + + // setup sender + recipient for some test stx transfers + // these are necessary for the interim blocks to get mined at all + let sender_addr = tests::to_addr(&sender_sk); + let deploy_fee = 3000; + let call_fee = 400; + naka_conf.add_initial_balance( + PrincipalData::from(sender_addr.clone()).to_string(), + deploy_fee + call_fee * 30, + ); + naka_conf.add_initial_balance( + PrincipalData::from(sender_signer_addr.clone()).to_string(), + 100000, + ); + + // Add epoch 3.3 to the configuration because it is not yet added to the + // default epoch list for integration tests. + naka_conf.burnchain.epochs = Some(EpochList::new(&*NAKAMOTO_INTEGRATION_3_3_EPOCHS)); + + // Default stacker used for bootstrapping + let stacker_sk = setup_stacker(&mut naka_conf); + + // Stackers used for testing + let stackers: Vec<_> = (0..3).map(|_| setup_stacker(&mut naka_conf)).collect(); + + test_observer::spawn(); + test_observer::register_any(&mut naka_conf); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(&naka_conf); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None); + btc_regtest_controller.bootstrap_chain(201); + + let mut run_loop = boot_nakamoto::BootRunLoop::new(naka_conf.clone()).unwrap(); + let run_loop_stopper = run_loop.get_termination_switch(); + let Counters { + blocks_processed, .. + } = run_loop.counters(); + let counters = run_loop.counters(); + + let coord_channel = run_loop.coordinator_channels(); + + let run_loop_thread = thread::Builder::new() + .name("run_loop".into()) + .spawn(move || run_loop.start(None, 0)) + .unwrap(); + wait_for_runloop(&blocks_processed); + + boot_to_epoch_3( + &naka_conf, + &blocks_processed, + &[stacker_sk.clone()], + &[sender_signer_sk], + &mut Some(&mut signers), + &mut btc_regtest_controller, + ); + + info!("Bootstrapped to Epoch-3.0 boundary, starting nakamoto miner"); + + info!("Nakamoto miner started..."); + blind_signer(&naka_conf, &signers, &counters); + wait_for_first_naka_block_commit(60, &counters.naka_submitted_commits); + + // mine until epoch 3.3 height + loop { + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel) + .unwrap(); + + // once we actually get a block in epoch 3.3, exit + let blocks = test_observer::get_blocks(); + let last_block = blocks.last().unwrap(); + if last_block + .get("burn_block_height") + .unwrap() + .as_u64() + .unwrap() + >= naka_conf.burnchain.epochs.as_ref().unwrap()[StacksEpochId::Epoch33].start_height + { + break; + } + } + + info!( + "Nakamoto miner has advanced to bitcoin height {}", + get_chain_info_opt(&naka_conf).unwrap().burn_block_height + ); + + let info = get_chain_info_result(&naka_conf).unwrap(); + let last_stacks_block_height = info.stacks_tip_height as u128; + + next_block_and_mine_commit(&mut btc_regtest_controller, 60, &naka_conf, &counters).unwrap(); + + let signer_key_hex = Value::buff_from(signer_pk.to_bytes_compressed()).unwrap(); + let mut sender_nonce = 0; + let contract_name = "test-contract"; + let contract = format!( + r#" +(define-constant signer-key {signer_key_hex}) +(define-public (stack-stx (amount uint) (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) (signature (optional (buff 65))) (auth-id uint) (allowed uint)) + (restrict-assets? tx-sender ((with-stacking allowed)) + (match + (contract-call? 'ST000000000000000000002AMW42H.pox-4 stack-stx + amount pox-addr burn-block-height u12 signature signer-key amount auth-id + ) + v true + e (try! (if false (ok true) (err (to-uint e)))) + ) + ) +) +(define-public (stack-stx-2-allowances (amount uint) (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) (signature (optional (buff 65))) (auth-id uint) (allowed-1 uint) (allowed-2 uint)) + (restrict-assets? tx-sender ((with-stacking allowed-1) (with-stacking allowed-2)) + (match + (contract-call? 'ST000000000000000000002AMW42H.pox-4 stack-stx + amount pox-addr burn-block-height u12 signature signer-key amount auth-id + ) + v true + e (try! (if false (ok true) (err (to-uint e)))) + ) + ) +) +(define-public (stack-stx-no-allowance (amount uint) (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) (signature (optional (buff 65))) (auth-id uint)) + (restrict-assets? tx-sender () + (match + (contract-call? 'ST000000000000000000002AMW42H.pox-4 stack-stx + amount pox-addr burn-block-height u12 signature signer-key amount auth-id + ) + v true + e (try! (if false (ok true) (err (to-uint e)))) + ) + ) +) +(define-public (stack-stx-all (amount uint) (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) (signature (optional (buff 65))) (auth-id uint)) + (begin + (try! (stx-transfer? amount tx-sender current-contract)) + (as-contract? ((with-all-assets-unsafe)) + (match + (contract-call? 'ST000000000000000000002AMW42H.pox-4 stack-stx + amount pox-addr burn-block-height u12 signature signer-key amount auth-id + ) + v true + e (try! (if false (ok true) (err (to-uint e)))) + ) + ) + ) +) +"# + ); + + let contract_tx = make_contract_publish_versioned( + &sender_sk, + sender_nonce, + deploy_fee, + naka_conf.burnchain.chain_id, + contract_name, + &contract, + Some(ClarityVersion::Clarity4), + ); + sender_nonce += 1; + let deploy_txid = submit_tx(&http_origin, &contract_tx); + info!("Submitted deploy txid: {deploy_txid}"); + + let mut stacks_block_height = 0; + wait_for(60, || { + let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce; + let info = get_chain_info_result(&naka_conf).unwrap(); + stacks_block_height = info.stacks_tip_height as u128; + Ok(stacks_block_height > last_stacks_block_height && cur_sender_nonce == sender_nonce) + }) + .expect("Timed out waiting for contracts to publish"); + + next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 30, &coord_channel) + .unwrap(); + + let block_height = btc_regtest_controller.get_headers_height(); + let reward_cycle = btc_regtest_controller + .get_burnchain() + .block_height_to_reward_cycle(block_height) + .unwrap(); + + test_observer::clear(); + + // Amount to stack + let amount = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT); + + // Map txid to expected result, `true` for ok, `false` for error + let mut expected_results = HashMap::new(); + let mut wait_for_nonce = HashMap::new(); + + // ***** Successfully stack with stackers[0] + let stacker = &stackers[0]; + let stacker_addr = tests::to_addr(stacker); + let mut stacker_nonce = 0; + + // Authorize the contract + let authorize_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &boot_code_addr(false), + "pox-4", + "allow-contract-caller", + &[ + QualifiedContractIdentifier::new(sender_addr.clone().into(), contract_name.into()) + .into(), + Value::none(), + ], + ); + stacker_nonce += 1; + let authorize_txid = submit_tx(&http_origin, &authorize_tx); + info!("Submitted authorize txid: {authorize_txid}"); + expected_results.insert(authorize_txid, Value::okay_true()); + + let auth_id = 1; + let pox_addr = PoxAddress::from_legacy( + AddressHashMode::SerializeP2PKH, + stacker_addr.bytes().clone(), + ); + let pox_addr_tuple: clarity::vm::Value = pox_addr.clone().as_clarity_tuple().unwrap().into(); + let signature_bytes = make_pox_4_signer_key_signature( + &pox_addr, + &signer_sk, + reward_cycle.into(), + &Pox4SignatureTopic::StackStx, + naka_conf.burnchain.chain_id, + 12_u128, + POX_4_DEFAULT_STACKER_STX_AMT, + auth_id, + ) + .unwrap() + .to_rsv(); + let signature = Value::some(clarity::vm::Value::buff_from(signature_bytes).unwrap()).unwrap(); + let stack_ok_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx", + &[ + amount.clone(), + pox_addr_tuple, + signature, + Value::UInt(auth_id), + amount.clone(), + ], + ); + stacker_nonce += 1; + let stack_ok_txid = submit_tx(&http_origin, &stack_ok_tx); + info!("Submitted stack_ok txid: {stack_ok_txid}"); + expected_results.insert(stack_ok_txid, Value::okay_true()); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + // ***** Fail to stack with stackers[1] + let stacker = &stackers[1]; + let stacker_addr = tests::to_addr(stacker); + let mut stacker_nonce = 0; + + // Authorize the contract + let authorize_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &boot_code_addr(false), + "pox-4", + "allow-contract-caller", + &[ + QualifiedContractIdentifier::new(sender_addr.clone().into(), contract_name.into()) + .into(), + Value::none(), + ], + ); + stacker_nonce += 1; + let authorize_txid = submit_tx(&http_origin, &authorize_tx); + info!("Submitted authorize txid: {authorize_txid}"); + expected_results.insert(authorize_txid, Value::okay_true()); + + let auth_id = 1; + let allowed = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT - 1); + let pox_addr = PoxAddress::from_legacy( + AddressHashMode::SerializeP2PKH, + stacker_addr.bytes().clone(), + ); + let pox_addr_tuple: clarity::vm::Value = pox_addr.clone().as_clarity_tuple().unwrap().into(); + let signature_bytes = make_pox_4_signer_key_signature( + &pox_addr, + &signer_sk, + reward_cycle.into(), + &Pox4SignatureTopic::StackStx, + naka_conf.burnchain.chain_id, + 12_u128, + POX_4_DEFAULT_STACKER_STX_AMT, + auth_id, + ) + .unwrap() + .to_rsv(); + let signature = Value::some(clarity::vm::Value::buff_from(signature_bytes).unwrap()).unwrap(); + let stack_err_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx", + &[ + amount.clone(), + pox_addr_tuple.clone(), + signature.clone(), + Value::UInt(auth_id), + allowed, + ], + ); + stacker_nonce += 1; + let stack_err_txid = submit_tx(&http_origin, &stack_err_tx); + info!("Submitted stack_err txid: {stack_err_txid}"); + expected_results.insert(stack_err_txid, Value::error(Value::UInt(0)).unwrap()); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + // ***** Stack successfully with stackers[1] with two allowances + let allowed1 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT); + let allowed2 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT + 100); + let stack_2_ok_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx-2-allowances", + &[ + amount.clone(), + pox_addr_tuple, + signature, + Value::UInt(auth_id), + allowed1, + allowed2, + ], + ); + stacker_nonce += 1; + let stack_2_ok_txid = submit_tx(&http_origin, &stack_2_ok_tx); + info!("Submitted stack_2_ok_txid txid: {stack_2_ok_txid}"); + expected_results.insert(stack_2_ok_txid, Value::okay_true()); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + // ***** Fail to stack with stackers[2] with two allowances (both too small) + let stacker = &stackers[2]; + let stacker_addr = tests::to_addr(stacker); + let mut stacker_nonce = 0; + + // Authorize the contract + let authorize_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &boot_code_addr(false), + "pox-4", + "allow-contract-caller", + &[ + QualifiedContractIdentifier::new(sender_addr.clone().into(), contract_name.into()) + .into(), + Value::none(), + ], + ); + stacker_nonce += 1; + let authorize_txid = submit_tx(&http_origin, &authorize_tx); + info!("Submitted authorize txid: {authorize_txid}"); + expected_results.insert(authorize_txid, Value::okay_true()); + + let auth_id = 1; + let allowed1 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT - 100); + let allowed2 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT - 1000); + let pox_addr = PoxAddress::from_legacy( + AddressHashMode::SerializeP2PKH, + stacker_addr.bytes().clone(), + ); + let pox_addr_tuple: clarity::vm::Value = pox_addr.clone().as_clarity_tuple().unwrap().into(); + let signature_bytes = make_pox_4_signer_key_signature( + &pox_addr, + &signer_sk, + reward_cycle.into(), + &Pox4SignatureTopic::StackStx, + naka_conf.burnchain.chain_id, + 12_u128, + POX_4_DEFAULT_STACKER_STX_AMT, + auth_id, + ) + .unwrap() + .to_rsv(); + let signature = Value::some(clarity::vm::Value::buff_from(signature_bytes).unwrap()).unwrap(); + let stack_2_both_err_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx-2-allowances", + &[ + amount.clone(), + pox_addr_tuple.clone(), + signature.clone(), + Value::UInt(auth_id), + allowed1, + allowed2, + ], + ); + stacker_nonce += 1; + let stack_2_both_err_txid = submit_tx(&http_origin, &stack_2_both_err_tx); + info!("Submitted stack_2_both_err txid: {stack_2_both_err_txid}"); + expected_results.insert(stack_2_both_err_txid, Value::error(Value::UInt(0)).unwrap()); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + // ***** Fail to stack with stackers[2] with two allowances (first too small) + let allowed1 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT - 100); + let allowed2 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT); + + let stack_2_first_err_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx-2-allowances", + &[ + amount.clone(), + pox_addr_tuple.clone(), + signature.clone(), + Value::UInt(auth_id), + allowed1, + allowed2, + ], + ); + stacker_nonce += 1; + let stack_2_first_err_txid = submit_tx(&http_origin, &stack_2_first_err_tx); + info!("Submitted stack_2_first_err txid: {stack_2_first_err_txid}"); + expected_results.insert( + stack_2_first_err_txid, + Value::error(Value::UInt(0)).unwrap(), + ); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + // ***** Fail to stack with stackers[2] with two allowances (second too small) + let allowed1 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT); + let allowed2 = Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT - 100); + + let stack_2_second_err_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx-2-allowances", + &[ + amount.clone(), + pox_addr_tuple.clone(), + signature.clone(), + Value::UInt(auth_id), + allowed1, + allowed2, + ], + ); + stacker_nonce += 1; + let stack_2_second_err_txid = submit_tx(&http_origin, &stack_2_second_err_tx); + info!("Submitted stack_2_second_err txid: {stack_2_second_err_txid}"); + expected_results.insert( + stack_2_second_err_txid, + Value::error(Value::UInt(1)).unwrap(), + ); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + // ***** Fail to stack with stackers[2] with no allowance + let stack_no_allowance_err_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx-no-allowance", + &[ + amount.clone(), + pox_addr_tuple.clone(), + signature.clone(), + Value::UInt(auth_id), + ], + ); + stacker_nonce += 1; + let stack_no_allowance_err_txid = submit_tx(&http_origin, &stack_no_allowance_err_tx); + info!("Submitted stack_no_allowance_err txid: {stack_no_allowance_err_txid}"); + expected_results.insert( + stack_no_allowance_err_txid, + Value::error(Value::UInt(128)).unwrap(), + ); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + // ***** Stack successfully with stackers[2] with with-all-assets-unsafe + let stack_all_tx = make_contract_call( + stacker, + stacker_nonce, + call_fee, + naka_conf.burnchain.chain_id, + &sender_addr, + contract_name, + "stack-stx-all", + &[ + amount.clone(), + pox_addr_tuple.clone(), + signature.clone(), + Value::UInt(auth_id), + ], + ); + stacker_nonce += 1; + let stack_all_txid = submit_tx(&http_origin, &stack_all_tx); + info!("Submitted stack_all txid: {stack_all_txid}"); + expected_results.insert(stack_all_txid, Value::okay_true()); + wait_for_nonce.insert(stacker_addr.clone(), stacker_nonce); + + wait_for(60, || { + for (addr, expected_nonce) in &wait_for_nonce { + let cur_nonce = get_account(&http_origin, addr).nonce; + if cur_nonce != *expected_nonce { + return Ok(false); + } + } + Ok(true) + }) + .expect("Timed out waiting for contract calls"); + + let blocks = test_observer::get_blocks(); + let mut found = 0; + for block in blocks.iter() { + for tx in block.get("transactions").unwrap().as_array().unwrap() { + let txid = tx + .get("txid") + .unwrap() + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap(); + if let Some(expected) = expected_results.get(txid) { + let raw_result = tx.get("raw_result").unwrap().as_str().unwrap(); + let parsed = Value::try_deserialize_hex_untyped(&raw_result[2..]).unwrap(); + found += 1; + assert_eq!(&parsed, expected); + } else { + // If there are any txids we don't expect, panic, because it probably means + // there is an error in the test itself. + panic!("Found unexpected txid: {txid}"); + } + } + } + + assert_eq!( + found, + expected_results.len(), + "Should have found all expected txs" + ); + + coord_channel + .lock() + .expect("Mutex poisoned") + .stop_chains_coordinator(); + run_loop_stopper.store(false, Ordering::SeqCst); + + run_loop_thread.join().unwrap(); +} + #[test] #[ignore] /// Verify the error handling and rollback works as expected in From c824cde92810d9abe4e322160cefc4c8b3a5f45f Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 1 Oct 2025 16:11:50 -0400 Subject: [PATCH 28/29] chore: remove todo for event on allowance violation We decided to remove this because it doesn't really make sense. --- clarity/src/vm/functions/post_conditions.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index 12dd2f3294..c5621f3386 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -208,7 +208,6 @@ pub fn special_restrict_assets( // - Emit an event if let Some(violation_index) = check_allowances(&asset_owner, &allowances, asset_maps)? { env.global_context.roll_back()?; - // TODO: Emit an event about the allowance violation return Value::error(Value::UInt(violation_index)); } @@ -293,7 +292,6 @@ pub fn special_as_contract( Ok(None) => {} Ok(Some(violation_index)) => { nested_env.global_context.roll_back()?; - // TODO: Emit an event about the allowance violation return Value::error(Value::UInt(violation_index)); } Err(e) => { From a6afc031234e90cbc00f8b3ce1558cf40951efb7 Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Wed, 1 Oct 2025 16:13:09 -0400 Subject: [PATCH 29/29] chore: formatting --- stackslib/src/config/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/stackslib/src/config/mod.rs b/stackslib/src/config/mod.rs index bfd13535ae..c09bed9848 100644 --- a/stackslib/src/config/mod.rs +++ b/stackslib/src/config/mod.rs @@ -1101,9 +1101,7 @@ impl Config { pub fn add_initial_balance(&mut self, address: String, amount: u64) { let new_balance = InitialBalance { - address: PrincipalData::parse(&address) - .unwrap() - .into(), + address: PrincipalData::parse(&address).unwrap().into(), amount, }; self.initial_balances.push(new_balance);