From 66b0a0591bf56976a1dcc80dcbe12370c522e309 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 20 Jul 2026 08:18:03 +0000 Subject: [PATCH 1/7] feat(solana-solvers): Jupiter v1 swap adapter (sells) --- Cargo.lock | 4 + crates/solana-solvers/Cargo.toml | 4 + .../config/example.jupiter.toml | 4 +- crates/solana-solvers/src/config.rs | 10 +- crates/solana-solvers/src/dex/jupiter/dto.rs | 99 ++++++++ crates/solana-solvers/src/dex/jupiter/mod.rs | 219 ++++++++++++++++++ crates/solana-solvers/src/dex/mod.rs | 53 +++++ crates/solana-solvers/src/lib.rs | 1 + 8 files changed, 383 insertions(+), 11 deletions(-) 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 bd09a5859a..e4a21af6d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10833,10 +10833,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/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..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 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; From 03af04ad6d9318d53262dd755f56813bb3a9117a Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 20 Jul 2026 09:25:34 +0000 Subject: [PATCH 2/7] refactor(solana-solvers): typed request struct, tiered send errors, context on BadResponse --- crates/solana-solvers/src/dex/jupiter/dto.rs | 57 +++++++++++++++++--- crates/solana-solvers/src/dex/jupiter/mod.rs | 52 +++++++++++------- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index 34232808f3..af4e4c5262 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -1,11 +1,11 @@ -//! DTO for Jupiter's `/swap-instructions` response, converted to Solana -//! instructions. Field names follow Jupiter's camelCase JSON. +//! 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, + serde::{Deserialize, Serialize}, solana_sdk::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, @@ -13,6 +13,44 @@ use { std::str::FromStr, }; +/// Body of the `/swap-instructions` request. `Pubkey` serializes to its base58 +/// string in JSON, so no manual conversion. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapInstructionsRequest<'a> { + quote_response: &'a serde_json::Value, + #[serde(serialize_with = "as_base58")] + user_public_key: Pubkey, + #[serde(serialize_with = "as_base58")] + 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, + } + } +} + +/// Serialize a `Pubkey` as its base58 string. Jupiter expects strings, and +/// `Pubkey`'s default serialization is a byte array. +fn as_base58(pubkey: &Pubkey, serializer: S) -> Result { + serializer.serialize_str(&pubkey.to_string()) +} + /// The parts of the `/swap-instructions` response we need to build a [`Swap`]. /// Amounts come from the `/quote` response. #[derive(Deserialize)] @@ -42,7 +80,10 @@ impl SwapInstructionsResponse { let address_lookup_tables = self .address_lookup_table_addresses .iter() - .map(|address| Pubkey::from_str(address).map_err(|_| Error::BadResponse)) + .map(|address| { + Pubkey::from_str(address) + .map_err(|err| Error::BadResponse(format!("lookup table address: {err}"))) + }) .collect::>()?; Ok(Swap { in_amount, @@ -63,7 +104,8 @@ struct JupInstruction { impl JupInstruction { fn into_instruction(self) -> Result { - let program_id = Pubkey::from_str(&self.program_id).map_err(|_| Error::BadResponse)?; + let program_id = Pubkey::from_str(&self.program_id) + .map_err(|err| Error::BadResponse(format!("program id: {err}")))?; let accounts = self .accounts .into_iter() @@ -71,7 +113,7 @@ impl JupInstruction { .collect::>()?; let data = BASE64_STANDARD .decode(&self.data) - .map_err(|_| Error::BadResponse)?; + .map_err(|err| Error::BadResponse(format!("instruction data: {err}")))?; Ok(Instruction { program_id, accounts, @@ -91,7 +133,8 @@ struct JupAccount { impl JupAccount { fn into_meta(self) -> Result { Ok(AccountMeta { - pubkey: Pubkey::from_str(&self.pubkey).map_err(|_| Error::BadResponse)?, + 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 index 5668f6a5a9..06c1a88ee8 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -74,16 +74,8 @@ impl Jupiter { .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 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) @@ -107,15 +99,20 @@ impl Jupiter { 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); + if status.is_success() { + return serde_json::from_str(&body) + .map_err(|err| Error::BadResponse(format!("response body: {err}"))); } - // 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); + 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, + }), } - serde_json::from_str(&body).map_err(|_| Error::BadResponse) } } @@ -124,7 +121,7 @@ fn amount_field(quote: &serde_json::Value, field: &str) -> Result { .get(field) .and_then(serde_json::Value::as_str) .and_then(|amount| amount.parse().ok()) - .ok_or(Error::BadResponse) + .ok_or_else(|| Error::BadResponse(format!("missing or non-numeric {field}"))) } #[derive(Debug, thiserror::Error)] @@ -137,8 +134,10 @@ pub enum Error { OrderNotSupported, #[error("rate limited")] RateLimited, - #[error("malformed response from the swap API")] - BadResponse, + #[error("jupiter api error {status}: {body}")] + Api { status: u16, body: String }, + #[error("malformed swap response: {0}")] + BadResponse(String), #[error(transparent)] Http(#[from] reqwest::Error), } @@ -180,6 +179,19 @@ mod tests { assert!(matches!(result, Err(Error::OrderNotSupported))); } + #[test] + fn request_serializes_pubkeys_as_base58() { + let quote = serde_json::json!({}); + let taker = Pubkey::from_str(WSOL).unwrap(); + let destination = Pubkey::from_str(USDC).unwrap(); + let payload = dto::SwapInstructionsRequest::new("e, &taker, &destination); + let value = serde_json::to_value(payload).unwrap(); + assert_eq!(value["userPublicKey"], WSOL); + assert_eq!(value["destinationTokenAccount"], USDC); + assert_eq!(value["wrapAndUnwrapSol"], false); + assert_eq!(value["skipUserAccountsRpcCalls"], true); + } + #[test] fn parses_swap_instructions() { let json = serde_json::json!({ From 6d5bd37835f1c6dd852c14b5591f614b34ff5329 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 20 Jul 2026 09:27:55 +0000 Subject: [PATCH 3/7] docs(solana-solvers): fix stale pubkey-serialization comment --- crates/solana-solvers/src/dex/jupiter/dto.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index af4e4c5262..57ccbe5b76 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -13,8 +13,7 @@ use { std::str::FromStr, }; -/// Body of the `/swap-instructions` request. `Pubkey` serializes to its base58 -/// string in JSON, so no manual conversion. +/// Body of the `/swap-instructions` request. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SwapInstructionsRequest<'a> { From 0fee69dc6b0344d62bc1ac05cb4f6935a4998800 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 20 Jul 2026 09:43:57 +0000 Subject: [PATCH 4/7] refactor(solana-solvers): serialize request pubkeys via serde_with DisplayFromStr --- Cargo.lock | 1 + crates/solana-solvers/Cargo.toml | 1 + crates/solana-solvers/src/dex/jupiter/dto.rs | 15 ++++++--------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4a21af6d8..1ac4105e4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10839,6 +10839,7 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", + "serde_with", "solana-sdk", "thiserror 1.0.69", "tokio", diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index c97ce83231..fbf4331ece 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -21,6 +21,7 @@ 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"] } diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index 57ccbe5b76..b69c61c0cd 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -6,6 +6,7 @@ use { crate::dex::Swap, base64::prelude::*, serde::{Deserialize, Serialize}, + serde_with::serde_as, solana_sdk::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, @@ -13,14 +14,16 @@ use { std::str::FromStr, }; -/// Body of the `/swap-instructions` request. +/// Body of the `/swap-instructions` request. `Pubkey`'s default serialization +/// is a byte array, so the pubkeys serialize via their base58 `Display`. +#[serde_as] #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SwapInstructionsRequest<'a> { quote_response: &'a serde_json::Value, - #[serde(serialize_with = "as_base58")] + #[serde_as(as = "serde_with::DisplayFromStr")] user_public_key: Pubkey, - #[serde(serialize_with = "as_base58")] + #[serde_as(as = "serde_with::DisplayFromStr")] destination_token_account: Pubkey, wrap_and_unwrap_sol: bool, skip_user_accounts_rpc_calls: bool, @@ -44,12 +47,6 @@ impl<'a> SwapInstructionsRequest<'a> { } } -/// Serialize a `Pubkey` as its base58 string. Jupiter expects strings, and -/// `Pubkey`'s default serialization is a byte array. -fn as_base58(pubkey: &Pubkey, serializer: S) -> Result { - serializer.serialize_str(&pubkey.to_string()) -} - /// The parts of the `/swap-instructions` response we need to build a [`Swap`]. /// Amounts come from the `/quote` response. #[derive(Deserialize)] From 784b8c5254bdec8709b5e1934078472ec4e97daf Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 20 Jul 2026 09:49:46 +0000 Subject: [PATCH 5/7] docs(solana-solvers): drop redundant serde note from request DTO comment --- crates/solana-solvers/src/dex/jupiter/dto.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index b69c61c0cd..db9b971b02 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -14,8 +14,7 @@ use { std::str::FromStr, }; -/// Body of the `/swap-instructions` request. `Pubkey`'s default serialization -/// is a byte array, so the pubkeys serialize via their base58 `Display`. +/// Body of the `/swap-instructions` request. #[serde_as] #[derive(Serialize)] #[serde(rename_all = "camelCase")] From b65ca338885eae9f504650daa89479407e1f6e90 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 20 Jul 2026 10:45:25 +0000 Subject: [PATCH 6/7] test(solana-solvers): drop redundant swap-instructions parse test --- crates/solana-solvers/src/dex/jupiter/mod.rs | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index 06c1a88ee8..f7214745ee 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -192,26 +192,6 @@ mod tests { assert_eq!(value["skipUserAccountsRpcCalls"], true); } - #[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] From e76acf2f7afc042e5e6c878fdb7eaeee975f5c3a Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 20 Jul 2026 11:47:08 +0000 Subject: [PATCH 7/7] test(solana-solvers): drop request-serialization test covered by live_sell --- crates/solana-solvers/src/dex/jupiter/mod.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index f7214745ee..be6210fa2e 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -179,19 +179,6 @@ mod tests { assert!(matches!(result, Err(Error::OrderNotSupported))); } - #[test] - fn request_serializes_pubkeys_as_base58() { - let quote = serde_json::json!({}); - let taker = Pubkey::from_str(WSOL).unwrap(); - let destination = Pubkey::from_str(USDC).unwrap(); - let payload = dto::SwapInstructionsRequest::new("e, &taker, &destination); - let value = serde_json::to_value(payload).unwrap(); - assert_eq!(value["userPublicKey"], WSOL); - assert_eq!(value["destinationTokenAccount"], USDC); - assert_eq!(value["wrapAndUnwrapSol"], false); - assert_eq!(value["skipUserAccountsRpcCalls"], true); - } - /// Live Jupiter API. Needs network. Keyless works, set `JUPITER_API_KEY` /// for headroom. #[tokio::test]