Skip to content

Commit f6b2ffc

Browse files
authored
feat(solana-solvers): PR 1 crate skeleton, config, /solve scaffold (#4632)
# Description First PR of the Jupiter solver: a new `solana-solvers` crate that hosts the `/solve` API. It mirrors the `crates/solvers` shape over Solana-native types without reusing the EVM code, which is typed end to end on EVM primitives (`U256`, 20-byte `Address`, single-call calldata) with no chain abstraction. This PR is the skeleton. The Jupiter adapter, solution assembly, and solve loop land in the following PRs. # Changes - New `crates/solana-solvers` binary crate (`main`/`lib`/`run`/`cli`), an axum HTTP server with `/healthz` and `/solve`. - Subcommand CLI (`solana-solvers jupiter --config ...`), so a Solana baseline engine can slot in later. - `observe`-based logging (JSON option, panic hook) and graceful shutdown on SIGTERM/SIGINT. - `/solve` is a scaffold: it accepts any auction and returns `{"solutions": []}`, so the driver wiring can be exercised before real quoting lands. - Config: a `[dex]` table for the Jupiter backend (endpoint, api-key, slippage) with `deny_unknown_fields`, plus `config/example.jupiter.toml`. - Lean dependency set (axum, clap, observe, serde, tokio, url). No EVM deps pulled in. # How to test New unit tests (config parse plus unknown-key rejection). # Notes - Part of the Jupiter solver PR plan (specs repo PR cowprotocol/solana-services-specifications#33). PRs 2-4 add the Jupiter adapter, solution assembly, and the solve loop.
1 parent 195413e commit f6b2ffc

9 files changed

Lines changed: 294 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/solana-solvers/Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[package]
2+
name = "solana-solvers"
3+
version = "0.1.0"
4+
edition = "2024"
5+
description = "Solana solver engines for CoW Protocol (Jupiter dex-wrapper)"
6+
license = "GPL-3.0-or-later"
7+
8+
[lib]
9+
name = "solana_solvers"
10+
path = "src/lib.rs"
11+
12+
[[bin]]
13+
name = "solana-solvers"
14+
path = "src/main.rs"
15+
16+
[dependencies]
17+
axum = { workspace = true }
18+
clap = { workspace = true, features = ["derive", "env"] }
19+
observe = { workspace = true }
20+
serde = { workspace = true, features = ["derive"] }
21+
serde_json = { workspace = true }
22+
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "signal"] }
23+
toml = { workspace = true }
24+
tower-http = { workspace = true, features = ["limit"] }
25+
tracing = { workspace = true }
26+
url = { workspace = true, features = ["serde"] }
27+
28+
[lints]
29+
workspace = true
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Example Jupiter solver configuration.
2+
# Run with: solana-solvers jupiter --config <this file>
3+
4+
[dex]
5+
# Jupiter swap API base URL. Use api.jup.ag, or a Triton-hosted endpoint.
6+
endpoint = "https://api.jup.ag"
7+
# API key from the Jupiter developer portal (or Triton). Works without one but
8+
# heavily rate-limited, set it for production.
9+
api-key = "your-jupiter-api-key"
10+
# Slippage tolerance in basis points, sent to Jupiter as slippageBps. 50 = 0.5%.
11+
slippage-bps = 50
12+
# Buy orders quote via Jupiter ExactOut, which is route-limited. Off by default.
13+
enable-buy-orders = false

