diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 0253a661d5..5fe375cc05 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -948,3 +948,72 @@ paths: $ref: ./api/core-node/get-health-error.schema.json example: $ref: ./api/core-node/get-health-error.example.json + + /v3/contracts/fast-call-read/{contract_address}/{contract_name}/{function_name}: + post: + summary: Call read-only function in fast mode (no cost and memory 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. + + **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": + 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 + "400": + description: Endpoint not enabled. + "401": + description: Unauthorized. + "408": + description: ExecutionTime expired. + 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/config/mod.rs b/stackslib/src/config/mod.rs index 4689b73ace..1c9ffd9879 100644 --- a/stackslib/src/config/mod.rs +++ b/stackslib/src/config/mod.rs @@ -3719,6 +3719,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 { @@ -3870,6 +3877,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 }) } diff --git a/stackslib/src/net/api/callreadonly.rs b/stackslib/src/net/api/callreadonly.rs index b2c863d2bb..355c83ef26 100644 --- a/stackslib/src/net/api/callreadonly.rs +++ b/stackslib/src/net/api/callreadonly.rs @@ -59,7 +59,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 diff --git a/stackslib/src/net/api/fastcallreadonly.rs b/stackslib/src/net/api/fastcallreadonly.rs new file mode 100644 index 0000000000..33ead2e3c1 --- /dev/null +++ b/stackslib/src/net/api/fastcallreadonly.rs @@ -0,0 +1,364 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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::errors::Error as ClarityRuntimeError; +use clarity::vm::errors::Error::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 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, HttpRequestTimeout, 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, + pub auth: Option, +} + +impl RPCFastCallReadOnlyRequestHandler { + 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, + ExecutionCost { + write_length: 0, + write_count: 0, + read_length: 0, + read_count: 0, + runtime: 0, + }, + ), + read_only_max_execution_time, + auth, + } + } +} + +/// Decode the HTTP request +impl HttpRequest for RPCFastCallReadOnlyRequestHandler { + fn verb(&self) -> &'static str { + "POST" + } + + fn path_regex(&self) -> Regex { + Regex::new(&format!( + "^/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 { + "/v3/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 { + // 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) + { + 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; + + chainstate.maybe_read_only_clarity_tx( + &sortdb.index_handle_at_block(chainstate, &tip)?, + &tip, + |clarity_tx| { + 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()), + } + } + 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, + 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!( + "/v3/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") + } +} diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index 560e2bd23b..2f2f052eb6 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; @@ -74,6 +75,11 @@ impl StacksHttp { 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_max_execution_time, + self.auth_token.clone(), + )); self.register_rpc_endpoint(getaccount::RPCGetAccountRequestHandler::new()); self.register_rpc_endpoint(getattachment::RPCGetAttachmentRequestHandler::new()); self.register_rpc_endpoint(getattachmentsinv::RPCGetAttachmentsInvRequestHandler::new()); diff --git a/stackslib/src/net/api/tests/fastcallreadonly.rs b/stackslib/src/net/api/tests/fastcallreadonly.rs new file mode 100644 index 0000000000..c65ab75955 --- /dev/null +++ b/stackslib/src/net/api/tests/fastcallreadonly.rs @@ -0,0 +1,399 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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::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 mut 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])), + ); + + // add the authorization header + request.add_header("authorization".into(), "password".into()); + + 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, + Duration::from_secs(30), + Some("password".into()), + ); + 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 request + parsed_request.clear_headers(); + parsed_request.add_header("authorization".into(), "password".into()); + 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 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(), "password".into()); + + requests.push(request); + + // query unconfirmed tip + let mut 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, + ); + request.add_header("authorization".into(), "password".into()); + + requests.push(request); + + // query non-existent function + let mut 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, + ); + request.add_header("authorization".into(), "password".into()); + + requests.push(request); + + // query non-existent contract + let mut 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, + ); + request.add_header("authorization".into(), "password".into()); + + requests.push(request); + + // query non-existent 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::SpecificTip(StacksBlockId([0x11; 32])), + ); + request.add_header("authorization".into(), "password".into()); + + 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 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(), "password".into()); + + 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 + }, + ); + + let response = responses.remove(0); + 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()); + + 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, + ); + + 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); +} diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index 29edd95e08..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; @@ -253,6 +254,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 +360,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 +1309,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 06d41e6f51..1b8d47826b 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/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, diff --git a/stackslib/src/net/httpcore.rs b/stackslib/src/net/httpcore.rs index c00b9a7f32..28e1673e1a 100644 --- a/stackslib/src/net/httpcore.rs +++ b/stackslib/src/net/httpcore.rs @@ -942,6 +942,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 { @@ -961,6 +963,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 @@ -982,6 +987,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, + ), } }