// Copyright (C) 2025-2026 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 std::collections::HashMap; use clarity_types::ClarityName; use clarity_types::types::{AssetIdentifier, PrincipalData, StandardPrincipalData}; use stacks_common::types::StacksEpochId; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::contexts::{AssetMap, ExecutionState, InvocationContext}; use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::{ CostErrors, CostTracker, MemoryConsumer, constants as cost_constants, runtime_cost, }; use crate::vm::errors::{ RuntimeCheckErrorKind, RuntimeError, VmExecutionError, VmInternalError, check_arguments_at_least, }; use crate::vm::functions::NativeFunctions; use crate::vm::representations::SymbolicExpression; use crate::vm::types::Value; use crate::vm::{LocalContext, eval}; #[derive(Debug)] pub struct StxAllowance { amount: u128, } #[derive(Debug)] pub struct FtAllowance { asset: AssetIdentifier, amount: u128, } #[derive(Debug)] pub struct NftAllowance { asset: AssetIdentifier, asset_ids: Vec, } #[derive(Debug)] pub struct StackingAllowance { amount: u128, } #[derive(Debug)] pub enum Allowance { /// Permits the asset owner to move or burn up to the specified amount of /// STX within the protected scope. Stx(StxAllowance), /// Permits the asset owner to move or burn up to the specified amount of a /// fungible token within the protected scope. Ft(FtAllowance), /// Permits the asset owner to transfer or burn specific non-fungible /// tokens within the protected scope. Nft(NftAllowance), /// Permits the asset owner to stake up to the specified amount of STX /// within the protected scope. Stacking(StackingAllowance), /// Permits the asset owner to perform a position-altering PoX action /// (`unstake`, `unstake-sbtc`, `update-bond-registration`, /// `announce-l1-early-exit`) within the protected scope. Pox, /// Permits the asset owner to access all assets within the protected /// scope. All, } impl Allowance { /// Returns the size in bytes of the allowance when stored in memory. /// This is used to account for memory usage when evaluating `as-contract?` /// and `restrict-assets?` expressions. pub fn size_in_bytes(&self) -> Result { match self { Allowance::Stx(_) => Ok(std::mem::size_of::()), Allowance::Ft(ft) => Ok(std::mem::size_of::() + std::mem::size_of::() + ft.asset.contract_identifier.name.len() as usize + ft.asset.asset_name.len() as usize), Allowance::Nft(nft) => { let mut total_size = std::mem::size_of::() + std::mem::size_of::() + nft.asset.contract_identifier.name.len() as usize + nft.asset.asset_name.len() as usize; for id in &nft.asset_ids { let memory_use = id.get_memory_use().map_err(|e| { VmInternalError::Expect(format!("Failed to calculate memory use: {e}")) })?; total_size += memory_use as usize; } Ok(total_size) } Allowance::Stacking(_) => Ok(std::mem::size_of::()), Allowance::Pox => Ok(0), Allowance::All => Ok(0), } } } fn eval_allowance( allowance_expr: &SymbolicExpression, exec_state: &mut ExecutionState, invoke_ctx: &InvocationContext, context: &LocalContext, ) -> Result { let list = allowance_expr .match_list() .ok_or(RuntimeCheckErrorKind::Unreachable( "Non functional application".to_string(), ))?; let (name_expr, rest) = list .split_first() .ok_or(RuntimeCheckErrorKind::Unreachable( "Non functional application".to_string(), ))?; let name = name_expr .match_atom() .ok_or(RuntimeCheckErrorKind::Unreachable( "Bad function name".to_string(), ))?; let Some(ref native_function) = NativeFunctions::lookup_by_name_at_version( name, invoke_ctx.contract_context.get_clarity_version(), ) else { return Err( RuntimeCheckErrorKind::Unreachable(format!("Expected allowance expr: {name}")).into(), ); }; match native_function { NativeFunctions::AllowanceWithStx => { if rest.len() != 1 { return Err(RuntimeCheckErrorKind::IncorrectArgumentCount(1, rest.len()).into()); } let amount = eval(&rest[0], exec_state, invoke_ctx, context)?; let amount = match amount.as_ref() { Value::UInt(amount) => *amount, _ => { return Err(VmInternalError::Expect("Expected u128".into()).into()); } }; Ok(Allowance::Stx(StxAllowance { amount })) } NativeFunctions::AllowanceWithFt => { if rest.len() != 3 { return Err(RuntimeCheckErrorKind::IncorrectArgumentCount(3, rest.len()).into()); } let contract_value = eval(&rest[0], exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let contract = contract_value .clone() .expect_principal() .map_err(|_| VmInternalError::Expect("Expected principal".into()))?; let contract_identifier = match contract { PrincipalData::Standard(_) => { return Err(RuntimeCheckErrorKind::ExpectedContractPrincipalValue( contract_value.to_error_string(), ) .into()); } PrincipalData::Contract(c) => c, }; let asset_name = eval(&rest[1], exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let asset_name = asset_name .expect_string_ascii() .map_err(|_| VmInternalError::Expect("Expected ASCII String.".into()))?; let asset_name = match ClarityName::try_from(asset_name) { Ok(name) => name, Err(_) => { return Err(RuntimeError::BadTokenName(rest[1].to_string()).into()); } }; let asset = AssetIdentifier { contract_identifier, asset_name, }; let amount = eval(&rest[2], exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let amount = amount .expect_u128() .map_err(|_| VmInternalError::Expect("Expected u128".into()))?; Ok(Allowance::Ft(FtAllowance { asset, amount })) } NativeFunctions::AllowanceWithNft => { if rest.len() != 3 { return Err(RuntimeCheckErrorKind::IncorrectArgumentCount(3, rest.len()).into()); } let contract_value = eval(&rest[0], exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let contract = contract_value .clone() .expect_principal() .map_err(|_| VmInternalError::Expect("Expected principal".into()))?; let contract_identifier = match contract { PrincipalData::Standard(_) => { return Err(RuntimeCheckErrorKind::ExpectedContractPrincipalValue( contract_value.to_error_string(), ) .into()); } PrincipalData::Contract(c) => c, }; let asset_name = eval(&rest[1], exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let asset_name = asset_name .expect_string_ascii() .map_err(|_| VmInternalError::Expect("Expected ASCII String.".into()))?; let asset_name = match ClarityName::try_from(asset_name) { Ok(name) => name, Err(_) => { return Err(RuntimeError::BadTokenName(rest[1].to_string()).into()); } }; let asset = AssetIdentifier { contract_identifier, asset_name, }; let asset_id_list = eval(&rest[2], exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let asset_ids = asset_id_list .expect_list() .map_err(|_| VmInternalError::Expect("Expected list".into()))?; Ok(Allowance::Nft(NftAllowance { asset, asset_ids })) } // `with-stacking` (Clarity 4-5) and `with-staking` (Clarity 6+) are the // same allowance under two spellings; the version gating is handled by // `lookup_by_name_at_version`. NativeFunctions::AllowanceWithStacking | NativeFunctions::AllowanceWithStaking => { if rest.len() != 1 { return Err(RuntimeCheckErrorKind::IncorrectArgumentCount(1, rest.len()).into()); } let amount = eval(&rest[0], exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let amount = amount .expect_u128() .map_err(|_| VmInternalError::Expect("Expected u128".into()))?; Ok(Allowance::Stacking(StackingAllowance { amount })) } NativeFunctions::AllowanceWithPox => { if !rest.is_empty() { return Err(RuntimeCheckErrorKind::IncorrectArgumentCount(0, rest.len()).into()); } Ok(Allowance::Pox) } NativeFunctions::AllowanceAll => { if !rest.is_empty() { return Err(RuntimeCheckErrorKind::IncorrectArgumentCount(1, rest.len()).into()); } Ok(Allowance::All) } _ => Err( RuntimeCheckErrorKind::Unreachable(format!("Expected allowance expr: {name}")).into(), ), } } /// Handles the function `restrict-assets?` pub fn special_restrict_assets( args: &[SymbolicExpression], exec_state: &mut ExecutionState, invoke_ctx: &InvocationContext, context: &LocalContext, ) -> Result { // (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(RuntimeCheckErrorKind::Unreachable( "Expected list of allowances: for restrict-assets? as argument 2".to_string(), ))?; let body_exprs = &args[2..]; let asset_owner = eval(asset_owner_expr, exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; let asset_owner = asset_owner .expect_principal() .map_err(|_| VmInternalError::Expect("Expected principal".into()))?; let allowance_len = allowance_list.len(); runtime_cost( ClarityCostFunction::RestrictAssets, exec_state, allowance_len, )?; if allowance_len > MAX_ALLOWANCES { return Err(RuntimeCheckErrorKind::Unreachable(format!( "Too many allowances: got {allowance_len}, allowed {MAX_ALLOWANCES}" )) .into()); } let starting_memory = exec_state.global_context.cost_track.get_memory(); let mut memory_use: u64 = 0; finally_drop_memory!( exec_state, memory_use; { let mut allowances = Vec::with_capacity(allowance_len); for allowance in allowance_list { let allowance = eval_allowance(allowance, exec_state, invoke_ctx, context)?; let allowance_memory = u64::try_from(allowance.size_in_bytes()?) .map_err(|_| VmInternalError::Expect("Allowance size too large".into()))?; match exec_state.add_memory(allowance_memory) { Ok(()) => { memory_use = memory_use.checked_add(allowance_memory).ok_or_else(|| { VmInternalError::Expect( "restrict-assets allowance memory overflowed".into(), ) })?; } Err(CostErrors::MemoryBalanceExceeded(used, limit)) => { memory_use = used.checked_sub(starting_memory).ok_or_else(|| { VmInternalError::Expect( "restrict-assets allowance memory cleanup underflowed".into(), ) })?; return Err( RuntimeCheckErrorKind::RestrictAssetsMemoryExceeded(used, limit).into(), ); } Err(e) => return Err(e.into()), } allowances.push(allowance); } evaluate_body_with_allowance_check( &asset_owner, allowances, body_exprs, invoke_ctx, exec_state, context, ) }) } /// Evaluate the body of a post-condition scope inside a rollback sub-context, then enforce /// the accumulated `allowances` against the resulting asset map. /// /// On allowance violation the sub-context is rolled back and the caller gets back /// `(err )`. On an error from `check_allowances` the sub-context is rolled /// back and the error is propagated. Otherwise the sub-context is committed and the body /// result is wrapped in `(ok ...)`. fn evaluate_body_with_allowance_check( asset_owner: &PrincipalData, allowances: Vec, body_exprs: &[SymbolicExpression], invoke_ctx: &InvocationContext, exec_state: &mut ExecutionState, context: &LocalContext, ) -> Result { let epoch = *exec_state.epoch(); exec_state.global_context.begin(); // Evaluate the body expressions inside a closure so `?` only exits the closure let eval_result: Result, VmExecutionError> = (|| -> Result, VmExecutionError> { let mut last_result = None; for expr in body_exprs { let result = eval(expr, exec_state, invoke_ctx, context)?.clone_with_cost(exec_state)?; last_result.replace(result); } Ok(last_result) })(); let asset_maps = exec_state.global_context.get_readonly_asset_map()?; // If the allowances are violated: // - Rollback the context // - Return an error with the index of the violated allowance match check_allowances(asset_owner, allowances, asset_maps, epoch) { Ok(None) => {} Ok(Some(violation_index)) => { exec_state.global_context.roll_back()?; return Ok(Value::error(Value::UInt(violation_index))?); } Err(e) => { exec_state.global_context.roll_back()?; return Err(e); } } exec_state.global_context.commit()?; // 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) Ok(Value::okay(last)?) } Ok(None) => { // Body had no expressions (shouldn't happen due to argument checks) Err(VmInternalError::Expect("Failed to get body result".into()).into()) } Err(e) => { // Runtime error inside body, pass it up Err(e) } } } /// Handles the function `as-contract?` pub fn special_as_contract( args: &[SymbolicExpression], exec_state: &mut ExecutionState, invoke_ctx: &InvocationContext, context: &LocalContext, ) -> Result { // (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(RuntimeCheckErrorKind::Unreachable( "Expected list of allowances: for as-contract? as argument 1".to_string(), ))?; let body_exprs = &args[1..]; runtime_cost( ClarityCostFunction::AsContractSafe, exec_state, allowance_list.len(), )?; let mut memory_use = 0u64; finally_drop_memory!( exec_state, memory_use; { let mut allowances = Vec::with_capacity(allowance_list.len()); for allowance_expr in allowance_list { let allowance = eval_allowance(allowance_expr, exec_state, invoke_ctx, context)?; let allowance_memory = u64::try_from(allowance.size_in_bytes()?) .map_err(|_| VmInternalError::Expect("Allowance size too large".into()))?; exec_state.add_memory(allowance_memory)?; memory_use += allowance_memory; allowances.push(allowance); } exec_state.add_memory(cost_constants::AS_CONTRACT_MEMORY)?; memory_use += cost_constants::AS_CONTRACT_MEMORY; let contract_principal: PrincipalData = invoke_ctx.contract_context.contract_identifier.clone().into(); let nested_view = invoke_ctx.with_principal(contract_principal.clone()); evaluate_body_with_allowance_check( &contract_principal, allowances, body_exprs, &nested_view, exec_state, context, ) }) } /// 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 128 if an asset with no allowance caused the violation. If all /// allowances are satisfied, return `Ok(None)`. fn check_allowances( owner: &PrincipalData, allowances: Vec, assets: &AssetMap, epoch: StacksEpochId, ) -> Result, VmExecutionError> { let mut earliest_violation: Option = None; let record_violation = |earliest: &mut Option, candidate: u128| { if earliest.is_none_or(|current| candidate < current) { *earliest = Some(candidate); } }; // 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> = HashMap::new(); // Map assets to a tuple with the first allowance's index and a vector of // asset identifiers. We use Vec instead of HashSet because: // 1. Most NFT IDs are simple (`uint`s), making Value::eq() very fast // 2. Linear search through ≤128 items is cache-friendly and fast // 3. Avoids serialization cost during both setup and lookup phases // 4. Simpler implementation with lower memory overhead (no cloning or // space used for serialization) let mut nft_allowances: HashMap)> = HashMap::new(); // Elements are (index in allowances, amount) let mut stacking_allowances: Vec<(usize, u128)> = Vec::new(); // Index of the first `with-pox` allowance, if any. let mut pox_allowance: Option = None; for (i, allowance) in allowances.into_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 (_, vec) = nft_allowances .entry(nft.asset) .or_insert_with(|| (i, Vec::new())); vec.extend(nft.asset_ids); } Allowance::Stacking(stacking) => { stacking_allowances.push((i, stacking.amount)); } Allowance::Pox => { if pox_allowance.is_none() { pox_allowance = Some(i); } } } } // Check the movement and burn of STX separately first for backward compatibility with // pre-epoch 3.4 behavior. The combined check is done after all other checks. // Check STX movements let amount_moved = assets.get_stx(owner); if let Some(stx_moved) = amount_moved { if stx_allowances.is_empty() { // If there are no allowances for STX, any movement is a violation record_violation(&mut earliest_violation, MAX_ALLOWANCES as u128); } else { for (index, allowance) in &stx_allowances { if stx_moved > *allowance { record_violation(&mut earliest_violation, *index as u128); break; } } } } // Check STX burns let amount_burned = assets.get_stx_burned(owner); if let Some(stx_burned) = amount_burned { if stx_allowances.is_empty() { // If there are no allowances for STX, any burn is a violation record_violation(&mut earliest_violation, MAX_ALLOWANCES as u128); } else { for (index, allowance) in &stx_allowances { if stx_burned > *allowance { record_violation(&mut earliest_violation, *index as u128); break; } } } } // Check FT movements 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) { merged.extend(allowance_vec.iter().cloned()); } if let Some(wildcard_vec) = ft_allowances.get(&AssetIdentifier { contract_identifier: asset.contract_identifier.clone(), asset_name: ClarityName::from_literal("*"), }) { merged.extend(wildcard_vec.iter().cloned()); } if merged.is_empty() { // No allowance for this asset, any movement is a violation record_violation(&mut earliest_violation, MAX_ALLOWANCES as u128); continue; } for (index, allowance) in merged { if *amount_moved > allowance { record_violation(&mut earliest_violation, index as u128); } } } } // Check NFT movements if let Some(nft_moved) = assets.get_all_nonfungible_tokens(owner) { for (asset, ids_moved) in nft_moved { let mut merged: Vec<(usize, &Vec)> = Vec::new(); if let Some((index, allowance_vec)) = nft_allowances.get(asset) { merged.push((*index, allowance_vec)); } if let Some((index, allowance_vec)) = nft_allowances.get(&AssetIdentifier { contract_identifier: asset.contract_identifier.clone(), asset_name: ClarityName::from_literal("*"), }) { merged.push((*index, allowance_vec)); } if merged.is_empty() { // No allowance for this asset, any movement is a violation record_violation(&mut earliest_violation, MAX_ALLOWANCES as u128); continue; } for (index, allowance_vec) in merged { if ids_moved.iter().any(|id| !allowance_vec.contains(id)) { record_violation(&mut earliest_violation, index as u128); } } } } // 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() { record_violation(&mut earliest_violation, MAX_ALLOWANCES as u128); } else { for (index, allowance) in &stacking_allowances { if stx_stacked > *allowance { record_violation(&mut earliest_violation, *index as u128); break; } } } } // Check position-altering PoX actions. if assets.did_pox_action(owner) && pox_allowance.is_none() { record_violation(&mut earliest_violation, MAX_ALLOWANCES as u128); } // Check combined STX movements and burns. In epochs that don't support the combined check, // this happens after all other checks to ensure that we only need to reach this rejectable // error if there are no other errors already reached. let total_stx_change = amount_moved .unwrap_or(0) .checked_add(amount_burned.unwrap_or(0)) .ok_or(VmInternalError::Expect( "STX movement and burn overflowed u128".into(), ))?; if total_stx_change > 0 { for (index, allowance) in &stx_allowances { if total_stx_change > *allowance { if epoch.handles_with_stx_combined_check() { record_violation(&mut earliest_violation, *index as u128); break; } else if earliest_violation.is_none() { return Err(VmExecutionError::Internal(VmInternalError::Expect( "Total STX movement and burn exceeds allowance".into(), ))); } } } } Ok(earliest_violation) } /// 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 ExecutionState, _invoke_ctx: &InvocationContext, _context: &LocalContext, ) -> Result { Err(RuntimeCheckErrorKind::Unreachable("Allowance expr not allowed".to_string()).into()) } #[cfg(test)] mod test { use clarity_types::types::QualifiedContractIdentifier; use stacks_common::consts::CHAIN_ID_TESTNET; use stacks_common::types::StacksEpochId; use super::*; use crate::vm::contexts::GlobalContext; use crate::vm::costs::LimitedCostTracker; use crate::vm::database::MemoryBackingStore; use crate::vm::tests::test_clarity_versions; use crate::vm::{CallStack, ClarityVersion, ContractContext}; #[apply(test_clarity_versions)] fn non_function_application_in_eval_allowance( #[case] version: ClarityVersion, #[case] epoch: StacksEpochId, ) { let allowance_expr = SymbolicExpression::atom_value(Value::UInt(1)); // not a list let mut marf = MemoryBackingStore::new(); let mut global_context = GlobalContext::new( false, CHAIN_ID_TESTNET, marf.as_clarity_db(), LimitedCostTracker::new_free(), StacksEpochId::latest(), ); let contract_context = ContractContext::new( QualifiedContractIdentifier::transient(), ClarityVersion::Clarity3, ); let context = LocalContext::new(); let mut call_stack = CallStack::new(); let mut exec_state = ExecutionState { global_context: &mut global_context, call_stack: &mut call_stack, }; let invoke_ctx = InvocationContext { contract_context: &contract_context, sender: None, caller: None, sponsor: None, }; let err = eval_allowance(&allowance_expr, &mut exec_state, &invoke_ctx, &context).unwrap_err(); assert_eq!( VmExecutionError::RuntimeCheck(RuntimeCheckErrorKind::Unreachable( "Non functional application".to_string() )), err ); } }