mod decode;
mod log;
mod proxy;
mod rpc;
mod ws;
use std::sync::Arc;
use actix_cors::Cors;
use actix_web::{App, HttpRequest, HttpResponse, HttpServer, web};
use clap::Parser;
use rust_embed::Embed;
use serde_json::Value;
use rpc::{JsonRpcRequest, JsonRpcResponse, MethodType, classify_method};
use ws::AppState;
#[derive(Embed)]
#[folder = "frontend/out"]
struct FrontendAssets;
const DEFAULT_REOWN_PROJECT_ID: &str = "15362e9c1f56f36ebec18ad143adf101";
#[derive(Parser)]
#[command(
name = "cipher-gate",
about = "Cipher Gate — proxy RPC that routes signing requests to a browser wallet"
)]
struct Cli {
#[arg(long)]
rpc_url: String,
#[arg(long, default_value = "8545")]
port: u16,
#[arg(long)]
ui_port: Option<u16>,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long = "allowed-origin")]
allowed_origins: Vec<String>,
#[arg(long, env = "REOWN_PROJECT_ID", default_value = DEFAULT_REOWN_PROJECT_ID)]
reown_project_id: String,
}
pub struct Config {
pub reown_project_id: String,
pub ws_url: String,
}
async fn handle_rpc(state: web::Data<Arc<AppState>>, body: web::Json<Value>) -> HttpResponse {
let raw = body.into_inner();
if let Value::Array(batch) = &raw {
let requests: Vec<JsonRpcRequest> = match batch
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<Result<Vec<_>, _>>()
{
Ok(r) => r,
Err(e) => {
return HttpResponse::Ok().json(JsonRpcResponse::error(
Value::Null,
-32700,
format!("Parse error: {e}"),
));
}
};
let mut responses = Vec::with_capacity(requests.len());
for req in &requests {
responses.push(handle_single_request(&state, req).await);
}
return HttpResponse::Ok().json(responses);
}
let request: JsonRpcRequest = match serde_json::from_value(raw) {
Ok(r) => r,
Err(e) => {
return HttpResponse::Ok().json(JsonRpcResponse::error(
Value::Null,
-32700,
format!("Parse error: {e}"),
));
}
};
let response = handle_single_request(&state, &request).await;
HttpResponse::Ok().json(response)
}
async fn handle_single_request(state: &AppState, request: &JsonRpcRequest) -> JsonRpcResponse {
let method_type = classify_method(&request.method);
match method_type {
MethodType::Read => {
proxy::forward_to_upstream(&state.http_client, &state.rpc_url, request).await
}
MethodType::Account => {
let addr = state.connected_address.read().await;
match addr.as_ref() {
Some(address) => JsonRpcResponse::success(
request.id.clone(),
Value::Array(vec![Value::String(address.clone())]),
),
None => JsonRpcResponse::success(request.id.clone(), Value::Array(vec![])),
}
}
MethodType::Write => {
log::intercepted(&request.method);
let (simulation, decoded_calldata) = if request.method == "eth_sendTransaction" {
let tx_obj = if let Value::Array(arr) = &request.params {
arr.first()
} else {
Some(&request.params)
};
let calldata_hex = tx_obj.and_then(|obj| {
obj.get("data")
.or(obj.get("input"))
.and_then(|v| v.as_str())
.filter(|s| s.len() > 2 && *s != "0x")
.map(String::from)
});
let to_addr = tx_obj
.and_then(|obj| obj.get("to"))
.and_then(|v| v.as_str())
.map(String::from);
let chain_id = state.chain_id.read().await.clone();
let (sim, decoded) = tokio::join!(
async {
let sim = proxy::simulate_transaction(
&state.http_client,
&state.rpc_url,
&request.params,
)
.await;
if sim.success {
log::simulation_passed(sim.gas_estimate.as_deref().unwrap_or("?"));
} else {
log::simulation_failed(
sim.revert_reason.as_deref().unwrap_or("unknown"),
);
}
Some(sim)
},
async {
match calldata_hex {
Some(ref hex) => {
let result = decode::decode_calldata(
&state.http_client,
&state.selector_cache,
&state.rpc_url,
chain_id.as_deref(),
to_addr.as_deref(),
hex,
)
.await;
if let Some(ref d) = result {
log::decoded(&d.signature);
for w in &d.warnings {
log::calldata_warning(&format!("{:?}", w));
}
}
result
}
None => None,
}
}
);
(sim, decoded)
} else {
(None, None)
};
let request_id = uuid::Uuid::new_v4().to_string();
let method_for_log = request.method.clone();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(600);
let mut logged_waiting = false;
let rx = loop {
let connected = state.frontend_connected.notified();
if let Some(rx) = state
.send_signing_request(
request_id.clone(),
request.method.clone(),
request.params.clone(),
simulation.clone(),
decoded_calldata.clone(),
)
.await
{
break rx;
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
log::timeout(&method_for_log);
return JsonRpcResponse::error(
request.id.clone(),
-32603,
"No frontend connected before timeout. Open the signing UI and connect a wallet.",
);
}
if !logged_waiting {
log::waiting_for_frontend();
logged_waiting = true;
}
tokio::select! {
_ = connected => {}
_ = tokio::time::sleep(remaining) => {
log::timeout(&method_for_log);
return JsonRpcResponse::error(
request.id.clone(),
-32603,
"No frontend connected before timeout. Open the signing UI and connect a wallet.",
);
}
}
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
match tokio::time::timeout(remaining, rx).await {
Ok(Ok(mut resp)) => {
resp.id = request.id.clone();
if resp.error.is_some() {
log::rejected(&method_for_log);
} else {
log::signed(&method_for_log);
}
resp
}
Ok(Err(_)) => {
log::rejected(&method_for_log);
JsonRpcResponse::error(
request.id.clone(),
-32603,
"Frontend connection lost while waiting for signature",
)
}
Err(_) => {
state.pending.remove(&request_id);
log::timeout(&method_for_log);
JsonRpcResponse::error(
request.id.clone(),
-32603,
"Signing request timed out (600s)",
)
}
}
}
}
}
async fn ws_handler(
req: HttpRequest,
stream: web::Payload,
state: web::Data<Arc<AppState>>,
) -> Result<HttpResponse, actix_web::Error> {
let token_ok = req.query_string().split('&').any(|kv| {
let mut it = kv.splitn(2, '=');
it.next() == Some("token") && it.next() == Some(state.auth_token.as_str())
});
if !token_ok {
return Ok(HttpResponse::Unauthorized().body("invalid or missing token"));
}
if let Some(origin) = req.headers().get("origin").and_then(|v| v.to_str().ok())
&& !state.allowed_origins.iter().any(|o| o == origin)
{
return Ok(HttpResponse::Forbidden().body("origin not allowed"));
}
let (response, session, msg_stream) = actix_ws::handle(&req, stream)?;
let state = state.get_ref().clone();
actix_web::rt::spawn(ws::handle_ws_connection(state, session, msg_stream));
Ok(response)
}
async fn api_config(config: web::Data<Arc<Config>>) -> HttpResponse {
HttpResponse::Ok().json(serde_json::json!({
"projectId": config.reown_project_id,
"wsUrl": config.ws_url,
}))
}
async fn frontend_handler(req: HttpRequest) -> HttpResponse {
let path = req.path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
if let Some(file) = FrontendAssets::get(path) {
let mime = mime_guess::from_path(path).first_or_octet_stream();
return HttpResponse::Ok()
.content_type(mime.as_ref())
.body(file.data.into_owned());
}
if !path.contains('.')
&& let Some(file) = FrontendAssets::get("index.html")
{
return HttpResponse::Ok()
.content_type("text/html")
.body(file.data.into_owned());
}
HttpResponse::NotFound().body("Not found")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let cli = Cli::parse();
let ui_port = cli.ui_port.unwrap_or(cli.port);
let separate_ui = cli.ui_port.is_some();
let exposed = !matches!(cli.host.as_str(), "127.0.0.1" | "localhost" | "::1");
let auth_token = uuid::Uuid::new_v4().to_string();
let mut allowed_origins: Vec<String> = Vec::new();
for hostname in ["localhost", "127.0.0.1"] {
for p in [cli.port, ui_port] {
allowed_origins.push(format!("http://{hostname}:{p}"));
}
}
allowed_origins.extend(cli.allowed_origins.iter().cloned());
allowed_origins.sort();
allowed_origins.dedup();
log::banner(
&cli.rpc_url,
&cli.host,
cli.port,
ui_port,
separate_ui,
exposed,
);
let state = Arc::new(AppState::new(
cli.rpc_url,
auth_token.clone(),
allowed_origins,
));
if let Some(chain_id_val) = proxy::fetch_chain_id(&state.http_client, &state.rpc_url).await {
if let Some(cid) = chain_id_val.as_str() {
log::chain_id(cid);
let mut lock = state.chain_id.write().await;
*lock = Some(cid.to_string());
}
} else {
log::chain_id_failed();
}
log::ready();
let ws_url = format!("ws://localhost:{}/ws?token={}", cli.port, auth_token);
let config = Arc::new(Config {
reown_project_id: cli.reown_project_id,
ws_url,
});
if separate_ui {
let state2 = state.clone();
let config2 = config.clone();
let proxy_server = HttpServer::new(move || {
App::new()
.wrap(Cors::permissive())
.app_data(web::Data::new(state.clone()))
.app_data(web::Data::new(config.clone()))
.route("/", web::post().to(handle_rpc))
.route("/ws", web::get().to(ws_handler))
.route("/api/config", web::get().to(api_config))
})
.bind((cli.host.as_str(), cli.port))?
.run();
let ui_server = HttpServer::new(move || {
App::new()
.wrap(Cors::permissive())
.app_data(web::Data::new(state2.clone()))
.app_data(web::Data::new(config2.clone()))
.route("/api/config", web::get().to(api_config))
.default_service(web::to(frontend_handler))
})
.bind((cli.host.as_str(), ui_port))?
.run();
tokio::try_join!(proxy_server, ui_server)?;
} else {
HttpServer::new(move || {
App::new()
.wrap(Cors::permissive())
.app_data(web::Data::new(state.clone()))
.app_data(web::Data::new(config.clone()))
.route("/", web::post().to(handle_rpc))
.route("/ws", web::get().to(ws_handler))
.route("/api/config", web::get().to(api_config))
.default_service(web::get().to(frontend_handler))
})
.bind((cli.host.as_str(), cli.port))?
.run()
.await?;
}
Ok(())
}