diff --git a/Cargo.lock b/Cargo.lock index 1347ec6b59..2acef3d169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10825,6 +10825,26 @@ dependencies = [ "solana-sysvar-id", ] +[[package]] +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", + "tracing", + "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..c97ce83231 --- /dev/null +++ b/crates/solana-solvers/Cargo.toml @@ -0,0 +1,33 @@ +[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] +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"] } +tracing = { 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..d3c5b75469 --- /dev/null +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -0,0 +1,11 @@ +# Example Jupiter solver configuration. +# Run with: solana-solvers jupiter --config + +[dex] +# 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. +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/api.rs b/crates/solana-solvers/src/api.rs new file mode 100644 index 0000000000..26a6024690 --- /dev/null +++ b/crates/solana-solvers/src/api.rs @@ -0,0 +1,55 @@ +//! HTTP API for the solver engine. +//! +//! Serves the `/solve` contract the driver calls. The handler is a scaffold: it +//! accepts any auction and returns no solutions. + +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`: 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 new file mode 100644 index 0000000000..9a8cadbe3e --- /dev/null +++ b/crates/solana-solvers/src/cli.rs @@ -0,0 +1,38 @@ +//! 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, + }, + // TODO: add a baseline engine subcommand. +} diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs new file mode 100644 index 0000000000..5d53fe8062 --- /dev/null +++ b/crates/solana-solvers/src/config.rs @@ -0,0 +1,65 @@ +//! 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. +#[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 + /// endpoint. + pub endpoint: Url, + + /// 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, +} + +/// 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.endpoint.as_str(), "https://api.jup.ag/"); + assert_eq!(config.dex.slippage_bps, 50); + assert!(config.dex.api_key.is_some()); + } + + #[test] + fn rejects_unknown_keys() { + let toml = r#" +[dex] +endpoint = "https://api.jup.ag" +slippage-bps = 50 +bogus = true +"#; + assert!(toml::from_str::(toml).is_err()); + } +} 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..34232808f3 --- /dev/null +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -0,0 +1,99 @@ +//! 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, + solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + }, + std::str::FromStr, +}; + +/// 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 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-table addresses. + 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..5668f6a5a9 --- /dev/null +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -0,0 +1,219 @@ +//! Jupiter swap-API adapter. +//! +//! v1 `/quote` + `/swap-instructions` (ExactIn). Triton is a base-URL and key +//! swap behind the 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, +} + +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, + }) + } + + /// 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 { + // Buy orders (ExactOut) aren't served here. + if order.side == Side::Buy { + return Err(Error::OrderNotSupported); + } + 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/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(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", 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. + "destinationTokenAccount": destination.to_string(), + // SOL wrapping is handled outside the swap. + "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 { + 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() -> JupiterConfig { + JupiterConfig { + endpoint: "https://api.jup.ag".parse().unwrap(), + 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 { + side: Side::Buy, + ..sell_order() + }; + 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. Needs network. Keyless works, set `JUPITER_API_KEY` + /// for headroom. + #[tokio::test] + #[ignore] + 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(&sell_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..ccdc1bdd4a --- /dev/null +++ b/crates/solana-solvers/src/dex/mod.rs @@ -0,0 +1,53 @@ +//! DEX-adapter boundary: quote one order into an executable swap. +//! +//! `Dex` dispatches to the configured engine. + +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, + /// 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 a `Sell`, buy amount for a `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 new file mode 100644 index 0000000000..98f1a02c77 --- /dev/null +++ b/crates/solana-solvers/src/lib.rs @@ -0,0 +1,12 @@ +//! Solana solver engines for CoW Protocol. +//! +//! 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; +pub mod config; +pub mod dex; +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..1168f474c4 --- /dev/null +++ b/crates/solana-solvers/src/run.rs @@ -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) { + 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 +}