From 01885b2ff4f81a395ab1d48dc0cef6bd8383c4cd Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Thu, 16 Jul 2026 10:29:23 +0000 Subject: [PATCH 01/16] feat(solana-solvers): PR 1 crate skeleton, config, /solve scaffold --- Cargo.lock | 19 +++++ crates/solana-solvers/Cargo.toml | 32 ++++++++ .../config/example.jupiter.toml | 13 +++ crates/solana-solvers/src/api.rs | 58 +++++++++++++ crates/solana-solvers/src/config.rs | 82 +++++++++++++++++++ crates/solana-solvers/src/lib.rs | 11 +++ crates/solana-solvers/src/main.rs | 4 + crates/solana-solvers/src/run.rs | 40 +++++++++ 8 files changed, 259 insertions(+) create mode 100644 crates/solana-solvers/Cargo.toml create mode 100644 crates/solana-solvers/config/example.jupiter.toml create mode 100644 crates/solana-solvers/src/api.rs create mode 100644 crates/solana-solvers/src/config.rs create mode 100644 crates/solana-solvers/src/lib.rs create mode 100644 crates/solana-solvers/src/main.rs create mode 100644 crates/solana-solvers/src/run.rs diff --git a/Cargo.lock b/Cargo.lock index 1347ec6b59..72e9ca9b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10825,6 +10825,25 @@ dependencies = [ "solana-sysvar-id", ] +[[package]] +name = "solana-solvers" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum 0.8.8", + "clap", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "toml", + "tower 0.5.3", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "solana-stable-layout" version = "3.0.1" diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml new file mode 100644 index 0000000000..d82686d8b1 --- /dev/null +++ b/crates/solana-solvers/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "solana-solvers" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0-or-later" +description = "Solana solver engines for CoW Protocol (Jupiter dex-wrapper)" + +[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"] } +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 } +tracing-subscriber = { workspace = true } +url = { workspace = true, features = ["serde"] } + +[lints] +workspace = true diff --git a/crates/solana-solvers/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml new file mode 100644 index 0000000000..3e414f0d0b --- /dev/null +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -0,0 +1,13 @@ +# Example Jupiter solver configuration. + +[dex.jupiter] +# Base URL of the swap API. Public Jupiter by default; point at Triton's +# hosted Metis (and set api-key) to hedge against public rate limits. +endpoint = "https://api.jup.ag" +# 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 +# Compute-unit estimate used when a quote response carries none. +cu-default = 200000 +# api-key = "..." # required only for Triton's hosted Metis diff --git a/crates/solana-solvers/src/api.rs b/crates/solana-solvers/src/api.rs new file mode 100644 index 0000000000..7be96957d7 --- /dev/null +++ b/crates/solana-solvers/src/api.rs @@ -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 + 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()); + + 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>, Json(_auction): Json) -> Json { + Json(json!({ "solutions": [] })) +} diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs new file mode 100644 index 0000000000..c6af74b522 --- /dev/null +++ b/crates/solana-solvers/src/config.rs @@ -0,0 +1,82 @@ +//! Solver-engine configuration. +//! +//! Mirrors the `crates/solvers` config shape: a `[dex]` table with a +//! per-aggregator sub-table. Jupiter is the only backend at MVP. + +use {serde::Deserialize, std::path::Path, url::Url}; + +/// Top-level configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Config { + pub dex: DexConfig, +} + +/// The `[dex]` table. One sub-table per aggregator backend. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct DexConfig { + pub jupiter: JupiterConfig, +} + +/// The `[dex.jupiter]` sub-table. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct JupiterConfig { + /// Base URL of the Jupiter (or Triton-hosted) swap API. + pub endpoint: Url, + + /// API key. `None` for the public API, set for Triton's hosted Metis. + #[serde(default)] + pub api_key: Option, + + /// Slippage tolerance encoded into the quote request, as a percent string. + pub slippage: String, + + /// Whether buy orders (Jupiter `ExactOut`) are served. Off by default. + #[serde(default)] + pub enable_buy_orders: bool, + + /// Default compute-unit estimate used when a quote response carries none. + pub cu_default: u32, +} + +/// 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())) +} + +#[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.jupiter.endpoint.as_str(), "https://api.jup.ag/"); + assert_eq!(config.dex.jupiter.slippage, "0.5"); + assert!(!config.dex.jupiter.enable_buy_orders); + assert_eq!(config.dex.jupiter.cu_default, 200_000); + assert!(config.dex.jupiter.api_key.is_none()); + } + + #[test] + fn rejects_unknown_keys() { + let toml = r#" +[dex.jupiter] +endpoint = "https://api.jup.ag" +slippage = "0.5" +cu-default = 200000 +bogus = true +"#; + assert!(toml::from_str::(toml).is_err()); + } +} diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs new file mode 100644 index 0000000000..8c061dc16d --- /dev/null +++ b/crates/solana-solvers/src/lib.rs @@ -0,0 +1,11 @@ +//! 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. + +pub mod api; +pub mod config; +mod run; + +pub use run::start; diff --git a/crates/solana-solvers/src/main.rs b/crates/solana-solvers/src/main.rs new file mode 100644 index 0000000000..4d285d5801 --- /dev/null +++ b/crates/solana-solvers/src/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() { + solana_solvers::start(std::env::args()).await; +} diff --git a/crates/solana-solvers/src/run.rs b/crates/solana-solvers/src/run.rs new file mode 100644 index 0000000000..3075fc294e --- /dev/null +++ b/crates/solana-solvers/src/run.rs @@ -0,0 +1,40 @@ +//! Binary entry: parse args, load config, serve the `/solve` API. + +use { + crate::{api::Api, config}, + clap::Parser, + std::{net::SocketAddr, path::PathBuf}, +}; + +/// Command-line arguments. +#[derive(Parser, Debug)] +#[clap(name = "solana-solvers")] +pub struct Args { + /// Path to the TOML configuration file. + #[clap(long, env = "SOLANA_SOLVERS_CONFIG")] + pub config: PathBuf, + + /// Socket address the HTTP API binds to. + #[clap(long, env = "SOLANA_SOLVERS_BIND", default_value = "0.0.0.0:7900")] + pub bind: SocketAddr, +} + +/// Parse args and run the solver engine until shutdown. +pub async fn start(args: impl IntoIterator) { + let args = Args::parse_from(args); + tracing_subscriber::fmt::init(); + tracing::info!(?args, "starting solana-solvers"); + + let config = config::load(&args.config).await; + let api = Api { + addr: args.bind, + config, + }; + if let Err(err) = api.serve(shutdown_signal()).await { + tracing::error!(?err, "server error"); + } +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} From 78cc58ac3c74efc13f0ec08c15d754aabf996e67 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Thu, 16 Jul 2026 11:35:24 +0000 Subject: [PATCH 02/16] chore(solana-solvers): tombi-format Cargo.toml --- crates/solana-solvers/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index d82686d8b1..b8314271dc 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -2,8 +2,8 @@ name = "solana-solvers" version = "0.1.0" edition = "2024" -license = "GPL-3.0-or-later" description = "Solana solver engines for CoW Protocol (Jupiter dex-wrapper)" +license = "GPL-3.0-or-later" [lib] name = "solana_solvers" From 5ef277f13e9c8aeff65e177cf07e3c5960468bc0 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Thu, 16 Jul 2026 14:59:35 +0000 Subject: [PATCH 03/16] refactor(solana-solvers): align CLI/logging/shutdown with crates/solvers, drop cu-default --- Cargo.lock | 2 +- crates/solana-solvers/Cargo.toml | 2 +- .../config/example.jupiter.toml | 12 ++-- crates/solana-solvers/src/cli.rs | 39 ++++++++++ crates/solana-solvers/src/config.rs | 41 ++++------- crates/solana-solvers/src/lib.rs | 4 +- crates/solana-solvers/src/run.rs | 71 ++++++++++++------- 7 files changed, 109 insertions(+), 62 deletions(-) create mode 100644 crates/solana-solvers/src/cli.rs diff --git a/Cargo.lock b/Cargo.lock index 72e9ca9b02..d0e7bce919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10832,6 +10832,7 @@ dependencies = [ "anyhow", "axum 0.8.8", "clap", + "observe", "serde", "serde_json", "thiserror 1.0.69", @@ -10840,7 +10841,6 @@ dependencies = [ "tower 0.5.3", "tower-http", "tracing", - "tracing-subscriber", "url", ] diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index b8314271dc..afd237d4fc 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" 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 } @@ -25,7 +26,6 @@ toml = { workspace = true } tower = { workspace = true } tower-http = { workspace = true, features = ["limit", "trace"] } tracing = { workspace = true } -tracing-subscriber = { workspace = true } url = { workspace = true, features = ["serde"] } [lints] diff --git a/crates/solana-solvers/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml index 3e414f0d0b..f6cef5eb7d 100644 --- a/crates/solana-solvers/config/example.jupiter.toml +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -1,13 +1,13 @@ # Example Jupiter solver configuration. +# Run with: solana-solvers jupiter --config -[dex.jupiter] -# Base URL of the swap API. Public Jupiter by default; point at Triton's -# hosted Metis (and set api-key) to hedge against public rate limits. +[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 -# Compute-unit estimate used when a quote response carries none. -cu-default = 200000 -# api-key = "..." # required only for Triton's hosted Metis diff --git a/crates/solana-solvers/src/cli.rs b/crates/solana-solvers/src/cli.rs new file mode 100644 index 0000000000..0cba5232b4 --- /dev/null +++ b/crates/solana-solvers/src/cli.rs @@ -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. +} diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index c6af74b522..44a994da51 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -1,44 +1,35 @@ //! Solver-engine configuration. -//! -//! Mirrors the `crates/solvers` config shape: a `[dex]` table with a -//! per-aggregator sub-table. Jupiter is the only backend at MVP. use {serde::Deserialize, std::path::Path, url::Url}; -/// Top-level configuration. +/// Jupiter solver configuration. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Config { - pub dex: DexConfig, + pub dex: JupiterConfig, } -/// The `[dex]` table. One sub-table per aggregator backend. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct DexConfig { - pub jupiter: JupiterConfig, -} - -/// The `[dex.jupiter]` sub-table. +/// 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 (or Triton-hosted) swap API. + /// Base URL of the Jupiter swap API (`api.jup.ag`) or a Triton-hosted Metis + /// endpoint. pub endpoint: Url, - /// API key. `None` for the public API, set for Triton's hosted Metis. + /// 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, - /// Slippage tolerance encoded into the quote request, as a percent string. + /// Slippage tolerance encoded into each quote request, as a percent string. pub slippage: String, /// Whether buy orders (Jupiter `ExactOut`) are served. Off by default. #[serde(default)] pub enable_buy_orders: bool, - - /// Default compute-unit estimate used when a quote response carries none. - pub cu_default: u32, } /// Load and parse the TOML config file. @@ -61,20 +52,18 @@ mod tests { fn parses_example_config() { let config: Config = toml::from_str(include_str!("../config/example.jupiter.toml")).unwrap(); - assert_eq!(config.dex.jupiter.endpoint.as_str(), "https://api.jup.ag/"); - assert_eq!(config.dex.jupiter.slippage, "0.5"); - assert!(!config.dex.jupiter.enable_buy_orders); - assert_eq!(config.dex.jupiter.cu_default, 200_000); - assert!(config.dex.jupiter.api_key.is_none()); + 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.jupiter] +[dex] endpoint = "https://api.jup.ag" slippage = "0.5" -cu-default = 200000 bogus = true "#; assert!(toml::from_str::(toml).is_err()); diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs index 8c061dc16d..2e208b5448 100644 --- a/crates/solana-solvers/src/lib.rs +++ b/crates/solana-solvers/src/lib.rs @@ -2,9 +2,11 @@ //! //! 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. +//! 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; diff --git a/crates/solana-solvers/src/run.rs b/crates/solana-solvers/src/run.rs index 3075fc294e..1168f474c4 100644 --- a/crates/solana-solvers/src/run.rs +++ b/crates/solana-solvers/src/run.rs @@ -1,40 +1,57 @@ -//! Binary entry: parse args, load config, serve the `/solve` API. +//! Binary entry: parse args, initialize observability, dispatch to the engine. +#[cfg(unix)] +use tokio::signal::unix::{self, SignalKind}; use { - crate::{api::Api, config}, + crate::{ + api::Api, + cli::{Args, Command}, + config, + }, clap::Parser, - std::{net::SocketAddr, path::PathBuf}, }; -/// Command-line arguments. -#[derive(Parser, Debug)] -#[clap(name = "solana-solvers")] -pub struct Args { - /// Path to the TOML configuration file. - #[clap(long, env = "SOLANA_SOLVERS_CONFIG")] - pub config: PathBuf, - - /// Socket address the HTTP API binds to. - #[clap(long, env = "SOLANA_SOLVERS_BIND", default_value = "0.0.0.0:7900")] - pub bind: SocketAddr, -} - -/// Parse args and run the solver engine until shutdown. +/// Parse args and run the selected solver engine until shutdown. pub async fn start(args: impl IntoIterator) { + observe::panic_hook::install(); let args = Args::parse_from(args); - tracing_subscriber::fmt::init(); - tracing::info!(?args, "starting solana-solvers"); - let config = config::load(&args.config).await; - let api = Api { - addr: args.bind, - config, - }; - if let Err(err) = api.serve(shutdown_signal()).await { - tracing::error!(?err, "server error"); + 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() { - let _ = tokio::signal::ctrl_c().await; + // Signal handling is not supported on Windows. + std::future::pending().await } From 6dccbfbf111383b4c12f9e69714e9bed6bfb46e1 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Thu, 16 Jul 2026 17:37:55 +0000 Subject: [PATCH 04/16] refactor(solana-solvers): drop unused deps, type slippage as bps (address review) --- Cargo.lock | 3 --- crates/solana-solvers/Cargo.toml | 5 +---- crates/solana-solvers/config/example.jupiter.toml | 4 ++-- crates/solana-solvers/src/config.rs | 9 +++++---- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0e7bce919..1f809fb8d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10829,16 +10829,13 @@ dependencies = [ name = "solana-solvers" version = "0.1.0" dependencies = [ - "anyhow", "axum 0.8.8", "clap", "observe", "serde", "serde_json", - "thiserror 1.0.69", "tokio", "toml", - "tower 0.5.3", "tower-http", "tracing", "url", diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index afd237d4fc..24f720b618 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -14,17 +14,14 @@ 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"] } +tower-http = { workspace = true, features = ["limit"] } tracing = { workspace = true } url = { workspace = true, features = ["serde"] } diff --git a/crates/solana-solvers/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml index f6cef5eb7d..2992bee61c 100644 --- a/crates/solana-solvers/config/example.jupiter.toml +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -7,7 +7,7 @@ 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" +# Slippage tolerance in basis points, sent to Jupiter as slippageBps. 50 = 0.5%. +slippage-bps = 50 # Buy orders quote via Jupiter ExactOut, which is route-limited. Off by default. enable-buy-orders = false diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index 44a994da51..3a8d817190 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -24,8 +24,9 @@ pub struct JupiterConfig { #[serde(default)] pub api_key: Option, - /// Slippage tolerance encoded into each quote request, as a percent string. - pub slippage: String, + /// Slippage tolerance in basis points, sent to Jupiter as `slippageBps`. + /// 50 = 0.5%. + pub slippage_bps: u16, /// Whether buy orders (Jupiter `ExactOut`) are served. Off by default. #[serde(default)] @@ -53,7 +54,7 @@ mod tests { 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_eq!(config.dex.slippage_bps, 50); assert!(!config.dex.enable_buy_orders); assert!(config.dex.api_key.is_some()); } @@ -63,7 +64,7 @@ mod tests { let toml = r#" [dex] endpoint = "https://api.jup.ag" -slippage = "0.5" +slippage-bps = 50 bogus = true "#; assert!(toml::from_str::(toml).is_err()); From a061fa257c64e8ecc59058675a8e54a70f589072 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 08:51:21 +0000 Subject: [PATCH 05/16] feat(solana-solvers): Jupiter /swap-instructions adapter + provider boundary --- Cargo.lock | 4 + crates/solana-solvers/Cargo.toml | 4 + crates/solana-solvers/src/dex/jupiter/dto.rs | 100 +++++++++ crates/solana-solvers/src/dex/jupiter/mod.rs | 224 +++++++++++++++++++ crates/solana-solvers/src/dex/mod.rs | 51 +++++ crates/solana-solvers/src/lib.rs | 1 + 6 files changed, 384 insertions(+) create mode 100644 crates/solana-solvers/src/dex/jupiter/dto.rs create mode 100644 crates/solana-solvers/src/dex/jupiter/mod.rs create mode 100644 crates/solana-solvers/src/dex/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 1f809fb8d1..2acef3d169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10830,10 +10830,14 @@ name = "solana-solvers" version = "0.1.0" dependencies = [ "axum 0.8.8", + "base64 0.22.1", "clap", "observe", + "reqwest 0.13.4", "serde", "serde_json", + "solana-sdk", + "thiserror 1.0.69", "tokio", "toml", "tower-http", diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index 24f720b618..c97ce83231 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -15,10 +15,14 @@ path = "src/main.rs" [dependencies] axum = { workspace = true } +base64 = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } observe = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +solana-sdk = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "signal"] } toml = { workspace = true } tower-http = { workspace = true, features = ["limit"] } diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs new file mode 100644 index 0000000000..41da7ae005 --- /dev/null +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -0,0 +1,100 @@ +//! DTOs for Jupiter's `/swap-instructions` response, converted to Solana +//! instructions. Field names follow Jupiter's camelCase JSON. + +use { + super::Error, + crate::dex::Swap, + base64::prelude::*, + serde::Deserialize, + solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + }, + std::str::FromStr, +}; + +/// Subset of Jupiter's `/swap-instructions` response we consume. +/// Compute-budget, token-ledger, and Jito instructions are ignored: the driver +/// owns compute budget and tipping. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapInstructionsResponse { + #[serde(default)] + setup_instructions: Vec, + swap_instruction: JupInstruction, + #[serde(default)] + cleanup_instruction: Option, + #[serde(default)] + address_lookup_table_addresses: Vec, +} + +impl SwapInstructionsResponse { + /// Flatten into execution order (setup, swap, cleanup) and resolve the + /// lookup tables the driver needs to build the v0 transaction. + pub fn into_swap(self, in_amount: u64, out_amount: u64) -> Result { + let mut instructions = Vec::with_capacity(self.setup_instructions.len() + 2); + for instruction in self.setup_instructions { + instructions.push(instruction.into_instruction()?); + } + instructions.push(self.swap_instruction.into_instruction()?); + if let Some(instruction) = self.cleanup_instruction { + instructions.push(instruction.into_instruction()?); + } + let address_lookup_tables = self + .address_lookup_table_addresses + .iter() + .map(|address| Pubkey::from_str(address).map_err(|_| Error::BadResponse)) + .collect::>()?; + Ok(Swap { + in_amount, + out_amount, + instructions, + address_lookup_tables, + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JupInstruction { + program_id: String, + accounts: Vec, + data: String, +} + +impl JupInstruction { + fn into_instruction(self) -> Result { + let program_id = Pubkey::from_str(&self.program_id).map_err(|_| Error::BadResponse)?; + let accounts = self + .accounts + .into_iter() + .map(JupAccount::into_meta) + .collect::>()?; + let data = BASE64_STANDARD + .decode(&self.data) + .map_err(|_| Error::BadResponse)?; + Ok(Instruction { + program_id, + accounts, + data, + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JupAccount { + pubkey: String, + is_signer: bool, + is_writable: bool, +} + +impl JupAccount { + fn into_meta(self) -> Result { + Ok(AccountMeta { + pubkey: Pubkey::from_str(&self.pubkey).map_err(|_| Error::BadResponse)?, + is_signer: self.is_signer, + is_writable: self.is_writable, + }) + } +} diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs new file mode 100644 index 0000000000..ec892d5fcc --- /dev/null +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -0,0 +1,224 @@ +//! Jupiter swap-API adapter. +//! +//! Chains Jupiter's two-step flow: `GET /swap/v1/quote` for the route and +//! amounts, then `POST /swap/v1/swap-instructions` for the on-chain +//! instructions. `api.jup.ag` needs a portal-issued api-key, keyless +//! `lite-api.jup.ag` works too but is rate-limited. A self-hosted provider +//! (Triton) is a base-URL and key swap behind this same adapter. + +mod dto; + +use { + super::{Order, Side, Swap}, + crate::config::JupiterConfig, + solana_sdk::pubkey::Pubkey, +}; + +const QUOTE_PATH: &str = "swap/v1/quote"; +const SWAP_INSTRUCTIONS_PATH: &str = "swap/v1/swap-instructions"; + +/// Adapter over the Jupiter swap API. +pub struct Jupiter { + client: reqwest::Client, + endpoint: reqwest::Url, + api_key: Option, + slippage_bps: u16, + enable_buy_orders: bool, +} + +impl Jupiter { + pub fn new(config: &JupiterConfig) -> Result { + Ok(Self { + client: reqwest::Client::builder().build()?, + endpoint: config.endpoint.clone(), + api_key: config.api_key.clone(), + slippage_bps: config.slippage_bps, + enable_buy_orders: config.enable_buy_orders, + }) + } + + /// Quote `order` for settlement signer `user` and return the swap to run + /// inside the settlement transaction. + pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result { + if order.side == Side::Buy && !self.enable_buy_orders { + return Err(Error::OrderNotSupported); + } + let quote = self.quote(order).await?; + let in_amount = amount_field("e, "inAmount")?; + let out_amount = amount_field("e, "outAmount")?; + self.swap_instructions("e, user) + .await? + .into_swap(in_amount, out_amount) + } + + /// `GET /swap/v1/quote`. Kept opaque and passed back verbatim to + /// `/swap-instructions`, we only read the amounts. + async fn quote(&self, order: &Order) -> Result { + let mode = match order.side { + Side::Sell => "ExactIn", + Side::Buy => "ExactOut", + }; + let mut url = self + .endpoint + .join(QUOTE_PATH) + .map_err(|_| Error::RequestBuildFailed)?; + url.query_pairs_mut() + .append_pair("inputMint", &order.sell_mint.to_string()) + .append_pair("outputMint", &order.buy_mint.to_string()) + .append_pair("amount", &order.amount.to_string()) + .append_pair("swapMode", mode) + .append_pair("slippageBps", &self.slippage_bps.to_string()); + self.send(self.with_key(self.client.get(url))).await + } + + /// `POST /swap/v1/swap-instructions` with the quote and settlement signer. + async fn swap_instructions( + &self, + quote: &serde_json::Value, + user: &Pubkey, + ) -> Result { + let url = self + .endpoint + .join(SWAP_INSTRUCTIONS_PATH) + .map_err(|_| Error::RequestBuildFailed)?; + let body = serde_json::json!({ + "quoteResponse": quote, + "userPublicKey": user.to_string(), + // The swap rides inside the settlement tx and the driver handles SOL + // wrapping, so leave Jupiter's wrap/unwrap off. + "wrapAndUnwrapSol": false, + }); + let body = serde_json::to_string(&body).map_err(|_| Error::RequestBuildFailed)?; + let request = self.with_key( + self.client + .post(url) + .header("content-type", "application/json") + .body(body), + ); + self.send(request).await + } + + fn with_key(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match &self.api_key { + Some(key) => request.header("x-api-key", key), + None => request, + } + } + + async fn send( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let response = request.send().await?; + let status = response.status(); + let body = response.text().await?; + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err(Error::RateLimited); + } + // Jupiter answers an unroutable pair with a 4xx error body, not an empty + // 200, so any non-success is "no swap for this order". + if !status.is_success() { + return Err(Error::NotFound); + } + serde_json::from_str(&body).map_err(|_| Error::BadResponse) + } +} + +fn amount_field(quote: &serde_json::Value, field: &str) -> Result { + quote + .get(field) + .and_then(serde_json::Value::as_str) + .and_then(|amount| amount.parse().ok()) + .ok_or(Error::BadResponse) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to build the request")] + RequestBuildFailed, + #[error("no route for this order")] + NotFound, + #[error("order type is not supported")] + OrderNotSupported, + #[error("rate limited")] + RateLimited, + #[error("malformed response from the swap API")] + BadResponse, + #[error(transparent)] + Http(#[from] reqwest::Error), +} + +#[cfg(test)] +mod tests { + use {super::*, std::str::FromStr}; + + // USDC and wrapped SOL mints. + const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const WSOL: &str = "So11111111111111111111111111111111111111112"; + + fn config(enable_buy_orders: bool) -> JupiterConfig { + JupiterConfig { + endpoint: "https://lite-api.jup.ag".parse().unwrap(), + api_key: None, + slippage_bps: 50, + enable_buy_orders, + } + } + + #[tokio::test] + async fn buy_disabled_short_circuits() { + let jupiter = Jupiter::new(&config(false)).unwrap(); + let order = Order { + sell_mint: Pubkey::from_str(WSOL).unwrap(), + buy_mint: Pubkey::from_str(USDC).unwrap(), + amount: 1_000_000, + side: Side::Buy, + }; + let result = jupiter.swap(&order, &Pubkey::new_unique()).await; + assert!(matches!(result, Err(Error::OrderNotSupported))); + } + + #[test] + fn parses_swap_instructions() { + let json = serde_json::json!({ + "setupInstructions": [], + "swapInstruction": { + "programId": WSOL, + "accounts": [{ "pubkey": USDC, "isSigner": false, "isWritable": true }], + "data": "AAEC" + }, + "cleanupInstruction": null, + "addressLookupTableAddresses": [USDC, WSOL] + }); + let response: dto::SwapInstructionsResponse = serde_json::from_value(json).unwrap(); + let swap = response.into_swap(100, 250).unwrap(); + assert_eq!(swap.instructions.len(), 1); + assert_eq!(swap.instructions[0].accounts.len(), 1); + assert_eq!(swap.address_lookup_tables.len(), 2); + assert_eq!((swap.in_amount, swap.out_amount), (100, 250)); + } + + /// Live Jupiter API (keyless lite-api). Needs network. Run with: + /// `cargo test -p solana-solvers -- --ignored jupiter_live`. + /// Production points at `api.jup.ag` with an api-key instead. + #[tokio::test] + #[ignore] + async fn jupiter_live_sell_quote() { + let jupiter = Jupiter::new(&config(false)).unwrap(); + let order = Order { + sell_mint: Pubkey::from_str(USDC).unwrap(), + buy_mint: Pubkey::from_str(WSOL).unwrap(), + amount: 1_000_000, + side: Side::Sell, + }; + // Any valid pubkey works for building instructions, the swap only runs + // for real once the driver supplies its settlement signer. + let swap = jupiter + .swap(&order, &Pubkey::from_str(WSOL).unwrap()) + .await + .unwrap(); + assert_eq!(swap.in_amount, 1_000_000); + assert!(swap.out_amount > 0); + assert!(!swap.instructions.is_empty()); + } +} diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs new file mode 100644 index 0000000000..df39c0a21b --- /dev/null +++ b/crates/solana-solvers/src/dex/mod.rs @@ -0,0 +1,51 @@ +//! DEX-adapter boundary: quote one order into an executable swap. +//! +//! Mirrors the shape of `crates/solvers`'s `infra::dex` over Solana types. +//! Jupiter is the only backend at MVP, the enum leaves room for a self-hosted +//! provider (Triton) behind the same interface. + +pub mod jupiter; + +use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; + +/// A single order to quote, distilled from the auction. +#[derive(Debug, Clone)] +pub struct Order { + pub sell_mint: Pubkey, + pub buy_mint: Pubkey, + /// Sell amount for `Sell`, buy amount for `Buy`. + pub amount: u64, + pub side: Side, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Buy, + Sell, +} + +/// A quoted swap: the executed amounts plus the instructions that perform it, +/// in execution order (setup, swap, cleanup). The address lookup tables travel +/// alongside so the driver can build the v0 transaction the instructions +/// assume. +#[derive(Debug, Clone)] +pub struct Swap { + pub in_amount: u64, + pub out_amount: u64, + pub instructions: Vec, + pub address_lookup_tables: Vec, +} + +/// The configured DEX backend. +pub enum Dex { + Jupiter(jupiter::Jupiter), +} + +impl Dex { + /// Quote `order` for settlement signer `user`. + pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result { + match self { + Dex::Jupiter(jupiter) => jupiter.swap(order, user).await, + } + } +} diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs index 2e208b5448..ce711e60ef 100644 --- a/crates/solana-solvers/src/lib.rs +++ b/crates/solana-solvers/src/lib.rs @@ -8,6 +8,7 @@ pub mod api; mod cli; pub mod config; +pub mod dex; mod run; pub use run::start; From aa98a0644ae0b014f274bde33da432f4dfea8860 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 09:01:37 +0000 Subject: [PATCH 06/16] feat(solana-solvers): direct-delivery to order receiver via destinationTokenAccount --- crates/solana-solvers/src/dex/jupiter/mod.rs | 14 ++++++++++---- crates/solana-solvers/src/dex/mod.rs | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index ec892d5fcc..fec8534dac 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -46,7 +46,7 @@ impl Jupiter { let quote = self.quote(order).await?; let in_amount = amount_field("e, "inAmount")?; let out_amount = amount_field("e, "outAmount")?; - self.swap_instructions("e, user) + self.swap_instructions("e, user, &order.buy_token_account) .await? .into_swap(in_amount, out_amount) } @@ -76,6 +76,7 @@ impl Jupiter { &self, quote: &serde_json::Value, user: &Pubkey, + destination: &Pubkey, ) -> Result { let url = self .endpoint @@ -84,9 +85,12 @@ impl Jupiter { let body = serde_json::json!({ "quoteResponse": quote, "userPublicKey": user.to_string(), - // The swap rides inside the settlement tx and the driver handles SOL - // wrapping, so leave Jupiter's wrap/unwrap off. - "wrapAndUnwrapSol": false, + // Deliver the swap output straight to the order's receiver account so + // the swap credits the user, with no separate payout instruction. + "destinationTokenAccount": destination.to_string(), + // The settlement manages token accounts, so Jupiter shouldn't probe + // them over RPC. It still populates the setup instructions. + "skipUserAccountsRpcCalls": true, }); let body = serde_json::to_string(&body).map_err(|_| Error::RequestBuildFailed)?; let request = self.with_key( @@ -171,6 +175,7 @@ mod tests { let order = Order { sell_mint: Pubkey::from_str(WSOL).unwrap(), buy_mint: Pubkey::from_str(USDC).unwrap(), + buy_token_account: Pubkey::new_unique(), amount: 1_000_000, side: Side::Buy, }; @@ -208,6 +213,7 @@ mod tests { let order = Order { sell_mint: Pubkey::from_str(USDC).unwrap(), buy_mint: Pubkey::from_str(WSOL).unwrap(), + buy_token_account: Pubkey::from_str(WSOL).unwrap(), amount: 1_000_000, side: Side::Sell, }; diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index df39c0a21b..4e69a9a520 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -13,6 +13,10 @@ use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; pub struct Order { pub sell_mint: Pubkey, pub buy_mint: Pubkey, + /// The order's receiver account. Passed to Jupiter as + /// `destinationTokenAccount` so the swap credits the user directly, with no + /// separate payout instruction. + pub buy_token_account: Pubkey, /// Sell amount for `Sell`, buy amount for `Buy`. pub amount: u64, pub side: Side, From dbb20c951d54f978360df359d5b7d364f6bf00ea Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 09:06:42 +0000 Subject: [PATCH 07/16] refactor(solana-solvers): drop single-variant Dex enum, call the adapter directly --- crates/solana-solvers/src/dex/mod.rs | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 4e69a9a520..646912744f 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -1,8 +1,7 @@ -//! DEX-adapter boundary: quote one order into an executable swap. -//! -//! Mirrors the shape of `crates/solvers`'s `infra::dex` over Solana types. -//! Jupiter is the only backend at MVP, the enum leaves room for a self-hosted -//! provider (Triton) behind the same interface. +//! Types the Jupiter adapter works with: an auction order in, a quoted swap +//! out. A second engine (a Solana baseline) would join through an enum here, +//! once the frozen liquidity module unfreezes. Triton (hosted Jupiter) is not a +//! separate backend, just a base-URL and key swap inside the adapter. pub mod jupiter; @@ -39,17 +38,3 @@ pub struct Swap { pub instructions: Vec, pub address_lookup_tables: Vec, } - -/// The configured DEX backend. -pub enum Dex { - Jupiter(jupiter::Jupiter), -} - -impl Dex { - /// Quote `order` for settlement signer `user`. - pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result { - match self { - Dex::Jupiter(jupiter) => jupiter.swap(order, user).await, - } - } -} From a29cc0f223e24afeb8fb822077b592c6c3230749 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 09:12:42 +0000 Subject: [PATCH 08/16] refactor(solana-solvers): keep Dex enum ahead of the baseline engine --- crates/solana-solvers/src/dex/mod.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 646912744f..58803a9789 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -1,7 +1,9 @@ -//! Types the Jupiter adapter works with: an auction order in, a quoted swap -//! out. A second engine (a Solana baseline) would join through an enum here, -//! once the frozen liquidity module unfreezes. Triton (hosted Jupiter) is not a -//! separate backend, just a base-URL and key swap inside the adapter. +//! DEX-adapter boundary: quote one order into an executable swap. +//! +//! `Dex` dispatches to the configured engine. Jupiter is the only one at MVP, +//! a Solana baseline joins once the frozen liquidity module unfreezes. Triton +//! (hosted Jupiter) is not a separate engine, just a base-URL and key swap +//! inside the Jupiter adapter. pub mod jupiter; @@ -38,3 +40,17 @@ pub struct Swap { pub instructions: Vec, pub address_lookup_tables: Vec, } + +/// The configured DEX backend. +pub enum Dex { + Jupiter(jupiter::Jupiter), +} + +impl Dex { + /// Quote `order` for settlement signer `user`. + pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result { + match self { + Dex::Jupiter(jupiter) => jupiter.swap(order, user).await, + } + } +} From e28027c088741d9314ab14b37a8783a47a3cef05 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 10:21:48 +0000 Subject: [PATCH 09/16] refactor(solana-solvers): send swap output to the settlement buffer, not the user account --- crates/solana-solvers/src/dex/jupiter/mod.rs | 10 +++++----- crates/solana-solvers/src/dex/mod.rs | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index fec8534dac..8846e02de1 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -46,7 +46,7 @@ impl Jupiter { let quote = self.quote(order).await?; let in_amount = amount_field("e, "inAmount")?; let out_amount = amount_field("e, "outAmount")?; - self.swap_instructions("e, user, &order.buy_token_account) + self.swap_instructions("e, user, &order.buy_destination) .await? .into_swap(in_amount, out_amount) } @@ -85,8 +85,8 @@ impl Jupiter { let body = serde_json::json!({ "quoteResponse": quote, "userPublicKey": user.to_string(), - // Deliver the swap output straight to the order's receiver account so - // the swap credits the user, with no separate payout instruction. + // Send the swap output to the settlement buffer for the buy mint. + // FinalizeSettle pushes it to the user, so there is no payout here. "destinationTokenAccount": destination.to_string(), // The settlement manages token accounts, so Jupiter shouldn't probe // them over RPC. It still populates the setup instructions. @@ -175,7 +175,7 @@ mod tests { let order = Order { sell_mint: Pubkey::from_str(WSOL).unwrap(), buy_mint: Pubkey::from_str(USDC).unwrap(), - buy_token_account: Pubkey::new_unique(), + buy_destination: Pubkey::new_unique(), amount: 1_000_000, side: Side::Buy, }; @@ -213,7 +213,7 @@ mod tests { let order = Order { sell_mint: Pubkey::from_str(USDC).unwrap(), buy_mint: Pubkey::from_str(WSOL).unwrap(), - buy_token_account: Pubkey::from_str(WSOL).unwrap(), + buy_destination: Pubkey::from_str(WSOL).unwrap(), amount: 1_000_000, side: Side::Sell, }; diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 58803a9789..11cef3944e 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -14,10 +14,10 @@ use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; pub struct Order { pub sell_mint: Pubkey, pub buy_mint: Pubkey, - /// The order's receiver account. Passed to Jupiter as - /// `destinationTokenAccount` so the swap credits the user directly, with no - /// separate payout instruction. - pub buy_token_account: Pubkey, + /// Where the swap sends its output: the settlement's buy-mint buffer, + /// resolved upstream (driver or autopilot). Passed to Jupiter as + /// `destinationTokenAccount`. `FinalizeSettle` then pushes to the user. + pub buy_destination: Pubkey, /// Sell amount for `Sell`, buy amount for `Buy`. pub amount: u64, pub side: Side, From d9e5c3682a91e5ef54e5f43de25e708a362cfe82 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 10:44:41 +0000 Subject: [PATCH 10/16] refactor(solana-solvers): switch to Jupiter Swap V2 /build (single call, ExactIn only) --- .../config/example.jupiter.toml | 10 +- crates/solana-solvers/src/config.rs | 18 +-- crates/solana-solvers/src/dex/jupiter/dto.rs | 37 ++++-- crates/solana-solvers/src/dex/jupiter/mod.rs | 124 +++++++----------- crates/solana-solvers/src/dex/mod.rs | 3 +- 5 files changed, 81 insertions(+), 111 deletions(-) diff --git a/crates/solana-solvers/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml index 2992bee61c..155005e43e 100644 --- a/crates/solana-solvers/config/example.jupiter.toml +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -2,12 +2,12 @@ # Run with: solana-solvers jupiter --config [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. +# Jupiter swap API base URL. /swap/v2/build is on api.jup.ag (lite-api.jup.ag +# only serves the deprecated v1 API). A Triton-hosted endpoint also works, via a +# base-URL and key swap. endpoint = "https://api.jup.ag" -# API key from the Jupiter developer portal (or Triton). Omit only for lite-api. +# API key from the Jupiter developer portal (or Triton). Works without one but +# heavily rate-limited, set it for production. api-key = "your-jupiter-api-key" # Slippage tolerance in basis points, sent to Jupiter as slippageBps. 50 = 0.5%. slippage-bps = 50 -# Buy orders quote via Jupiter ExactOut, which is route-limited. Off by default. -enable-buy-orders = false diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index 3a8d817190..6adfeb77f2 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -9,28 +9,23 @@ 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. +/// The `[dex]` table for the Jupiter backend. #[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. + /// Base URL of the Jupiter swap API. `/swap/v2/build` lives on `api.jup.ag` + /// (the keyless `lite-api.jup.ag` only serves the deprecated v1 API), or a + /// Triton-hosted 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. + /// API key from the Jupiter developer portal (or Triton). Requests work + /// without one but are heavily rate-limited, so set it for production. #[serde(default)] pub api_key: Option, /// Slippage tolerance in basis points, sent to Jupiter as `slippageBps`. /// 50 = 0.5%. pub slippage_bps: u16, - - /// Whether buy orders (Jupiter `ExactOut`) are served. Off by default. - #[serde(default)] - pub enable_buy_orders: bool, } /// Load and parse the TOML config file. @@ -55,7 +50,6 @@ mod tests { 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_bps, 50); - assert!(!config.dex.enable_buy_orders); assert!(config.dex.api_key.is_some()); } diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index 41da7ae005..fda324137d 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -1,37 +1,45 @@ -//! DTOs for Jupiter's `/swap-instructions` response, converted to Solana +//! DTO for Jupiter's `/swap/v2/build` response, converted to Solana //! instructions. Field names follow Jupiter's camelCase JSON. use { super::Error, crate::dex::Swap, base64::prelude::*, - serde::Deserialize, + serde::{Deserialize, de::IgnoredAny}, solana_sdk::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, }, - std::str::FromStr, + std::{collections::HashMap, str::FromStr}, }; -/// Subset of Jupiter's `/swap-instructions` response we consume. -/// Compute-budget, token-ledger, and Jito instructions are ignored: the driver -/// owns compute budget and tipping. +/// Subset of the `/swap/v2/build` response we consume. Compute-budget, +/// token-ledger, tip, and other instructions are ignored: the driver owns +/// compute budget and tipping. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SwapInstructionsResponse { +pub struct BuildResponse { + /// In and out amounts, sent as decimal strings. + in_amount: String, + out_amount: String, #[serde(default)] setup_instructions: Vec, swap_instruction: JupInstruction, #[serde(default)] cleanup_instruction: Option, + /// Map from lookup-table address to the accounts it holds. We keep only the + /// table addresses (the keys), which the driver needs to build the v0 tx. #[serde(default)] - address_lookup_table_addresses: Vec, + addresses_by_lookup_table_address: Option>, } -impl SwapInstructionsResponse { +impl BuildResponse { /// Flatten into execution order (setup, swap, cleanup) and resolve the - /// lookup tables the driver needs to build the v0 transaction. - pub fn into_swap(self, in_amount: u64, out_amount: u64) -> Result { + /// lookup-table addresses. + pub fn into_swap(self) -> Result { + let in_amount = self.in_amount.parse().map_err(|_| Error::BadResponse)?; + let out_amount = self.out_amount.parse().map_err(|_| Error::BadResponse)?; + let mut instructions = Vec::with_capacity(self.setup_instructions.len() + 2); for instruction in self.setup_instructions { instructions.push(instruction.into_instruction()?); @@ -40,11 +48,14 @@ impl SwapInstructionsResponse { if let Some(instruction) = self.cleanup_instruction { instructions.push(instruction.into_instruction()?); } + let address_lookup_tables = self - .address_lookup_table_addresses - .iter() + .addresses_by_lookup_table_address + .unwrap_or_default() + .keys() .map(|address| Pubkey::from_str(address).map_err(|_| Error::BadResponse)) .collect::>()?; + Ok(Swap { in_amount, out_amount, diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index 8846e02de1..ea7debdcb5 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -1,10 +1,12 @@ //! Jupiter swap-API adapter. //! -//! Chains Jupiter's two-step flow: `GET /swap/v1/quote` for the route and -//! amounts, then `POST /swap/v1/swap-instructions` for the on-chain -//! instructions. `api.jup.ag` needs a portal-issued api-key, keyless -//! `lite-api.jup.ag` works too but is rate-limited. A self-hosted provider -//! (Triton) is a base-URL and key swap behind this same adapter. +//! One `GET /swap/v2/build` call returns the route, amounts, and the on-chain +//! instructions in a single response (the v1 `/quote` + `/swap-instructions` +//! pair it replaces is deprecated). `/build` is ExactIn only, so sell orders +//! are served and buy orders are rejected until a post-MVP ExactIn-iterate path +//! lands. `/build` is hosted on `api.jup.ag` (keyless `lite-api.jup.ag` is v1 +//! only). A self-hosted provider (Triton) is a base-URL and key swap behind +//! this adapter. mod dto; @@ -14,8 +16,7 @@ use { solana_sdk::pubkey::Pubkey, }; -const QUOTE_PATH: &str = "swap/v1/quote"; -const SWAP_INSTRUCTIONS_PATH: &str = "swap/v1/swap-instructions"; +const BUILD_PATH: &str = "swap/v2/build"; /// Adapter over the Jupiter swap API. pub struct Jupiter { @@ -23,7 +24,6 @@ pub struct Jupiter { endpoint: reqwest::Url, api_key: Option, slippage_bps: u16, - enable_buy_orders: bool, } impl Jupiter { @@ -33,73 +33,39 @@ impl Jupiter { endpoint: config.endpoint.clone(), api_key: config.api_key.clone(), slippage_bps: config.slippage_bps, - enable_buy_orders: config.enable_buy_orders, }) } - /// Quote `order` for settlement signer `user` and return the swap to run - /// inside the settlement transaction. - pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result { - if order.side == Side::Buy && !self.enable_buy_orders { + /// Quote `order` for the settlement signer `taker` and return the swap to + /// run inside the settlement transaction. + pub async fn swap(&self, order: &Order, taker: &Pubkey) -> Result { + // `/swap/v2/build` is ExactIn only, so buy orders (ExactOut) aren't + // served. The post-MVP path quotes the sell side and iterates. + if order.side == Side::Buy { return Err(Error::OrderNotSupported); } - let quote = self.quote(order).await?; - let in_amount = amount_field("e, "inAmount")?; - let out_amount = amount_field("e, "outAmount")?; - self.swap_instructions("e, user, &order.buy_destination) - .await? - .into_swap(in_amount, out_amount) + self.build(order, taker).await?.into_swap() } - /// `GET /swap/v1/quote`. Kept opaque and passed back verbatim to - /// `/swap-instructions`, we only read the amounts. - async fn quote(&self, order: &Order) -> Result { - let mode = match order.side { - Side::Sell => "ExactIn", - Side::Buy => "ExactOut", - }; + /// `GET /swap/v2/build`: route, amounts, and instructions in one response. + async fn build(&self, order: &Order, taker: &Pubkey) -> Result { let mut url = self .endpoint - .join(QUOTE_PATH) + .join(BUILD_PATH) .map_err(|_| Error::RequestBuildFailed)?; url.query_pairs_mut() .append_pair("inputMint", &order.sell_mint.to_string()) .append_pair("outputMint", &order.buy_mint.to_string()) .append_pair("amount", &order.amount.to_string()) - .append_pair("swapMode", mode) - .append_pair("slippageBps", &self.slippage_bps.to_string()); - self.send(self.with_key(self.client.get(url))).await - } - - /// `POST /swap/v1/swap-instructions` with the quote and settlement signer. - async fn swap_instructions( - &self, - quote: &serde_json::Value, - user: &Pubkey, - destination: &Pubkey, - ) -> Result { - let url = self - .endpoint - .join(SWAP_INSTRUCTIONS_PATH) - .map_err(|_| Error::RequestBuildFailed)?; - let body = serde_json::json!({ - "quoteResponse": quote, - "userPublicKey": user.to_string(), + .append_pair("taker", &taker.to_string()) + .append_pair("slippageBps", &self.slippage_bps.to_string()) // Send the swap output to the settlement buffer for the buy mint. // FinalizeSettle pushes it to the user, so there is no payout here. - "destinationTokenAccount": destination.to_string(), - // The settlement manages token accounts, so Jupiter shouldn't probe - // them over RPC. It still populates the setup instructions. - "skipUserAccountsRpcCalls": true, - }); - let body = serde_json::to_string(&body).map_err(|_| Error::RequestBuildFailed)?; - let request = self.with_key( - self.client - .post(url) - .header("content-type", "application/json") - .body(body), - ); - self.send(request).await + .append_pair("destinationTokenAccount", &order.buy_destination.to_string()) + // The swap rides inside the settlement tx and the driver handles SOL + // wrapping, so leave Jupiter's wrap/unwrap off. + .append_pair("wrapAndUnwrapSol", "false"); + self.send(self.with_key(self.client.get(url))).await } fn with_key(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { @@ -128,14 +94,6 @@ impl Jupiter { } } -fn amount_field(quote: &serde_json::Value, field: &str) -> Result { - quote - .get(field) - .and_then(serde_json::Value::as_str) - .and_then(|amount| amount.parse().ok()) - .ok_or(Error::BadResponse) -} - #[derive(Debug, thiserror::Error)] pub enum Error { #[error("failed to build the request")] @@ -160,18 +118,17 @@ mod tests { const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; const WSOL: &str = "So11111111111111111111111111111111111111112"; - fn config(enable_buy_orders: bool) -> JupiterConfig { + fn config() -> JupiterConfig { JupiterConfig { - endpoint: "https://lite-api.jup.ag".parse().unwrap(), + endpoint: "https://api.jup.ag".parse().unwrap(), api_key: None, slippage_bps: 50, - enable_buy_orders, } } #[tokio::test] - async fn buy_disabled_short_circuits() { - let jupiter = Jupiter::new(&config(false)).unwrap(); + async fn buy_unsupported() { + let jupiter = Jupiter::new(&config()).unwrap(); let order = Order { sell_mint: Pubkey::from_str(WSOL).unwrap(), buy_mint: Pubkey::from_str(USDC).unwrap(), @@ -184,8 +141,10 @@ mod tests { } #[test] - fn parses_swap_instructions() { + fn parses_build_response() { let json = serde_json::json!({ + "inAmount": "100", + "outAmount": "250", "setupInstructions": [], "swapInstruction": { "programId": WSOL, @@ -193,23 +152,28 @@ mod tests { "data": "AAEC" }, "cleanupInstruction": null, - "addressLookupTableAddresses": [USDC, WSOL] + "addressesByLookupTableAddress": { + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": [WSOL], + "So11111111111111111111111111111111111111112": [USDC] + } }); - let response: dto::SwapInstructionsResponse = serde_json::from_value(json).unwrap(); - let swap = response.into_swap(100, 250).unwrap(); + let response: dto::BuildResponse = serde_json::from_value(json).unwrap(); + let swap = response.into_swap().unwrap(); assert_eq!(swap.instructions.len(), 1); assert_eq!(swap.instructions[0].accounts.len(), 1); assert_eq!(swap.address_lookup_tables.len(), 2); assert_eq!((swap.in_amount, swap.out_amount), (100, 250)); } - /// Live Jupiter API (keyless lite-api). Needs network. Run with: + /// Live Jupiter API. `/swap/v2/build` is on `api.jup.ag`, keyless works but + /// is throttled, set `JUPITER_API_KEY` for headroom. Needs network. Run: /// `cargo test -p solana-solvers -- --ignored jupiter_live`. - /// Production points at `api.jup.ag` with an api-key instead. #[tokio::test] #[ignore] - async fn jupiter_live_sell_quote() { - let jupiter = Jupiter::new(&config(false)).unwrap(); + async fn jupiter_live_sell_build() { + let mut config = config(); + config.api_key = std::env::var("JUPITER_API_KEY").ok(); + let jupiter = Jupiter::new(&config).unwrap(); let order = Order { sell_mint: Pubkey::from_str(USDC).unwrap(), buy_mint: Pubkey::from_str(WSOL).unwrap(), diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 11cef3944e..eab6d31f62 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -18,7 +18,8 @@ pub struct Order { /// resolved upstream (driver or autopilot). Passed to Jupiter as /// `destinationTokenAccount`. `FinalizeSettle` then pushes to the user. pub buy_destination: Pubkey, - /// Sell amount for `Sell`, buy amount for `Buy`. + /// The sell amount, sent to `/build` as the ExactIn input. Buy orders are + /// rejected (see [`Side`]), so this is always the sell side. pub amount: u64, pub side: Side, } From 78a8bcad19dfbde44a83e965250f8ed4c93247fe Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 12:06:28 +0000 Subject: [PATCH 11/16] docs(solana-solvers): describe current behavior in comments, drop frozen-liquidity phrasing --- crates/solana-solvers/src/api.rs | 11 ++++------- crates/solana-solvers/src/cli.rs | 3 +-- crates/solana-solvers/src/dex/jupiter/mod.rs | 15 +++++++-------- crates/solana-solvers/src/dex/mod.rs | 7 +++---- crates/solana-solvers/src/lib.rs | 6 ++---- 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/crates/solana-solvers/src/api.rs b/crates/solana-solvers/src/api.rs index 7be96957d7..26a6024690 100644 --- a/crates/solana-solvers/src/api.rs +++ b/crates/solana-solvers/src/api.rs @@ -1,8 +1,7 @@ //! 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. +//! Serves the `/solve` contract the driver calls. The handler is a scaffold: it +//! accepts any auction and returns no solutions. use { crate::config::Config, @@ -49,10 +48,8 @@ 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. +/// Scaffold `/solve`: accepts any auction and returns no solutions, so the +/// driver wiring can be exercised against an empty result. async fn solve(State(_config): State>, Json(_auction): Json) -> Json { Json(json!({ "solutions": [] })) } diff --git a/crates/solana-solvers/src/cli.rs b/crates/solana-solvers/src/cli.rs index 0cba5232b4..9a8cadbe3e 100644 --- a/crates/solana-solvers/src/cli.rs +++ b/crates/solana-solvers/src/cli.rs @@ -34,6 +34,5 @@ pub enum Command { #[clap(long, env)] config: PathBuf, }, - // Baseline (self-indexed on-chain liquidity) lands when the driver's - // liquidity module unfreezes. + // TODO: add a baseline engine subcommand. } diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index ea7debdcb5..f2e5409939 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -1,12 +1,11 @@ //! Jupiter swap-API adapter. //! //! One `GET /swap/v2/build` call returns the route, amounts, and the on-chain -//! instructions in a single response (the v1 `/quote` + `/swap-instructions` -//! pair it replaces is deprecated). `/build` is ExactIn only, so sell orders -//! are served and buy orders are rejected until a post-MVP ExactIn-iterate path -//! lands. `/build` is hosted on `api.jup.ag` (keyless `lite-api.jup.ag` is v1 -//! only). A self-hosted provider (Triton) is a base-URL and key swap behind -//! this adapter. +//! instructions in a single response (it replaces the deprecated v1 `/quote` + +//! `/swap-instructions` pair). `/build` is ExactIn only, so sell orders are +//! served and buy orders are rejected. `/build` is on `api.jup.ag` (keyless +//! `lite-api.jup.ag` is v1 only). A self-hosted provider (Triton) is a base-URL +//! and key swap behind this adapter. mod dto; @@ -39,8 +38,8 @@ impl Jupiter { /// Quote `order` for the settlement signer `taker` and return the swap to /// run inside the settlement transaction. pub async fn swap(&self, order: &Order, taker: &Pubkey) -> Result { - // `/swap/v2/build` is ExactIn only, so buy orders (ExactOut) aren't - // served. The post-MVP path quotes the sell side and iterates. + // `/swap/v2/build` is ExactIn only, so buy orders (ExactOut) aren't served. + // TODO: serve buy orders via an ExactIn-iterate path. if order.side == Side::Buy { return Err(Error::OrderNotSupported); } diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index eab6d31f62..12908e46d3 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -1,9 +1,8 @@ //! DEX-adapter boundary: quote one order into an executable swap. //! -//! `Dex` dispatches to the configured engine. Jupiter is the only one at MVP, -//! a Solana baseline joins once the frozen liquidity module unfreezes. Triton -//! (hosted Jupiter) is not a separate engine, just a base-URL and key swap -//! inside the Jupiter adapter. +//! `Dex` dispatches to the configured engine. Jupiter is the only variant. +//! Triton (hosted Jupiter) is not a separate engine, just a base-URL and key +//! swap inside the Jupiter adapter. pub mod jupiter; diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs index ce711e60ef..98f1a02c77 100644 --- a/crates/solana-solvers/src/lib.rs +++ b/crates/solana-solvers/src/lib.rs @@ -1,9 +1,7 @@ //! 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. +//! An MVP dex-wrapper over Jupiter, mirroring the `crates/solvers` shape over +//! Solana-native types (`u64`, `Pubkey`, instructions). Hosts the `/solve` API. pub mod api; mod cli; From 03e36aa84aacd89e9a226a9332ad0d4f865f087d Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 13:15:25 +0000 Subject: [PATCH 12/16] docs(solana-solvers): describe current behavior in comments, drop frozen-liquidity phrasing --- crates/solana-solvers/src/api.rs | 11 ++++------- crates/solana-solvers/src/cli.rs | 3 +-- crates/solana-solvers/src/lib.rs | 6 ++---- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/solana-solvers/src/api.rs b/crates/solana-solvers/src/api.rs index 7be96957d7..26a6024690 100644 --- a/crates/solana-solvers/src/api.rs +++ b/crates/solana-solvers/src/api.rs @@ -1,8 +1,7 @@ //! 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. +//! Serves the `/solve` contract the driver calls. The handler is a scaffold: it +//! accepts any auction and returns no solutions. use { crate::config::Config, @@ -49,10 +48,8 @@ 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. +/// Scaffold `/solve`: accepts any auction and returns no solutions, so the +/// driver wiring can be exercised against an empty result. async fn solve(State(_config): State>, Json(_auction): Json) -> Json { Json(json!({ "solutions": [] })) } diff --git a/crates/solana-solvers/src/cli.rs b/crates/solana-solvers/src/cli.rs index 0cba5232b4..9a8cadbe3e 100644 --- a/crates/solana-solvers/src/cli.rs +++ b/crates/solana-solvers/src/cli.rs @@ -34,6 +34,5 @@ pub enum Command { #[clap(long, env)] config: PathBuf, }, - // Baseline (self-indexed on-chain liquidity) lands when the driver's - // liquidity module unfreezes. + // TODO: add a baseline engine subcommand. } diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs index 2e208b5448..177ff38e4a 100644 --- a/crates/solana-solvers/src/lib.rs +++ b/crates/solana-solvers/src/lib.rs @@ -1,9 +1,7 @@ //! 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. +//! An MVP dex-wrapper over Jupiter, mirroring the `crates/solvers` shape over +//! Solana-native types (`u64`, `Pubkey`, instructions). Hosts the `/solve` API. pub mod api; mod cli; From 1c721572e7bbe3c6d39fb22a2d5cbc738849287d Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 13:32:50 +0000 Subject: [PATCH 13/16] docs(solana-solvers): drop lite-api mentions --- crates/solana-solvers/config/example.jupiter.toml | 5 ++--- crates/solana-solvers/src/config.rs | 5 ++--- crates/solana-solvers/src/dex/jupiter/mod.rs | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/solana-solvers/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml index 155005e43e..bdeea4742f 100644 --- a/crates/solana-solvers/config/example.jupiter.toml +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -2,9 +2,8 @@ # Run with: solana-solvers jupiter --config [dex] -# Jupiter swap API base URL. /swap/v2/build is on api.jup.ag (lite-api.jup.ag -# only serves the deprecated v1 API). A Triton-hosted endpoint also works, via a -# base-URL and key swap. +# Jupiter swap API base URL. /swap/v2/build is on api.jup.ag. A Triton-hosted +# endpoint also works, via a base-URL and key swap. endpoint = "https://api.jup.ag" # API key from the Jupiter developer portal (or Triton). Works without one but # heavily rate-limited, set it for production. diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index 6adfeb77f2..e5f7860339 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -13,9 +13,8 @@ pub struct Config { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct JupiterConfig { - /// Base URL of the Jupiter swap API. `/swap/v2/build` lives on `api.jup.ag` - /// (the keyless `lite-api.jup.ag` only serves the deprecated v1 API), or a - /// Triton-hosted endpoint. + /// Base URL of the Jupiter swap API. `/swap/v2/build` lives on + /// `api.jup.ag`, or a Triton-hosted endpoint. pub endpoint: Url, /// API key from the Jupiter developer portal (or Triton). Requests work diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index f2e5409939..1c183b324f 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -3,9 +3,9 @@ //! One `GET /swap/v2/build` call returns the route, amounts, and the on-chain //! instructions in a single response (it replaces the deprecated v1 `/quote` + //! `/swap-instructions` pair). `/build` is ExactIn only, so sell orders are -//! served and buy orders are rejected. `/build` is on `api.jup.ag` (keyless -//! `lite-api.jup.ag` is v1 only). A self-hosted provider (Triton) is a base-URL -//! and key swap behind this adapter. +//! served and buy orders are rejected. `/build` is on `api.jup.ag`. A +//! self-hosted provider (Triton) is a base-URL and key swap behind this +//! adapter. mod dto; From cfa1dc5aa2f575cf2432c910fbfaf2d2a8ddb457 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 13:34:46 +0000 Subject: [PATCH 14/16] docs(solana-solvers): drop lite-api mentions --- crates/solana-solvers/config/example.jupiter.toml | 6 +++--- crates/solana-solvers/src/config.rs | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/solana-solvers/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml index 2992bee61c..e31b84e086 100644 --- a/crates/solana-solvers/config/example.jupiter.toml +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -2,10 +2,10 @@ # Run with: solana-solvers jupiter --config [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. +# Jupiter swap API base URL. Use api.jup.ag, or a Triton-hosted endpoint. endpoint = "https://api.jup.ag" -# API key from the Jupiter developer portal (or Triton). Omit only for lite-api. +# API key from the Jupiter developer portal (or Triton). Works without one but +# heavily rate-limited, set it for production. api-key = "your-jupiter-api-key" # Slippage tolerance in basis points, sent to Jupiter as slippageBps. 50 = 0.5%. slippage-bps = 50 diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index 3a8d817190..72aa3c0231 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -18,9 +18,8 @@ pub struct JupiterConfig { /// 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. + /// API key from the Jupiter developer portal (or Triton). Requests work + /// without one but are heavily rate-limited, so set it for production. #[serde(default)] pub api_key: Option, From cc5928a276a39a3a93e7ead95e3aac29c0c7dddc Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 13:52:55 +0000 Subject: [PATCH 15/16] docs(solana-solvers): trim comments to the relevant fact --- crates/solana-solvers/src/dex/jupiter/dto.rs | 7 ++---- crates/solana-solvers/src/dex/jupiter/mod.rs | 23 +++++++------------- crates/solana-solvers/src/dex/mod.rs | 4 +--- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index fda324137d..c87774f273 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -13,9 +13,7 @@ use { std::{collections::HashMap, str::FromStr}, }; -/// Subset of the `/swap/v2/build` response we consume. Compute-budget, -/// token-ledger, tip, and other instructions are ignored: the driver owns -/// compute budget and tipping. +/// The parts of the `/swap/v2/build` response we need to build a [`Swap`]. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildResponse { @@ -27,8 +25,7 @@ pub struct BuildResponse { swap_instruction: JupInstruction, #[serde(default)] cleanup_instruction: Option, - /// Map from lookup-table address to the accounts it holds. We keep only the - /// table addresses (the keys), which the driver needs to build the v0 tx. + /// Lookup tables keyed by address. We keep only the addresses (the keys). #[serde(default)] addresses_by_lookup_table_address: Option>, } diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index 1c183b324f..7e11e5fa2a 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -1,11 +1,8 @@ //! Jupiter swap-API adapter. //! -//! One `GET /swap/v2/build` call returns the route, amounts, and the on-chain -//! instructions in a single response (it replaces the deprecated v1 `/quote` + -//! `/swap-instructions` pair). `/build` is ExactIn only, so sell orders are -//! served and buy orders are rejected. `/build` is on `api.jup.ag`. A -//! self-hosted provider (Triton) is a base-URL and key swap behind this -//! adapter. +//! `GET /swap/v2/build` returns the route, amounts, and on-chain instructions +//! in one call. ExactIn only: sell orders are served, buys are rejected. +//! Triton is a base-URL and key swap behind the same adapter. mod dto; @@ -38,8 +35,7 @@ impl Jupiter { /// Quote `order` for the settlement signer `taker` and return the swap to /// run inside the settlement transaction. pub async fn swap(&self, order: &Order, taker: &Pubkey) -> Result { - // `/swap/v2/build` is ExactIn only, so buy orders (ExactOut) aren't served. - // TODO: serve buy orders via an ExactIn-iterate path. + // `/build` is ExactIn only, so buy orders aren't served here. if order.side == Side::Buy { return Err(Error::OrderNotSupported); } @@ -58,11 +54,9 @@ impl Jupiter { .append_pair("amount", &order.amount.to_string()) .append_pair("taker", &taker.to_string()) .append_pair("slippageBps", &self.slippage_bps.to_string()) - // Send the swap output to the settlement buffer for the buy mint. - // FinalizeSettle pushes it to the user, so there is no payout here. + // Send the swap output to the order's destination account. .append_pair("destinationTokenAccount", &order.buy_destination.to_string()) - // The swap rides inside the settlement tx and the driver handles SOL - // wrapping, so leave Jupiter's wrap/unwrap off. + // SOL wrapping is handled outside the swap. .append_pair("wrapAndUnwrapSol", "false"); self.send(self.with_key(self.client.get(url))).await } @@ -164,9 +158,8 @@ mod tests { assert_eq!((swap.in_amount, swap.out_amount), (100, 250)); } - /// Live Jupiter API. `/swap/v2/build` is on `api.jup.ag`, keyless works but - /// is throttled, set `JUPITER_API_KEY` for headroom. Needs network. Run: - /// `cargo test -p solana-solvers -- --ignored jupiter_live`. + /// Live Jupiter API. Needs network. Keyless works, set `JUPITER_API_KEY` + /// for headroom. #[tokio::test] #[ignore] async fn jupiter_live_sell_build() { diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 12908e46d3..03931bacae 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -1,8 +1,6 @@ //! DEX-adapter boundary: quote one order into an executable swap. //! -//! `Dex` dispatches to the configured engine. Jupiter is the only variant. -//! Triton (hosted Jupiter) is not a separate engine, just a base-URL and key -//! swap inside the Jupiter adapter. +//! `Dex` dispatches to the configured engine. pub mod jupiter; From 6e3bae12f3c6dcf641f501ec511159eb98f08362 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 17 Jul 2026 16:06:23 +0000 Subject: [PATCH 16/16] refactor(solana-solvers): route sells through v1 /quote + /swap-instructions --- .../config/example.jupiter.toml | 3 +- crates/solana-solvers/src/config.rs | 4 +- crates/solana-solvers/src/dex/jupiter/dto.rs | 31 ++--- crates/solana-solvers/src/dex/jupiter/mod.rs | 113 +++++++++++------- crates/solana-solvers/src/dex/mod.rs | 3 +- 5 files changed, 88 insertions(+), 66 deletions(-) diff --git a/crates/solana-solvers/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml index bdeea4742f..d3c5b75469 100644 --- a/crates/solana-solvers/config/example.jupiter.toml +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -2,8 +2,7 @@ # Run with: solana-solvers jupiter --config [dex] -# Jupiter swap API base URL. /swap/v2/build is on api.jup.ag. A Triton-hosted -# endpoint also works, via a base-URL and key swap. +# Jupiter swap API base URL: api.jup.ag, or a Triton-hosted endpoint. endpoint = "https://api.jup.ag" # API key from the Jupiter developer portal (or Triton). Works without one but # heavily rate-limited, set it for production. diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index e5f7860339..5d53fe8062 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -13,8 +13,8 @@ pub struct Config { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct JupiterConfig { - /// Base URL of the Jupiter swap API. `/swap/v2/build` lives on - /// `api.jup.ag`, or a Triton-hosted endpoint. + /// Base URL of the Jupiter swap API: `api.jup.ag`, or a Triton-hosted + /// endpoint. pub endpoint: Url, /// API key from the Jupiter developer portal (or Triton). Requests work diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index c87774f273..34232808f3 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -1,42 +1,36 @@ -//! DTO for Jupiter's `/swap/v2/build` response, converted to Solana +//! DTO for Jupiter's `/swap-instructions` response, converted to Solana //! instructions. Field names follow Jupiter's camelCase JSON. use { super::Error, crate::dex::Swap, base64::prelude::*, - serde::{Deserialize, de::IgnoredAny}, + serde::Deserialize, solana_sdk::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, }, - std::{collections::HashMap, str::FromStr}, + std::str::FromStr, }; -/// The parts of the `/swap/v2/build` response we need to build a [`Swap`]. +/// The parts of the `/swap-instructions` response we need to build a [`Swap`]. +/// Amounts come from the `/quote` response. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct BuildResponse { - /// In and out amounts, sent as decimal strings. - in_amount: String, - out_amount: String, +pub struct SwapInstructionsResponse { #[serde(default)] setup_instructions: Vec, swap_instruction: JupInstruction, #[serde(default)] cleanup_instruction: Option, - /// Lookup tables keyed by address. We keep only the addresses (the keys). #[serde(default)] - addresses_by_lookup_table_address: Option>, + address_lookup_table_addresses: Vec, } -impl BuildResponse { +impl SwapInstructionsResponse { /// Flatten into execution order (setup, swap, cleanup) and resolve the /// lookup-table addresses. - pub fn into_swap(self) -> Result { - let in_amount = self.in_amount.parse().map_err(|_| Error::BadResponse)?; - let out_amount = self.out_amount.parse().map_err(|_| Error::BadResponse)?; - + pub fn into_swap(self, in_amount: u64, out_amount: u64) -> Result { let mut instructions = Vec::with_capacity(self.setup_instructions.len() + 2); for instruction in self.setup_instructions { instructions.push(instruction.into_instruction()?); @@ -45,14 +39,11 @@ impl BuildResponse { if let Some(instruction) = self.cleanup_instruction { instructions.push(instruction.into_instruction()?); } - let address_lookup_tables = self - .addresses_by_lookup_table_address - .unwrap_or_default() - .keys() + .address_lookup_table_addresses + .iter() .map(|address| Pubkey::from_str(address).map_err(|_| Error::BadResponse)) .collect::>()?; - Ok(Swap { in_amount, out_amount, diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index 7e11e5fa2a..5668f6a5a9 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -1,8 +1,7 @@ //! Jupiter swap-API adapter. //! -//! `GET /swap/v2/build` returns the route, amounts, and on-chain instructions -//! in one call. ExactIn only: sell orders are served, buys are rejected. -//! Triton is a base-URL and key swap behind the same adapter. +//! v1 `/quote` + `/swap-instructions` (ExactIn). Triton is a base-URL and key +//! swap behind the same adapter. mod dto; @@ -12,7 +11,8 @@ use { solana_sdk::pubkey::Pubkey, }; -const BUILD_PATH: &str = "swap/v2/build"; +const QUOTE_PATH: &str = "swap/v1/quote"; +const SWAP_INSTRUCTIONS_PATH: &str = "swap/v1/swap-instructions"; /// Adapter over the Jupiter swap API. pub struct Jupiter { @@ -35,30 +35,62 @@ impl Jupiter { /// Quote `order` for the settlement signer `taker` and return the swap to /// run inside the settlement transaction. pub async fn swap(&self, order: &Order, taker: &Pubkey) -> Result { - // `/build` is ExactIn only, so buy orders aren't served here. + // Buy orders (ExactOut) aren't served here. if order.side == Side::Buy { return Err(Error::OrderNotSupported); } - self.build(order, taker).await?.into_swap() + let quote = self.quote(order, "ExactIn").await?; + let in_amount = amount_field("e, "inAmount")?; + let out_amount = amount_field("e, "outAmount")?; + self.swap_instructions("e, taker, &order.buy_destination) + .await? + .into_swap(in_amount, out_amount) } - /// `GET /swap/v2/build`: route, amounts, and instructions in one response. - async fn build(&self, order: &Order, taker: &Pubkey) -> Result { + /// `GET /swap/v1/quote`. Kept opaque and passed back verbatim to + /// `/swap-instructions`, we only read the amounts. + async fn quote(&self, order: &Order, swap_mode: &str) -> Result { let mut url = self .endpoint - .join(BUILD_PATH) + .join(QUOTE_PATH) .map_err(|_| Error::RequestBuildFailed)?; url.query_pairs_mut() .append_pair("inputMint", &order.sell_mint.to_string()) .append_pair("outputMint", &order.buy_mint.to_string()) .append_pair("amount", &order.amount.to_string()) - .append_pair("taker", &taker.to_string()) - .append_pair("slippageBps", &self.slippage_bps.to_string()) + .append_pair("swapMode", swap_mode) + .append_pair("slippageBps", &self.slippage_bps.to_string()); + self.send(self.with_key(self.client.get(url))).await + } + + /// `POST /swap/v1/swap-instructions` for the given quote. + async fn swap_instructions( + &self, + quote: &serde_json::Value, + taker: &Pubkey, + destination: &Pubkey, + ) -> Result { + let url = self + .endpoint + .join(SWAP_INSTRUCTIONS_PATH) + .map_err(|_| Error::RequestBuildFailed)?; + let body = serde_json::json!({ + "quoteResponse": quote, + "userPublicKey": taker.to_string(), // Send the swap output to the order's destination account. - .append_pair("destinationTokenAccount", &order.buy_destination.to_string()) + "destinationTokenAccount": destination.to_string(), // SOL wrapping is handled outside the swap. - .append_pair("wrapAndUnwrapSol", "false"); - self.send(self.with_key(self.client.get(url))).await + "wrapAndUnwrapSol": false, + "skipUserAccountsRpcCalls": true, + }); + let body = serde_json::to_string(&body).map_err(|_| Error::RequestBuildFailed)?; + let request = self.with_key( + self.client + .post(url) + .header("content-type", "application/json") + .body(body), + ); + self.send(request).await } fn with_key(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { @@ -87,6 +119,14 @@ impl Jupiter { } } +fn amount_field(quote: &serde_json::Value, field: &str) -> Result { + quote + .get(field) + .and_then(serde_json::Value::as_str) + .and_then(|amount| amount.parse().ok()) + .ok_or(Error::BadResponse) +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("failed to build the request")] @@ -114,30 +154,35 @@ mod tests { fn config() -> JupiterConfig { JupiterConfig { endpoint: "https://api.jup.ag".parse().unwrap(), - api_key: None, + api_key: std::env::var("JUPITER_API_KEY").ok(), slippage_bps: 50, } } + fn sell_order() -> Order { + Order { + sell_mint: Pubkey::from_str(USDC).unwrap(), + buy_mint: Pubkey::from_str(WSOL).unwrap(), + buy_destination: Pubkey::from_str(WSOL).unwrap(), + amount: 1_000_000, + side: Side::Sell, + } + } + #[tokio::test] async fn buy_unsupported() { let jupiter = Jupiter::new(&config()).unwrap(); let order = Order { - sell_mint: Pubkey::from_str(WSOL).unwrap(), - buy_mint: Pubkey::from_str(USDC).unwrap(), - buy_destination: Pubkey::new_unique(), - amount: 1_000_000, side: Side::Buy, + ..sell_order() }; let result = jupiter.swap(&order, &Pubkey::new_unique()).await; assert!(matches!(result, Err(Error::OrderNotSupported))); } #[test] - fn parses_build_response() { + fn parses_swap_instructions() { let json = serde_json::json!({ - "inAmount": "100", - "outAmount": "250", "setupInstructions": [], "swapInstruction": { "programId": WSOL, @@ -145,13 +190,10 @@ mod tests { "data": "AAEC" }, "cleanupInstruction": null, - "addressesByLookupTableAddress": { - "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": [WSOL], - "So11111111111111111111111111111111111111112": [USDC] - } + "addressLookupTableAddresses": [USDC, WSOL] }); - let response: dto::BuildResponse = serde_json::from_value(json).unwrap(); - let swap = response.into_swap().unwrap(); + let response: dto::SwapInstructionsResponse = serde_json::from_value(json).unwrap(); + let swap = response.into_swap(100, 250).unwrap(); assert_eq!(swap.instructions.len(), 1); assert_eq!(swap.instructions[0].accounts.len(), 1); assert_eq!(swap.address_lookup_tables.len(), 2); @@ -162,21 +204,12 @@ mod tests { /// for headroom. #[tokio::test] #[ignore] - async fn jupiter_live_sell_build() { - let mut config = config(); - config.api_key = std::env::var("JUPITER_API_KEY").ok(); - let jupiter = Jupiter::new(&config).unwrap(); - let order = Order { - sell_mint: Pubkey::from_str(USDC).unwrap(), - buy_mint: Pubkey::from_str(WSOL).unwrap(), - buy_destination: Pubkey::from_str(WSOL).unwrap(), - amount: 1_000_000, - side: Side::Sell, - }; + async fn jupiter_live_sell() { + let jupiter = Jupiter::new(&config()).unwrap(); // Any valid pubkey works for building instructions, the swap only runs // for real once the driver supplies its settlement signer. let swap = jupiter - .swap(&order, &Pubkey::from_str(WSOL).unwrap()) + .swap(&sell_order(), &Pubkey::from_str(WSOL).unwrap()) .await .unwrap(); assert_eq!(swap.in_amount, 1_000_000); diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 03931bacae..ccdc1bdd4a 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -15,8 +15,7 @@ pub struct Order { /// resolved upstream (driver or autopilot). Passed to Jupiter as /// `destinationTokenAccount`. `FinalizeSettle` then pushes to the user. pub buy_destination: Pubkey, - /// The sell amount, sent to `/build` as the ExactIn input. Buy orders are - /// rejected (see [`Side`]), so this is always the sell side. + /// Sell amount for a `Sell`, buy amount for a `Buy`. pub amount: u64, pub side: Side, }