-
Notifications
You must be signed in to change notification settings - Fork 186
feat(solana-solvers): PR 1 crate skeleton, config, /solve scaffold #4632
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
01885b2
78cc58a
5ef277f
6dccbfb
03e36aa
cfa1dc5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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"] } | ||
| tracing = { workspace = true } | ||
| url = { workspace = true, features = ["serde"] } | ||
|
|
||
| [lints] | ||
| workspace = true | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This option being set to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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()); | ||
|
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": [] })) | ||
| } | ||
| 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. | ||
| } |
| 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, | ||||||||||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm wondering if this needs to be async.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: services/crates/solvers/src/infra/config/dex/file.rs Lines 137 to 140 in cfbec98
|
||||||||||
| #[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()); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| 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; |
| 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; | ||
| } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm surprised we care for windows builds.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, it was some third-party contribution to this, IIRC. |
||
Uh oh!
There was an error while loading. Please reload this page.