Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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 @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Debug logging for `sncast` commands that can be enabled by setting `CAST_LOG` env variable.
- `sncast declare` command now outputs a ready-to-use deployment command after successful declaration.
- Support for `--network devnet` flag that attempts to auto-detect running `starknet-devnet` instance and connect to it.

## [0.50.0] - 2025-09-29

Expand Down
3 changes: 2 additions & 1 deletion crates/sncast/src/helpers/block_explorer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ impl Service {
(Service::ViewBlock, Network::Mainnet) => Ok(Box::new(ViewBlock)),
(Service::OkLink, Network::Mainnet) => Ok(Box::new(OkLink)),
(_, Network::Sepolia) => Err(ExplorerError::SepoliaNotSupported),
(_, Network::Devnet) => Err(ExplorerError::DevnetNotSupported),
}
}
}
Expand All @@ -36,8 +37,8 @@ pub trait LinkProvider {

const fn network_subdomain(network: Network) -> &'static str {
match network {
Network::Mainnet => "",
Network::Sepolia => "sepolia.",
Network::Mainnet | Network::Devnet => "",
}
}

Expand Down
312 changes: 312 additions & 0 deletions crates/sncast/src/helpers/devnet_detection.rs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make a separate module devnet, place this file and the one containing DevnetProvider.

Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
use std::process::Command;

#[derive(Debug, Clone)]
struct DevnetInfo {
host: String,
port: u16,
}

#[derive(Debug)]
enum FindDevnetError {
None,
Multiple,
CommandFailed,
}

pub fn detect_devnet_url() -> Result<String, String> {
detect_devnet_from_processes()
}

#[must_use]
pub fn is_devnet_running() -> bool {
detect_devnet_from_processes().is_ok()
}

fn detect_devnet_from_processes() -> Result<String, String> {
match find_devnet_process_info() {
Ok(info) => Ok(format!("http://{}:{}", info.host, info.port)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably do this

Suggested change
Ok(info) => Ok(format!("http://{}:{}", info.host, info.port)),
Ok(DevnetProcessInfo { host, port }) => Ok(format!("http://{}:{}", host, port)),

Err(FindDevnetError::Multiple) => {
Err("Multiple starknet-devnet instances found. Please use --url to specify which one to use.".to_string())
}
Err(FindDevnetError::None | FindDevnetError::CommandFailed) => {
// Fallback to default starknet-devnet URL if reachable
if is_port_reachable("127.0.0.1", 5050) {
Ok("http://127.0.0.1:5050".to_string())
} else {
Err(
"Could not detect running starknet-devnet instance. Please use --url instead."
.to_string(),
)
}
}
}
}

fn find_devnet_process_info() -> Result<DevnetInfo, FindDevnetError> {
let output = Command::new("ps")
.args(["aux"])
.output()
.map_err(|_| FindDevnetError::CommandFailed)?;
let ps_output = String::from_utf8_lossy(&output.stdout);

let devnet_processes: Vec<DevnetInfo> = ps_output
.lines()
.filter(|line| line.contains("starknet-devnet"))
.map(|line| {
if line.contains("docker") || line.contains("podman") {
extract_devnet_info_from_docker_line(line)
} else {
extract_devnet_info_from_cmdline(line)
}
})
.collect();

match devnet_processes.as_slice() {
[] => Err(FindDevnetError::None),
[single] => Ok(single.clone()),
_ => Err(FindDevnetError::Multiple),
}
}

fn extract_string_from_flag(cmdline: &str, flag: &str) -> Option<String> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some helpers could go into a submodule within devnet_detection, in a separate file.

Generally this file could use being split into submodules.

if let Some(pos) = cmdline.find(flag) {
let after_pattern = &cmdline[pos + flag.len()..];
let value_str = after_pattern
.split_whitespace()
.next()
.unwrap_or("")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the iterator returns nothing (we unwrap with default), doesn't that mean we won't find anything anyway?

Also a nit, but probably unwrap_or_default could do here.

.trim_start_matches('=')
.trim_start_matches(':');
Comment on lines +80 to +81
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose if these?


if !value_str.is_empty() {
return Some(value_str.to_string());
}
}
None
}
Comment on lines +74 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't regex be better here?


fn extract_port_from_flag(cmdline: &str, flag: &str) -> Option<u16> {
if let Some(port_str) = extract_string_from_flag(cmdline, flag)
&& let Ok(p) = port_str.parse::<u16>()
&& p > 1024
&& p < 65535
{
return Some(p);
}

None
}

fn extract_docker_mapping(cmdline: &str) -> Option<(String, u16)> {
let port_flags = ["-p", "--publish"];

for flag in &port_flags {
if let Some(port_mapping) = extract_string_from_flag(cmdline, flag) {
let parts: Vec<&str> = port_mapping.split(':').collect();
if parts.len() == 3
&& let Ok(host_port) = parts[1].parse::<u16>()
{
return Some((parts[0].to_string(), host_port));
} else if parts.len() == 2
&& let Ok(host_port) = parts[0].parse::<u16>()
{
return Some(("127.0.0.1".to_string(), host_port));
}
}
}

None
}

fn extract_devnet_info_from_docker_line(cmdline: &str) -> DevnetInfo {
let mut port = None;
let mut host = None;

if let Some((docker_host, docker_port)) = extract_docker_mapping(cmdline) {
host = Some(docker_host);
port = Some(docker_port);
}

if port.is_none() {
port = extract_port_from_flag(cmdline, "--port");
}

let final_host = host.unwrap_or_else(|| "127.0.0.1".to_string());
let final_port = port.unwrap_or(5050);

DevnetInfo {
host: final_host,
port: final_port,
}
}

