diff --git a/Cargo.lock b/Cargo.lock index bd09a5859a..1ac4105e4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10833,10 +10833,15 @@ 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", + "serde_with", + "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..fbf4331ece 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -15,10 +15,15 @@ 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 } +serde_with = { 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/config/example.jupiter.toml b/crates/solana-solvers/config/example.jupiter.toml index e31b84e086..d3c5b75469 100644 --- a/crates/solana-solvers/config/example.jupiter.toml +++ b/crates/solana-solvers/config/example.jupiter.toml @@ -2,12 +2,10 @@ # Run with: solana-solvers jupiter --config [dex] -# Jupiter swap API base URL. Use api.jup.ag, or a Triton-hosted endpoint. +# 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 -# 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 72aa3c0231..5d53fe8062 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -9,12 +9,11 @@ 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 + /// Base URL of the Jupiter swap API: `api.jup.ag`, or a Triton-hosted /// endpoint. pub endpoint: Url, @@ -26,10 +25,6 @@ pub struct JupiterConfig { /// 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. @@ -54,7 +49,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 new file mode 100644 index 0000000000..db9b971b02 --- /dev/null +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -0,0 +1,137 @@ +//! DTOs for Jupiter's `/swap-instructions` request and response, converting to +//! and from Solana instructions. Field names follow Jupiter's camelCase JSON. + +use { + super::Error, + crate::dex::Swap, + base64::prelude::*, + serde::{Deserialize, Serialize}, + serde_with::serde_as, + solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + }, + std::str::FromStr, +}; + +/// Body of the `/swap-instructions` request. +#[serde_as] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapInstructionsRequest<'a> { + quote_response: &'a serde_json::Value, + #[serde_as(as = "serde_with::DisplayFromStr")] + user_public_key: Pubkey, + #[serde_as(as = "serde_with::DisplayFromStr")] + destination_token_account: Pubkey, + wrap_and_unwrap_sol: bool, + skip_user_accounts_rpc_calls: bool, +} + +impl<'a> SwapInstructionsRequest<'a> { + /// The swap rides inside the settlement tx, so SOL wrapping is left off and + /// Jupiter skips its account RPC probes. + pub fn new( + quote_response: &'a serde_json::Value, + taker: &Pubkey, + destination: &Pubkey, + ) -> Self { + Self { + quote_response, + user_public_key: *taker, + destination_token_account: *destination, + wrap_and_unwrap_sol: false, + skip_user_accounts_rpc_calls: true, + } + } +} + +/// 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(|err| Error::BadResponse(format!("lookup table address: {err}"))) + }) + .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(|err| Error::BadResponse(format!("program id: {err}")))?; + let accounts = self + .accounts + .into_iter() + .map(JupAccount::into_meta) + .collect::>()?; + let data = BASE64_STANDARD + .decode(&self.data) + .map_err(|err| Error::BadResponse(format!("instruction data: {err}")))?; + 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(|err| Error::BadResponse(format!("account pubkey: {err}")))?, + 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..be6210fa2e --- /dev/null +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -0,0 +1,198 @@ +//! 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 payload = dto::SwapInstructionsRequest::new(quote, taker, destination); + let body = serde_json::to_string(&payload).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.is_success() { + return serde_json::from_str(&body) + .map_err(|err| Error::BadResponse(format!("response body: {err}"))); + } + match status { + reqwest::StatusCode::TOO_MANY_REQUESTS => Err(Error::RateLimited), + // Jupiter returns 400 with an error body when it can't route the order. + reqwest::StatusCode::BAD_REQUEST => Err(Error::NotFound), + // Auth or server errors: carry the status and body so the cause shows. + _ => Err(Error::Api { + status: status.as_u16(), + body, + }), + } + } +} + +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_else(|| Error::BadResponse(format!("missing or non-numeric {field}"))) +} + +#[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("jupiter api error {status}: {body}")] + Api { status: u16, body: String }, + #[error("malformed swap response: {0}")] + BadResponse(String), + #[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))); + } + + /// 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 index 177ff38e4a..98f1a02c77 100644 --- a/crates/solana-solvers/src/lib.rs +++ b/crates/solana-solvers/src/lib.rs @@ -6,6 +6,7 @@ pub mod api; mod cli; pub mod config; +pub mod dex; mod run; pub use run::start;