From 830707b3798fdf2f9493288ce25acc085458f018 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Tue, 17 Jun 2025 11:46:21 +0200 Subject: [PATCH 01/16] added implicit max_execution_time when executin readonly calls in cost-free mode --- stackslib/src/net/api/callreadonly.rs | 99 ++++++++++++--- stackslib/src/net/api/mod.rs | 1 + stackslib/src/net/api/tests/callreadonly.rs | 133 +++++++++++++++++++- stackslib/src/net/api/tests/mod.rs | 40 ++++++ stackslib/src/net/connection.rs | 5 + stackslib/src/net/httpcore.rs | 8 ++ 6 files changed, 266 insertions(+), 20 deletions(-) diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index b2c863d2bb..ce9f80edad 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -14,12 +14,14 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::time::Duration; + use clarity::vm::analysis::CheckErrors; use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; use clarity::vm::clarity::ClarityConnection; -use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; +use clarity::vm::costs::{CostErrors, ExecutionCost, LimitedCostTracker}; +use clarity::vm::errors::Error as ClarityRuntimeError; use clarity::vm::errors::Error::Unchecked; -use clarity::vm::errors::{Error as ClarityRuntimeError, InterpreterError}; 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}; @@ -68,10 +70,16 @@ pub struct RPCCallReadOnlyRequestHandler { pub sender: Option, pub sponsor: Option, pub arguments: Option>, + + read_only_max_execution_time: Duration, } impl RPCCallReadOnlyRequestHandler { - pub fn new(maximum_call_argument_size: u32, read_only_call_limit: ExecutionCost) -> Self { + pub fn new( + maximum_call_argument_size: u32, + read_only_call_limit: ExecutionCost, + read_only_max_execution_time: Duration, + ) -> Self { Self { maximum_call_argument_size, read_only_call_limit, @@ -80,6 +88,7 @@ impl RPCCallReadOnlyRequestHandler { sender: None, sponsor: None, arguments: None, + read_only_max_execution_time, } } } @@ -184,6 +193,12 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { } }; + let cost_tracker = contents + .get_query_args() + .get("cost_tracker") + .map(|cost_tracker| cost_tracker.as_str().into()) + .unwrap_or(CostTrackerRequest::Limited); + let contract_identifier = self .contract_identifier .take() @@ -216,20 +231,27 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { cost_limit.write_length = 0; cost_limit.write_count = 0; + let mut enforce_max_execution_time = false; + chainstate.maybe_read_only_clarity_tx( &sortdb.index_handle_at_block(chainstate, &tip)?, &tip, |clarity_tx| { let epoch = clarity_tx.get_epoch(); let cost_track = clarity_tx - .with_clarity_db_readonly(|clarity_db| { - LimitedCostTracker::new_mid_block( + .with_clarity_db_readonly(|clarity_db| match cost_tracker { + CostTrackerRequest::Limited => LimitedCostTracker::new_mid_block( mainnet, chain_id, cost_limit, clarity_db, epoch, - ) + ), + CostTrackerRequest::Free => { + enforce_max_execution_time = true; + Ok(LimitedCostTracker::new_free()) + } + CostTrackerRequest::Invalid => { + Err(CostErrors::Expect("Invalid cost tracker".into())) + } }) - .map_err(|_| { - ClarityRuntimeError::from(InterpreterError::CostContractLoadFailure) - })?; + .map_err(|e| ClarityRuntimeError::from(e))?; let clarity_version = clarity_tx .with_analysis_db_readonly(|analysis_db| { @@ -250,6 +272,13 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { sponsor, cost_track, |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 + if enforce_max_execution_time { + 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 @@ -326,6 +355,38 @@ impl HttpResponse for RPCCallReadOnlyRequestHandler { } } +/// All representations of the `cost_tracker=` query parameter value +#[derive(Debug, Clone, PartialEq)] +pub enum CostTrackerRequest { + Limited, + Free, + Invalid, +} + +impl CostTrackerRequest {} + +impl std::fmt::Display for CostTrackerRequest { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Limited => write!(f, "limited"), + Self::Free => write!(f, "free"), + Self::Invalid => write!(f, "invalid"), + } + } +} + +impl From<&str> for CostTrackerRequest { + fn from(s: &str) -> CostTrackerRequest { + if s == "limited" || s == "" { + CostTrackerRequest::Limited + } else if s == "free" { + CostTrackerRequest::Free + } else { + CostTrackerRequest::Invalid + } + } +} + impl StacksHttpRequest { /// Make a new request to run a read-only function pub fn new_callreadonlyfunction( @@ -337,6 +398,7 @@ impl StacksHttpRequest { function_name: ClarityName, function_args: Vec, tip_req: TipRequest, + cost_tracker: CostTrackerRequest, ) -> StacksHttpRequest { StacksHttpRequest::new_for_peer( host, @@ -345,14 +407,17 @@ impl StacksHttpRequest { "/v2/contracts/call-read/{}/{}/{}", &contract_addr, &contract_name, &function_name ), - HttpRequestContents::new().for_tip(tip_req).payload_json( - serde_json::to_value(CallReadOnlyRequestBody { - sender: sender.to_string(), - sponsor: sponsor.map(|s| s.to_string()), - arguments: function_args.into_iter().map(|v| v.to_string()).collect(), - }) - .expect("FATAL: failed to encode infallible data"), - ), + HttpRequestContents::new() + .for_tip(tip_req) + .query_arg("cost_tracker".to_string(), cost_tracker.to_string()) + .payload_json( + serde_json::to_value(CallReadOnlyRequestBody { + sender: sender.to_string(), + sponsor: sponsor.map(|s| s.to_string()), + arguments: function_args.into_iter().map(|v| v.to_string()).collect(), + }) + .expect("FATAL: failed to encode infallible data"), + ), ) .expect("FATAL: failed to construct request from infallible data") } diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index 560e2bd23b..07009018e3 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -73,6 +73,7 @@ impl StacksHttp { self.register_rpc_endpoint(callreadonly::RPCCallReadOnlyRequestHandler::new( self.maximum_call_argument_size, self.read_only_call_limit.clone(), + self.read_only_max_execution_time, )); self.register_rpc_endpoint(getaccount::RPCGetAccountRequestHandler::new()); self.register_rpc_endpoint(getattachment::RPCGetAttachmentRequestHandler::new()); diff --git a/stackslib/src/net/api/tests/callreadonly.rs b/stackslib/src/net/api/tests/callreadonly.rs index 3ca5f473f2..d1b11a6629 100644 --- a/stackslib/src/net/api/tests/callreadonly.rs +++ b/stackslib/src/net/api/tests/callreadonly.rs @@ -15,14 +15,16 @@ // along with this program. If not, see . use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; use clarity::types::chainstate::StacksBlockId; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, StacksAddressExtensions}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::types::Address; -use super::test_rpc; +use super::{test_rpc, test_rpc_with_config}; use crate::core::BLOCK_LIMIT_MAINNET_21; +use crate::net::api::callreadonly::CostTrackerRequest; use crate::net::api::*; use crate::net::connection::ConnectionOptions; use crate::net::httpcore::{ @@ -47,6 +49,7 @@ fn test_try_parse_request() { "ro-test".try_into().unwrap(), vec![], TipRequest::SpecificTip(StacksBlockId([0x22; 32])), + CostTrackerRequest::Limited, ); assert_eq!( request.contents().tip_request(), @@ -58,8 +61,11 @@ fn test_try_parse_request() { debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); - let mut handler = - callreadonly::RPCCallReadOnlyRequestHandler::new(4096, BLOCK_LIMIT_MAINNET_21); + let mut handler = callreadonly::RPCCallReadOnlyRequestHandler::new( + 4096, + BLOCK_LIMIT_MAINNET_21, + Duration::from_secs(30), + ); let mut parsed_request = http .handle_try_parse_request( &mut handler, @@ -119,6 +125,7 @@ fn test_try_make_response() { "ro-confirmed".try_into().unwrap(), vec![], TipRequest::UseLatestAnchoredTip, + CostTrackerRequest::Limited, ); requests.push(request); @@ -134,6 +141,7 @@ fn test_try_make_response() { "ro-test".try_into().unwrap(), vec![], TipRequest::UseLatestUnconfirmedTip, + CostTrackerRequest::Limited, ); requests.push(request); @@ -149,6 +157,7 @@ fn test_try_make_response() { "does-not-exist".try_into().unwrap(), vec![], TipRequest::UseLatestUnconfirmedTip, + CostTrackerRequest::Limited, ); requests.push(request); @@ -164,6 +173,7 @@ fn test_try_make_response() { "ro-test".try_into().unwrap(), vec![], TipRequest::UseLatestUnconfirmedTip, + CostTrackerRequest::Limited, ); requests.push(request); @@ -179,6 +189,7 @@ fn test_try_make_response() { "ro-confirmed".try_into().unwrap(), vec![], TipRequest::SpecificTip(StacksBlockId([0x11; 32])), + CostTrackerRequest::Limited, ); requests.push(request); @@ -269,3 +280,119 @@ fn test_try_make_response() { let (preamble, payload) = response.destruct(); assert_eq!(preamble.status_code, 404); } + +#[test] +fn test_try_make_response_free_cost_tracker() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let mut requests = vec![]; + + // query confirmed tip + 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, + "ro-confirmed".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, + CostTrackerRequest::Free, + ); + requests.push(request); + + let mut responses = test_rpc_with_config( + function_name!(), + requests, + |peer_1_config| { + peer_1_config + .connection_opts + .read_only_max_execution_time_secs = 0 + }, + |peer_2_config| { + peer_2_config + .connection_opts + .read_only_max_execution_time_secs = 0 + }, + ); + + // confirmed tip + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + assert_eq!( + response.preamble().get_canonical_stacks_tip_height(), + Some(1) + ); + + let resp = response.decode_call_readonly_response().unwrap(); + + assert!(!resp.okay); + assert!(resp.result.is_none()); + assert!(resp.cause.is_some()); + + assert_eq!(resp.cause.unwrap(), "Unchecked(ExecutionTimeExpired)"); +} + +#[test] +fn test_try_make_response_invalid_cost_tracker() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let mut requests = vec![]; + + // query confirmed tip + 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, + "ro-confirmed".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, + CostTrackerRequest::Invalid, + ); + requests.push(request); + + let mut responses = test_rpc_with_config( + function_name!(), + requests, + |peer_1_config| { + peer_1_config + .connection_opts + .read_only_max_execution_time_secs = 0 + }, + |peer_2_config| { + peer_2_config + .connection_opts + .read_only_max_execution_time_secs = 0 + }, + ); + + // confirmed tip + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + assert_eq!( + response.preamble().get_canonical_stacks_tip_height(), + Some(1) + ); + + let resp = response.decode_call_readonly_response().unwrap(); + + assert!(!resp.okay); + assert!(resp.result.is_none()); + assert!(resp.cause.is_some()); + + assert_eq!(resp.cause.unwrap(), "Interpreter(Expect(\"Interpreter failure during cost calculation: Invalid cost tracker\"))"); +} diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index 29edd95e08..fa7ff49520 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -253,6 +253,28 @@ impl<'a> TestRPC<'a> { rpc_handler_args_opt_1: Option, rpc_handler_args_opt_2: Option, ) -> TestRPC<'a> { + Self::setup_ex_with_config( + test_name, + process_microblock, + rpc_handler_args_opt_1, + rpc_handler_args_opt_2, + |_| {}, + |_| {}, + ) + } + + pub fn setup_ex_with_config( + test_name: &str, + process_microblock: bool, + rpc_handler_args_opt_1: Option, + rpc_handler_args_opt_2: Option, + with_peer_1_config: F0, + with_peer_2_config: F1, + ) -> TestRPC<'a> + where + F0: Fn(&mut TestPeerConfig), + F1: Fn(&mut TestPeerConfig), + { // ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R let privk1 = StacksPrivateKey::from_hex( "9f1f85a512a96a244e4c0d762788500687feb97481639572e3bffbd6860e6ab001", @@ -337,6 +359,9 @@ impl<'a> TestRPC<'a> { let burnchain = peer_1_config.burnchain.clone(); + with_peer_1_config(&mut peer_1_config); + with_peer_2_config(&mut peer_2_config); + let mut peer_1 = TestPeer::new(peer_1_config); let mut peer_2 = TestPeer::new(peer_2_config); @@ -1283,6 +1308,21 @@ pub fn test_rpc(test_name: &str, requests: Vec) -> Vec( + test_name: &str, + requests: Vec, + peer_1_config: F0, + peer_2_config: F1, +) -> Vec +where + F0: Fn(&mut TestPeerConfig), + F1: Fn(&mut TestPeerConfig), +{ + let test = + TestRPC::setup_ex_with_config(test_name, true, None, None, peer_1_config, peer_2_config); + test.run(requests) +} + #[test] fn prefixed_opt_hex_serialization() { let tests_32b = [ diff --git a/stackslib/src/net/connection.rs b/stackslib/src/net/connection.rs index d5fc250a38..b5a9a21a35 100644 --- a/stackslib/src/net/connection.rs +++ b/stackslib/src/net/connection.rs @@ -482,6 +482,9 @@ pub struct ConnectionOptions { /// Do not require that an unsolicited message originate from an authenticated, connected /// neighbor pub test_disable_unsolicited_message_authentication: bool, + + /// max execution time of readonly calls when cost tracking is disabled + pub read_only_max_execution_time_secs: u64, } impl std::default::Default for ConnectionOptions { @@ -592,6 +595,8 @@ impl std::default::Default for ConnectionOptions { // no test facilitations on by default test_disable_unsolicited_message_authentication: false, + + read_only_max_execution_time_secs: 30, } } } diff --git a/stackslib/src/net/httpcore.rs b/stackslib/src/net/httpcore.rs index 1e41558add..096b086019 100644 --- a/stackslib/src/net/httpcore.rs +++ b/stackslib/src/net/httpcore.rs @@ -939,6 +939,8 @@ pub struct StacksHttp { pub auth_token: Option, /// Allow arbitrary responses to be handled in addition to request handlers allow_arbitrary_response: bool, + /// Maximum execution time of a read-only call when in zero cost-tracking mode + pub read_only_max_execution_time: Duration, } impl StacksHttp { @@ -958,6 +960,9 @@ impl StacksHttp { read_only_call_limit: conn_opts.read_only_call_limit.clone(), auth_token: conn_opts.auth_token.clone(), allow_arbitrary_response: false, + read_only_max_execution_time: Duration::from_secs( + conn_opts.read_only_max_execution_time_secs, + ), }; http.register_rpc_methods(); http @@ -979,6 +984,9 @@ impl StacksHttp { read_only_call_limit: conn_opts.read_only_call_limit.clone(), auth_token: conn_opts.auth_token.clone(), allow_arbitrary_response: true, + read_only_max_execution_time: Duration::from_secs( + conn_opts.read_only_max_execution_time_secs, + ), } } From 23c17f5bafcccf691296e68f3ada6c9b97964a86 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Tue, 17 Jun 2025 12:15:53 +0200 Subject: [PATCH 02/16] updated docs --- CHANGELOG.md | 1 + docs/rpc/openapi.yaml | 6 ++++++ stackslib/src/config/mod.rs | 10 ++++++++++ 3 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 080a3bf20b..13e8dd6d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE ### Added - Added a new RPC endpoint `/v3/health` to query the node's health status. The endpoint returns a 200 status code with relevant synchronization information (including the node's current Stacks tip height, the maximum Stacks tip height among its neighbors, and the difference between these two). A user can use the `difference_from_max_peer` value to decide what is a good threshold for them before considering the node out of sync. The endpoint returns a 500 status code if the query cannot retrieve viable data. +- Added a new query string option for rpc readonly call (cost_tracker=) for allowing faster "free" cost tracking mode. When the "free" mode is enabled, max_execution_time is automatically activated (can be configured with the read_only_max_execution_time_secs connection option, default is 30 seconds) ### Changed diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 848e753550..b7afc5ff9f 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -229,6 +229,12 @@ paths: description: The Stacks chain tip to query from. If tip == latest, the query will be run from the latest known tip (includes unconfirmed state). required: false + - name: cost_tracker + in: query + schema: + type: string + description: the cost tracker to apply ("limited" or "free"). + required: false requestBody: description: map of arguments and the simulated tx-sender where sender is either a Contract identifier or a normal Stacks address, and arguments is an array of hex serialized Clarity values. required: true diff --git a/stackslib/src/config/mod.rs b/stackslib/src/config/mod.rs index 5693a6fe39..b8912374ca 100644 --- a/stackslib/src/config/mod.rs +++ b/stackslib/src/config/mod.rs @@ -3709,6 +3709,13 @@ pub struct ConnectionOptionsFile { /// @default: [`DEFAULT_BLOCK_PROPOSAL_MAX_AGE_SECS`] /// @units: seconds pub block_proposal_max_age_secs: Option, + + /// Maximum time (in seconds) that a readonly call in free cost tracking mode + /// can run before being interrupted + /// --- + /// @default: 30 + /// @units: seconds + pub read_only_max_execution_time_secs: Option, } impl ConnectionOptionsFile { @@ -3860,6 +3867,9 @@ impl ConnectionOptionsFile { block_proposal_max_age_secs: self .block_proposal_max_age_secs .unwrap_or(DEFAULT_BLOCK_PROPOSAL_MAX_AGE_SECS), + read_only_max_execution_time_secs: self + .read_only_max_execution_time_secs + .unwrap_or(default.read_only_max_execution_time_secs), ..default }) } From f1846ba9082d1a139baf6c48835dcde9c2f84399 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 27 Jun 2025 15:31:55 +0200 Subject: [PATCH 03/16] added fast-call-read-only endpoint --- stackslib/src/net/api/callreadonly.rs | 95 +---- stackslib/src/net/api/fastcallreadonly.rs | 352 ++++++++++++++++++ stackslib/src/net/api/mod.rs | 5 + stackslib/src/net/api/tests/callreadonly.rs | 130 +------ .../src/net/api/tests/fastcallreadonly.rs | 335 +++++++++++++++++ stackslib/src/net/api/tests/mod.rs | 1 + 6 files changed, 712 insertions(+), 206 deletions(-) create mode 100644 stackslib/src/net/api/fastcallreadonly.rs create mode 100644 stackslib/src/net/api/tests/fastcallreadonly.rs diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index ce9f80edad..5ecbb6c775 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -20,8 +20,8 @@ use clarity::vm::analysis::CheckErrors; use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; use clarity::vm::clarity::ClarityConnection; use clarity::vm::costs::{CostErrors, ExecutionCost, LimitedCostTracker}; -use clarity::vm::errors::Error as ClarityRuntimeError; use clarity::vm::errors::Error::Unchecked; +use clarity::vm::errors::{Error as ClarityRuntimeError, InterpreterError}; 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}; @@ -61,7 +61,7 @@ pub struct CallReadOnlyResponse { #[derive(Clone)] pub struct RPCCallReadOnlyRequestHandler { - maximum_call_argument_size: u32, + pub maximum_call_argument_size: u32, read_only_call_limit: ExecutionCost, /// Runtime fields @@ -70,16 +70,10 @@ pub struct RPCCallReadOnlyRequestHandler { pub sender: Option, pub sponsor: Option, pub arguments: Option>, - - read_only_max_execution_time: Duration, } impl RPCCallReadOnlyRequestHandler { - pub fn new( - maximum_call_argument_size: u32, - read_only_call_limit: ExecutionCost, - read_only_max_execution_time: Duration, - ) -> Self { + pub fn new(maximum_call_argument_size: u32, read_only_call_limit: ExecutionCost) -> Self { Self { maximum_call_argument_size, read_only_call_limit, @@ -88,7 +82,6 @@ impl RPCCallReadOnlyRequestHandler { sender: None, sponsor: None, arguments: None, - read_only_max_execution_time, } } } @@ -193,12 +186,6 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { } }; - let cost_tracker = contents - .get_query_args() - .get("cost_tracker") - .map(|cost_tracker| cost_tracker.as_str().into()) - .unwrap_or(CostTrackerRequest::Limited); - let contract_identifier = self .contract_identifier .take() @@ -239,19 +226,14 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { |clarity_tx| { let epoch = clarity_tx.get_epoch(); let cost_track = clarity_tx - .with_clarity_db_readonly(|clarity_db| match cost_tracker { - CostTrackerRequest::Limited => LimitedCostTracker::new_mid_block( + .with_clarity_db_readonly(|clarity_db| { + LimitedCostTracker::new_mid_block( mainnet, chain_id, cost_limit, clarity_db, epoch, - ), - CostTrackerRequest::Free => { - enforce_max_execution_time = true; - Ok(LimitedCostTracker::new_free()) - } - CostTrackerRequest::Invalid => { - Err(CostErrors::Expect("Invalid cost tracker".into())) - } + ) }) - .map_err(|e| ClarityRuntimeError::from(e))?; + .map_err(|_| { + ClarityRuntimeError::from(InterpreterError::CostContractLoadFailure) + })?; let clarity_version = clarity_tx .with_analysis_db_readonly(|analysis_db| { @@ -272,13 +254,6 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { sponsor, cost_track, |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 - if enforce_max_execution_time { - 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 @@ -355,38 +330,6 @@ impl HttpResponse for RPCCallReadOnlyRequestHandler { } } -/// All representations of the `cost_tracker=` query parameter value -#[derive(Debug, Clone, PartialEq)] -pub enum CostTrackerRequest { - Limited, - Free, - Invalid, -} - -impl CostTrackerRequest {} - -impl std::fmt::Display for CostTrackerRequest { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Limited => write!(f, "limited"), - Self::Free => write!(f, "free"), - Self::Invalid => write!(f, "invalid"), - } - } -} - -impl From<&str> for CostTrackerRequest { - fn from(s: &str) -> CostTrackerRequest { - if s == "limited" || s == "" { - CostTrackerRequest::Limited - } else if s == "free" { - CostTrackerRequest::Free - } else { - CostTrackerRequest::Invalid - } - } -} - impl StacksHttpRequest { /// Make a new request to run a read-only function pub fn new_callreadonlyfunction( @@ -398,7 +341,6 @@ impl StacksHttpRequest { function_name: ClarityName, function_args: Vec, tip_req: TipRequest, - cost_tracker: CostTrackerRequest, ) -> StacksHttpRequest { StacksHttpRequest::new_for_peer( host, @@ -407,17 +349,14 @@ impl StacksHttpRequest { "/v2/contracts/call-read/{}/{}/{}", &contract_addr, &contract_name, &function_name ), - HttpRequestContents::new() - .for_tip(tip_req) - .query_arg("cost_tracker".to_string(), cost_tracker.to_string()) - .payload_json( - serde_json::to_value(CallReadOnlyRequestBody { - sender: sender.to_string(), - sponsor: sponsor.map(|s| s.to_string()), - arguments: function_args.into_iter().map(|v| v.to_string()).collect(), - }) - .expect("FATAL: failed to encode infallible data"), - ), + HttpRequestContents::new().for_tip(tip_req).payload_json( + serde_json::to_value(CallReadOnlyRequestBody { + sender: sender.to_string(), + sponsor: sponsor.map(|s| s.to_string()), + arguments: function_args.into_iter().map(|v| v.to_string()).collect(), + }) + .expect("FATAL: failed to encode infallible data"), + ), ) .expect("FATAL: failed to construct request from infallible data") } diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs new file mode 100644 index 0000000000..6d67ca9f44 --- /dev/null +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -0,0 +1,352 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// 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 +// 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::time::Duration; + +use clarity::vm::analysis::CheckErrors; +use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; +use clarity::vm::clarity::ClarityConnection; +use clarity::vm::costs::{CostErrors, ExecutionCost, LimitedCostTracker}; +use clarity::vm::errors::Error::Unchecked; +use clarity::vm::errors::{Error as ClarityRuntimeError, InterpreterError}; +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 regex::{Captures, Regex}; +use stacks_common::types::chainstate::StacksAddress; +use stacks_common::types::net::PeerHost; + +use crate::net::api::callreadonly::{ + CallReadOnlyRequestBody, CallReadOnlyResponse, RPCCallReadOnlyRequestHandler, +}; +use crate::net::http::{ + parse_json, Error, HttpContentType, HttpNotFound, HttpRequest, HttpRequestContents, + HttpRequestPreamble, HttpResponse, HttpResponseContents, HttpResponsePayload, + HttpResponsePreamble, +}; +use crate::net::httpcore::{ + request, HttpPreambleExtensions, HttpRequestContentsExtensions, RPCRequestHandler, + StacksHttpRequest, StacksHttpResponse, +}; +use crate::net::{Error as NetError, StacksNodeState, TipRequest}; + +#[derive(Clone)] +pub struct RPCFastCallReadOnlyRequestHandler { + pub call_read_only_handler: RPCCallReadOnlyRequestHandler, + read_only_max_execution_time: Duration, +} + +impl RPCFastCallReadOnlyRequestHandler { + pub fn new( + maximum_call_argument_size: u32, + read_only_call_limit: ExecutionCost, + read_only_max_execution_time: Duration, + ) -> Self { + Self { + call_read_only_handler: RPCCallReadOnlyRequestHandler::new( + maximum_call_argument_size, + read_only_call_limit, + ), + read_only_max_execution_time, + } + } +} + +/// Decode the HTTP request +impl HttpRequest for RPCFastCallReadOnlyRequestHandler { + fn verb(&self) -> &'static str { + "POST" + } + + fn path_regex(&self) -> Regex { + Regex::new(&format!( + "^/v2/contracts/fast-call-read/(?P
{})/(?P{})/(?P{})$", + *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_NAME_REGEX_STRING, *CLARITY_NAME_REGEX + )) + .unwrap() + } + + fn metrics_identifier(&self) -> &str { + "/v2/contracts/fast-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.call_read_only_handler.maximum_call_argument_size) + { + return Err(Error::DecodeError(format!( + "Invalid Http request: invalid body length for FastCallReadOnly ({})", + 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.call_read_only_handler.contract_identifier = Some(contract_identifier); + self.call_read_only_handler.function = Some(function); + self.call_read_only_handler.sender = Some(sender); + self.call_read_only_handler.sponsor = sponsor; + self.call_read_only_handler.arguments = Some(arguments); + + Ok(HttpRequestContents::new().query_string(query)) + } +} + +/// Handle the HTTP request +impl RPCRequestHandler for RPCFastCallReadOnlyRequestHandler { + /// Reset internal state + fn restart(&mut self) { + self.call_read_only_handler.contract_identifier = None; + self.call_read_only_handler.function = None; + self.call_read_only_handler.sender = None; + self.call_read_only_handler.sponsor = None; + self.call_read_only_handler.arguments = None; + } + + /// Make the response + fn try_handle_request( + &mut self, + preamble: HttpRequestPreamble, + 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; + + let mut enforce_max_execution_time = false; + + chainstate.maybe_read_only_clarity_tx( + &sortdb.index_handle_at_block(chainstate, &tip)?, + &tip, + |clarity_tx| { + let epoch = clarity_tx.get_epoch(); + + let clarity_version = clarity_tx + .with_analysis_db_readonly(|analysis_db| { + analysis_db.get_clarity_version(&contract_identifier) + }) + .map_err(|_| { + ClarityRuntimeError::from(CheckErrors::NoSuchContract(format!( + "{}", + &contract_identifier + ))) + })?; + + clarity_tx.with_readonly_clarity_env( + mainnet, + chain_id, + clarity_version, + 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(CheckErrors::CostBalanceExceeded(actual_cost, _)) + if actual_cost.write_count > 0 => + { + CallReadOnlyResponse { + okay: false, + result: None, + cause: Some("NotReadOnly".to_string()), + } + } + _ => CallReadOnlyResponse { + okay: false, + result: None, + cause: Some(e.to_string()), + }, + }, + Ok(None) | Err(_) => { + return StacksHttpResponse::new_error( + &preamble, + &HttpNotFound::new("Chain tip not found".to_string()), + ) + .try_into_contents() + .map_err(NetError::from); + } + }; + + let mut preamble = HttpResponsePreamble::ok_json(&preamble); + preamble.set_canonical_stacks_tip_height(Some(node.canonical_stacks_tip_height())); + let body = HttpResponseContents::try_from_json(&data_resp)?; + Ok((preamble, body)) + } +} + +/// Decode the HTTP response +impl HttpResponse for RPCFastCallReadOnlyRequestHandler { + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + let map_entry: CallReadOnlyResponse = parse_json(preamble, body)?; + Ok(HttpResponsePayload::try_from_json(map_entry)?) + } +} + +impl StacksHttpRequest { + /// Make a new request to run a read-only function + pub fn new_fastcallreadonlyfunction( + host: PeerHost, + contract_addr: StacksAddress, + contract_name: ContractName, + sender: PrincipalData, + sponsor: Option, + function_name: ClarityName, + function_args: Vec, + tip_req: TipRequest, + ) -> StacksHttpRequest { + StacksHttpRequest::new_for_peer( + host, + "POST".into(), + format!( + "/v2/contracts/fast-call-read/{}/{}/{}", + &contract_addr, &contract_name, &function_name + ), + HttpRequestContents::new().for_tip(tip_req).payload_json( + serde_json::to_value(CallReadOnlyRequestBody { + sender: sender.to_string(), + sponsor: sponsor.map(|s| s.to_string()), + arguments: function_args.into_iter().map(|v| v.to_string()).collect(), + }) + .expect("FATAL: failed to encode infallible data"), + ), + ) + .expect("FATAL: failed to construct request from infallible data") + } +} + +impl StacksHttpResponse { + pub fn decode_fast_call_readonly_response(self) -> Result { + let contents = self.get_http_payload_ok()?; + let contents_json: serde_json::Value = contents.try_into()?; + let resp: CallReadOnlyResponse = serde_json::from_value(contents_json) + .map_err(|_e| NetError::DeserializeError("Failed to load from JSON".to_string()))?; + Ok(resp) + } +} diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index 07009018e3..3da90a5d2c 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -18,6 +18,7 @@ use crate::net::httpcore::StacksHttp; use crate::net::Error as NetError; pub mod callreadonly; +pub mod fastcallreadonly; pub mod get_tenures_fork_info; pub mod getaccount; pub mod getattachment; @@ -73,6 +74,10 @@ impl StacksHttp { self.register_rpc_endpoint(callreadonly::RPCCallReadOnlyRequestHandler::new( self.maximum_call_argument_size, self.read_only_call_limit.clone(), + )); + self.register_rpc_endpoint(fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new( + self.maximum_call_argument_size, + self.read_only_call_limit.clone(), self.read_only_max_execution_time, )); self.register_rpc_endpoint(getaccount::RPCGetAccountRequestHandler::new()); diff --git a/stackslib/src/net/api/tests/callreadonly.rs b/stackslib/src/net/api/tests/callreadonly.rs index d1b11a6629..004cfeb6fd 100644 --- a/stackslib/src/net/api/tests/callreadonly.rs +++ b/stackslib/src/net/api/tests/callreadonly.rs @@ -24,7 +24,6 @@ use stacks_common::types::Address; use super::{test_rpc, test_rpc_with_config}; use crate::core::BLOCK_LIMIT_MAINNET_21; -use crate::net::api::callreadonly::CostTrackerRequest; use crate::net::api::*; use crate::net::connection::ConnectionOptions; use crate::net::httpcore::{ @@ -49,7 +48,6 @@ fn test_try_parse_request() { "ro-test".try_into().unwrap(), vec![], TipRequest::SpecificTip(StacksBlockId([0x22; 32])), - CostTrackerRequest::Limited, ); assert_eq!( request.contents().tip_request(), @@ -61,11 +59,8 @@ fn test_try_parse_request() { debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); - let mut handler = callreadonly::RPCCallReadOnlyRequestHandler::new( - 4096, - BLOCK_LIMIT_MAINNET_21, - Duration::from_secs(30), - ); + let mut handler = + callreadonly::RPCCallReadOnlyRequestHandler::new(4096, BLOCK_LIMIT_MAINNET_21); let mut parsed_request = http .handle_try_parse_request( &mut handler, @@ -125,7 +120,6 @@ fn test_try_make_response() { "ro-confirmed".try_into().unwrap(), vec![], TipRequest::UseLatestAnchoredTip, - CostTrackerRequest::Limited, ); requests.push(request); @@ -141,7 +135,6 @@ fn test_try_make_response() { "ro-test".try_into().unwrap(), vec![], TipRequest::UseLatestUnconfirmedTip, - CostTrackerRequest::Limited, ); requests.push(request); @@ -157,7 +150,6 @@ fn test_try_make_response() { "does-not-exist".try_into().unwrap(), vec![], TipRequest::UseLatestUnconfirmedTip, - CostTrackerRequest::Limited, ); requests.push(request); @@ -173,7 +165,6 @@ fn test_try_make_response() { "ro-test".try_into().unwrap(), vec![], TipRequest::UseLatestUnconfirmedTip, - CostTrackerRequest::Limited, ); requests.push(request); @@ -189,7 +180,6 @@ fn test_try_make_response() { "ro-confirmed".try_into().unwrap(), vec![], TipRequest::SpecificTip(StacksBlockId([0x11; 32])), - CostTrackerRequest::Limited, ); requests.push(request); @@ -280,119 +270,3 @@ fn test_try_make_response() { let (preamble, payload) = response.destruct(); assert_eq!(preamble.status_code, 404); } - -#[test] -fn test_try_make_response_free_cost_tracker() { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); - - let mut requests = vec![]; - - // query confirmed tip - 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, - "ro-confirmed".try_into().unwrap(), - vec![], - TipRequest::UseLatestAnchoredTip, - CostTrackerRequest::Free, - ); - requests.push(request); - - let mut responses = test_rpc_with_config( - function_name!(), - requests, - |peer_1_config| { - peer_1_config - .connection_opts - .read_only_max_execution_time_secs = 0 - }, - |peer_2_config| { - peer_2_config - .connection_opts - .read_only_max_execution_time_secs = 0 - }, - ); - - // confirmed tip - let response = responses.remove(0); - debug!( - "Response:\n{}\n", - std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() - ); - - assert_eq!( - response.preamble().get_canonical_stacks_tip_height(), - Some(1) - ); - - let resp = response.decode_call_readonly_response().unwrap(); - - assert!(!resp.okay); - assert!(resp.result.is_none()); - assert!(resp.cause.is_some()); - - assert_eq!(resp.cause.unwrap(), "Unchecked(ExecutionTimeExpired)"); -} - -#[test] -fn test_try_make_response_invalid_cost_tracker() { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); - - let mut requests = vec![]; - - // query confirmed tip - 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, - "ro-confirmed".try_into().unwrap(), - vec![], - TipRequest::UseLatestAnchoredTip, - CostTrackerRequest::Invalid, - ); - requests.push(request); - - let mut responses = test_rpc_with_config( - function_name!(), - requests, - |peer_1_config| { - peer_1_config - .connection_opts - .read_only_max_execution_time_secs = 0 - }, - |peer_2_config| { - peer_2_config - .connection_opts - .read_only_max_execution_time_secs = 0 - }, - ); - - // confirmed tip - let response = responses.remove(0); - debug!( - "Response:\n{}\n", - std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() - ); - - assert_eq!( - response.preamble().get_canonical_stacks_tip_height(), - Some(1) - ); - - let resp = response.decode_call_readonly_response().unwrap(); - - assert!(!resp.okay); - assert!(resp.result.is_none()); - assert!(resp.cause.is_some()); - - assert_eq!(resp.cause.unwrap(), "Interpreter(Expect(\"Interpreter failure during cost calculation: Invalid cost tracker\"))"); -} diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs new file mode 100644 index 0000000000..ec7dd321f3 --- /dev/null +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -0,0 +1,335 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020-2023 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::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use clarity::types::chainstate::StacksBlockId; +use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, StacksAddressExtensions}; +use stacks_common::types::chainstate::StacksAddress; +use stacks_common::types::Address; + +use super::{test_rpc, test_rpc_with_config}; +use crate::core::BLOCK_LIMIT_MAINNET_21; +use crate::net::api::*; +use crate::net::connection::ConnectionOptions; +use crate::net::httpcore::{ + HttpPreambleExtensions, HttpRequestContentsExtensions, RPCRequestHandler, StacksHttp, + StacksHttpRequest, +}; +use crate::net::{ProtocolFamily, TipRequest}; + +#[test] +fn test_try_parse_request() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + let mut http = StacksHttp::new(addr.clone(), &ConnectionOptions::default()); + + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world-unconfirmed".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-test".try_into().unwrap(), + vec![], + TipRequest::SpecificTip(StacksBlockId([0x22; 32])), + ); + assert_eq!( + request.contents().tip_request(), + TipRequest::SpecificTip(StacksBlockId([0x22; 32])) + ); + + let bytes = request.try_serialize().unwrap(); + + debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); + + let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); + let mut handler = fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new( + 4096, + BLOCK_LIMIT_MAINNET_21, + Duration::from_secs(30), + ); + let mut parsed_request = http + .handle_try_parse_request( + &mut handler, + &parsed_preamble.expect_request(), + &bytes[offset..], + ) + .unwrap(); + + // consumed path args and body + assert_eq!( + handler.call_read_only_handler.contract_identifier, + Some( + QualifiedContractIdentifier::parse( + "ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R.hello-world-unconfirmed" + ) + .unwrap() + ) + ); + assert_eq!( + handler.call_read_only_handler.function, + Some("ro-test".into()) + ); + assert_eq!( + handler.call_read_only_handler.sender, + Some(PrincipalData::parse("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap()) + ); + assert_eq!(handler.call_read_only_handler.sponsor, None); + assert_eq!(handler.call_read_only_handler.arguments, Some(vec![])); + + // parsed request consumes headers that would not be in a constructed reqeuest + parsed_request.clear_headers(); + let (preamble, contents) = parsed_request.destruct(); + + assert_eq!(&preamble, request.preamble()); + + // restart clears the handler state + handler.restart(); + assert!(handler.call_read_only_handler.contract_identifier.is_none()); + assert!(handler.call_read_only_handler.function.is_none()); + assert!(handler.call_read_only_handler.sender.is_none()); + assert!(handler.call_read_only_handler.sponsor.is_none()); + assert!(handler.call_read_only_handler.arguments.is_none()); +} + +#[test] +fn test_try_make_response() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let mut requests = vec![]; + + // query confirmed tip + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-confirmed".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + + // query unconfirmed tip + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world-unconfirmed".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-test".try_into().unwrap(), + vec![], + TipRequest::UseLatestUnconfirmedTip, + ); + requests.push(request); + + // query non-existent function + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world-unconfirmed".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "does-not-exist".try_into().unwrap(), + vec![], + TipRequest::UseLatestUnconfirmedTip, + ); + requests.push(request); + + // query non-existent contract + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "does-not-exist".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-test".try_into().unwrap(), + vec![], + TipRequest::UseLatestUnconfirmedTip, + ); + requests.push(request); + + // query non-existent tip + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-confirmed".try_into().unwrap(), + vec![], + TipRequest::SpecificTip(StacksBlockId([0x11; 32])), + ); + requests.push(request); + + let mut responses = test_rpc(function_name!(), requests); + + // confirmed tip + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + assert_eq!( + response.preamble().get_canonical_stacks_tip_height(), + Some(1) + ); + + let resp = response.decode_call_readonly_response().unwrap(); + + assert!(resp.okay); + assert!(resp.result.is_some()); + assert!(resp.cause.is_none()); + + // u1 + assert_eq!(resp.result.unwrap(), "0x0100000000000000000000000000000001"); + + // unconfirmed tip + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + assert_eq!( + response.preamble().get_canonical_stacks_tip_height(), + Some(1) + ); + + let resp = response.decode_call_readonly_response().unwrap(); + + assert!(resp.okay); + assert!(resp.result.is_some()); + assert!(resp.cause.is_none()); + + // (ok 1) + assert_eq!( + resp.result.unwrap(), + "0x070000000000000000000000000000000001" + ); + + // non-existent function + 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_none()); + assert!(resp.cause.is_some()); + + assert!(resp.cause.unwrap().find("UndefinedFunction").is_some()); + + // non-existent function + 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_none()); + assert!(resp.cause.is_some()); + + assert!(resp.cause.unwrap().find("NoSuchContract").is_some()); + + // non-existent tip + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let (preamble, payload) = response.destruct(); + assert_eq!(preamble.status_code, 404); +} + +#[test] +fn test_try_make_response_free_cost_tracker() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let mut requests = vec![]; + + // query confirmed tip + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-confirmed".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, + ); + requests.push(request); + + let mut responses = test_rpc_with_config( + function_name!(), + requests, + |peer_1_config| { + peer_1_config + .connection_opts + .read_only_max_execution_time_secs = 0 + }, + |peer_2_config| { + peer_2_config + .connection_opts + .read_only_max_execution_time_secs = 0 + }, + ); + + // confirmed tip + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + assert_eq!( + response.preamble().get_canonical_stacks_tip_height(), + Some(1) + ); + + let resp = response.decode_call_readonly_response().unwrap(); + + assert!(!resp.okay); + assert!(resp.result.is_none()); + assert!(resp.cause.is_some()); + + assert_eq!(resp.cause.unwrap(), "Unchecked(ExecutionTimeExpired)"); +} diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index fa7ff49520..b227af0763 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -57,6 +57,7 @@ use crate::net::{ }; mod callreadonly; +mod fastcallreadonly; mod get_tenures_fork_info; mod getaccount; mod getattachment; From 50439a0a55cc7e085d457cc9118ecdaf05f657e4 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 27 Jun 2025 15:32:46 +0200 Subject: [PATCH 04/16] reverted callreadonly.rs --- stackslib/src/net/api/tests/callreadonly.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stackslib/src/net/api/tests/callreadonly.rs b/stackslib/src/net/api/tests/callreadonly.rs index 004cfeb6fd..3ca5f473f2 100644 --- a/stackslib/src/net/api/tests/callreadonly.rs +++ b/stackslib/src/net/api/tests/callreadonly.rs @@ -15,14 +15,13 @@ // along with this program. If not, see . use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::time::Duration; use clarity::types::chainstate::StacksBlockId; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, StacksAddressExtensions}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::types::Address; -use super::{test_rpc, test_rpc_with_config}; +use super::test_rpc; use crate::core::BLOCK_LIMIT_MAINNET_21; use crate::net::api::*; use crate::net::connection::ConnectionOptions; From 6b8acf240c9ca197a29c7b0332d06e5c432d85f5 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 27 Jun 2025 15:43:01 +0200 Subject: [PATCH 05/16] cleaned up fastcallreadonly --- stackslib/src/net/api/callreadonly.rs | 6 +---- stackslib/src/net/api/fastcallreadonly.rs | 24 +++++++++---------- stackslib/src/net/api/mod.rs | 1 - .../src/net/api/tests/fastcallreadonly.rs | 7 ++---- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index 5ecbb6c775..355c83ef26 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -14,12 +14,10 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::time::Duration; - use clarity::vm::analysis::CheckErrors; use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; use clarity::vm::clarity::ClarityConnection; -use clarity::vm::costs::{CostErrors, ExecutionCost, LimitedCostTracker}; +use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; use clarity::vm::errors::Error::Unchecked; use clarity::vm::errors::{Error as ClarityRuntimeError, InterpreterError}; use clarity::vm::representations::{CONTRACT_NAME_REGEX_STRING, STANDARD_PRINCIPAL_REGEX_STRING}; @@ -218,8 +216,6 @@ impl RPCRequestHandler for RPCCallReadOnlyRequestHandler { cost_limit.write_length = 0; cost_limit.write_count = 0; - let mut enforce_max_execution_time = false; - chainstate.maybe_read_only_clarity_tx( &sortdb.index_handle_at_block(chainstate, &tip)?, &tip, diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs index 6d67ca9f44..9276b4d95b 100644 --- a/stackslib/src/net/api/fastcallreadonly.rs +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -19,11 +19,11 @@ use std::time::Duration; use clarity::vm::analysis::CheckErrors; use clarity::vm::ast::parser::v1::CLARITY_NAME_REGEX; use clarity::vm::clarity::ClarityConnection; -use clarity::vm::costs::{CostErrors, ExecutionCost, LimitedCostTracker}; +use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; +use clarity::vm::errors::Error as ClarityRuntimeError; use clarity::vm::errors::Error::Unchecked; -use clarity::vm::errors::{Error as ClarityRuntimeError, InterpreterError}; use clarity::vm::representations::{CONTRACT_NAME_REGEX_STRING, STANDARD_PRINCIPAL_REGEX_STRING}; -use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; +use clarity::vm::types::PrincipalData; use clarity::vm::{ClarityName, ContractName, SymbolicExpression, Value}; use regex::{Captures, Regex}; use stacks_common::types::chainstate::StacksAddress; @@ -50,15 +50,17 @@ pub struct RPCFastCallReadOnlyRequestHandler { } impl RPCFastCallReadOnlyRequestHandler { - pub fn new( - maximum_call_argument_size: u32, - read_only_call_limit: ExecutionCost, - read_only_max_execution_time: Duration, - ) -> Self { + pub fn new(maximum_call_argument_size: u32, read_only_max_execution_time: Duration) -> Self { Self { call_read_only_handler: RPCCallReadOnlyRequestHandler::new( maximum_call_argument_size, - read_only_call_limit, + ExecutionCost { + write_length: 0, + write_count: 0, + read_length: 0, + read_count: 0, + runtime: 0, + }, ), read_only_max_execution_time, } @@ -200,14 +202,10 @@ impl RPCRequestHandler for RPCFastCallReadOnlyRequestHandler { let mainnet = chainstate.mainnet; let chain_id = chainstate.chain_id; - let mut enforce_max_execution_time = false; - chainstate.maybe_read_only_clarity_tx( &sortdb.index_handle_at_block(chainstate, &tip)?, &tip, |clarity_tx| { - let epoch = clarity_tx.get_epoch(); - let clarity_version = clarity_tx .with_analysis_db_readonly(|analysis_db| { analysis_db.get_clarity_version(&contract_identifier) diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index 3da90a5d2c..cef71f394f 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -77,7 +77,6 @@ impl StacksHttp { )); self.register_rpc_endpoint(fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new( self.maximum_call_argument_size, - self.read_only_call_limit.clone(), self.read_only_max_execution_time, )); self.register_rpc_endpoint(getaccount::RPCGetAccountRequestHandler::new()); diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs index ec7dd321f3..5f66019f5d 100644 --- a/stackslib/src/net/api/tests/fastcallreadonly.rs +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -59,11 +59,8 @@ fn test_try_parse_request() { debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); - let mut handler = fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new( - 4096, - BLOCK_LIMIT_MAINNET_21, - Duration::from_secs(30), - ); + let mut handler = + fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new(4096, Duration::from_secs(30)); let mut parsed_request = http .handle_try_parse_request( &mut handler, From 33cad91239e3852a2b479f076254c6687ce193fb Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 27 Jun 2025 15:49:11 +0200 Subject: [PATCH 06/16] cargo fmt --- stackslib/src/net/api/tests/fastcallreadonly.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs index 5f66019f5d..e37e3447b6 100644 --- a/stackslib/src/net/api/tests/fastcallreadonly.rs +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -23,7 +23,6 @@ use stacks_common::types::chainstate::StacksAddress; use stacks_common::types::Address; use super::{test_rpc, test_rpc_with_config}; -use crate::core::BLOCK_LIMIT_MAINNET_21; use crate::net::api::*; use crate::net::connection::ConnectionOptions; use crate::net::httpcore::{ From e551df34010250aae3ebfe75624939ff0d30ec53 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 27 Jun 2025 16:48:34 +0200 Subject: [PATCH 07/16] updated openapi.yaml --- docs/rpc/openapi.yaml | 67 +++++++++++++++++-- stackslib/src/net/api/fastcallreadonly.rs | 31 +++++---- stackslib/src/net/api/mod.rs | 1 + .../src/net/api/tests/fastcallreadonly.rs | 40 ++++++++--- 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index b7afc5ff9f..20c8aa645e 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -229,12 +229,6 @@ paths: description: The Stacks chain tip to query from. If tip == latest, the query will be run from the latest known tip (includes unconfirmed state). required: false - - name: cost_tracker - in: query - schema: - type: string - description: the cost tracker to apply ("limited" or "free"). - required: false requestBody: description: map of arguments and the simulated tx-sender where sender is either a Contract identifier or a normal Stacks address, and arguments is an array of hex serialized Clarity values. required: true @@ -934,3 +928,64 @@ paths: $ref: ./api/core-node/get-health-error.schema.json example: $ref: ./api/core-node/get-health-error.example.json + + /v2/contracts/fast-call-read/{contract_address}/{contract_name}/{function_name}: + post: + summary: Call read-only function in fast mode (no cost tracking) + tags: + - Smart Contracts + operationId: fast_call_read_only_function + description: | + Call a read-only public function on a given smart contract without cost tracking. + + The smart contract and function are specified using the URL path. The arguments and the simulated tx-sender are supplied via the POST body in the following JSON format: + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: ./api/contract/post-call-read-only-fn.schema.json + examples: + success: + $ref: ./api/contract/post-call-read-only-fn-success.example.json + fail: + $ref: ./api/contract/post-call-read-only-fn-fail.example.json + parameters: + - name: contract_address + in: path + required: true + description: Stacks address + schema: + type: string + - name: contract_name + in: path + required: true + description: Contract name + schema: + type: string + - name: function_name + in: path + required: true + description: Function name + schema: + type: string + - name: tip + in: query + schema: + type: string + description: The Stacks chain tip to query from. If tip == latest, the query will be run from the latest + known tip (includes unconfirmed state). + required: false + requestBody: + description: map of arguments and the simulated tx-sender where sender is either a Contract identifier or a normal Stacks address, and arguments is an array of hex serialized Clarity values. + required: true + content: + application/json: + schema: + $ref: './entities/contracts/read-only-function-args.schema.json' + example: + sender: 'SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info' + arguments: + - '0x0011...' + - '0x00231...' diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs index 9276b4d95b..786c005ae8 100644 --- a/stackslib/src/net/api/fastcallreadonly.rs +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -47,10 +47,15 @@ use crate::net::{Error as NetError, StacksNodeState, TipRequest}; pub struct RPCFastCallReadOnlyRequestHandler { pub call_read_only_handler: RPCCallReadOnlyRequestHandler, read_only_max_execution_time: Duration, + pub auth: Option, } impl RPCFastCallReadOnlyRequestHandler { - pub fn new(maximum_call_argument_size: u32, read_only_max_execution_time: Duration) -> Self { + pub fn new( + maximum_call_argument_size: u32, + read_only_max_execution_time: Duration, + auth: Option, + ) -> Self { Self { call_read_only_handler: RPCCallReadOnlyRequestHandler::new( maximum_call_argument_size, @@ -63,6 +68,7 @@ impl RPCFastCallReadOnlyRequestHandler { }, ), read_only_max_execution_time, + auth, } } } @@ -93,6 +99,17 @@ impl HttpRequest for RPCFastCallReadOnlyRequestHandler { query: Option<&str>, body: &[u8], ) -> Result { + // If no authorization is set, then the block proposal endpoint is not enabled + let Some(password) = &self.auth else { + return Err(Error::Http(400, "Bad Request.".into())); + }; + let Some(auth_header) = preamble.headers.get("authorization") else { + return Err(Error::Http(401, "Unauthorized".into())); + }; + if auth_header != password { + return Err(Error::Http(401, "Unauthorized".into())); + } + let content_len = preamble.get_content_length(); if !(content_len > 0 && content_len < self.call_read_only_handler.maximum_call_argument_size) @@ -337,14 +354,4 @@ impl StacksHttpRequest { ) .expect("FATAL: failed to construct request from infallible data") } -} - -impl StacksHttpResponse { - pub fn decode_fast_call_readonly_response(self) -> Result { - let contents = self.get_http_payload_ok()?; - let contents_json: serde_json::Value = contents.try_into()?; - let resp: CallReadOnlyResponse = serde_json::from_value(contents_json) - .map_err(|_e| NetError::DeserializeError("Failed to load from JSON".to_string()))?; - Ok(resp) - } -} +} \ No newline at end of file diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index cef71f394f..2f2f052eb6 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -78,6 +78,7 @@ impl StacksHttp { self.register_rpc_endpoint(fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new( self.maximum_call_argument_size, self.read_only_max_execution_time, + self.auth_token.clone(), )); self.register_rpc_endpoint(getaccount::RPCGetAccountRequestHandler::new()); self.register_rpc_endpoint(getattachment::RPCGetAttachmentRequestHandler::new()); diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs index e37e3447b6..5e91a4f3b0 100644 --- a/stackslib/src/net/api/tests/fastcallreadonly.rs +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -36,7 +36,7 @@ fn test_try_parse_request() { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); let mut http = StacksHttp::new(addr.clone(), &ConnectionOptions::default()); - let request = StacksHttpRequest::new_fastcallreadonlyfunction( + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( addr.into(), StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), "hello-world-unconfirmed".try_into().unwrap(), @@ -48,6 +48,10 @@ fn test_try_parse_request() { vec![], TipRequest::SpecificTip(StacksBlockId([0x22; 32])), ); + + // add the authorization header + request.add_header("authorization".into(), "password".into()); + assert_eq!( request.contents().tip_request(), TipRequest::SpecificTip(StacksBlockId([0x22; 32])) @@ -58,8 +62,11 @@ fn test_try_parse_request() { debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); - let mut handler = - fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new(4096, Duration::from_secs(30)); + let mut handler = fastcallreadonly::RPCFastCallReadOnlyRequestHandler::new( + 4096, + Duration::from_secs(30), + Some("password".into()), + ); let mut parsed_request = http .handle_try_parse_request( &mut handler, @@ -89,8 +96,9 @@ fn test_try_parse_request() { assert_eq!(handler.call_read_only_handler.sponsor, None); assert_eq!(handler.call_read_only_handler.arguments, Some(vec![])); - // parsed request consumes headers that would not be in a constructed reqeuest + // parsed request consumes headers that would not be in a constructed request parsed_request.clear_headers(); + parsed_request.add_header("authorization".into(), "password".into()); let (preamble, contents) = parsed_request.destruct(); assert_eq!(&preamble, request.preamble()); @@ -111,7 +119,7 @@ fn test_try_make_response() { let mut requests = vec![]; // query confirmed tip - let request = StacksHttpRequest::new_fastcallreadonlyfunction( + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( addr.into(), StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), "hello-world".try_into().unwrap(), @@ -123,10 +131,12 @@ fn test_try_make_response() { vec![], TipRequest::UseLatestAnchoredTip, ); + request.add_header("authorization".into(), "password".into()); + requests.push(request); // query unconfirmed tip - let request = StacksHttpRequest::new_fastcallreadonlyfunction( + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( addr.into(), StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), "hello-world-unconfirmed".try_into().unwrap(), @@ -138,10 +148,12 @@ fn test_try_make_response() { vec![], TipRequest::UseLatestUnconfirmedTip, ); + request.add_header("authorization".into(), "password".into()); + requests.push(request); // query non-existent function - let request = StacksHttpRequest::new_fastcallreadonlyfunction( + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( addr.into(), StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), "hello-world-unconfirmed".try_into().unwrap(), @@ -153,10 +165,12 @@ fn test_try_make_response() { vec![], TipRequest::UseLatestUnconfirmedTip, ); + request.add_header("authorization".into(), "password".into()); + requests.push(request); // query non-existent contract - let request = StacksHttpRequest::new_fastcallreadonlyfunction( + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( addr.into(), StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), "does-not-exist".try_into().unwrap(), @@ -168,10 +182,12 @@ fn test_try_make_response() { vec![], TipRequest::UseLatestUnconfirmedTip, ); + request.add_header("authorization".into(), "password".into()); + requests.push(request); // query non-existent tip - let request = StacksHttpRequest::new_fastcallreadonlyfunction( + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( addr.into(), StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), "hello-world".try_into().unwrap(), @@ -183,6 +199,8 @@ fn test_try_make_response() { vec![], TipRequest::SpecificTip(StacksBlockId([0x11; 32])), ); + request.add_header("authorization".into(), "password".into()); + requests.push(request); let mut responses = test_rpc(function_name!(), requests); @@ -280,7 +298,7 @@ fn test_try_make_response_free_cost_tracker() { let mut requests = vec![]; // query confirmed tip - let request = StacksHttpRequest::new_fastcallreadonlyfunction( + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( addr.into(), StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), "hello-world".try_into().unwrap(), @@ -292,6 +310,8 @@ fn test_try_make_response_free_cost_tracker() { vec![], TipRequest::UseLatestAnchoredTip, ); + request.add_header("authorization".into(), "password".into()); + requests.push(request); let mut responses = test_rpc_with_config( From ac7717254e3ee82207fd6ded12d6acb1b32f63d8 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 27 Jun 2025 16:49:58 +0200 Subject: [PATCH 08/16] added authorization in openapi.yaml for fast-call-read --- docs/rpc/openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 20c8aa645e..e028d9f190 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -951,6 +951,10 @@ paths: $ref: ./api/contract/post-call-read-only-fn-success.example.json fail: $ref: ./api/contract/post-call-read-only-fn-fail.example.json + "400": + description: Endpoint not enabled. + "401": + description: Unauthorized. parameters: - name: contract_address in: path From d88bc2f22b716726b54d0b55b68693b6bead3729 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 27 Jun 2025 16:51:45 +0200 Subject: [PATCH 09/16] use /v3 for the url --- docs/rpc/openapi.yaml | 2 +- stackslib/src/net/api/fastcallreadonly.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index e028d9f190..62a09ec046 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -929,7 +929,7 @@ paths: example: $ref: ./api/core-node/get-health-error.example.json - /v2/contracts/fast-call-read/{contract_address}/{contract_name}/{function_name}: + /v3/contracts/fast-call-read/{contract_address}/{contract_name}/{function_name}: post: summary: Call read-only function in fast mode (no cost tracking) tags: diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs index 786c005ae8..7d0b15dcda 100644 --- a/stackslib/src/net/api/fastcallreadonly.rs +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -81,14 +81,14 @@ impl HttpRequest for RPCFastCallReadOnlyRequestHandler { fn path_regex(&self) -> Regex { Regex::new(&format!( - "^/v2/contracts/fast-call-read/(?P
{})/(?P{})/(?P{})$", + "^/v3/contracts/fast-call-read/(?P
{})/(?P{})/(?P{})$", *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_NAME_REGEX_STRING, *CLARITY_NAME_REGEX )) .unwrap() } fn metrics_identifier(&self) -> &str { - "/v2/contracts/fast-call-read/:principal/:contract_name/:func_name" + "/v3/contracts/fast-call-read/:principal/:contract_name/:func_name" } /// Try to decode this request. @@ -340,7 +340,7 @@ impl StacksHttpRequest { host, "POST".into(), format!( - "/v2/contracts/fast-call-read/{}/{}/{}", + "/v3/contracts/fast-call-read/{}/{}/{}", &contract_addr, &contract_name, &function_name ), HttpRequestContents::new().for_tip(tip_req).payload_json( From 74e7c04c6a1ceb910c19fa09c279fa25c50c1d4b Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Wed, 2 Jul 2025 16:39:49 +0200 Subject: [PATCH 10/16] cargo fmt --- stackslib/src/net/api/fastcallreadonly.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs index 7d0b15dcda..e120f696a7 100644 --- a/stackslib/src/net/api/fastcallreadonly.rs +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -354,4 +354,4 @@ impl StacksHttpRequest { ) .expect("FATAL: failed to construct request from infallible data") } -} \ No newline at end of file +} From 38ca600c5ce2fe45cc42458988da9cc231e3a538 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 4 Jul 2025 17:24:03 +0200 Subject: [PATCH 11/16] Update docs/rpc/openapi.yaml Co-authored-by: Francesco <2530388+Jiloc@users.noreply.github.com> --- docs/rpc/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 62a09ec046..da236646d2 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -931,7 +931,7 @@ paths: /v3/contracts/fast-call-read/{contract_address}/{contract_name}/{function_name}: post: - summary: Call read-only function in fast mode (no cost tracking) + summary: Call read-only function in fast mode (no cost and memory tracking) tags: - Smart Contracts operationId: fast_call_read_only_function From 05f9943f9868de2a18e39a6684a77b6f8123b42f Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 4 Jul 2025 17:24:27 +0200 Subject: [PATCH 12/16] Update stackslib/src/net/api/fastcallreadonly.rs Co-authored-by: Brice --- stackslib/src/net/api/fastcallreadonly.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs index e120f696a7..2730649a13 100644 --- a/stackslib/src/net/api/fastcallreadonly.rs +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -1,5 +1,4 @@ -// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation -// Copyright (C) 2020-2025 Stacks Open Internet Foundation +// 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 From 0da8cda6e6e255f3f7803398724da32d5b40a50d Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Fri, 4 Jul 2025 17:25:23 +0200 Subject: [PATCH 13/16] Update stackslib/src/net/api/tests/fastcallreadonly.rs Co-authored-by: Brice --- stackslib/src/net/api/tests/fastcallreadonly.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs index 5e91a4f3b0..c36248fc3c 100644 --- a/stackslib/src/net/api/tests/fastcallreadonly.rs +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -1,5 +1,4 @@ -// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation -// Copyright (C) 2020-2023 Stacks Open Internet Foundation +// 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 From c8e0a41f6e3baa698c377173783dce92c7377e6a Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Tue, 8 Jul 2025 11:19:23 +0200 Subject: [PATCH 14/16] explicit about Authorization header in api endpoint --- docs/rpc/openapi.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index da236646d2..cff8ab6ecc 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -938,6 +938,8 @@ paths: description: | Call a read-only public function on a given smart contract without cost tracking. + **This API endpoint requires a basic Authorization header.** + The smart contract and function are specified using the URL path. The arguments and the simulated tx-sender are supplied via the POST body in the following JSON format: responses: "200": From 862f9a76785ff7a150ef0f020fbd7e761d127e22 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Tue, 8 Jul 2025 12:17:08 +0200 Subject: [PATCH 15/16] added explicit ExecutionTime expiration --- stackslib/src/net/api/fastcallreadonly.rs | 12 ++- .../src/net/api/tests/fastcallreadonly.rs | 73 ++++++++++++++++--- stackslib/src/net/http/error.rs | 28 +++++++ stackslib/src/net/http/mod.rs | 4 +- 4 files changed, 101 insertions(+), 16 deletions(-) diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs index 2730649a13..33ead2e3c1 100644 --- a/stackslib/src/net/api/fastcallreadonly.rs +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -33,8 +33,8 @@ use crate::net::api::callreadonly::{ }; 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, HttpPreambleExtensions, HttpRequestContentsExtensions, RPCRequestHandler, @@ -288,6 +288,14 @@ impl RPCRequestHandler for RPCFastCallReadOnlyRequestHandler { cause: Some("NotReadOnly".to_string()), } } + Unchecked(CheckErrors::ExecutionTimeExpired) => { + return StacksHttpResponse::new_error( + &preamble, + &HttpRequestTimeout::new("ExecutionTime expired".to_string()), + ) + .try_into_contents() + .map_err(NetError::from) + } _ => CallReadOnlyResponse { okay: false, result: None, diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs index c36248fc3c..c65ab75955 100644 --- a/stackslib/src/net/api/tests/fastcallreadonly.rs +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -328,23 +328,72 @@ fn test_try_make_response_free_cost_tracker() { }, ); - // confirmed tip let response = responses.remove(0); - debug!( - "Response:\n{}\n", - std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + let (preamble, contents) = response.destruct(); + + assert_eq!(preamble.status_code, 408); + + let body: String = contents.try_into().unwrap(); + assert_eq!(body, "ExecutionTime expired"); +} + +#[test] +fn test_wrong_auth() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let mut requests = vec![]; + + // query confirmed tip + let mut request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-confirmed".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, ); + request.add_header("authorization".into(), "wrong".into()); - assert_eq!( - response.preamble().get_canonical_stacks_tip_height(), - Some(1) + requests.push(request); + + let mut responses = test_rpc(function_name!(), requests); + + let response = responses.remove(0); + let (preamble, contents) = response.destruct(); + + assert_eq!(preamble.status_code, 401); +} + +#[test] +fn test_missing_auth() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let mut requests = vec![]; + + // query confirmed tip + let request = StacksHttpRequest::new_fastcallreadonlyfunction( + addr.into(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R").unwrap(), + "hello-world".try_into().unwrap(), + StacksAddress::from_string("ST2DS4MSWSGJ3W9FBC6BVT0Y92S345HY8N3T6AV7R") + .unwrap() + .to_account_principal(), + None, + "ro-confirmed".try_into().unwrap(), + vec![], + TipRequest::UseLatestAnchoredTip, ); - let resp = response.decode_call_readonly_response().unwrap(); + requests.push(request); - assert!(!resp.okay); - assert!(resp.result.is_none()); - assert!(resp.cause.is_some()); + let mut responses = test_rpc(function_name!(), requests); + + let response = responses.remove(0); + let (preamble, contents) = response.destruct(); - assert_eq!(resp.cause.unwrap(), "Unchecked(ExecutionTimeExpired)"); + assert_eq!(preamble.status_code, 401); } diff --git a/stackslib/src/net/http/error.rs b/stackslib/src/net/http/error.rs index 1b8559697a..31b91a7694 100644 --- a/stackslib/src/net/http/error.rs +++ b/stackslib/src/net/http/error.rs @@ -128,6 +128,7 @@ pub fn http_error_from_code_and_text(code: u16, message: String) -> Box Box::new(HttpPaymentRequired::new(message)), 403 => Box::new(HttpForbidden::new(message)), 404 => Box::new(HttpNotFound::new(message)), + 408 => Box::new(HttpRequestTimeout::new(message)), 500 => Box::new(HttpServerError::new(message)), 501 => Box::new(HttpNotImplemented::new(message)), 503 => Box::new(HttpServiceUnavailable::new(message)), @@ -292,6 +293,33 @@ impl HttpErrorResponse for HttpNotFound { } } +/// HTTP 408 +pub struct HttpRequestTimeout { + error_text: String, +} + +impl HttpRequestTimeout { + pub fn new(error_text: String) -> Self { + Self { error_text } + } +} + +impl HttpErrorResponse for HttpRequestTimeout { + fn code(&self) -> u16 { + 408 + } + fn payload(&self) -> HttpResponsePayload { + HttpResponsePayload::Text(self.error_text.clone()) + } + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + try_parse_error_response(preamble.status_code, preamble.content_type, body) + } +} + /// HTTP 500 pub struct HttpServerError { error_text: String, diff --git a/stackslib/src/net/http/mod.rs b/stackslib/src/net/http/mod.rs index 11de4df167..ce7c00dc06 100644 --- a/stackslib/src/net/http/mod.rs +++ b/stackslib/src/net/http/mod.rs @@ -37,8 +37,8 @@ pub use crate::net::http::common::{ }; pub use crate::net::http::error::{ http_error_from_code_and_text, http_reason, HttpBadRequest, HttpError, HttpErrorResponse, - HttpForbidden, HttpNotFound, HttpNotImplemented, HttpPaymentRequired, HttpServerError, - HttpServiceUnavailable, HttpUnauthorized, + HttpForbidden, HttpNotFound, HttpNotImplemented, HttpPaymentRequired, HttpRequestTimeout, + HttpServerError, HttpServiceUnavailable, HttpUnauthorized, }; pub use crate::net::http::request::{ HttpRequest, HttpRequestContents, HttpRequestPayload, HttpRequestPreamble, From 997c3185c4903c5a9eafec6ed5883525ec6adce6 Mon Sep 17 00:00:00 2001 From: Roberto De Ioris Date: Tue, 8 Jul 2025 15:43:17 +0200 Subject: [PATCH 16/16] added 408 error to openapi --- docs/rpc/openapi.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 4d10ca9f64..5fe375cc05 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -977,6 +977,8 @@ paths: description: Endpoint not enabled. "401": description: Unauthorized. + "408": + description: ExecutionTime expired. parameters: - name: contract_address in: path