Skip to content

Add /v3/contracts/fast-call-read endpoint for (authorized) read only calls without cost tracking #6207

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jul 9, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/rpc/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions stackslib/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3709,6 +3709,13 @@ pub struct ConnectionOptionsFile {
/// @default: [`DEFAULT_BLOCK_PROPOSAL_MAX_AGE_SECS`]
/// @units: seconds
pub block_proposal_max_age_secs: Option<u64>,

/// 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<u64>,
}

impl ConnectionOptionsFile {
Expand Down Expand Up @@ -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
})
}
Expand Down
99 changes: 82 additions & 17 deletions stackslib/src/net/api/callreadonly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

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};
Expand Down Expand Up @@ -68,10 +70,16 @@ pub struct RPCCallReadOnlyRequestHandler {
pub sender: Option<PrincipalData>,
pub sponsor: Option<PrincipalData>,
pub arguments: Option<Vec<Value>>,

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,
Expand All @@ -80,6 +88,7 @@ impl RPCCallReadOnlyRequestHandler {
sender: None,
sponsor: None,
arguments: None,
read_only_max_execution_time,
}
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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| {
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -337,6 +398,7 @@ impl StacksHttpRequest {
function_name: ClarityName,
function_args: Vec<Value>,
tip_req: TipRequest,
cost_tracker: CostTrackerRequest,
) -> StacksHttpRequest {
StacksHttpRequest::new_for_peer(
host,
Expand All @@ -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")
}
Expand Down
1 change: 1 addition & 0 deletions stackslib/src/net/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading