mod guard;
mod stats;
use axum::{
body::Body,
extract::{ConnectInfo, Path, Query, Request},
http::{header, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::json;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use svmscope::spec::{MutationInput, SuiteRequest};
use svmscope::{Analysis, Mutation, ReplayResult, ScenarioOutcome, Scope, TimeTravel};
const DEFAULT_RPC: &str = "https://api.mainnet-beta.solana.com";
fn rpc_url() -> String {
std::env::var("SVMSCOPE_RPC_URL")
.or_else(|_| std::env::var("RPC_URL"))
.unwrap_or_else(|_| DEFAULT_RPC.to_string())
}
fn env_http(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| v.starts_with("http"))
}
fn cluster_env_rpc(cluster: Option<&str>) -> Option<String> {
match cluster.map(|c| c.trim().to_ascii_lowercase()).as_deref() {
Some("devnet") | Some("d") => env_http("SVMSCOPE_RPC_URL_DEVNET"),
Some("testnet") | Some("t") => env_http("SVMSCOPE_RPC_URL_TESTNET"),
Some("mainnet") | Some("mainnet-beta") | Some("m") => env_http("SVMSCOPE_RPC_URL_MAINNET")
.or_else(|| env_http("SVMSCOPE_RPC_URL"))
.or_else(|| env_http("RPC_URL")),
_ => None,
}
}
#[derive(Deserialize)]
struct ClusterQuery {
cluster: Option<String>,
rpc: Option<String>,
}
fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| v6.to_ipv4_mapped().is_some_and(|m| is_blocked_ip(IpAddr::V4(m)))
}
}
}
fn vet_custom_rpc(url: &str) -> Option<String> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let hostport = rest.split(['/', '?', '#']).next().unwrap_or("");
let host = hostport
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(hostport);
let host = host.trim_matches(['[', ']']); if host.is_empty() {
return None;
}
if let Ok(ip) = host.parse::<IpAddr>() {
return (!is_blocked_ip(ip)).then(|| url.to_string());
}
let addrs = (host, 443u16).to_socket_addrs().ok()?;
let mut any = false;
for a in addrs {
any = true;
if is_blocked_ip(a.ip()) {
return None;
}
}
any.then(|| url.to_string())
}
fn custom_rpc_allowed() -> bool {
matches!(
std::env::var("SVMSCOPE_ALLOW_CUSTOM_RPC").ok().as_deref(),
Some("1") | Some("true") | Some("yes")
)
}
fn public_cluster_ok(c: &str) -> bool {
matches!(
c.trim().to_ascii_lowercase().as_str(),
"mainnet" | "mainnet-beta" | "m" | "devnet" | "d" | "testnet" | "t"
)
}
fn localnet_alias(c: &str) -> bool {
matches!(
c.trim().to_ascii_lowercase().as_str(),
"localnet" | "local" | "localhost" | "l"
)
}
fn rpc_for(cluster: Option<&str>, rpc: Option<&str>) -> String {
let allow = custom_rpc_allowed();
let cluster = cluster.filter(|c| public_cluster_ok(c) || (allow && localnet_alias(c)));
if allow {
if let Some(u) = rpc {
if let Some(safe) = vet_custom_rpc(u) {
return safe;
}
}
}
if let Some(u) = cluster_env_rpc(cluster) {
return u;
}
let safe_rpc = rpc.filter(|u| allow && vet_custom_rpc(u).is_some());
svmscope::resolve_rpc_url(cluster, safe_rpc, &rpc_url()).unwrap_or_else(|_| rpc_url())
}
#[derive(Deserialize)]
struct SimRequest {
signature: String,
mutations: Vec<MutationInput>,
#[serde(default)]
time_travel: TimeTravel,
#[serde(default)]
features: Vec<svmscope::spec::FeatureInput>,
#[serde(default)]
cluster: Option<String>,
#[serde(default)]
rpc: Option<String>,
}
async fn index() -> Html<&'static str> {
Html(include_str!("../../../static/index.html"))
}
fn lib_err(e: svmscope::Error) -> (StatusCode, String) {
use svmscope::Error as E;
match &e {
E::TransactionNotFound(_) | E::NoSignatures(_) | E::AccountNotFound(_) => {
(StatusCode::NOT_FOUND, e.to_string())
}
E::Rpc(_) | E::MalformedRpcResponse(_) => {
eprintln!("upstream RPC error: {e}");
(StatusCode::BAD_GATEWAY, "upstream RPC error".to_string())
}
_ => (StatusCode::BAD_REQUEST, e.to_string()),
}
}
async fn analyze_handler(
Path(signature): Path<String>,
Query(q): Query<ClusterQuery>,
) -> Result<Json<Analysis>, (StatusCode, String)> {
let url = rpc_for(q.cluster.as_deref(), q.rpc.as_deref());
let result = tokio::task::spawn_blocking(move || Scope::new(url).analyze(&signature))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(analysis) => Ok(Json(analysis)),
Err(e) => Err(lib_err(e)),
}
}
const MAX_MUTATIONS_PER_REQUEST: usize = 256;
const MAX_SCENARIOS_PER_REQUEST: usize = 64;
fn cap(count: usize, limit: usize, what: &str) -> Result<(), (StatusCode, String)> {
if count > limit {
return Err((
StatusCode::BAD_REQUEST,
format!("too many {what}: {count} (limit {limit})"),
));
}
Ok(())
}
async fn simulate_handler(
Json(req): Json<SimRequest>,
) -> Result<Json<ReplayResult>, (StatusCode, String)> {
cap(req.mutations.len(), MAX_MUTATIONS_PER_REQUEST, "mutations")?;
let mutations: Vec<Mutation> = req
.mutations
.into_iter()
.map(MutationInput::into_mutation)
.collect::<Result<_, _>>()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let features = svmscope::spec::feature_toggles(req.features)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let url = rpc_for(req.cluster.as_deref(), req.rpc.as_deref());
let result = tokio::task::spawn_blocking(move || -> Result<ReplayResult, svmscope::Error> {
let mut replay = Scope::new(url).replay(&req.signature)?;
replay.set_time_travel(req.time_travel);
replay.set_features(features);
Ok(replay.simulate(&mutations)?.result)
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(replay) => Ok(Json(replay)),
Err(e) => Err(lib_err(e)),
}
}
async fn suite_handler(
Json(req): Json<SuiteRequest>,
) -> Result<Json<Vec<ScenarioOutcome>>, (StatusCode, String)> {
if req.fixture.is_some() {
return Err((
StatusCode::BAD_REQUEST,
"fixture suites run locally: `svmscope test suite.json`. The API needs a `signature`."
.to_string(),
));
}
let signature = req
.signature
.clone()
.ok_or((StatusCode::BAD_REQUEST, "signature is required".to_string()))?;
cap(req.scenarios.len(), MAX_SCENARIOS_PER_REQUEST, "scenarios")?;
let total_mutations: usize = req.scenarios.iter().map(|s| s.mutations.len()).sum();
cap(total_mutations, MAX_MUTATIONS_PER_REQUEST, "mutations")?;
let url = rpc_for(req.cluster.as_deref(), req.rpc.as_deref());
let scenarios = req
.scenarios
.into_iter()
.map(|s| s.into_scenario())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let features = svmscope::spec::feature_toggles(req.features)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let result =
tokio::task::spawn_blocking(move || -> Result<Vec<ScenarioOutcome>, svmscope::Error> {
let mut replay = Scope::new(url).replay(&signature)?;
replay.set_time_travel(req.time_travel);
replay.set_features(features);
replay.run_suite(&scenarios)
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(outcomes) => Ok(Json(outcomes)),
Err(e) => Err(lib_err(e)),
}
}
#[derive(Deserialize)]
struct PreflightRequest {
transaction: String,
#[serde(default)]
mutations: Vec<MutationInput>,
#[serde(default)]
time_travel: TimeTravel,
#[serde(default)]
features: Vec<svmscope::spec::FeatureInput>,
#[serde(default)]
cluster: Option<String>,
#[serde(default)]
rpc: Option<String>,
}
async fn preflight_handler(
Json(req): Json<PreflightRequest>,
) -> Result<Json<ReplayResult>, (StatusCode, String)> {
cap(req.mutations.len(), MAX_MUTATIONS_PER_REQUEST, "mutations")?;
let mutations: Vec<Mutation> = req
.mutations
.into_iter()
.map(MutationInput::into_mutation)
.collect::<Result<_, _>>()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let url = rpc_for(req.cluster.as_deref(), req.rpc.as_deref());
let result = tokio::task::spawn_blocking(move || -> Result<ReplayResult, svmscope::Error> {
let replay = Scope::new(url).preflight(&req.transaction)?;
Ok(replay.simulate(&mutations)?.result)
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(r) => Ok(Json(r)),
Err(e) => Err(lib_err(e)),
}
}
async fn account_handler(
Path(address): Path<String>,
Query(q): Query<ClusterQuery>,
) -> Result<Json<svmscope::AccountOverview>, (StatusCode, String)> {
let url = rpc_for(q.cluster.as_deref(), q.rpc.as_deref());
let result = tokio::task::spawn_blocking(move || Scope::new(url).account(&address))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(ov) => Ok(Json(ov)),
Err(e) => Err(lib_err(e)),
}
}
async fn signatures_handler(
Path(address): Path<String>,
Query(q): Query<ClusterQuery>,
) -> Result<Json<Vec<svmscope::SigInfo>>, (StatusCode, String)> {
let url = rpc_for(q.cluster.as_deref(), q.rpc.as_deref());
let result = tokio::task::spawn_blocking(move || Scope::new(url).signatures(&address, 25))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(sigs) => Ok(Json(sigs)),
Err(e) => Err(lib_err(e)),
}
}
async fn preflight_report_handler(
Json(req): Json<PreflightRequest>,
) -> Result<Json<svmscope::SimulationReport>, (StatusCode, String)> {
cap(req.mutations.len(), MAX_MUTATIONS_PER_REQUEST, "mutations")?;
let mutations: Vec<Mutation> = req
.mutations
.into_iter()
.map(MutationInput::into_mutation)
.collect::<Result<_, _>>()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let features = svmscope::spec::feature_toggles(req.features)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let url = rpc_for(req.cluster.as_deref(), req.rpc.as_deref());
let tt = req.time_travel.clone();
let result = tokio::task::spawn_blocking(
move || -> Result<svmscope::SimulationReport, svmscope::Error> {
let mut replay = Scope::new(url).preflight(&req.transaction)?;
replay.set_time_travel(tt);
replay.set_features(features);
Ok(replay.simulate(&mutations)?.into_report())
},
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
result.map(Json).map_err(lib_err)
}
async fn replay_report_handler(
Json(req): Json<SimRequest>,
) -> Result<Json<svmscope::SimulationReport>, (StatusCode, String)> {
cap(req.mutations.len(), MAX_MUTATIONS_PER_REQUEST, "mutations")?;
let mutations: Vec<Mutation> = req
.mutations
.into_iter()
.map(MutationInput::into_mutation)
.collect::<Result<_, _>>()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let features = svmscope::spec::feature_toggles(req.features)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let url = rpc_for(req.cluster.as_deref(), req.rpc.as_deref());
let tt = req.time_travel.clone();
let result = tokio::task::spawn_blocking(
move || -> Result<svmscope::SimulationReport, svmscope::Error> {
let mut replay = Scope::new(url).replay(&req.signature)?;
replay.set_time_travel(tt);
replay.set_features(features);
Ok(replay.simulate(&mutations)?.into_report())
},
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
result.map(Json).map_err(lib_err)
}
#[derive(Deserialize)]
struct IdlRequest {
#[serde(default)]
address: Option<String>,
idl: serde_json::Value,
#[serde(default)]
cluster: Option<String>,
#[serde(default)]
rpc: Option<String>,
}
async fn decode_account_handler(
Json(req): Json<IdlRequest>,
) -> Result<Json<svmscope::AccountInfo>, (StatusCode, String)> {
let address = req
.address
.clone()
.ok_or((StatusCode::BAD_REQUEST, "address is required".to_string()))?;
let url = rpc_for(req.cluster.as_deref(), req.rpc.as_deref());
let idl = (!req.idl.is_null()).then_some(req.idl);
let result =
tokio::task::spawn_blocking(move || Scope::new(url).decode_account(&address, idl.as_ref()))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
result.map(Json).map_err(lib_err)
}
async fn idl_instructions_handler(
Json(req): Json<IdlRequest>,
) -> Json<Vec<svmscope::idl::IdlInstruction>> {
Json(svmscope::idl::instructions(&req.idl))
}
async fn instructions_handler(
Path(program): Path<String>,
Query(q): Query<ClusterQuery>,
) -> Result<Json<Vec<svmscope::idl::IdlInstruction>>, (StatusCode, String)> {
let url = rpc_for(q.cluster.as_deref(), q.rpc.as_deref());
let result =
tokio::task::spawn_blocking(move || Scope::new(url).program_instructions(&program))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
result.map(Json).map_err(lib_err)
}
async fn replay_handler(
Path(signature): Path<String>,
Query(q): Query<ClusterQuery>,
) -> Result<Json<ReplayResult>, (StatusCode, String)> {
let url = rpc_for(q.cluster.as_deref(), q.rpc.as_deref());
let result = tokio::task::spawn_blocking(move || -> Result<ReplayResult, svmscope::Error> {
Ok(Scope::new(url).replay(&signature)?.run()?.result)
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(replay) => Ok(Json(replay)),
Err(e) => Err(lib_err(e)),
}
}
async fn freeze_handler(
Path(signature): Path<String>,
Query(q): Query<ClusterQuery>,
) -> Result<Json<svmscope::Fixture>, (StatusCode, String)> {
let url = rpc_for(q.cluster.as_deref(), q.rpc.as_deref());
let result = tokio::task::spawn_blocking(move || Scope::new(url).capture(&signature))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("task error: {e}"),
)
})?;
match result {
Ok(fx) => Ok(Json(fx)),
Err(e) => Err(lib_err(e)),
}
}
async fn api_index() -> Json<serde_json::Value> {
Json(json!({
"name": "svmscope",
"description": "Solana transaction simulation layer — decode, replay, mutate, assert.",
"version": env!("CARGO_PKG_VERSION"),
"custom_rpc": custom_rpc_allowed(),
"endpoints": {
"GET /analyze/{signature}": "Decode a transaction: CPI tree, balance & token changes, compute, and IDL-decoded accounts.",
"GET /replay/{signature}": "Re-execute the transaction locally against reconstructed pre-state.",
"POST /simulate": "{ signature, mutations[], time_travel?, features? } — replay with what-if account mutations, an optional clock warp, and optional runtime feature-gate toggles.",
"POST /simulate_suite": "{ signature, scenarios[], time_travel?, features? } — run a suite of scenarios with outcome + state assertions, under optional feature-gate toggles.",
"POST /preflight": "{ transaction, mutations[] } — simulate an UNSIGNED transaction against current state before sending.",
"GET /freeze/{signature}": "Capture a self-contained fixture for deterministic, offline replay."
}
}))
}
fn client_id(req: &Request, peer: Option<SocketAddr>) -> String {
req.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next_back())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| peer.map(|p| p.ip().to_string()))
.unwrap_or_else(|| "unknown".into())
}
async fn rate_limit(
ConnectInfo(peer): ConnectInfo<SocketAddr>,
req: Request,
next: Next,
) -> Response {
let path = req.uri().path().to_string();
if let Some(label) = endpoint_label(&path) {
let cid = client_id(&req, Some(peer));
if let Err(retry) = guard::rate_check(&cid) {
return (
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, retry.to_string())],
format!("rate limit reached — try again in {retry}s"),
)
.into_response();
}
stats::record(label, &cid);
}
next.run(req).await
}
fn endpoint_label(path: &str) -> Option<&'static str> {
if path.starts_with("/analyze") {
Some("analyze")
} else if path.starts_with("/replay") {
Some("replay")
} else if path.starts_with("/simulate_suite") {
Some("simulate_suite")
} else if path.starts_with("/simulate") {
Some("simulate")
} else if path.starts_with("/preflight") {
Some("preflight")
} else if path.starts_with("/freeze") {
Some("freeze")
} else if path.starts_with("/account") {
Some("account")
} else if path.starts_with("/signatures") {
Some("signatures")
} else {
None
}
}
async fn cache_layer(req: Request, next: Next) -> Response {
let path = req.uri().path();
let cacheable = req.method() == axum::http::Method::GET
&& (path.starts_with("/analyze")
|| path.starts_with("/account")
|| path.starts_with("/signatures")
|| path.starts_with("/replay"));
if !cacheable {
return next.run(req).await;
}
let key = req.uri().to_string();
if let Some(body) = guard::cache_get(&key) {
return (
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/json"),
(header::HeaderName::from_static("x-cache"), "HIT"),
],
body,
)
.into_response();
}
let res = next.run(req).await;
if res.status() != StatusCode::OK {
return res;
}
let (mut parts, body) = res.into_parts();
let bytes = match axum::body::to_bytes(body, 32 * 1024 * 1024).await {
Ok(b) => b,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "response read error").into_response()
}
};
if let Ok(text) = String::from_utf8(bytes.to_vec()) {
guard::cache_put(key, text);
}
parts.headers.insert(
header::HeaderName::from_static("x-cache"),
header::HeaderValue::from_static("MISS"),
);
Response::from_parts(parts, Body::from(bytes))
}
#[derive(Deserialize)]
struct StatsQuery {
token: Option<String>,
}
async fn stats_handler(Query(q): Query<StatsQuery>) -> Response {
let configured = std::env::var("SVMSCOPE_STATS_TOKEN")
.ok()
.filter(|t| !t.is_empty());
match configured {
Some(expected) if q.token.as_deref().is_some_and(|t| ct_eq(t, &expected)) => {
Json(stats::snapshot_json()).into_response()
}
_ => (StatusCode::NOT_FOUND, "not found").into_response(),
}
}
fn ct_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[tokio::main]
async fn main() {
stats::load();
let cors = tower_http::cors::CorsLayer::permissive();
let app = Router::new()
.route("/", get(index))
.route("/api", get(api_index))
.route("/analyze/{signature}", get(analyze_handler))
.route("/simulate", post(simulate_handler))
.route("/simulate_suite", post(suite_handler))
.route("/preflight", post(preflight_handler))
.route("/preflight_report", post(preflight_report_handler))
.route("/replay_report", post(replay_report_handler))
.route("/instructions/{program}", get(instructions_handler))
.route("/idl_instructions", post(idl_instructions_handler))
.route("/decode_account", post(decode_account_handler))
.route("/account/{address}", get(account_handler))
.route("/signatures/{address}", get(signatures_handler))
.route("/replay/{signature}", get(replay_handler))
.route("/freeze/{signature}", get(freeze_handler))
.route("/stats", get(stats_handler))
.layer(middleware::from_fn(cache_layer))
.layer(middleware::from_fn(rate_limit))
.layer(cors);
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let addr = format!("{host}:{port}");
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
eprintln!("svmscope: {addr} is already in use — is a server already running?");
eprintln!(" (stop it with: lsof -ti:{port} | xargs kill )");
std::process::exit(1);
}
Err(e) => {
eprintln!("svmscope: could not bind {addr}: {e}");
std::process::exit(1);
}
};
println!("svmscope → http://{addr} (API index: /api)");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssrf_guard_blocks_internal_targets() {
assert!(vet_custom_rpc("http://169.254.169.254/latest/meta-data").is_none());
assert!(vet_custom_rpc("http://localhost:8899").is_none());
assert!(vet_custom_rpc("http://127.0.0.1/").is_none());
assert!(vet_custom_rpc("http://10.0.0.5:8899").is_none());
assert!(vet_custom_rpc("http://192.168.1.1").is_none());
assert!(vet_custom_rpc("http://[::1]:8899").is_none());
assert!(vet_custom_rpc("http://0.0.0.0").is_none());
assert!(vet_custom_rpc("file:///etc/passwd").is_none());
assert!(vet_custom_rpc("not-a-url").is_none());
}
#[test]
fn ssrf_guard_allows_public_rpc() {
let ok = vet_custom_rpc("https://8.8.8.8/");
assert_eq!(ok.as_deref(), Some("https://8.8.8.8/"));
}
#[test]
fn blocked_ip_classifies_ranges() {
assert!(is_blocked_ip("169.254.169.254".parse().unwrap()));
assert!(is_blocked_ip("127.0.0.1".parse().unwrap()));
assert!(is_blocked_ip("100.100.0.1".parse().unwrap())); assert!(!is_blocked_ip("8.8.8.8".parse().unwrap()));
assert!(!is_blocked_ip("1.1.1.1".parse().unwrap()));
}
#[test]
fn caller_rpc_ignored_when_custom_disabled() {
assert!(!custom_rpc_allowed());
let out = rpc_for(None, Some("http://8.8.8.8:9999/evil"));
assert_ne!(out, "http://8.8.8.8:9999/evil");
}
#[test]
fn public_instance_rejects_url_and_localnet_clusters() {
assert!(!custom_rpc_allowed());
let meta = rpc_for(Some("http://169.254.169.254/latest/meta-data"), None);
assert!(!meta.contains("169.254"), "url cluster leaked: {meta}");
let local = rpc_for(Some("localnet"), None);
assert!(!local.contains("127.0.0.1"), "localnet leaked: {local}");
assert!(rpc_for(Some("devnet"), None).starts_with("http"));
}
#[test]
fn public_cluster_allowlist() {
assert!(public_cluster_ok("mainnet"));
assert!(public_cluster_ok("devnet"));
assert!(public_cluster_ok("testnet"));
assert!(!public_cluster_ok("localnet"));
assert!(!public_cluster_ok("http://169.254.169.254"));
}
#[test]
fn localnet_alias_is_a_name_never_a_url() {
assert!(localnet_alias("localnet"));
assert!(localnet_alias("localhost"));
assert!(!localnet_alias("http://127.0.0.1:8899"));
assert!(!localnet_alias("http://169.254.169.254"));
}
}