fn extract_devnet_info_from_cmdline(cmdline: &str) -> DevnetInfo {
let mut port = extract_port_from_flag(cmdline, "--port");
let mut host = extract_string_from_flag(cmdline, "--host");

if port.is_none()
&& let Ok(port_env) = std::env::var("PORT")
&& let Ok(p) = port_env.parse::<u16>()
&& p > 1024
&& p < 65535
{
port = Some(p);
}

if host.is_none()
&& let Ok(host_env) = std::env::var("HOST")
&& !host_env.is_empty()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We validate port but take host directly, to be consistent we should probably either validate both or none.

What was the motivation for only validating one of them?

{
host = Some(host_env);
}

let final_port = port.unwrap_or(5050);
let final_host = host.unwrap_or_else(|| "127.0.0.1".to_string());

DevnetInfo {
host: final_host,
port: final_port,
}
}

fn is_port_reachable(host: &str, port: u16) -> bool {
let url = format!("http://{host}:{port}/is_alive");

std::process::Command::new("curl")
.args(["-s", "-f", "--max-time", "1", &url])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}

#[cfg(test)]
mod tests {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't review the tests yet

use super::*;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

// These tests are marked to run serially to avoid interference from environment variables
#[test]
fn test_devnet_parsing() {
test_extract_devnet_info_from_cmdline();

test_extract_devnet_info_from_docker_line();

test_extract_devnet_info_with_both_envs();

test_cmdline_args_override_env();

test_detect_devnet_url();
}

fn test_extract_devnet_info_from_cmdline() {
let cmdline1 = "starknet-devnet --port 6000 --host 127.0.0.1";
let info1 = extract_devnet_info_from_cmdline(cmdline1);
assert_eq!(info1.port, 6000);
assert_eq!(info1.host, "127.0.0.1");

let cmdline2 = "/usr/bin/starknet-devnet --port=5000";
let info2 = extract_devnet_info_from_cmdline(cmdline2);
assert_eq!(info2.port, 5000);
assert_eq!(info2.host, "127.0.0.1");

let cmdline3 = "starknet-devnet --host 127.0.0.1";
let info3 = extract_devnet_info_from_cmdline(cmdline3);
assert_eq!(info3.port, 5050);
assert_eq!(info3.host, "127.0.0.1");
}

fn test_extract_devnet_info_from_docker_line() {
let cmdline1 = "docker run -p 127.0.0.1:5055:5050 shardlabs/starknet-devnet-rs";
let info1 = extract_devnet_info_from_docker_line(cmdline1);
assert_eq!(info1.port, 5055);
assert_eq!(info1.host, "127.0.0.1");

let cmdline2 = "docker run --publish 8080:5050 shardlabs/starknet-devnet-rs";
let info2 = extract_devnet_info_from_docker_line(cmdline2);
assert_eq!(info2.port, 8080);
assert_eq!(info2.host, "127.0.0.1");

let cmdline3 = "docker run --network host shardlabs/starknet-devnet-rs --port 5055";
let info3 = extract_devnet_info_from_docker_line(cmdline3);
assert_eq!(info3.port, 5055);
assert_eq!(info3.host, "127.0.0.1");
}

fn test_extract_devnet_info_with_both_envs() {
// SAFETY: Variables are only modified within this test and cleaned up afterwards
unsafe {
std::env::set_var("PORT", "9999");
std::env::set_var("HOST", "9.9.9.9");
}

let cmdline = "starknet-devnet";
let info = extract_devnet_info_from_cmdline(cmdline);
assert_eq!(info.port, 9999);
assert_eq!(info.host, "9.9.9.9");

// SAFETY: Clean up environment variables to prevent interference
unsafe {
std::env::remove_var("PORT");
std::env::remove_var("HOST");
}
}

fn test_cmdline_args_override_env() {
// SAFETY: Variables are only modified within this test and cleaned up afterwards
unsafe {
std::env::set_var("PORT", "3000");
std::env::set_var("HOST", "7.7.7.7");
}

let cmdline = "starknet-devnet --port 9999 --host 192.168.1.1";
let info = extract_devnet_info_from_cmdline(cmdline);
assert_eq!(info.port, 9999);
assert_eq!(info.host, "192.168.1.1");

// SAFETY: Clean up environment variables to prevent interference
unsafe {
std::env::remove_var("PORT");
std::env::remove_var("HOST");
}
}

fn test_detect_devnet_url() {
let child = spawn_devnet("5090");

let result = detect_devnet_url().expect("Failed to detect devnet URL");
assert_eq!(result, "http://127.0.0.1:5090");

cleanup_process(child);
}

fn spawn_devnet(port: &str) -> std::process::Child {
let mut child = Command::new("starknet-devnet")
.args(["--port", port])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("Failed to spawn starknet-devnet process");

let port_num: u16 = port.parse().expect("Invalid port number");
let start_time = Instant::now();
let timeout = Duration::from_secs(10);

while start_time.elapsed() < timeout {
if is_port_reachable("127.0.0.1", port_num) {
return child;
}
thread::sleep(Duration::from_millis(500));
}

let _ = child.kill();
let _ = child.wait();
panic!("Devnet did not start in time on port {port}");
}

fn cleanup_process(mut child: std::process::Child) {
child.kill().expect("Failed to kill devnet process");
child.wait().expect("Failed to wait for devnet process");
}
}
1 change: 1 addition & 0 deletions crates/sncast/src/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod command;
pub mod config;
pub mod configuration;
pub mod constants;
pub mod devnet_detection;
pub mod devnet_provider;
pub mod fee;
pub mod interactive;
Expand Down
Loading
Loading