crates/solana-solvers/src/api.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//! HTTP API for the solver engine.
2+
//!
3+
//! Serves the `/solve` contract the driver calls. The handler is a scaffold: it
4+
//! accepts any auction and returns no solutions.
5+
6+
use {
7+
crate::config::Config,
8+
axum::{
9+
Json,
10+
Router,
11+
extract::State,
12+
routing::{get, post},
13+
},
14+
serde_json::{Value, json},
15+
std::{future::Future, net::SocketAddr, sync::Arc},
16+
tower_http::limit::RequestBodyLimitLayer,
17+
};
18+
19+
const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024;
20+
21+
pub struct Api {
22+
pub addr: SocketAddr,
23+
pub config: Config,
24+
}
25+
26+
impl Api {
27+
/// Bind and serve until `shutdown` resolves.
28+
pub async fn serve(
29+
self,
30+
shutdown: impl Future<Output = ()> + Send + 'static,
31+
) -> std::io::Result<()> {
32+
let app = Router::new()
33+
.route("/healthz", get(healthz))
34+
.route("/solve", post(solve))
35+
.with_state(Arc::new(self.config))
36+
.layer(RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT))
37+
.layer(axum::extract::DefaultBodyLimit::disable());
38+
39+
let listener = tokio::net::TcpListener::bind(self.addr).await?;
40+
tracing::info!(addr = %self.addr, "solana-solvers listening");
41+
axum::serve(listener, app)
42+
.with_graceful_shutdown(shutdown)
43+
.await
44+
}
45+
}
46+
47+
async fn healthz() -> &'static str {
48+
"ok"
49+
}
50+
51+
/// Scaffold `/solve`: accepts any auction and returns no solutions, so the
52+
/// driver wiring can be exercised against an empty result.
53+
async fn solve(State(_config): State<Arc<Config>>, Json(_auction): Json<Value>) -> Json<Value> {
54+
Json(json!({ "solutions": [] }))
55+
}

