Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -20,6 +20,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.
- Possibility to use [`starknet-devnet`](https://github.com/0xSpaceShard/starknet-devnet) predeployed accounts directly in `sncast` without needing to import them. They are available under specific names - `devnet-1`, `devnet-2`, ..., `devnet-<N>`. Read more [here](https://foundry-rs.github.io/starknet-foundry/starknet/integration_with_devnet.html#predeployed-accounts)
- 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
308 changes: 308 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,308 @@
use std::process::Command;

const DEFAULT_DEVNET_HOST: &str = "127.0.0.1";
const DEFAULT_DEVNET_PORT: u16 = 5050;

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

#[derive(Debug)]
enum DevnetDetectionError {
NoInstance,
MultipleInstances,
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(DevnetDetectionError::MultipleInstances) => {
Err("Multiple starknet-devnet instances found. Please use `--url <URL>` to specify which one to use.".to_string())
}
Err(DevnetDetectionError::NoInstance | DevnetDetectionError::CommandFailed) => {
// Fallback to default starknet-devnet URL if reachable
if is_port_reachable(DEFAULT_DEVNET_HOST, DEFAULT_DEVNET_PORT) {
Ok(format!("http://{DEFAULT_DEVNET_HOST}:{DEFAULT_DEVNET_PORT}"))
} else {
Err(
"Could not detect running starknet-devnet instance. Please use `--url <URL>` instead or start devnet if it is not running."
.to_string(),
)
}
}
}
}

fn find_devnet_process_info() -> Result<DevnetProcessInfo, DevnetDetectionError> {
let output = Command::new("sh")
.args(["-c", "ps aux | grep starknet-devnet | grep -v grep"])
.output()
.map_err(|_| DevnetDetectionError::CommandFailed)?;
let ps_output = String::from_utf8_lossy(&output.stdout);

let devnet_processes: Vec<DevnetProcessInfo> = ps_output
.lines()
.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(DevnetDetectionError::NoInstance),
[single] => Ok(single.clone()),
_ => Err(DevnetDetectionError::MultipleInstances),
Comment on lines +67 to +69
Copy link
Contributor

@franciszekjob franciszekjob Oct 7, 2025

Choose a reason for hiding this comment

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

Ok at the top:

Suggested change
[] => Err(DevnetDetectionError::NoInstance),
[single] => Ok(single.clone()),
_ => Err(DevnetDetectionError::MultipleInstances),
[single] => Ok(single.clone()),
[] => Err(DevnetDetectionError::NoInstance),
_ => Err(DevnetDetectionError::MultipleInstances),

}
}

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> {
extract_string_from_flag(cmdline, flag).and_then(|port_str| parse_valid_port(&port_str))
}

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((DEFAULT_DEVNET_HOST.to_string(), host_port));
}
}
}

None
}
Comment on lines +94 to +113
Copy link
Contributor

Choose a reason for hiding this comment

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

Same remark about regex usage


fn extract_devnet_info_from_docker_line(cmdline: &str) -> DevnetProcessInfo {
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(|| DEFAULT_DEVNET_HOST.to_string());
let final_port = port.unwrap_or(DEFAULT_DEVNET_PORT);

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

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

if port.is_none() {
port = std::env::var("PORT")
.ok()
.and_then(|port_env| parse_valid_port(&port_env));
Copy link
Member

Choose a reason for hiding this comment

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

This way we silently ignore user settings if they are invalid. If we aren't doing any nice error handling for invalid values I'd rather we panic than silently ignore them.

Copy link
Member

Choose a reason for hiding this comment

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

This goes for all places we do so, not only here

}

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(DEFAULT_DEVNET_PORT);
let final_host = host.unwrap_or_else(|| DEFAULT_DEVNET_HOST.to_string());

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

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

Command::new("curl")
.args(["-s", "-f", "--max-time", "1", &url])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
Comment on lines +166 to +170
Copy link
Member

Choose a reason for hiding this comment

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

Rather than rely on curl being installed, couldn't we use some library for HTTP requests?

Copy link
Member

Choose a reason for hiding this comment

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

We already have a dependency on reqwest

}

fn parse_valid_port(port_str: &str) -> Option<u16> {
// Ports below 1024 typically require elevated permissions
port_str.parse::<u16>().ok().filter(|&p| p >= 1024)
}

#[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