Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions crates/solana-solvers/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[package]
name = "solana-solvers"
version = "0.1.0"
edition = "2024"
description = "Solana solver engines for CoW Protocol (Jupiter dex-wrapper)"
license = "GPL-3.0-or-later"

[lib]
name = "solana_solvers"
path = "src/lib.rs"

[[bin]]
name = "solana-solvers"
path = "src/main.rs"

[dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
observe = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "signal"] }
toml = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true, features = ["limit", "trace"] }
Comment thread
squadgazzz marked this conversation as resolved.
Outdated
tracing = { workspace = true }
url = { workspace = true, features = ["serde"] }

[lints]
workspace = true
13 changes: 13 additions & 0 deletions crates/solana-solvers/config/example.jupiter.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Example Jupiter solver configuration.
# Run with: solana-solvers jupiter --config <this file>

[dex]
# Jupiter swap API base URL. Use api.jup.ag with an api-key, or a Triton-hosted
# Metis endpoint. The keyless lite-api.jup.ag also works but is rate-limited.
endpoint = "https://api.jup.ag"
# API key from the Jupiter developer portal (or Triton). Omit only for lite-api.
api-key = "your-jupiter-api-key"
# Slippage tolerance encoded into each quote request, as a percent.
slippage = "0.5"
# Buy orders quote via Jupiter ExactOut, which is route-limited. Off by default.
enable-buy-orders = false
Comment on lines +12 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This option being set to false means the Solver won't solve any buy order?
(I'm not familiar w/ that "ExactOut" thing, I'll do my research later)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, the buy orders routing is marked as limited in the API. Also, it is a part of the deprecated Jupiter V1 API. We have the same option for other EVM DEX solvers.

58 changes: 58 additions & 0 deletions crates/solana-solvers/src/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! HTTP API for the solver engine.
//!
//! Serves the `/solve` contract the driver calls. At this stage the handler is
//! a scaffold: it accepts any auction and returns no solutions. Real quoting
//! and solution assembly land in later PRs.

use {
crate::config::Config,
axum::{
Json,
Router,
extract::State,
routing::{get, post},
},
serde_json::{Value, json},
std::{future::Future, net::SocketAddr, sync::Arc},
tower_http::limit::RequestBodyLimitLayer,
};

const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024;

pub struct Api {
pub addr: SocketAddr,
pub config: Config,
}

impl Api {
/// Bind and serve until `shutdown` resolves.
pub async fn serve(
self,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> std::io::Result<()> {
let app = Router::new()
.route("/healthz", get(healthz))
.route("/solve", post(solve))
.with_state(Arc::new(self.config))
.layer(RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT))
.layer(axum::extract::DefaultBodyLimit::disable());
Comment thread
squadgazzz marked this conversation as resolved.

let listener = tokio::net::TcpListener::bind(self.addr).await?;
tracing::info!(addr = %self.addr, "solana-solvers listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await
}
}

async fn healthz() -> &'static str {
"ok"
}

/// Scaffold `/solve`: accept any auction, return no solutions. The real solve
/// loop wraps each order's Jupiter quote into a single-order solution (later
/// PRs); until then the driver wiring can be exercised end to end against an
/// empty result.
async fn solve(State(_config): State<Arc<Config>>, Json(_auction): Json<Value>) -> Json<Value> {
Json(json!({ "solutions": [] }))
}
39 changes: 39 additions & 0 deletions crates/solana-solvers/src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! CLI arguments for the `solana-solvers` binary.

use {
clap::{Parser, Subcommand},
std::{net::SocketAddr, path::PathBuf},
};

/// Run a Solana solver engine.
#[derive(Parser, Debug)]
#[command(version)]
pub struct Args {
/// The log filter.
#[arg(long, env, default_value = "warn,solana_solvers=debug")]
pub log: String,

/// Whether to use JSON format for the logs.
#[clap(long, env, default_value = "false")]
pub use_json_logs: bool,

/// The socket address to bind to.
#[arg(long, env, default_value = "127.0.0.1:7900")]
pub addr: SocketAddr,

#[command(subcommand)]
pub command: Command,
}

/// The solver engine to run. `config` is a path to a TOML config file.
#[derive(Subcommand, Debug)]
#[clap(rename_all = "lowercase")]
pub enum Command {
/// Wrap Jupiter's quote API into single-order solutions.
Jupiter {
#[clap(long, env)]
config: PathBuf,
},
// Baseline (self-indexed on-chain liquidity) lands when the driver's
// liquidity module unfreezes.
}
71 changes: 71 additions & 0 deletions crates/solana-solvers/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Solver-engine configuration.

use {serde::Deserialize, std::path::Path, url::Url};

