From 611fb2039b236beca6f5f73024bc75f0d0738541 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Fri, 5 Dec 2025 13:02:08 +0100 Subject: [PATCH 01/10] export events in readonly endpoints --- clarity/src/vm/contexts.rs | 12 +- clarity/src/vm/mod.rs | 6 + stackslib/src/net/api/callreadonly.rs | 286 ++++++++++++------ stackslib/src/net/api/fastcallreadonly.rs | 145 +-------- stackslib/src/net/api/tests/callreadonly.rs | 62 ++++ .../src/net/api/tests/fastcallreadonly.rs | 62 ++++ stackslib/src/net/api/tests/mod.rs | 8 + 7 files changed, 352 insertions(+), 229 deletions(-) diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index e385c2b99ae..99d1dc9df40 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -14,9 +14,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::cell::RefCell; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::mem::replace; +use std::rc::Rc; use std::time::{Duration, Instant}; pub use clarity_types::errors::StackTrace; @@ -27,7 +29,7 @@ use serde_json::json; use stacks_common::types::chainstate::StacksBlockId; use stacks_common::types::StacksEpochId; -use super::EvalHook; +use super::{EvalHook, EventHook}; use crate::vm::ast::ContractAST; use crate::vm::callables::{DefinedFunction, FunctionIdentifier}; use crate::vm::contracts::Contract; @@ -231,6 +233,7 @@ pub struct GlobalContext<'a, 'hooks> { pub chain_id: u32, pub eval_hooks: Option>, pub execution_time_tracker: ExecutionTimeTracker, + pub event_hooks: Option>>>, } #[derive(Serialize, Deserialize, Clone)] @@ -1440,6 +1443,12 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { pub fn push_to_event_batch(&mut self, event: StacksTransactionEvent) { if let Some(batch) = self.global_context.event_batches.last_mut() { + if let Some(mut event_hooks) = self.global_context.event_hooks.take() { + for hook in event_hooks.iter_mut() { + hook.borrow_mut().on_event(&event); + } + self.global_context.event_hooks = Some(event_hooks); + } batch.events.push(event); } } @@ -1624,6 +1633,7 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { chain_id, eval_hooks: None, execution_time_tracker: ExecutionTimeTracker::NoTracking, + event_hooks: None, } } diff --git a/clarity/src/vm/mod.rs b/clarity/src/vm/mod.rs index 9a3799f92f8..bdd7d8d5ab3 100644 --- a/clarity/src/vm/mod.rs +++ b/clarity/src/vm/mod.rs @@ -160,6 +160,12 @@ pub trait EvalHook { fn did_complete(&mut self, _result: core::result::Result<&mut ExecutionResult, String>); } +/// EventHook defines an interface for hooks to execute during events generation. +pub trait EventHook { + // Called at every event generation + fn on_event(&mut self, _event: &StacksTransactionEvent); +} + fn lookup_variable( name: &str, context: &LocalContext, diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index 3f62d0ac210..dc55839fac3 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -14,22 +14,26 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::cell::RefCell; +use std::rc::Rc; + use clarity::vm::analysis::CheckErrorKind; use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; use clarity::vm::clarity::ClarityConnection; use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; use clarity::vm::errors::VmExecutionError::Unchecked; +use clarity::vm::events::StacksTransactionEvent; use clarity::vm::representations::{CONTRACT_NAME_REGEX_STRING, STANDARD_PRINCIPAL_REGEX_STRING}; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; -use clarity::vm::{ClarityName, ContractName, SymbolicExpression, Value}; +use clarity::vm::{ClarityName, ContractName, Environment, EventHook, SymbolicExpression, Value}; use regex::{Captures, Regex}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::types::net::PeerHost; use crate::net::http::{ parse_json, Error, HttpContentType, HttpNotFound, HttpRequest, HttpRequestContents, - HttpRequestPreamble, HttpResponse, HttpResponseContents, HttpResponsePayload, - HttpResponsePreamble, + HttpRequestPreamble, HttpRequestTimeout, HttpResponse, HttpResponseContents, + HttpResponsePayload, HttpResponsePreamble, }; use crate::net::httpcore::{ request, HttpRequestContentsExtensions as _, RPCRequestHandler, StacksHttpRequest, @@ -45,6 +49,13 @@ pub struct CallReadOnlyRequestBody { pub arguments: Vec, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CallReadOnlyEvent { + pub sender: String, + pub key: String, + pub value: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CallReadOnlyResponse { pub okay: bool, @@ -54,6 +65,25 @@ pub struct CallReadOnlyResponse { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub cause: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub events: Option>, +} + +#[derive(Clone)] +struct RPCCallReadOnlyEventHook { + events: Vec, +} + +impl EventHook for RPCCallReadOnlyEventHook { + fn on_event(&mut self, event: &StacksTransactionEvent) { + self.events.push(event.clone()); + } +} + +impl RPCCallReadOnlyEventHook { + fn new() -> Self { + Self { events: vec![] } + } } #[derive(Clone)] @@ -81,101 +111,18 @@ impl RPCCallReadOnlyRequestHandler { arguments: None, } } -} - -/// Decode the HTTP request -impl HttpRequest for RPCCallReadOnlyRequestHandler { - fn verb(&self) -> &'static str { - "POST" - } - - fn path_regex(&self) -> Regex { - Regex::new(&format!( - "^/v2/contracts/call-read/(?P
{})/(?P{})/(?P{})$", - *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_NAME_REGEX_STRING, *CLARITY_NAME_REGEX - )) - .unwrap() - } - - fn metrics_identifier(&self) -> &str { - "/v2/contracts/call-read/:principal/:contract_name/:func_name" - } - - /// Try to decode this request. - fn try_parse_request( - &mut self, - preamble: &HttpRequestPreamble, - captures: &Captures, - query: Option<&str>, - body: &[u8], - ) -> Result { - let content_len = preamble.get_content_length(); - if !(content_len > 0 && content_len < self.maximum_call_argument_size) { - return Err(Error::DecodeError(format!( - "Invalid Http request: invalid body length for CallReadOnly ({})", - content_len - ))); - } - - if preamble.content_type != Some(HttpContentType::JSON) { - return Err(Error::DecodeError( - "Invalid content-type: expected application/json".to_string(), - )); - } - - let contract_identifier = request::get_contract_address(captures, "address", "contract")?; - let function = request::get_clarity_name(captures, "function")?; - let body: CallReadOnlyRequestBody = serde_json::from_slice(body) - .map_err(|_e| Error::DecodeError("Failed to parse JSON body".into()))?; - - let sender = PrincipalData::parse(&body.sender) - .map_err(|_e| Error::DecodeError("Failed to parse sender principal".into()))?; - - let sponsor = if let Some(sponsor) = body.sponsor { - Some( - PrincipalData::parse(&sponsor) - .map_err(|_e| Error::DecodeError("Failed to parse sponsor principal".into()))?, - ) - } else { - None - }; - - // arguments must be valid Clarity values - let arguments = body - .arguments - .into_iter() - .map(|hex| Value::try_deserialize_hex_untyped(&hex).ok()) - .collect::>>() - .ok_or_else(|| Error::DecodeError("Failed to deserialize argument value".into()))?; - - self.contract_identifier = Some(contract_identifier); - self.function = Some(function); - self.sender = Some(sender); - self.sponsor = sponsor; - self.arguments = Some(arguments); - - Ok(HttpRequestContents::new().query_string(query)) - } -} - -/// Handle the HTTP request -impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { - /// Reset internal state - fn restart(&mut self) { - self.contract_identifier = None; - self.function = None; - self.sender = None; - self.sponsor = None; - self.arguments = None; - } - /// Make the response - fn try_handle_request( + pub fn execute_contract_function( &mut self, preamble: HttpRequestPreamble, contents: HttpRequestContents, node: &mut StacksNodeState, - ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { + override_cost_tracker: Option, + to_do: F, + ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> + where + F: FnOnce(&mut Environment), + { let tip = match node.load_stacks_chain_tip(&preamble, &contents) { Ok(tip) => tip, Err(error_resp) => { @@ -201,6 +148,8 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { .take() .ok_or(NetError::SendError("Missing `arguments`".into()))?; + let event_hook = Rc::new(RefCell::new(RPCCallReadOnlyEventHook::new())); + // run the read-only call let data_resp = node.with_node_state(|_network, sortdb, chainstate, _mempool, _rpc_args| { @@ -221,9 +170,13 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { |clarity_tx| { let epoch = clarity_tx.get_epoch(); let cost_track = clarity_tx.with_clarity_db_readonly(|clarity_db| { - LimitedCostTracker::new_mid_block( - mainnet, chain_id, cost_limit, clarity_db, epoch, - ) + if let Some(cost_tracker) = override_cost_tracker { + Ok(cost_tracker) + } else { + LimitedCostTracker::new_mid_block( + mainnet, chain_id, cost_limit, clarity_db, epoch, + ) + } })?; clarity_tx.with_readonly_clarity_env( @@ -233,6 +186,12 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { sponsor, cost_track, |env| { + let event_hook_clone = Rc::clone(&event_hook); + + env.global_context.event_hooks = Some(vec![event_hook_clone]); + + to_do(env); + // we want to execute any function as long as no actual writes are made as // opposed to be limited to purely calling `define-read-only` functions, // so use `read_only = false`. This broadens the number of functions that @@ -251,6 +210,33 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { ) }); + let events = { + let events: Vec = event_hook + .borrow() + .events + .iter() + .filter_map(|event| match event { + StacksTransactionEvent::SmartContractEvent(event_data) => { + if let Ok(event_hex) = event_data.value.serialize_to_hex() { + Some(CallReadOnlyEvent { + sender: event_data.key.0.to_string(), + key: event_data.key.1.clone(), + value: event_hex, + }) + } else { + None + } + } + _ => None, + }) + .collect(); + if events.is_empty() { + None + } else { + Some(events) + } + }; + // decode the response let data_resp = match data_resp { Ok(Some(Ok(data))) => { @@ -262,6 +248,7 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { okay: true, result: Some(format!("0x{}", hex_result)), cause: None, + events, } } Ok(Some(Err(e))) => match e { @@ -272,12 +259,22 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { okay: false, result: None, cause: Some("NotReadOnly".to_string()), + events: None, } } + Unchecked(CheckErrorKind::ExecutionTimeExpired) => { + return StacksHttpResponse::new_error( + &preamble, + &HttpRequestTimeout::new("ExecutionTime expired".to_string()), + ) + .try_into_contents() + .map_err(NetError::from) + } _ => CallReadOnlyResponse { okay: false, result: None, cause: Some(e.to_string()), + events: None, }, }, Ok(None) | Err(_) => { @@ -296,6 +293,103 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { } } +/// Decode the HTTP request +impl HttpRequest for RPCCallReadOnlyRequestHandler { + fn verb(&self) -> &'static str { + "POST" + } + + fn path_regex(&self) -> Regex { + Regex::new(&format!( + "^/v2/contracts/call-read/(?P
{})/(?P{})/(?P{})$", + *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_NAME_REGEX_STRING, *CLARITY_NAME_REGEX + )) + .unwrap() + } + + fn metrics_identifier(&self) -> &str { + "/v2/contracts/call-read/:principal/:contract_name/:func_name" + } + + /// Try to decode this request. + fn try_parse_request( + &mut self, + preamble: &HttpRequestPreamble, + captures: &Captures, + query: Option<&str>, + body: &[u8], + ) -> Result { + let content_len = preamble.get_content_length(); + if !(content_len > 0 && content_len < self.maximum_call_argument_size) { + return Err(Error::DecodeError(format!( + "Invalid Http request: invalid body length for CallReadOnly ({})", + content_len + ))); + } + + if preamble.content_type != Some(HttpContentType::JSON) { + return Err(Error::DecodeError( + "Invalid content-type: expected application/json".to_string(), + )); + } + + let contract_identifier = request::get_contract_address(captures, "address", "contract")?; + let function = request::get_clarity_name(captures, "function")?; + let body: CallReadOnlyRequestBody = serde_json::from_slice(body) + .map_err(|_e| Error::DecodeError("Failed to parse JSON body".into()))?; + + let sender = PrincipalData::parse(&body.sender) + .map_err(|_e| Error::DecodeError("Failed to parse sender principal".into()))?; + + let sponsor = if let Some(sponsor) = body.sponsor { + Some( + PrincipalData::parse(&sponsor) + .map_err(|_e| Error::DecodeError("Failed to parse sponsor principal".into()))?, + ) + } else { + None + }; + + // arguments must be valid Clarity values + let arguments = body + .arguments + .into_iter() + .map(|hex| Value::try_deserialize_hex_untyped(&hex).ok()) + .collect::>>() + .ok_or_else(|| Error::DecodeError("Failed to deserialize argument value".into()))?; + + self.contract_identifier = Some(contract_identifier); + self.function = Some(function); + self.sender = Some(sender); + self.sponsor = sponsor; + self.arguments = Some(arguments); + + Ok(HttpRequestContents::new().query_string(query)) + } +} + +/// Handle the HTTP request +impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { + /// Reset internal state + fn restart(&mut self) { + self.contract_identifier = None; + self.function = None; + self.sender = None; + self.sponsor = None; + self.arguments = None; + } + + /// Make the response + fn try_handle_request( + &mut self, + preamble: HttpRequestPreamble, + contents: HttpRequestContents, + node: &mut StacksNodeState, + ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { + self.execute_contract_function(preamble, contents, node, None, |_| {}) + } +} + /// Decode the HTTP response impl HttpResponse for RPCCallReadOnlyRequestHandler { fn try_parse_response( diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs index 67ca10529e2..0ca186a7c9e 100644 --- a/stackslib/src/net/api/fastcallreadonly.rs +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -15,14 +15,11 @@ use std::time::Duration; -use clarity::vm::analysis::CheckErrorKind; use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; -use clarity::vm::clarity::ClarityConnection; use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; -use clarity::vm::errors::VmExecutionError::Unchecked; use clarity::vm::representations::{CONTRACT_NAME_REGEX_STRING, STANDARD_PRINCIPAL_REGEX_STRING}; use clarity::vm::types::PrincipalData; -use clarity::vm::{ClarityName, ContractName, SymbolicExpression, Value}; +use clarity::vm::{ClarityName, ContractName, Value}; use regex::{Captures, Regex}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::types::net::PeerHost; @@ -31,13 +28,11 @@ use crate::net::api::callreadonly::{ CallReadOnlyRequestBody, CallReadOnlyResponse, RPCCallReadOnlyRequestHandler, }; use crate::net::http::{ - parse_json, Error, HttpContentType, HttpNotFound, HttpRequest, HttpRequestContents, - HttpRequestPreamble, HttpRequestTimeout, HttpResponse, HttpResponseContents, - HttpResponsePayload, HttpResponsePreamble, + parse_json, Error, HttpContentType, HttpRequest, HttpRequestContents, HttpRequestPreamble, + HttpResponse, HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, }; use crate::net::httpcore::{ request, HttpRequestContentsExtensions as _, RPCRequestHandler, StacksHttpRequest, - StacksHttpResponse, }; use crate::net::{Error as NetError, StacksNodeState, TipRequest}; @@ -177,131 +172,17 @@ impl RPCRequestHandler for RPCFastCallReadOnlyRequestHandler { contents: HttpRequestContents, node: &mut StacksNodeState, ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { - let tip = match node.load_stacks_chain_tip(&preamble, &contents) { - Ok(tip) => tip, - Err(error_resp) => { - return error_resp.try_into_contents().map_err(NetError::from); - } - }; - - let contract_identifier = self - .call_read_only_handler - .contract_identifier - .take() - .ok_or(NetError::SendError("Missing `contract_identifier`".into()))?; - let function = self - .call_read_only_handler - .function - .take() - .ok_or(NetError::SendError("Missing `function`".into()))?; - let sender = self - .call_read_only_handler - .sender - .take() - .ok_or(NetError::SendError("Missing `sender`".into()))?; - let sponsor = self.call_read_only_handler.sponsor.clone(); - let arguments = self - .call_read_only_handler - .arguments - .take() - .ok_or(NetError::SendError("Missing `arguments`".into()))?; - - // run the read-only call - let data_resp = - node.with_node_state(|_network, sortdb, chainstate, _mempool, _rpc_args| { - let args: Vec<_> = arguments - .iter() - .map(|x| SymbolicExpression::atom_value(x.clone())) - .collect(); - - let mainnet = chainstate.mainnet; - let chain_id = chainstate.chain_id; - - chainstate.maybe_read_only_clarity_tx( - &sortdb.index_handle_at_block(chainstate, &tip)?, - &tip, - |clarity_tx| { - clarity_tx.with_readonly_clarity_env( - mainnet, - chain_id, - sender, - sponsor, - LimitedCostTracker::new_free(), - |env| { - // cost tracking in read only calls is meamingful mainly from a security point of view - // for this reason we enforce max_execution_time when cost tracking is disabled/free - - env.global_context - .set_max_execution_time(self.read_only_max_execution_time); - - // we want to execute any function as long as no actual writes are made as - // opposed to be limited to purely calling `define-read-only` functions, - // so use `read_only = false`. This broadens the number of functions that - // can be called, and also circumvents limitations on `define-read-only` - // functions that can not use `contrac-call?`, even when calling other - // read-only functions - env.execute_contract( - &contract_identifier, - function.as_str(), - &args, - false, - ) - }, - ) - }, - ) - }); - - // decode the response - let data_resp = match data_resp { - Ok(Some(Ok(data))) => { - let hex_result = data - .serialize_to_hex() - .map_err(|e| NetError::SerializeError(format!("{:?}", &e)))?; - - CallReadOnlyResponse { - okay: true, - result: Some(format!("0x{}", hex_result)), - cause: None, - } - } - Ok(Some(Err(e))) => match e { - Unchecked(CheckErrorKind::CostBalanceExceeded(actual_cost, _)) - if actual_cost.write_count > 0 => - { - CallReadOnlyResponse { - okay: false, - result: None, - cause: Some("NotReadOnly".to_string()), - } - } - Unchecked(CheckErrorKind::ExecutionTimeExpired) => { - return StacksHttpResponse::new_error( - &preamble, - &HttpRequestTimeout::new("ExecutionTime expired".to_string()), - ) - .try_into_contents() - .map_err(NetError::from) - } - _ => CallReadOnlyResponse { - okay: false, - result: None, - cause: Some(e.to_string()), - }, + // + self.call_read_only_handler.execute_contract_function( + preamble, + contents, + node, + Some(LimitedCostTracker::new_free()), + |env| { + env.global_context + .set_max_execution_time(self.read_only_max_execution_time); }, - Ok(None) | Err(_) => { - return StacksHttpResponse::new_error( - &preamble, - &HttpNotFound::new("Chain tip not found".to_string()), - ) - .try_into_contents() - .map_err(NetError::from); - } - }; - - let preamble = HttpResponsePreamble::ok_json(&preamble); - let body = HttpResponseContents::try_from_json(&data_resp)?; - Ok((preamble, body)) + ) } } diff --git a/stackslib/src/net/api/tests/callreadonly.rs b/stackslib/src/net/api/tests/callreadonly.rs index 1ad5c149cdb..e88ab1b0436 100644 --- a/stackslib/src/net/api/tests/callreadonly.rs +++ b/stackslib/src/net/api/tests/callreadonly.rs @@ -181,6 +181,21 @@ fn test_try_make_response() { ); requests.push(request); + // call function generating events + let request = StacksHttpRequest::new_callreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "printer".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + let mut responses = test_rpc(function_name!(), requests); // confirmed tip @@ -257,4 +272,51 @@ fn test_try_make_response() { let (preamble, payload) = response.destruct(); assert_eq!(preamble.status_code, 404); + + // generated events + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let resp = response.decode_call_readonly_response().unwrap(); + + assert!(resp.okay); + assert!(resp.result.is_some()); + assert!(resp.cause.is_none()); + + // Ok(u1) + assert_eq!( + resp.result.unwrap(), + "0x070100000000000000000000000000000001" + ); + + let events = resp.events.unwrap(); + + assert_eq!(events.len(), 4); + assert_eq!( + events[0].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[0].key, "print"); + assert_eq!(events[0].value, "0000000000000000000000000000000064"); // 100 + assert_eq!( + events[1].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[1].key, "print"); + assert_eq!(events[1].value, "01000000000000000000000000000003e8"); // u1000 + assert_eq!( + events[2].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[2].key, "print"); + assert_eq!(events[2].value, "0d0000000474657374"); // "test" + assert_eq!( + events[3].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[3].key, "print"); + assert_eq!(events[3].value, "03"); // true } diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs index cd6c6814ec4..5c0123ac775 100644 --- a/stackslib/src/net/api/tests/fastcallreadonly.rs +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -201,6 +201,21 @@ fn test_try_make_response() { requests.push(request); + // call function generating events + let request = StacksHttpRequest::new_callreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "printer".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + let mut responses = test_rpc(function_name!(), requests); // confirmed tip @@ -277,6 +292,53 @@ fn test_try_make_response() { let (preamble, payload) = response.destruct(); assert_eq!(preamble.status_code, 404); + + // generated events + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let resp = response.decode_call_readonly_response().unwrap(); + + assert!(resp.okay); + assert!(resp.result.is_some()); + assert!(resp.cause.is_none()); + + // Ok(u1) + assert_eq!( + resp.result.unwrap(), + "0x070100000000000000000000000000000001" + ); + + let events = resp.events.unwrap(); + + assert_eq!(events.len(), 4); + assert_eq!( + events[0].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[0].key, "print"); + assert_eq!(events[0].value, "0000000000000000000000000000000064"); // 100 + assert_eq!( + events[1].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[1].key, "print"); + assert_eq!(events[1].value, "01000000000000000000000000000003e8"); // u1000 + assert_eq!( + events[2].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[2].key, "print"); + assert_eq!(events[2].value, "0d0000000474657374"); // "test" + assert_eq!( + events[3].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[3].key, "print"); + assert_eq!(events[3].value, "03"); // true } #[test] diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index aacc86a0fcd..85d38a453e9 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -160,6 +160,14 @@ const TEST_CONTRACT: &str = " max-neighbors: u32, hint-replicas: (list ) })) + + (define-read-only (printer) + (begin + (print 100) + (print u1000) + (print \"test\") + (print true) + (ok u1))) "; const TEST_CONTRACT_UNCONFIRMED: &str = " From cb376c9d96913e0e1eb5ebf039bf4743ecc3f627 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Fri, 5 Dec 2025 15:15:48 +0100 Subject: [PATCH 02/10] updated changelog and openapi --- CHANGELOG.md | 1 + .../schemas/read-only-function-result.schema.yaml | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d170fffd2..ba444d84fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE - Fixed an issue where `event.committed` was always equal to `true` in the block replay RPC endpoint - Added `result_hex` and `post_condition_aborted` to the block replay RPC endpoint - Added `--epoch ` flag to `clarity-cli` commands to specify the epoch context for evaluation. +- Added the `events` array to readonly endpoints output. ### Fixed diff --git a/docs/rpc/components/schemas/read-only-function-result.schema.yaml b/docs/rpc/components/schemas/read-only-function-result.schema.yaml index 890a90e3053..0c1d6591fe0 100644 --- a/docs/rpc/components/schemas/read-only-function-result.schema.yaml +++ b/docs/rpc/components/schemas/read-only-function-result.schema.yaml @@ -24,3 +24,18 @@ oneOf: cause: type: string description: A string representing the cause of the error. + events: + type: array + description: Contract events generated by the call + items: + type: object + properties: + sender: + type: string + description: The contract address generating the event. + key: + type: string + description: Who generated the event (generally "print"). + value: + type: string + description: Hex-encoded Clarity value of the event. From ddff52786d2745d91ab52ce2f988879948188e80 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Fri, 5 Dec 2025 16:48:38 +0100 Subject: [PATCH 03/10] fixed unit test --- stackslib/src/net/api/tests/getclaritymetadata.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stackslib/src/net/api/tests/getclaritymetadata.rs b/stackslib/src/net/api/tests/getclaritymetadata.rs index 649ba5aa71a..043ffa66213 100644 --- a/stackslib/src/net/api/tests/getclaritymetadata.rs +++ b/stackslib/src/net/api/tests/getclaritymetadata.rs @@ -309,7 +309,7 @@ fn test_try_make_response() { // contract size metadata let response = responses.remove(0); let resp = response.decode_clarity_metadata_response().unwrap(); - assert_eq!(resp.data, "1432"); + assert_eq!(resp.data, "1584"); // data map metadata let response = responses.remove(0); @@ -352,7 +352,7 @@ fn test_try_make_response() { // contract size metadata let response = responses.remove(0); let resp = response.decode_clarity_metadata_response().unwrap(); - assert_eq!(resp.data, "1432"); + assert_eq!(resp.data, "1584"); // unknwnon data var let response = responses.remove(0); From 3b85f12137b5fa086558c173f3a84bfab5b42729 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Wed, 10 Dec 2025 07:42:12 +0100 Subject: [PATCH 04/10] refactored events gathering --- clarity/src/vm/clarity.rs | 40 +++++++++++++++++++++++++++ clarity/src/vm/contexts.rs | 12 +------- clarity/src/vm/mod.rs | 6 ---- stackslib/src/net/api/callreadonly.rs | 35 ++++------------------- 4 files changed, 46 insertions(+), 47 deletions(-) diff --git a/clarity/src/vm/clarity.rs b/clarity/src/vm/clarity.rs index 0bbd6a7d0a6..8594201a7a5 100644 --- a/clarity/src/vm/clarity.rs +++ b/clarity/src/vm/clarity.rs @@ -204,6 +204,46 @@ pub trait ClarityConnection { (result, db) }) } + + #[allow(clippy::too_many_arguments)] + fn with_readonly_clarity_env_and_fill_events( + &mut self, + mainnet: bool, + chain_id: u32, + sender: PrincipalData, + sponsor: Option, + cost_track: LimitedCostTracker, + events: &mut Vec, + to_do: F, + ) -> Result + where + F: FnOnce(&mut Environment) -> Result, + { + let epoch_id = self.get_epoch(); + let clarity_version = ClarityVersion::default_for_epoch(epoch_id); + self.with_clarity_db_readonly_owned(|clarity_db| { + let initial_context = + ContractContext::new(QualifiedContractIdentifier::transient(), clarity_version); + let mut vm_env = OwnedEnvironment::new_cost_limited( + mainnet, chain_id, clarity_db, cost_track, epoch_id, + ); + let result = vm_env + .execute_in_env(sender, sponsor, Some(initial_context), to_do) + .map(|(result, _, transaction_events)| { + events.extend(transaction_events); + result + }); + // this expect is allowed, if the database has escaped this context, then it is no longer sane + // and we must crash + #[allow(clippy::expect_used)] + let (db, _) = { + vm_env + .destruct() + .expect("Failed to recover database reference after executing transaction") + }; + (result, db) + }) + } } pub trait TransactionConnection: ClarityConnection { diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 99d1dc9df40..e385c2b99ae 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -14,11 +14,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::cell::RefCell; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::mem::replace; -use std::rc::Rc; use std::time::{Duration, Instant}; pub use clarity_types::errors::StackTrace; @@ -29,7 +27,7 @@ use serde_json::json; use stacks_common::types::chainstate::StacksBlockId; use stacks_common::types::StacksEpochId; -use super::{EvalHook, EventHook}; +use super::EvalHook; use crate::vm::ast::ContractAST; use crate::vm::callables::{DefinedFunction, FunctionIdentifier}; use crate::vm::contracts::Contract; @@ -233,7 +231,6 @@ pub struct GlobalContext<'a, 'hooks> { pub chain_id: u32, pub eval_hooks: Option>, pub execution_time_tracker: ExecutionTimeTracker, - pub event_hooks: Option>>>, } #[derive(Serialize, Deserialize, Clone)] @@ -1443,12 +1440,6 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { pub fn push_to_event_batch(&mut self, event: StacksTransactionEvent) { if let Some(batch) = self.global_context.event_batches.last_mut() { - if let Some(mut event_hooks) = self.global_context.event_hooks.take() { - for hook in event_hooks.iter_mut() { - hook.borrow_mut().on_event(&event); - } - self.global_context.event_hooks = Some(event_hooks); - } batch.events.push(event); } } @@ -1633,7 +1624,6 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { chain_id, eval_hooks: None, execution_time_tracker: ExecutionTimeTracker::NoTracking, - event_hooks: None, } } diff --git a/clarity/src/vm/mod.rs b/clarity/src/vm/mod.rs index bdd7d8d5ab3..9a3799f92f8 100644 --- a/clarity/src/vm/mod.rs +++ b/clarity/src/vm/mod.rs @@ -160,12 +160,6 @@ pub trait EvalHook { fn did_complete(&mut self, _result: core::result::Result<&mut ExecutionResult, String>); } -/// EventHook defines an interface for hooks to execute during events generation. -pub trait EventHook { - // Called at every event generation - fn on_event(&mut self, _event: &StacksTransactionEvent); -} - fn lookup_variable( name: &str, context: &LocalContext, diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index dc55839fac3..b4e2a0abf10 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -14,9 +14,6 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::cell::RefCell; -use std::rc::Rc; - use clarity::vm::analysis::CheckErrorKind; use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; use clarity::vm::clarity::ClarityConnection; @@ -25,7 +22,7 @@ use clarity::vm::errors::VmExecutionError::Unchecked; use clarity::vm::events::StacksTransactionEvent; use clarity::vm::representations::{CONTRACT_NAME_REGEX_STRING, STANDARD_PRINCIPAL_REGEX_STRING}; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; -use clarity::vm::{ClarityName, ContractName, Environment, EventHook, SymbolicExpression, Value}; +use clarity::vm::{ClarityName, ContractName, Environment, SymbolicExpression, Value}; use regex::{Captures, Regex}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::types::net::PeerHost; @@ -69,23 +66,6 @@ pub struct CallReadOnlyResponse { pub events: Option>, } -#[derive(Clone)] -struct RPCCallReadOnlyEventHook { - events: Vec, -} - -impl EventHook for RPCCallReadOnlyEventHook { - fn on_event(&mut self, event: &StacksTransactionEvent) { - self.events.push(event.clone()); - } -} - -impl RPCCallReadOnlyEventHook { - fn new() -> Self { - Self { events: vec![] } - } -} - #[derive(Clone)] pub struct RPCCallReadOnlyRequestHandler { pub maximum_call_argument_size: u32, @@ -148,7 +128,7 @@ impl RPCCallReadOnlyRequestHandler { .take() .ok_or(NetError::SendError("Missing `arguments`".into()))?; - let event_hook = Rc::new(RefCell::new(RPCCallReadOnlyEventHook::new())); + let mut events: Vec = vec![]; // run the read-only call let data_resp = @@ -179,17 +159,14 @@ impl RPCCallReadOnlyRequestHandler { } })?; - clarity_tx.with_readonly_clarity_env( + clarity_tx.with_readonly_clarity_env_and_fill_events( mainnet, chain_id, sender, sponsor, cost_track, + &mut events, |env| { - let event_hook_clone = Rc::clone(&event_hook); - - env.global_context.event_hooks = Some(vec![event_hook_clone]); - to_do(env); // we want to execute any function as long as no actual writes are made as @@ -211,9 +188,7 @@ impl RPCCallReadOnlyRequestHandler { }); let events = { - let events: Vec = event_hook - .borrow() - .events + let events: Vec = events .iter() .filter_map(|event| match event { StacksTransactionEvent::SmartContractEvent(event_data) => { From cbf0ebb9b11564a1179635fd5b8a54bb7aec2d9a Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Wed, 10 Dec 2025 11:10:18 +0100 Subject: [PATCH 05/10] added keep_event_batches --- clarity/src/vm/contexts.rs | 15 ++++++++++++++- stackslib/src/net/api/callreadonly.rs | 2 ++ stackslib/src/net/api/tests/callreadonly.rs | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index e385c2b99ae..7b93e24bc0e 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -231,6 +231,8 @@ pub struct GlobalContext<'a, 'hooks> { pub chain_id: u32, pub eval_hooks: Option>, pub execution_time_tracker: ExecutionTimeTracker, + /// avoid events to be cleared too early while in read only mode + pub keep_event_batches: bool, } #[derive(Serialize, Deserialize, Clone)] @@ -1261,7 +1263,17 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { }; if make_read_only { + let mut event_batches_clone = None; + if self.global_context.keep_event_batches { + event_batches_clone = Some(self.global_context.event_batches.clone()); + } + self.global_context.roll_back()?; + + if let Some(event_batches_clone) = event_batches_clone { + self.global_context.event_batches = event_batches_clone + } + result } else { self.global_context.handle_tx_result(result, allow_private) @@ -1624,6 +1636,7 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { chain_id, eval_hooks: None, execution_time_tracker: ExecutionTimeTracker::NoTracking, + keep_event_batches: false, } } @@ -1801,7 +1814,7 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { let out_batch = match self.event_batches.last_mut() { Some(tail_back) => { tail_back.events.append(&mut event_batch.events); - None + Some(tail_back.clone()) } None => Some(event_batch), }; diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index b4e2a0abf10..f7c10bb0fc3 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -167,6 +167,8 @@ impl RPCCallReadOnlyRequestHandler { cost_track, &mut events, |env| { + env.global_context.keep_event_batches = true; + to_do(env); // we want to execute any function as long as no actual writes are made as diff --git a/stackslib/src/net/api/tests/callreadonly.rs b/stackslib/src/net/api/tests/callreadonly.rs index e88ab1b0436..6a6f0a245fa 100644 --- a/stackslib/src/net/api/tests/callreadonly.rs +++ b/stackslib/src/net/api/tests/callreadonly.rs @@ -1,5 +1,5 @@ // Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation -// Copyright (C) 2020-2023 Stacks Open Internet Foundation +// Copyright (C) 2020-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 From 126afd81094ba4bfb1eb29a2478d1ac3d81d9443 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Fri, 12 Dec 2025 15:47:59 +0100 Subject: [PATCH 06/10] refacotred events management --- clarity/src/vm/clarity.rs | 10 +++-- stackslib/src/net/api/callreadonly.rs | 57 +++++++++++++-------------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/clarity/src/vm/clarity.rs b/clarity/src/vm/clarity.rs index 8594201a7a5..d2be247b563 100644 --- a/clarity/src/vm/clarity.rs +++ b/clarity/src/vm/clarity.rs @@ -213,15 +213,15 @@ pub trait ClarityConnection { sender: PrincipalData, sponsor: Option, cost_track: LimitedCostTracker, - events: &mut Vec, to_do: F, - ) -> Result + ) -> Result<(R, Vec), VmExecutionError> where F: FnOnce(&mut Environment) -> Result, { let epoch_id = self.get_epoch(); let clarity_version = ClarityVersion::default_for_epoch(epoch_id); - self.with_clarity_db_readonly_owned(|clarity_db| { + let mut events = vec![]; + let result_and_db = self.with_clarity_db_readonly_owned(|clarity_db| { let initial_context = ContractContext::new(QualifiedContractIdentifier::transient(), clarity_version); let mut vm_env = OwnedEnvironment::new_cost_limited( @@ -242,7 +242,9 @@ pub trait ClarityConnection { .expect("Failed to recover database reference after executing transaction") }; (result, db) - }) + })?; + + Ok((result_and_db, events)) } } diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index f7c10bb0fc3..257c003b847 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -128,8 +128,6 @@ impl RPCCallReadOnlyRequestHandler { .take() .ok_or(NetError::SendError("Missing `arguments`".into()))?; - let mut events: Vec = vec![]; - // run the read-only call let data_resp = node.with_node_state(|_network, sortdb, chainstate, _mempool, _rpc_args| { @@ -165,7 +163,6 @@ impl RPCCallReadOnlyRequestHandler { sender, sponsor, cost_track, - &mut events, |env| { env.global_context.keep_event_batches = true; @@ -189,38 +186,38 @@ impl RPCCallReadOnlyRequestHandler { ) }); - let events = { - let events: Vec = events - .iter() - .filter_map(|event| match event { - StacksTransactionEvent::SmartContractEvent(event_data) => { - if let Ok(event_hex) = event_data.value.serialize_to_hex() { - Some(CallReadOnlyEvent { - sender: event_data.key.0.to_string(), - key: event_data.key.1.clone(), - value: event_hex, - }) - } else { - None - } - } - _ => None, - }) - .collect(); - if events.is_empty() { - None - } else { - Some(events) - } - }; - - // decode the response + // decode the response (and serialize the events) let data_resp = match data_resp { - Ok(Some(Ok(data))) => { + Ok(Some(Ok((data, events)))) => { let hex_result = data .serialize_to_hex() .map_err(|e| NetError::SerializeError(format!("{:?}", &e)))?; + let events = { + let events: Vec = events + .iter() + .filter_map(|event| match event { + StacksTransactionEvent::SmartContractEvent(event_data) => { + if let Ok(event_hex) = event_data.value.serialize_to_hex() { + Some(CallReadOnlyEvent { + sender: event_data.key.0.to_string(), + key: event_data.key.1.clone(), + value: event_hex, + }) + } else { + None + } + } + _ => None, + }) + .collect(); + if events.is_empty() { + None + } else { + Some(events) + } + }; + CallReadOnlyResponse { okay: true, result: Some(format!("0x{}", hex_result)), From 29388952c2a3c94e9c9409c240d1ec6c55e26f71 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Fri, 12 Dec 2025 16:17:00 +0100 Subject: [PATCH 07/10] refactored contract calls events management --- clarity/src/vm/clarity.rs | 42 ---------------- clarity/src/vm/contexts.rs | 72 ++++++++++++++++++++------- stackslib/src/net/api/callreadonly.rs | 6 +-- 3 files changed, 55 insertions(+), 65 deletions(-) diff --git a/clarity/src/vm/clarity.rs b/clarity/src/vm/clarity.rs index d2be247b563..0bbd6a7d0a6 100644 --- a/clarity/src/vm/clarity.rs +++ b/clarity/src/vm/clarity.rs @@ -204,48 +204,6 @@ pub trait ClarityConnection { (result, db) }) } - - #[allow(clippy::too_many_arguments)] - fn with_readonly_clarity_env_and_fill_events( - &mut self, - mainnet: bool, - chain_id: u32, - sender: PrincipalData, - sponsor: Option, - cost_track: LimitedCostTracker, - to_do: F, - ) -> Result<(R, Vec), VmExecutionError> - where - F: FnOnce(&mut Environment) -> Result, - { - let epoch_id = self.get_epoch(); - let clarity_version = ClarityVersion::default_for_epoch(epoch_id); - let mut events = vec![]; - let result_and_db = self.with_clarity_db_readonly_owned(|clarity_db| { - let initial_context = - ContractContext::new(QualifiedContractIdentifier::transient(), clarity_version); - let mut vm_env = OwnedEnvironment::new_cost_limited( - mainnet, chain_id, clarity_db, cost_track, epoch_id, - ); - let result = vm_env - .execute_in_env(sender, sponsor, Some(initial_context), to_do) - .map(|(result, _, transaction_events)| { - events.extend(transaction_events); - result - }); - // this expect is allowed, if the database has escaped this context, then it is no longer sane - // and we must crash - #[allow(clippy::expect_used)] - let (db, _) = { - vm_env - .destruct() - .expect("Failed to recover database reference after executing transaction") - }; - (result, db) - })?; - - Ok((result_and_db, events)) - } } pub trait TransactionConnection: ClarityConnection { diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 7b93e24bc0e..808e025a2e4 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -231,8 +231,6 @@ pub struct GlobalContext<'a, 'hooks> { pub chain_id: u32, pub eval_hooks: Option>, pub execution_time_tracker: ExecutionTimeTracker, - /// avoid events to be cleared too early while in read only mode - pub keep_event_batches: bool, } #[derive(Serialize, Deserialize, Clone)] @@ -1125,6 +1123,16 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { &self.global_context.epoch_id } + pub fn execute_contract_with_events( + &mut self, + contract: &QualifiedContractIdentifier, + tx_name: &str, + args: &[SymbolicExpression], + read_only: bool, + ) -> Result<(Value, Vec), VmExecutionError> { + self.inner_execute_contract(contract, tx_name, args, read_only, false) + } + pub fn execute_contract( &mut self, contract: &QualifiedContractIdentifier, @@ -1132,7 +1140,10 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { args: &[SymbolicExpression], read_only: bool, ) -> Result { - self.inner_execute_contract(contract, tx_name, args, read_only, false) + match self.inner_execute_contract(contract, tx_name, args, read_only, false) { + Ok((value, _events)) => Ok(value), + Err(e) => Err(e), + } } /// This method is exposed for callers that need to invoke a private method directly. @@ -1145,7 +1156,10 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { args: &[SymbolicExpression], read_only: bool, ) -> Result { - self.inner_execute_contract(contract, tx_name, args, read_only, true) + match self.inner_execute_contract(contract, tx_name, args, read_only, true) { + Ok((value, _events)) => Ok(value), + Err(e) => Err(e), + } } /// This method handles actual execution of contract-calls on a contract. @@ -1161,7 +1175,7 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { args: &[SymbolicExpression], read_only: bool, allow_private: bool, - ) -> Result { + ) -> Result<(Value, Vec), VmExecutionError> { let contract_size = self .global_context .database @@ -1209,11 +1223,11 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { return Err(CheckErrorKind::CircularReference(vec![func_identifier.to_string()]).into()) } self.call_stack.insert(&func_identifier, true); - let res = self.execute_function_as_transaction(&func, &args, Some(&contract.contract_context), allow_private); + let res = self.execute_function_as_transaction_and_events(&func, &args, Some(&contract.contract_context), allow_private); self.call_stack.remove(&func_identifier, true)?; match res { - Ok(value) => { + Ok((value, events)) => { if let Some(handler) = self.global_context.database.get_cc_special_cases_handler() { handler( self.global_context, @@ -1225,7 +1239,7 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { &value )?; } - Ok(value) + Ok((value, events)) }, Err(e) => Err(e) } @@ -1239,6 +1253,23 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { next_contract_context: Option<&ContractContext>, allow_private: bool, ) -> Result { + let (value, _events) = self.execute_function_as_transaction_and_events( + function, + args, + next_contract_context, + allow_private, + )?; + + Ok(value) + } + + pub fn execute_function_as_transaction_and_events( + &mut self, + function: &DefinedFunction, + args: &[Value], + next_contract_context: Option<&ContractContext>, + allow_private: bool, + ) -> Result<(Value, Vec), VmExecutionError> { let make_read_only = function.is_read_only(); if make_read_only { @@ -1262,21 +1293,25 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { function.execute_apply(args, &mut nested_env) }; - if make_read_only { - let mut event_batches_clone = None; - if self.global_context.keep_event_batches { - event_batches_clone = Some(self.global_context.event_batches.clone()); - } + // retrieve all the events + let mut events = vec![]; + self.global_context + .event_batches + .iter() + .for_each(|event_batch| { + events.extend(event_batch.events.clone()); + }); + let result = if make_read_only { self.global_context.roll_back()?; - - if let Some(event_batches_clone) = event_batches_clone { - self.global_context.event_batches = event_batches_clone - } - result } else { self.global_context.handle_tx_result(result, allow_private) + }; + + match result { + Ok(result) => Ok((result, events)), + Err(e) => Err(e), } } @@ -1636,7 +1671,6 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { chain_id, eval_hooks: None, execution_time_tracker: ExecutionTimeTracker::NoTracking, - keep_event_batches: false, } } diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index 257c003b847..13dceaad965 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -157,15 +157,13 @@ impl RPCCallReadOnlyRequestHandler { } })?; - clarity_tx.with_readonly_clarity_env_and_fill_events( + clarity_tx.with_readonly_clarity_env( mainnet, chain_id, sender, sponsor, cost_track, |env| { - env.global_context.keep_event_batches = true; - to_do(env); // we want to execute any function as long as no actual writes are made as @@ -174,7 +172,7 @@ impl RPCCallReadOnlyRequestHandler { // can be called, and also circumvents limitations on `define-read-only` // functions that can not use `contrac-call?`, even when calling other // read-only functions - env.execute_contract( + env.execute_contract_with_events( &contract_identifier, function.as_str(), &args, From 9b45a03985fd0514bfc6ae995ce14ecbfe8f9091 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Fri, 12 Dec 2025 16:20:36 +0100 Subject: [PATCH 08/10] reverted change for event batch --- clarity/src/vm/contexts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 808e025a2e4..64dab3e1c21 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -1848,7 +1848,7 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> { let out_batch = match self.event_batches.last_mut() { Some(tail_back) => { tail_back.events.append(&mut event_batch.events); - Some(tail_back.clone()) + None } None => Some(event_batch), }; From dfdb2fa72b1172be0585ab428ce4ddc81333f25b Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Mon, 15 Dec 2025 12:21:48 +0100 Subject: [PATCH 09/10] added test for inner events --- stackslib/src/net/api/tests/callreadonly.rs | 20 ++++++++++++++++++- .../src/net/api/tests/fastcallreadonly.rs | 20 ++++++++++++++++++- .../src/net/api/tests/getclaritymetadata.rs | 4 ++-- stackslib/src/net/api/tests/mod.rs | 9 ++++++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/stackslib/src/net/api/tests/callreadonly.rs b/stackslib/src/net/api/tests/callreadonly.rs index 6a6f0a245fa..0f4864880ae 100644 --- a/stackslib/src/net/api/tests/callreadonly.rs +++ b/stackslib/src/net/api/tests/callreadonly.rs @@ -294,7 +294,7 @@ fn test_try_make_response() { let events = resp.events.unwrap(); - assert_eq!(events.len(), 4); + assert_eq!(events.len(), 7); assert_eq!( events[0].sender, "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" @@ -319,4 +319,22 @@ fn test_try_make_response() { ); assert_eq!(events[3].key, "print"); assert_eq!(events[3].value, "03"); // true + assert_eq!( + events[4].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[4].key, "print"); + assert_eq!(events[4].value, "0d0000000578797a7a79"); // "xyzzy" + assert_eq!( + events[5].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[5].key, "print"); + assert_eq!(events[5].value, "0d0000000578797a7a77"); // "xyzzw" + assert_eq!( + events[6].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[6].key, "print"); + assert_eq!(events[6].value, "0d0000000471757578"); // "quux" } diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs index 5c0123ac775..b2a7111a893 100644 --- a/stackslib/src/net/api/tests/fastcallreadonly.rs +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -314,7 +314,7 @@ fn test_try_make_response() { let events = resp.events.unwrap(); - assert_eq!(events.len(), 4); + assert_eq!(events.len(), 7); assert_eq!( events[0].sender, "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" @@ -339,6 +339,24 @@ fn test_try_make_response() { ); assert_eq!(events[3].key, "print"); assert_eq!(events[3].value, "03"); // true + assert_eq!( + events[4].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[4].key, "print"); + assert_eq!(events[4].value, "0d0000000578797a7a79"); // "xyzzy" + assert_eq!( + events[5].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[5].key, "print"); + assert_eq!(events[5].value, "0d0000000578797a7a77"); // "xyzzw" + assert_eq!( + events[6].sender, + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world" + ); + assert_eq!(events[6].key, "print"); + assert_eq!(events[6].value, "0d0000000471757578"); // "quux" } #[test] diff --git a/stackslib/src/net/api/tests/getclaritymetadata.rs b/stackslib/src/net/api/tests/getclaritymetadata.rs index 043ffa66213..fe6e2d63c53 100644 --- a/stackslib/src/net/api/tests/getclaritymetadata.rs +++ b/stackslib/src/net/api/tests/getclaritymetadata.rs @@ -309,7 +309,7 @@ fn test_try_make_response() { // contract size metadata let response = responses.remove(0); let resp = response.decode_clarity_metadata_response().unwrap(); - assert_eq!(resp.data, "1584"); + assert_eq!(resp.data, "1786"); // data map metadata let response = responses.remove(0); @@ -352,7 +352,7 @@ fn test_try_make_response() { // contract size metadata let response = responses.remove(0); let resp = response.decode_clarity_metadata_response().unwrap(); - assert_eq!(resp.data, "1584"); + assert_eq!(resp.data, "1786"); // unknwnon data var let response = responses.remove(0); diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index 85d38a453e9..9ed65732081 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -161,13 +161,20 @@ const TEST_CONTRACT: &str = " hint-replicas: (list ) })) + (define-public (quux) + (begin (print \"quux\") (ok u1))) + (define-public (xyzzw) + (begin (print \"xyzzw\") (quux))) + (define-public (xyzzy) + (begin (print \"xyzzy\") (xyzzw))) + (define-read-only (printer) (begin (print 100) (print u1000) (print \"test\") (print true) - (ok u1))) + (xyzzy))) "; const TEST_CONTRACT_UNCONFIRMED: &str = " From f0687333da09ea107ee565daed99a23bebaf531f Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Mon, 15 Dec 2025 12:50:15 +0100 Subject: [PATCH 10/10] nit fixes --- clarity/src/vm/contexts.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 64dab3e1c21..97dabc0573d 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -1140,10 +1140,8 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { args: &[SymbolicExpression], read_only: bool, ) -> Result { - match self.inner_execute_contract(contract, tx_name, args, read_only, false) { - Ok((value, _events)) => Ok(value), - Err(e) => Err(e), - } + self.inner_execute_contract(contract, tx_name, args, read_only, false) + .map(|(value, _)| value) } /// This method is exposed for callers that need to invoke a private method directly. @@ -1156,10 +1154,8 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> { args: &[SymbolicExpression], read_only: bool, ) -> Result { - match self.inner_execute_contract(contract, tx_name, args, read_only, true) { - Ok((value, _events)) => Ok(value), - Err(e) => Err(e), - } + self.inner_execute_contract(contract, tx_name, args, read_only, true) + .map(|(value, _)| value) } /// This method handles actual execution of contract-calls on a contract.