crates/solana-solvers/src/cli.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//! CLI arguments for the `solana-solvers` binary.
2+
3+
use {
4+
clap::{Parser, Subcommand},
5+
std::{net::SocketAddr, path::PathBuf},
6+
};
7+
8+
/// Run a Solana solver engine.
9+
#[derive(Parser, Debug)]
10+
#[command(version)]
11+
pub struct Args {
12+
/// The log filter.
13+
#[arg(long, env, default_value = "warn,solana_solvers=debug")]
14+
pub log: String,
15+
16+
/// Whether to use JSON format for the logs.
17+
#[clap(long, env, default_value = "false")]
18+
pub use_json_logs: bool,
19+
20+
/// The socket address to bind to.
21+
#[arg(long, env, default_value = "127.0.0.1:7900")]
22+
pub addr: SocketAddr,
23+
24+
#[command(subcommand)]
25+
pub command: Command,
26+
}
27+
28+
/// The solver engine to run. `config` is a path to a TOML config file.
29+
#[derive(Subcommand, Debug)]
30+
#[clap(rename_all = "lowercase")]
31+
pub enum Command {
32+
/// Wrap Jupiter's quote API into single-order solutions.
33+
Jupiter {
34+
#[clap(long, env)]
35+
config: PathBuf,
36+
},
37+
// TODO: add a baseline engine subcommand.
38+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//! Solver-engine configuration.
2+
3+
use {serde::Deserialize, std::path::Path, url::Url};
4+
5+
/// Jupiter solver configuration.
6+
#[derive(Debug, Clone, Deserialize)]
7+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
8+
pub struct Config {
9+
pub dex: JupiterConfig,
10+
}
11+
12+
/// The `[dex]` table for the Jupiter backend. The subcommand selects the
13+
/// engine, so there is no per-aggregator sub-table.
14+
#[derive(Debug, Clone, Deserialize)]
15+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
16+
pub struct JupiterConfig {
17+
/// Base URL of the Jupiter swap API (`api.jup.ag`) or a Triton-hosted Metis
18+
/// endpoint.
19+
pub endpoint: Url,
20+
21+
/// API key from the Jupiter developer portal (or Triton). Requests work
22+
/// without one but are heavily rate-limited, so set it for production.
23+
#[serde(default)]
24+
pub api_key: Option<String>,
25+
26+
/// Slippage tolerance in basis points, sent to Jupiter as `slippageBps`.
27+
/// 50 = 0.5%.
28+
pub slippage_bps: u16,
29+
30+
/// Whether buy orders (Jupiter `ExactOut`) are served. Off by default.
31+
#[serde(default)]
32+
pub enable_buy_orders: bool,
33+
}
34+
35+
/// Load and parse the TOML config file.
36+
///
37+
/// # Panics
38+
///
39+
/// Panics on I/O or parse errors: a bad config is a startup failure.
40+
pub async fn load(path: &Path) -> Config {
41+
let text = tokio::fs::read_to_string(path)
42+
.await
43+
.unwrap_or_else(|err| panic!("read config {}: {err}", path.display()));
44+
toml::from_str(&text).unwrap_or_else(|err| panic!("parse config {}: {err}", path.display()))
45+
}
46+
47+
#[cfg(test)]
48+
mod tests {
49+
use super::*;
50+
51+
#[test]
52+
fn parses_example_config() {
53+
let config: Config =
54+
toml::from_str(include_str!("../config/example.jupiter.toml")).unwrap();
55+
assert_eq!(config.dex.endpoint.as_str(), "https://api.jup.ag/");
56+
assert_eq!(config.dex.slippage_bps, 50);
57+
assert!(!config.dex.enable_buy_orders);
58+
assert!(config.dex.api_key.is_some());
59+
}
60+
61+
#[test]
62+
fn rejects_unknown_keys() {
63+
let toml = r#"
64+
[dex]
65+
endpoint = "https://api.jup.ag"
66+
slippage-bps = 50
67+
bogus = true
68+
"#;
69+
assert!(toml::from_str::<Config>(toml).is_err());
70+
}
71+
}

crates/solana-solvers/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//! Solana solver engines for CoW Protocol.
2+
//!
3+
//! An MVP dex-wrapper over Jupiter, mirroring the `crates/solvers` shape over
4+
//! Solana-native types (`u64`, `Pubkey`, instructions). Hosts the `/solve` API.
5+
6+
pub mod api;
7+
mod cli;
8+
pub mod config;
9+
mod run;
10+
11+
pub use run::start;

crates/solana-solvers/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#[tokio::main]
2+
async fn main() {
3+
solana_solvers::start(std::env::args()).await;
4+
}

crates/solana-solvers/src/run.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! Binary entry: parse args, initialize observability, dispatch to the engine.
2+
3+
#[cfg(unix)]
4+
use tokio::signal::unix::{self, SignalKind};
5+
use {
6+
crate::{
7+
api::Api,
8+
cli::{Args, Command},
9+
config,
10+
},
11+
clap::Parser,
12+
};
13+
14+
/// Parse args and run the selected solver engine until shutdown.
15+
pub async fn start(args: impl IntoIterator<Item = String>) {
16+
observe::panic_hook::install();
17+
let args = Args::parse_from(args);
18+
19+
let obs_config = observe::Config::new(
20+
&args.log,
21+
Some(tracing::Level::ERROR),
22+
args.use_json_logs,
23+
None,
24+
);
25+
observe::tracing::init::initialize_reentrant(&obs_config);
26+
tracing::info!(version = %observe::version::git_version(), "running solana-solvers with {args:#?}");
27+
28+
match args.command {
29+
Command::Jupiter { config: path } => {
30+
let config = config::load(&path).await;
31+
let api = Api {
32+
addr: args.addr,
33+
config,
34+
};
35+
if let Err(err) = api.serve(shutdown_signal()).await {
36+
tracing::error!(?err, "server error");
37+
}
38+
}
39+
}
40+
}
41+
42+
#[cfg(unix)]
43+
async fn shutdown_signal() {
44+
// Kubernetes sends SIGTERM; locally SIGINT (ctrl-c) is most common.
45+
let mut interrupt = unix::signal(SignalKind::interrupt()).expect("install SIGINT handler");
46+
let mut terminate = unix::signal(SignalKind::terminate()).expect("install SIGTERM handler");
47+
tokio::select! {
48+
_ = interrupt.recv() => (),
49+
_ = terminate.recv() => (),
50+
};
51+
}
52+
53+
#[cfg(windows)]
54+
async fn shutdown_signal() {
55+
// Signal handling is not supported on Windows.
56+
std::future::pending().await
57+
}

0 commit comments

Comments
 (0)