/// Jupiter solver configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Config {
pub dex: JupiterConfig,
}

/// The `[dex]` table for the Jupiter backend. The subcommand selects the
/// engine, so there is no per-aggregator sub-table.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct JupiterConfig {
/// Base URL of the Jupiter swap API (`api.jup.ag`) or a Triton-hosted Metis
/// endpoint.
pub endpoint: Url,

/// API key for the Jupiter API. Required for `api.jup.ag` (issued by the
/// Jupiter developer portal) and for Triton. Omit only for the keyless
/// `lite-api.jup.ag` endpoint.
#[serde(default)]
pub api_key: Option<String>,

/// Slippage tolerance encoded into each quote request, as a percent string.
pub slippage: String,
Comment thread
squadgazzz marked this conversation as resolved.
Outdated

/// Whether buy orders (Jupiter `ExactOut`) are served. Off by default.
#[serde(default)]
pub enable_buy_orders: bool,
}

/// Load and parse the TOML config file.
///
/// # Panics
///
/// Panics on I/O or parse errors: a bad config is a startup failure.
pub async fn load(path: &Path) -> Config {
let text = tokio::fs::read_to_string(path)
.await
.unwrap_or_else(|err| panic!("read config {}: {err}", path.display()));
toml::from_str(&text).unwrap_or_else(|err| panic!("parse config {}: {err}", path.display()))
}

Comment on lines +40 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm wondering if this needs to be async.
I think not, but I don't have a strong opinion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It reads from a file, so it is an I/O operation. Also, it mirrors the EVM solvers implementation:

let data = fs::read_to_string(path)
.await
.unwrap_or_else(|e| panic!("I/O error while reading {path:?}: {e:?}"));

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_example_config() {
let config: Config =
toml::from_str(include_str!("../config/example.jupiter.toml")).unwrap();
assert_eq!(config.dex.endpoint.as_str(), "https://api.jup.ag/");
assert_eq!(config.dex.slippage, "0.5");
assert!(!config.dex.enable_buy_orders);
assert!(config.dex.api_key.is_some());
}

#[test]
fn rejects_unknown_keys() {
let toml = r#"
[dex]
endpoint = "https://api.jup.ag"
slippage = "0.5"
bogus = true
"#;
assert!(toml::from_str::<Config>(toml).is_err());
}
}
13 changes: 13 additions & 0 deletions crates/solana-solvers/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Solana solver engines for CoW Protocol.
//!
//! An MVP dex-wrapper over Jupiter's quote API, mirroring the `crates/solvers`
//! shape over Solana-native types. This crate is the HTTP `/solve` host; the
//! Jupiter adapter, solution assembly, and solve loop land in later PRs, and a
//! Solana baseline engine joins once the driver's liquidity module unfreezes.

pub mod api;
mod cli;
pub mod config;
mod run;

pub use run::start;
4 changes: 4 additions & 0 deletions crates/solana-solvers/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#[tokio::main]
async fn main() {
solana_solvers::start(std::env::args()).await;
}
57 changes: 57 additions & 0 deletions crates/solana-solvers/src/run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Binary entry: parse args, initialize observability, dispatch to the engine.

#[cfg(unix)]
use tokio::signal::unix::{self, SignalKind};
use {
crate::{
api::Api,
cli::{Args, Command},
config,
},
clap::Parser,
};

/// Parse args and run the selected solver engine until shutdown.
pub async fn start(args: impl IntoIterator<Item = String>) {
observe::panic_hook::install();
let args = Args::parse_from(args);

let obs_config = observe::Config::new(
&args.log,
Some(tracing::Level::ERROR),
args.use_json_logs,
None,
);
observe::tracing::init::initialize_reentrant(&obs_config);
tracing::info!(version = %observe::version::git_version(), "running solana-solvers with {args:#?}");

match args.command {
Command::Jupiter { config: path } => {
let config = config::load(&path).await;
let api = Api {
addr: args.addr,
config,
};
if let Err(err) = api.serve(shutdown_signal()).await {
tracing::error!(?err, "server error");
}
}
}
}

#[cfg(unix)]
async fn shutdown_signal() {
// Kubernetes sends SIGTERM; locally SIGINT (ctrl-c) is most common.
let mut interrupt = unix::signal(SignalKind::interrupt()).expect("install SIGINT handler");
let mut terminate = unix::signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! {
_ = interrupt.recv() => (),
_ = terminate.recv() => (),
};
}

#[cfg(windows)]
async fn shutdown_signal() {
// Signal handling is not supported on Windows.
std::future::pending().await
}
Comment on lines +53 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm surprised we care for windows builds.
Nice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it was some third-party contribution to this, IIRC.

Loading